From 04bd57b2c7870bb198bd3a3b51769ce4d276e382 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:45:55 -0700 Subject: [PATCH 1/5] Add an API for setting a window's title This controls how the window appears in the Window menu. --- crates/gpui/src/app.rs | 22 ++++++++++++++++++++-- crates/gpui/src/platform.rs | 1 + crates/gpui/src/platform/mac/platform.rs | 5 +++++ crates/gpui/src/platform/mac/window.rs | 9 ++++++++- crates/gpui/src/platform/test.rs | 10 ++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index eb4b9650a6..2604848e3b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -542,12 +542,23 @@ impl TestAppContext { !prompts.is_empty() } - #[cfg(any(test, feature = "test-support"))] + pub fn current_window_title(&self, window_id: usize) -> Option { + let mut state = self.cx.borrow_mut(); + let (_, window) = state + .presenters_and_platform_windows + .get_mut(&window_id) + .unwrap(); + let test_window = window + .as_any_mut() + .downcast_mut::() + .unwrap(); + test_window.title.clone() + } + pub fn leak_detector(&self) -> Arc> { self.cx.borrow().leak_detector() } - #[cfg(any(test, feature = "test-support"))] pub fn assert_dropped(&self, handle: impl WeakHandle) { self.cx .borrow() @@ -3265,6 +3276,13 @@ impl<'a, T: View> ViewContext<'a, T> { self.app.focus(self.window_id, None); } + pub fn set_window_title(&mut self, title: &str) { + let window_id = self.window_id(); + if let Some((_, window)) = self.presenters_and_platform_windows.get_mut(&window_id) { + window.set_title(title); + } + } + pub fn add_model(&mut self, build_model: F) -> ModelHandle where S: Entity, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index c4b68c0741..16a6481a43 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -96,6 +96,7 @@ pub trait Window: WindowContext { fn on_close(&mut self, callback: Box); fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver; fn activate(&self); + fn set_title(&mut self, title: &str); } pub trait WindowContext { diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 26cde46c04..7ace58f428 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -202,6 +202,11 @@ impl MacForegroundPlatform { menu_bar_item.setSubmenu_(menu); menu_bar.addItem_(menu_bar_item); + + if menu_name == "Window" { + let app: id = msg_send![APP_CLASS, sharedApplication]; + app.setWindowsMenu_(menu); + } } menu_bar diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 518cefcd60..5d6848cd7b 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -386,8 +386,15 @@ impl platform::Window for Window { } fn activate(&self) { + unsafe { msg_send![self.0.borrow().native_window, makeKeyAndOrderFront: nil] } + } + + fn set_title(&mut self, title: &str) { unsafe { - let _: () = msg_send![self.0.borrow().native_window, makeKeyAndOrderFront: nil]; + let app = NSApplication::sharedApplication(nil); + let window = self.0.borrow().native_window; + let title = ns_string(title); + msg_send![app, changeWindowsItem:window title:title filename:false] } } } diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index a3d5cc5406..e22db89e3b 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -37,6 +37,7 @@ pub struct Window { event_handlers: Vec>, resize_handlers: Vec>, close_handlers: Vec>, + pub(crate) title: Option, pub(crate) pending_prompts: RefCell>>, } @@ -189,9 +190,14 @@ impl Window { close_handlers: Vec::new(), scale_factor: 1.0, current_scene: None, + title: None, pending_prompts: Default::default(), } } + + pub fn title(&self) -> Option { + self.title.clone() + } } impl super::Dispatcher for Dispatcher { @@ -248,6 +254,10 @@ impl super::Window for Window { } fn activate(&self) {} + + fn set_title(&mut self, title: &str) { + self.title = Some(title.to_string()) + } } pub fn platform() -> Platform { From a1a4c7084582236cf83e9bdf5cffe3142fb55781 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:48:47 -0700 Subject: [PATCH 2/5] Emit an event when adding a worktree to a project --- crates/collab/src/rpc.rs | 21 +++++++++++---------- crates/project/src/project.rs | 2 ++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7ea925fce7..6371e51784 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2234,7 +2234,6 @@ mod tests { .read_with(cx_a, |project, _| project.next_remote_id()) .await; - let project_a_events = Rc::new(RefCell::new(Vec::new())); let user_b = client_a .user_store .update(cx_a, |store, cx| { @@ -2242,15 +2241,6 @@ mod tests { }) .await .unwrap(); - project_a.update(cx_a, { - let project_a_events = project_a_events.clone(); - move |_, cx| { - cx.subscribe(&cx.handle(), move |_, _, event, _| { - project_a_events.borrow_mut().push(event.clone()); - }) - .detach(); - } - }); let (worktree_a, _) = project_a .update(cx_a, |p, cx| { @@ -2262,6 +2252,17 @@ mod tests { .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) .await; + let project_a_events = Rc::new(RefCell::new(Vec::new())); + project_a.update(cx_a, { + let project_a_events = project_a_events.clone(); + move |_, cx| { + cx.subscribe(&cx.handle(), move |_, _, event, _| { + project_a_events.borrow_mut().push(event.clone()); + }) + .detach(); + } + }); + // Request to join that project as client B let project_b = cx_b.spawn(|mut cx| { let client = client_b.client.clone(); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index abcd667293..964ee1c97e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -139,6 +139,7 @@ pub struct Collaborator { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Event { ActiveEntryChanged(Option), + WorktreeAdded, WorktreeRemoved(WorktreeId), DiskBasedDiagnosticsStarted, DiskBasedDiagnosticsUpdated, @@ -3637,6 +3638,7 @@ impl Project { self.worktrees .push(WorktreeHandle::Weak(worktree.downgrade())); } + cx.emit(Event::WorktreeAdded); cx.notify(); } From e6be151a642bbc790d851294839b2b8b0784e35a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:49:10 -0700 Subject: [PATCH 3/5] Emit the WorktreeRemoved event when removing a worktree from a project --- crates/project/src/project.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 964ee1c97e..808561f110 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3603,11 +3603,19 @@ impl Project { }) } - pub fn remove_worktree(&mut self, id: WorktreeId, cx: &mut ModelContext) { + pub fn remove_worktree(&mut self, id_to_remove: WorktreeId, cx: &mut ModelContext) { self.worktrees.retain(|worktree| { - worktree - .upgrade(cx) - .map_or(false, |w| w.read(cx).id() != id) + if let Some(worktree) = worktree.upgrade(cx) { + let id = worktree.read(cx).id(); + if id == id_to_remove { + cx.emit(Event::WorktreeRemoved(id)); + false + } else { + true + } + } else { + false + } }); cx.notify(); } From a88b4eb3c507f740f2e35a0b29353953c43fc962 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:51:12 -0700 Subject: [PATCH 4/5] Populate the window title whenever worktrees or active path change * Refactor the way the project's active entry is assigned. Assign it together with the window title, as opposed to on every notification from a pane. * Emit the ActiveItem event from panes consistently, even when adding the first item to an empty pane. --- crates/workspace/src/pane.rs | 31 +++-- crates/workspace/src/workspace.rs | 223 ++++++++++++++++++++++++------ crates/zed/src/menus.rs | 4 + 3 files changed, 210 insertions(+), 48 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 8b97ef1a80..f6c516a445 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -15,7 +15,7 @@ use gpui::{ use project::{Project, ProjectEntryId, ProjectPath}; use serde::Deserialize; use settings::Settings; -use std::{any::Any, cell::RefCell, cmp, mem, path::Path, rc::Rc}; +use std::{any::Any, cell::RefCell, mem, path::Path, rc::Rc}; use util::ResultExt; actions!( @@ -109,6 +109,7 @@ pub enum Event { ActivateItem { local: bool }, Remove, Split(SplitDirection), + ChangeItemTitle, } pub struct Pane { @@ -334,9 +335,20 @@ impl Pane { item.set_nav_history(pane.read(cx).nav_history.clone(), cx); item.added_to_pane(workspace, pane.clone(), cx); pane.update(cx, |pane, cx| { - let item_idx = cmp::min(pane.active_item_index + 1, pane.items.len()); - pane.items.insert(item_idx, item); - pane.activate_item(item_idx, activate_pane, focus_item, cx); + // If there is already an active item, then insert the new item + // right after it. Otherwise, adjust the `active_item_index` field + // before activating the new item, so that in the `activate_item` + // method, we can detect that the active item is changing. + let item_ix; + if pane.active_item_index < pane.items.len() { + item_ix = pane.active_item_index + 1 + } else { + item_ix = pane.items.len(); + pane.active_item_index = usize::MAX; + }; + + pane.items.insert(item_ix, item); + pane.activate_item(item_ix, activate_pane, focus_item, cx); cx.notify(); }); } @@ -383,11 +395,12 @@ impl Pane { use NavigationMode::{GoingBack, GoingForward}; if index < self.items.len() { let prev_active_item_ix = mem::replace(&mut self.active_item_index, index); - if matches!(self.nav_history.borrow().mode, GoingBack | GoingForward) - || (prev_active_item_ix != self.active_item_index - && prev_active_item_ix < self.items.len()) + if prev_active_item_ix != self.active_item_index + || matches!(self.nav_history.borrow().mode, GoingBack | GoingForward) { - self.items[prev_active_item_ix].deactivated(cx); + if let Some(prev_item) = self.items.get(prev_active_item_ix) { + prev_item.deactivated(cx); + } cx.emit(Event::ActivateItem { local: activate_pane, }); @@ -424,7 +437,7 @@ impl Pane { self.activate_item(index, true, true, cx); } - fn close_active_item( + pub fn close_active_item( workspace: &mut Workspace, _: &CloseActiveItem, cx: &mut ViewContext, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e9f0efa311..fc8d3ba16e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -38,6 +38,7 @@ use status_bar::StatusBar; pub use status_bar::StatusItemView; use std::{ any::{Any, TypeId}, + borrow::Cow, cell::RefCell, fmt, future::Future, @@ -532,7 +533,10 @@ impl ItemHandle for ViewHandle { } if T::should_update_tab_on_event(event) { - pane.update(cx, |_, cx| cx.notify()); + pane.update(cx, |_, cx| { + cx.emit(pane::Event::ChangeItemTitle); + cx.notify(); + }); } }) .detach(); @@ -744,6 +748,9 @@ impl Workspace { project::Event::CollaboratorLeft(peer_id) => { this.collaborator_left(*peer_id, cx); } + project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => { + this.update_window_title(cx); + } _ => {} } if project.read(cx).is_read_only() { @@ -755,14 +762,8 @@ impl Workspace { let pane = cx.add_view(|cx| Pane::new(cx)); let pane_id = pane.id(); - cx.observe(&pane, move |me, _, cx| { - let active_entry = me.active_project_path(cx); - me.project - .update(cx, |project, cx| project.set_active_path(active_entry, cx)); - }) - .detach(); - cx.subscribe(&pane, move |me, _, event, cx| { - me.handle_pane_event(pane_id, event, cx) + cx.subscribe(&pane, move |this, _, event, cx| { + this.handle_pane_event(pane_id, event, cx) }) .detach(); cx.focus(&pane); @@ -825,6 +826,11 @@ impl Workspace { _observe_current_user, }; this.project_remote_id_changed(this.project.read(cx).remote_id(), cx); + + cx.defer(|this, cx| { + this.update_window_title(cx); + }); + this } @@ -1238,14 +1244,8 @@ impl Workspace { fn add_pane(&mut self, cx: &mut ViewContext) -> ViewHandle { let pane = cx.add_view(|cx| Pane::new(cx)); let pane_id = pane.id(); - cx.observe(&pane, move |me, _, cx| { - let active_entry = me.active_project_path(cx); - me.project - .update(cx, |project, cx| project.set_active_path(active_entry, cx)); - }) - .detach(); - cx.subscribe(&pane, move |me, _, event, cx| { - me.handle_pane_event(pane_id, event, cx) + cx.subscribe(&pane, move |this, _, event, cx| { + this.handle_pane_event(pane_id, event, cx) }) .detach(); self.panes.push(pane.clone()); @@ -1385,6 +1385,7 @@ impl Workspace { self.status_bar.update(cx, |status_bar, cx| { status_bar.set_active_pane(&self.active_pane, cx); }); + self.active_item_path_changed(cx); cx.focus(&self.active_pane); cx.notify(); } @@ -1419,6 +1420,14 @@ impl Workspace { if *local { self.unfollow(&pane, cx); } + if pane == self.active_pane { + self.active_item_path_changed(cx); + } + } + pane::Event::ChangeItemTitle => { + if pane == self.active_pane { + self.active_item_path_changed(cx); + } } } } else { @@ -1451,6 +1460,8 @@ impl Workspace { self.unfollow(&pane, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); cx.notify(); + } else { + self.active_item_path_changed(cx); } } @@ -1638,15 +1649,7 @@ impl Workspace { fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { let mut worktree_root_names = String::new(); - { - let mut worktrees = self.project.read(cx).visible_worktrees(cx).peekable(); - while let Some(worktree) = worktrees.next() { - worktree_root_names.push_str(worktree.read(cx).root_name()); - if worktrees.peek().is_some() { - worktree_root_names.push_str(", "); - } - } - } + self.worktree_root_names(&mut worktree_root_names, cx); ConstrainedBox::new( Container::new( @@ -1682,6 +1685,50 @@ impl Workspace { .named("titlebar") } + fn active_item_path_changed(&mut self, cx: &mut ViewContext) { + let active_entry = self.active_project_path(cx); + self.project + .update(cx, |project, cx| project.set_active_path(active_entry, cx)); + self.update_window_title(cx); + } + + fn update_window_title(&mut self, cx: &mut ViewContext) { + let mut title = String::new(); + if let Some(path) = self.active_item(cx).and_then(|item| item.project_path(cx)) { + let filename = path + .path + .file_name() + .map(|s| s.to_string_lossy()) + .or_else(|| { + Some(Cow::Borrowed( + self.project() + .read(cx) + .worktree_for_id(path.worktree_id, cx)? + .read(cx) + .root_name(), + )) + }); + if let Some(filename) = filename { + title.push_str(filename.as_ref()); + title.push_str(" — "); + } + } + self.worktree_root_names(&mut title, cx); + if title.is_empty() { + title = "empty project".to_string(); + } + cx.set_window_title(&title); + } + + fn worktree_root_names(&self, string: &mut String, cx: &mut MutableAppContext) { + for (i, worktree) in self.project.read(cx).visible_worktrees(cx).enumerate() { + if i != 0 { + string.push_str(", "); + } + string.push_str(worktree.read(cx).root_name()); + } + } + fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext) -> Vec { let mut collaborators = self .project @@ -2417,6 +2464,110 @@ mod tests { use project::{FakeFs, Project, ProjectEntryId}; use serde_json::json; + #[gpui::test] + async fn test_tracking_active_path(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); + Settings::test_async(cx); + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root1", + json!({ + "one.txt": "", + "two.txt": "", + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "three.txt": "", + }), + ) + .await; + + let project = Project::test(fs, ["root1".as_ref()], cx).await; + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx)); + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let item1 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.project_path = Some((worktree_id, "one.txt").into()); + item + }); + let item2 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.project_path = Some((worktree_id, "two.txt").into()); + item + }); + + // Add an item to an empty pane + workspace.update(cx, |workspace, cx| workspace.add_item(Box::new(item1), cx)); + project.read_with(cx, |project, cx| { + assert_eq!( + project.active_entry(), + project.entry_for_path(&(worktree_id, "one.txt").into(), cx) + ); + }); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root1") + ); + + // Add a second item to a non-empty pane + workspace.update(cx, |workspace, cx| workspace.add_item(Box::new(item2), cx)); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("two.txt — root1") + ); + project.read_with(cx, |project, cx| { + assert_eq!( + project.active_entry(), + project.entry_for_path(&(worktree_id, "two.txt").into(), cx) + ); + }); + + // Close the active item + workspace + .update(cx, |workspace, cx| { + Pane::close_active_item(workspace, &Default::default(), cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root1") + ); + project.read_with(cx, |project, cx| { + assert_eq!( + project.active_entry(), + project.entry_for_path(&(worktree_id, "one.txt").into(), cx) + ); + }); + + // Add a project folder + project + .update(cx, |project, cx| { + project.find_or_create_local_worktree("/root2", true, cx) + }) + .await + .unwrap(); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root1, root2") + ); + + // Remove a project folder + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root2") + ); + } + #[gpui::test] async fn test_close_window(cx: &mut TestAppContext) { cx.foreground().forbid_parking(); @@ -2456,18 +2607,6 @@ mod tests { cx.foreground().run_until_parked(); assert!(!cx.has_pending_prompt(window_id)); assert_eq!(task.await.unwrap(), false); - - // If there are multiple dirty items representing the same project entry. - workspace.update(cx, |w, cx| { - w.add_item(Box::new(item2.clone()), cx); - w.add_item(Box::new(item3.clone()), cx); - }); - let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx)); - cx.foreground().run_until_parked(); - cx.simulate_prompt_answer(window_id, 2 /* cancel */); - cx.foreground().run_until_parked(); - assert!(!cx.has_pending_prompt(window_id)); - assert_eq!(task.await.unwrap(), false); } #[gpui::test] @@ -2667,6 +2806,7 @@ mod tests { is_dirty: bool, has_conflict: bool, project_entry_ids: Vec, + project_path: Option, is_singleton: bool, } @@ -2679,6 +2819,7 @@ mod tests { is_dirty: false, has_conflict: false, project_entry_ids: Vec::new(), + project_path: None, is_singleton: true, } } @@ -2704,7 +2845,7 @@ mod tests { } fn project_path(&self, _: &AppContext) -> Option { - None + self.project_path.clone() } fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> { @@ -2763,5 +2904,9 @@ mod tests { self.reload_count += 1; Task::ready(Ok(())) } + + fn should_update_tab_on_event(_: &Self::Event) -> bool { + true + } } } diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index cfe4ca0826..e90b716d02 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -229,6 +229,10 @@ pub fn menus() -> Vec> { }, ], }, + Menu { + name: "Window", + items: vec![MenuItem::Separator], + }, Menu { name: "Help", items: vec![MenuItem::Action { From 23cd948b5fcf767ceab49c78d615da803ba7a7fa Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:53:14 -0700 Subject: [PATCH 5/5] Adjust test to flush effects between splitting pane and following Panes now emit an event when adding the first item, so we need to flush effects between splitting and following in order to avoid accidentally cancelling the follow. --- crates/collab/src/rpc.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 6371e51784..51c2a42225 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -5856,6 +5856,9 @@ mod tests { .update(cx_a, |workspace, cx| { workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx); assert_ne!(*workspace.active_pane(), pane_a1); + }); + workspace_a + .update(cx_a, |workspace, cx| { let leader_id = *project_a.read(cx).collaborators().keys().next().unwrap(); workspace .toggle_follow(&workspace::ToggleFollow(leader_id), cx) @@ -5867,6 +5870,9 @@ mod tests { .update(cx_b, |workspace, cx| { workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx); assert_ne!(*workspace.active_pane(), pane_b1); + }); + workspace_b + .update(cx_b, |workspace, cx| { let leader_id = *project_b.read(cx).collaborators().keys().next().unwrap(); workspace .toggle_follow(&workspace::ToggleFollow(leader_id), cx)