diff --git a/CHANGELOG.md b/CHANGELOG.md index 015b1fd605..8f77cb3856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **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. - **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 f6e50021d8..a50097e21c 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -202,6 +202,8 @@ 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. | +| 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}`. | 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. | +| 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; `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. | | 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}` (the last for a Work side whose sandboxes are all stopped, which unlike starting or failed will never come up on its own). | 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. | | `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 97c70a56c0..547d0dd18b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -31,8 +31,8 @@ import type { PromptSession } from "@/context/prompt" import type { PromptModel } from "@/context/prompt-state" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" -import { reorderWithinSide, tabsForSide } from "@/workspace/side" -import { useWorkspaceSide } from "@/workspace/tabs" +import { reorderWithinScope, tabsForScope } from "@/workspace/side" +import { useWorkspaceScope } from "@/workspace/tabs" type TauriDesktopWindow = { startDragging?: () => Promise @@ -265,11 +265,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs - // Fork: Code and Work each keep their own session tabs, so the strip - // shows one side at a time. Everything else — close, reopen, the - // tab commands — still works against the whole store. - const side = useWorkspaceSide() - const sideTabs = createMemo(() => tabsForSide(tabsStore, side())) const [session] = createResource( () => { const route = layout.route() @@ -310,6 +305,18 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const currentTab = () => matchRoute(layout.route()) + // Fork: Code and Work each keep their own session tabs, and on Work + // each mounted folder of a sandbox keeps its own, so the strip shows + // one scope at a time. Reopen and the rest of the tab commands still + // work against the whole store. + // + // routeDirectory is the folder on screen. It falls back to the + // resolving session so a tab added from a route (the effect below) + // is never briefly left out of its own strip. + const routeDirectory = () => currentTab()?.directory ?? session()?.directory + const scope = useWorkspaceScope(routeDirectory) + const scopedTabs = createMemo(() => tabsForScope(tabsStore, scope())) + createEffect(() => { const route = layout.route() if (!tabs.ready()) return @@ -323,7 +330,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const s = session() if (!s) return const sessionId = s.parentID ?? s.id - const next = { server: route.server ?? server.key, sessionId } + // Fork: the directory places the tab in its folder's strip on + // Work. A child session shares its parent's directory, so this + // is right even when the tab is the parent. + const next = { server: route.server ?? server.key, sessionId, directory: s.directory } // A tab for a server that is not connected is dropped again the // moment it lands (context/tabs.tsx prunes tabs whose server // left the list), and this effect would add it straight back. @@ -377,6 +387,16 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return } + // Fork: with a folder scope the strip already names the server and + // folder a new tab belongs to. Without this the chain below falls + // through to the local server and its first project, which on Work + // creates a tab the visible strip cannot show. + const folderScope = scope() + if (folderScope.type === "folder") { + openDraftFor({ server: folderScope.server, directory: folderScope.directory }) + return + } + if (route.type === "home") { const selection = layout.home.selection() const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) @@ -425,7 +445,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: "mod+w", hidden: true, onSelect: () => { - tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab)) + tabsStoreActions.closeTab( + tabsStore.findIndex((tab) => current === tab), + scopedTabs(), + ) }, }, { @@ -442,10 +465,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, hidden: true, onSelect: () => { - // Cycles the visible side only: the other side's tabs are - // not on screen, so stepping onto one would look like a jump - // to nowhere. - const visible = sideTabs() + // Cycles the visible scope only — the side, or on Work the + // one mounted folder: tabs outside it are not on screen, so + // stepping onto one would look like a jump to nowhere. + const visible = scopedTabs() let index = visible.findIndex((tab) => tab === currentTab()) if (index === -1) return @@ -463,7 +486,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: `mod+option+ArrowRight,ctrl+tab`, hidden: true, onSelect: () => { - const visible = sideTabs() + const visible = scopedTabs() let index = visible.findIndex((tab) => tab === currentTab()) if (index === -1) return @@ -534,7 +557,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl { const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) - if (index !== -1) tabsStoreActions.closeTab(index) + if (index !== -1) tabsStoreActions.closeTab(index, scopedTabs()) }} - onReorder={(keys) => - tabsStoreActions.reorder(reorderWithinSide(tabsStore, side(), keys)) - } + onReorder={(keys) => tabsStoreActions.reorder(reorderWithinScope(tabsStore, scope(), keys))} /> & { worktree: string; expanded: boolean } export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } +// Fork: identifies one mounted folder of one sandbox. Normalized so a guest +// path that differs only in a trailing slash resolves to the same folder. +const folderSessionKey = (server: ServerConnection.Key, directory: string) => `${server}\n${pathKey(directory)}` + export type ReviewDiffStyle = "unified" | "split" export type ReviewChangeMode = "git" | "branch" | "turn" export type ReviewPanelSource = "context-button" | "other" @@ -308,6 +312,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( // you when you switch back to it. workspace: "code" as WorkspaceSide, lastSession: {} as Partial>, + // Fork: and where each mounted folder of a sandbox sends you, keyed + // `${server}\n${pathKey(directory)}`. Separate from `lastSession` + // rather than widening its key space, so the side switch keeps + // reading exactly what it always did. + lastFolderSession: {} as Record, }, }), ) @@ -653,6 +662,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( recordLastSession(side: WorkspaceSide, entry: { server: ServerConnection.Key; sessionId: string }) { setStore("home", "lastSession", side, entry) }, + // Fork: per mounted folder. Optional chaining because makePersisted + // replaces `home` wholesale on load, so a store written before this + // field existed has no key here — the same reason `lastSession` and + // `workspace` above are read defensively. + folderSession: (server: ServerConnection.Key, directory: string) => + store.home.lastFolderSession?.[folderSessionKey(server, directory)], + recordFolderSession(server: ServerConnection.Key, directory: string, sessionId: string) { + setStore("home", "lastFolderSession", folderSessionKey(server, directory), sessionId) + }, }, handoff: { tabs: createMemo(() => store.handoff?.tabs), diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 4dd191f6c2..00a4c9766a 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -17,6 +17,13 @@ export type SessionTab = { type: "session" server: ServerConnection.Key sessionId: string + // Fork: which folder this session belongs to, so the Work side can keep one + // tab strip per mounted folder. Deliberately not part of tabKey(), which is + // what keeps every persisted info/memory/closed key valid as this field + // appears. Optional because a tab persisted before it existed, or added from + // a route before its session resolved, has not learned it yet — see + // backfillTabDirectories. + directory?: string } export type DraftTab = { @@ -49,6 +56,19 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) } +// Fork: recover the directory of session tabs that predate the field, or that +// were added from a route before their session resolved. The strip already +// records one per tab it renders (rememberSessionInfo), and `info` is a sibling +// key in the same per-window store, so this reads back what is usually already +// on disk instead of waiting for every tab to resolve again. +export function backfillTabDirectories(tabs: Tab[], info: Record) { + return tabs.flatMap((tab, index) => { + if (tab.type !== "session" || tab.directory) return [] + const directory = info[tabKey(tab)]?.directory + return directory ? [{ index, directory }] : [] + }) +} + export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ name: "Tabs", gate: false, @@ -70,7 +90,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) - const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore>({})) + const [info, setInfo, , infoReady] = persisted( + Persist.window("tabs.info"), + createStore>({}), + ) const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const params = useParams() @@ -155,18 +178,46 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (next.length !== closed.length) setClosed(() => next) }) + // Fork: fill in missing tab directories. Runs after the reconcile effects + // above so pruned tabs are never touched, and re-runs as the strip records + // new info, which is what covers a tab added from a route before its + // session resolved. + createEffect(() => { + if (!ready() || !infoReady()) return + const pending = backfillTabDirectories(store, info) + if (!pending.length) return + setStore( + produce((tabs) => { + for (const entry of pending) { + const tab = tabs[entry.index] + if (tab?.type === "session" && !tab.directory) tab.directory = entry.directory + } + }), + ) + }) + const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) navigate(href) } - const removeTab = (index: number) => { + // Fork: `within` is the list the strip is showing. The neighbour to land on + // after closing the active tab is picked from it rather than from the flat + // store, so closing the last tab of a folder does not jump to a tab in + // another folder — or, from Work, to a Code tab. The splice still uses the + // store index; only the choice of successor is scoped. + const removeTab = (index: number, within?: Tab[]) => { const tab = store[index] if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/") + const active = recentKey() === key && location.pathname !== "/" + const scopedIndex = within ? within.indexOf(tab) : -1 + const nextTab = + within && scopedIndex !== -1 + ? nextTabAfterClose(within, scopedIndex, active) + : nextTabAfterClose(store, index, active) closing.add(key) void startTransition(() => { setStore( @@ -189,7 +240,20 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ addSessionTab: (tab: Omit) => { const next = { type: "session" as const, ...tab } const existing = store.find((item) => tabKey(item) === tabKey(next)) - if (existing) return existing + if (existing) { + // Fork: only ever fills a gap. A session's directory never moves, so + // this is the tab learning where it belongs rather than changing + // folder, and re-opening an already-open session stays a no-op. + if (existing.type === "session" && !existing.directory && next.directory) { + setStore( + (item) => item.type === "session" && tabKey(item) === tabKey(next), + produce((item) => { + if (item.type === "session") item.directory = next.directory + }), + ) + } + return existing + } void startTransition(() => { setStore( produce((tabs) => { @@ -249,7 +313,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ setStore( produce((tabs) => { const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) - if (index !== -1) tabs[index] = next + if (index === -1) return + // Fork: a draft always knows its directory, so inheriting it here + // means a promoted draft is never born without a folder. + const draft = tabs[index] + tabs[index] = { + ...next, + directory: next.directory ?? (draft?.type === "draft" ? draft.directory : undefined), + } }), ) if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next)) @@ -262,11 +333,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ // User-initiated close: records the tab so it can be reopened. // Cleanup paths (missing sessions, archive, server removal) go through // removeTab and friends directly and are not recorded. - closeTab(index: number) { + closeTab(index: number, within?: Tab[]) { const tab = store[index] if (!tab) return if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index)) - removeTab(index) + removeTab(index, within) }, reopenClosedTab() { if (!closedReady()) { diff --git a/packages/app/src/pages/home-model.tsx b/packages/app/src/pages/home-model.tsx index 6a47a3d310..5f76ed897a 100644 --- a/packages/app/src/pages/home-model.tsx +++ b/packages/app/src/pages/home-model.tsx @@ -30,6 +30,8 @@ import { import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" +import { sideForServerKey } from "@/workspace/side" +import { useWorkspaceFolder } from "@/workspace/tabs" import { archiveHomeSession } from "./home-session-archive" import { findOpenDraft } from "./home-draft-reuse" import { shouldOpenSessionInBackground } from "./home-session-open" @@ -107,6 +109,7 @@ export function createHomeModel() { const notification = useNotification() const pickDirectory = useDirectoryPicker() const pinned = usePinnedSessions() + const folder = useWorkspaceFolder() const selection = layout.home.selection @@ -235,6 +238,15 @@ export function createHomeModel() { .some((project) => project.worktree === directory) ) return + // Fork: on Work a project is a mounted folder of a sandbox, and it owns a + // tab strip of its own, so selecting it is a move rather than a highlight: + // select instead of toggling (a cleared directory would silently send the + // strip back to the primary folder) and go where that folder was left. + if (sideForServerKey(key) === "work") { + setSelection({ server: key, directory }) + folder.enterFolder(key, directory) + return + } setSelection(toggleHomeProjectSelection(selection(), key, directory)) } @@ -312,12 +324,12 @@ export function createHomeModel() { const ctx = global.ensureServerCtx(conn) ctx.projects.open(directory) if (options?.background) { - tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id, directory }) return } ctx.projects.touch(directory) startTransition(() => { - const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id, directory }) tabs.select(tab) }) } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 04c0a2f339..fcb8c5e24d 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -349,7 +349,7 @@ export function NewHome() { ctx.projects.open(directory) ctx.projects.touch(directory) void startTransition(() => { - const tab = tabs.addSessionTab({ server, sessionId: sessionID }) + const tab = tabs.addSessionTab({ server, sessionId: sessionID, directory }) tabs.select(tab) }) }} diff --git a/packages/app/src/workspace/side.ts b/packages/app/src/workspace/side.ts index 324b0a7ebd..5a4e8b620d 100644 --- a/packages/app/src/workspace/side.ts +++ b/packages/app/src/workspace/side.ts @@ -1,5 +1,6 @@ import { ServerConnection } from "@/context/server" import { tabKey, type Tab } from "@/context/tabs" +import { pathKey } from "@/utils/path-key" // The desktop app is split into two workspaces: Code, everything that runs on // a normal server (the local sidecar, WSL servers, remote and SSH servers), and @@ -38,7 +39,83 @@ export function serversForSide(servers: ServerConnection.Any[], side: WorkspaceS } export function tabsForSide(tabs: Tab[], side: WorkspaceSide) { - return tabs.filter((tab) => sideForServerKey(tab.server) === side) + 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. +// +// `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. +export type TabScope = + | { type: "side"; side: WorkspaceSide } + | { type: "folder"; server: ServerConnection.Key; directory: string; folders: string[] } + +// 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. +export function folderForDirectory(directory: string | undefined, folders: string[]) { + if (!directory) return + const key = pathKey(directory) + const exact = folders.find((folder) => pathKey(folder) === key) + if (exact) return exact + return folders + .filter((folder) => key.startsWith(`${pathKey(folder)}/`)) + .sort((a, b) => pathKey(b).length - pathKey(a).length)[0] +} + +export function tabsForScope(tabs: Tab[], scope: TabScope) { + return tabs.filter(inScope(scope)) +} + +// 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 +// chain is the part worth pinning down. +export function folderEntry(input: { + lastSession?: string + serverExists: boolean + openDraft?: Tab + openSession?: Tab +}) { + // A recorded session whose server has gone away falls through rather than + // navigating to a dead route. + if (input.lastSession && input.serverExists) return { type: "session" as const, sessionId: input.lastSession } + if (input.openDraft) return { type: "draft" as const, tab: input.openDraft } + // A folder can hold open session tabs and still have no recorded history — + // every folder does the first time it is opened after per-folder strips + // shipped. Landing on a tab that is already there beats minting an empty + // draft beside it. Only offered for Work: on Code this step would change the + // side switch's long-standing behaviour. + if (input.openSession) return { type: "tab" as const, tab: input.openSession } + return { type: "new" as const } +} + +export function reorderWithinScope(tabs: Tab[], scope: TabScope, keys: string[]) { + return reorderWithin(tabs, keys, inScope(scope)) +} + +function inSide(side: WorkspaceSide) { + return (tab: Tab) => sideForServerKey(tab.server) === side +} + +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 + // 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. + const folder = folderForDirectory(tab.directory, scope.folders) + if (!folder) return true + return pathKey(folder) === pathKey(scope.directory) + } } // Which server the sidebar should focus after a switch. Keeping the current @@ -67,11 +144,15 @@ export function leavesWorkEmpty(sandboxes: { config: { id: string } }[], removed // the full set, so without this a drag inside a filtered strip is a silent // no-op. export function reorderWithinSide(tabs: Tab[], side: WorkspaceSide, keys: string[]) { + return reorderWithin(tabs, keys, inSide(side)) +} + +function reorderWithin(tabs: Tab[], keys: string[], scoped: (tab: Tab) => boolean) { const moved = keys.flatMap((key) => { const tab = tabs.find((item) => tabKey(item) === key) - return tab && sideForServerKey(tab.server) === side ? [tab] : [] + return tab && scoped(tab) ? [tab] : [] }) - if (moved.length !== tabsForSide(tabs, side).length) return tabs.map(tabKey) + if (moved.length !== tabs.filter(scoped).length) return tabs.map(tabKey) const queue = [...moved] - return tabs.map((tab) => tabKey(sideForServerKey(tab.server) === side ? queue.shift()! : tab)) + return tabs.map((tab) => tabKey(scoped(tab) ? queue.shift()! : tab)) } diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index dbd3b926e6..92b7c88916 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -1,5 +1,5 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createMemo, For, Show } from "solid-js" +import { createEffect, createMemo, For, Show, type Accessor } from "solid-js" import { useNavigate } from "@solidjs/router" import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" @@ -10,7 +10,33 @@ import { findOpenDraft } from "@/pages/home-draft-reuse" import { sessionHref } from "@/utils/session-route" import { useSandboxes } from "@/sandbox/context" import { usePlatform } from "@/context/platform" -import { effectiveSide, leavesWorkEmpty, selectionForSide, WORKSPACE_SIDES, type WorkspaceSide } from "./side" +import { + effectiveSide, + folderEntry, + folderForDirectory, + leavesWorkEmpty, + selectionForSide, + sideForServer, + WORKSPACE_SIDES, + type TabScope, + type WorkspaceSide, +} from "./side" + +// The slice of a server ctx these read, structural for the same reason +// src/sandbox/projects.ts is: it keeps the folder logic testable without +// standing up a whole ctx. +type FolderServerCtx = { + projects: { list: () => { worktree: string }[] } + sync: { data: { path: { directory: string; worktree: string } } } +} + +// A sandbox's own project folder, as the guest reports its working directory. +// Deliberately not projects.list()[0]: projects.open() prepends, so that is +// whichever mount was added most recently, not the folder the sandbox was +// created with. +function primaryFolder(ctx: FolderServerCtx) { + return ctx.sync.data.path.directory || ctx.sync.data.path.worktree || undefined +} // The side everything should actually render, as opposed to the side that // happens to be persisted. Read this rather than layout.home.workspace(). @@ -20,6 +46,105 @@ export function useWorkspaceSide() { return createMemo(() => effectiveSide(layout.home.workspace(), !!platform.sandboxes)) } +// Where selecting a mounted folder sends you. Its own hook rather than part of +// useWorkspaceSwitch because home-model calls it, and it should not have to +// satisfy that hook's dialog and sandbox-list dependencies. +export function useWorkspaceFolder() { + const layout = useLayout() + const global = useGlobal() + const tabs = useTabs() + const navigate = useNavigate() + + // Same chain enter() uses for a side, one folder down: the session you were + // last in there, else the draft that folder already has open, else a new one. + const enterFolder = (server: ServerConnection.Key, directory: string) => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === server) + const entry = folderEntry({ + lastSession: layout.home.folderSession(server, directory), + serverExists: !!conn, + openDraft: findOpenDraft(tabs.store, { server, directory }), + openSession: conn && sideForServer(conn) === "work" ? openSessionIn(conn, server, directory) : undefined, + }) + if (entry.type === "session") { + navigate(sessionHref(server, entry.sessionId)) + return + } + if (entry.type === "draft" || entry.type === "tab") { + tabs.select(entry.tab) + return + } + void tabs.newDraft({ server, directory }) + } + + // A session tab that resolves to exactly this folder. Resolved through the + // same folders list the strip uses, so the tab landed on is always one the + // strip will be showing. Deliberately skips tabs whose folder is unknown — + // those are visible in every folder of the sandbox, so picking one could send + // you to a tab that belongs elsewhere the moment it resolves. + const openSessionIn = (conn: ServerConnection.Any, server: ServerConnection.Key, directory: string) => { + const folders = global + .ensureServerCtx(conn) + .projects.list() + .map((project) => project.worktree) + return tabs.store.find( + (tab) => + tab.type === "session" && tab.server === server && folderForDirectory(tab.directory, folders) === directory, + ) + } + + return { enterFolder } +} + +// 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. +export function useWorkspaceScope(routeDirectory: () => string | undefined): Accessor { + const layout = useLayout() + const global = useGlobal() + 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 + return { key: ServerConnection.key(conn), ctx: global.ensureServerCtx(conn) } + }) + + const scope = createMemo(() => { + const current = sandbox() + // 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 } + }) + + // Remember where each folder was left. Recorded here rather than beside the + // per-side recorder in context/layout.tsx because that effect cannot see a + // session's folder, while this one has already resolved it. + createEffect(() => { + const current = scope() + if (current.type !== "folder") return + const route = layout.route() + if (route.type !== "session" || route.server !== current.server) return + if (layout.home.folderSession(current.server, current.directory) === route.sessionId) return + layout.home.recordFolderSession(current.server, current.directory, route.sessionId) + }) + + return scope +} + // Code and Work are the same UI pointed at different servers, so switching one // is: move the sidebar's selection to a server on that side, then go wherever // that side was last. Everything below the tabs — projects, pinned, recents, @@ -27,11 +152,11 @@ export function useWorkspaceSide() { export function useWorkspaceSwitch() { const layout = useLayout() const global = useGlobal() - const tabs = useTabs() const navigate = useNavigate() const dialog = useDialog() const sandboxes = useSandboxes() const side = useWorkspaceSide() + const folder = useWorkspaceFolder() // A sandbox that exists but is still starting counts: the Work side should // show it coming up, not offer to create a second one. @@ -53,20 +178,20 @@ export function useWorkspaceSwitch() { return } - // No history for this side: land on its open draft, or a new one for the - // first project it has. With neither, the home screen is the only honest - // destination — that side has nothing to open yet. - const directory = global.ensureServerCtx(target).projects.list()[0]?.worktree + // No history for this side: land on its open draft, or a new one. With + // neither, the home screen is the only honest destination — that side has + // nothing to open yet. + // + // Work resolves the sandbox's own project folder, so switching to Work + // lands on the same folder its strip will be scoped to. Code keeps its + // first-project fallback. + const ctx = global.ensureServerCtx(target) + const directory = side === "work" ? primaryFolder(ctx) : ctx.projects.list()[0]?.worktree if (!directory) { navigate("/") return } - const existing = findOpenDraft(tabs.store, { server, directory }) - if (existing) { - tabs.select(existing) - return - } - void tabs.newDraft({ server, directory }) + folder.enterFolder(server, directory) } return { diff --git a/packages/app/test-browser/tab-directories.test.ts b/packages/app/test-browser/tab-directories.test.ts new file mode 100644 index 0000000000..7edc4cb84b --- /dev/null +++ b/packages/app/test-browser/tab-directories.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { backfillTabDirectories, tabKey, type Tab, type TabInfo } from "@/context/tabs" + +const sessionTab = (server: string, sessionId: string, directory?: string): Tab => + ({ type: "session", server, sessionId, directory }) as unknown as Tab + +const draftTab = (server: string, draftID: string, directory: string): Tab => + ({ type: "draft", server, draftID, directory }) as unknown as Tab + +const info = (entries: [Tab, TabInfo][]) => Object.fromEntries(entries.map(([tab, value]) => [tabKey(tab), value])) + +// Session tabs persisted before the field existed, and tabs added from a route +// before their session resolved, recover their folder from the info record the +// strip already writes for every tab it renders. +test("fills in the directory of session tabs that lack one", () => { + const tabs = [sessionTab("sandbox:a", "a1"), sessionTab("sandbox:a", "a2")] + const records = info([ + [tabs[0], { title: "One", directory: "/workspace" }], + [tabs[1], { title: "Two", directory: "/mnt/shared" }], + ]) + expect(backfillTabDirectories(tabs, records)).toEqual([ + { index: 0, directory: "/workspace" }, + { index: 1, directory: "/mnt/shared" }, + ]) +}) + +test("leaves a tab that already knows its directory alone", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace")] + expect(backfillTabDirectories(tabs, info([[tabs[0], { directory: "/mnt/shared" }]]))).toEqual([]) +}) + +// A draft's directory is required and set at creation, so there is nothing to +// recover and nothing to overwrite. +test("ignores draft tabs", () => { + const tabs = [draftTab("sandbox:a", "d1", "/workspace")] + expect(backfillTabDirectories(tabs, info([[tabs[0], { directory: "/mnt/shared" }]]))).toEqual([]) +}) + +test("ignores info records that carry no directory", () => { + const tabs = [sessionTab("sandbox:a", "a1")] + expect(backfillTabDirectories(tabs, info([[tabs[0], { title: "Untitled" }]]))).toEqual([]) + expect(backfillTabDirectories(tabs, {})).toEqual([]) +}) + +// Indexes address the live store, so they have to survive gaps. +test("reports the store index, not the position among the unresolved", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace"), sessionTab("sandbox:a", "a2")] + expect(backfillTabDirectories(tabs, info([[tabs[1], { directory: "/mnt/shared" }]]))).toEqual([ + { index: 1, directory: "/mnt/shared" }, + ]) +}) diff --git a/packages/app/test-browser/workspace-side.test.ts b/packages/app/test-browser/workspace-side.test.ts index 5e623a90e4..1f28c56895 100644 --- a/packages/app/test-browser/workspace-side.test.ts +++ b/packages/app/test-browser/workspace-side.test.ts @@ -3,12 +3,17 @@ import { ServerConnection } from "@/context/server" import { tabKey, type Tab } from "@/context/tabs" import { effectiveSide, + folderEntry, + folderForDirectory, leavesWorkEmpty, + reorderWithinScope, reorderWithinSide, selectionForSide, serversForSide, sideForServer, + tabsForScope, tabsForSide, + type TabScope, } from "@/workspace/side" const local: ServerConnection.Any = { @@ -40,8 +45,16 @@ const sandbox = (name: string): ServerConnection.Any => ({ http: { url: `http://127.0.0.1:9${name.length}` }, }) -const sessionTab = (server: string, sessionId: string): Tab => - ({ type: "session", server, sessionId }) as unknown as Tab +const sessionTab = (server: string, sessionId: string, directory?: string): Tab => + ({ type: "session", server, sessionId, directory }) as unknown as Tab + +const draftTab = (server: string, draftID: string, directory: string): Tab => + ({ type: "draft", server, draftID, directory }) as unknown as Tab + +const FOLDERS = ["/workspace", "/mnt/shared", "/mnt/shared-2"] + +const folderScope = (server: string, directory: string, folders = FOLDERS): TabScope => + ({ type: "folder", server, directory, folders }) as TabScope test("only sandboxes belong to Work", () => { expect(sideForServer(local)).toBe("code") @@ -126,3 +139,161 @@ test("removing a non-sandbox server never empties Work", () => { expect(leavesWorkEmpty([], "wsl:Ubuntu-24.04")).toBe(false) expect(leavesWorkEmpty([], "sidecar")).toBe(false) }) + +// A folder owns a directory that is itself or that sits under it. The prefix +// test has to stop at a path boundary, because mount guest names dedup with a +// -2 suffix and /mnt/shared must not swallow /mnt/shared-2. +test("resolves a directory to the folder that owns it", () => { + expect(folderForDirectory("/workspace", FOLDERS)).toBe("/workspace") + expect(folderForDirectory("/workspace/packages/app", FOLDERS)).toBe("/workspace") + expect(folderForDirectory("/mnt/shared-2", FOLDERS)).toBe("/mnt/shared-2") + expect(folderForDirectory("/mnt/shared-2/src", FOLDERS)).toBe("/mnt/shared-2") + expect(folderForDirectory("/mnt/other", FOLDERS)).toBeUndefined() + expect(folderForDirectory(undefined, FOLDERS)).toBeUndefined() +}) + +test("the deepest folder wins when folders nest", () => { + const nested = ["/workspace", "/workspace/packages"] + expect(folderForDirectory("/workspace/packages/app", nested)).toBe("/workspace/packages") + expect(folderForDirectory("/workspace/script", nested)).toBe("/workspace") +}) + +test("normalizes separators and trailing slashes", () => { + expect(folderForDirectory("/workspace/", FOLDERS)).toBe("/workspace") + 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. +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")) + expect(tabsForScope(tabs, { type: "side", side: "work" })).toEqual(tabsForSide(tabs, "work")) +}) + +test("a folder scope keeps only that sandbox's tabs in that folder", () => { + const tabs = [ + sessionTab("sidecar", "c1", "/home/dev/api"), + sessionTab("sandbox:a", "a-primary", "/workspace"), + sessionTab("sandbox:a", "a-shared", "/mnt/shared"), + sessionTab("sandbox:b", "b-primary", "/workspace"), + ] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/workspace")).map((tab) => tab.sessionId)).toEqual(["a-primary"]) + expect(tabsForScope(tabs, folderScope("sandbox:a", "/mnt/shared")).map((tab) => tab.sessionId)).toEqual(["a-shared"]) +}) + +// Both sandboxes mount their project folder at the same guest path, which is +// why the server has to be part of the key. +test("the same guest path in two sandboxes gives two lists", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace"), sessionTab("sandbox:b", "b1", "/workspace")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/workspace")).map((tab) => tab.sessionId)).toEqual(["a1"]) + expect(tabsForScope(tabs, folderScope("sandbox:b", "/workspace")).map((tab) => tab.sessionId)).toEqual(["b1"]) +}) + +// 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")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/mnt/shared")).map((tab) => tab.sessionId)).toEqual(["extra"]) +}) + +// A tab that has not learned its directory, or points at a folder the sandbox +// no longer lists, must never be invisible in every folder — it is exactly the +// tab that has never rendered and so never had a chance to learn where it goes. +test("an unresolved tab shows in every folder of its own sandbox and no other", () => { + const tabs = [sessionTab("sandbox:a", "unknown"), sessionTab("sandbox:a", "stale", "/mnt/gone")] + for (const directory of FOLDERS) { + expect(tabsForScope(tabs, folderScope("sandbox:a", directory)).map((tab) => tab.sessionId)).toEqual([ + "unknown", + "stale", + ]) + } + expect(tabsForScope(tabs, folderScope("sandbox:b", "/workspace"))).toEqual([]) +}) + +test("draft tabs scope by their own directory", () => { + const tabs = [draftTab("sandbox:a", "d1", "/mnt/shared/src"), draftTab("sandbox:a", "d2", "/workspace")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/mnt/shared"))).toEqual([tabs[0]]) +}) + +// The strip compares tabs by reference (the active highlight, the close index, +// the For key), so the filter must hand back the store's own objects. +test("keeps tab identity", () => { + const tabs = [sessionTab("sandbox:a", "a1", "/workspace")] + expect(tabsForScope(tabs, folderScope("sandbox:a", "/workspace"))[0]).toBe(tabs[0]) +}) + +test("reorders one folder in place and leaves the rest untouched", () => { + const tabs = [ + sessionTab("sidecar", "c1"), + sessionTab("sandbox:a", "w1", "/workspace"), + sessionTab("sandbox:a", "s1", "/mnt/shared"), + sessionTab("sandbox:a", "w2", "/workspace"), + ] + const scope = folderScope("sandbox:a", "/workspace") + const reordered = reorderWithinScope(tabs, scope, [tabKey(tabs[3]), tabKey(tabs[1])]) + expect(reordered).toEqual([tabKey(tabs[0]), tabKey(tabs[3]), tabKey(tabs[2]), tabKey(tabs[1])]) +}) + +test("ignores a reorder that does not cover the whole folder", () => { + const tabs = [sessionTab("sandbox:a", "w1", "/workspace"), sessionTab("sandbox:a", "w2", "/workspace")] + const scope = folderScope("sandbox:a", "/workspace") + expect(reorderWithinScope(tabs, scope, [tabKey(tabs[0])])).toEqual(tabs.map(tabKey)) +}) + +// An unresolved tab is in the visible list, so it must be in the key list the +// strip hands back — otherwise reorder() sees a short list and silently drops +// the drag. +test("a folder holding an unresolved tab still reorders", () => { + const tabs = [sessionTab("sandbox:a", "w1", "/workspace"), sessionTab("sandbox:a", "unknown")] + const scope = folderScope("sandbox:a", "/workspace") + expect(reorderWithinScope(tabs, scope, [tabKey(tabs[1]), tabKey(tabs[0])])).toEqual([ + tabKey(tabs[1]), + tabKey(tabs[0]), + ]) +}) + +test("selecting a folder returns to the session it was left on", () => { + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ lastSession: "ses_1", serverExists: true, openDraft: draft })).toEqual({ + type: "session", + sessionId: "ses_1", + }) +}) + +// A removed sandbox leaves its recorded session behind; navigating to it would +// be a dead route. +test("a recorded session on a vanished server falls through", () => { + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ lastSession: "ses_1", serverExists: false, openDraft: draft })).toEqual({ + type: "draft", + tab: draft, + }) + expect(folderEntry({ lastSession: "ses_1", serverExists: false })).toEqual({ type: "new" }) +}) + +test("with no history a folder lands on its open draft, then on a new one", () => { + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ serverExists: true, openDraft: draft })).toEqual({ type: "draft", tab: draft }) + expect(folderEntry({ serverExists: true })).toEqual({ type: "new" }) +}) + +// Every folder is in this state the first time it is opened after per-folder +// strips shipped: it has tabs, but nothing has recorded where it was left. +// Minting an empty draft beside tabs that are already there is the wrong move. +test("a folder with tabs but no recorded history lands on one of them", () => { + const open = sessionTab("sandbox:a", "a1", "/workspace") + expect(folderEntry({ serverExists: true, openSession: open })).toEqual({ type: "tab", tab: open }) +}) + +test("a recorded session and an open draft both outrank an open tab", () => { + const open = sessionTab("sandbox:a", "a1", "/workspace") + const draft = draftTab("sandbox:a", "d1", "/workspace") + expect(folderEntry({ lastSession: "ses_1", serverExists: true, openSession: open })).toEqual({ + type: "session", + sessionId: "ses_1", + }) + expect(folderEntry({ serverExists: true, openDraft: draft, openSession: open })).toEqual({ + type: "draft", + tab: draft, + }) +})