From c526bfd59b70015e1806ca64e129b5bb9d986adb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:56:41 +0200 Subject: [PATCH] fix(sidebar): let groups that arrive from elsewhere pull no tab into a folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window draws its first frames before its copy of the workspace's groups lands, so every tab's entry watch recorded "in no folder" — and the groups landing then read as each tab walking into its folder. A restored tab, or one dragged out of its folder group, was pulled back in on every launch. Groups adopted from a pull or from another window's `GroupsChanged` now start every tab's watch over from where it is; only this window's own pin gathers the tabs inside the folder, and says so tab by tab. A tab also goes up with the group it names even when the window does not know that group yet, so a sync in that same gap cannot send every kept tab back to auto grouping. --- src/ui/tab_sidebar.rs | 52 +++++++++++++++++++++++++++++++++++++++++-- src/ui/tree_sync.rs | 17 ++++++-------- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 5a614a19..65cd43d7 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -63,7 +63,7 @@ mod row_metrics { pub(super) const BRANCH_ICON: f32 = 11.; /// `pl_2` + `pr_1p5` on a group header. pub(super) const HEADER_PAD: f32 = 8. + 6.; - /// The chevron a header opens with, and the asterisk that marks a custom + /// The chevron a header opens with, and the pin that marks a folder /// group: both `xsmall` icons, which resolve to 12px. pub(super) const HEADER_ICON: f32 = 12.; @@ -94,7 +94,7 @@ const ROW_CWD_FLOOR: f32 = 24.; /// rows share. The branch takes what it wants up to half the line, and the /// heading keeps the rest — so a long branch can no longer crush the name /// (flex used to hand the overflow to them in proportion to what each asked -/// for, which gave the longer string the smaller cut), and a long custom name +/// for, which gave the longer string the smaller cut), and a long group name /// cannot crush the branch in return. `git_want` is `None` for a header with /// no shared branch on it, which then owns the whole line. fn header_name_avail(avail: f32, git_want: Option) -> f32 { @@ -2059,6 +2059,28 @@ impl Tty7App { cx.notify(); } + /// Take the workspace's groups as another window, or the machine, has them. + /// + /// Every tab's [`EntryWatch`](crate::core::group_key::EntryWatch) starts + /// over from where it is. A folder that arrives from elsewhere is not one + /// this window's tabs walked into — and in particular, a window that drew + /// a frame before its copy of the groups landed saw no folders at all, so + /// without this every tab sitting in a pinned folder at launch would read + /// the groups landing as an entry, and a tab dragged out of its folder + /// group would be pulled back in on every start. The window that pinned + /// the folder gathers the tabs itself and says so tab by tab. + pub(crate) fn adopt_sidebar_groups(&mut self, groups: WorkspaceGroups, cx: &mut Context) { + if self.sidebar_groups == groups { + return; + } + self.sidebar_groups = groups; + for tab in &self.tabs { + tab.folder_watch + .set(crate::core::group_key::EntryWatch::baseline()); + } + cx.notify(); + } + /// Put the dragged tab where it was dropped. /// /// By id rather than index: a drag is several frames long, and a tab @@ -3913,6 +3935,32 @@ mod fold_tests { ); }); } + + /// Groups that arrive from another window, or from the machine at launch, + /// are not folders this window's tabs walked into. A tab sitting in one — + /// here, one that was dragged out of it — stays where it is. + #[gpui::test] + fn groups_arriving_from_elsewhere_pull_no_tab_in(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + let mut groups = WorkspaceGroups::default(); + groups + .pinned + .push(PinnedGroup::folder(Path::new("/w/probed"))); + app.adopt_sidebar_groups(groups, cx); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), None); + }); + } } #[cfg(test)] diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 901ecf1f..70d75ecc 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -133,9 +133,11 @@ pub(crate) fn desired_tabs( out.push(DesiredTab { id, name: tab.name.clone(), - // A tab still naming a group this window has deleted is an auto - // tab, and goes up as one. - group: tab.group.get().filter(|g| app.sidebar_groups.contains(*g)), + // As the tab has it, even when this window does not know the + // group: a window whose copy of the groups has not landed yet + // would otherwise send every tab it holds back to auto grouping. + // A group that really is gone was already cleared by the machine. + group: tab.group.get(), root, }); } @@ -1599,12 +1601,7 @@ fn adopt_groups(cx: &mut App, client_ws: WorkspaceId) { else { return; }; - app.update(cx, |app, cx| { - if app.sidebar_groups != groups { - app.sidebar_groups = groups; - cx.notify(); - } - }); + app.update(cx, |app, cx| app.adopt_sidebar_groups(groups, cx)); }); } @@ -2777,7 +2774,7 @@ impl Tty7App { true } LayoutDelta::GroupsChanged { groups } => { - self.sidebar_groups = groups.clone(); + self.adopt_sidebar_groups(groups.clone(), cx); true } LayoutDelta::TabMoved { tab, to } => {