Skip to main content

cove_cli/sidebar/
app.rs

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