From a2e1cb806963f7bca23131a5c2a8077be936bd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 16:18:31 -0300 Subject: [PATCH 01/13] refactor(files): separate explorer rendering from editor layout --- crates/ui/src/files/mod.rs | 270 +++++++++++++++++++++++-------------- 1 file changed, 169 insertions(+), 101 deletions(-) diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 6f9b49206..1fef1e2c2 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -156,6 +156,7 @@ pub enum FilesCloseDisposition { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FilesPresentation { Browser, + Explorer, Editor, } @@ -205,107 +206,8 @@ pub struct FilesSurface { impl Render for FilesSurface { fn render(&mut self, window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { let theme = crate::theme::Theme::of(cx).clone(); - let phase = self.tree.node("").map(|root| root.load.clone()); - let content = if !self.search_state.query.is_empty() { - self.render_search_results(cx) - } else if let Some(error) = self.error.clone().filter(|_| !self.tree_has_content()) { - div() - .flex_1() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap(px(10.0)) - .px(px(28.0)) - .child( - div() - .text_center() - .text_size(px(12.0)) - .text_color(theme.text_muted) - .child(error), - ) - .child( - div() - .id("files-retry-root") - .h(px(28.0)) - .px(px(12.0)) - .rounded(px(7.0)) - .border_1() - .border_color(theme.border) - .bg(crate::theme::wash(0.04)) - .hover(|style| style.bg(crate::theme::wash(0.09))) - .cursor_pointer() - .flex() - .items_center() - .text_size(px(11.5)) - .text_color(theme.text) - .child("Retry") - .on_click(cx.listener(|this, _, _, cx| this.retry_root(cx))), - ) - .into_any_element() - } else if !self.tree_has_content() - && matches!( - phase.as_ref(), - Some(DirectoryLoadState::Unloaded | DirectoryLoadState::Loading { .. }) - ) - { - div().flex_1().into_any_element() - } else { - self.render_tree(cx) - }; let split_editor = self.presentation.is_editor() && self.preview.has_active(); - let watch_error = self.watch_error.clone(); - let tree_pane = div() - .size_full() - .min_w_0() - .flex() - .flex_col() - .when(!split_editor, |pane| { - pane.child(self.render_header(&theme, cx)) - }) - .when_some(watch_error, |element, error| { - element.child( - div() - .h(px(27.0)) - .flex_none() - .px(px(10.0)) - .border_b_1() - .border_color(theme.warning.opacity(0.22)) - .bg(theme.warning.opacity(0.045)) - .flex() - .items_center() - .gap(px(6.0)) - .text_size(px(10.0)) - .text_color(theme.warning_muted) - .child( - crate::icons::icon(crate::icons::REFRESH) - .size(px(10.5)) - .flex_none(), - ) - .child(div().min_w_0().flex_1().truncate().child(error)) - .child( - div() - .id("files-watch-refresh-now") - .h(px(20.0)) - .flex_none() - .px(px(6.0)) - .rounded(px(5.0)) - .flex() - .items_center() - .cursor_pointer() - .role(gpui::Role::Button) - .aria_label("Refresh workspace files now") - .text_color(theme.text_muted) - .hover(|style| style.bg(crate::theme::wash(0.07))) - .child("Refresh now") - .on_click(cx.listener(|this, _, _, cx| { - this.refresh(cx); - this.reconcile_open_documents(cx); - })), - ), - ) - }) - .child(content); + let tree_pane = self.render_explorer(&theme, !split_editor, cx); let is_editor = self.presentation.is_editor(); let mut header = None; let body = if split_editor { @@ -428,6 +330,135 @@ impl Render for FilesSurface { } impl FilesSurface { + fn render_explorer( + &mut self, + theme: &crate::theme::Theme, + show_header: bool, + cx: &mut Context, + ) -> gpui::Div { + let phase = self.tree.node("").map(|root| root.load.clone()); + let content = if !self.search_state.query.is_empty() { + self.render_search_results(cx) + } else if let Some(error) = self.error.clone().filter(|_| !self.tree_has_content()) { + div() + .flex_1() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(10.0)) + .px(px(28.0)) + .child( + div() + .text_center() + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child(error), + ) + .child( + div() + .id("files-retry-root") + .h(px(28.0)) + .px(px(12.0)) + .rounded(px(7.0)) + .border_1() + .border_color(theme.border) + .bg(crate::theme::wash(0.04)) + .hover(|style| style.bg(crate::theme::wash(0.09))) + .cursor_pointer() + .flex() + .items_center() + .text_size(px(11.5)) + .text_color(theme.text) + .child("Retry") + .on_click(cx.listener(|this, _, _, cx| this.retry_root(cx))), + ) + .into_any_element() + } else if !self.tree_has_content() + && matches!( + phase.as_ref(), + Some(DirectoryLoadState::Unloaded | DirectoryLoadState::Loading { .. }) + ) + { + div().flex_1().into_any_element() + } else { + self.render_tree(cx) + }; + let watch_error = self.watch_error.clone(); + div() + .size_full() + .min_w_0() + .flex() + .flex_col() + .when(show_header, |pane| { + pane.child(self.render_header(theme, cx)) + }) + .when_some(watch_error, |element, error| { + element.child( + div() + .h(px(27.0)) + .flex_none() + .px(px(10.0)) + .border_b_1() + .border_color(theme.warning.opacity(0.22)) + .bg(theme.warning.opacity(0.045)) + .flex() + .items_center() + .gap(px(6.0)) + .text_size(px(10.0)) + .text_color(theme.warning_muted) + .child( + crate::icons::icon(crate::icons::REFRESH) + .size(px(10.5)) + .flex_none(), + ) + .child(div().min_w_0().flex_1().truncate().child(error)) + .child( + div() + .id("files-watch-refresh-now") + .h(px(20.0)) + .flex_none() + .px(px(6.0)) + .rounded(px(5.0)) + .flex() + .items_center() + .cursor_pointer() + .role(gpui::Role::Button) + .aria_label("Refresh workspace files now") + .text_color(theme.text_muted) + .hover(|style| style.bg(crate::theme::wash(0.07))) + .child("Refresh now") + .on_click(cx.listener(|this, _, _, cx| { + this.refresh(cx); + this.reconcile_open_documents(cx); + })), + ), + ) + }) + .child(content) + } + + /// A persistent explorer: opening a path always delegates to the shell. + pub fn new_explorer( + state: Entity, + chat_id: String, + show_all_files: bool, + cx: &mut Context, + ) -> Self { + Self::new_with_presentation( + state, + chat_id, + FilesPresentation::Explorer, + None, + false, + 1000, + 13.0, + false, + show_all_files, + cx, + ) + } + pub fn new( state: Entity, chat_id: String, @@ -725,6 +756,10 @@ impl FilesSurface { { self.open_file(path, cx); } + self.ensure_tree_loaded(cx); + } + + fn ensure_tree_loaded(&mut self, cx: &mut Context) { if self.started { return; } @@ -765,7 +800,7 @@ impl FilesSurface { } pub(super) fn open_tree_file(&mut self, path: String, cx: &mut Context) { - if self.presentation.is_editor() { + if self.presentation != FilesPresentation::Browser { cx.emit(FilesEvent::OpenFile(path)); return; } @@ -1003,3 +1038,36 @@ impl FilesSurface { ) } } + +#[cfg(test)] +mod explorer_tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + use std::{cell::RefCell, rc::Rc}; + + #[gpui::test] + fn explorer_open_delegates_without_becoming_an_editor(cx: &mut TestAppContext) { + let surface = cx.new(|cx| { + let state = cx.new(|_| AppState::new()); + FilesSurface::new_explorer(state, "chat".into(), false, cx) + }); + let paths = Rc::new(RefCell::new(Vec::new())); + let emitted = paths.clone(); + let _sub = cx.update(|cx| { + cx.subscribe(&surface, move |_, event, _| { + if let FilesEvent::OpenFile(path) = event { + emitted.borrow_mut().push(path.clone()); + } + }) + }); + surface.update(cx, |surface, cx| { + surface.open_tree_file("src/main.rs".into(), cx); + surface.open_tree_file("README.md".into(), cx); + assert_eq!(surface.presentation, FilesPresentation::Explorer); + assert!(surface.editor_path.is_none()); + assert!(!surface.preview.has_active()); + assert!(!surface.preview.has_unsaved_changes()); + }); + assert_eq!(*paths.borrow(), ["src/main.rs", "README.md"]); + } +} From 07aaa4185e4aaf7b2104750f7628310685ff125c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 16:23:26 -0300 Subject: [PATCH 02/13] feat(shell): add an independent right files panel --- crates/ui/src/files/mod.rs | 160 ++++---------- crates/ui/src/files/preview.rs | 326 +---------------------------- crates/ui/src/files/search.rs | 4 +- crates/ui/src/files/watch.rs | 4 + crates/ui/src/settings.rs | 38 ++++ crates/ui/src/shell.rs | 238 ++++++++------------- crates/ui/src/shell/files_panel.rs | 228 ++++++++++++++++++++ crates/ui/src/shell/tabs.rs | 23 +- 8 files changed, 425 insertions(+), 596 deletions(-) create mode 100644 crates/ui/src/shell/files_panel.rs diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 1fef1e2c2..cf5b139a1 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -138,6 +138,7 @@ pub(crate) fn workspace_path_drag_ghost( #[derive(Debug, Clone, PartialEq, Eq)] pub enum FilesEvent { OpenFile(String), + RevealFile(String), TitleChanged, FileRenamed { old_path: String, new_path: String }, WordWrapChanged(bool), @@ -155,7 +156,6 @@ pub enum FilesCloseDisposition { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FilesPresentation { - Browser, Explorer, Editor, } @@ -206,92 +206,15 @@ pub struct FilesSurface { impl Render for FilesSurface { fn render(&mut self, window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { let theme = crate::theme::Theme::of(cx).clone(); - let split_editor = self.presentation.is_editor() && self.preview.has_active(); - let tree_pane = self.render_explorer(&theme, !split_editor, cx); let is_editor = self.presentation.is_editor(); - let mut header = None; - let body = if split_editor { - let wide = self.preview.is_wide(); - let tree_width = if wide { - self.preview.tree_width() - } else { - self.preview.narrow_tree_width() - }; - let openness = self.preview.tree_sidebar_frame(window, cx); - // Same arrangement as the outer right-sidebar toggle: the trigger - // is outside the animated controls, in a permanently mounted slot. - let toggle_width = - crate::surface_chrome::CONTROL_SIZE + crate::surface_chrome::EDGE_INSET; - let tree_header = self - .render_header(&theme, cx) - .pr(px(crate::surface_chrome::CONTROL_GAP)) - .border_l_1() - .border_color(theme.border); - header = Some( - div() - .w_full() - .h(px(crate::surface_chrome::HEADER_HEIGHT)) - .flex_none() - .flex() - .child( - div() - .flex_1() - .min_w_0() - .children(self.render_editor_header(&theme, cx)), - ) - .child( - div() - .w(px((tree_width * openness - toggle_width).max(0.0))) - .h_full() - .flex_none() - .overflow_hidden() - .child( - div() - .w(px(tree_width - toggle_width)) - .h_full() - .child(tree_header), - ), - ) - .child(self.render_tree_toggle(&theme, cx)), - ); - - div() - .size_full() - .min_w_0() - .flex() - .child( - div() - .flex_1() - .min_w_0() - .child(self.render_preview(window, cx)), - ) - .child( - div() - .w(px(tree_width * openness)) - .h_full() - .flex_none() - .relative() - .child( - div().size_full().overflow_hidden().child( - div() - .w(px(tree_width)) - .h_full() - .relative() - .border_l_1() - .border_color(theme.border) - .child(tree_pane), - ), - ) - .when(wide && self.preview.tree_sidebar_visible(), |pane| { - pane.child(self.preview_split_handle(cx)) - }), - ) - .into_any_element() + let header = is_editor + .then(|| self.render_editor_header(&theme, cx)) + .flatten(); + let body = if is_editor { + self.render_preview(window, cx) } else { - tree_pane.into_any_element() + self.render_explorer(&theme, cx).into_any_element() }; - let measured_width = self.preview.width_cell(); - let entity = cx.entity(); let editor_context_menu = self.render_editor_context_menu(&theme, cx); div() .id(SharedString::from(format!( @@ -304,24 +227,6 @@ impl Render for FilesSurface { .relative() .flex() .bg(crate::theme::ink(0.0)) - .when(is_editor, |element| { - element - .on_drag_move(cx.listener(Self::on_preview_split_drag)) - .child( - gpui::canvas( - move |bounds, _, cx| { - let width = f32::from(bounds.size.width); - if (measured_width.get() - width).abs() > 1.0 { - measured_width.set(width); - entity.update(cx, |_, cx| cx.notify()); - } - }, - |_, _, _, _| {}, - ) - .absolute() - .inset_0(), - ) - }) .flex_col() .children(header) .child(div().flex_1().min_h_0().w_full().child(body)) @@ -333,7 +238,6 @@ impl FilesSurface { fn render_explorer( &mut self, theme: &crate::theme::Theme, - show_header: bool, cx: &mut Context, ) -> gpui::Div { let phase = self.tree.node("").map(|root| root.load.clone()); @@ -390,9 +294,7 @@ impl FilesSurface { .min_w_0() .flex() .flex_col() - .when(show_header, |pane| { - pane.child(self.render_header(theme, cx)) - }) + .child(self.render_header(theme, cx)) .when_some(watch_error, |element, error| { element.child( div() @@ -459,6 +361,7 @@ impl FilesSurface { ) } + #[cfg(test)] pub fn new( state: Entity, chat_id: String, @@ -472,7 +375,7 @@ impl FilesSurface { Self::new_with_presentation( state, chat_id, - FilesPresentation::Browser, + FilesPresentation::Editor, None, autosave_enabled, autosave_delay_ms, @@ -756,7 +659,9 @@ impl FilesSurface { { self.open_file(path, cx); } - self.ensure_tree_loaded(cx); + if !self.presentation.is_editor() { + self.ensure_tree_loaded(cx); + } } fn ensure_tree_loaded(&mut self, cx: &mut Context) { @@ -774,6 +679,9 @@ impl FilesSurface { } fn refresh(&mut self, cx: &mut Context) { + if self.presentation.is_editor() { + return; + } self.error = None; self.started = true; self.tree.invalidate_all_directories(); @@ -800,15 +708,29 @@ impl FilesSurface { } pub(super) fn open_tree_file(&mut self, path: String, cx: &mut Context) { - if self.presentation != FilesPresentation::Browser { - cx.emit(FilesEvent::OpenFile(path)); - return; - } + cx.emit(FilesEvent::OpenFile(path)); + } - self.presentation = FilesPresentation::Editor; - self.editor_path = Some(path.clone()); - self.open_file(path, cx); - cx.emit(FilesEvent::TitleChanged); + pub(crate) fn focus_explorer(&self, window: &mut Window, cx: &mut Context) { + let focus = if self.search_state.query.is_empty() { + self.tree_focus.clone() + } else { + use gpui::Focusable; + self.search.focus_handle(cx) + }; + window.defer(cx, move |window, cx| focus.focus(window, cx)); + } + + pub(crate) fn reveal_file(&mut self, path: String, cx: &mut Context) { + self.reveal_search_result( + zeron_proto::WorkspaceFileSearchMatch { + name: path.rsplit('/').next().unwrap_or(&path).to_string(), + path, + kind: zeron_proto::WorkspaceEntryKind::File, + score: 0, + }, + cx, + ); } fn toggle_ignored(&mut self, cx: &mut Context) { @@ -822,8 +744,10 @@ impl FilesSurface { self.loads.clear(); self.error = None; self.sync_tree_list(); - self.started = true; - self.load_directory(String::new(), None, cx); + self.started = false; + if !self.presentation.is_editor() { + self.ensure_tree_loaded(cx); + } if !self.search_state.query.is_empty() { self.search_state.query.clear(); self.on_search_edited(cx); diff --git a/crates/ui/src/files/preview.rs b/crates/ui/src/files/preview.rs index 860df93e5..d2fd44f19 100644 --- a/crates/ui/src/files/preview.rs +++ b/crates/ui/src/files/preview.rs @@ -1,5 +1,4 @@ use std::{ - cell::Cell, collections::{HashMap, HashSet, VecDeque}, rc::Rc, sync::Arc, @@ -8,13 +7,13 @@ use std::{ use gpui::{ AnyElement, App, Context, Entity, Focusable as _, HighlightStyle, ListAlignment, - ListSizingBehavior, ListState, Point, Render, ScrollHandle, SharedString, Subscription, Window, - div, font, list, prelude::*, px, + ListSizingBehavior, ListState, Render, ScrollHandle, SharedString, Subscription, Window, div, + font, list, prelude::*, px, }; use gpui_base::input::{RopeExt as _, TextDecoration, TextDecorationCollection}; use zeron_proto::{ - ReadWorkspaceFileRequest, WorkspaceFileSearchMatch, WorkspaceReadOnlyReason, - WriteWorkspaceFileOutcome, WriteWorkspaceFileRequest, + ReadWorkspaceFileRequest, WorkspaceReadOnlyReason, WriteWorkspaceFileOutcome, + WriteWorkspaceFileRequest, }; use super::{ @@ -32,8 +31,6 @@ use crate::{ }; const PREVIEW_LINE_HEIGHT: f32 = 20.0; -const WIDE_BREAKPOINT: f32 = 680.0; -const TREE_SPLIT_DEFAULT: f32 = 286.0; const EDITOR_COMMENT_CARD_WIDTH: f32 = 320.0; const EDITOR_COMMENT_CARD_MARGIN: f32 = 8.0; const EDITOR_COMMENT_CARD_MIN_ANCHORED_WIDTH: f32 = 220.0; @@ -87,47 +84,6 @@ enum ReloadDecision { AwaitDiscardConfirmation, } -/// Openness is independent of the dragged width, so resizing remains direct. -#[derive(Default)] -struct TreeSidebarMotion { - target: Option, - from: f32, - started: Option, -} - -impl TreeSidebarMotion { - fn sample(&mut self, visible: bool, now: Instant, reduced: bool) -> (f32, bool) { - let end = f32::from(visible); - let duration = crate::motion::RESIZE - .total() - .mul_f32(crate::motion::speed_scale()); - // Layout and file activation changes are immediate. Only the toggle - // action starts a transition through animate_to. - if reduced || self.target != Some(visible) { - self.target = Some(visible); - self.started = None; - return (end, false); - } - if let Some(started) = self.started { - let raw = now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32(); - if raw < 1.0 { - return ( - crate::motion::lerp(self.from, end, crate::motion::RESIZE.progress(raw)), - true, - ); - } - self.started = None; - } - (end, false) - } - - fn animate_to(&mut self, previous: bool, visible: bool, now: Instant) { - self.from = self.sample(previous, now, false).0; - self.target = Some(visible); - self.started = Some(now); - } -} - pub(super) struct FilePreviewState { documents: HashMap, document_recency: VecDeque, @@ -136,17 +92,12 @@ pub(super) struct FilePreviewState { syntax_cache: SyntaxHighlightCache, list: ListState, horizontal_scroll: ScrollHandle, - surface_width: Rc>, word_wrap: bool, editor_font_size: f32, autosave_enabled: bool, autosave_delay_ms: u64, reload_confirmation: Option, close_requested: bool, - tree_sidebar_visible: bool, - tree_sidebar_dismissed: bool, - tree_width: f32, - tree_motion: TreeSidebarMotion, comment_anchors: HashMap>, comment_draft: Option, active_comment: Option, @@ -167,17 +118,12 @@ impl FilePreviewState { syntax_cache: SyntaxHighlightCache::default(), list: ListState::new(0, ListAlignment::Top, px(520.0)), horizontal_scroll: ScrollHandle::new(), - surface_width: Rc::new(Cell::new(520.0)), word_wrap, editor_font_size, autosave_enabled, autosave_delay_ms, reload_confirmation: None, close_requested: false, - tree_sidebar_visible: false, - tree_sidebar_dismissed: false, - tree_width: TREE_SPLIT_DEFAULT, - tree_motion: TreeSidebarMotion::default(), comment_anchors: HashMap::new(), comment_draft: None, active_comment: None, @@ -192,8 +138,6 @@ impl FilePreviewState { self.list.reset(0); self.reload_confirmation = None; self.close_requested = false; - self.tree_sidebar_visible = false; - self.tree_motion = TreeSidebarMotion::default(); self.comment_anchors.clear(); self.comment_draft = None; self.active_comment = None; @@ -305,35 +249,6 @@ impl FilePreviewState { evicted } - pub(super) fn is_wide(&self) -> bool { - self.surface_width.get() >= WIDE_BREAKPOINT - } - - pub(super) fn width_cell(&self) -> Rc> { - self.surface_width.clone() - } - - pub(super) fn tree_sidebar_visible(&self) -> bool { - self.tree_sidebar_visible || (self.is_wide() && !self.tree_sidebar_dismissed) - } - - fn show_tree_sidebar(&mut self) { - self.tree_sidebar_visible = true; - self.tree_sidebar_dismissed = false; - } - - fn toggle_tree_sidebar(&mut self) { - let previous = self.tree_sidebar_visible(); - if previous { - self.tree_sidebar_visible = false; - self.tree_sidebar_dismissed = true; - } else { - self.show_tree_sidebar(); - } - self.tree_motion - .animate_to(previous, self.tree_sidebar_visible(), Instant::now()); - } - fn word_wrap(&self) -> bool { self.word_wrap } @@ -366,22 +281,6 @@ impl FilePreviewState { pending } - pub(super) fn tree_sidebar_frame(&mut self, window: &mut Window, cx: &App) -> f32 { - let (openness, active) = self.tree_motion.sample( - self.tree_sidebar_visible(), - Instant::now(), - crate::motion::reduced_motion(cx), - ); - if active { - window.request_animation_frame(); - } - openness - } - - pub(super) fn tree_width(&self) -> f32 { - self.tree_width - } - pub(super) fn has_unsaved_changes(&self) -> bool { self.documents.values().any(FileDocument::is_dirty) } @@ -419,10 +318,6 @@ impl FilePreviewState { fn autosave_paused_for_reload(&self, path: &str) -> bool { self.reload_confirmation.as_deref() == Some(path) } - - pub(super) fn narrow_tree_width(&self) -> f32 { - (self.surface_width.get() * 0.44).clamp(152.0, self.tree_width) - } } fn estimated_highlighted_file_bytes(highlight: &HighlightedFile) -> usize { @@ -566,16 +461,6 @@ fn path_is_same_or_descendant(path: &str, ancestor: &str) -> bool { path == ancestor || path.starts_with(&format!("{ancestor}/")) } -pub(super) struct PreviewSplitResize; - -struct PreviewDragGhost; - -impl Render for PreviewDragGhost { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().size(px(1.0)) - } -} - pub(super) struct FileEditorTooltip { pub(super) text: SharedString, } @@ -626,20 +511,6 @@ impl FilesSurface { window.defer(cx, move |window, cx| focus.focus(window, cx)); } - pub(super) fn show_tree_sidebar(&mut self, cx: &mut Context) { - self.preview.show_tree_sidebar(); - cx.notify(); - } - - fn toggle_tree_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - self.preview.toggle_tree_sidebar(); - if !self.preview.tree_sidebar_visible() { - // A hidden search input must not keep receiving editor keystrokes. - self.focus_editor(window, cx); - } - cx.notify(); - } - fn toggle_word_wrap(&mut self, _window: &mut Window, cx: &mut Context) { let word_wrap = !self.preview.word_wrap; cx.emit(FilesEvent::WordWrapChanged(word_wrap)); @@ -991,7 +862,6 @@ impl FilesSurface { } self.preview.active = Some(path.clone()); self.preview.touch_document(&path); - self.preview.tree_sidebar_visible = false; if !self.preview.documents.contains_key(&path) { let Some(context) = self.request_context.as_ref() else { return; @@ -2020,35 +1890,6 @@ impl FilesSurface { .into_any_element() } - pub(super) fn render_tree_toggle( - &mut self, - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - toolbar(theme) - .w(px( - crate::surface_chrome::CONTROL_SIZE + crate::surface_chrome::EDGE_INSET - )) - .pl_0() - .child( - toolbar_button( - "files-toggle-tree-sidebar", - if self.preview.tree_sidebar_visible() { - "Hide files sidebar" - } else { - "Show files sidebar" - }, - ) - .on_click(cx.listener(|this, _, window, cx| this.toggle_tree_sidebar(window, cx))) - .child( - icon(icons::SIDEBAR_MINIMALISTIC) - .size(px(crate::surface_chrome::ICON_SIZE)) - .text_color(theme.text_muted), - ), - ) - .into_any_element() - } - pub(super) fn render_editor_header( &mut self, theme: &Theme, @@ -2243,21 +2084,8 @@ impl FilesSurface { }) .child( toolbar_button("files-reveal-active", "Reveal file in tree") - .on_click(cx.listener(move |this, _, _, cx| { - let name = reveal_path - .rsplit('/') - .next() - .unwrap_or(&reveal_path) - .to_string(); - this.reveal_search_result( - WorkspaceFileSearchMatch { - path: reveal_path.clone(), - name, - kind: zeron_proto::WorkspaceEntryKind::File, - score: 0, - }, - cx, - ); + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(FilesEvent::RevealFile(reveal_path.clone())); })) .child( icon(icons::FOLDER) @@ -2890,39 +2718,6 @@ impl FilesSurface { ) .into_any_element() } - - pub(super) fn on_preview_split_drag( - &mut self, - event: &gpui::DragMoveEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let width = f32::from(event.bounds.right() - event.event.position.x); - self.preview.tree_width = width.clamp(220.0, 360.0); - cx.notify(); - } - - pub(super) fn preview_split_handle(&self, cx: &mut Context) -> AnyElement { - let color = Theme::of(cx).border_strong; - div() - .id("files-preview-split") - .absolute() - .left(px(-3.0)) - .top_0() - .bottom_0() - .w(px(6.0)) - .occlude() - .cursor_col_resize() - .hover(move |style| style.bg(color)) - .on_drag( - PreviewSplitResize, - |_, _point: Point, _, cx| { - cx.stop_propagation(); - cx.new(|_| PreviewDragGhost) - }, - ) - .into_any_element() - } } fn editor_comment_overlay_top( @@ -3055,13 +2850,11 @@ mod tests { .get_mut("private.env") .unwrap() .mark_external(None); - preview.tree_sidebar_visible = true; preview.reset(); assert!(preview.documents.is_empty()); assert!(preview.active.is_none()); - assert!(!preview.tree_sidebar_visible); } #[test] @@ -3251,113 +3044,6 @@ mod tests { assert!(preview.set_autosave_enabled(false).is_empty()); } - #[test] - fn sidebar_layout_changes_are_immediate_without_a_user_toggle() { - let mut preview = FilePreviewState::new(false, 900, false, 11.5); - let now = Instant::now(); - assert_eq!( - preview - .tree_motion - .sample(preview.tree_sidebar_visible(), now, false), - (0.0, false) - ); - // A newly opened surface measures its width after the first render. - preview.surface_width.set(WIDE_BREAKPOINT); - assert_eq!( - preview - .tree_motion - .sample(preview.tree_sidebar_visible(), now, false), - (1.0, false) - ); - preview.surface_width.set(WIDE_BREAKPOINT - 1.0); - assert_eq!( - preview - .tree_motion - .sample(preview.tree_sidebar_visible(), now, false), - (0.0, false) - ); - preview.show_tree_sidebar(); - assert_eq!( - preview - .tree_motion - .sample(preview.tree_sidebar_visible(), now, false), - (1.0, false) - ); - preview.toggle_tree_sidebar(); - let started = preview.tree_motion.started.unwrap(); - assert_eq!( - preview - .tree_motion - .sample(preview.tree_sidebar_visible(), started, false), - (1.0, true) - ); - } - - #[test] - fn sidebar_motion_reverses_from_its_current_width() { - let mut motion = TreeSidebarMotion::default(); - let now = Instant::now(); - assert_eq!(motion.sample(true, now, false), (1.0, false)); - motion.animate_to(true, false, now); - assert_eq!(motion.sample(false, now, false), (1.0, true)); - let midway = now - + crate::motion::RESIZE - .total() - .mul_f32(crate::motion::speed_scale() * 0.4); - let closing = motion.sample(false, midway, false).0; - assert!(closing > 0.0 && closing < 1.0); - motion.animate_to(false, true, midway); - assert_eq!(motion.sample(true, midway, false), (closing, true)); - assert_eq!( - motion.sample(true, midway + Duration::from_secs(10), false), - (1.0, false) - ); - motion.animate_to(true, false, midway + Duration::from_secs(10)); - assert_eq!( - motion.sample(false, midway + Duration::from_secs(20), false), - (0.0, false) - ); - } - - #[test] - fn sidebar_motion_snaps_when_reduced_motion_is_enabled() { - let mut motion = TreeSidebarMotion::default(); - let now = Instant::now(); - motion.sample(true, now, false); - motion.animate_to(true, false, now); - assert_eq!(motion.sample(false, now, true), (0.0, false)); - assert_eq!(motion.sample(true, now, true), (1.0, false)); - assert_eq!(motion.sample(true, now, false), (1.0, false)); - } - - #[test] - fn wide_layout_respects_an_explicitly_hidden_tree_sidebar() { - let mut preview = FilePreviewState::new(false, 900, false, 11.5); - preview.surface_width.set(WIDE_BREAKPOINT); - - assert!(preview.tree_sidebar_visible()); - - preview.toggle_tree_sidebar(); - assert!(!preview.tree_sidebar_visible()); - - preview.surface_width.set(WIDE_BREAKPOINT - 1.0); - preview.surface_width.set(WIDE_BREAKPOINT); - assert!(!preview.tree_sidebar_visible()); - } - - #[test] - fn explicitly_showing_tree_sidebar_clears_responsive_dismissal() { - let mut preview = FilePreviewState::new(false, 900, false, 11.5); - preview.surface_width.set(WIDE_BREAKPOINT); - preview.toggle_tree_sidebar(); - - preview.show_tree_sidebar(); - - assert!(preview.tree_sidebar_visible()); - preview.tree_sidebar_visible = false; - assert!(preview.tree_sidebar_visible()); - } - #[test] fn dirty_reload_waits_for_explicit_discard_confirmation() { let path = "src/lib.rs"; diff --git a/crates/ui/src/files/search.rs b/crates/ui/src/files/search.rs index 0001460dc..d36c2e38f 100644 --- a/crates/ui/src/files/search.rs +++ b/crates/ui/src/files/search.rs @@ -452,9 +452,7 @@ impl FilesSurface { .search .update(cx, |search, cx| search.set_text("", cx)); surface.reveal_tree_selection(); - if result.kind == WorkspaceEntryKind::Directory { - surface.show_tree_sidebar(cx); - } else { + if result.kind != WorkspaceEntryKind::Directory { surface.open_tree_file(result.path.clone(), cx); } cx.notify(); diff --git a/crates/ui/src/files/watch.rs b/crates/ui/src/files/watch.rs index b521d0f63..acbdedce7 100644 --- a/crates/ui/src/files/watch.rs +++ b/crates/ui/src/files/watch.rs @@ -134,6 +134,10 @@ impl FilesSurface { } } + if self.presentation.is_editor() { + cx.notify(); + return; + } for parent in &parents { self.tree.invalidate_directory(parent); } diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index c6d26b773..6f4ed1ab3 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -33,6 +33,9 @@ pub const SIDEBAR_DEFAULT: f32 = 256.0; /// Right ("Changes") pane drag-resize floor and default (px). Its runtime /// maximum is the window space remaining after the left sidebar and the /// conversation's [`CHAT_PANEL_MIN`] reservation. +pub const FILES_PANEL_DEFAULT: f32 = 286.0; +pub const FILES_PANEL_MIN: f32 = 220.0; +pub const FILES_PANEL_MAX: f32 = 440.0; pub const RIGHT_PANE_MIN: f32 = 360.0; pub const RIGHT_PANE_DEFAULT: f32 = 520.0; /// Minimum width retained for the conversation when the right pane is open. @@ -389,6 +392,7 @@ pub struct UiSettings { /// Suppress the banner while a Zeron window is focused (the chime covers /// the foreground case). pub notifications_background_only: bool, + pub files_panel_width: f32, pub right_pane_width: f32, /// Legacy: panel *open* flags are session-scoped in-memory state now /// (`shell::SessionPanels`, zeron `sessionPanels` parity). Kept for file @@ -465,6 +469,7 @@ impl Default for UiSettings { sound_enabled: true, notifications_enabled: true, notifications_background_only: true, + files_panel_width: FILES_PANEL_DEFAULT, right_pane_width: RIGHT_PANE_DEFAULT, right_pane_open: false, terminal_height: TERMINAL_DEFAULT_HEIGHT, @@ -916,6 +921,12 @@ impl UiSettings { ); // The right pane has no persisted upper bound: its live drag clamps // against the current window, which is unavailable while loading. + self.files_panel_width = clamp_or( + self.files_panel_width, + FILES_PANEL_MIN, + FILES_PANEL_MAX, + FILES_PANEL_DEFAULT, + ); self.right_pane_width = min_or(self.right_pane_width, RIGHT_PANE_MIN, RIGHT_PANE_DEFAULT); self.terminal_height = clamp_or( self.terminal_height, @@ -1112,6 +1123,7 @@ mod tests { sound_enabled: false, notifications_enabled: false, notifications_background_only: false, + files_panel_width: 310.0, right_pane_width: 700.0, right_pane_open: true, terminal_height: 320.0, @@ -1370,6 +1382,32 @@ mod tests { assert_eq!(UiSettings::load(dir.path()), UiSettings::default()); } + #[test] + fn files_panel_width_defaults_roundtrips_and_clamps() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(UiSettings::path(dir.path()), r#"{"sidebarWidth":256}"#).unwrap(); + assert_eq!( + UiSettings::load(dir.path()).files_panel_width, + FILES_PANEL_DEFAULT + ); + for (value, expected) in [ + (310.0, 310.0), + (1.0, FILES_PANEL_MIN), + (900.0, FILES_PANEL_MAX), + (f32::NAN, FILES_PANEL_DEFAULT), + ] { + let settings = UiSettings { + files_panel_width: value, + ..Default::default() + } + .clamped(); + assert_eq!(settings.files_panel_width, expected); + let encoded = serde_json::to_string(&settings).unwrap(); + let decoded: UiSettings = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.files_panel_width, expected); + } + } + #[test] fn loaded_values_are_clamped() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index f979a2873..1d0795bc8 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -58,6 +58,7 @@ use crate::theme::Theme; use crate::transcript::{self, Transcript, TranscriptEvent}; use crate::workspace_links::resolve_workspace_file_link; +mod files_panel; mod spaces; mod tabs; @@ -450,7 +451,6 @@ fn right_pane_takeover_width(viewport: f32, sidebar: f32) -> f32 { pub enum RightSurface { #[default] Picker, - Files, File(u64), Browser(u64), Diff(u64), @@ -483,6 +483,7 @@ fn workspace_file_title(path: &str) -> SharedString { /// the app run; a fresh open with no surface tabs lands on the picker. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ChatPanels { + pub files_open: bool, pub terminal_open: bool, /// Right pane visible (the surface host — historically the Changes pane). pub changes_open: bool, @@ -1207,7 +1208,7 @@ pub struct Shell { /// its file watcher and every in-flight workspace request. files: std::collections::HashMap>, files_subs: std::collections::HashMap, - /// One independent editor/tree per opened workspace file. IDs are global + /// One independent editor per opened workspace file. IDs are global /// while the lookup key keeps a file tab scoped to its chat panel. file_surfaces: std::collections::HashMap>, file_surface_paths: std::collections::HashMap, @@ -1332,6 +1333,7 @@ pub struct Shell { debug_gate: Option, debug_upload: Option, sidebar_tween: Option, + files_tween: Option, right_tween: Option, /// Mirrors `right_tween` only for takeover entry/exit, allowing the visible /// right-panel contents to resize with their outer frame in that mode. @@ -1634,6 +1636,7 @@ impl Shell { debug_gate, debug_upload, sidebar_tween: None, + files_tween: None, right_tween: None, right_takeover_content_tween: None, main_takeover_tween: None, @@ -1899,6 +1902,7 @@ impl Shell { self.nav.push(entry); } } + self.files_tween = None; self.right_tween = None; self.right_takeover_content_tween = None; self.main_takeover_tween = None; @@ -1995,11 +1999,15 @@ impl Shell { // tween so toggling it remains seamless. let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); if self.right_pane_expanded { - right_pane_takeover_width(self.viewport_width, sidebar_now) + right_pane_takeover_width( + self.viewport_width - self.files_reserved_width(cx), + sidebar_now, + ) } else { - self.settings - .right_pane_width - .min(right_pane_max_width(self.viewport_width, sidebar_now)) + self.settings.right_pane_width.min(right_pane_max_width( + self.viewport_width - self.files_reserved_width(cx), + sidebar_now, + )) } } } @@ -2016,7 +2024,11 @@ impl Shell { // Reverse from the visible width when toggled during an animation. let from = self.eval_tween(self.right_tween, self.right_target(cx)); let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); - let from_main = conversation_width(self.viewport_width, sidebar_now, from); + let from_main = conversation_width( + self.viewport_width - self.files_reserved_width(cx), + sidebar_now, + from, + ); let was_expanded = self.right_pane_expanded; let key = self.panel_key(cx); let open = self.panels.toggle_changes(&key); @@ -2031,7 +2043,11 @@ impl Shell { self.main_takeover_tween = was_expanded.then(|| { WidthTween::new( from_main, - conversation_width(self.viewport_width, sidebar_now, to), + conversation_width( + self.viewport_width - self.files_reserved_width(cx), + sidebar_now, + to, + ), ) }); if open @@ -2074,15 +2090,6 @@ impl Shell { stored .iter() .filter_map(|surface| match surface { - RightSurface::Files => self.files.get(&key).map(|files| { - let files = files.read(cx); - ( - *surface, - files.tab_title(), - files.has_unsaved_changes(), - None, - ) - }), RightSurface::File(id) => self.file_surfaces.get(id).map(|file| { let path = self.file_surface_paths.get(id); let title = path @@ -2126,13 +2133,9 @@ impl Shell { fn workspace_path_for_surface( &self, surface: RightSurface, - cx: &App, + _cx: &App, ) -> Option { let path = match surface { - RightSurface::Files => self - .files - .get(&self.panel_key(cx)) - .and_then(|files| files.read(cx).attachment_path().map(str::to_string))?, RightSurface::File(id) => self.file_surface_paths.get(&id)?.clone(), RightSurface::Picker | RightSurface::Diff(_) @@ -2205,11 +2208,6 @@ impl Shell { let key = self.panel_key(cx); self.panels.update(&key, |p| p.right_active = surface); match surface { - RightSurface::Files => { - if let Some(files) = self.files.get(&key).cloned() { - files.update(cx, |files, cx| files.ensure_loaded(cx)); - } - } RightSurface::File(id) => { if let Some(file) = self.file_surfaces.get(&id).cloned() { file.update(cx, |file, cx| file.ensure_loaded(cx)); @@ -2249,9 +2247,7 @@ impl Shell { } return; } - let key = self.panel_key(cx); let files = match surface { - RightSurface::Files => self.files.get(&key).cloned(), RightSurface::File(id) => self.file_surfaces.get(&id).cloned(), _ => None, }; @@ -2272,12 +2268,7 @@ impl Shell { if let Some(page) = self.files_settings_page.clone() { page.update(cx, |page, cx| page.set_word_wrap(word_wrap, cx)); } - let surfaces = self - .files - .values() - .chain(self.file_surfaces.values()) - .cloned() - .collect::>(); + let surfaces = self.file_surfaces.values().cloned().collect::>(); for surface in surfaces { surface.update(cx, |surface, cx| { surface.set_word_wrap(word_wrap, window, cx) @@ -2289,12 +2280,7 @@ impl Shell { fn set_files_editor_font_size(&mut self, editor_font_size: f32, cx: &mut Context) { self.settings.files_editor_font_size = editor_font_size; - let surfaces = self - .files - .values() - .chain(self.file_surfaces.values()) - .cloned() - .collect::>(); + let surfaces = self.file_surfaces.values().cloned().collect::>(); for surface in surfaces { surface.update(cx, |surface, cx| { surface.set_editor_font_size(editor_font_size, cx) @@ -2310,9 +2296,9 @@ impl Shell { page.update(cx, |page, cx| page.set_show_all_files(show_all_files, cx)); } let surfaces = self - .files + .file_surfaces .values() - .chain(self.file_surfaces.values()) + .chain(self.files.values()) .cloned() .collect::>(); for surface in surfaces { @@ -2393,68 +2379,14 @@ impl Shell { self.register_diff_surface(changes, cx); } - /// Files is single-instance per chat: both the picker and the `+` menu - /// focus the existing surface instead of creating duplicate trees and - /// duplicate workspace subscriptions. - fn add_files_surface(&mut self, window: &mut Window, cx: &mut Context) { - if self.active_chat.is_empty() { - return; - } - let key = self.panel_key(cx); - if !self.files.contains_key(&key) { - let autosave_enabled = self.settings.files_autosave_enabled; - let delay = self.settings.files_autosave_delay_ms; - let editor_font_size = self.settings.files_editor_font_size; - let word_wrap = self.settings.files_word_wrap; - let show_all_files = self.settings.files_show_all; - let files = cx.new(|cx| { - FilesSurface::new( - self.state.clone(), - self.active_chat.clone(), - autosave_enabled, - delay, - editor_font_size, - word_wrap, - show_all_files, - cx, - ) - }); - let event_key = key.clone(); - let sub = cx.subscribe_in( - &files, - window, - move |this: &mut Self, _, event, window, cx| match event { - FilesEvent::OpenFile(path) => this.add_file_surface(path.clone(), window, cx), - FilesEvent::TitleChanged => cx.notify(), - FilesEvent::FileRenamed { .. } => cx.notify(), - FilesEvent::WordWrapChanged(word_wrap) => { - this.set_files_word_wrap(*word_wrap, window, cx) - } - FilesEvent::ShowAllFilesChanged(show_all_files) => { - this.set_files_show_all(*show_all_files, cx) - } - FilesEvent::CloseReady => { - this.on_file_close_ready(RightSurface::Files, &event_key, cx) - } - FilesEvent::CloseCancelled => this.cancel_file_close(RightSurface::Files, cx), - }, - ); - self.files.insert(key.clone(), files); - self.files_subs.insert(key.clone(), sub); - } - let tabs = self.right_tabs.entry(key).or_default(); - push_unique_right_surface(tabs, RightSurface::Files); - self.set_right_active(RightSurface::Files, cx); - self.focus_right_file_editor(RightSurface::Files, window, cx); - } - - /// Open a workspace file as a first-class right-pane tab. Every editor is - /// a separate FilesSurface so its tree, search, watcher and split layout - /// stay stable while users move among open files. + /// Open or focus a session-owned editor tab. The explorer is independent. fn add_file_surface(&mut self, path: String, window: &mut Window, cx: &mut Context) { if self.active_chat.is_empty() { return; } + if !self.right_pane_open(cx) { + self.toggle_right_pane(cx); + } let panel_key = self.panel_key(cx); let lookup = (panel_key.clone(), path.clone()); if let Some(id) = self.file_surface_keys.get(&lookup).copied() { @@ -2485,6 +2417,12 @@ impl Shell { window, move |this: &mut Self, _, event, window, cx| match event { FilesEvent::OpenFile(path) => this.add_file_surface(path.clone(), window, cx), + FilesEvent::RevealFile(path) => { + this.add_files_surface(window, cx); + if let Some(files) = this.files.get(&this.panel_key(cx)).cloned() { + files.update(cx, |files, cx| files.reveal_file(path.clone(), cx)); + } + } FilesEvent::TitleChanged => cx.notify(), FilesEvent::FileRenamed { old_path, new_path } => { this.rename_file_surface(id, &event_panel_key, old_path, new_path, cx) @@ -2505,10 +2443,10 @@ impl Shell { self.file_surface_paths.insert(id, path); self.file_surface_keys.insert(lookup, id); self.file_surface_subs.insert(id, sub); - self.right_tabs - .entry(panel_key) - .or_default() - .push(RightSurface::File(id)); + push_unique_right_surface( + self.right_tabs.entry(panel_key).or_default(), + RightSurface::File(id), + ); self.set_right_active(RightSurface::File(id), cx); } @@ -2753,7 +2691,6 @@ impl Shell { let was_active = self.resolved_right_active(cx) == surface; let key = self.panel_key(cx); let files = match surface { - RightSurface::Files => self.files.get(&key).cloned(), RightSurface::File(id) => self.file_surfaces.get(&id).cloned(), _ => None, }; @@ -2773,7 +2710,7 @@ impl Shell { tabs.retain(|s| *s != surface); } match surface { - RightSurface::Files | RightSurface::File(_) => {} + RightSurface::File(_) => {} RightSurface::Browser(id) => { if let Some(browser) = self.browsers.remove(&id) { browser.update(cx, |browser, cx| browser.close(cx)); @@ -2871,12 +2808,7 @@ impl Shell { } fn prepare_exit(&mut self, action: PendingExit, cx: &mut Context) -> bool { - let surfaces = self - .files - .values() - .chain(self.file_surfaces.values()) - .cloned() - .collect::>(); + let surfaces = self.file_surfaces.values().cloned().collect::>(); if surfaces .iter() .all(|surface| !surface.read(cx).has_unsaved_changes()) @@ -2900,12 +2832,6 @@ impl Shell { } fn reveal_unsaved_file(&mut self, cx: &mut Context) { - let browser = self.files.iter().filter_map(|(key, files)| { - files - .read(cx) - .has_unsaved_changes() - .then(|| (key.clone(), RightSurface::Files)) - }); let editors = self.file_surface_keys.iter().filter_map(|((key, _), id)| { self.file_surfaces .get(id) @@ -2913,7 +2839,7 @@ impl Shell { .map(|_| (key.clone(), RightSurface::File(*id))) }); let current = self.panel_key(cx); - let mut dirty = browser.chain(editors).collect::>(); + let mut dirty = editors.collect::>(); dirty.sort_by_key(|(key, _)| (key != ¤t, key.clone())); if let Some((key, surface)) = dirty.into_iter().next() { self.panels.update(&key, |panel| { @@ -2925,9 +2851,8 @@ impl Shell { } fn all_file_edits_flushed(&self, cx: &App) -> bool { - self.files + self.file_surfaces .values() - .chain(self.file_surfaces.values()) .all(|surface| !surface.read(cx).has_unsaved_changes()) } @@ -2941,10 +2866,6 @@ impl Shell { tabs.retain(|candidate| *candidate != surface); } match surface { - RightSurface::Files => { - self.files.remove(panel_key); - self.files_subs.remove(panel_key); - } RightSurface::File(id) => { self.file_surfaces.remove(&id); self.file_surface_paths.remove(&id); @@ -3058,10 +2979,13 @@ impl Shell { cx: &mut Context, ) { let viewport = f32::from(window.viewport_size().width); - let width = viewport - f32::from(event.event.position.x); + let width = viewport - self.files_reserved_width(cx) - f32::from(event.event.position.x); // No arbitrary percentage ceiling, but retain the chat's usable 300px // floor instead of allowing the conversation to collapse to zero. - let max = right_pane_max_width(viewport, self.sidebar_target()); + let max = right_pane_max_width( + viewport - self.files_reserved_width(cx), + self.sidebar_target(), + ); self.settings.right_pane_width = if max >= RIGHT_PANE_MIN { width.clamp(RIGHT_PANE_MIN, max) } else { @@ -7081,15 +7005,6 @@ impl Shell { let content: AnyElement = if self.right_pane_open(cx) || self.tween_active(self.right_tween) { match self.resolved_right_active(cx) { - RightSurface::Files => { - let key = self.panel_key(cx); - if let Some(files) = self.files.get(&key).cloned() { - files.update(cx, |files, cx| files.ensure_loaded(cx)); - files.into_any_element() - } else { - self.render_surface_picker(cx) - } - } RightSurface::File(id) => { if let Some(file) = self.file_surfaces.get(&id).cloned() { file.update(cx, |file, cx| file.ensure_loaded(cx)); @@ -7459,7 +7374,6 @@ impl Shell { for (ix, (surface, title, dirty, detail)) in rows.into_iter().enumerate() { let is_active = surface == active; let icon_path = match surface { - RightSurface::Files => icons::FOLDER_WITH_FILES, RightSurface::File(_) => icons::DOCUMENT, RightSurface::Diff(id) => self .diffs @@ -7881,7 +7795,11 @@ impl Shell { fn toggle_right_pane_expand(&mut self, cx: &mut Context) { let from = self.right_target(cx); let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); - let from_main = conversation_width(self.viewport_width, sidebar_now, from); + let from_main = conversation_width( + self.viewport_width - self.files_reserved_width(cx), + sidebar_now, + from, + ); self.right_pane_expanded = !self.right_pane_expanded; let to = self.right_target(cx); let right_transition = WidthTween::new(from, to); @@ -7889,7 +7807,11 @@ impl Shell { self.right_takeover_content_tween = Some(right_transition); self.main_takeover_tween = Some(WidthTween::new( from_main, - conversation_width(self.viewport_width, sidebar_now, to), + conversation_width( + self.viewport_width - self.files_reserved_width(cx), + sidebar_now, + to, + ), )); cx.notify(); } @@ -8739,6 +8661,7 @@ impl Render for Shell { .on_key_down(cx.listener(Self::on_key_down)) .on_drag_move(cx.listener(Self::on_sidebar_drag)) .on_drag_move(cx.listener(Self::on_right_pane_drag)) + .on_drag_move(cx.listener(Self::on_files_panel_drag)) .on_drag_move(cx.listener(Self::on_terminal_drag)) // The panel shortcuts are chat-scoped chrome: in Settings they are // no-ops (zeron __root.tsx gates the hotkey on `!isSettings`, and @@ -8752,7 +8675,6 @@ impl Render for Shell { .on_action(cx.listener(|this, _: &SaveFile, _, cx| { if matches!(this.route, Route::Chat) && this.right_pane_open(cx) { let file = match this.resolved_right_active(cx) { - RightSurface::Files => this.files.get(&this.panel_key(cx)).cloned(), RightSurface::File(id) => this.file_surfaces.get(&id).cloned(), _ => None, }; @@ -8864,8 +8786,11 @@ impl Render for Shell { // Stamped for `right_target` — the expanded changes panel // sizes itself to the viewport. self.viewport_width = viewport; - let main_target_width = - conversation_width(viewport, self.sidebar_target(), self.right_target(cx)); + let main_target_width = conversation_width( + viewport - self.files_reserved_width(cx), + self.sidebar_target(), + self.right_target(cx), + ); let main_transition = self.active_tween_endpoints(self.main_takeover_tween); let main_content_width = stable_panel_content_width(main_target_width, main_transition); @@ -8918,6 +8843,7 @@ impl Render for Shell { } else { Empty.into_any_element() }; + let files_panel = self.render_files_panel(cx); let overlays = self.render_overlays(window.viewport_size(), window, cx); // Copied out (not held) — `render_title_bar` needs `cx` mutable. let border_color = Theme::of(cx).border; @@ -9017,7 +8943,8 @@ impl Render for Shell { .relative() .child(right) .child(right_seam), - ), + ) + .child(files_panel), ) .child(div().absolute().top_0().left_0().right_0().child(title_bar)) .child(self.render_titlebar_cluster(cx)) @@ -9769,21 +9696,21 @@ mod tests { assert_eq!(panels.get("b").right_active, RightSurface::Picker); panels.update("a", |p| p.right_active = RightSurface::Terminal(7)); assert_eq!(panels.get("a").right_active, RightSurface::Terminal(7)); - panels.update("a", |p| p.right_active = RightSurface::Files); - assert_eq!(panels.get("a").right_active, RightSurface::Files); + panels.update("a", |p| p.right_active = RightSurface::File(0)); + assert_eq!(panels.get("a").right_active, RightSurface::File(0)); } #[test] - fn files_surface_is_single_instance_per_tab_list() { + fn file_surface_is_single_instance_per_tab_list() { let mut tabs = vec![RightSurface::Terminal(1)]; - assert!(push_unique_right_surface(&mut tabs, RightSurface::Files)); - assert!(!push_unique_right_surface(&mut tabs, RightSurface::Files)); - assert_eq!(tabs, vec![RightSurface::Terminal(1), RightSurface::Files]); + assert!(push_unique_right_surface(&mut tabs, RightSurface::File(0))); + assert!(!push_unique_right_surface(&mut tabs, RightSurface::File(0))); + assert_eq!(tabs, vec![RightSurface::Terminal(1), RightSurface::File(0)]); } #[test] fn file_editors_are_distinct_surface_tabs_with_stable_titles() { - let mut tabs = vec![RightSurface::Files]; + let mut tabs = vec![RightSurface::File(0)]; assert!(push_unique_right_surface(&mut tabs, RightSurface::File(1))); assert!(push_unique_right_surface(&mut tabs, RightSurface::File(2))); assert!(!push_unique_right_surface(&mut tabs, RightSurface::File(1))); @@ -10471,7 +10398,10 @@ mod exit_regressions { files.seed_pending_exit_test_document(failed); files }); - shell.files.insert("test".into(), files); + shell.file_surfaces.insert(0, files); + shell + .file_surface_keys + .insert(("test".into(), "test.rs".into()), 0); }) .unwrap(); cx.update(|cx| cx.dispatch_action(&crate::app_menus::Quit)); @@ -10480,7 +10410,7 @@ mod exit_regressions { .update(cx, |shell, _, cx| { assert!(matches!(shell.pending_exit, Some(PendingExit::Quit))); assert!(!shell.all_file_edits_flushed(cx)); - shell.cancel_file_close(RightSurface::Files, cx); + shell.cancel_file_close(RightSurface::File(0), cx); assert!(shell.pending_exit.is_none()); }) .unwrap(); @@ -10501,7 +10431,7 @@ mod exit_regressions { Some(PendingExit::InstallUpdate(_)) )); assert!(matches!(shell.update_flow, UpdateFlow::Idle)); - shell.cancel_file_close(RightSurface::Files, cx); + shell.cancel_file_close(RightSurface::File(0), cx); assert!(shell.pending_exit.is_none()); }) .unwrap(); diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs new file mode 100644 index 000000000..118238fc9 --- /dev/null +++ b/crates/ui/src/shell/files_panel.rs @@ -0,0 +1,228 @@ +//! Session-owned explorer chrome, independent of the surface tab host. + +use super::*; +use crate::settings::{FILES_PANEL_DEFAULT, FILES_PANEL_MAX, FILES_PANEL_MIN}; + +pub(super) struct FilesPanelResize; + +impl Shell { + pub(super) fn files_panel_open(&self, cx: &App) -> bool { + matches!(self.route, Route::Chat) + && !self.active_chat.is_empty() + && self.panels.get(&self.panel_key(cx)).files_open + } + + pub(super) fn files_target(&self, cx: &App) -> f32 { + if self.files_panel_open(cx) { + self.settings + .files_panel_width + .min((self.viewport_width - self.sidebar_target() - CHAT_PANEL_MIN).max(0.0)) + } else { + 0.0 + } + } + + pub(super) fn files_reserved_width(&self, cx: &App) -> f32 { + if matches!(self.route, Route::Chat) { + self.eval_tween(self.files_tween, self.files_target(cx)) + } else { + 0.0 + } + } + + pub(super) fn add_files_surface(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_chat.is_empty() { + return; + } + let key = self.panel_key(cx); + if !self.files.contains_key(&key) { + let files = cx.new(|cx| { + FilesSurface::new_explorer( + self.state.clone(), + self.active_chat.clone(), + self.settings.files_show_all, + cx, + ) + }); + let owner = key.clone(); + let sub = cx.subscribe_in( + &files, + window, + move |this: &mut Self, _, event, window, cx| match event { + FilesEvent::OpenFile(path) if this.panel_key(cx) == owner => { + this.add_file_surface(path.clone(), window, cx); + } + FilesEvent::ShowAllFilesChanged(show_all) => { + this.set_files_show_all(*show_all, cx) + } + _ => cx.notify(), + }, + ); + self.files.insert(key.clone(), files); + self.files_subs.insert(key.clone(), sub); + } + let from = self.files_reserved_width(cx); + let was_open = self.files_panel_open(cx); + self.panels.update(&key, |p| p.files_open = true); + if !was_open { + self.files_tween = Some(WidthTween::new(from, self.files_target(cx))); + } + if let Some(files) = self.files.get(&key).cloned() { + files.update(cx, |files, cx| { + files.ensure_loaded(cx); + files.focus_explorer(window, cx); + }); + } + self.composer + .update(cx, |composer, _| composer.focus_pending = false); + cx.notify(); + } + + pub(super) fn toggle_files_panel(&mut self, window: &mut Window, cx: &mut Context) { + if !self.files_panel_open(cx) { + self.add_files_surface(window, cx); + return; + } + let from = self.files_reserved_width(cx); + self.panels + .update(&self.panel_key(cx), |p| p.files_open = false); + self.files_tween = Some(WidthTween::new(from, 0.0)); + window.focus(&self.composer.focus_handle(cx), cx); + if self.right_pane_open(cx) { + self.focus_right_file_editor(self.resolved_right_active(cx), window, cx); + } + cx.notify(); + } + + pub(super) fn on_files_panel_drag( + &mut self, + event: &gpui::DragMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.settings.files_panel_width = (f32::from(window.viewport_size().width) + - f32::from(event.event.position.x)) + .clamp(FILES_PANEL_MIN, FILES_PANEL_MAX); + self.files_tween = None; + self.schedule_save(cx); + cx.notify(); + } + + pub(super) fn render_files_panel(&mut self, cx: &mut Context) -> AnyElement { + if !matches!(self.route, Route::Chat) + || self.active_chat.is_empty() + || (!self.files_panel_open(cx) && !self.tween_active(self.files_tween)) + { + return Empty.into_any_element(); + } + let theme = Theme::of(cx).clone(); + let content = self.files.get(&self.panel_key(cx)).cloned(); + if let Some(files) = &content { + files.update(cx, |files, cx| files.ensure_loaded(cx)); + } + let target = self.files_target(cx); + let content_width = + stable_panel_content_width(target, self.active_tween_endpoints(self.files_tween)); + let inner = div() + .w(px(content_width)) + .h_full() + .pt(px(Theme::TITLEBAR_HEIGHT)) + .border_l_1() + .border_color(theme.border) + .bg(theme.bg) + .children(content); + div() + .id("files-panel") + .h_full() + .flex_none() + .relative() + .child(self.pane_container(self.files_tween, target, inner.into_any_element())) + .when( + self.files_panel_open(cx) && !self.tween_active(self.files_tween), + |panel| { + panel.child( + self.resize_handle( + "files-panel-resize", + || FilesPanelResize, + |shell, _| shell.settings.files_panel_width = FILES_PANEL_DEFAULT, + cx, + ) + .left(px(-PANE_RESIZE_HITBOX_HALF_WIDTH)), + ) + }, + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + + #[gpui::test] + fn explorer_and_editor_panels_have_independent_session_lifetimes(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + window + .update(cx, |shell, window, cx| { + shell.add_files_surface(window, cx); + assert!( + shell.files.is_empty(), + "the new-session canvas has no explorer" + ); + shell.active_chat = "first".into(); + shell.add_files_surface(window, cx); + let explorer = shell.files["first"].entity_id(); + assert!(shell.files_panel_open(cx)); + assert!(!shell.right_pane_open(cx)); + assert!(shell.right_surface_rows(cx).is_empty()); + shell.add_files_surface(window, cx); + assert_eq!(shell.files["first"].entity_id(), explorer); + shell.add_file_surface("src/main.rs".into(), window, cx); + shell.add_file_surface("src/main.rs".into(), window, cx); + assert_eq!(shell.file_surfaces.len(), 1); + assert_eq!(shell.right_surface_rows(cx).len(), 1); + assert!(shell.right_pane_open(cx)); + shell.toggle_files_panel(window, cx); + assert!(!shell.files_panel_open(cx)); + assert!(shell.right_pane_open(cx)); + assert_eq!(shell.file_surfaces.len(), 1); + assert!(shell.pending_file_closes.is_empty()); + shell.active_chat = "second".into(); + assert!(!shell.files_panel_open(cx)); + shell.add_files_surface(window, cx); + assert_ne!(shell.files["second"].entity_id(), explorer); + shell.active_chat = "first".into(); + assert!(!shell.files_panel_open(cx)); + shell.add_files_surface(window, cx); + assert_eq!(shell.files["first"].entity_id(), explorer); + shell.route = Route::Settings(SettingsSection::Files); + assert!(!shell.files_panel_open(cx)); + assert_eq!(shell.files_reserved_width(cx), 0.0); + shell.route = Route::Chat; + assert!(shell.files_panel_open(cx)); + }) + .unwrap(); + } +} diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index e4a9f1871..bd98cdc93 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -219,6 +219,8 @@ impl Shell { } else { content_left }; + let files_width = self.files_reserved_width(cx); + let files_slot = files_width.max(28.0); let trailing: Option = if on_canvas { None } else { @@ -241,7 +243,7 @@ impl Shell { // budgeting them the capped strip overflows by exactly one gap and // the buttons slide right on expand (user report). let gap_budget = if takeover { 8.0 } else { 16.0 }; - let avail = self.viewport_width - row_left - pr - gap_budget; + let avail = self.viewport_width - files_slot - row_left - pr - gap_budget; // The right pane's SURFACE TABS (t3 RightPanelTabs) — the diff // options that used to live here moved into the pane's own // second row; expand stays in this band (user request). @@ -292,6 +294,25 @@ impl Shell { &theme, cx.listener(|this, _, _, cx| this.toggle_right_pane(cx)), )) + .child( + div() + .w(px((files_slot + - self.titlebar_right_pad(TITLEBAR_ACTION_EDGE_INSET)) + .max(28.0))) + .h_full() + .flex_none() + .flex() + .items_center() + .justify_end() + .child(header_icon_button( + "toggle-files-panel", + icons::FOLDER_WITH_FILES, + &theme, + cx.listener(|this, _, window, cx| { + this.toggle_files_panel(window, cx) + }), + )), + ) .into_any_element(), ) }; From 5fed757ca7bb2ceabcf6a135c047e379ee725540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 16:28:13 -0300 Subject: [PATCH 03/13] fix(layout): coordinate files panel resizing and expanded mode --- crates/ui/src/browser/mod.rs | 13 +++ crates/ui/src/browser/view.rs | 5 +- crates/ui/src/shell.rs | 26 ++++- crates/ui/src/shell/files_panel.rs | 169 ++++++++++++++++++++++++++--- crates/ui/src/shell/tabs.rs | 43 ++++++-- 5 files changed, 228 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index f25318cef..61a9f1f38 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -92,6 +92,8 @@ pub struct BrowserSurface { presentation: Presentation, #[cfg(target_os = "macos")] resize_inset: gpui::Pixels, + #[cfg(target_os = "macos")] + right_occlusion: gpui::Pixels, _input_sub: Subscription, #[cfg(any(target_os = "macos", target_os = "linux"))] native: Option, @@ -168,6 +170,8 @@ impl BrowserSurface { presentation: Presentation::Hidden, #[cfg(target_os = "macos")] resize_inset: gpui::px(0.0), + #[cfg(target_os = "macos")] + right_occlusion: gpui::px(0.0), _input_sub: input_sub, #[cfg(any(target_os = "macos", target_os = "linux"))] native: None, @@ -221,6 +225,15 @@ impl BrowserSurface { } } + /// Crop a GPUI overlay out of both native painting and native hit testing. + #[cfg(target_os = "macos")] + pub fn set_right_occlusion(&mut self, width: gpui::Pixels, cx: &mut Context) { + if self.right_occlusion != width { + self.right_occlusion = width; + cx.notify(); + } + } + pub fn set_presentation(&mut self, presentation: Presentation, cx: &mut Context) { if self.presentation == presentation { return; diff --git a/crates/ui/src/browser/view.rs b/crates/ui/src/browser/view.rs index eb75ed15a..b1931ffb9 100644 --- a/crates/ui/src/browser/view.rs +++ b/crates/ui/src/browser/view.rs @@ -520,12 +520,15 @@ impl Render for BrowserSurface { } else { let native = native.handle(); let resize_inset = self.resize_inset; + let right_occlusion = self.right_occlusion; body.child( gpui::canvas( |_, _, _| (), move |bounds, _, window, cx| { let native = std::rc::Rc::downgrade(&native); - let mask = window.content_mask().bounds; + let mut mask = window.content_mask().bounds; + let right = (window.viewport_size().width - right_occlusion).max(mask.left()); + mask.size.width = mask.size.width.min(right - mask.left()); let dragging = cx.has_active_drag(); window.on_present(move || { if let Some(native) = native.upgrade() { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 1d0795bc8..1f61dab99 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -7041,7 +7041,9 @@ impl Shell { let panel = self.right_terminal_panel(cx); // Keep the embedded panel's own active tab aligned with // the resolved surface (fallbacks can move it). - let resize_suspended = self.tween_active(self.right_tween); + let resize_suspended = self.tween_active(self.right_tween) + || self.tween_active(self.files_tween) + || self.tween_active(self.sidebar_tween); panel.update(cx, |panel, cx| { panel.set_resize_suspended(resize_suspended); panel.select_tab_by_key(tab, cx); @@ -8441,7 +8443,7 @@ fn header_icon_button( icon_path: &'static str, theme: &Theme, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, -) -> impl IntoElement { +) -> gpui::Stateful { let muted = theme.text_muted; let fade_key = format!("header-icon-{id}"); div() @@ -8501,7 +8503,14 @@ impl Render for Shell { }); } crate::transcript::record_view_frame("shell"); - self.viewport_width = f32::from(window.viewport_size().width); + let viewport = f32::from(window.viewport_size().width); + if (self.viewport_width - viewport).abs() > 1.0 { + self.files_tween = None; + self.right_tween = None; + self.right_takeover_content_tween = None; + self.main_takeover_tween = None; + } + self.viewport_width = viewport; // Appearance actions persist independently of the shell. Mirror the // globals before any later debounced settings save can overwrite them. self.settings.appearance = crate::appearance::mode(cx); @@ -8562,6 +8571,12 @@ impl Render for Shell { } else { px(0.0) }; + #[cfg(target_os = "macos")] + let browser_overlay_width = px(if self.files_visible_width(cx) > 0.0 { + self.files_visible_width(cx) + PANE_RESIZE_HITBOX_HALF_WIDTH + } else { + 0.0 + }); let selected_surface = self.resolved_right_active(cx); for (id, browser) in &self.browsers { let presentation = crate::browser::model::presentation( @@ -8570,7 +8585,10 @@ impl Render for Shell { ); browser.update(cx, |browser, cx| { #[cfg(target_os = "macos")] - browser.set_resize_inset(browser_resize_inset, cx); + { + browser.set_resize_inset(browser_resize_inset, cx); + browser.set_right_occlusion(browser_overlay_width, cx); + } browser.set_shortcuts(&self.settings.keymap); browser.set_presentation(presentation, cx); }); diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 118238fc9..22dd7c997 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -5,6 +5,44 @@ use crate::settings::{FILES_PANEL_DEFAULT, FILES_PANEL_MAX, FILES_PANEL_MIN}; pub(super) struct FilesPanelResize; +/// Resolve the explorer against the space required by its neighboring panes. +/// `visible` is the sampled animation width; the preferred width determines +/// the breakpoint so an opening drawer never changes modes halfway through. +#[derive(Debug, Clone, Copy, PartialEq)] +struct FilesPanelLayout { + width: f32, + reserved: f32, + overlay: bool, +} + +fn files_panel_layout( + viewport: f32, + sidebar: f32, + preferred: f32, + visible: f32, + surfaces_open: bool, + expanded: bool, +) -> FilesPanelLayout { + let available = (viewport - sidebar).max(0.0); + let content_min = if surfaces_open { + RIGHT_PANE_MIN + if expanded { 0.0 } else { CHAT_PANEL_MIN } + } else { + CHAT_PANEL_MIN + }; + let overlay = available < preferred + content_min; + let max_width = if overlay { + viewport.max(0.0) + } else { + (available - content_min).max(0.0) + }; + let width = visible.max(0.0).min(max_width); + FilesPanelLayout { + width, + reserved: if overlay { 0.0 } else { width }, + overlay, + } +} + impl Shell { pub(super) fn files_panel_open(&self, cx: &App) -> bool { matches!(self.route, Route::Chat) @@ -12,22 +50,50 @@ impl Shell { && self.panels.get(&self.panel_key(cx)).files_open } + fn files_layout(&self, visible: f32, cx: &App) -> FilesPanelLayout { + files_panel_layout( + self.viewport_width, + self.eval_tween(self.sidebar_tween, self.sidebar_target()), + self.settings.files_panel_width, + visible, + self.right_pane_open(cx), + self.right_pane_expanded, + ) + } + pub(super) fn files_target(&self, cx: &App) -> f32 { - if self.files_panel_open(cx) { - self.settings - .files_panel_width - .min((self.viewport_width - self.sidebar_target() - CHAT_PANEL_MIN).max(0.0)) - } else { - 0.0 + self.files_layout( + if self.files_panel_open(cx) { + self.settings.files_panel_width + } else { + 0.0 + }, + cx, + ) + .width + } + + pub(super) fn files_visible_width(&self, cx: &App) -> f32 { + if !matches!(self.route, Route::Chat) || self.active_chat.is_empty() { + return 0.0; } + self.files_layout(self.eval_tween(self.files_tween, self.files_target(cx)), cx) + .width } pub(super) fn files_reserved_width(&self, cx: &App) -> f32 { - if matches!(self.route, Route::Chat) { - self.eval_tween(self.files_tween, self.files_target(cx)) - } else { - 0.0 - } + self.files_layout(self.files_visible_width(cx), cx).reserved + } + + pub(super) fn files_overlay_width(&self, cx: &App) -> f32 { + let layout = self.files_layout(self.files_visible_width(cx), cx); + if layout.overlay { layout.width } else { 0.0 } + } + + fn clear_surface_transitions(&mut self) { + self.right_tween = None; + self.main_takeover_tween = None; + self.right_takeover_content_tween = None; } pub(super) fn add_files_surface(&mut self, window: &mut Window, cx: &mut Context) { @@ -61,10 +127,11 @@ impl Shell { self.files.insert(key.clone(), files); self.files_subs.insert(key.clone(), sub); } - let from = self.files_reserved_width(cx); + let from = self.files_visible_width(cx); let was_open = self.files_panel_open(cx); self.panels.update(&key, |p| p.files_open = true); if !was_open { + self.clear_surface_transitions(); self.files_tween = Some(WidthTween::new(from, self.files_target(cx))); } if let Some(files) = self.files.get(&key).cloned() { @@ -83,9 +150,10 @@ impl Shell { self.add_files_surface(window, cx); return; } - let from = self.files_reserved_width(cx); + let from = self.files_visible_width(cx); self.panels .update(&self.panel_key(cx), |p| p.files_open = false); + self.clear_surface_transitions(); self.files_tween = Some(WidthTween::new(from, 0.0)); window.focus(&self.composer.focus_handle(cx), cx); if self.right_pane_open(cx) { @@ -104,6 +172,7 @@ impl Shell { - f32::from(event.event.position.x)) .clamp(FILES_PANEL_MIN, FILES_PANEL_MAX); self.files_tween = None; + self.clear_surface_transitions(); self.schedule_save(cx); cx.notify(); } @@ -123,10 +192,12 @@ impl Shell { let target = self.files_target(cx); let content_width = stable_panel_content_width(target, self.active_tween_endpoints(self.files_tween)); + let overlay = self.files_layout(target, cx).overlay; let inner = div() .w(px(content_width)) .h_full() .pt(px(Theme::TITLEBAR_HEIGHT)) + .occlude() .border_l_1() .border_color(theme.border) .bg(theme.bg) @@ -136,7 +207,14 @@ impl Shell { .h_full() .flex_none() .relative() - .child(self.pane_container(self.files_tween, target, inner.into_any_element())) + .when(overlay, |panel| panel.absolute().right_0().top_0()) + .child( + div() + .h_full() + .w(px(self.files_visible_width(cx))) + .overflow_hidden() + .child(inner), + ) .when( self.files_panel_open(cx) && !self.tween_active(self.files_tween), |panel| { @@ -160,6 +238,69 @@ mod tests { use super::*; use gpui::{AppContext, TestAppContext}; + #[test] + fn files_layout_reserves_space_or_overlays_without_squeezing_the_chat() { + let docked = files_panel_layout(1400.0, 256.0, 286.0, 286.0, true, false); + assert_eq!( + docked, + FilesPanelLayout { + width: 286.0, + reserved: 286.0, + overlay: false + } + ); + let narrow = files_panel_layout(1100.0, 256.0, 286.0, 286.0, true, false); + assert_eq!( + narrow, + FilesPanelLayout { + width: 286.0, + reserved: 0.0, + overlay: true + } + ); + assert_eq!(right_pane_max_width(1100.0 - narrow.reserved, 256.0), 544.0); + // Without a surface, the same window can dock Files beside the chat. + assert!(!files_panel_layout(1100.0, 256.0, 286.0, 286.0, false, false).overlay); + // Takeover may collapse the chat but reserves room for Files. + let expanded = files_panel_layout(1100.0, 256.0, 286.0, 286.0, true, true); + assert!(!expanded.overlay); + assert_eq!( + right_pane_takeover_width(1100.0 - expanded.reserved, 256.0), + 558.0 + ); + } + + #[test] + fn files_layout_keeps_its_mode_through_animation_and_clamps_tiny_windows() { + for width in [0.0, 1.0, 140.0, 286.0] { + let layout = files_panel_layout(1100.0, 256.0, 286.0, width, true, false); + assert!(layout.overlay); + assert_eq!(layout.width, width); + assert_eq!(layout.reserved, 0.0); + } + for viewport in [0.0, 120.0, 280.0, 600.0, 1000.0, 1600.0] { + for sidebar in [0.0, 256.0, 400.0] { + for surfaces in [false, true] { + for expanded in [false, true] { + let layout = + files_panel_layout(viewport, sidebar, 286.0, 286.0, surfaces, expanded); + assert!(layout.width >= 0.0 && layout.width <= viewport); + assert!(layout.reserved >= 0.0 && layout.reserved <= layout.width); + if !layout.overlay { + let content = viewport - sidebar - layout.reserved; + let required = if surfaces { + RIGHT_PANE_MIN + if expanded { 0.0 } else { CHAT_PANEL_MIN } + } else { + CHAT_PANEL_MIN + }; + assert!(content >= required); + } + } + } + } + } + } + #[gpui::test] fn explorer_and_editor_panels_have_independent_session_lifetimes(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index bd98cdc93..2ede0fdd3 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -219,7 +219,7 @@ impl Shell { } else { content_left }; - let files_width = self.files_reserved_width(cx); + let files_width = self.files_visible_width(cx); let files_slot = files_width.max(28.0); let trailing: Option = if on_canvas { None @@ -233,7 +233,9 @@ impl Shell { .flex_row() .items_center(); if right_open { - let right_now = self.eval_tween(self.right_tween, self.right_target(cx)); + let right_now = (self.eval_tween(self.right_tween, self.right_target(cx)) + - self.files_overlay_width(cx)) + .max(0.0); let pr = self.titlebar_right_pad(TITLEBAR_ACTION_EDGE_INSET); // The row's own left padding is part of its content box: a strip // wider than what's left after it overflows and clips at the right @@ -304,14 +306,37 @@ impl Shell { .flex() .items_center() .justify_end() - .child(header_icon_button( - "toggle-files-panel", - icons::FOLDER_WITH_FILES, - &theme, - cx.listener(|this, _, window, cx| { - this.toggle_files_panel(window, cx) + .when(files_width >= 120.0, |slot| { + slot.border_l_1() + .border_color(theme.border) + .pl(px(10.0)) + .child( + div() + .flex_1() + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child("Files"), + ) + }) + .child( + header_icon_button( + "toggle-files-panel", + icons::FOLDER_WITH_FILES, + &theme, + cx.listener(|this, _, window, cx| { + this.toggle_files_panel(window, cx) + }), + ) + .role(gpui::Role::Button) + .aria_label(if self.files_panel_open(cx) { + "Hide files panel" + } else { + "Show files panel" + }) + .when(self.files_panel_open(cx), |button| { + button.bg(crate::theme::wash(0.09)) }), - )), + ), ) .into_any_element(), ) From e39a75c202024fcafa23290f2f4165f846f38673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 16:45:08 -0300 Subject: [PATCH 04/13] feat(files): synchronize explorer selection with file tabs --- crates/ui/src/browser/view.rs | 8 +- crates/ui/src/files/mod.rs | 47 ++- crates/ui/src/files/preview.rs | 14 + crates/ui/src/files/search.rs | 192 +++++++-- crates/ui/src/settings.rs | 7 +- crates/ui/src/shell.rs | 53 ++- crates/ui/src/shell/files_panel.rs | 52 ++- .../src/shell/files_panel_workspace_tests.rs | 377 ++++++++++++++++++ 8 files changed, 687 insertions(+), 63 deletions(-) create mode 100644 crates/ui/src/shell/files_panel_workspace_tests.rs diff --git a/crates/ui/src/browser/view.rs b/crates/ui/src/browser/view.rs index b1931ffb9..3f473ea0e 100644 --- a/crates/ui/src/browser/view.rs +++ b/crates/ui/src/browser/view.rs @@ -135,7 +135,10 @@ impl BrowserSurface { }; content = content.child( div() - .id(gpui::SharedString::from(format!("preview-row-{}", service.id))) + .id(gpui::SharedString::from(format!( + "preview-row-{}", + service.id + ))) .w_full() .h(px(56.0)) .px(px(14.0)) @@ -527,7 +530,8 @@ impl Render for BrowserSurface { move |bounds, _, window, cx| { let native = std::rc::Rc::downgrade(&native); let mut mask = window.content_mask().bounds; - let right = (window.viewport_size().width - right_occlusion).max(mask.left()); + let right = + (window.viewport_size().width - right_occlusion).max(mask.left()); mask.size.width = mask.size.width.min(right - mask.left()); let dragging = cx.has_active_drag(); window.on_present(move || { diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index cf5b139a1..1a5a7bc90 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -182,6 +182,7 @@ pub struct FilesSurface { editor_path: Option, request_context: Option, target_change_pending: bool, + selected_editor_path: Option, pending_request_context: Option, tree: FileTreeModel, tree_list: ListState, @@ -467,6 +468,7 @@ impl FilesSurface { editor_path: editor_path.clone(), request_context: None, target_change_pending: false, + selected_editor_path: None, pending_request_context: None, tree: FileTreeModel::with_include_ignored(show_all_files), tree_list: ListState::new(0, ListAlignment::Top, px(560.0)), @@ -721,16 +723,29 @@ impl FilesSurface { window.defer(cx, move |window, cx| focus.focus(window, cx)); } + /// Synchronize selection without replacing the user's current search. pub(crate) fn reveal_file(&mut self, path: String, cx: &mut Context) { - self.reveal_search_result( - zeron_proto::WorkspaceFileSearchMatch { - name: path.rsplit('/').next().unwrap_or(&path).to_string(), - path, - kind: zeron_proto::WorkspaceEntryKind::File, - score: 0, - }, - cx, - ); + if self.selected_editor_path.as_deref() == Some(&path) { + return; + } + if self.request_context.is_none() || self.state.read(cx).engine().is_none() { + return; + } + self.selected_editor_path = Some(path.clone()); + self.reveal_path(path, false, cx); + } + + pub(crate) fn reveal_file_explicit(&mut self, path: String, cx: &mut Context) { + self.selected_editor_path = None; + self.search.update(cx, |search, cx| search.set_text("", cx)); + self.reveal_file(path, cx); + } + + pub(crate) fn is_current_target(&self, cx: &gpui::App) -> bool { + self.request_context.is_some() + && self.request_context + == FilesRequestContext::for_chat(self.state.read(cx), &self.chat_id) + && !self.target_change_pending } fn toggle_ignored(&mut self, cx: &mut Context) { @@ -741,6 +756,8 @@ impl FilesSurface { fn apply_show_all_files(&mut self, show_all_files: bool, cx: &mut Context) { if self.tree.set_include_ignored(show_all_files) { + self.selected_editor_path = None; + self.cancel_reveal(); self.loads.clear(); self.error = None; self.sync_tree_list(); @@ -889,6 +906,15 @@ impl FilesSurface { self.editor_context_menu = crate::popover::Popup::default(); self.preview.reset(); self.tree.reset(); + self.selected_editor_path = None; + self.cancel_reveal(); + self.search_state.task = None; + self.search_state.generation = self.search_state.generation.wrapping_add(1); + self.search_state.query.clear(); + self.search_state.results.clear(); + self.search_state.loading = false; + self.search_state.error = None; + self.reset_search_results(); self.sync_tree_list(); self.error = if next.is_none() { Some("No workspace available for this chat.".into()) @@ -897,6 +923,9 @@ impl FilesSurface { }; self.request_context = next; self.started = false; + if !self.search.read(cx).text().trim().is_empty() { + self.on_search_edited(cx); + } } fn tree_has_content(&self) -> bool { diff --git a/crates/ui/src/files/preview.rs b/crates/ui/src/files/preview.rs index d2fd44f19..f9ac75913 100644 --- a/crates/ui/src/files/preview.rs +++ b/crates/ui/src/files/preview.rs @@ -3194,6 +3194,20 @@ mod tests { #[cfg(test)] impl FilesSurface { + pub(crate) fn test_document_text(&self, path: &str) -> Option { + self.preview + .documents + .get(path)? + .file + .as_ref()? + .text + .clone() + } + + pub(crate) fn test_document_phase(&self, path: &str) -> Option { + Some(format!("{:?}", self.preview.documents.get(path)?.phase)) + } + pub(crate) fn seed_pending_exit_test_document(&mut self, failed: bool) { let mut document = FileDocument::loading(DocumentKey { chat_id: "test".into(), diff --git a/crates/ui/src/files/search.rs b/crates/ui/src/files/search.rs index d36c2e38f..d90799891 100644 --- a/crates/ui/src/files/search.rs +++ b/crates/ui/src/files/search.rs @@ -252,6 +252,9 @@ pub(super) struct FileSearchState { pub active: usize, pub task: Option>, pub reveal_task: Option>, + reveal_generation: u64, + reveal_opens_file: bool, + reveal_scroll_pending: bool, tree: SearchTreeModel, } @@ -271,6 +274,9 @@ impl FilesSurface { if self.search_state.query == query { return; } + if self.search_state.reveal_opens_file { + self.cancel_reveal(); + } self.search_state.generation = self.search_state.generation.wrapping_add(1); self.search_state.query = query.clone(); self.search_state.active = 0; @@ -281,6 +287,10 @@ impl FilesSurface { self.search_state.results.clear(); self.search_state.tree.clear(); self.search_list.reset(0); + if self.search_state.reveal_scroll_pending { + self.search_state.reveal_scroll_pending = false; + self.reveal_tree_selection(); + } self.search.update(cx, |search, cx| { search.set_mention_controls(false, false, cx) }); @@ -310,14 +320,16 @@ impl FilesSurface { include_ignored: self.tree.include_ignored(), limit: Some(SEARCH_RESULT_LIMIT as u16), }; - let client = WorkspaceFilesClient::new(engine, context); + let client = WorkspaceFilesClient::new(engine, context.clone()); self.search_state.task = Some(cx.spawn(async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(200)) .await; let result = client.search(request).await; let _ = this.update(cx, |surface, cx| { - if !surface.search_state.accepts(generation, &query) { + if !surface.search_state.accepts(generation, &query) + || surface.request_context.as_ref() != Some(&context) + { return; } surface.search_state.loading = false; @@ -386,48 +398,73 @@ impl FilesSurface { self.reveal_search_result(row.as_match(), cx); } + pub(super) fn reset_search_results(&mut self) { + self.search_state.tree.clear(); + self.search_list.reset(0); + } + + pub(super) fn cancel_reveal(&mut self) { + self.search_state.reveal_generation = self.search_state.reveal_generation.wrapping_add(1); + self.search_state.reveal_task = None; + self.search_state.reveal_opens_file = false; + self.search_state.reveal_scroll_pending = false; + } + pub(super) fn reveal_search_result( &mut self, result: WorkspaceFileSearchMatch, cx: &mut Context, ) { + self.reveal_path(result.path, result.kind == WorkspaceEntryKind::File, cx); + } + + pub(super) fn reveal_path(&mut self, path: String, open: bool, cx: &mut Context) { + self.cancel_reveal(); let Some(context) = self.request_context.clone() else { return; }; let Some(engine) = self.state.read(cx).engine().cloned() else { return; }; - let mut directories = vec![String::new()]; let mut ancestors = Vec::new(); - let mut current = parent_path(&result.path); - while let Some(path) = current { - if path.is_empty() { + let mut current = parent_path(&path); + while let Some(directory) = current { + if directory.is_empty() { break; } - ancestors.push(path.clone()); - current = parent_path(&path); + ancestors.push(directory.clone()); + current = parent_path(&directory); } ancestors.reverse(); - directories.extend(ancestors.clone()); + let directories = std::iter::once(String::new()) + .chain(ancestors.clone()) + .collect::>(); let generation = self.tree.generation(); + let reveal_generation = self.search_state.reveal_generation; let include_ignored = self.tree.include_ignored(); let client = WorkspaceFilesClient::new(engine, context.clone()); + self.search_state.reveal_opens_file = open; self.search_state.reveal_task = Some(cx.spawn(async move |this, cx| { let mut pages = Vec::with_capacity(directories.len()); - for directory in directories { + for (index, directory) in directories.into_iter().enumerate() { + // Walk through pagination until the next ancestor (or file) is found. + let required = ancestors.get(index).unwrap_or(&path).clone(); match client - .list_directory(ListWorkspaceDirectoryRequest { - target: context.target.clone(), - directory, - include_ignored, - cursor: None, - }) + .list_directory_snapshot( + ListWorkspaceDirectoryRequest { + target: context.target.clone(), + directory, + include_ignored, + cursor: None, + }, + &[required], + ) .await { Ok(page) => pages.push(page), Err(error) => { let _ = this.update(cx, |surface, cx| { - if surface.tree.generation() == generation { + if surface.accepts_reveal(&context, generation, reveal_generation) { surface.search_state.error = Some(error.to_string().into()); cx.notify(); } @@ -437,7 +474,7 @@ impl FilesSurface { } } let _ = this.update(cx, |surface, cx| { - if surface.tree.generation() != generation { + if !surface.accepts_reveal(&context, generation, reveal_generation) { return; } for (index, page) in pages.into_iter().enumerate() { @@ -446,20 +483,39 @@ impl FilesSurface { surface.tree.expand(next); } } - surface.tree.select(result.path.clone()); + surface.tree.select(path.clone()); surface.sync_tree_list(); - surface - .search - .update(cx, |search, cx| search.set_text("", cx)); - surface.reveal_tree_selection(); - if result.kind != WorkspaceEntryKind::Directory { - surface.open_tree_file(result.path.clone(), cx); - } - cx.notify(); + surface.search_state.reveal_opens_file = false; + surface.finish_reveal(path, open, cx); }); })); } + fn accepts_reveal( + &self, + context: &super::client::FilesRequestContext, + tree_generation: u64, + reveal_generation: u64, + ) -> bool { + self.request_context.as_ref() == Some(context) + && self.tree.generation() == tree_generation + && self.search_state.reveal_generation == reveal_generation + } + + fn finish_reveal(&mut self, path: String, open: bool, cx: &mut Context) { + if open { + self.selected_editor_path = Some(path.clone()); + self.search.update(cx, |search, cx| search.set_text("", cx)); + self.open_tree_file(path, cx); + } + if self.search_state.query.is_empty() { + self.reveal_tree_selection(); + } else { + self.search_state.reveal_scroll_pending = true; + } + cx.notify(); + } + pub(super) fn render_search_results(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); if let Some(error) = self.search_state.error.clone() { @@ -709,3 +765,85 @@ mod tests { assert!(tree.is_expanded("src")); } } + +#[cfg(test)] +mod reveal_tests { + use super::*; + use crate::files::{FilesEvent, client::FilesRequestContext}; + use gpui::{AppContext, TestAppContext}; + use std::{cell::RefCell, rc::Rc}; + + fn context(checkout: &str) -> FilesRequestContext { + FilesRequestContext { + target: zeron_proto::WorkspaceTarget { + chat_id: Some("chat".into()), + space_id: None, + checkout_path: None, + }, + target_device_id: None, + cwd: format!("/workspace/{checkout}"), + checkout_id: Some(checkout.into()), + } + } + + #[gpui::test] + fn selecting_a_tab_preserves_search_and_never_opens_another_file(cx: &mut TestAppContext) { + let surface = cx.new(|cx| { + let state = cx.new(|_| crate::state::AppState::new()); + FilesSurface::new_explorer(state, "chat".into(), false, cx) + }); + let opened = Rc::new(RefCell::new(Vec::new())); + let events = opened.clone(); + let _sub = cx.update(|cx| { + cx.subscribe(&surface, move |_, event, _| { + if let FilesEvent::OpenFile(path) = event { + events.borrow_mut().push(path.clone()); + } + }) + }); + surface.update(cx, |surface, cx| { + surface + .search + .update(cx, |search, cx| search.set_text("config", cx)); + }); + surface.update(cx, |surface, cx| { + surface.finish_reveal("src/main.rs".into(), false, cx); + assert_eq!(surface.search.read(cx).text(), "config"); + assert_eq!(surface.search_state.query, "config"); + assert!(surface.search_state.reveal_scroll_pending); + }); + assert!(opened.borrow().is_empty()); + surface.update(cx, |surface, cx| { + surface.finish_reveal("src/config.rs".into(), true, cx); + }); + assert_eq!(*opened.borrow(), ["src/config.rs"]); + surface.read_with(cx, |surface, cx| { + assert!(surface.search.read(cx).text().is_empty()); + assert!(!surface.search_state.reveal_scroll_pending); + }); + } + + #[gpui::test] + fn checkout_switch_and_newer_reveal_reject_old_search_results(cx: &mut TestAppContext) { + let surface = cx.new(|cx| { + let state = cx.new(|_| crate::state::AppState::new()); + FilesSurface::new_explorer(state, "chat".into(), false, cx) + }); + surface.update(cx, |surface, cx| { + let first = context("first"); + surface.apply_target(Some(first.clone()), cx); + let tree_generation = surface.tree.generation(); + let reveal_generation = surface.search_state.reveal_generation; + assert!(surface.accepts_reveal(&first, tree_generation, reveal_generation)); + surface.cancel_reveal(); + assert!(!surface.accepts_reveal(&first, tree_generation, reveal_generation)); + let reveal_generation = surface.search_state.reveal_generation; + surface.search_state.query = "config".into(); + let search_generation = surface.search_state.generation; + surface.apply_target(Some(context("second")), cx); + assert!(!surface.accepts_reveal(&first, tree_generation, reveal_generation)); + assert!(!surface.search_state.accepts(search_generation, "config")); + assert!(surface.search_state.results.is_empty()); + }); + } +} diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 6f4ed1ab3..5e5a9a52b 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -30,12 +30,13 @@ pub const SIDEBAR_MIN: f32 = 208.0; pub const SIDEBAR_MAX: f32 = 400.0; pub const SIDEBAR_DEFAULT: f32 = 256.0; -/// Right ("Changes") pane drag-resize floor and default (px). Its runtime -/// maximum is the window space remaining after the left sidebar and the -/// conversation's [`CHAT_PANEL_MIN`] reservation. +/// Independent file explorer width preference and drag bounds (px). pub const FILES_PANEL_DEFAULT: f32 = 286.0; pub const FILES_PANEL_MIN: f32 = 220.0; pub const FILES_PANEL_MAX: f32 = 440.0; + +/// Surface pane floor and default (px). Runtime sizing also reserves space +/// for the conversation and any docked file explorer. pub const RIGHT_PANE_MIN: f32 = 360.0; pub const RIGHT_PANE_DEFAULT: f32 = 520.0; /// Minimum width retained for the conversation when the right pane is open. diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 1f61dab99..2c01c4e9b 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1670,6 +1670,7 @@ impl Shell { // ---- splash ---- fn on_state_changed(&mut self, state: &Entity, cx: &mut Context) { + self.prune_file_explorers(cx); if let Some(notice) = state.update(cx, |state, _| state.take_deep_link_notice()) { self.sidebar_notice = Some(notice.into()); } @@ -2232,6 +2233,7 @@ impl Shell { RightSurface::Subagent(_) | RightSurface::Browser(_) => {} RightSurface::Picker => {} } + self.sync_explorer_selection(cx); cx.notify(); } @@ -2415,28 +2417,39 @@ impl Shell { let sub = cx.subscribe_in( &file, window, - move |this: &mut Self, _, event, window, cx| match event { - FilesEvent::OpenFile(path) => this.add_file_surface(path.clone(), window, cx), - FilesEvent::RevealFile(path) => { - this.add_files_surface(window, cx); - if let Some(files) = this.files.get(&this.panel_key(cx)).cloned() { - files.update(cx, |files, cx| files.reveal_file(path.clone(), cx)); - } - } - FilesEvent::TitleChanged => cx.notify(), - FilesEvent::FileRenamed { old_path, new_path } => { - this.rename_file_surface(id, &event_panel_key, old_path, new_path, cx) - } - FilesEvent::WordWrapChanged(word_wrap) => { - this.set_files_word_wrap(*word_wrap, window, cx) - } - FilesEvent::ShowAllFilesChanged(show_all_files) => { - this.set_files_show_all(*show_all_files, cx) + move |this: &mut Self, source, event, window, cx| { + if matches!(event, FilesEvent::OpenFile(_) | FilesEvent::RevealFile(_)) + && !this.accepts_file_navigation(&event_panel_key, &source, cx) + { + return; } - FilesEvent::CloseReady => { - this.on_file_close_ready(RightSurface::File(id), &event_panel_key, cx) + match event { + FilesEvent::OpenFile(path) => this.add_file_surface(path.clone(), window, cx), + FilesEvent::RevealFile(path) => { + this.add_files_surface(window, cx); + if let Some(files) = this.files.get(&this.panel_key(cx)).cloned() { + files.update(cx, |files, cx| { + files.reveal_file_explicit(path.clone(), cx) + }); + } + } + FilesEvent::TitleChanged => cx.notify(), + FilesEvent::FileRenamed { old_path, new_path } => { + this.rename_file_surface(id, &event_panel_key, old_path, new_path, cx) + } + FilesEvent::WordWrapChanged(word_wrap) => { + this.set_files_word_wrap(*word_wrap, window, cx) + } + FilesEvent::ShowAllFilesChanged(show_all_files) => { + this.set_files_show_all(*show_all_files, cx) + } + FilesEvent::CloseReady => { + this.on_file_close_ready(RightSurface::File(id), &event_panel_key, cx) + } + FilesEvent::CloseCancelled => { + this.cancel_file_close(RightSurface::File(id), cx) + } } - FilesEvent::CloseCancelled => this.cancel_file_close(RightSurface::File(id), cx), }, ); self.file_surfaces.insert(id, file); diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 22dd7c997..b643d6882 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -96,6 +96,47 @@ impl Shell { self.right_takeover_content_tween = None; } + pub(super) fn accepts_file_navigation( + &self, + owner: &str, + source: &Entity, + cx: &App, + ) -> bool { + matches!(self.route, Route::Chat) + && self.panel_key(cx) == owner + && source.read(cx).is_current_target(cx) + } + + pub(super) fn prune_file_explorers(&mut self, cx: &mut Context) { + let state = self.state.read(cx); + if !state.chats_synced { + return; + } + let live = state + .chats + .iter() + .filter(|chat| !chat.archived) + .map(|chat| chat.id.as_str()) + .collect::>(); + for key in self.files.keys().filter(|key| !live.contains(key.as_str())) { + self.panels.update(key, |panels| panels.files_open = false); + } + self.files.retain(|key, _| live.contains(key.as_str())); + self.files_subs.retain(|key, _| live.contains(key.as_str())); + } + + pub(super) fn sync_explorer_selection(&mut self, cx: &mut Context) { + let RightSurface::File(id) = self.resolved_right_active(cx) else { + return; + }; + let Some(path) = self.file_surface_paths.get(&id).cloned() else { + return; + }; + if let Some(files) = self.files.get(&self.panel_key(cx)).cloned() { + files.update(cx, |files, cx| files.reveal_file(path, cx)); + } + } + pub(super) fn add_files_surface(&mut self, window: &mut Window, cx: &mut Context) { if self.active_chat.is_empty() { return; @@ -114,8 +155,10 @@ impl Shell { let sub = cx.subscribe_in( &files, window, - move |this: &mut Self, _, event, window, cx| match event { - FilesEvent::OpenFile(path) if this.panel_key(cx) == owner => { + move |this: &mut Self, source, event, window, cx| match event { + FilesEvent::OpenFile(path) + if this.accepts_file_navigation(&owner, &source, cx) => + { this.add_file_surface(path.clone(), window, cx); } FilesEvent::ShowAllFilesChanged(show_all) => { @@ -189,6 +232,7 @@ impl Shell { if let Some(files) = &content { files.update(cx, |files, cx| files.ensure_loaded(cx)); } + self.sync_explorer_selection(cx); let target = self.files_target(cx); let content_width = stable_panel_content_width(target, self.active_tween_endpoints(self.files_tween)); @@ -367,3 +411,7 @@ mod tests { .unwrap(); } } + +#[cfg(all(test, target_os = "linux"))] +#[path = "files_panel_workspace_tests.rs"] +mod workspace_tests; diff --git a/crates/ui/src/shell/files_panel_workspace_tests.rs b/crates/ui/src/shell/files_panel_workspace_tests.rs new file mode 100644 index 000000000..61b0ad493 --- /dev/null +++ b/crates/ui/src/shell/files_panel_workspace_tests.rs @@ -0,0 +1,377 @@ +//! Exercise the explorer and editors against an isolated real workspace/RPC. +//! Set ZERON_FILES_CAPTURES to a directory to run on X11 and capture the fixture. +use super::*; +use gpui::{AppContext, AsyncApp, WindowHandle}; +use std::{path::Path, sync::Arc}; + +struct ClosedFixture; +impl Render for ClosedFixture { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full() + } +} + +async fn pause(cx: &mut AsyncApp) { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; +} + +async fn wait_for( + window: WindowHandle, + cx: &mut AsyncApp, + label: &str, + predicate: impl Fn(&Shell, &App) -> bool, +) { + for _ in 0..200 { + gpui::AnyWindowHandle::from(window) + .update(cx, |_, window, cx| { + window.refresh(); + let _ = window.draw(cx); + }) + .unwrap(); + if window + .update(cx, |shell, window, cx| { + window.refresh(); + predicate(shell, cx) + }) + .unwrap() + { + return; + } + pause(cx).await; + } + panic!("timed out waiting for {label}"); +} + +async fn frame(window: WindowHandle, cx: &mut AsyncApp, output: Option<&Path>, name: &str) { + for _ in 0..6 { + pause(cx).await; + } + gpui::AnyWindowHandle::from(window) + .update(cx, |_, window, cx| { + window.refresh(); + let _ = window.draw(cx); + }) + .unwrap(); + if let Some(output) = output { + std::fs::create_dir_all(output).unwrap(); + let status = std::process::Command::new("import") + .args(["-window", "Files panel fixture"]) + .arg(output.join(format!("{name}.png"))) + .status() + .unwrap(); + assert!(status.success(), "fixture capture failed"); + } +} + +#[test] +fn files_panel_workspace_navigation_and_external_updates() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project"); + std::fs::create_dir_all(project.join("src/nested")).unwrap(); + std::fs::write( + project.join("src/nested/main.rs"), + "fn main() { println!(\"Hello\"); }\n", + ) + .unwrap(); + std::fs::write( + project.join("README.md"), + "# Workspace\n\nAn independent file explorer.\n", + ) + .unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let core = runtime + .block_on(async { + zeron_engine::EngineCore::assemble( + &directory.path().join("engine"), + Arc::new(zeron_engine::default_registry()), + zeron_proto::HarnessId::Mock, + None, + ) + }) + .unwrap(); + core.workspace + .create_space( + "project", + &core.device_id, + &project.to_string_lossy(), + Some("Workspace".into()), + false, + ) + .unwrap(); + for id in ["first", "second"] { + core.workspace + .create_chat( + id, + Some("project"), + None, + None, + Some(project.to_string_lossy().into_owned()), + ) + .unwrap(); + core.workspace + .rename_chat(id, &format!("Explore files · {id}")) + .unwrap(); + } + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let _ipc = runtime + .block_on(zeron_engine::serve_ipc(port, core.rpc_service())) + .unwrap(); + let output = std::env::var_os("ZERON_FILES_CAPTURES").map(PathBuf::from); + let application = if output.is_some() { + gpui_platform::application() + } else { + gpui_platform::headless() + }; + application + .with_assets(crate::icons::Assets) + .run(move |cx| { + gpui_tokio::init(cx); + gpui_base::init(cx); + let data = directory.path().join("ui"); + let settings = UiSettings::default(); + settings::init(settings.clone(), &data, cx); + let fonts = crate::typography::register_fonts(cx); + crate::typography::init( + settings.ui_font_family.clone(), + settings.ui_font_size, + fonts, + cx, + ); + crate::theme_library::init(data.clone(), cx); + crate::appearance::init( + crate::appearance::AppearanceMode::Dark, + settings.theme_selection.clone(), + settings.accent, + settings.surface, + cx, + ); + crate::history::init( + settings.git_history_columns, + settings.git_history_column_widths, + settings.git_history_column_order, + settings.git_history_author_display, + cx, + ); + crate::composer::init(cx, settings.composer_send_behavior); + crate::terminal::panel::init(cx); + crate::app_menus::init(cx); + let boot = EngineBootConfig { + data_dir: data, + ipc_port: port, + edge_url: String::new(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }; + let state = cx.new(|_| AppState::new()); + let window = cx + .open_window( + gpui::WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds::new( + gpui::Point::default(), + gpui::size(px(1400.0), px(800.0)), + ))), + ..Default::default() + }, + |window, cx| { + window.set_window_title("Files panel fixture"); + cx.new(|cx| Shell::new(state.clone(), boot.clone(), cx)) + }, + ) + .unwrap(); + AppState::bootstrap(state.clone(), boot, cx); + cx.spawn(async move |cx| { + // Keep the temporary store, workspace and daemon alive throughout the UI run. + let _directory = directory; + wait_for(window, cx, "engine and chats", |shell, cx| { + shell.state.read(cx).engine().is_some() && shell.state.read(cx).chats.len() == 2 + }) + .await; + state.update(cx, |state, cx| state.select_chat(Some("first".into()), cx)); + wait_for(window, cx, "first session", |shell, _| { + shell.active_chat == "first" + }) + .await; + window + .update(cx, |shell, window, cx| { + shell.splash = SplashPhase::Gone; + shell.add_files_surface(window, cx); + }) + .unwrap(); + wait_for(window, cx, "root listing", |shell, cx| { + shell.files["first"].read(cx).tree().node("src").is_some() + }) + .await; + frame(window, cx, output.as_deref(), "01-chat-files").await; + window + .update(cx, |shell, window, cx| { + shell.add_file_surface("src/nested/main.rs".into(), window, cx) + }) + .unwrap(); + wait_for(window, cx, "nested file and selection", |shell, cx| { + shell.files["first"].read(cx).tree().selected() == Some("src/nested/main.rs") + && shell.file_surfaces.values().any(|file| { + file.read(cx) + .test_document_text("src/nested/main.rs") + .is_some() + }) + }) + .await; + window + .update(cx, |shell, _, cx| { + let tree = shell.files["first"].read(cx).tree(); + assert!(tree.is_expanded("src") && tree.is_expanded("src/nested")); + assert!( + shell.file_surfaces.values().all(|file| file + .read(cx) + .tree() + .visible_rows() + .is_empty()), + "editors must not load hidden trees" + ); + }) + .unwrap(); + frame(window, cx, output.as_deref(), "02-editor-files").await; + window + .update(cx, |shell, _, cx| shell.toggle_right_pane_expand(cx)) + .unwrap(); + frame(window, cx, output.as_deref(), "03-expanded-files").await; + window + .update(cx, |shell, window, cx| { + shell.toggle_right_pane_expand(cx); + window.resize(gpui::size(px(1000.0), px(720.0))); + window.bounds_changed(cx); + }) + .unwrap(); + frame(window, cx, output.as_deref(), "04-narrow-files").await; + window + .update(cx, |shell, _, cx| { + assert!(shell.files_overlay_width(cx) > 0.0); + assert_eq!(shell.files_reserved_width(cx), 0.0); + }) + .unwrap(); + // A browser surface coexists with the explorer and keeps its own tab. + window + .update(cx, |shell, window, cx| { + shell.add_browser_surface(None, window, cx) + }) + .unwrap(); + frame(window, cx, output.as_deref(), "05-browser-files").await; + window + .update(cx, |shell, window, cx| { + shell.add_file_surface("src/nested/main.rs".into(), window, cx); + shell.toggle_files_panel(window, cx); + }) + .unwrap(); + std::fs::write( + project.join("src/nested/main.rs"), + "fn main() { println!(\"Updated\"); }\n", + ) + .unwrap(); + wait_for( + window, + cx, + "document update with explorer hidden", + |shell, cx| { + shell.file_surfaces.values().any(|file| { + file.read(cx) + .test_document_text("src/nested/main.rs") + .is_some_and(|text| text.contains("Updated")) + }) + }, + ) + .await; + // Events queued in an inactive session must never open a tab in another. + let first_explorer = window + .update(cx, |shell, _, _| shell.files["first"].clone()) + .unwrap(); + state.update(cx, |state, cx| state.select_chat(Some("second".into()), cx)); + wait_for(window, cx, "second session", |shell, _| { + shell.active_chat == "second" + }) + .await; + first_explorer.update(cx, |_, cx| { + cx.emit(FilesEvent::OpenFile("README.md".into())) + }); + frame(window, cx, None, "inactive-event").await; + window + .update(cx, |shell, _, _| { + assert!( + !shell + .file_surface_keys + .contains_key(&("second".into(), "README.md".into())) + ) + }) + .unwrap(); + state.update(cx, |state, cx| state.select_chat(Some("first".into()), cx)); + wait_for(window, cx, "restored session", |shell, _| { + shell.active_chat == "first" + }) + .await; + window + .update(cx, |shell, window, cx| { + shell.add_files_surface(window, cx); + assert_eq!(shell.files["first"].entity_id(), first_explorer.entity_id()); + }) + .unwrap(); + std::fs::rename( + project.join("src/nested/main.rs"), + project.join("src/nested/renamed.rs"), + ) + .unwrap(); + wait_for(window, cx, "renamed file and tab", |shell, cx| { + shell + .file_surface_keys + .contains_key(&("first".into(), "src/nested/renamed.rs".into())) + && shell.files["first"].read(cx).tree().selected() + == Some("src/nested/renamed.rs") + }) + .await; + std::fs::remove_file(project.join("src/nested/renamed.rs")).unwrap(); + wait_for(window, cx, "deleted file", |shell, cx| { + shell.files["first"] + .read(cx) + .tree() + .node("src/nested/renamed.rs") + .is_none() + && shell.file_surfaces.values().any(|file| { + file.read(cx) + .test_document_phase("src/nested/renamed.rs") + .as_deref() + == Some("DeletedOnDisk") + }) + }) + .await; + core.workspace.set_chat_archived("first", true).unwrap(); + wait_for(window, cx, "archived explorer cleanup", |shell, _| { + !shell.files.contains_key("first") && !shell.files_subs.contains_key("first") + }) + .await; + drop(first_explorer); + drop(state); + let handle = gpui::AnyWindowHandle::from(window); + // Render an input-free frame before closing. X11's retained + // IME handler otherwise outlives the app's leak detector. + handle + .update(cx, |_, window, cx| { + window.replace_root(cx, |_, _| ClosedFixture); + window.blur(); + window.refresh(); + let _ = window.draw(cx); + }) + .unwrap(); + pause(cx).await; + handle + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + pause(cx).await; + cx.update(|cx| cx.quit()); + }) + .detach(); + }); +} From be87e9c2eb062d8722540325b67c6b92ac3d163c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 16:54:17 -0300 Subject: [PATCH 05/13] fix(files): match sidebar frost and blur floating explorer --- crates/ui/src/shell/files_panel.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index b643d6882..214eca172 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -244,8 +244,19 @@ impl Shell { .occlude() .border_l_1() .border_color(theme.border) - .bg(theme.bg) + // Match the left sidebar's subtle wash over the shell frost. + // An overlay needs its own tint and blur over the covered content. + .bg(if overlay { + theme.glass_overlay() + } else { + crate::theme::wash(0.05) + }) .children(content); + let inner = if overlay { + crate::frost::frosted(0.0, crate::frost::MENU_BLUR, inner).into_any_element() + } else { + inner.into_any_element() + }; div() .id("files-panel") .h_full() From a4f6dca5a5699a985fbd22ebae72600c8f5612ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 17:05:43 -0300 Subject: [PATCH 06/13] fix(layout): align surface tabs with the panel beside Files --- .../src/shell/files_panel_workspace_tests.rs | 22 ++++ crates/ui/src/shell/tabs.rs | 122 +++++++++++++++--- 2 files changed, 124 insertions(+), 20 deletions(-) diff --git a/crates/ui/src/shell/files_panel_workspace_tests.rs b/crates/ui/src/shell/files_panel_workspace_tests.rs index 61b0ad493..b5e9de579 100644 --- a/crates/ui/src/shell/files_panel_workspace_tests.rs +++ b/crates/ui/src/shell/files_panel_workspace_tests.rs @@ -237,6 +237,28 @@ fn files_panel_workspace_navigation_and_external_updates() { }) .unwrap(); frame(window, cx, output.as_deref(), "02-editor-files").await; + window + .update(cx, |shell, window, cx| { + shell.add_file_surface("README.md".into(), window, cx); + }) + .unwrap(); + frame(window, cx, output.as_deref(), "02b-two-file-tabs").await; + window + .update(cx, |shell, window, cx| shell.toggle_files_panel(window, cx)) + .unwrap(); + frame( + window, + cx, + output.as_deref(), + "02c-two-file-tabs-explorer-hidden", + ) + .await; + window + .update(cx, |shell, window, cx| { + shell.toggle_files_panel(window, cx); + shell.add_file_surface("src/nested/main.rs".into(), window, cx); + }) + .unwrap(); window .update(cx, |shell, _, cx| shell.toggle_right_pane_expand(cx)) .unwrap(); diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 2ede0fdd3..373acb61f 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -39,6 +39,28 @@ pub(super) fn right_pane_expand_icon(expanded: bool) -> &'static str { } } +struct PanelTitlebarWidths { + surface_reveal: f32, + files_controls: f32, +} + +fn panel_titlebar_widths( + surfaces_visible: f32, + files_visible: f32, + available: f32, + right_pad: f32, +) -> PanelTitlebarWidths { + // Caption controls occupy the far-right panel first. Subtract their + // clearance once across the combined header, then split it at Files. + // The folder toggle keeps one slot even when its panel is closed. + let files_controls = (files_visible - right_pad).max(28.0); + let surfaces = surfaces_visible + files_visible - right_pad - files_controls; + PanelTitlebarWidths { + surface_reveal: (surfaces.min(available - files_controls) - 28.0).max(0.0), + files_controls, + } +} + impl Shell { /// Navigation requests focus once the destination composer renders. pub(super) fn focus_composer(&mut self, cx: &mut Context) { @@ -220,7 +242,18 @@ impl Shell { content_left }; let files_width = self.files_visible_width(cx); - let files_slot = files_width.max(28.0); + let right_pad = self.titlebar_right_pad(TITLEBAR_ACTION_EDGE_INSET); + // The title row's gaps are outside the fixed-width panel controls. + let gap_budget = if takeover { 8.0 } else { 16.0 }; + let right_visible = (self.eval_tween(self.right_tween, self.right_target(cx)) + - self.files_overlay_width(cx)) + .max(0.0); + let widths = panel_titlebar_widths( + right_visible, + files_width, + self.viewport_width - row_left - right_pad - gap_budget, + right_pad, + ); let trailing: Option = if on_canvas { None } else { @@ -233,19 +266,6 @@ impl Shell { .flex_row() .items_center(); if right_open { - let right_now = (self.eval_tween(self.right_tween, self.right_target(cx)) - - self.files_overlay_width(cx)) - .max(0.0); - let pr = self.titlebar_right_pad(TITLEBAR_ACTION_EDGE_INSET); - // The row's own left padding is part of its content box: a strip - // wider than what's left after it overflows and clips at the right - // edge (flex_none never shrinks) — cap to the available width. The - // row's 8px child gaps sit OUTSIDE the strip's width (one before - // the strip in takeover, two with the title row present): without - // budgeting them the capped strip overflows by exactly one gap and - // the buttons slide right on expand (user report). - let gap_budget = if takeover { 8.0 } else { 16.0 }; - let avail = self.viewport_width - files_slot - row_left - pr - gap_budget; // The right pane's SURFACE TABS (t3 RightPanelTabs) — the diff // options that used to live here moved into the pane's own // second row; expand stays in this band (user request). @@ -254,10 +274,9 @@ impl Shell { // sidebar control. Only the tabs + expand section reveals to // its left; including the toggle in this animated width // compressed both icons into the same clipped box at open. - let animated_width = ((right_now - pr).min(avail) - 28.0).max(0.0); controls = controls.child( div() - .w(px(animated_width)) + .w(px(widths.surface_reveal)) .h_full() .flex_none() .flex() @@ -298,9 +317,7 @@ impl Shell { )) .child( div() - .w(px((files_slot - - self.titlebar_right_pad(TITLEBAR_ACTION_EDGE_INSET)) - .max(28.0))) + .w(px(widths.files_controls)) .h_full() .flex_none() .flex() @@ -349,7 +366,7 @@ impl Shell { .pt(px(Theme::TITLEBAR_TOP_PAD)) .gap(px(8.0)) .pl(px(row_left)) - .pr(px(self.titlebar_right_pad(TITLEBAR_ACTION_EDGE_INSET))) + .pr(px(right_pad)) // In panel takeover the header strip spans the whole band — the // title would sit UNDER it (both flex_none, the row overflows and // paint order stacks them), so it hides for the duration. @@ -408,6 +425,71 @@ impl Shell { } } +#[cfg(test)] +mod panel_titlebar_tests { + use super::*; + + #[test] + fn tabs_align_with_the_panel_for_each_caption_layout_and_files_width() { + let viewport = 1400.0; + for right_pad in [6.0, 40.0, 92.0, 114.0] { + for files in [0.0, 10.0, 28.0, 100.0, 220.0, 286.0, 440.0] { + let widths = panel_titlebar_widths(520.0, files, 1100.0, right_pad); + let controls_left = + viewport - right_pad - widths.files_controls - 28.0 - widths.surface_reveal; + assert_eq!( + controls_left, + viewport - files - 520.0, + "caption clearance {right_pad}, Files width {files}" + ); + assert!(widths.files_controls >= 28.0); + if files >= right_pad + 28.0 { + assert_eq!( + viewport - right_pad - widths.files_controls, + viewport - files + ); + } + } + } + } + + #[test] + fn expanded_tabs_clear_the_left_controls_without_reserving_captions_twice() { + for right_pad in [6.0, 92.0, 114.0] { + let viewport = 1400.0; + let files = 286.0; + // With the sidebar open, the header starts exactly at its seam. + // With it collapsed, leave room for the window/nav controls. + for (sidebar, row_left) in [(256.0, 248.0), (0.0, 180.0)] { + let widths = panel_titlebar_widths( + viewport - sidebar - files, + files, + viewport - row_left - right_pad - 8.0, + right_pad, + ); + let controls_left = + viewport - right_pad - widths.files_controls - 28.0 - widths.surface_reveal; + assert_eq!(controls_left, sidebar.max(row_left + 8.0)); + } + } + } + + #[test] + fn overlaid_files_and_tight_headers_keep_nonnegative_reveal_widths() { + // A 286px overlay covers the right end of a 520px surface. + let widths = panel_titlebar_widths(234.0, 286.0, 600.0, 92.0); + assert_eq!( + 1000.0 - 92.0 - widths.files_controls - 28.0 - widths.surface_reveal, + 480.0 + ); + for available in [-20.0, 0.0, 28.0, 56.0, 100.0] { + let widths = panel_titlebar_widths(0.0, 0.0, available, 114.0); + assert_eq!(widths.surface_reveal, 0.0); + assert_eq!(widths.files_controls, 28.0); + } + } +} + #[cfg(test)] mod cycle_tests { use super::*; From dad1c86d95715da08d4059fab8e4235d8c250395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 17:16:17 -0300 Subject: [PATCH 07/13] fix(shell): keep the surface picker clear of the Files overlay --- crates/ui/src/shell.rs | 3 +++ .../src/shell/files_panel_workspace_tests.rs | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 2c01c4e9b..09b84c9c7 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -7177,6 +7177,9 @@ impl Shell { .items_center() .justify_center() .p(px(16.0)) + // The responsive Files drawer covers the right end of this + // surface. Center the picker in the remaining visible region. + .pr(px(16.0 + self.files_overlay_width(cx))) .child( div() .w_full() diff --git a/crates/ui/src/shell/files_panel_workspace_tests.rs b/crates/ui/src/shell/files_panel_workspace_tests.rs index b5e9de579..4cf0714c1 100644 --- a/crates/ui/src/shell/files_panel_workspace_tests.rs +++ b/crates/ui/src/shell/files_panel_workspace_tests.rs @@ -208,6 +208,27 @@ fn files_panel_workspace_navigation_and_external_updates() { }) .await; frame(window, cx, output.as_deref(), "01-chat-files").await; + window + .update(cx, |shell, window, cx| { + shell.settings.files_panel_width = FILES_PANEL_MAX; + shell.settings.right_pane_width = 760.0; + shell.toggle_right_pane(cx); + window.resize(gpui::size(px(1200.0), px(800.0))); + window.bounds_changed(cx); + }) + .unwrap(); + frame(window, cx, output.as_deref(), "01b-picker-files-overlay").await; + window + .update(cx, |shell, window, cx| { + assert!(shell.files_overlay_width(cx) > 0.0); + assert!(shell.right_surface_rows(cx).is_empty()); + shell.toggle_right_pane(cx); + shell.settings.files_panel_width = FILES_PANEL_DEFAULT; + shell.settings.right_pane_width = RIGHT_PANE_DEFAULT; + window.resize(gpui::size(px(1400.0), px(800.0))); + window.bounds_changed(cx); + }) + .unwrap(); window .update(cx, |shell, window, cx| { shell.add_file_surface("src/nested/main.rs".into(), window, cx) From 9033ff4eb3b6f70228e28ff1b07e6d29e580f91b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 21:33:26 -0300 Subject: [PATCH 08/13] fix(layout): keep the explorer and editor in separate columns --- crates/ui/src/shell.rs | 32 ++-- crates/ui/src/shell/files_panel.rs | 156 +++++++++++------- .../src/shell/files_panel_workspace_tests.rs | 30 +++- crates/ui/src/shell/tabs.rs | 8 +- 4 files changed, 134 insertions(+), 92 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 09b84c9c7..c40d62d7e 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -434,8 +434,8 @@ pub enum Route { /// floor. On unusually small windows this deliberately falls below the right /// pane's preferred minimum: the chat remains usable and the side surface /// yields the scarce space. -fn right_pane_max_width(viewport: f32, sidebar: f32) -> f32 { - (viewport - sidebar - CHAT_PANEL_MIN).max(0.0) +fn right_pane_max_width(viewport: f32, sidebar: f32, chat_floor: f32) -> f32 { + (viewport - sidebar - chat_floor).max(0.0) } /// Width used by right-pane takeover. Unlike manual resizing, takeover is @@ -2005,10 +2005,9 @@ impl Shell { sidebar_now, ) } else { - self.settings.right_pane_width.min(right_pane_max_width( - self.viewport_width - self.files_reserved_width(cx), - sidebar_now, - )) + self.settings + .right_pane_width + .min(self.surface_max_width(cx)) } } } @@ -2023,7 +2022,7 @@ impl Shell { fn toggle_right_pane(&mut self, cx: &mut Context) { // Reverse from the visible width when toggled during an animation. - let from = self.eval_tween(self.right_tween, self.right_target(cx)); + let from = self.right_visible_width(cx); let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); let from_main = conversation_width( self.viewport_width - self.files_reserved_width(cx), @@ -2993,12 +2992,8 @@ impl Shell { ) { let viewport = f32::from(window.viewport_size().width); let width = viewport - self.files_reserved_width(cx) - f32::from(event.event.position.x); - // No arbitrary percentage ceiling, but retain the chat's usable 300px - // floor instead of allowing the conversation to collapse to zero. - let max = right_pane_max_width( - viewport - self.files_reserved_width(cx), - self.sidebar_target(), - ); + // Use the same shared budget as rendering, including compact windows. + let max = self.surface_max_width(cx); self.settings.right_pane_width = if max >= RIGHT_PANE_MIN { width.clamp(RIGHT_PANE_MIN, max) } else { @@ -4165,6 +4160,7 @@ impl Shell { &self, tween: Option, target: f32, + visible: f32, inner: AnyElement, ) -> AnyElement { let takeover_width = self @@ -4177,7 +4173,7 @@ impl Shell { .flex_none() .relative() .overflow_hidden() - .w(px(self.eval_tween(tween, target))) + .w(px(visible)) .child( div() .absolute() @@ -7133,6 +7129,7 @@ impl Shell { self.right_pane_container( self.right_tween, target, + self.right_visible_width(cx), div().h_full().relative().child(panel).into_any_element(), ) } @@ -7177,9 +7174,6 @@ impl Shell { .items_center() .justify_center() .p(px(16.0)) - // The responsive Files drawer covers the right end of this - // surface. Center the picker in the remaining visible region. - .pr(px(16.0 + self.files_overlay_width(cx))) .child( div() .w_full() @@ -9077,11 +9071,11 @@ mod tests { #[test] fn right_pane_ceiling_preserves_the_chat_floor() { - assert_eq!(right_pane_max_width(1200.0, 256.0), 644.0); + assert_eq!(right_pane_max_width(1200.0, 256.0, CHAT_PANEL_MIN), 644.0); assert_eq!(1200.0 - 256.0 - 644.0, CHAT_PANEL_MIN); // The chat floor wins over the right pane's preferred 360px minimum // when the whole window is unusually narrow. - assert_eq!(right_pane_max_width(800.0, 256.0), 244.0); + assert_eq!(right_pane_max_width(800.0, 256.0, CHAT_PANEL_MIN), 244.0); assert_eq!(800.0 - 256.0 - 244.0, CHAT_PANEL_MIN); } diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 214eca172..046c98f5b 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -5,14 +5,13 @@ use crate::settings::{FILES_PANEL_DEFAULT, FILES_PANEL_MAX, FILES_PANEL_MIN}; pub(super) struct FilesPanelResize; -/// Resolve the explorer against the space required by its neighboring panes. -/// `visible` is the sampled animation width; the preferred width determines -/// the breakpoint so an opening drawer never changes modes halfway through. +/// Allocate a real column to Files. Reduce its preferred width before taking +/// space from the chat/editor minima; below those minima, share the shortage +/// proportionally so no open panel covers another. #[derive(Debug, Clone, Copy, PartialEq)] struct FilesPanelLayout { width: f32, - reserved: f32, - overlay: bool, + surface_max: f32, } fn files_panel_layout( @@ -24,22 +23,30 @@ fn files_panel_layout( expanded: bool, ) -> FilesPanelLayout { let available = (viewport - sidebar).max(0.0); - let content_min = if surfaces_open { - RIGHT_PANE_MIN + if expanded { 0.0 } else { CHAT_PANEL_MIN } + let chat_min = if surfaces_open && expanded { + 0.0 } else { CHAT_PANEL_MIN }; - let overlay = available < preferred + content_min; - let max_width = if overlay { - viewport.max(0.0) + let surface_min = if surfaces_open { RIGHT_PANE_MIN } else { 0.0 }; + let scale = if preferred > 0.0 { + (available / (chat_min + surface_min + preferred.min(FILES_PANEL_MIN))).min(1.0) } else { - (available - content_min).max(0.0) + // Preserve the existing chat floor when Files is closed. + 1.0 }; + let max_width = (available - (chat_min + surface_min) * scale).max(0.0); let width = visible.max(0.0).min(max_width); + // As Files animates closed, return its space to the remaining columns + // smoothly instead of changing their minima when the tween finishes. + let content_scale = if preferred > 0.0 { + ((available - width) / (chat_min + surface_min)).min(1.0) + } else { + 1.0 + }; FilesPanelLayout { width, - reserved: if overlay { 0.0 } else { width }, - overlay, + surface_max: right_pane_max_width(viewport - width, sidebar, chat_min * content_scale), } } @@ -54,7 +61,11 @@ impl Shell { files_panel_layout( self.viewport_width, self.eval_tween(self.sidebar_tween, self.sidebar_target()), - self.settings.files_panel_width, + if self.files_panel_open(cx) || self.tween_active(self.files_tween) { + self.settings.files_panel_width + } else { + 0.0 + }, visible, self.right_pane_open(cx), self.right_pane_expanded, @@ -82,12 +93,21 @@ impl Shell { } pub(super) fn files_reserved_width(&self, cx: &App) -> f32 { - self.files_layout(self.files_visible_width(cx), cx).reserved + self.files_visible_width(cx) } - pub(super) fn files_overlay_width(&self, cx: &App) -> f32 { - let layout = self.files_layout(self.files_visible_width(cx), cx); - if layout.overlay { layout.width } else { 0.0 } + pub(super) fn surface_max_width(&self, cx: &App) -> f32 { + self.files_layout(self.files_visible_width(cx), cx) + .surface_max + } + + pub(super) fn right_visible_width(&self, cx: &App) -> f32 { + let available = (self.viewport_width + - self.eval_tween(self.sidebar_tween, self.sidebar_target()) + - self.files_visible_width(cx)) + .max(0.0); + self.eval_tween(self.right_tween, self.right_target(cx)) + .min(available) } fn clear_surface_transitions(&mut self) { @@ -236,7 +256,6 @@ impl Shell { let target = self.files_target(cx); let content_width = stable_panel_content_width(target, self.active_tween_endpoints(self.files_tween)); - let overlay = self.files_layout(target, cx).overlay; let inner = div() .w(px(content_width)) .h_full() @@ -245,24 +264,13 @@ impl Shell { .border_l_1() .border_color(theme.border) // Match the left sidebar's subtle wash over the shell frost. - // An overlay needs its own tint and blur over the covered content. - .bg(if overlay { - theme.glass_overlay() - } else { - crate::theme::wash(0.05) - }) + .bg(crate::theme::wash(0.05)) .children(content); - let inner = if overlay { - crate::frost::frosted(0.0, crate::frost::MENU_BLUR, inner).into_any_element() - } else { - inner.into_any_element() - }; div() .id("files-panel") .h_full() .flex_none() .relative() - .when(overlay, |panel| panel.absolute().right_0().top_0()) .child( div() .h_full() @@ -294,61 +302,81 @@ mod tests { use gpui::{AppContext, TestAppContext}; #[test] - fn files_layout_reserves_space_or_overlays_without_squeezing_the_chat() { + fn files_layout_shrinks_the_tree_before_the_chat_or_editor() { let docked = files_panel_layout(1400.0, 256.0, 286.0, 286.0, true, false); assert_eq!( docked, FilesPanelLayout { width: 286.0, - reserved: 286.0, - overlay: false + surface_max: 558.0, } ); - let narrow = files_panel_layout(1100.0, 256.0, 286.0, 286.0, true, false); + let narrow = files_panel_layout(1200.0, 256.0, 440.0, 440.0, true, false); assert_eq!( narrow, FilesPanelLayout { - width: 286.0, - reserved: 0.0, - overlay: true + width: 284.0, + surface_max: 360.0, } ); - assert_eq!(right_pane_max_width(1100.0 - narrow.reserved, 256.0), 544.0); - // Without a surface, the same window can dock Files beside the chat. - assert!(!files_panel_layout(1100.0, 256.0, 286.0, 286.0, false, false).overlay); - // Takeover may collapse the chat but reserves room for Files. + assert_eq!( + 1200.0 - 256.0 - narrow.width - narrow.surface_max, + CHAT_PANEL_MIN + ); + // Closing the surface or expanding it releases space for the tree. + assert_eq!( + files_panel_layout(1200.0, 256.0, 440.0, 440.0, false, false).width, + 440.0 + ); let expanded = files_panel_layout(1100.0, 256.0, 286.0, 286.0, true, true); - assert!(!expanded.overlay); + assert_eq!(expanded.width, 286.0); + assert_eq!(expanded.surface_max, 558.0); + // Growing the viewport restores the preferred width. assert_eq!( - right_pane_takeover_width(1100.0 - expanded.reserved, 256.0), - 558.0 + files_panel_layout(1600.0, 256.0, 440.0, 440.0, true, false).width, + 440.0 ); } #[test] - fn files_layout_keeps_its_mode_through_animation_and_clamps_tiny_windows() { - for width in [0.0, 1.0, 140.0, 286.0] { - let layout = files_panel_layout(1100.0, 256.0, 286.0, width, true, false); - assert!(layout.overlay); - assert_eq!(layout.width, width); - assert_eq!(layout.reserved, 0.0); + fn files_layout_returns_space_smoothly_during_close() { + let mut previous_chat = 0.0; + for visible in [186.0, 140.0, 84.0, 40.0, 0.0] { + let layout = files_panel_layout(1000.0, 256.0, 286.0, visible, true, false); + let chat = 744.0 - layout.width - layout.surface_max; + assert!(chat >= previous_chat && chat <= CHAT_PANEL_MIN); + previous_chat = chat; } - for viewport in [0.0, 120.0, 280.0, 600.0, 1000.0, 1600.0] { + assert_eq!( + files_panel_layout(1000.0, 256.0, 286.0, 0.0, true, false), + files_panel_layout(1000.0, 256.0, 0.0, 0.0, true, false), + ); + } + + #[test] + fn files_layout_shares_tight_windows_without_covering_any_column() { + let compact = files_panel_layout(1000.0, 256.0, 440.0, 440.0, true, false); + let chat = 1000.0 - 256.0 - compact.width - compact.surface_max; + assert!((compact.width / FILES_PANEL_MIN - chat / CHAT_PANEL_MIN).abs() < 0.001); + assert!((compact.surface_max / RIGHT_PANE_MIN - chat / CHAT_PANEL_MIN).abs() < 0.001); + for viewport in [0.0, 120.0, 280.0, 600.0, 1000.0, 1200.0, 1600.0] { for sidebar in [0.0, 256.0, 400.0] { for surfaces in [false, true] { for expanded in [false, true] { - let layout = - files_panel_layout(viewport, sidebar, 286.0, 286.0, surfaces, expanded); - assert!(layout.width >= 0.0 && layout.width <= viewport); - assert!(layout.reserved >= 0.0 && layout.reserved <= layout.width); - if !layout.overlay { - let content = viewport - sidebar - layout.reserved; - let required = if surfaces { - RIGHT_PANE_MIN + if expanded { 0.0 } else { CHAT_PANEL_MIN } - } else { - CHAT_PANEL_MIN - }; - assert!(content >= required); + for visible in [0.0, 1.0, 140.0, 440.0] { + let layout = files_panel_layout( + viewport, sidebar, 440.0, visible, surfaces, expanded, + ); + let available = (viewport - sidebar).max(0.0); + assert!(layout.width >= 0.0 && layout.width <= visible); + assert!(layout.surface_max >= 0.0); + assert!(layout.width + layout.surface_max <= available + 0.001); + if available > 0.0 && surfaces { + assert!(layout.surface_max > 0.0, "the editor must remain visible"); + if visible > 0.0 { + assert!(layout.width > 0.0, "the tree must remain visible"); + } + } } } } diff --git a/crates/ui/src/shell/files_panel_workspace_tests.rs b/crates/ui/src/shell/files_panel_workspace_tests.rs index 4cf0714c1..e70984c63 100644 --- a/crates/ui/src/shell/files_panel_workspace_tests.rs +++ b/crates/ui/src/shell/files_panel_workspace_tests.rs @@ -217,10 +217,13 @@ fn files_panel_workspace_navigation_and_external_updates() { window.bounds_changed(cx); }) .unwrap(); - frame(window, cx, output.as_deref(), "01b-picker-files-overlay").await; + frame(window, cx, output.as_deref(), "01b-picker-files-compact").await; window .update(cx, |shell, window, cx| { - assert!(shell.files_overlay_width(cx) > 0.0); + assert_eq!(shell.files_visible_width(cx), 284.0); + assert_eq!(shell.files_reserved_width(cx), 284.0); + assert_eq!(shell.right_visible_width(cx), RIGHT_PANE_MIN); + assert_eq!(shell.settings.files_panel_width, FILES_PANEL_MAX); assert!(shell.right_surface_rows(cx).is_empty()); shell.toggle_right_pane(cx); shell.settings.files_panel_width = FILES_PANEL_DEFAULT; @@ -294,8 +297,27 @@ fn files_panel_workspace_navigation_and_external_updates() { frame(window, cx, output.as_deref(), "04-narrow-files").await; window .update(cx, |shell, _, cx| { - assert!(shell.files_overlay_width(cx) > 0.0); - assert_eq!(shell.files_reserved_width(cx), 0.0); + let files = shell.files_visible_width(cx); + let surface = shell.right_visible_width(cx); + let sidebar = shell.eval_tween(shell.sidebar_tween, shell.sidebar_target()); + assert_eq!(shell.files_reserved_width(cx), files); + assert!(files > 0.0 && surface > 0.0); + assert!(sidebar + files + surface < shell.viewport_width); + assert_eq!(shell.settings.files_panel_width, FILES_PANEL_DEFAULT); + }) + .unwrap(); + window + .update(cx, |shell, _, cx| shell.toggle_right_pane_expand(cx)) + .unwrap(); + frame(window, cx, output.as_deref(), "04b-expanded-narrow-files").await; + window + .update(cx, |shell, _, cx| { + assert_eq!(shell.files_visible_width(cx), FILES_PANEL_DEFAULT); + assert_eq!( + shell.right_visible_width(cx), + 1000.0 - 256.0 - FILES_PANEL_DEFAULT + ); + shell.toggle_right_pane_expand(cx); }) .unwrap(); // A browser surface coexists with the explorer and keeps its own tab. diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 373acb61f..947baf4e3 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -245,9 +245,7 @@ impl Shell { let right_pad = self.titlebar_right_pad(TITLEBAR_ACTION_EDGE_INSET); // The title row's gaps are outside the fixed-width panel controls. let gap_budget = if takeover { 8.0 } else { 16.0 }; - let right_visible = (self.eval_tween(self.right_tween, self.right_target(cx)) - - self.files_overlay_width(cx)) - .max(0.0); + let right_visible = self.right_visible_width(cx); let widths = panel_titlebar_widths( right_visible, files_width, @@ -475,8 +473,8 @@ mod panel_titlebar_tests { } #[test] - fn overlaid_files_and_tight_headers_keep_nonnegative_reveal_widths() { - // A 286px overlay covers the right end of a 520px surface. + fn narrow_panels_and_tight_headers_keep_nonnegative_reveal_widths() { + // A narrow surface and Files share a 520px header. let widths = panel_titlebar_widths(234.0, 286.0, 600.0, 92.0); assert_eq!( 1000.0 - 92.0 - widths.files_controls - 28.0 - widths.surface_reveal, From 86c27d7942c53f80807285e3cc4553dd464cbb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 21:56:27 -0300 Subject: [PATCH 09/13] fix(ui): draw the Files sidebar divider only once --- crates/ui/src/shell/tabs.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 947baf4e3..0e9b38945 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -322,16 +322,15 @@ impl Shell { .items_center() .justify_end() .when(files_width >= 120.0, |slot| { - slot.border_l_1() - .border_color(theme.border) - .pl(px(10.0)) - .child( - div() - .flex_1() - .text_size(px(12.0)) - .text_color(theme.text_muted) - .child("Files"), - ) + // The full-height Files panel already paints this seam. + // Preserve the label inset without drawing a second border. + slot.pl(px(11.0)).child( + div() + .flex_1() + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child("Files"), + ) }) .child( header_icon_button( From 2012bf29d481853d56d4786323332706582483e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 22:12:52 -0300 Subject: [PATCH 10/13] fix(ui): remove Files from the central surface menus --- crates/ui/src/shell.rs | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 00e8fd31e..181fb71f7 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1203,7 +1203,7 @@ pub struct Shell { /// entity from the bottom drawer's (own PTYs, own grid geometry; one /// panel can only size one visible grid at a time). right_terminal: Option>, - /// The surface-tab strip's `+` menu (Files / Terminal / Diffs / History rows). + /// The surface-tab strip's `+` menu (Browser / Terminal / Diffs / History rows). right_plus: popover::Popup<()>, /// Diff surfaces by id — each tab its own [`Changes`] viewer with its own /// scope/base pick and diff watch (multiple diff panels, user request). @@ -7351,13 +7351,6 @@ impl Shell { .flex() .flex_col() .gap(px(8.0)) - .child( - row("surface-card-files", icons::FOLDER_WITH_FILES, "Files").on_click( - cx.listener(|this, _, window, cx| { - this.add_files_surface(window, cx); - }), - ), - ) .child( row("surface-card-browser", icons::GLOBE, "Browser").on_click(cx.listener( |this, _, window, cx| this.add_browser_surface(None, window, cx), @@ -7834,20 +7827,6 @@ impl Shell { .flex() .flex_col() .gap(px(2.0)) - .child( - popover::menu_row(&theme, false, "right-plus-files") - .id("right-plus-files-row") - .on_click(cx.listener(|this, _, window, cx| { - this.add_files_surface(window, cx); - this.close_right_plus(cx); - })) - .child( - icon(icons::FOLDER_WITH_FILES) - .size(px(13.0)) - .text_color(theme.text_muted), - ) - .child(SharedString::from("Files")), - ) .child( popover::menu_row(&theme, false, "right-plus-browser") .id("right-plus-browser-row") From 5166d41992773e856d6e93a1cd6e9dbd3d959ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 12 Sep 2026 23:04:08 -0300 Subject: [PATCH 11/13] test(browser): keep blur sampling inside compact menu --- crates/ui/examples/browser-fixture.rs | 2 +- crates/ui/examples/browser-fixture/linux.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui/examples/browser-fixture.rs b/crates/ui/examples/browser-fixture.rs index 8bd265f5c..65e6692fc 100644 --- a/crates/ui/examples/browser-fixture.rs +++ b/crates/ui/examples/browser-fixture.rs @@ -345,7 +345,7 @@ fn main() -> anyhow::Result<()> { #[cfg(target_os = "macos")] { cx.update(|cx|appearance::set_surface(zeron_theme::SurfacePreference::Frosted,cx)); - first.read_with(cx, |b,_| b.fixture_eval("(() => {let grid=document.createElement('div'); grid.id='browser-blur-grid'; grid.style='height:140px;background:repeating-conic-gradient(#172f25 0% 25%,#f5f0df 0% 50%) 0 0/16px 16px'; document.body.prepend(grid);})()")); + first.read_with(cx, |b,_| b.fixture_eval("(() => {let grid=document.createElement('div'); grid.id='browser-blur-grid'; grid.style='height:140px;background:repeating-conic-gradient(#172f25 0% 25%,#f5f0df 0% 50%) 0 0/16px 16px'; document.body.style.paddingTop='0'; document.body.prepend(grid);})()")); pause(cx,300).await; let mut layout_video = std::process::Command::new("/usr/sbin/screencapture").args(["-v","-V","30","-C","-k","-D","1"]).arg(output.join("browser-layout.mov")).spawn()?; pause(cx,800).await; diff --git a/crates/ui/examples/browser-fixture/linux.rs b/crates/ui/examples/browser-fixture/linux.rs index 0eec8a234..dddd37ffe 100644 --- a/crates/ui/examples/browser-fixture/linux.rs +++ b/crates/ui/examples/browser-fixture/linux.rs @@ -111,7 +111,7 @@ pub async fn exercise( output: &std::path::Path, cx: &mut AsyncApp, ) -> anyhow::Result<()> { - eval(&page,"document.body.insertAdjacentHTML('afterbegin', `
`); window.browserClicks=0; document.body.addEventListener('pointerdown',()=>window.pagePresses=(window.pagePresses||0)+1); let live=document.createElement('div');live.style='position:fixed;bottom:12px;right:12px;background:#29483b;color:white;padding:8px;border-radius:6px;font:12px monospace';document.body.append(live);window.browserFrames=0;function frame(){live.textContent='LIVE '+(++window.browserFrames);requestAnimationFrame(frame)}frame();true",cx).await?; + eval(&page,"document.body.style.paddingTop='0'; document.body.insertAdjacentHTML('afterbegin', `
`); window.browserClicks=0; document.body.addEventListener('pointerdown',()=>window.pagePresses=(window.pagePresses||0)+1); let live=document.createElement('div');live.style='position:fixed;bottom:12px;right:12px;background:#29483b;color:white;padding:8px;border-radius:6px;font:12px monospace';document.body.append(live);window.browserFrames=0;function frame(){live.textContent='LIVE '+(++window.browserFrames);requestAnimationFrame(frame)}frame();true",cx).await?; pause(cx, 400).await; let bounds = page.read_with(cx, |b, _| b.fixture_linux_bounds()); let input=eval(&page,"(()=>{let r=document.getElementById('browser-input').getBoundingClientRect();return [r.x+20,r.y+15]})()",cx).await?; @@ -510,7 +510,9 @@ pub async fn exercise( AnyWindowHandle::from(window).update(cx, |_, w, _| f32::from(w.viewport_size().width))?; super::validate_blur( output, - (f32::from(bounds.origin.x) as f64 + 124., 42., 168., 112.), + // Browser and Terminal are the two permanent rows. Keep this in sync + // with the compact menu so the lower-right sample stays inside it. + (f32::from(bounds.origin.x) as f64 + 124., 42., 168., 78.), viewport, )?; window.update(cx, |s, _, cx| s.fixture_browser_menu(false, cx))?; From f53a33be9188bcf32019ac95116d419f8ab2ec97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 13 Sep 2026 01:43:19 -0300 Subject: [PATCH 12/13] fix(files): reveal directories activated from search --- crates/ui/src/files/mod.rs | 2 +- crates/ui/src/files/search.rs | 84 +++++++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 2ff70f0e7..b9d117542 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -733,7 +733,7 @@ impl FilesSurface { return; } self.selected_editor_path = Some(path.clone()); - self.reveal_path(path, false, cx); + self.reveal_path(path, search::RevealIntent::SynchronizeSelection, cx); } pub(crate) fn reveal_file_explicit(&mut self, path: String, cx: &mut Context) { diff --git a/crates/ui/src/files/search.rs b/crates/ui/src/files/search.rs index d90799891..38adce8aa 100644 --- a/crates/ui/src/files/search.rs +++ b/crates/ui/src/files/search.rs @@ -242,6 +242,24 @@ fn append_search_rows( } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum RevealIntent { + #[default] + SynchronizeSelection, + ActivateDirectory, + OpenFile, +} + +impl RevealIntent { + fn clears_search(self) -> bool { + self != Self::SynchronizeSelection + } + + fn opens_file(self) -> bool { + self == Self::OpenFile + } +} + #[derive(Default)] pub(super) struct FileSearchState { pub query: String, @@ -253,7 +271,7 @@ pub(super) struct FileSearchState { pub task: Option>, pub reveal_task: Option>, reveal_generation: u64, - reveal_opens_file: bool, + reveal_intent: RevealIntent, reveal_scroll_pending: bool, tree: SearchTreeModel, } @@ -274,7 +292,7 @@ impl FilesSurface { if self.search_state.query == query { return; } - if self.search_state.reveal_opens_file { + if self.search_state.reveal_intent.clears_search() { self.cancel_reveal(); } self.search_state.generation = self.search_state.generation.wrapping_add(1); @@ -406,7 +424,7 @@ impl FilesSurface { pub(super) fn cancel_reveal(&mut self) { self.search_state.reveal_generation = self.search_state.reveal_generation.wrapping_add(1); self.search_state.reveal_task = None; - self.search_state.reveal_opens_file = false; + self.search_state.reveal_intent = RevealIntent::default(); self.search_state.reveal_scroll_pending = false; } @@ -415,10 +433,19 @@ impl FilesSurface { result: WorkspaceFileSearchMatch, cx: &mut Context, ) { - self.reveal_path(result.path, result.kind == WorkspaceEntryKind::File, cx); + let intent = match result.kind { + WorkspaceEntryKind::Directory => RevealIntent::ActivateDirectory, + WorkspaceEntryKind::File | WorkspaceEntryKind::Symlink => RevealIntent::OpenFile, + }; + self.reveal_path(result.path, intent, cx); } - pub(super) fn reveal_path(&mut self, path: String, open: bool, cx: &mut Context) { + pub(super) fn reveal_path( + &mut self, + path: String, + intent: RevealIntent, + cx: &mut Context, + ) { self.cancel_reveal(); let Some(context) = self.request_context.clone() else { return; @@ -443,7 +470,7 @@ impl FilesSurface { let reveal_generation = self.search_state.reveal_generation; let include_ignored = self.tree.include_ignored(); let client = WorkspaceFilesClient::new(engine, context.clone()); - self.search_state.reveal_opens_file = open; + self.search_state.reveal_intent = intent; self.search_state.reveal_task = Some(cx.spawn(async move |this, cx| { let mut pages = Vec::with_capacity(directories.len()); for (index, directory) in directories.into_iter().enumerate() { @@ -485,8 +512,8 @@ impl FilesSurface { } surface.tree.select(path.clone()); surface.sync_tree_list(); - surface.search_state.reveal_opens_file = false; - surface.finish_reveal(path, open, cx); + surface.search_state.reveal_intent = RevealIntent::default(); + surface.finish_reveal(path, intent, cx); }); })); } @@ -502,10 +529,12 @@ impl FilesSurface { && self.search_state.reveal_generation == reveal_generation } - fn finish_reveal(&mut self, path: String, open: bool, cx: &mut Context) { - if open { - self.selected_editor_path = Some(path.clone()); + fn finish_reveal(&mut self, path: String, intent: RevealIntent, cx: &mut Context) { + if intent.clears_search() { self.search.update(cx, |search, cx| search.set_text("", cx)); + } + if intent.opens_file() { + self.selected_editor_path = Some(path.clone()); self.open_tree_file(path, cx); } if self.search_state.query.is_empty() { @@ -807,14 +836,14 @@ mod reveal_tests { .update(cx, |search, cx| search.set_text("config", cx)); }); surface.update(cx, |surface, cx| { - surface.finish_reveal("src/main.rs".into(), false, cx); + surface.finish_reveal("src/main.rs".into(), RevealIntent::SynchronizeSelection, cx); assert_eq!(surface.search.read(cx).text(), "config"); assert_eq!(surface.search_state.query, "config"); assert!(surface.search_state.reveal_scroll_pending); }); assert!(opened.borrow().is_empty()); surface.update(cx, |surface, cx| { - surface.finish_reveal("src/config.rs".into(), true, cx); + surface.finish_reveal("src/config.rs".into(), RevealIntent::OpenFile, cx); }); assert_eq!(*opened.borrow(), ["src/config.rs"]); surface.read_with(cx, |surface, cx| { @@ -823,6 +852,35 @@ mod reveal_tests { }); } + #[gpui::test] + fn activating_a_directory_clears_search_without_opening_a_file(cx: &mut TestAppContext) { + let surface = cx.new(|cx| { + let state = cx.new(|_| crate::state::AppState::new()); + FilesSurface::new_explorer(state, "chat".into(), false, cx) + }); + let opened = Rc::new(RefCell::new(Vec::new())); + let events = opened.clone(); + let _sub = cx.update(|cx| { + cx.subscribe(&surface, move |_, event, _| { + if let FilesEvent::OpenFile(path) = event { + events.borrow_mut().push(path.clone()); + } + }) + }); + surface.update(cx, |surface, cx| { + surface + .search + .update(cx, |search, cx| search.set_text("emptydir", cx)); + surface.finish_reveal("emptydir".into(), RevealIntent::ActivateDirectory, cx); + }); + surface.read_with(cx, |surface, cx| { + assert!(surface.search.read(cx).text().is_empty()); + assert!(surface.search_state.query.is_empty()); + assert!(!surface.search_state.reveal_scroll_pending); + }); + assert!(opened.borrow().is_empty()); + } + #[gpui::test] fn checkout_switch_and_newer_reveal_reject_old_search_results(cx: &mut TestAppContext) { let surface = cx.new(|cx| { From 3ebe2d8a8b0af5e9e1ca22e7965ccc6fd5953a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 13 Sep 2026 03:17:31 -0300 Subject: [PATCH 13/13] fix(ui): match Files background and scroll Markdown padding --- crates/ui/src/files/markdown_preview.rs | 16 +++++++++++----- crates/ui/src/shell.rs | 7 +------ crates/ui/src/shell/files_panel.rs | 3 +-- crates/ui/src/theme.rs | 9 +++++++++ 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index 02a807afa..60392545a 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -23,6 +23,7 @@ const MAX_MEDIA_BYTES: usize = 64 * 1024 * 1024; const MAX_MEDIA_ENTRIES: usize = 32; const MAX_MARKDOWN_BYTES: usize = 2 * 1024 * 1024; const MAX_PREVIEW_CONTENT_WIDTH: f32 = 900.0; +const PREVIEW_VERTICAL_PADDING: f32 = 16.0; /// A visual block cites its first source line. Notes on inner lines (for /// example list items or fenced code) remain attached to that containing block. @@ -936,7 +937,10 @@ impl MarkdownPreview { #[cfg(test)] pub(super) fn test_block_bounds(&self, ix: usize) -> gpui::Bounds { - self.list.bounds_for_item(ix).unwrap() + let mut bounds = self.list.bounds_for_item(ix).unwrap(); + // GPUI's bounds_for_item omits the list padding applied during paint. + bounds.origin.y += px(PREVIEW_VERTICAL_PADDING); + bounds } fn render_row(&mut self, ix: usize, window: &mut Window, cx: &mut Context) -> AnyElement { @@ -1236,7 +1240,6 @@ impl Render for MarkdownPreview { .min_h_0() .flex() .flex_col() - .py(px(16.0)) .font_family(theme.font_sans.clone()) .text_color(theme.text) .track_focus(&self.focus) @@ -1287,6 +1290,9 @@ impl Render for MarkdownPreview { list(self.list.clone(), cx.processor(Self::render_row)) .flex_1() .min_h_0() + // Scroll the breathing room with the document so content + // clips at the viewport edge, directly below the toolbar. + .py(px(PREVIEW_VERTICAL_PADDING)) .with_sizing_behavior(ListSizingBehavior::Auto), ); if let Some(preview) = &self.preview_image { @@ -1520,7 +1526,7 @@ mod layout_tests { cx.update_window(window.into(), |_, window, cx| { window.refresh(); let _ = window.draw(cx); - let bounds = preview.read(cx).list.bounds_for_item(0).unwrap(); + let bounds = preview.read(cx).test_block_bounds(0); let gutter = ((bounds.size.width - px(MAX_PREVIEW_CONTENT_WIDTH)) / 2.0).max(px(24.0)); let position = @@ -1684,7 +1690,7 @@ mod layout_tests { }).unwrap(); let view = window.entity(cx).unwrap(); cx.update_window(window.into(), |_, window, cx| { window.refresh(); let _ = window.draw(cx); }).unwrap(); - let bounds = view.read(cx).list.bounds_for_item(0).unwrap(); + let bounds = view.read(cx).test_block_bounds(0); assert!(bounds.size.height > px(100.0)); let position = bounds.center(); cx.update_window(window.into(), |_, window, cx| { @@ -1752,7 +1758,7 @@ mod layout_tests { }) .unwrap(); - let bounds = view.read(cx).list.bounds_for_item(0).unwrap(); + let bounds = view.read(cx).test_block_bounds(0); let image_position = gpui::point(bounds.center().x, bounds.top() + px(28.0 + 80.0)); cx.update_window(window.into(), |_, window, cx| { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 5484c6d55..f131dee42 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -7175,7 +7175,6 @@ impl Shell { /// an embedded terminal, or the surface picker when no tabs exist. fn render_right_pane(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); - let bg = theme.bg; let content: AnyElement = if self.right_pane_open(cx) || self.tween_active(self.right_tween) { match self.resolved_right_active(cx) { @@ -7274,11 +7273,7 @@ impl Shell { // height with a left hairline, glass-friendly like the terminal dock // (translucent over the frost; solid otherwise). The resize grabber // lives outside this clipped container, on the root layout's seam. - let panel_bg = if theme.is_glass() { - bg.opacity(0.4) - } else { - bg - }; + let panel_bg = theme.panel_bg(); let panel = div() .size_full() .flex() diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 046c98f5b..e1834c7cc 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -263,8 +263,7 @@ impl Shell { .occlude() .border_l_1() .border_color(theme.border) - // Match the left sidebar's subtle wash over the shell frost. - .bg(crate::theme::wash(0.05)) + .bg(theme.panel_bg()) .children(content); div() .id("files-panel") diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 704d31f65..a93546ca7 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -885,6 +885,15 @@ impl Theme { self.glass().a < 1.0 } + /// Shared background for the editor host and the adjacent Files column. + pub fn panel_bg(&self) -> Hsla { + if self.is_glass() { + self.bg.opacity(0.4) + } else { + self.bg + } + } + /// Whether FLOATING surfaces (popovers, the composer pill) paint their /// backdrop blur and translucent tints. Unlike [`Self::is_glass`] this is /// scene-level: the blur runs on in-app content inside the window, not on