Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { useRemoveFromSidebarIntent } from "renderer/stores/remove-workspace-fro

/**
* Headless effect that unpins a workspace from the sidebar as soon as a
* removal is requested. Unpinning is reversible — the workspace itself is not
* deleted and can be re-pinned from the Workspaces page or sidebar — so it runs
* removal is requested. Unpinning is reversible — the workspace is not deleted,
* its pane layout is kept on the hidden row, and re-pinning it from the
* Workspaces page or sidebar brings back its tabs and splits — so it runs
* immediately with no confirmation dialog. Lives here (rather than at each call
* site) because callers like the command palette fire imperatively and can't
* use the router/collections hooks the removal needs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type SidebarWorkspaceRow,
tombstoneSidebarWorkspaceRecord,
} from "./sidebarMutations";
import { ensureSidebarWorkspaceRecord } from "./useDashboardSidebarState";

/**
* Minimal in-memory stand-in for a TanStack DB collection, implementing only
Expand Down Expand Up @@ -33,6 +34,12 @@ function makeCollection<T>(getKey: (item: T) => string) {
};
}

type PaneLayout = {
version: number;
tabs: unknown[];
activeTabId: string | null;
};

type LocalStateRow = {
workspaceId: string;
createdAt: Date;
Expand All @@ -43,13 +50,56 @@ type LocalStateRow = {
isHidden: boolean;
pinnedAt: number | null;
};
paneLayout: { version: number; tabs: unknown[]; activeTabId: string | null };
paneLayout: PaneLayout;
};

/** A layout with real content: two tabs, one of them split across two panes. */
function populatedPaneLayout(workspaceId: string): PaneLayout {
return {
version: 1,
tabs: [
{
id: `${workspaceId}-tab-1`,
title: "agent",
panes: {
"pane-1": {
id: "pane-1",
kind: "terminal",
data: { terminalId: `${workspaceId}-term-1` },
},
"pane-2": {
id: "pane-2",
kind: "terminal",
data: { terminalId: `${workspaceId}-term-2` },
},
},
layout: {
type: "split",
direction: "row",
first: { type: "pane", paneId: "pane-1" },
second: { type: "pane", paneId: "pane-2" },
},
activePaneId: "pane-1",
},
{
id: `${workspaceId}-tab-2`,
title: "docs",
panes: {
"pane-3": { id: "pane-3", kind: "browser", data: {} },
},
layout: { type: "pane", paneId: "pane-3" },
activePaneId: "pane-3",
},
],
activeTabId: `${workspaceId}-tab-1`,
};
}

function localStateRow(
workspaceId: string,
projectId: string,
overrides: Partial<LocalStateRow["sidebarState"]> = {},
paneLayout: PaneLayout = { version: 1, tabs: [], activeTabId: null },
): LocalStateRow {
return {
workspaceId,
Expand All @@ -62,7 +112,7 @@ function localStateRow(
pinnedAt: null,
...overrides,
},
paneLayout: { version: 1, tabs: [], activeTabId: null },
paneLayout,
};
}

Expand Down Expand Up @@ -95,6 +145,11 @@ function asTombstoneArg(collections: Collections) {
typeof tombstoneSidebarWorkspaceRecord
>[0];
}
function asEnsureArg(collections: Collections) {
return collections as unknown as Parameters<
typeof ensureSidebarWorkspaceRecord
>[0];
}

const noopCleanup = () => {};

Expand Down Expand Up @@ -212,6 +267,35 @@ describe("removeProjectFromSidebarState", () => {
expect(row?.sidebarState.isHidden).toBe(false);
});

it("keeps the pane layout of each tombstoned worktree so re-adding the project restores them", () => {
const collections = makeCollections();
const layout = populatedPaneLayout("ws-placed");
collections.v2WorkspaceLocalState.insert(
localStateRow("ws-placed", "proj-1", {}, layout),
);
const workspaces: SidebarWorkspaceRow[] = [
{
id: "ws-placed",
projectId: "proj-1",
hostId: "machine-1",
type: "worktree",
},
];
collections.v2SidebarProjects.insert({ projectId: "proj-1" });

removeProjectFromSidebarState(
asRemoveArg(collections),
workspaces,
"proj-1",
"machine-1",
noopCleanup,
);

const row = collections.v2WorkspaceLocalState.get("ws-placed");
expect(row?.sidebarState.isHidden).toBe(true);
expect(row?.paneLayout).toEqual(layout);
});

it("leaves workspaces from other projects untouched", () => {
const collections = makeCollections();
collections.v2WorkspaceLocalState.insert(
Expand Down Expand Up @@ -312,4 +396,50 @@ describe("tombstoneSidebarWorkspaceRecord", () => {
expect(row?.sidebarState.pinnedAt).toBeNull();
expect(cleaned).toEqual(["ws-1"]);
});

it("keeps the row's pane layout — hiding is a visibility change, not a reset", () => {
// Hiding is advertised as reversible, and the re-add path only flips
// `isHidden` back to false: dropping paneLayout here loses every tab and
// split with no way to get them back.
const collections = makeCollections();
const layout = populatedPaneLayout("ws-1");
collections.v2WorkspaceLocalState.insert(
localStateRow("ws-1", "proj-1", {}, layout),
);

tombstoneSidebarWorkspaceRecord(
asTombstoneArg(collections),
"ws-1",
"proj-1",
noopCleanup,
);

expect(collections.v2WorkspaceLocalState.get("ws-1")?.paneLayout).toEqual(
layout,
);
});
});

describe("hide → re-add round trip", () => {
it("brings the workspace back with its tabs, splits and terminal ids intact", () => {
// The journey from the bug report: click "Remove from sidebar", then add
// the workspace back. It must return with its work, not empty.
const collections = makeCollections();
const layout = populatedPaneLayout("ws-1");
collections.v2WorkspaceLocalState.insert(
localStateRow("ws-1", "proj-1", {}, layout),
);

tombstoneSidebarWorkspaceRecord(
asTombstoneArg(collections),
"ws-1",
"proj-1",
noopCleanup,
);
ensureSidebarWorkspaceRecord(asEnsureArg(collections), "ws-1", "proj-1");

const row = collections.v2WorkspaceLocalState.get("ws-1");
expect(row?.sidebarState.isHidden).toBe(false);
expect(row?.paneLayout).toEqual(layout);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ type CleanupPaneRuntimes = (rows: PaneLifecycleRow[]) => void;
* with no local-state row is re-surfaced by the gated auto-include path, so
* hiding one requires a row (`isHidden: true`) to suppress it; a hard-delete
* would let it reappear.
*
* Hiding is a visibility change only: the row keeps its `paneLayout`, so
* re-adding the workspace restores its tabs and splits.
*/
export function tombstoneSidebarWorkspaceRecord(
collections: Pick<AppCollections, "v2WorkspaceLocalState">,
Expand All @@ -53,6 +56,12 @@ export function tombstoneSidebarWorkspaceRecord(
return;
}

// Runtimes are released — xterm views detached, WebSockets closed, webviews
// destroyed — because nothing renders a hidden workspace. `paneLayout` is
// deliberately kept: it is the only record of this workspace's tabs, splits
// and terminal ids, and releasing a terminal runtime does not kill its PTY.
// Keeping it is what makes hiding reversible — re-adding the workspace
// remounts the same panes and reattaches to the still-running sessions.
cleanupPaneRuntimes([existing]);
collections.v2WorkspaceLocalState.update(workspaceId, (draft) => {
draft.sidebarState.projectId = projectId;
Expand All @@ -61,7 +70,6 @@ export function tombstoneSidebarWorkspaceRecord(
// A row must never be hidden and pinned at once — a resurrected
// workspace would otherwise reappear pre-pinned.
draft.sidebarState.pinnedAt = null;
draft.paneLayout = createEmptyPaneLayout();
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ function ensureSidebarProjectRecord(
});
}

function ensureSidebarWorkspaceRecord(
/**
* Re-surfaces a workspace in the sidebar. A hidden ("tombstoned") row is only
* un-hidden and re-placed — `paneLayout` is left alone, which is what makes
* hiding reversible: the tabs and splits the row still carries come back with
* it.
*/
export function ensureSidebarWorkspaceRecord(
collections: Pick<
AppCollections,
"v2SidebarSections" | "v2WorkspaceLocalState"
Expand Down