Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ already in use` and no way forward short of finding the file in a temporary

### Desktop / Web UI

- **Switching to Code brings Code's tabs back** — once you had visited
**Work**, the tab strip stayed pinned to the sandbox folder you left:
switching to **Code** kept Work's tabs across the top of a Code session,
Code's own tabs appeared nowhere, and the **+** button opened new sessions in
the sandbox rather than in the project on screen. The hold that keeps the
strip still while a sandbox restarts no longer applies after you switch
sides.
- **A folder on Work is unmounted from its own menu** — the menu on a **Work**
project offered **Close**, which only took the row out of the sidebar while
the sandbox went on holding the folder: the folder was still mounted, still
Expand Down
41 changes: 41 additions & 0 deletions packages/app/src/workspace/side.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,47 @@ export function tabsForScope(tabs: Tab[], scope: TabScope) {
return tabs.filter(inScope(scope))
}

// What the strip shows next, given what it showed before. Pure and separate
// from useWorkspaceScope for the same reason folderEntry is: the Work side
// cannot be driven from the web e2e harness, and this decision chain is the
// part worth pinning down.
//
// The previous scope matters in exactly one case: on Work, with the selected
// sandbox's connection missing — a restart to apply a mounted folder — the
// strip holds the folder scope it had rather than widening to every folder of
// the sandbox and narrowing again. The hold is gated on the side still being
// Work: the sandbox also resolves to nothing once the user has switched to
// Code, and holding there pinned the sandbox's folder scope under Code — the
// strip went on showing Work's tabs while every Code tab was filtered out, and
// the + button opened drafts in the sandbox instead of the project on screen.
export function nextTabScope(input: {
side: WorkspaceSide
previous: TabScope | undefined
// Sandboxes that exist whether or not they are connected right now; the
// hold is only for a server that is coming back, never one that was removed.
retained: string[]
// The selected sandbox with its folder list and the folder to scope to,
// resolved by the caller; absent while its connection is away or before one
// is selected.
sandbox?: { server: ServerConnection.Key; directory?: string; folders: string[] }
}): TabScope {
if (input.side !== "work") return { type: "side", side: input.side }
if (!input.sandbox) {
if (input.previous?.type === "folder" && input.retained.includes(input.previous.server)) return input.previous
// No sandbox selected yet — one is still starting, or the last was
// removed. The side scope keeps the old behaviour for a state the sidebar
// already reports through WorkspaceEmpty.
return { type: "side", side: input.side }
}
if (!input.sandbox.directory) return { type: "side", side: input.side }
return {
type: "folder",
server: input.sandbox.server,
directory: input.sandbox.directory,
folders: input.sandbox.folders,
}
}

// Where selecting a mounted folder lands. Split out of the hook because the
// Work side cannot be driven from the web e2e harness — that build has no
// sandbox platform at all, so effectiveSide collapses it to Code — and this
Expand Down
36 changes: 19 additions & 17 deletions packages/app/src/workspace/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
folderEntry,
folderForDirectory,
leavesWorkEmpty,
nextTabScope,
selectionForSide,
sideForServer,
WORKSPACE_SIDES,
Expand Down Expand Up @@ -161,25 +162,26 @@ export function useWorkspaceScope(routeDirectory: () => string | undefined): Acc
return { key: ServerConnection.key(conn), ctx: global.ensureServerCtx(conn) }
})

// The decision lives in nextTabScope (side.ts) with the tests: it holds a
// restarting sandbox's folder scope still, but only while the side is still
// Work — sandbox() is also empty after a switch to Code, and holding there
// kept Work's tabs on the strip while every Code tab was filtered out.
const scope = createMemo<TabScope>((previous) => {
const current = sandbox()
// A sandbox that is merely away — restarting to apply a mounted folder —
// keeps the scope it had. Its folder list lives on the connection, which is
// gone for those seconds, and falling through would widen the strip to
// every folder of the sandbox and then narrow it again: a reshuffle in the
// middle of an operation the user is waiting on.
if (!current && previous?.type === "folder" && server.retained.includes(previous.server)) return previous
// No sandbox selected yet — one is still starting, or the last was removed.
// Falling back to the side keeps today's behaviour for a state the sidebar
// already reports through WorkspaceEmpty.
if (!current) return { type: "side", side: side() }
const folders = current.ctx.projects.list().map((project) => project.worktree)
const directory =
folderForDirectory(routeDirectory(), folders) ??
folderForDirectory(layout.home.selection().directory, folders) ??
primaryFolder(current.ctx)
if (!directory) return { type: "side", side: side() }
return { type: "folder", server: current.key, directory, folders }
const folders = current ? current.ctx.projects.list().map((project) => project.worktree) : []
return nextTabScope({
side: side(),
previous,
retained: server.retained,
sandbox: current && {
server: current.key,
folders,
directory:
folderForDirectory(routeDirectory(), folders) ??
folderForDirectory(layout.home.selection().directory, folders) ??
primaryFolder(current.ctx),
},
})
})

// Remember where each folder was left. Recorded here rather than beside the
Expand Down
49 changes: 49 additions & 0 deletions packages/app/test-browser/workspace-side.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
folderEntry,
folderForDirectory,
leavesWorkEmpty,
nextTabScope,
reorderWithinScope,
reorderWithinSide,
selectionForSide,
Expand Down Expand Up @@ -251,6 +252,54 @@ test("a folder holding an unresolved tab still reorders", () => {
])
})

const selectedSandbox = (server: string, directory?: string, folders = FOLDERS) =>
({ server, directory, folders }) as Parameters<typeof nextTabScope>[0]["sandbox"]

// The regression: every sandbox that exists is retained, and the sandbox
// resolver is empty off Work — so the restart hold below also fired on a plain
// side switch, pinning the Work folder scope under Code. The strip went on
// showing the sandbox's tabs, every Code tab was filtered out, and + opened
// drafts in the sandbox.
test("switching to Code scopes the strip to Code, whatever Work left behind", () => {
const previous = folderScope("sandbox:a", "/workspace")
expect(nextTabScope({ side: "code", previous, retained: ["sandbox:a"] })).toEqual({ type: "side", side: "code" })
})

// The case the hold is for: a sandbox restarting to apply a mounted folder
// leaves the connection list for a second or two while the user stays on Work.
test("on Work a restarting sandbox keeps the folder scope it had", () => {
const previous = folderScope("sandbox:a", "/workspace")
expect(nextTabScope({ side: "work", previous, retained: ["sandbox:a"] })).toBe(previous)
})

test("a removed sandbox does not hold its folder scope", () => {
const previous = folderScope("sandbox:a", "/workspace")
expect(nextTabScope({ side: "work", previous, retained: [] })).toEqual({ type: "side", side: "work" })
})

test("a selected sandbox scopes the strip to its folder", () => {
const previous = folderScope("sandbox:a", "/workspace")
expect(
nextTabScope({
side: "work",
previous,
retained: ["sandbox:a", "sandbox:b"],
sandbox: selectedSandbox("sandbox:b", "/mnt/shared"),
}),
).toEqual(folderScope("sandbox:b", "/mnt/shared"))
})

test("a sandbox with no resolvable folder falls back to the whole side", () => {
expect(
nextTabScope({
side: "work",
previous: undefined,
retained: ["sandbox:a"],
sandbox: selectedSandbox("sandbox:a", undefined, []),
}),
).toEqual({ type: "side", side: "work" })
})

// Every session the chain can reach still exists unless a test says otherwise,
// and the folder has no other session to fall back to — the arrangement every
// case below varies one piece of.
Expand Down
Loading