feat(sidebar): open folders as pinned groups from Finder, the file tree and the palette

Every way into a pinned group the design calls for:

- Drop a folder from Finder or Explorer onto the sidebar to pin it (a local
  workspace only — a dropped path is this machine's, and a folder group keeps
  a directory on the workspace's host). Files are let fall.
- "Pin as Group" on a folder in the file tree, on local and remote workspaces
  alike, since the tree and the group are both on the workspace's host.
- Palette "New Group" makes an empty label group and opens its name for
  typing; "Open Folder as Group…" picks a folder with the system picker, pins
  it and opens a tab in it. The picker browses this computer, so that one is
  not offered on a remote workspace.
- Tab right-click "Move to Group" lists the pinned groups plus "New Group…",
  which files the tab in a fresh label group with its name open for typing.

Pinning a folder already pinned hands back the group that keeps it rather
than making a second one to split its tabs with.
This commit is contained in:
l0ng-ai
2026-09-25 02:53:40 +02:00
parent bcbff199c9
commit 2e16e648cc
4 changed files with 164 additions and 8 deletions
+28
View File
@@ -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::<Config>().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),
+22
View File
@@ -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::<crate::core::config::Config>().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<Self>,
) -> 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(
+6
View File
@@ -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
+108 -8
View File
@@ -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::<ExternalPaths>(|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<Self>) {
pub(crate) fn pin_folder(&mut self, folder: PathBuf, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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)]