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.)))