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