Skip to main content

cove_cli/sidebar/
app.rs

1// ── Sidebar application ──
2
3use std::collections::HashMap;
4use std::io::{self, stdout};
5
6use crossterm::cursor;
7use crossterm::execute;
8use crossterm::terminal::{self, DisableLineWrap, EnableLineWrap};
9use ratatui::Terminal;
10use ratatui::backend::CrosstermBackend;
11
12use crate::sidebar::context::ContextManager;
13use crate::sidebar::event::{self, Action};
14use crate::sidebar::state::{StateDetector, WindowState};
15use crate::sidebar::ui::SidebarWidget;
16use crate::tmux::{self, WindowInfo};
17
18// ── Types ──
19
20struct SidebarApp {
21    windows: Vec<WindowInfo>,
22    states: HashMap<u32, WindowState>,
23    selected: usize,
24    tick: u64,
25    detector: StateDetector,
26    context_mgr: ContextManager,
27}
28
29// ── Constants ──
30
31const REFRESH_EVERY: u64 = 2;
32
33// ── Public API ──
34
35pub fn run() -> Result<(), String> {
36    // No alternate screen — render in-place in tmux pane (matches bash behavior)
37    let mut stdout = stdout();
38    execute!(stdout, cursor::Hide, DisableLineWrap).map_err(|e| format!("terminal: {e}"))?;
39    terminal::enable_raw_mode().map_err(|e| format!("terminal: {e}"))?;
40
41    let result = run_loop();
42
43    // Cleanup
44    terminal::disable_raw_mode().ok();
45    execute!(stdout, cursor::Show, EnableLineWrap).ok();
46
47    result
48}
49
50// ── Helpers ──
51
52fn run_loop() -> Result<(), String> {
53    let backend = CrosstermBackend::new(io::stdout());
54    let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal: {e}"))?;
55
56    let mut app = SidebarApp {
57        windows: Vec::new(),
58        states: HashMap::new(),
59        selected: 0,
60        tick: 0,
61        detector: StateDetector::new(),
62        context_mgr: ContextManager::new(),
63    };
64
65    loop {
66        // Refresh window list periodically
67        if app.tick % REFRESH_EVERY == 0 {
68            refresh_windows(&mut app);
69        }
70
71        // Detect states every tick
72        app.states = app.detector.detect(&app.windows);
73
74        // Context orchestration: prefetch, drain, handle selection changes
75        let detector = &app.detector;
76        app.context_mgr.tick(
77            &app.windows,
78            &app.states,
79            app.selected,
80            &|idx| detector.pane_id(idx).map(str::to_string),
81            &|idx| detector.cwd(idx).map(str::to_string),
82        );
83
84        // Prepare context for rendering
85        let context = app
86            .windows
87            .get(app.selected)
88            .and_then(|win| app.context_mgr.get(&win.name));
89        let context_loading = app
90            .windows
91            .get(app.selected)
92            .is_some_and(|win| app.context_mgr.is_loading(&win.name));
93        let context_error = app
94            .windows
95            .get(app.selected)
96            .and_then(|win| app.context_mgr.get_error(&win.name));
97
98        // Render
99        terminal
100            .draw(|frame| {
101                let area = frame.area();
102                let widget = SidebarWidget {
103                    windows: &app.windows,
104                    states: &app.states,
105                    selected: app.selected,
106                    tick: app.tick,
107                    context,
108                    context_loading,
109                    context_error,
110                };
111                frame.render_widget(widget, area);
112            })
113            .map_err(|e| format!("render: {e}"))?;
114
115        // Handle events
116        let actions = event::poll();
117        let mut moved = false;
118
119        for action in actions {
120            match action {
121                Action::Up => {
122                    if app.selected > 0 {
123                        app.selected -= 1;
124                        moved = true;
125                    }
126                }
127                Action::Down => {
128                    if app.selected + 1 < app.windows.len() {
129                        app.selected += 1;
130                        moved = true;
131                    }
132                }
133                Action::Select => {
134                    if let Some(win) = app.windows.get(app.selected) {
135                        let _ = tmux::select_window(win.index);
136                        refresh_windows(&mut app);
137                        app.tick = 0;
138                        continue;
139                    }
140                }
141                Action::Quit => return Ok(()),
142                Action::Tick => {}
143            }
144        }
145
146        // Single tmux call after all queued keys are processed
147        if moved {
148            if let Some(win) = app.windows.get(app.selected) {
149                let _ = tmux::select_window_sidebar(win.index);
150            }
151            // Skip next refresh so select-window has time to take effect
152            app.tick = 1;
153        } else {
154            app.tick += 1;
155        }
156    }
157}
158
159fn refresh_windows(app: &mut SidebarApp) {
160    if let Ok(windows) = tmux::list_windows() {
161        // Sync selected to the tmux-active window
162        let active_pos = windows.iter().position(|w| w.is_active).unwrap_or(0);
163
164        app.selected = active_pos;
165        app.windows = windows;
166
167        // Clamp
168        if app.selected >= app.windows.len() && !app.windows.is_empty() {
169            app.selected = app.windows.len() - 1;
170        }
171    }
172}