mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user