gpui: Add support for NSWindow's representedFilename (#48029)

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.

<img width="1370" height="1162" alt="Screenshot 2026-01-30 at 16 18 25"
src="https://github.com/user-attachments/assets/dc260252-91fb-41e1-97a9-e6fb843c6a70"
/>

Release Notes:

- Set the represented filename property of windows on macOS

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
This commit is contained in:
Daniel Gräfe 2026-04-24 05:48:27 +02:00 committed by GitHub
parent 9adb4ea63e
commit 385f6134bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 135 additions and 2 deletions

View file

@ -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<std::path::PathBuf> {
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) {

View file

@ -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<dyn FnMut()>) {}

View file

@ -20,6 +20,7 @@ pub(crate) struct TestWindowState {
display: Rc<dyn PlatformDisplay>,
pub(crate) title: Option<String>,
pub(crate) edited: bool,
pub(crate) document_path: Option<std::path::PathBuf>,
platform: Weak<TestPlatform>,
// TODO: Replace with `Rc`
sprite_atlas: Arc<dyn PlatformAtlas>,
@ -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!()
}

View file

@ -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<Rc<dyn PlatformDisplay>> {
cx.platform

View file

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

View file

@ -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<TestProjectItem> {
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,