Skip to main content

ccs/tui/
tree_mode.rs

1use crate::tree::SessionTree;
2use crate::tui::App;
3use std::sync::mpsc;
4use std::thread;
5
6impl App {
7    /// Enter tree mode from search results (press 'b' on a session group)
8    pub fn enter_tree_mode(&mut self) {
9        let file_path = match self.selected_group() {
10            Some(group) => group.file_path.clone(),
11            None => return,
12        };
13        self.enter_tree_mode_for_file(&file_path);
14    }
15
16    /// Enter tree mode directly for a file path or session ID
17    pub fn enter_tree_mode_direct(&mut self, target: &str) {
18        // If target looks like a file path, use directly
19        let file_path = if target.contains('/') || target.ends_with(".jsonl") {
20            target.to_string()
21        } else {
22            // Search for session ID in known paths
23            match self.find_session_file(target) {
24                Some(path) => path,
25                None => {
26                    self.error = Some(format!("Session not found: {}", target));
27                    return;
28                }
29            }
30        };
31        self.tree_mode_standalone = true;
32        self.enter_tree_mode_for_file(&file_path);
33    }
34
35    pub(crate) fn enter_tree_mode_for_file(&mut self, file_path: &str) {
36        self.tree_mode = true;
37        self.tree_loading = true;
38        self.tree_cursor = 0;
39        self.tree_scroll_offset = 0;
40        self.session_tree = None;
41        self.preview_mode = false;
42        self.needs_full_redraw = true;
43
44        let fp = file_path.to_string();
45        let (tx, rx) = mpsc::channel();
46        self.tree_load_rx = Some(rx);
47
48        thread::spawn(move || {
49            let result = SessionTree::from_file(&fp);
50            let _ = tx.send(result);
51        });
52    }
53
54    /// Search for a JSONL file by session ID across search paths.
55    /// Checks both CLI format (projects/<encoded>/<id>.jsonl) and
56    /// Desktop format (deep hierarchy with audit.jsonl containing session_id).
57    fn find_session_file(&self, session_id: &str) -> Option<String> {
58        use std::fs;
59        let target_filename = format!("{}.jsonl", session_id);
60
61        for search_path in &self.search_paths {
62            if let Ok(entries) = fs::read_dir(search_path) {
63                for entry in entries.flatten() {
64                    let path = entry.path();
65                    if path.is_dir() {
66                        // CLI: ~/.claude/projects/<encoded-path>/<session-id>.jsonl
67                        let candidate = path.join(&target_filename);
68                        if candidate.exists() {
69                            return Some(candidate.to_string_lossy().to_string());
70                        }
71                        // Desktop: deeper hierarchy, recurse one more level
72                        if let Ok(subentries) = fs::read_dir(&path) {
73                            for subentry in subentries.flatten() {
74                                let subpath = subentry.path();
75                                if subpath.is_dir() {
76                                    let candidate = subpath.join(&target_filename);
77                                    if candidate.exists() {
78                                        return Some(candidate.to_string_lossy().to_string());
79                                    }
80                                    // Desktop local_<id>/audit.jsonl — check by reading first line
81                                    let audit = subpath.join("audit.jsonl");
82                                    if audit.exists()
83                                        && Self::file_contains_session_id(&audit, session_id)
84                                    {
85                                        return Some(audit.to_string_lossy().to_string());
86                                    }
87                                }
88                            }
89                        }
90                    }
91                }
92            }
93        }
94        None
95    }
96
97    /// Quick check if a JSONL file contains the given session ID (reads first 5 lines).
98    fn file_contains_session_id(path: &std::path::Path, session_id: &str) -> bool {
99        use std::io::{BufRead, BufReader};
100        let Ok(file) = std::fs::File::open(path) else {
101            return false;
102        };
103        let reader = BufReader::new(file);
104        for line in reader.lines().take(5).flatten() {
105            if line.contains(session_id) {
106                return true;
107            }
108        }
109        false
110    }
111
112    pub fn exit_tree_mode(&mut self) {
113        if self.tree_mode_standalone {
114            self.should_quit = true;
115            return;
116        }
117        self.tree_mode = false;
118        self.session_tree = None;
119        self.tree_loading = false;
120        self.tree_load_rx = None;
121        self.preview_mode = false;
122        self.needs_full_redraw = true;
123    }
124
125    pub fn on_up_tree(&mut self) {
126        if self.tree_cursor > 0 {
127            self.tree_cursor -= 1;
128            self.adjust_tree_scroll();
129            if self.preview_mode {
130                self.needs_full_redraw = true;
131            }
132        }
133    }
134
135    pub fn on_down_tree(&mut self) {
136        if let Some(ref tree) = self.session_tree {
137            if self.tree_cursor < tree.rows.len().saturating_sub(1) {
138                self.tree_cursor += 1;
139                self.adjust_tree_scroll();
140                if self.preview_mode {
141                    self.needs_full_redraw = true;
142                }
143            }
144        }
145    }
146
147    pub fn on_left_tree(&mut self) {
148        // Jump to previous branch point
149        if let Some(ref tree) = self.session_tree {
150            for i in (0..self.tree_cursor).rev() {
151                if tree.rows[i].is_branch_point {
152                    self.tree_cursor = i;
153                    self.adjust_tree_scroll();
154                    if self.preview_mode {
155                        self.needs_full_redraw = true;
156                    }
157                    return;
158                }
159            }
160        }
161    }
162
163    pub fn on_right_tree(&mut self) {
164        // Jump to next branch point
165        if let Some(ref tree) = self.session_tree {
166            for i in (self.tree_cursor + 1)..tree.rows.len() {
167                if tree.rows[i].is_branch_point {
168                    self.tree_cursor = i;
169                    self.adjust_tree_scroll();
170                    if self.preview_mode {
171                        self.needs_full_redraw = true;
172                    }
173                    return;
174                }
175            }
176        }
177    }
178
179    pub fn on_enter_tree(&mut self) {
180        if self.preview_mode {
181            self.preview_mode = false;
182            self.needs_full_redraw = true;
183            return;
184        }
185
186        if let Some(ref tree) = self.session_tree {
187            if let Some(row) = tree.rows.get(self.tree_cursor) {
188                self.resume_uuid = Some(row.uuid.clone());
189                self.resume_id = Some(tree.session_id.clone());
190                self.resume_file_path = Some(tree.file_path.clone());
191                self.resume_source = Some(tree.source);
192                self.should_quit = true;
193            }
194        }
195    }
196
197    pub fn on_tab_tree(&mut self) {
198        if let Some(ref tree) = self.session_tree {
199            if !tree.rows.is_empty() {
200                self.preview_mode = !self.preview_mode;
201                self.needs_full_redraw = true;
202            }
203        }
204    }
205
206    fn adjust_tree_scroll(&mut self) {
207        let visible = 20; // approximate visible height
208        if self.tree_cursor < self.tree_scroll_offset {
209            self.tree_scroll_offset = self.tree_cursor;
210        } else if self.tree_cursor >= self.tree_scroll_offset + visible {
211            self.tree_scroll_offset = self.tree_cursor.saturating_sub(visible) + 1;
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use crate::tui::App;
219
220    #[test]
221    fn test_exit_tree_mode_returns_to_search() {
222        let mut app = App::new(vec!["/test".to_string()]);
223        app.tree_mode = true;
224        app.tree_mode_standalone = false;
225
226        app.exit_tree_mode();
227
228        assert!(!app.tree_mode);
229        assert!(!app.should_quit);
230    }
231
232    #[test]
233    fn test_exit_tree_mode_standalone_quits() {
234        let mut app = App::new(vec!["/test".to_string()]);
235        app.tree_mode = true;
236        app.tree_mode_standalone = true;
237
238        app.exit_tree_mode();
239
240        assert!(app.should_quit);
241    }
242}