diff --git a/crates/ui/examples/browser-fixture.rs b/crates/ui/examples/browser-fixture.rs index 4dbf1ea16..9da0aff82 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))?; 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..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)) @@ -520,12 +523,16 @@ 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/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/files/mod.rs b/crates/ui/src/files/mod.rs index fd752a3c8..12bcfdaf0 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -139,6 +139,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), @@ -156,7 +157,7 @@ pub enum FilesCloseDisposition { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FilesPresentation { - Browser, + Explorer, Editor, } @@ -182,6 +183,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, @@ -206,6 +208,40 @@ 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 is_editor = self.presentation.is_editor(); + let header = is_editor + .then(|| self.render_editor_header(&theme, cx)) + .flatten(); + let body = if is_editor { + self.render_preview(window, cx) + } else { + self.render_explorer(&theme, cx).into_any_element() + }; + let editor_context_menu = self.render_editor_context_menu(&theme, cx); + div() + .id(SharedString::from(format!( + "files-surface-{}", + self.chat_id + ))) + .role(gpui::Role::Group) + .aria_label("Workspace files") + .size_full() + .relative() + .flex() + .bg(crate::theme::ink(0.0)) + .flex_col() + .children(header) + .child(div().flex_1().min_h_0().w_full().child(body)) + .children(editor_context_menu) + } +} + +impl FilesSurface { + fn render_explorer( + &mut self, + theme: &crate::theme::Theme, + 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) @@ -254,16 +290,13 @@ impl Render for FilesSurface { } 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() + div() .size_full() .min_w_0() .flex() .flex_col() - .when(!split_editor, |pane| { - pane.child(self.render_header(&theme, cx)) - }) + .child(self.render_header(theme, cx)) .when_some(watch_error, |element, error| { element.child( div() @@ -306,134 +339,31 @@ impl Render for FilesSurface { ), ) }) - .child(content); - let is_editor = self.presentation.is_editor(); - let mut header = None; - let mut preview_split_right = None; - let body = if split_editor { - let wide = self.preview.is_wide(); - let tree_width = if wide { - self.preview.tree_width_frame(window, cx) - } else { - self.preview.narrow_tree_width() - }; - let openness = self.preview.tree_sidebar_frame(window, cx); - if wide && self.preview.tree_sidebar_visible() { - preview_split_right = - Some(tree_width * openness - preview::TREE_SPLIT_HITBOX_HALF_WIDTH); - } - // 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)), - ); + .child(content) + } - 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), - ), - ), - ) - .into_any_element() - } else { - tree_pane.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); - let preview_split_handle = - preview_split_right.map(|right| self.preview_split_handle(right, cx)); - div() - .id(SharedString::from(format!( - "files-surface-{}", - self.chat_id - ))) - .role(gpui::Role::Group) - .aria_label("Workspace files") - .size_full() - .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)) - .children(preview_split_handle) - .children(editor_context_menu) + /// 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, + ) } -} -impl FilesSurface { + #[cfg(test)] pub fn new( state: Entity, chat_id: String, @@ -447,7 +377,7 @@ impl FilesSurface { Self::new_with_presentation( state, chat_id, - FilesPresentation::Browser, + FilesPresentation::Editor, None, autosave_enabled, autosave_delay_ms, @@ -539,6 +469,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)), @@ -731,6 +662,12 @@ impl FilesSurface { { self.open_file(path, cx); } + if !self.presentation.is_editor() { + self.ensure_tree_loaded(cx); + } + } + + fn ensure_tree_loaded(&mut self, cx: &mut Context) { if self.started { return; } @@ -745,6 +682,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(); @@ -771,15 +711,42 @@ impl FilesSurface { } pub(super) fn open_tree_file(&mut self, path: String, cx: &mut Context) { - if self.presentation.is_editor() { - cx.emit(FilesEvent::OpenFile(path)); + cx.emit(FilesEvent::OpenFile(path)); + } + + 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)); + } + + /// Synchronize selection without replacing the user's current search. + pub(crate) fn reveal_file(&mut self, path: String, cx: &mut Context) { + 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, search::RevealIntent::SynchronizeSelection, 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); + } - self.presentation = FilesPresentation::Editor; - self.editor_path = Some(path.clone()); - self.open_file(path, cx); - cx.emit(FilesEvent::TitleChanged); + 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) { @@ -790,11 +757,15 @@ 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(); - 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); @@ -938,6 +909,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()) @@ -946,6 +926,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 { @@ -1011,3 +994,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"]); + } +} diff --git a/crates/ui/src/files/preview.rs b/crates/ui/src/files/preview.rs index f7e97ca47..50c6b3e66 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,11 +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 TREE_SPLIT_MIN: f32 = 220.0; -const TREE_SPLIT_MAX: f32 = 360.0; -pub(super) const TREE_SPLIT_HITBOX_HALF_WIDTH: f32 = 10.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; @@ -91,47 +85,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 { images_visible: bool, documents: HashMap, @@ -141,21 +94,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, - tree_edge_bounce: Option, - tree_resize_edge: Option, - tree_resize_active: bool, - tree_resize_dragging: bool, comment_anchors: HashMap>, comment_draft: Option, active_comment: Option, @@ -177,21 +121,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(), - tree_edge_bounce: None, - tree_resize_edge: None, - tree_resize_active: false, - tree_resize_dragging: false, comment_anchors: HashMap::new(), comment_draft: None, active_comment: None, @@ -206,12 +141,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.tree_edge_bounce = None; - self.tree_resize_edge = None; - self.tree_resize_active = false; - self.tree_resize_dragging = false; self.comment_anchors.clear(); self.comment_draft = None; self.active_comment = None; @@ -323,39 +252,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) { - self.tree_edge_bounce = None; - self.tree_resize_edge = None; - self.tree_resize_active = false; - self.tree_resize_dragging = false; - 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 } @@ -388,52 +284,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_frame(&self, window: &mut Window, cx: &App) -> f32 { - let Some(bounce) = self.tree_edge_bounce else { - return self.tree_width; - }; - if crate::motion::reduced_motion(cx) || !self.tree_sidebar_visible() { - return self.tree_width; - } - let total = Duration::from_millis(crate::motion::RESIZE_EDGE_BOUNCE_MS) - .mul_f32(crate::motion::speed_scale()); - let raw = Instant::now() - .saturating_duration_since(bounce.started) - .as_secs_f32() - / total.as_secs_f32(); - if raw >= 1.0 { - return self.tree_width; - } - window.request_animation_frame(); - self.tree_width + crate::motion::resize_bounce_offset(bounce.edge, raw) - } - - pub(super) fn tree_resize_active(&self) -> bool { - self.tree_resize_active - } - - pub(super) fn tree_resize_constrained(&self) -> bool { - self.tree_resize_dragging && !self.tree_resize_active - } - - fn finish_tree_resize(&mut self) { - self.tree_resize_active = false; - self.tree_resize_dragging = false; - self.tree_resize_edge = None; - } - pub(super) fn has_unsaved_changes(&self) -> bool { self.documents.values().any(FileDocument::is_dirty) } @@ -471,10 +321,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 { @@ -618,16 +464,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, } @@ -706,20 +542,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)); @@ -1119,7 +941,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; @@ -2240,35 +2061,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, @@ -2471,21 +2263,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) @@ -3170,118 +2949,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 requested = f32::from(event.bounds.right() - event.event.position.x); - let sample = crate::motion::resize_drag_sample( - requested, - TREE_SPLIT_MIN, - TREE_SPLIT_MAX, - self.preview.tree_resize_edge, - crate::motion::reduced_motion(cx), - ); - self.preview.tree_width = sample.width; - self.preview.tree_resize_dragging = true; - self.preview.tree_resize_active = sample.edge.is_none(); - if sample.starts_bounce { - self.preview.tree_edge_bounce = sample.edge.map(crate::motion::ResizeEdgeBounce::new); - } else if sample.edge.is_none() { - self.preview.tree_edge_bounce = None; - } - self.preview.tree_resize_edge = sample.edge; - cx.notify(); - } - - pub(super) fn preview_split_handle(&self, right: f32, cx: &mut Context) -> AnyElement { - let theme = Theme::of(cx); - let fade_key = "pane-resize-files-preview-split"; - let hover_highlight = crate::motion::hover_blend( - fade_key, - theme.border_strong.opacity(0.0), - theme.border_strong, - ); - let highlight = if self.preview.tree_resize_constrained() { - theme.border_strong.opacity(0.0) - } else if self.preview.tree_resize_active() { - theme.border_strong - } else { - hover_highlight - }; - let clear = highlight.opacity(0.0); - div() - .id("files-preview-split") - .absolute() - .right(px(right)) - .top_0() - .bottom_0() - .w(px(TREE_SPLIT_HITBOX_HALF_WIDTH * 2.0)) - .occlude() - .cursor_col_resize() - .on_hover(crate::motion::hover_listener(fade_key)) - .child( - div() - .absolute() - .top_0() - .bottom_0() - .left(px(TREE_SPLIT_HITBOX_HALF_WIDTH)) - .w(px(1.0)) - .flex() - .flex_col() - .child(div().flex_1().bg(gpui::linear_gradient( - 180.0, - gpui::linear_color_stop(clear, 0.0), - gpui::linear_color_stop(highlight, 1.0), - ))) - .child(div().flex_1().bg(gpui::linear_gradient( - 180.0, - gpui::linear_color_stop(highlight, 0.0), - gpui::linear_color_stop(clear, 1.0), - ))), - ) - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|this, _, _, cx| { - this.preview.tree_resize_dragging = true; - this.preview.tree_resize_active = true; - cx.notify(); - }), - ) - .on_drag( - PreviewSplitResize, - |_, _point: Point, _, cx| { - cx.stop_propagation(); - cx.new(|_| PreviewDragGhost) - }, - ) - .on_mouse_up( - gpui::MouseButton::Left, - cx.listener(|this, event: &gpui::MouseUpEvent, window, cx| { - if event.click_count == 2 { - this.preview.tree_width = TREE_SPLIT_DEFAULT; - this.preview.tree_edge_bounce = None; - } - this.preview.finish_tree_resize(); - crate::motion::set_hover(fade_key, false, crate::motion::reduced_motion(cx)); - window.refresh(); - cx.notify(); - }), - ) - .on_mouse_up_out( - gpui::MouseButton::Left, - cx.listener(|this, _, window, cx| { - this.preview.finish_tree_resize(); - crate::motion::set_hover(fade_key, false, crate::motion::reduced_motion(cx)); - window.refresh(); - cx.notify(); - }), - ) - .into_any_element() - } } fn editor_comment_overlay_top( @@ -3377,31 +3044,6 @@ fn read_only_message(reason: Option) -> SharedString { mod tests { use super::*; - #[test] - fn tree_split_uses_the_standard_resize_geometry_and_limits() { - assert_eq!(TREE_SPLIT_HITBOX_HALF_WIDTH * 2.0, 20.0); - let min = crate::motion::resize_drag_sample( - TREE_SPLIT_MIN - 1.0, - TREE_SPLIT_MIN, - TREE_SPLIT_MAX, - None, - false, - ); - let max = crate::motion::resize_drag_sample( - TREE_SPLIT_MAX + 1.0, - TREE_SPLIT_MIN, - TREE_SPLIT_MAX, - None, - false, - ); - assert_eq!(min.width, TREE_SPLIT_MIN); - assert_eq!(min.edge, Some(crate::motion::ResizeEdge::Min)); - assert!(min.starts_bounce); - assert_eq!(max.width, TREE_SPLIT_MAX); - assert_eq!(max.edge, Some(crate::motion::ResizeEdge::Max)); - assert!(max.starts_bounce); - } - fn cached_document(path: &str, text: &str) -> FileDocument { let mut document = FileDocument::loading(DocumentKey { chat_id: "chat-1".into(), @@ -3439,13 +3081,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] @@ -3635,113 +3275,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"; @@ -3892,6 +3425,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 64252df4a..85a0f7798 100644 --- a/crates/ui/src/files/search.rs +++ b/crates/ui/src/files/search.rs @@ -243,6 +243,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,6 +271,9 @@ pub(super) struct FileSearchState { pub active: usize, pub task: Option>, pub reveal_task: Option>, + reveal_generation: u64, + reveal_intent: RevealIntent, + reveal_scroll_pending: bool, tree: SearchTreeModel, } @@ -272,6 +293,9 @@ impl FilesSurface { if self.search_state.query == query { return; } + if self.search_state.reveal_intent.clears_search() { + 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; @@ -282,6 +306,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) }); @@ -311,14 +339,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; @@ -387,48 +417,82 @@ 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_intent = RevealIntent::default(); + self.search_state.reveal_scroll_pending = false; + } + pub(super) fn reveal_search_result( &mut self, result: WorkspaceFileSearchMatch, cx: &mut Context, ) { + 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, + intent: RevealIntent, + 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_intent = intent; 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(); } @@ -438,7 +502,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() { @@ -447,22 +511,41 @@ 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.show_tree_sidebar(cx); - } else { - surface.open_tree_file(result.path.clone(), cx); - } - cx.notify(); + surface.search_state.reveal_intent = RevealIntent::default(); + surface.finish_reveal(path, intent, 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, 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() { + 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() { @@ -714,3 +797,114 @@ 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(), 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(), RevealIntent::OpenFile, 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 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| { + 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/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 e8f4f3a7e..8416e5e84 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -30,9 +30,13 @@ pub const SIDEBAR_MIN: f32 = 224.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. @@ -555,6 +559,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 @@ -647,6 +652,7 @@ impl Default for UiSettings { sound_attention_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, @@ -1129,6 +1135,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, @@ -1551,6 +1563,7 @@ mod tests { sound_attention_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, @@ -1845,6 +1858,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 dedb47949..5fef14b47 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -59,6 +59,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; @@ -439,8 +440,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 @@ -456,7 +457,6 @@ fn right_pane_takeover_width(viewport: f32, sidebar: f32) -> f32 { pub enum RightSurface { #[default] Picker, - Files, File(u64), Browser(u64), Diff(u64), @@ -489,6 +489,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, @@ -705,6 +706,7 @@ struct RightPaneResize; enum PaneResizeKind { Sidebar, Right, + Files, Terminal, } @@ -1311,7 +1313,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). @@ -1320,7 +1322,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, @@ -1452,6 +1454,7 @@ pub struct Shell { debug_gate: Option, debug_upload: Option, sidebar_tween: Option, + files_tween: Option, sidebar_edge_bounce: Option, /// Boundary currently held during a sidebar drag. Cleared on re-entry or /// release so the next genuine edge crossing can acknowledge the limit. @@ -1780,6 +1783,7 @@ impl Shell { debug_gate, debug_upload, sidebar_tween: None, + files_tween: None, sidebar_edge_bounce: None, sidebar_resize_edge: None, pane_resize_active: None, @@ -1874,6 +1878,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()); } @@ -2144,6 +2149,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; @@ -2240,11 +2246,14 @@ impl Shell { // tween so toggling it remains seamless. let sidebar_now = self.sidebar_now(); 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)) + .min(self.surface_max_width(cx)) } } } @@ -2263,12 +2272,16 @@ 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); self.right_edge_bounce = None; self.right_resize_edge = None; self.finish_pane_resize(PaneResizeKind::Right); let sidebar_now = self.sidebar_now(); - 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); @@ -2284,7 +2297,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 @@ -2327,15 +2344,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 @@ -2379,13 +2387,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(_) @@ -2467,11 +2471,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)); @@ -2496,6 +2495,7 @@ impl Shell { RightSurface::Subagent(_) | RightSurface::Browser(_) => {} RightSurface::Picker => {} } + self.sync_explorer_selection(cx); cx.notify(); } @@ -2511,9 +2511,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, }; @@ -2534,12 +2532,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) @@ -2551,12 +2544,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) @@ -2572,9 +2560,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 { @@ -2655,68 +2643,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() { @@ -2745,32 +2679,49 @@ 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::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); 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); } @@ -3015,7 +2966,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, }; @@ -3035,7 +2985,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)); @@ -3133,12 +3083,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()) @@ -3162,12 +3107,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) @@ -3175,7 +3114,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| { @@ -3187,9 +3126,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()) } @@ -3203,10 +3141,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); @@ -3338,6 +3272,7 @@ impl Shell { PaneResizeKind::Sidebar => self.sidebar_resize_edge = None, PaneResizeKind::Terminal => self.terminal_drag_anchor = None, PaneResizeKind::Right => self.right_resize_edge = None, + PaneResizeKind::Files => {} } } @@ -3348,10 +3283,9 @@ impl Shell { cx: &mut Context, ) { let viewport = f32::from(window.viewport_size().width); - let width = viewport - 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 width = viewport - self.files_reserved_width(cx) - f32::from(event.event.position.x); + // Use the same shared budget as rendering, including compact windows. + let max = self.surface_max_width(cx); let sample = if max >= RIGHT_PANE_MIN { motion::resize_drag_sample( width, @@ -4596,6 +4530,7 @@ impl Shell { &self, tween: Option, target: f32, + visible: f32, edge_offset: f32, inner: AnyElement, ) -> AnyElement { @@ -4610,7 +4545,7 @@ impl Shell { .flex_none() .relative() .overflow_hidden() - .w(px(self.eval_tween(tween, target) + edge_offset)) + .w(px(visible)) .child( div() .absolute() @@ -7661,24 +7596,14 @@ 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) { // Rendering a Files surface activates its image. Keep it unmounted // throughout the closing animation after suspending its resources. - RightSurface::Files | RightSurface::File(_) if !self.right_pane_open(cx) => { + RightSurface::File(_) if !self.right_pane_open(cx) => { gpui::Empty.into_any_element() } - 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)); @@ -7715,7 +7640,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); @@ -7767,11 +7694,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() @@ -7796,6 +7719,7 @@ impl Shell { self.right_pane_container( self.right_tween, target, + self.right_visible_width(cx), edge_offset, div().h_full().relative().child(panel).into_any_element(), ) @@ -7848,13 +7772,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), @@ -8054,7 +7971,6 @@ impl Shell { let is_active = surface == active; let file_identity_path = detail.as_ref().cloned().unwrap_or_else(|| title.clone()); let icon_path = match surface { - RightSurface::Files => icons::FOLDER_WITH_FILES, RightSurface::File(_) => icons::DOCUMENT, RightSurface::Diff(id) => self .diffs @@ -8343,20 +8259,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") @@ -8489,7 +8391,11 @@ impl Shell { self.right_resize_edge = None; self.finish_pane_resize(PaneResizeKind::Right); let sidebar_now = self.sidebar_now(); - 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); @@ -8497,7 +8403,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(); } @@ -9127,7 +9037,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() @@ -9187,7 +9097,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); @@ -9249,6 +9166,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( @@ -9257,7 +9180,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); }); @@ -9348,6 +9274,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 @@ -9361,7 +9288,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, }; @@ -9475,7 +9401,11 @@ impl Render for Shell { // sizes itself to the viewport. self.viewport_width = viewport; let on_chat = matches!(self.route, Route::Chat); - let right_target_width = if on_chat { self.right_now(cx) } else { 0.0 }; + let right_target_width = if on_chat { + self.right_visible_width(cx) + } else { + 0.0 + }; let panel_handoff = self.composer_dock.borrow_mut().observe_pane( self.state.read(cx).selected_chat.is_some(), right_target_width, @@ -9485,8 +9415,11 @@ impl Render for Shell { if panel_handoff { self.motion_active.set(true); } - let main_target_width = - conversation_width(viewport, self.sidebar_target(), right_target_width); + let main_target_width = conversation_width( + viewport - self.files_reserved_width(cx), + self.sidebar_target(), + right_target_width, + ); let main_transition = self.active_tween_endpoints(self.main_takeover_tween); let main_content_width = stable_panel_content_width(main_target_width, main_transition); @@ -9559,6 +9492,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; @@ -9658,7 +9592,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)) @@ -9889,11 +9824,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); } @@ -10544,21 +10479,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))); @@ -10904,10 +10839,13 @@ mod exit_regressions { ) }); let key = shell.panel_key(cx); - shell.files.insert(key.clone(), files.clone()); + shell.file_surfaces.insert(0, files.clone()); + shell + .right_tabs + .insert(key.clone(), vec![RightSurface::File(0)]); shell .panels - .update(&key, |panel| panel.right_active = RightSurface::Files); + .update(&key, |panel| panel.right_active = RightSurface::File(0)); assert!(files.read(cx).test_images_visible()); shell.toggle_right_pane(cx); assert!(!shell.right_pane_open(cx)); @@ -11410,7 +11348,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)); @@ -11419,7 +11360,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(); @@ -11440,7 +11381,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..3eb717297 --- /dev/null +++ b/crates/ui/src/shell/files_panel.rs @@ -0,0 +1,455 @@ +//! 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; + +/// 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, + surface_max: f32, +} + +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 chat_min = if surfaces_open && expanded { + 0.0 + } else { + CHAT_PANEL_MIN + }; + 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 { + // 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, + surface_max: right_pane_max_width(viewport - width, sidebar, chat_min * content_scale), + } +} + +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 + } + + fn files_layout(&self, visible: f32, cx: &App) -> FilesPanelLayout { + files_panel_layout( + self.viewport_width, + self.sidebar_now(), + 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, + ) + } + + pub(super) fn files_target(&self, cx: &App) -> f32 { + 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 { + self.files_visible_width(cx) + } + + 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.sidebar_now() - self.files_visible_width(cx)).max(0.0); + self.right_now(cx).min(available) + } + + fn clear_surface_transitions(&mut self) { + self.right_tween = None; + self.main_takeover_tween = None; + 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; + } + 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, 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) => { + 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_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() { + 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_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) { + 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, + ) { + let requested = f32::from(window.viewport_size().width) - f32::from(event.event.position.x); + self.settings.files_panel_width = requested.clamp(FILES_PANEL_MIN, FILES_PANEL_MAX); + self.pane_resize_dragging = Some(PaneResizeKind::Files); + self.pane_resize_active = (requested > FILES_PANEL_MIN && requested < FILES_PANEL_MAX) + .then_some(PaneResizeKind::Files); + self.files_tween = None; + self.clear_surface_transitions(); + 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)); + } + 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)); + let inner = div() + .w(px(content_width)) + .h_full() + .pt(px(Theme::TITLEBAR_HEIGHT)) + .occlude() + .border_l_1() + .border_color(theme.border) + .bg(theme.panel_bg()) + .children(content); + div() + .id("files-panel") + .h_full() + .flex_none() + .relative() + .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| { + panel.child( + self.resize_handle( + "files-panel-resize", + PaneResizeKind::Files, + || 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}; + + #[test] + 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, + surface_max: 558.0, + } + ); + let narrow = files_panel_layout(1200.0, 256.0, 440.0, 440.0, true, false); + assert_eq!( + narrow, + FilesPanelLayout { + width: 284.0, + surface_max: 360.0, + } + ); + 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_eq!(expanded.width, 286.0); + assert_eq!(expanded.surface_max, 558.0); + // Growing the viewport restores the preferred width. + assert_eq!( + files_panel_layout(1600.0, 256.0, 440.0, 440.0, true, false).width, + 440.0 + ); + } + + #[test] + 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; + } + 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] { + 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"); + } + } + } + } + } + } + } + } + + #[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(); + } +} + +#[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..e70984c63 --- /dev/null +++ b/crates/ui/src/shell/files_panel_workspace_tests.rs @@ -0,0 +1,442 @@ +//! 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.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-compact").await; + window + .update(cx, |shell, window, cx| { + 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; + 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) + }) + .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, 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(); + 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| { + 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. + 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(); + }); +} diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 0a96ee309..965a80035 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) { @@ -219,6 +241,17 @@ impl Shell { } else { content_left }; + let files_width = self.files_visible_width(cx); + 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.right_visible_width(cx); + 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 { @@ -231,17 +264,6 @@ impl Shell { .flex_row() .items_center(); if right_open { - let right_now = self.eval_tween(self.right_tween, self.right_target(cx)); - 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 - 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). @@ -250,10 +272,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() @@ -292,6 +313,45 @@ impl Shell { &theme, cx.listener(|this, _, _, cx| this.toggle_right_pane(cx)), )) + .child( + div() + .w(px(widths.files_controls)) + .h_full() + .flex_none() + .flex() + .items_center() + .justify_end() + .when(files_width >= 120.0, |slot| { + // 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( + "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(), ) }; @@ -303,7 +363,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. @@ -362,6 +422,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 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, + 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::*; diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index bc6f88cd6..c8f149be0 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -886,6 +886,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