From 2f1978618dadd11c1a2c051180723c12dc67775a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:18:25 +0800 Subject: [PATCH] refactor(code-panel): per-tab panel state, diff-overlay style The panel's open files, tree roots/expansion/selection, and visibility now live on Tab.code (same contract as Tab.diff_overlay): only the active tab's panel renders, switching tabs shows that tab's own panel (or none), and closing the tab drops its state. Hiding via Esc keeps the tab's open files. Shared infrastructure stays app-global: directory-listing and gitignore caches (path-keyed, tab-agnostic), the LSP registry, and single watchers over the union of every tab's roots / open files. External-change reloads and diagnostics now fan out to every buffer of the path across tabs. --- Cargo.lock | 3 - Cargo.toml | 4 +- src/ui/app.rs | 16 +- src/ui/code_editor.rs | 497 +++++++++++++++++++++++++++--------------- src/ui/file_tree.rs | 205 ++++++++++------- src/ui/tab_strip.rs | 2 +- 6 files changed, 460 insertions(+), 267 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f08417ee..da8718df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3153,7 +3153,6 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5" dependencies = [ "aho-corasick", "anyhow", @@ -3236,7 +3235,6 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5" dependencies = [ "anyhow", "gpui", @@ -3250,7 +3248,6 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 69023700..929936bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -223,8 +223,8 @@ edition = "2024" # `PopupMenu::with_size` plus the 1px hairline menu separator. The exact commit # is still pinned by Cargo.lock. For co-developing the UI crate, point these # back at a sibling checkout: `path = "../gpui-component/crates/{ui,assets}"`. -gpui-component = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.2", features = ["tree-sitter-languages"] } -gpui-component-assets = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.1" } +gpui-component = { path = "../../../../gpui-component/crates/ui", version = "0.5.2", features = ["tree-sitter-languages"] } +gpui-component-assets = { path = "../../../../gpui-component/crates/assets", version = "0.5.1" } gpui = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe" } # Base features are cross-platform (`font-kit`, `runtime_shaders` only map to the diff --git a/src/ui/app.rs b/src/ui/app.rs index 365ae184..554bc904 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -101,6 +101,13 @@ pub struct Tab { /// switching back restores it; closing the tab drops it. Only the active /// tab's overlay is rendered. See [`crate::ui::diff_overlay`]. pub(crate) diff_overlay: Option, + /// This tab's code panel (file tree + editor overlay): open files, tree + /// roots/expansion, and visibility. Same per-tab contract as + /// `diff_overlay` — switching away hides it, switching back restores it, + /// closing the tab drops it. Shared caches (directory listings, gitignore + /// matchers, language servers, watchers) live on [`Tty7App`]. `None` until + /// the panel is first opened in this tab. + pub(crate) code: Option>, /// The sidebar group this tab last *definitively* belonged to: the /// repository home of its first pane's cwd — the main checkout's root, so /// linked worktrees of one repo share a group (deliberately not the @@ -123,6 +130,7 @@ impl Tab { name: None, last_focused: None, diff_overlay: None, + code: None, sidebar_group: std::cell::RefCell::new(None), } } @@ -758,6 +766,7 @@ impl Tty7App { name: st.name, last_focused: None, diff_overlay: None, + code: None, // Keep the group it had when closed — the row reappears where // it lived instead of flashing through Scratch. sidebar_group: std::cell::RefCell::new(st.sidebar_group), @@ -2308,9 +2317,9 @@ impl Tty7App { // In sidebar mode, pull the newly active row into view (a no-op when // the strip is horizontal — the handle tracks no painted list then). self.sidebar_scroll.scroll_to_item(index); - if self.editor.open { - // Code panel up: the tree follows the incoming tab's repos, and - // focus stays on the panel (the terminal is covered). + if self.code_panel_visible() { + // The incoming tab has its own panel open: refresh its roots + // (pane cwds may have changed) and keep focus on the panel. self.file_tree_refresh_roots(window, cx); self.file_tree.focus_handle.focus(window, cx); } else { @@ -4490,6 +4499,7 @@ fn tabs_from_session( name: st.name.clone(), last_focused: None, diff_overlay: None, + code: None, // Seed the sticky group from the saved session so the sidebar // renders grouped on the first frame; the first landed probe // corrects it if the tab's repo changed while we were gone. diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 530e89c6..a7a5931d 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -81,21 +81,55 @@ impl OpenFile { } } -/// State for the editor panel, held on [`Tty7App`]. -pub(crate) struct EditorPanelState { - pub(crate) open: bool, +/// Per-tab code-panel state, hung on [`Tab::code`](crate::ui::app::Tab) with +/// the same lifecycle contract as the diff overlay: only the active tab's +/// panel renders, switching away hides it, closing the tab drops it. The +/// shared caches (directory listings, gitignore matchers, language servers, +/// filesystem watchers) live on [`Tty7App`] — this holds only what is truly +/// this tab's: its open files and its tree view state. +pub(crate) struct TabCode { + /// Whether the overlay is currently shown for this tab. The open-file set + /// survives hiding (Esc) — only closing the tab drops it. + pub(crate) visible: bool, pub(crate) files: Vec, pub(crate) active: usize, - /// Watches the parent directories of open files for external changes. - /// Rebuilt whenever the open set changes; `None` while nothing is open. + /// Find-references results, shown as a drawer under the editor. + pub(crate) references: Option>, + /// File-tree roots: this tab's pane cwds resolved to repo roots. + pub(crate) roots: Vec, + pub(crate) expanded: std::collections::HashSet, + pub(crate) selected: Option, +} + +impl TabCode { + pub(crate) fn new() -> Self { + Self { + visible: true, + files: Vec::new(), + active: 0, + references: None, + roots: Vec::new(), + expanded: std::collections::HashSet::new(), + selected: None, + } + } + + pub(crate) fn active_file(&self) -> Option<&OpenFile> { + self.files.get(self.active) + } +} + +/// App-global editor infrastructure shared by every tab's panel. +pub(crate) struct EditorPanelState { + /// Watches the parent directories of open files (across all tabs) for + /// external changes. Rebuilt whenever any open set changes; `None` while + /// nothing is open anywhere. watcher: Option, /// Feeds changed paths from the watcher thread into the UI-side reload /// loop spawned in [`EditorPanelState::new`]. events_tx: smol::channel::Sender, /// Language-server registry (one client per server × workspace root). pub(crate) lsp: crate::ui::lsp::LspRegistry, - /// Find-references results, shown as a drawer under the editor. - pub(crate) references: Option>, } /// One row in the find-references drawer. @@ -132,57 +166,11 @@ impl EditorPanelState { }) .detach(); Self { - open: false, - files: Vec::new(), - active: 0, watcher: None, events_tx: tx, lsp: crate::ui::lsp::LspRegistry::new(window, cx), - references: None, } } - - pub(crate) fn active_file(&self) -> Option<&OpenFile> { - self.files.get(self.active) - } - - /// Rebuild the external-change watcher over the current open set. Watches - /// each file's *parent directory* (non-recursively): editors that save via - /// rename replace the inode, which a direct file watch loses track of. - fn rebuild_watcher(&mut self) { - use notify::{RecursiveMode, Watcher}; - self.watcher = None; - if self.files.is_empty() { - return; - } - let watched: HashSet = self.files.iter().map(|f| f.path.clone()).collect(); - let dirs: HashSet = watched - .iter() - .filter_map(|p| p.parent().map(Path::to_path_buf)) - .collect(); - let tx = self.events_tx.clone(); - let handler = move |res: notify::Result| { - let Ok(event) = res else { return }; - for p in &event.paths { - if watched.contains(p) { - let _ = tx.try_send(p.clone()); - } - } - }; - let mut watcher = match notify::recommended_watcher(handler) { - Ok(w) => w, - Err(e) => { - log::warn!("editor: external-change watcher unavailable: {e}"); - return; - } - }; - for dir in dirs { - if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) { - log::warn!("editor: failed to watch {}: {e}", dir.display()); - } - } - self.watcher = Some(watcher); - } } // --------------------------------------------------------------------------- @@ -260,8 +248,65 @@ fn looks_binary(bytes: &[u8]) -> bool { // --------------------------------------------------------------------------- impl Tty7App { - /// Open `path` in the editor panel (activating an existing tab when the - /// file is already open) and reveal the panel. Errors surface as window + /// The active tab's code-panel state, if the panel was ever opened there. + pub(crate) fn tab_code(&self) -> Option<&TabCode> { + self.tabs.get(self.active)?.code.as_deref() + } + + pub(crate) fn tab_code_mut(&mut self) -> Option<&mut TabCode> { + self.tabs.get_mut(self.active)?.code.as_deref_mut() + } + + /// Whether the active tab's code panel is currently shown. + pub(crate) fn code_panel_visible(&self) -> bool { + self.tab_code().is_some_and(|c| c.visible) + } + + /// Rebuild the external-change watcher over every tab's open files. + /// Watches each file's *parent directory* (non-recursively): editors that + /// save via rename replace the inode, which a direct file watch loses. + fn editor_rebuild_watcher(&mut self) { + use notify::{RecursiveMode, Watcher}; + self.editor.watcher = None; + let watched: HashSet = self + .tabs + .iter() + .filter_map(|t| t.code.as_deref()) + .flat_map(|c| c.files.iter().map(|f| f.path.clone())) + .collect(); + if watched.is_empty() { + return; + } + let dirs: HashSet = watched + .iter() + .filter_map(|p| p.parent().map(Path::to_path_buf)) + .collect(); + let tx = self.editor.events_tx.clone(); + let handler = move |res: notify::Result| { + let Ok(event) = res else { return }; + for p in &event.paths { + if watched.contains(p) { + let _ = tx.try_send(p.clone()); + } + } + }; + let mut watcher = match notify::recommended_watcher(handler) { + Ok(w) => w, + Err(e) => { + log::warn!("editor: external-change watcher unavailable: {e}"); + return; + } + }; + for dir in dirs { + if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) { + log::warn!("editor: failed to watch {}: {e}", dir.display()); + } + } + self.editor.watcher = Some(watcher); + } + + /// Open `path` in the active tab's editor (activating an existing file tab + /// when it is already open) and reveal the panel. Errors surface as window /// notifications rather than a half-open tab. pub(crate) fn open_file_in_editor( &mut self, @@ -269,10 +314,15 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + if self.tabs.get(self.active).is_none() { + return; + } let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - if let Some(ix) = self.editor.files.iter().position(|f| f.path == path) { - self.editor.active = ix; - self.editor.open = true; + if let Some(code) = self.tab_code_mut() + && let Some(ix) = code.files.iter().position(|f| f.path == path) + { + code.active = ix; + code.visible = true; self.focus_editor(window, cx); cx.notify(); return; @@ -354,32 +404,45 @@ impl Tty7App { } // Dirty tracking: `set_value` suppresses events, so every Change here // is a real user edit. Each edit also (re)arms the debounced LSP - // didChange sync. + // didChange sync. Files may be open in any tab, not just the active + // one, so the lookup scans all tabs. let sub = cx.subscribe_in(&input, window, { let path = path.clone(); move |this: &mut Tty7App, _input, ev, window, cx| { if matches!(ev, InputEvent::Change) { let path = path.clone(); - if let Some(f) = this.editor.files.iter_mut().find(|f| f.path == path) { - if !f.dirty { - f.dirty = true; - } - if f.lsp.is_some() { - f.change_task = Some(cx.spawn_in(window, async move |app, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(150)) - .await; - let _ = app.update(cx, |app, cx| { - app.editor_sync_lsp_document(&path, cx); - }); - })); - } - cx.notify(); + let Some(f) = this + .tabs + .iter_mut() + .filter_map(|t| t.code.as_deref_mut()) + .flat_map(|c| c.files.iter_mut()) + .find(|f| f.path == path) + else { + return; + }; + if !f.dirty { + f.dirty = true; } + if f.lsp.is_some() { + f.change_task = Some(cx.spawn_in(window, async move |app, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(150)) + .await; + let _ = app.update(cx, |app, cx| { + app.editor_sync_lsp_document(&path, cx); + }); + })); + } + cx.notify(); } } }); - self.editor.files.push(OpenFile { + let tab = self + .tabs + .get_mut(self.active) + .expect("checked at function entry"); + let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new())); + code.files.push(OpenFile { path, input, dirty: false, @@ -391,27 +454,33 @@ impl Tty7App { change_task: None, _sub: sub, }); - self.editor.active = self.editor.files.len() - 1; - self.editor.open = true; - self.editor.rebuild_watcher(); + code.active = code.files.len() - 1; + code.visible = true; + self.editor_rebuild_watcher(); self.focus_editor(window, cx); cx.notify(); } /// `ToggleCodePanel` (⌘⇧E / the title-bar tree icon / Esc): flip the - /// code overlay. Opening re-roots the file tree from the active tab's - /// panes and focuses the panel; closing hands focus back to the terminal. + /// active tab's code overlay. First open creates the tab's panel state; + /// hiding keeps it (open files survive Esc), and only closing the tab + /// drops it. Opening re-roots the file tree from the tab's panes and + /// focuses the panel; closing hands focus back to the terminal. pub(crate) fn toggle_code_panel(&mut self, window: &mut Window, cx: &mut Context) { - if self.editor.open { - self.editor.open = false; + let Some(tab) = self.tabs.get_mut(self.active) else { + return; + }; + let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new())); + if code.visible { + code.visible = false; self.file_tree.editing = None; self.focus_active(window, cx); cx.notify(); return; } - self.editor.open = true; + code.visible = true; self.file_tree_refresh_roots(window, cx); - if self.editor.active_file().is_some() { + if self.tab_code().is_some_and(|c| c.active_file().is_some()) { self.focus_editor(window, cx); } else { self.file_tree.focus_handle.focus(window, cx); @@ -421,7 +490,7 @@ impl Tty7App { /// Focus the active file's text input (e.g. right after opening a file). fn focus_editor(&self, window: &mut Window, cx: &mut Context) { - if let Some(f) = self.editor.active_file() { + if let Some(f) = self.tab_code().and_then(|c| c.active_file()) { f.input.update(cx, |input, cx| input.focus(window, cx)); } } @@ -429,18 +498,25 @@ impl Tty7App { /// Whether keyboard focus currently sits inside the editor panel. Lets /// shared shortcuts (⌘S, ⌘W) route here before their terminal meaning. pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context) -> bool { - self.editor.open - && self.editor.active_file().is_some_and(|f| { - f.input - .read(cx) - .focus_handle(cx) - .contains_focused(window, cx) - }) + self.code_panel_visible() + && self + .tab_code() + .and_then(|c| c.active_file()) + .is_some_and(|f| { + f.input + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + }) } /// `EditorSave` (⌘S): write the active buffer back to its path. pub(crate) fn editor_save_active(&mut self, window: &mut Window, cx: &mut Context) { - let Some(f) = self.editor.files.get_mut(self.editor.active) else { + let Some(code) = self.tab_code_mut() else { + return; + }; + let active = code.active; + let Some(f) = code.files.get_mut(active) else { return; }; let text = f.input.read(cx).text().to_string(); @@ -472,7 +548,7 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let Some(f) = self.editor.files.get(ix) else { + let Some(f) = self.tab_code().and_then(|c| c.files.get(ix)) else { return; }; if !f.dirty { @@ -492,11 +568,19 @@ impl Tty7App { let _ = app.update_in(cx, |app, window, cx| match choice { 0 => { // Save, then close. Save failure keeps the tab open. - let prev_active = app.editor.active; - app.editor.active = ix; + let prev_active = app.tab_code().map(|c| c.active); + if let Some(code) = app.tab_code_mut() { + code.active = ix; + } app.editor_save_active(window, cx); - app.editor.active = prev_active; - if app.editor.files.get(ix).is_some_and(|f| !f.dirty) { + if let (Some(code), Some(prev)) = (app.tab_code_mut(), prev_active) { + code.active = prev; + } + if app + .tab_code() + .and_then(|c| c.files.get(ix)) + .is_some_and(|f| !f.dirty) + { app.editor_remove_file(ix, cx); } } @@ -517,42 +601,59 @@ impl Tty7App { if !self.editor_has_focus(window, cx) { return false; } - if self.editor.files.is_empty() { - self.editor.open = false; + let Some(code) = self.tab_code_mut() else { + return false; + }; + if code.files.is_empty() { + code.visible = false; cx.notify(); return true; } - self.editor_close_file(self.editor.active, window, cx); + let active = code.active; + self.editor_close_file(active, window, cx); true } fn editor_remove_file(&mut self, ix: usize, cx: &mut Context) { - if ix >= self.editor.files.len() { + let Some(code) = self.tab_code_mut() else { + return; + }; + if ix >= code.files.len() { return; } - let f = self.editor.files.remove(ix); + let f = code.files.remove(ix); if let Some((client, _)) = &f.lsp { client.did_close(&f.path); } - if self.editor.active >= ix && self.editor.active > 0 { - self.editor.active -= 1; + if code.active >= ix && code.active > 0 { + code.active -= 1; } - self.editor.rebuild_watcher(); + self.editor_rebuild_watcher(); cx.notify(); } + /// Every open buffer for `path`, across all tabs (a file can be open in + /// more than one tab's panel; each has its own buffer). + fn editor_files_for_path<'a>(&'a self, path: &'a Path) -> impl Iterator { + self.tabs + .iter() + .filter_map(|t| t.code.as_deref()) + .flat_map(|c| c.files.iter()) + .filter(move |f| f.path == *path) + } + /// Push the buffer's current text to the language server (the debounced /// tail of a typing burst). pub(crate) fn editor_sync_lsp_document(&mut self, path: &Path, cx: &mut Context) { - let Some(f) = self.editor.files.iter().find(|f| f.path == *path) else { - return; - }; - if let Some((client, _)) = &f.lsp { - client.did_change(&f.path, &f.input.read(cx).text().to_string()); + for f in self.editor_files_for_path(path) { + if let Some((client, _)) = &f.lsp { + client.did_change(&f.path, &f.input.read(cx).text().to_string()); + break; // one didChange per path — the server sees one document + } } } - /// Apply `publishDiagnostics` for `path` to its open buffer, if any. + /// Apply `publishDiagnostics` for `path` to its open buffers (any tab). pub(crate) fn editor_apply_diagnostics( &mut self, path: &Path, @@ -560,24 +661,27 @@ impl Tty7App { _window: &mut Window, cx: &mut Context, ) { - let Some(f) = self.editor.files.iter().find(|f| f.path == *path) else { - return; - }; - f.input.clone().update(cx, |st, cx| { - let text = st.text().clone(); - if let Some(set) = st.diagnostics_mut() { - set.reset(&text); - set.extend(diags); - cx.notify(); - } - }); + let inputs: Vec> = self + .editor_files_for_path(path) + .map(|f| f.input.clone()) + .collect(); + for input in inputs { + input.update(cx, |st, cx| { + let text = st.text().clone(); + if let Some(set) = st.diagnostics_mut() { + set.reset(&text); + set.extend(diags.iter().cloned()); + cx.notify(); + } + }); + } } /// `EditorGotoDefinition` (F12): resolve the definition at the cursor and /// jump — opening the target file first when it lives elsewhere (the /// in-buffer ⌘-click path can't cross files; this one can). pub(crate) fn editor_goto_definition(&mut self, window: &mut Window, cx: &mut Context) { - let Some(f) = self.editor.active_file() else { + let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { return; }; let Some((client, _)) = &f.lsp else { return }; @@ -606,7 +710,7 @@ impl Tty7App { /// `EditorFindReferences` (⇧F12): list every reference to the symbol at /// the cursor in a drawer under the editor. pub(crate) fn editor_find_references(&mut self, window: &mut Window, cx: &mut Context) { - let Some(f) = self.editor.active_file() else { + let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { return; }; let Some((client, _)) = &f.lsp else { return }; @@ -648,7 +752,9 @@ impl Tty7App { }); } let _ = app.update(cx, |app, cx| { - app.editor.references = Some(items); + if let Some(code) = app.tab_code_mut() { + code.references = Some(items); + } cx.notify(); }); }) @@ -664,7 +770,7 @@ impl Tty7App { cx: &mut Context, ) { self.open_file_in_editor(path, window, cx); - if let Some(f) = self.editor.active_file() + if let Some(f) = self.tab_code().and_then(|c| c.active_file()) && f.path == *path { f.input.clone().update(cx, |st, cx| { @@ -674,42 +780,61 @@ impl Tty7App { } /// A watched file changed on disk. Clean buffers reload silently; dirty - /// ones raise the conflict banner and let the user pick a side. + /// ones raise the conflict banner and let the user pick a side. The file + /// may be open in several tabs — each buffer is handled on its own. pub(crate) fn editor_handle_external_change( &mut self, path: &Path, window: &mut Window, cx: &mut Context, ) { - let Some(ix) = self.editor.files.iter().position(|f| f.path == *path) else { - return; - }; let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok(); - { - let f = &self.editor.files[ix]; - // Our own save's echo: mtime matches what we just wrote. - if mtime.is_some() && mtime == f.disk_mtime { - return; + let mut reload: Vec<(usize, usize)> = Vec::new(); + let mut changed = false; + for (tab_ix, tab) in self.tabs.iter_mut().enumerate() { + let Some(code) = tab.code.as_deref_mut() else { + continue; + }; + for (ix, f) in code.files.iter_mut().enumerate() { + if f.path != *path { + continue; + } + // Our own save's echo: mtime matches what we just wrote. + if mtime.is_some() && mtime == f.disk_mtime { + continue; + } + if f.dirty { + f.conflict = true; + changed = true; + } else { + reload.push((tab_ix, ix)); + } } } - if self.editor.files[ix].dirty { - self.editor.files[ix].conflict = true; + for (tab_ix, ix) in reload { + self.editor_reload_from_disk(tab_ix, ix, window, cx); + } + if changed { cx.notify(); - return; } - self.editor_reload_from_disk(ix, window, cx); } - /// Replace the buffer with the on-disk content (used by the silent reload + /// Replace one buffer with the on-disk content (used by the silent reload /// and the conflict banner's "Reload" choice). A vanished file just keeps /// the buffer and marks it dirty — saving will recreate it. pub(crate) fn editor_reload_from_disk( &mut self, + tab_ix: usize, ix: usize, window: &mut Window, cx: &mut Context, ) { - let Some(f) = self.editor.files.get_mut(ix) else { + let Some(f) = self + .tabs + .get_mut(tab_ix) + .and_then(|t| t.code.as_deref_mut()) + .and_then(|c| c.files.get_mut(ix)) + else { return; }; let Ok(text) = std::fs::read_to_string(&f.path) else { @@ -743,10 +868,10 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) -> Option { - if !self.editor.open { + if !self.code_panel_visible() { return None; } - let body = match self.editor.active_file() { + let body = match self.tab_code().and_then(|c| c.active_file()) { None => self.render_editor_empty(cx).into_any_element(), // Markdown preview replaces the buffer with a rendered view. Some(f) if f.preview => { @@ -773,8 +898,8 @@ impl Tty7App { } }; let conflict_banner = self - .editor - .active_file() + .tab_code() + .and_then(|c| c.active_file()) .filter(|f| f.conflict) .map(|_| self.render_editor_conflict_banner(cx)); let references = self.render_editor_references(cx); @@ -832,8 +957,9 @@ impl Tty7App { /// The file tab strip along the panel top. fn render_editor_tabs(&self, _window: &Window, cx: &mut Context) -> gpui::Div { - let active = self.editor.active; - let tabs = self.editor.files.iter().enumerate().map(|(ix, f)| { + let active = self.tab_code().map(|c| c.active).unwrap_or(0); + let files: &[OpenFile] = self.tab_code().map(|c| c.files.as_slice()).unwrap_or(&[]); + let tabs = files.iter().enumerate().map(|(ix, f)| { let is_active = ix == active; let title = f.label(); h_flex() @@ -854,7 +980,9 @@ impl Tty7App { .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, window, cx| { - this.editor.active = ix; + if let Some(code) = this.tab_code_mut() { + code.active = ix; + } this.focus_editor(window, cx); cx.notify(); }), @@ -887,8 +1015,8 @@ impl Tty7App { .child(div().flex_1()) // Markdown files get a preview toggle. .when_some( - self.editor - .active_file() + self.tab_code() + .and_then(|c| c.active_file()) .filter(|f| language_for_path(&f.path) == "markdown"), |this, f| { let preview = f.preview; @@ -898,35 +1026,43 @@ impl Tty7App { .ghost() .xsmall() .on_click(cx.listener(|this, _, _w, cx| { - let ix = this.editor.active; - if let Some(f) = this.editor.files.get_mut(ix) { - f.preview = !f.preview; - cx.notify(); + if let Some(code) = this.tab_code_mut() { + let ix = code.active; + if let Some(f) = code.files.get_mut(ix) { + f.preview = !f.preview; + cx.notify(); + } } })), ) }, ) // Soft-wrap toggle for the active buffer. - .when(self.editor.active_file().is_some(), |this| { - this.child( - Button::new("editor-wrap-toggle") - .label("Wrap") - .ghost() - .xsmall() - .tooltip("Toggle soft wrap") - .on_click(cx.listener(|this, _, window, cx| { - let ix = this.editor.active; - if let Some(f) = this.editor.files.get_mut(ix) { - f.wrap = !f.wrap; - let wrap = f.wrap; - f.input.clone().update(cx, |st, cx| { - st.set_soft_wrap(wrap, window, cx); - }); - } - })), - ) - }) + .when( + self.tab_code().is_some_and(|c| c.active_file().is_some()), + |this| { + this.child( + Button::new("editor-wrap-toggle") + .label("Wrap") + .ghost() + .xsmall() + .tooltip("Toggle soft wrap") + .on_click(cx.listener(|this, _, window, cx| { + let Some(code) = this.tab_code_mut() else { + return; + }; + let ix = code.active; + if let Some(f) = code.files.get_mut(ix) { + f.wrap = !f.wrap; + let wrap = f.wrap; + f.input.clone().update(cx, |st, cx| { + st.set_soft_wrap(wrap, window, cx); + }); + } + })), + ) + }, + ) .child( Button::new("editor-panel-close") .icon(IconName::Close) @@ -941,7 +1077,7 @@ impl Tty7App { /// The find-references drawer (⇧F12 results) under the editor body. fn render_editor_references(&self, cx: &mut Context) -> Option { - let refs = self.editor.references.as_ref()?; + let refs = self.tab_code()?.references.as_ref()?; let muted = cx.theme().muted_foreground; let rows = refs.iter().enumerate().map(|(ix, r)| { let name = r @@ -1003,7 +1139,9 @@ impl Tty7App { .ghost() .xsmall() .on_click(cx.listener(|this, _, _w, cx| { - this.editor.references = None; + if let Some(code) = this.tab_code_mut() { + code.references = None; + } cx.notify(); })), ), @@ -1022,7 +1160,8 @@ impl Tty7App { /// Banner shown when the file changed on disk while the buffer is dirty. fn render_editor_conflict_banner(&self, cx: &mut Context) -> AnyElement { - let ix = self.editor.active; + let tab_ix = self.active; + let ix = self.tab_code().map(|c| c.active).unwrap_or(0); h_flex() .flex_none() .w_full() @@ -1040,7 +1179,7 @@ impl Tty7App { .label("Reload") .small() .on_click(cx.listener(move |this, _, window, cx| { - this.editor_reload_from_disk(ix, window, cx); + this.editor_reload_from_disk(tab_ix, ix, window, cx); })), ) .child( @@ -1049,7 +1188,7 @@ impl Tty7App { .ghost() .small() .on_click(cx.listener(move |this, _, _w, cx| { - if let Some(f) = this.editor.files.get_mut(ix) { + if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) { f.conflict = false; cx.notify(); } diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 53da08a5..9451408d 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -97,22 +97,24 @@ impl TreeEdit { } } -/// State for the file-tree panel, held on [`Tty7App`]. +/// App-global file-tree infrastructure, held on [`Tty7App`]. The per-tab view +/// state (roots, expansion, selection) lives in +/// [`TabCode`](crate::ui::code_editor::TabCode); everything here is path-keyed +/// cache or chrome shared by every tab's panel — one panel shows at a time. pub(crate) struct FileTreeState { - pub(crate) roots: Vec, - expanded: HashSet, /// Lazily-loaded listing per directory; invalidated by watcher events. children: HashMap>, /// Compiled `.gitignore` per directory (`None` = the dir has none). /// Invalidated when a `.gitignore` changes. gitignore: HashMap>>, - pub(crate) selected: Option, pub(crate) show_hidden: bool, pub(crate) width: Rc>, dragging: Rc>, pub(crate) editing: Option, editing_subs: Vec, - /// Recursive watcher per root; rebuilt when the root set changes. + /// One recursive watcher over the union of every tab's roots; rebuilt + /// when any root set changes. Events invalidate the path-keyed caches + /// above, which are tab-agnostic. watcher: Option, events_tx: smol::channel::Sender, pub(crate) focus_handle: FocusHandle, @@ -138,11 +140,8 @@ impl FileTreeState { }) .detach(); Self { - roots: Vec::new(), - expanded: HashSet::new(), children: HashMap::new(), gitignore: HashMap::new(), - selected: None, show_hidden: false, width: Rc::new(Cell::new(DEFAULT_WIDTH)), dragging: Rc::new(Cell::new(false)), @@ -154,11 +153,11 @@ impl FileTreeState { } } - /// (Re)attach the recursive watcher to the current roots. - fn rebuild_watcher(&mut self) { + /// (Re)attach the recursive watcher to `roots` (the union across tabs). + fn rebuild_watcher(&mut self, roots: &HashSet) { use notify::{RecursiveMode, Watcher}; self.watcher = None; - if self.roots.is_empty() { + if roots.is_empty() { return; } let tx = self.events_tx.clone(); @@ -175,7 +174,7 @@ impl FileTreeState { return; } }; - for root in &self.roots { + for root in roots { if let Err(e) = watcher.watch(root, RecursiveMode::Recursive) { log::warn!("file tree: failed to watch {}: {e}", root.display()); } @@ -185,15 +184,15 @@ impl FileTreeState { /// Load any expanded directory whose listing isn't cached yet. Called once /// per render pass so `visible_rows` can stay `&self`. - fn ensure_loaded(&mut self) { + fn ensure_loaded(&mut self, roots: &[PathBuf], expanded: &HashSet) { // Roots always list; expanded dirs list on demand. Collect first: the // borrow checker won't let us mutate `children` while iterating it. let mut todo: Vec<(PathBuf, PathBuf)> = Vec::new(); // (dir, its root) - for root in &self.roots { + for root in roots { if !self.children.contains_key(root) { todo.push((root.clone(), root.clone())); } - for dir in &self.expanded { + for dir in expanded { if dir.starts_with(root) && !self.children.contains_key(dir) { todo.push((dir.clone(), root.clone())); } @@ -265,10 +264,11 @@ impl FileTreeState { state } - /// Flatten roots + expanded directories into display order. - pub(crate) fn visible_rows(&self) -> Vec { + /// Flatten `roots` + `expanded` directories into display order (both come + /// from the active tab's panel state). + pub(crate) fn visible_rows(&self, roots: &[PathBuf], expanded: &HashSet) -> Vec { let mut rows = Vec::new(); - for root in &self.roots { + for root in roots { let name = root .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -284,12 +284,18 @@ impl FileTreeState { is_root: true, expanded: true, }); - self.flatten_dir(root, 1, &mut rows); + self.flatten_dir(root, 1, expanded, &mut rows); } rows } - fn flatten_dir(&self, dir: &Path, depth: usize, out: &mut Vec) { + fn flatten_dir( + &self, + dir: &Path, + depth: usize, + expanded: &HashSet, + out: &mut Vec, + ) { let Some(entries) = self.children.get(dir) else { return; }; @@ -297,15 +303,15 @@ impl FileTreeState { if !self.show_hidden && e.name.starts_with('.') { continue; } - let expanded = e.is_dir && self.expanded.contains(&e.path); + let is_expanded = e.is_dir && expanded.contains(&e.path); out.push(TreeRow { entry: e.clone(), depth, is_root: false, - expanded, + expanded: is_expanded, }); - if expanded { - self.flatten_dir(&e.path, depth + 1, out); + if is_expanded { + self.flatten_dir(&e.path, depth + 1, expanded, out); } } } @@ -376,15 +382,24 @@ impl Tty7App { roots.push(PathBuf::from(home)); } let _ = window; - if roots != self.file_tree.roots { - self.file_tree.roots = roots; - self.file_tree.children.clear(); - self.file_tree.gitignore.clear(); - self.file_tree.rebuild_watcher(); - } else { - // Same roots: refresh listings but keep expansion state. - self.file_tree.children.clear(); + let Some(code) = self.tab_code_mut() else { + return; + }; + if roots != code.roots { + code.roots = roots; } + // Refresh listings but keep expansion state; the caches are shared + // (path-keyed), so a stale entry only costs a relist. + self.file_tree.children.clear(); + self.file_tree.gitignore.clear(); + // One watcher over every tab's roots. + let union: HashSet = self + .tabs + .iter() + .filter_map(|t| t.code.as_deref()) + .flat_map(|c| c.roots.iter().cloned()) + .collect(); + self.file_tree.rebuild_watcher(&union); cx.notify(); } @@ -396,11 +411,8 @@ impl Tty7App { paths: &HashSet, cx: &mut Context, ) { - // The tree only renders inside the code overlay; skip churn while it - // is closed (the caches rebuild lazily on the next open anyway). - if !self.editor.open { - return; - } + // The caches are shared across tabs, so invalidate unconditionally — + // a hidden tab's stale listing would otherwise survive until reopened. let gitignore_touched = paths .iter() .any(|p| p.file_name().is_some_and(|n| n == ".gitignore")); @@ -421,8 +433,11 @@ impl Tty7App { } fn file_tree_toggle_expand(&mut self, dir: &Path, cx: &mut Context) { - if !self.file_tree.expanded.remove(dir) { - self.file_tree.expanded.insert(dir.to_path_buf()); + let Some(code) = self.tab_code_mut() else { + return; + }; + if !code.expanded.remove(dir) { + code.expanded.insert(dir.to_path_buf()); } cx.notify(); } @@ -436,7 +451,9 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - self.file_tree.selected = Some(row_path.to_path_buf()); + if let Some(code) = self.tab_code_mut() { + code.selected = Some(row_path.to_path_buf()); + } if is_dir { self.file_tree_toggle_expand(row_path, cx); } else { @@ -452,12 +469,16 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let rows = self.file_tree.visible_rows(); + let Some(code) = self.tab_code() else { + return; + }; + let rows = self + .file_tree + .visible_rows(&code.roots, &code.expanded); if rows.is_empty() { return; } - let sel_ix = self - .file_tree + let sel_ix = code .selected .as_ref() .and_then(|s| rows.iter().position(|r| r.entry.path == *s)); @@ -469,18 +490,32 @@ impl Tty7App { (Some(i), "up") => i.saturating_sub(1), (Some(i), _) => (i + 1).min(rows.len() - 1), }; - self.file_tree.selected = Some(rows[next].entry.path.clone()); + let path = rows[next].entry.path.clone(); + if let Some(code) = self.tab_code_mut() { + code.selected = Some(path); + } cx.notify(); } "left" => { let Some(i) = sel_ix else { return }; let row = &rows[i]; - if row.entry.is_dir && row.expanded && !row.is_root { - self.file_tree.expanded.remove(&row.entry.path); - } else if let Some(parent) = row.entry.path.parent() { - // Jump to the parent row (stay put at a root). - if rows.iter().any(|r| r.entry.path == parent) { - self.file_tree.selected = Some(parent.to_path_buf()); + let (path, is_dir, expanded, is_root) = ( + row.entry.path.clone(), + row.entry.is_dir, + row.expanded, + row.is_root, + ); + let parent_in_rows = path + .parent() + .is_some_and(|p| rows.iter().any(|r| r.entry.path == p)); + if let Some(code) = self.tab_code_mut() { + if is_dir && expanded && !is_root { + code.expanded.remove(&path); + } else if parent_in_rows + && let Some(parent) = path.parent() + { + // Jump to the parent row (stay put at a root). + code.selected = Some(parent.to_path_buf()); } } cx.notify(); @@ -489,7 +524,10 @@ impl Tty7App { let Some(i) = sel_ix else { return }; let row = &rows[i]; if row.entry.is_dir && !row.expanded && !row.is_root { - self.file_tree.expanded.insert(row.entry.path.clone()); + let path = row.entry.path.clone(); + if let Some(code) = self.tab_code_mut() { + code.expanded.insert(path); + } cx.notify(); } } @@ -538,26 +576,27 @@ impl Tty7App { }, ); self.file_tree.editing_subs = vec![sub]; + // New entries land in the target dir (or the file's parent), which + // must be expanded for the inline input row to show. + let host_dir = if target.is_dir() { + target.to_path_buf() + } else { + target.parent().unwrap_or(target).to_path_buf() + }; + if !matches!(edit_for, TreeEditKind::Rename) + && let Some(code) = self.tab_code_mut() + { + code.expanded.insert(host_dir.clone()); + } self.file_tree.editing = Some(match edit_for { - TreeEditKind::NewFile => { - // New entries land in the target dir (or the file's parent). - let dir = if target.is_dir() { - target.to_path_buf() - } else { - target.parent().unwrap_or(target).to_path_buf() - }; - self.file_tree.expanded.insert(dir.clone()); - TreeEdit::NewFile { dir, input } - } - TreeEditKind::NewFolder => { - let dir = if target.is_dir() { - target.to_path_buf() - } else { - target.parent().unwrap_or(target).to_path_buf() - }; - self.file_tree.expanded.insert(dir.clone()); - TreeEdit::NewFolder { dir, input } - } + TreeEditKind::NewFile => TreeEdit::NewFile { + dir: host_dir, + input, + }, + TreeEditKind::NewFolder => TreeEdit::NewFolder { + dir: host_dir, + input, + }, TreeEditKind::Rename => TreeEdit::Rename { path: target.to_path_buf(), input, @@ -606,7 +645,9 @@ impl Tty7App { match result { Ok(new_path) => { self.file_tree.invalidate_dir(edit.host_dir()); - self.file_tree.selected = Some(new_path.clone()); + if let Some(code) = self.tab_code_mut() { + code.selected = Some(new_path.clone()); + } // A freshly created file opens straight into the editor. if matches!(edit, TreeEdit::NewFile { .. }) { self.open_file_in_editor(&new_path, window, cx); @@ -652,8 +693,10 @@ impl Tty7App { if let Some(parent) = path.parent() { app.file_tree.invalidate_dir(parent); } - if app.file_tree.selected.as_deref() == Some(&path) { - app.file_tree.selected = None; + if let Some(code) = app.tab_code_mut() + && code.selected.as_deref() == Some(&path) + { + code.selected = None; } cx.notify(); } @@ -694,9 +737,9 @@ impl Tty7App { // Prefer a repo-relative path (what agents resolve best) when the file // sits under one of the tree's roots. let rel = self - .file_tree - .roots - .iter() + .tab_code() + .into_iter() + .flat_map(|c| c.roots.iter()) .find_map(|r| path.strip_prefix(r).ok()) .map(|p| p.to_path_buf()) .unwrap_or_else(|| path.to_path_buf()); @@ -726,9 +769,13 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) -> AnyElement { - self.file_tree.ensure_loaded(); + let (roots, expanded) = match self.tab_code() { + Some(code) => (code.roots.clone(), code.expanded.clone()), + None => (Vec::new(), std::collections::HashSet::new()), + }; + self.file_tree.ensure_loaded(&roots, &expanded); let width = self.file_tree.width.get().clamp(MIN_WIDTH, MAX_WIDTH); - let rows = self.file_tree.visible_rows(); + let rows = self.file_tree.visible_rows(&roots, &expanded); let list = v_flex() .id("file-tree-rows") @@ -811,7 +858,7 @@ impl Tty7App { ) -> Vec { let path = row.entry.path.clone(); let is_dir = row.entry.is_dir; - let selected = self.file_tree.selected.as_deref() == Some(&*path); + let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path); let muted = cx.theme().muted_foreground; // Inline rename replaces the row's label with an input. diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index e4f6ac5a..05eef581 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -817,7 +817,7 @@ impl Tty7App { // affordance, so heavy split layouts don't grow a forest of icons; lit // (selected) while the overlay is up. Present in both tab-bar modes — // the sidebar layout keeps this strip as the right column's chrome. - let code_open = self.editor.open; + let code_open = self.code_panel_visible(); let code_button = div().occlude().flex_shrink_0().child( Button::new("titlebar-code-panel") .icon(Icon::new(IconName::FolderClosed).size(px(15.)))