diff --git a/CHANGELOG.md b/CHANGELOG.md index b50ebeafd4..3300e04d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -305,22 +305,26 @@ configure`: **Login with SSO** (sign in with your CoDev account in the Two separate causes, both fixed: the close was recorded in two steps and the app reacted to the moment in between, and a folder you had never started a session in was not recognised as one you had closed. -- **Each folder in a sandbox now has its own session tabs** — a sandbox can - mount several folders, but all of their sessions shared one tab strip on the - **Work** side, so working in two folders meant reading past the other one's - tabs. Every folder of every sandbox now keeps its own tabs, including the - project folder the sandbox was created with. Clicking a folder in the sidebar - takes you back to the session you were last in there — or to that folder's - draft, or to one of its open tabs, and only to a fresh new session when the - folder has nothing open at all. Tab shortcuts (`Ctrl`/`Cmd` + - number, next/previous tab, close tab) act within the folder you are looking - at, so closing the last tab in a folder no longer drops you into an unrelated - one. Two folders that happen to have the same name stay separate, and so does - the same folder mounted into two different sandboxes. **The Work strip now - shows one sandbox at a time**: while you are in one sandbox's folder, another - sandbox's tabs are not in the strip — selecting that sandbox brings them back. - Existing tabs keep their place; anything the app cannot immediately match to a - folder stays visible in that sandbox until it can. Code is unchanged. +- **Every project now has its own session tabs** — sessions from unrelated + projects shared one tab strip, so working in two of them meant reading past + the other one's tabs. On **Work** that was every folder a sandbox mounts; on + **Code** every project of every server. Each now keeps its own tabs, including + the folder a sandbox was created with. Tab shortcuts (`Ctrl`/`Cmd` + number, + next/previous tab, close tab) act within the project you are looking at, so + closing the last tab in a project no longer drops you into an unrelated one, + and **New tab** starts in the project the strip is showing. Two projects that + happen to have the same name stay separate, and so does the same path on two + different servers — a folder mounted into two sandboxes, or a checkout that + your machine and a WSL distro both see. **The strip shows one server at a + time**: while you are in one server's project, another server's tabs are not + in the strip — opening one of its sessions brings them back. On **Work**, + clicking a folder in the sidebar takes you back to the session you were last + in there — or to that folder's draft, or to one of its open tabs, and only to + a fresh new session when the folder has nothing open at all. On **Code**, + clicking a project still just filters **Recents** as it always did, and the + strip follows whatever session you open. Existing tabs keep their place; + anything the app cannot immediately match to a project stays visible on its + own server until it can. - **Adding a folder to a sandbox no longer freezes the app** — mounting a new folder on a sandbox you had a session open on locked the window up entirely: no clicks, no typing, force quit the only way out. Applying a mount restarts diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 43f15bb166..0f0bc06b2f 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -215,7 +215,7 @@ from the squash message first, or use a plain merge. | Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. | -| Per-folder Work tab strip: `packages/app/src/context/{layout,tabs}.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/pages/home.tsx` | Extends the Code/Work split row above so the Work side keeps one tab strip per **mounted folder** of a sandbox rather than one for the whole side. A sandbox exposes its project folder plus every extra mount as its own project (fork-only `src/sandbox/projects.ts`), so the strip is keyed by (sandbox, folder). All of the logic is in the fork-only `src/workspace/side.ts` (`TabScope`, `folderForDirectory`, `tabsForScope`, `reorderWithinScope` — `tabsForSide`/`reorderWithinSide` become thin wrappers over the same primitives so the Code path is provably unchanged) and `src/workspace/tabs.tsx` (`useWorkspaceScope`, `useWorkspaceFolder`). Upstream files hold wiring only: `tabs.tsx` adds `SessionTab.directory` — deliberately **not** part of `tabKey()`, which is what keeps every persisted `info`/`memory`/`closed` key valid as the field appears — plus `backfillTabDirectories` and the effect that applies it, `promoteDraft` inheriting the draft's directory, `addSessionTab` filling a missing directory on an already-open tab, and an optional `within` on `closeTab`/`removeTab` so the successor after a close is picked from the visible list, plus `removeDirectory({server, directory})` — every tab of one mounted folder, sessions and drafts, for when that folder is unmounted (matching lives in the fork-only `src/workspace/tab-directory.ts`, its own module because both `context/tabs.tsx` and `workspace/side.ts` reach the router at import time and so cannot be unit-tested). Modelled on `removeServer` rather than `closeTab`: the folder left the sandbox, so the tabs are not a close the user can undo, and leaving them behind is what made an unmounted folder's session reappear over the next folder the user mounted — `inScope()` shows a tab whose folder the sandbox no longer lists in *every* folder, so it followed the user around while appearing in no folder's Recents; `layout.tsx` adds `home.lastFolderSession` (keyed `` `${server}\n${pathKey(directory)}` ``) with `folderSession`/`recordFolderSession`; `titlebar.tsx` swaps the side filter for the scope in the strip, reorder, `tab.prev`/`tab.next` and `tab.close`, passes `session.directory` to `addSessionTab`, and gains a leading scope branch in `openNewTab`; `home.tsx` passes `directory` to `addSessionTab`. `pages/home-model.tsx` (fork-only) selects instead of toggling on Work and calls `enterFolder`. The scope is **derived**, never stored: the folder on screen wins, then the sidebar's selected project, then the sandbox's primary folder from `sync.data.path.directory` — so deep links, the command palette and a reopened tab all pull the strip to their own folder instead of leaving the active tab out of view. `pages/session.tsx` and `components/command-palette.ts` are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two `home` effects in `layout.tsx`, and it keeps the sidebar's Recents unnarrowed: writing the route's folder into `home.selection.directory` would feed `projectDirectories()` and silently filter that list too. | Keep the wiring and take upstream's surrounding changes. `tabsForScope` must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the `` key, so never map to copies. Never add `directory` to `tabKey()`. A tab whose folder cannot be resolved shows in **every** folder of its own sandbox on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. `folderForDirectory`'s prefix test must stay path-boundary aware — mount guest names dedup with a `-2` suffix, so `/mnt/shared` must not swallow `/mnt/shared-2`. Resolve the primary folder from `sync.data.path`, never `projects.list()[0]`: `projects.open()` prepends, so that is the most recently added mount. | +| Per-project tab strips: `packages/app/src/context/{layout,tabs}.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/pages/home.tsx` | Extends the Code/Work split row above so each side keeps one tab strip per **project** rather than one for the whole side. On Work a project is a mounted folder of a sandbox — the sandbox exposes its own folder plus every extra mount as an ordinary project (fork-only `src/sandbox/projects.ts`) — and on Code it is a project of a normal server; either way the strip is keyed by (server, project). All of the logic is in the fork-only `src/workspace/side.ts` (`TabScope`, `folderForDirectory`, `tabsForScope`, `reorderWithinScope`, `serverForScope`, `scopeDirectory` — `tabsForSide`/`reorderWithinSide` stay as the side-wide primitives the scope falls back to) and `src/workspace/tabs.tsx` (`useWorkspaceScope`, `useWorkspaceFolder`). Upstream files hold wiring only: `tabs.tsx` adds `SessionTab.directory` — deliberately **not** part of `tabKey()`, which is what keeps every persisted `info`/`memory`/`closed` key valid as the field appears — plus `backfillTabDirectories` and the effect that applies it, `promoteDraft` inheriting the draft's directory, `addSessionTab` filling a missing directory on an already-open tab, and an optional `within` on `closeTab`/`removeTab` so the successor after a close is picked from the visible list, plus `removeDirectory({server, directory})` — every tab of one mounted folder, sessions and drafts, for when that folder is unmounted (matching lives in the fork-only `src/workspace/tab-directory.ts`, its own module because both `context/tabs.tsx` and `workspace/side.ts` reach the router at import time and so cannot be unit-tested). Modelled on `removeServer` rather than `closeTab`: the folder left the sandbox, so the tabs are not a close the user can undo, and leaving them behind is what made an unmounted folder's session reappear over the next folder the user mounted — `inScope()` shows a tab whose folder the sandbox no longer lists in *every* folder, so it followed the user around while appearing in no folder's Recents; `layout.tsx` adds `home.lastFolderSession` (keyed `` `${server}\n${pathKey(directory)}` ``) with `folderSession`/`recordFolderSession`; `titlebar.tsx` swaps the side filter for the scope in the strip, reorder, `tab.prev`/`tab.next` and `tab.close`, passes `session.directory` to `addSessionTab`, and gains a leading scope branch in `openNewTab`; `home.tsx` passes `directory` to `addSessionTab`. `pages/home-model.tsx` (fork-only) selects instead of toggling on Work and calls `enterFolder`. The scope is **derived**, never stored, on both axes. Its server is the route's, then the sidebar's, each filtered to the side being shown — nothing syncs `home.selection.server` to the route, and on Code the two come apart routinely, since selecting a tab on a WSL server leaves the sidebar on the local one. Its project is the one on screen, then the sidebar's (only when the sidebar is on the scoped server, because two servers can hold the same path), then the side's fallback: the sandbox's primary folder from `sync.data.path.directory` on Work, the most recently opened project on Code, matching what `enter("code")` already used. So deep links, the command palette and a reopened tab all pull the strip to their own project instead of leaving the active tab out of view. A Code server with no project open yet has nothing to scope to and degrades to the side scope, which is what both sides showed before this. `pages/session.tsx` and `components/command-palette.ts` are deliberately left untouched; their tabs pick up a folder through the back-fill instead, which keeps two more upstream files out of this table. | Sandboxes gained extra folder mounts, so one sandbox can expose several unrelated folders while their sessions shared a single strip — working across two folders meant constantly reading past the other one's tabs. Code was the same pile for the same reason, one strip over every project of every connected server, so a session on the local machine sat beside one on a WSL distro and one from an unrelated checkout; the argument that split Work carries there unchanged. Deriving the scope rather than persisting a "current folder" makes "the strip always contains the session you are looking at" true by construction instead of by an effect racing the two `home` effects in `layout.tsx`, and it keeps the sidebar's Recents unnarrowed: writing the route's folder into `home.selection.directory` would feed `projectDirectories()` and silently filter that list too. | Keep the wiring and take upstream's surrounding changes. `tabsForScope` must return the store's own tab objects — the strip compares by reference for the active highlight, the close index and the `` key, so never map to copies. Never add `directory` to `tabKey()`. A tab whose project cannot be resolved shows in **every** project of its own server on purpose: it is exactly the tab that has never rendered and so never had a chance to learn where it belongs, and being invisible everywhere is unrecoverable. `folderForDirectory`'s prefix test must stay path-boundary aware — mount guest names dedup with a `-2` suffix, so `/mnt/shared` must not swallow `/mnt/shared-2`. Resolve Work's primary folder from `sync.data.path`, never `projects.list()[0]`: `projects.open()` prepends, so that is the most recently added mount. Code's fallback is the opposite — `projects.list()[0]` is right there, because a normal server's `sync.data.path` is wherever the app happened to start rather than a project. Keep the scope's server route-first: resolving it from `home.selection.server` alone hides the tab that was just opened whenever the route sits on another server, which on Code is the ordinary case rather than an edge one. | | Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting,empty.stopped,empty.failed,empty.failed.retry}` (`empty.stopped` for a Work side whose sandboxes are all stopped, which unlike starting will never come up on its own; `empty.failed`/`empty.failed.retry` for one whose sandbox failed to start — the empty state shows the runtime's failure message and a retry that calls `platform.sandboxes.start`, because the wait message otherwise left the user watching for a sandbox that already gave up). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. | | Workspace (worktree) bar is Code-only: `packages/app/src/pages/new-session.tsx` | `showWorkspaceBar` gains a third condition, `sideForServer(serverSDK().server) === "code"`, so the "Run session in → Local repository / New workspace / Workspace…" chip is not offered on Work. Work falls through to the same `PromptGitStatus` branch a non-git project already takes, which shows the branch on its own. Nothing else moves: `newSessionWorktree()` already returns `"main"` whenever the bar is hidden, and `"main"` in `prompt-input/submit.ts` means "leave `sessionDirectory` at `sdk().directory`" — it does not redirect a session to the project root. The bar remains gated on `VITE_CODEV_CHANNEL !== "prod"`, which is upstream's own gate. | A workspace is a git worktree created by the server that runs the session, and `Worktree.makeWorktreeInfo` roots it at `Global.Path.data/worktree/` (`packages/opencode/src/worktree/index.ts`). In a sandbox that is the guest's own data dir: it is not one of the host folders the sandbox mounts (`packages/codev-sandbox/src/mounts.ts`), so the work happens where the user's editor cannot see it, and a reset re-clones the guest disk and takes it along. Meanwhile the chip says "Local" under a monitor icon, which is the opposite of true. Three storage designs were costed before choosing to hide it — a checkout inside the mounted repo, a dedicated hidden worktree mount per sandbox, or leaving it on the guest disk — and none of them make a guest worktree usable from the host, because a worktree's `.git` file holds an absolute path back to `/.git/worktrees/` and that path is `/mnt/` in the guest and the real path on the host. Note also that `git worktree add` runs with `cwd` = the project worktree, i.e. the mounted host repo, so it registers the worktree in the user's real `.git` whatever we do with the checkout. | Keep the gate. If Work should offer workspaces later, the storage question has to be answered first — where the checkout lives, and what a reset does to it — not just the label; re-read the reasoning column before reopening it. Read the side from `sideForServer(serverSDK().server)`, matching the project picker row above, so a draft is judged by its own server rather than by whichever side the sidebar is showing. If upstream changes what `showWorkspaceBar` gates, keep the third condition rather than reverting to the two-term memo. | | New-session project picker on Work: `packages/app/src/pages/session/composer/session-composer-controls.ts`, `packages/app/src/components/{prompt-project-selector,app-sidebar}.tsx` | Extends the Code/Work split row above to the composer's project picker. `createPromptProjectControls` indexed `server.list` in four places — the grouped project list, the two server lookups behind select/add, and the `server` field the controls hand back — so a draft on the Work side listed the local machine's projects under a "Local Server" heading beside the sandbox's own, and picking one moved the session onto that server. All four read a `sideServers()` memo instead: `serversForSide(server.list, sideForServer(projectServer()))`, from the fork-only `src/workspace/side.ts`. The side comes from the draft's **own** server (`serverSDK().server`) rather than `useWorkspaceSide()`, so a draft always offers what it can actually run on whichever side happens to be showing. Nothing else changes: with one server on the side the picker takes the same single-server branch Code has always taken (`layout.projects.list()`, or the draft's server ctx for a draft route), which drops the group headings and the "Add project" submenu the way it does for a lone local server. **Add project on Work is a mount**: `addProject` opens the fork-only `DialogSandboxMounts` for that sandbox instead of `pickDirectory`, through the fork-only `src/sandbox/mount-dialog.tsx` — upstream's picker browses the server it is pointed at, which for a sandbox is the guest's own filesystem. `PromptProjectControls` gains an optional `mount` flag and `prompt-project-selector.tsx` reads it in two label getters (`labels.add`, `labels.new`) so the item says "Mount project" (`sandbox.server.mounts`, the string the Work sidebar's button already uses); nothing else in that component changes. `app-sidebar.tsx`'s Work branch and the fork-only `mount-prompt.tsx` both route through the same hook rather than repeating the lazy import. | The two sides exist so that "am I on my machine or in a sandbox?" is a property of where you are. A picker that offers both breaks that in the one place it matters most — the moment a session is created — and the failure is silent: the session opens on the local machine with no sandbox isolation, from a screen the user reached through the Work tab. | Keep the filter and take upstream's surrounding changes. If upstream adds a fifth `server.list` read here, filter it too. Read the side from `sideForServer(projectServer())`, not `useWorkspaceSide()` — a draft route mounts its own server (`ResolvedDraftRoute` in `app.tsx`), and the sidebar's side is not guaranteed to agree with it. The `sideServers().length <= 1` branch must stay a length test on the *side* list, not on `server.list`: on a machine with one sandbox and one local server the old test sent Work down the multi-server path. | diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 9042257c7e..f7afc8757a 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -43,18 +43,67 @@ export function tabsForSide(tabs: Tab[], side: WorkspaceSide) { return tabs.filter(inSide(side)) } -// What the tab strip is showing. Code is one list for the whole side, the way -// it has always been. Work is one list per (sandbox, mounted folder): a sandbox -// exposes its project folder plus every extra mount as its own project, and a -// session belongs to exactly one of them, so a shared strip made two unrelated -// folders read as one workspace. +// What the tab strip is showing: one list per (server, project). On Work a +// project is a mounted folder of a sandbox — the sandbox exposes its own folder +// plus every extra mount as an ordinary project — and on Code it is a project +// of a normal server. Either way a session belongs to exactly one of them, so a +// shared strip made two unrelated projects read as one workspace. // -// `folders` is the sandbox's open project list, needed because a session can -// sit in a subdirectory of a folder rather than at its root. +// The side scope is the fallback for a server with no projects open yet, and +// for a side with no server to scope to at all. It is what both sides did +// before their strips were split per project. +// +// `folders` is that server's open project list, needed because a session can +// sit in a subdirectory of a project rather than at its root. export type TabScope = | { type: "side"; side: WorkspaceSide } | { type: "folder"; server: ServerConnection.Key; directory: string; folders: string[] } +// Which server the strip is scoped to. The route wins over the sidebar's +// selection so the strip always contains the session on screen: nothing syncs +// the selection to the route, and on Code the two come apart routinely — +// selecting a tab on a WSL server leaves the sidebar on the local one, and +// scoping to the sidebar would hide the tab that was just opened. +// +// Both candidates are filtered to the side being shown, so a Code route can +// never scope the Work strip while a sandbox is starting, or the reverse. +export function serverForScope( + servers: ServerConnection.Any[], + side: WorkspaceSide, + route: ServerConnection.Key | undefined, + selection: ServerConnection.Key | undefined, +) { + const onSide = (key: ServerConnection.Key | undefined) => + key ? servers.find((conn) => ServerConnection.key(conn) === key && sideForServer(conn) === side) : undefined + return onSide(route) ?? onSide(selection) +} + +// Which project of `server` the strip is scoped to: the project on screen +// first, for the same reason serverForScope prefers the route, then the one the +// sidebar has selected, then the side's own fallback — the sandbox's project +// folder on Work, the most recently opened project on Code. +// +// The sidebar's directory only counts when its server is the scoped one. It is +// a path on some other server otherwise, and two servers can hold the same +// path: a WSL distro and a sandbox both mounting /home/user/repo is ordinary, +// and without this the sidebar would pull the strip to the wrong server's +// project of that name. +export function scopeDirectory(input: { + folders: string[] + server: ServerConnection.Key + routeDirectory: string | undefined + selection: { server: ServerConnection.Key; directory?: string } + fallback: string | undefined +}) { + return ( + folderForDirectory(input.routeDirectory, input.folders) ?? + (input.selection.server === input.server + ? folderForDirectory(input.selection.directory, input.folders) + : undefined) ?? + input.fallback + ) +} + // Which of `folders` owns `directory`: itself, or the deepest one it sits under. // The prefix test stops at a path boundary so /mnt/foo does not swallow // /mnt/foo-2 — a real pair, since mount guest names dedup with a -2 suffix. @@ -132,9 +181,9 @@ function inScope(scope: TabScope) { if (scope.type === "side") return inSide(scope.side) return (tab: Tab) => { if (tab.server !== scope.server) return false - // A tab whose folder cannot be resolved — a session tab that has not - // learned its directory yet, or one pointing at a folder the sandbox no - // longer lists — shows in every folder of its own sandbox. Being visible + // A tab whose project cannot be resolved — a session tab that has not + // learned its directory yet, or one pointing at a project the server no + // longer lists — shows in every project of its own server. Being visible // in the wrong folder for a moment is recoverable; being invisible in all // of them is not, and an unresolved tab is exactly the one that has never // rendered and so has had no chance to learn where it belongs. diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index 569b928aa3..cec3fe9016 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -18,7 +18,9 @@ import { folderEntry, folderForDirectory, leavesWorkEmpty, + scopeDirectory, selectionForSide, + serverForScope, sideForServer, WORKSPACE_SIDES, workspaceEmptyState, @@ -140,44 +142,59 @@ export function useWorkspaceFolder() { return { enterFolder, sessionAlive } } -// What the tab strip shows. Code is the whole side, unchanged. Work narrows to -// one mounted folder of one sandbox, resolved from the folder on screen first, -// so the strip always contains the session you are looking at: a deep link, a -// command-palette jump or a reopened tab pulls the strip to its own folder -// instead of leaving the active tab out of view. Only when nothing is on screen -// does the sidebar's selected project decide, and failing that the sandbox's -// primary folder — the strip is never unscoped. +// What the tab strip shows: one (server, project) on either side. It is +// resolved from the project on screen first, so the strip always contains the +// session you are looking at — a deep link, a command-palette jump or a +// reopened tab pulls the strip to its own project instead of leaving the active +// tab out of view. Only when nothing is on screen does the sidebar's selected +// project decide, and failing that the side's fallback project. export function useWorkspaceScope(routeDirectory: () => string | undefined): Accessor { const layout = useLayout() const global = useGlobal() const server = useServer() const side = useWorkspaceSide() - const sandbox = createMemo(() => { - if (side() !== "work") return - const key = layout.home.selection().server - const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) - if (!conn || sideForServer(conn) !== "work") return + const scoped = createMemo(() => { + const route = layout.route() + const conn = serverForScope( + global.servers.list(), + side(), + route.type === "home" ? undefined : route.server, + layout.home.selection().server, + ) + if (!conn) return return { key: ServerConnection.key(conn), ctx: global.ensureServerCtx(conn) } }) const scope = createMemo((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. + const current = scoped() + // A server that is merely away — a sandbox restarting to apply a mounted + // folder, or any sidecar restarting — keeps the scope it had. Its project + // list lives on the connection, which is gone for those seconds, and + // falling through would widen the strip to the whole side 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. + // Nothing to scope to: on Work a sandbox is still starting or the last was + // removed, which the sidebar already reports through WorkspaceEmpty; on + // Code every server would have to be gone. Falling back to the side is what + // both did before their strips were split per project. 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) + const directory = scopeDirectory({ + folders, + server: current.key, + routeDirectory: routeDirectory(), + selection: layout.home.selection(), + // Work scopes to the sandbox's own project folder, which is the one + // folder it always has. Code has no equivalent — a server's working + // directory is wherever the app happened to start — so it takes the most + // recently opened project instead, the same fallback enter("code") uses. + // projects.open() prepends, which is what makes folders[0] that project. + fallback: side() === "work" ? primaryFolder(current.ctx) : folders[0], + }) + // A Code server with no project open yet has nothing to scope to, and the + // whole side is the honest answer until the user opens one. if (!directory) return { type: "side", side: side() } return { type: "folder", server: current.key, directory, folders } }) diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index d1b13e08ea..b79fd47fdc 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -9,7 +9,9 @@ import { leavesWorkEmpty, reorderWithinScope, reorderWithinSide, + scopeDirectory, selectionForSide, + serverForScope, serversForSide, sideForServer, tabsForScope, @@ -162,8 +164,78 @@ test("normalizes separators and trailing slashes", () => { expect(folderForDirectory("C:\\repos\\api\\src", ["C:/repos/api"])).toBe("C:/repos/api") }) -// The regression lock for the Code side: a side scope must resolve to exactly -// what the side filter alone did before folders existed. +// Nothing syncs the sidebar's selection to the route, so on Code the two come +// apart the moment a tab on another server is selected. The strip has to follow +// the route or it would hide the tab that was just opened. +test("the scoped server is the route's, then the sidebar's", () => { + const all = [local, wsl, sandbox("a")] + expect(serverForScope(all, "code", ServerConnection.key(wsl), ServerConnection.key(local))).toBe(wsl) + expect(serverForScope(all, "code", undefined, ServerConnection.key(local))).toBe(local) +}) + +// A Code route while Work is showing, or the reverse: neither may pull the +// other side's strip onto a server that side never renders. +test("the scoped server ignores a route on the other side", () => { + const all = [local, wsl, sandbox("a")] + expect(serverForScope(all, "code", "sandbox:a", ServerConnection.key(local))).toBe(local) + expect(serverForScope(all, "work", ServerConnection.key(local), "sandbox:a")?.displayName).toBe("a") +}) + +// A removed sandbox, or a sidecar that is restarting: both candidates name a +// server that is not in the list, and the strip falls back to its side. +test("the scoped server is undefined when neither candidate is connected", () => { + expect(serverForScope([local], "code", "wsl:Ubuntu", "wsl:Ubuntu")).toBeUndefined() + expect(serverForScope([local], "work", undefined, "sandbox:a")).toBeUndefined() +}) + +const PROJECTS = ["/home/dev/api", "/home/dev/web"] + +test("the scoped project is the one on screen, then the sidebar's, then the fallback", () => { + const scoped = (routeDirectory?: string, selectionDirectory?: string) => + scopeDirectory({ + folders: PROJECTS, + server: "sidecar", + routeDirectory, + selection: { server: "sidecar", directory: selectionDirectory }, + fallback: "/home/dev/api", + }) + expect(scoped("/home/dev/web/src", "/home/dev/api")).toBe("/home/dev/web") + expect(scoped(undefined, "/home/dev/web")).toBe("/home/dev/web") + expect(scoped()).toBe("/home/dev/api") +}) + +// The sidebar can be sitting on another server entirely, and two servers can +// hold the same path — a WSL distro and the local server mounting the same +// checkout is ordinary. Its directory is only ever a path on its own server. +test("the scoped project ignores a sidebar directory from another server", () => { + expect( + scopeDirectory({ + folders: PROJECTS, + server: "wsl:Ubuntu", + routeDirectory: undefined, + selection: { server: "sidecar", directory: "/home/dev/web" }, + fallback: "/home/dev/api", + }), + ).toBe("/home/dev/api") +}) + +// A Code server with nothing open yet: no route, no selection, no project to +// fall back to. The caller degrades to the side scope on undefined. +test("the scoped project is undefined when the server has no projects", () => { + expect( + scopeDirectory({ + folders: [], + server: "sidecar", + routeDirectory: "/home/dev/api", + selection: { server: "sidecar", directory: "/home/dev/api" }, + fallback: undefined, + }), + ).toBeUndefined() +}) + +// The fallback both sides degrade to — a server with no project open, or no +// server to scope to at all — must resolve to exactly what the side filter +// alone did before per-project strips existed. test("a side scope is the plain side filter", () => { const tabs = [sessionTab("sidecar", "c1"), sessionTab("sandbox:a", "w1", "/workspace")] expect(tabsForScope(tabs, { type: "side", side: "code" })).toEqual(tabsForSide(tabs, "code")) @@ -189,6 +261,37 @@ test("the same guest path in two sandboxes gives two lists", () => { expect(tabsForScope(tabs, folderScope("sandbox:b", "/workspace")).map((tab) => tab.sessionId)).toEqual(["b1"]) }) +test("a folder scope keeps only that Code server's tabs in that project", () => { + const tabs = [ + sessionTab("sidecar", "api", "/home/dev/api"), + sessionTab("sidecar", "web", "/home/dev/web"), + sessionTab("sidecar", "nested", "/home/dev/api/packages/core"), + sessionTab("sandbox:a", "w1", "/workspace"), + ] + const projects = ["/home/dev/api", "/home/dev/web"] + expect(tabsForScope(tabs, folderScope("sidecar", "/home/dev/api", projects)).map((tab) => tab.sessionId)).toEqual([ + "api", + "nested", + ]) + expect(tabsForScope(tabs, folderScope("sidecar", "/home/dev/web", projects)).map((tab) => tab.sessionId)).toEqual([ + "web", + ]) +}) + +// Code is the side with several servers connected at once, and a WSL distro +// can hold the same path as the local server — the checkout is often the same +// tree seen twice. +test("the same path on two Code servers gives two lists", () => { + const tabs = [sessionTab("sidecar", "local", "/home/dev/api"), sessionTab("wsl:Ubuntu", "distro", "/home/dev/api")] + const projects = ["/home/dev/api"] + expect(tabsForScope(tabs, folderScope("sidecar", "/home/dev/api", projects)).map((tab) => tab.sessionId)).toEqual([ + "local", + ]) + expect(tabsForScope(tabs, folderScope("wsl:Ubuntu", "/home/dev/api", projects)).map((tab) => tab.sessionId)).toEqual([ + "distro", + ]) +}) + // The project folder is not privileged: it is one folder among the mounts. test("the primary folder is scoped like any other", () => { const tabs = [sessionTab("sandbox:a", "primary", "/workspace"), sessionTab("sandbox:a", "extra", "/mnt/shared")]