diff --git a/src/ui/app.rs b/src/ui/app.rs index 8418623a..677c4dcd 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5437,6 +5437,32 @@ impl Tty7App { // saved host has nothing to save, and a pane that is not an SSH one has // no connection at all — either would be a row that quietly did nothing // (#549). + // Groups are something the sidebar draws, so they are offered only + // while the tabs are in it. Opening a folder as a group asks the + // system picker, which browses this computer: a path picked there + // names nothing on a remote workspace's machine, where "Pin as + // Group" in the file tree is the way in instead. + if cx.global::().tab_bar_position == TabBarPosition::Left { + commands.push( + Command::localized(L10nKey::CmdNewGroup, CommandKind::NewGroup) + .with_subtitle(t(L10nKey::CmdNewGroupSubtitle)) + .in_group(CommandGroup::TabsPanes), + ); + if !WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()) + { + commands.push( + Command::localized( + L10nKey::CmdOpenFolderAsGroup, + CommandKind::OpenFolderAsGroup, + ) + .with_subtitle(t(L10nKey::CmdOpenFolderAsGroupSubtitle)) + .in_group(CommandGroup::TabsPanes), + ); + } + } + if self.unsaved_ssh_session(window, cx).is_some() { commands.push( Command::localized( @@ -5642,6 +5668,8 @@ impl Tty7App { ReopenClosedTab => self.reopen_closed_tab(window, cx), RenameTab => self.start_rename(self.active, window, cx), NewWorktreeTab => self.new_worktree_tab(self.active, window, cx), + NewGroup => self.new_empty_group(window, cx), + OpenFolderAsGroup => self.open_folder_as_group(cx), CloseOtherTabs => self.close_other_tabs(self.active, window, cx), CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx), CopyWorkingDirectory => self.copy_active_cwd(window, cx), diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index e290a5ff..60101fe3 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1963,6 +1963,10 @@ impl Tty7App { let paths_are_local = self.spawn_host(cx).is_local(); move |menu, _window, cx| { let danger = cx.theme().danger; + // A group is something the sidebar draws; with the tabs + // along the top there is nowhere to show one. + let groups_shown = cx.global::().tab_bar_position + == crate::core::config::TabBarPosition::Left; Self::tree_row_context_menu( menu, &path, @@ -1970,6 +1974,7 @@ impl Tty7App { is_root, show_hidden, paths_are_local, + groups_shown, danger, &app, ) @@ -2012,6 +2017,10 @@ impl Tty7App { // here — silently opening nothing, or the wrong thing if a local path // happens to collide. paths_are_local: bool, + // Whether "Pin as Group" has a sidebar to put the group in. Offered + // on a remote workspace too, unlike the file manager above: the tree + // and a pinned folder are both on the workspace's own host. + groups_shown: bool, danger: gpui::Hsla, app: &gpui::WeakEntity, ) -> PopupMenu { @@ -2039,6 +2048,19 @@ impl Tty7App { } }), ); + if groups_shown { + menu = menu.item( + PopupMenuItem::new(t(L10nKey::FileTreeContextPinAsGroup)).on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| { + this.pin_folder(p.clone(), cx); + }); + } + }), + ); + } } menu = menu .item( diff --git a/src/ui/palette.rs b/src/ui/palette.rs index a673e772..4906768f 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -29,6 +29,8 @@ pub enum CommandKind { ClosePane, RenameTab, NewWorktreeTab, + NewGroup, + OpenFolderAsGroup, CloseOtherTabs, CloseTabsToTheRight, CopyWorkingDirectory, @@ -137,6 +139,8 @@ impl CommandKind { ClosePane => "close-pane", RenameTab => "rename-tab", NewWorktreeTab => "new-worktree-tab", + NewGroup => "new-sidebar-group", + OpenFolderAsGroup => "open-folder-as-group", CloseOtherTabs => "close-other-tabs", CloseTabsToTheRight => "close-tabs-right", CopyWorkingDirectory => "copy-cwd", @@ -324,6 +328,8 @@ impl CommandKind { | SelectAllText | SendSelectionToAgent | SendGitDiffToAgent + | NewGroup + | OpenFolderAsGroup | UpdateLocalServer | UpdateRemoteServer | OpenWorkspacePicker diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index a144a988..5a614a19 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -1,7 +1,8 @@ use gpui::{ - Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, canvas, - deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px, + Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, ExternalPaths, + FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, + Stateful, Window, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient, + prelude::*, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent}; @@ -249,6 +250,11 @@ impl Tty7App { let mut list = v_flex() .id("tab-sidebar-list") + // A folder dragged in from Finder is pinned as a group of its own. + .drag_over::(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.06))) + .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { + this.pin_dropped_folders(paths.paths(), cx) + })) .track_scroll(&self.sidebar_scroll) .flex_1() .min_h_0() @@ -2179,18 +2185,63 @@ impl Tty7App { /// Tabs already sitting in the folder are gathered in on the next frame, /// the way a tab walking in would be: the folder is new, so each of them /// has just "entered" it as far as [`EntryWatch`](crate::core::group_key::EntryWatch) can tell. - pub(crate) fn pin_folder(&mut self, folder: PathBuf, cx: &mut Context) { + pub(crate) fn pin_folder(&mut self, folder: PathBuf, cx: &mut Context) -> GroupId { let spelled = folder.to_string_lossy(); - if self + if let Some(kept) = self .sidebar_groups .pinned .iter() - .any(|g| g.folder.as_deref() == Some(&*spelled)) + .find(|g| g.folder.as_deref() == Some(&*spelled)) + { + return kept.id; + } + let group = PinnedGroup::folder(&folder); + let id = group.id; + self.edit_groups(cx, |groups| groups.pinned.push(group)); + id + } + + /// The palette's "Open Folder as Group…": pick a folder on this computer, + /// pin it, and open a tab in it — "open" is the promise, and an empty group + /// with nothing in it would not keep it. + pub(crate) fn open_folder_as_group(&mut self, cx: &mut Context) { + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = rx.await + && let Some(path) = paths.into_iter().next() + { + let _ = this.update_in(cx, |this, window, cx| { + let id = this.pin_folder(path, cx); + this.new_tab_in_group(GroupKey::Pinned(id), window, cx); + }); + } + }) + .detach(); + } + + /// Folders dropped on the sidebar from Finder or Explorer, pinned as + /// groups. Anything that is not a directory is let fall: a file names no + /// group, and guessing its parent would pin something nobody pointed at. + /// + /// Only on a workspace on this computer. A dropped path is this + /// machine's, and a folder group keeps a directory on the workspace's + /// host — on a remote one the same spelling names something else, or + /// nothing at all. + pub(crate) fn pin_dropped_folders(&mut self, paths: &[PathBuf], cx: &mut Context) { + if crate::core::session::WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()) { return; } - let group = PinnedGroup::folder(&folder); - self.edit_groups(cx, |groups| groups.pinned.push(group)); + for path in paths.iter().filter(|p| p.is_dir()) { + self.pin_folder(path.clone(), cx); + } } /// Delete pinned group `id`. Its tabs are not closed — they go back to @@ -3813,6 +3864,55 @@ mod fold_tests { assert_eq!(group.given_name(), Some("tty7"), "a set name stays"); }); } + /// A folder dropped from Finder is pinned; a file is let fall, and a + /// folder already pinned is not pinned twice. + #[gpui::test] + fn a_dropped_folder_is_pinned_and_a_dropped_file_is_not(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("notes.txt"); + std::fs::write(&file, "x").expect("write"); + + app.update(&mut vcx, |app, cx| { + let paths = [dir.path().to_path_buf(), file.clone()]; + app.pin_dropped_folders(&paths, cx); + app.pin_dropped_folders(&paths, cx); + let folders: Vec<_> = app + .sidebar_groups + .pinned + .iter() + .map(|g| g.folder.clone()) + .collect(); + assert_eq!( + folders, + vec![Some(dir.path().to_string_lossy().into_owned())] + ); + }); + } + + /// The palette's "New Group" makes an empty pinned group and opens its + /// name for typing. + #[gpui::test] + fn the_palette_makes_an_empty_group_ready_to_name(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update_in(&mut vcx, |app, window, cx| { + app.new_empty_group(window, cx); + let id = app.sidebar_groups.pinned[0].id; + assert_eq!(app.group_rename.as_ref().map(|r| r.group), Some(id)); + assert!(app.tabs.iter().all(|t| t.group.get().is_none())); + let kinds: Vec<_> = app + .palette_commands(window, cx) + .into_iter() + .map(|c| c.kind) + .collect(); + assert!(kinds.contains(&crate::ui::palette::CommandKind::NewGroup)); + assert!( + kinds.contains(&crate::ui::palette::CommandKind::OpenFolderAsGroup), + "a workspace on this computer can pick a folder" + ); + }); + } } #[cfg(test)]