From 385f6134bbadf9820f30dfd5944c01359e5ce159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gr=C3=A4fe?= Date: Fri, 24 Apr 2026 05:48:27 +0200 Subject: [PATCH] gpui: Add support for NSWindow's representedFilename (#48029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This sets the accessibility document property (AXDocument) of the window, which other apps can use to understand what file the current window represents. Document-based apps on macOS are generally expected to set this property. The document path is set via `Window::set_document_path()` which calls `setRepresentedFilename:` on the underlying NSWindow. The workspace updates this path in `update_window_title()` whenever the active item or project structure changes. For tests, instead of trying to somehow assemble a proper NSWindow, we store the document path on the window mock used for testing and check its value. Motivation: I am the developer of Timing, an automatic time tracking app for Mac. Timing uses the `AXDocument` property (a standard property of most document-based app windows on macOS) to understand what document the user is working on. With this change, Timing is able to understand which directory the user is working in. Without this, Timing would only record the window title, i.e. the filename without information about the containing directory. I've had several users ask for better support for Zed. Here's a screenshot of the macOS Accessibility Inspector showing the `AXDocument` property with this change. The UI of Zed itself does not change. However, in my dev build of Zed, the traffic lights are a bit too large and misaligned. However, this happens even when building `main`, so I assume it’s unrelated to my changes. Screenshot 2026-01-30 at 16 18 25 Release Notes: - Set the represented filename property of windows on macOS --------- Co-authored-by: Christopher Biscardi --- crates/gpui/src/app/test_context.rs | 10 +++ crates/gpui/src/platform.rs | 1 + crates/gpui/src/platform/test/window.rs | 6 ++ crates/gpui/src/window.rs | 6 ++ crates/gpui_macos/src/window.rs | 8 ++ crates/workspace/src/workspace.rs | 106 +++++++++++++++++++++++- 6 files changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index d8f459df3c..99a64b54e2 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -735,6 +735,16 @@ impl VisualTestContext { self.cx.test_window(self.window).0.lock().title.clone() } + /// Read the document path off the window (set by `Window#set_document_path`) + pub fn document_path(&mut self) -> Option { + self.cx + .test_window(self.window) + .0 + .lock() + .document_path + .clone() + } + /// Simulate a sequence of keystrokes `cx.simulate_keystrokes("cmd-p escape")` /// Automatically runs until parked. pub fn simulate_keystrokes(&mut self, keystrokes: &str) { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 59957a9c6a..6cd8e9cd8d 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -651,6 +651,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { false } fn set_edited(&mut self, _edited: bool) {} + fn set_document_path(&self, _path: Option<&std::path::Path>) {} fn show_character_palette(&self) {} fn titlebar_double_click(&self) {} fn on_move_tab_to_new_window(&self, _callback: Box) {} diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index ac98027b52..2b5399ca98 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -20,6 +20,7 @@ pub(crate) struct TestWindowState { display: Rc, pub(crate) title: Option, pub(crate) edited: bool, + pub(crate) document_path: Option, platform: Weak, // TODO: Replace with `Rc` sprite_atlas: Arc, @@ -75,6 +76,7 @@ impl TestWindow { renderer, title: Default::default(), edited: false, + document_path: None, should_close_handler: None, hit_test_window_control_callback: None, input_callback: None, @@ -230,6 +232,10 @@ impl PlatformWindow for TestWindow { self.0.lock().edited = edited; } + fn set_document_path(&self, path: Option<&std::path::Path>) { + self.0.lock().document_path = path.map(|p| p.to_path_buf()); + } + fn show_character_palette(&self) { unimplemented!() } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index ab195c6294..49bdf2e65c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2214,6 +2214,12 @@ impl Window { self.platform_window.set_edited(edited); } + /// Set the path of the file this window represents. + /// On macOS, this sets the window's accessibility document property (AXDocument). + pub fn set_document_path(&self, path: Option<&std::path::Path>) { + self.platform_window.set_document_path(path); + } + /// Determine the display on which the window is visible. pub fn display(&self, cx: &App) -> Option> { cx.platform diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 7ce5badb2b..d6e1d296ac 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1439,6 +1439,14 @@ impl PlatformWindow for MacWindow { self.0.lock().move_traffic_light(); } + fn set_document_path(&self, path: Option<&std::path::Path>) { + unsafe { + let window = self.0.lock().native_window; + let filename = path.map_or(ns_string(""), |p| ns_string(&p.to_string_lossy())); + let _: () = msg_send![window, setRepresentedFilename: filename]; + } + } + fn show_character_palette(&self) { let this = self.0.lock(); let window = this.native_window; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index c88aec84e2..a6d966bb23 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5840,7 +5840,9 @@ impl Workspace { title = "empty project".to_string(); } - if let Some(path) = self.active_item(cx).and_then(|item| item.project_path(cx)) { + let active_project_path = self.active_item(cx).and_then(|item| item.project_path(cx)); + + if let Some(path) = active_project_path.as_ref() { let filename = path.path.file_name().or_else(|| { Some( project @@ -5862,6 +5864,11 @@ impl Workspace { title.push_str(" ↗"); } + let document_path = active_project_path + .as_ref() + .and_then(|path| project.absolute_path(path, cx)); + window.set_document_path(document_path.as_deref()); + if let Some(last_title) = self.last_window_title.as_ref() && &title == last_title { @@ -10880,7 +10887,7 @@ mod tests { DismissEvent, Empty, EventEmitter, FocusHandle, Focusable, Render, TestAppContext, UpdateGlobal, VisualTestContext, px, }; - use project::{Project, ProjectEntryId}; + use project::{Project, ProjectEntryId, WorktreeId}; use serde_json::json; use settings::SettingsStore; use util::path; @@ -11029,6 +11036,86 @@ mod tests { assert_eq!(cx.window_title().as_deref(), Some("root2 — one.txt")); } + #[gpui::test] + async fn test_document_path_updates_with_active_item(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "one.txt": "", + "two.txt": "", + }), + ) + .await; + + let project = Project::test(fs, ["root".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let item1 = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[new_test_project_item( + 1, + "one.txt", + worktree_id, + cx, + )]) + }); + let item2 = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[new_test_project_item( + 2, + "two.txt", + worktree_id, + cx, + )]) + }); + + // Initially no document path + assert_eq!(cx.document_path(), None); + + // Add an item - document path should be set + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item1), None, true, window, cx) + }); + assert_eq!( + cx.document_path(), + Some(std::path::PathBuf::from("root/one.txt")) + ); + + // Add a second item - document path should update + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item2), None, true, window, cx) + }); + assert_eq!( + cx.document_path(), + Some(std::path::PathBuf::from("root/two.txt")) + ); + + // Close the active item - document path should revert to first item + pane.update_in(cx, |pane, window, cx| { + pane.close_active_item(&Default::default(), window, cx) + }) + .await + .unwrap(); + assert_eq!( + cx.document_path(), + Some(std::path::PathBuf::from("root/one.txt")) + ); + + // Close all items - document path should be cleared + pane.update_in(cx, |pane, window, cx| { + pane.close_active_item(&Default::default(), window, cx) + }) + .await + .unwrap(); + assert_eq!(cx.document_path(), None); + } + #[gpui::test] async fn test_close_window(cx: &mut TestAppContext) { init_test(cx); @@ -15321,6 +15408,21 @@ mod tests { item } + fn new_test_project_item( + id: u64, + path: &str, + worktree_id: WorktreeId, + cx: &mut App, + ) -> Entity { + let item = TestProjectItem::new(id, path, cx); + item.update(cx, |item, _| { + if let Some(ref mut project_path) = item.project_path { + project_path.worktree_id = worktree_id; + } + }); + item + } + #[gpui::test] async fn test_zoomed_panel_without_pane_preserved_on_center_focus( cx: &mut gpui::TestAppContext,