diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e81f7264..72fad42e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,40 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Work now offers Mount project instead of Add project** — the folder button + above the **Work** sidebar opened a folder picker that browsed *inside* the + sandbox, starting at the guest's home directory. It could not reach anything + on your own machine, so the one thing it looked like it offered — pointing a + sandbox at a folder here — was the one thing it could not do. The same button + now reads **Mount project** and opens the sandbox's folder list, where you + pick a folder on this machine to share into the sandbox; it then appears as a + project on Work, the way the sandbox's original project folder does. The + matching **Folders...** item in **Settings → Servers** is now called **Mount + project** too, so both routes to it use the same name. +- **Closing a folder on Work now actually closes it** — closing a mounted + folder from a **Work** project's menu removed it from the list and then put + it straight back, so there was no way to get a folder off the list at all. + 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. This also fixes the + **Unmount** action below, which appeared to do nothing and left the menu + showing **Close** afterwards. +- **A Work folder's menu now offers Unmount instead of Close** — a project on + the **Work** side is a folder shared into the sandbox, but its menu offered + **Close**, which only hid the row: the folder stayed shared with the sandbox + with nothing left in the app to say so, and no way to take it back short of + the sandbox's folder list. That item is now **Unmount**, in red, and it + really does unmount the folder — the sandbox stops seeing it and the row + goes away. On macOS and Linux the sandbox restarts to apply it, as it does + for any folder change; your tabs and sessions sit through the restart. Every + folder on a sandbox got there by being mounted, so they all read **Unmount**; + the folder the sandbox was created with is fixed for its lifetime, so there + the entry is greyed out rather than quietly saying something different from + the folders beside it. +- **Deleting a sandbox now looks destructive** — **Delete** in a sandbox's menu + in **Settings → Servers** sat in the same colour as Start, Stop and Reset, + giving the one irreversible action in the list no more weight than the rest. + It is now red, matching Delete in a session's menu. - **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 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 038e8c8c63..0376d27016 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -205,6 +205,8 @@ from the squash message first, or use a plain merge. | 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. | +| Mount / unmount on Work: `packages/app/src/pages/home.tsx`, `packages/app/src/i18n/{en,es}.ts` | `HomeServerRow` and `HomeProjectEmpty` each gain one optional `mountProject?: (server) => void` prop. When it is set, the folder-add icon keeps its icon and position but takes that handler instead of `chooseProject`, and its label/`aria-label` switch from `home.project.add` to `sandbox.server.mounts` via the new local `addProjectLabel(props)` helper; the `data-action` hooks fork to `work-mount-project` / `work-mount-project-row` so the upstream `home-add-project` / `home-add-project-row` hooks keep meaning exactly what they meant. Nothing else in either component changes and no import is added — the handler is injected by the fork-only `src/components/app-sidebar.tsx`, which passes it only for `sideForServer(conn) === "work"` and opens the fork-only `DialogSandboxMounts` (lazily; the sandbox id and its `ServerConnection.Key` are the same `sandbox:` string). The sidebar's own header button, which is fork-only code, switches on `side() === "work"` directly. `HomeProjectList` and `HomeProjectRow` likewise gain one optional `unmountProject?: (server, directory) => { unmount?: () => void } | undefined` — a **resolver**, not an action, so the row can ask per directory how its destructive action should read. Undefined is an ordinary project and keeps `common.close` through the ``'s fallback. An object means the directory is a folder mounted into a sandbox, so the item becomes a red `sidebar.project.unmount` (same `--menu-v2-fg` danger hook as the session menu's Delete); `unmount` is absent when the folder cannot be unmounted — the one the sandbox was created with is `projectDir`, fixed for its lifetime, and never appears in `conn.mounts` — and the item renders disabled. Every folder on a sandbox got there by being mounted, so **no Work row shows Close**: a row that did would be reading differently from the rows beside it for a reason the user cannot see. Its body lives in the fork-only sidebar: it rebuilds the mount list from the **persisted** `SandboxConfig.mounts`, never from `conn.mounts` (the runtime list omits mounts the provider skipped because their host folder is gone, so writing it back would silently drop them), closes the project row before awaiting `updateMounts` because applying a mount change restarts the sandbox on macOS and Linux, and reopens the row plus toasts if that call rejects. The i18n change is a value only: `sandbox.server.mounts` "Folders..." -> "Mount project" (es "Carpetas..." -> "Montar proyecto"), which relabels the existing Settings -> Servers menu item at the same time, deliberately, so one sandbox verb appears everywhere. | On Work, "Add project" opened a directory picker rooted **inside the guest** (`/home/codev`): `directoryPickerKind` returns `"server"` for a sandbox because `ServerConnection.local()` is false for `variant: "sandbox"`. So the button could only reach folders that were already mounted — and those are auto-seeded as projects by `src/sandbox/projects.ts` anyway — while the one thing it looked like it offered, pointing the sandbox at a folder on this machine, was impossible. Mounting is that operation, and the mount then becomes a project, so the button now does what its icon has always implied. | Keep the prop. On conflict take upstream's row components wholesale and re-apply the optional prop, the `addProjectLabel` helper, the two `data-action` ternaries and the `??` on the click handler. Do not push the sandbox check down into `home.tsx` — keeping it in the sidebar is what holds this to a props-only upstream diff with no new imports. Never let `home-add-project` / `home-add-project-row` drift; the Work variants are fork-only names and are free to change. Keep `unmountProject` a resolver: collapsing it to a plain action would force the row to decide what is unmountable, which is exactly the sandbox knowledge `home.tsx` must not carry. Never rebuild the mount list from `conn.mounts`. | +| `packages/app/src/context/server.tsx` | `createServerProjects.close()` wraps its two store writes — `remove(directory)` on `projects`, then the `recentlyClosed` record — in `batch()` (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's `src/sandbox/projects.ts` seeding effect reconciles against **both** lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's `close()` and re-wrap the two writes. Any future write pair that spans `projects` and `recentlyClosed` needs the same treatment; regression cover is `packages/app/test-browser/sandbox-projects.test.ts` ("closing a mounted folder sticks when the guest knows the project"). | | `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/README.md b/README.md index cd436e0b48..2b9b38b0b8 100644 --- a/README.md +++ b/README.md @@ -59,5 +59,4 @@ bun install bun dev # run the CLI from source bun dev:desktop # run the Electron desktop app from source bun typecheck # all packages, via turbo -cd packages/opencode && bun test # tests must run from a package dir, never the repo root ``` diff --git a/packages/app/src/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx index 062294d532..2d459f8e95 100644 --- a/packages/app/src/components/app-sidebar.tsx +++ b/packages/app/src/components/app-sidebar.tsx @@ -18,7 +18,10 @@ import { useSettingsCommand } from "@/components/settings-dialog" import { ChannelIndicator } from "@/components/titlebar" import { useAppSidebar } from "@/context/app-sidebar" import { useGlobal } from "@/context/global" -import { serversForSide } from "@/workspace/side" +import { useSandboxes } from "@/sandbox/context" +import { serversForSide, sideForServer } from "@/workspace/side" +import { pathKey } from "@/utils/path-key" +import { showToast } from "@/utils/toast" import { useWorkspaceSide, WorkspaceEmpty, WorkspaceTabs } from "@/workspace/tabs" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -81,6 +84,69 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool // sections below are unchanged; they just read this instead of the full list. const side = useWorkspaceSide() const sideServers = createMemo(() => serversForSide(global.servers.list(), side())) + const sandboxes = useSandboxes() + + // Fork: on Work, "Add project" would open a picker rooted in the guest + // (/home/codev), so it can only reach folders that are already mounted and + // cannot reach the host at all. Mounting a host folder is the operation the + // user actually wants there, and seedSandboxProjects turns each mount into a + // project — so the same icon opens the mounts dialog instead. + const mountProject = (conn: ServerConnection.Any) => { + // The sandbox id and its connection key are the same string (sandbox:). + const id = ServerConnection.key(conn) + void import("@/sandbox/dialog-mounts").then((module) => + dialog.show(() => ), + ) + } + // Passed only for sandboxes, which is what switches the row components over. + const mountProjectFor = (conn: ServerConnection.Any) => + sideForServer(conn) === "work" ? mountProject : undefined + // The header button sits above the list rather than on a server row, so it + // reads the side directly — Work only ever lists sandboxes. + const headerActionLabel = () => (side() === "work" ? "sandbox.server.mounts" : "home.project.add") + const headerAction = (conn: ServerConnection.Any) => (mountProjectFor(conn) ?? model.chooseProject)(conn) + + // Fork: the inverse of Mount project. A Work project row is a host folder + // shared into the sandbox, so hiding the row while the folder stays mounted + // is not what "close" should mean there. Resolves an action only for an + // extra mount — the sandbox's own project folder is fixed for its lifetime, + // and a guest folder that was never a mount has nothing to unmount, so both + // of those keep Close. + const unmountProject = (conn: ServerConnection.Any, directory: string) => { + const api = platform.sandboxes + if (!api || conn.type !== "sidecar" || conn.variant !== "sandbox") return undefined + const mounted = conn.mounts?.find((entry) => pathKey(entry.guestPath) === pathKey(directory)) + // Every folder on a sandbox got there by being mounted, so the menu says + // Unmount for all of them. `conn.mounts` holds only the extra folders, + // though — the folder the sandbox was created with is `projectDir`, fixed + // for the sandbox's lifetime — so that one is offered disabled rather than + // given a different verb from the rows beside it. + if (!mounted) return {} + return { + unmount: () => { + const key = ServerConnection.key(conn) + // Rebuilt from the persisted config rather than conn.mounts: the + // runtime list omits mounts the provider skipped because their folder + // is gone, and writing that back would drop them for good. + const configured = sandboxes.state.data?.sandboxes.find((item) => item.config.id === key)?.config.mounts ?? [] + // Closed first so the row goes away on the click. Applying the mount + // change restarts the sandbox on macOS and Linux, which takes seconds; + // a row that lingers through it reads as the click being ignored. + model.closeProject(conn, directory) + void api.updateMounts(key, configured.filter((entry) => entry.path !== mounted.path)).catch((error) => { + // Put it back: the folder is still mounted, so the list must say so. + global.ensureServerCtx(conn).projects.open(directory) + showToast({ + variant: "error", + title: language.t("sidebar.project.unmount.failed"), + description: error instanceof Error ? error.message : String(error), + }) + }) + }, + } + } + const unmountProjectFor = (conn: ServerConnection.Any) => + sideForServer(conn) === "work" ? unmountProject : undefined // Collapsed hides the column outright rather than narrowing it to a rail, so // the content pane takes the full width. The titlebar toggle is the way back. @@ -102,16 +168,16 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool !(model.projects().length === 0 && model.recentlyClosed().length > 0) } > - + } disabled={global.servers.health[ServerConnection.key(sideServers()[0]!)]?.healthy === false} - onClick={() => model.chooseProject(sideServers()[0]!)} - aria-label={language.t("home.project.add")} + onClick={() => headerAction(sideServers()[0]!)} + aria-label={language.t(headerActionLabel())} /> @@ -132,6 +198,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool recentlyClosed={model.recentlyClosed()} homedir={model.homedir()} chooseProject={model.chooseProject} + mountProject={mountProjectFor(sideServers()[0]!)} openRecentProject={(conn, directory) => model.addProjects(conn, [directory])} language={language} /> @@ -145,6 +212,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool openNewSession={model.openProjectNewSession} editProject={model.editProject} closeProject={model.closeProject} + unmountProject={unmountProjectFor(sideServers()[0]!)} clearNotifications={model.clearNotifications} unseenCount={model.unseenCount} language={language} @@ -171,6 +239,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool controller={controller} focusServer={model.focusServer} chooseProject={model.chooseProject} + mountProject={mountProjectFor(item)} openEdit={(server) => dialog.show(() => )} toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])} language={language} @@ -185,6 +254,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool openNewSession={model.openProjectNewSession} editProject={model.editProject} closeProject={model.closeProject} + unmountProject={unmountProjectFor(item)} clearNotifications={model.clearNotifications} unseenCount={model.unseenCount} language={language} diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 5319f3971a..4d83dc9569 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -111,14 +111,22 @@ export function createServerProjects(input: { }, // User-initiated close: removes the project and records it in recently closed. // Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove(). + // + // Fork: batched. The open list and the recently-closed list are two store + // keys, and Solid flushes effects after every write, so between the two a + // project is momentarily in neither list. An effect that reconciles + // against both — the sandbox project seeding in src/sandbox/projects.ts — + // sees that gap and opens the project straight back up. close(directory: string) { - remove(directory) const key = pathKey(directory) const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice( 0, RECENTLY_CLOSED_HISTORY_LIMIT, ) - setStore("recentlyClosed", input.scope(), closed) + batch(() => { + remove(directory) + setStore("recentlyClosed", input.scope(), closed) + }) }, expand(directory: string) { const index = current().findIndex((project) => project.worktree === directory) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d93966e4ed..cc2890ad1b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -386,7 +386,7 @@ export const dict = { "sandbox.server.start": "Start", "sandbox.server.stop": "Stop", "sandbox.server.reset": "Reset", - "sandbox.server.mounts": "Folders...", + "sandbox.server.mounts": "Mount project", "sandbox.status.starting": "Starting...", "sandbox.status.ready": "Ready", "sandbox.status.stopped": "Stopped", @@ -891,6 +891,8 @@ export const dict = { "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.clearNotifications": "Clear notifications", + "sidebar.project.unmount": "Unmount", + "sidebar.project.unmount.failed": "The folder could not be unmounted", "sidebar.empty.title": "No projects open", "sidebar.empty.description": "Open a project to get started", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 9d83d1bcba..e385c47520 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -377,7 +377,7 @@ export const dict = { "sandbox.server.start": "Iniciar", "sandbox.server.stop": "Detener", "sandbox.server.reset": "Restablecer", - "sandbox.server.mounts": "Carpetas...", + "sandbox.server.mounts": "Montar proyecto", "sandbox.status.starting": "Iniciando...", "sandbox.status.ready": "Listo", "sandbox.status.stopped": "Detenido", @@ -823,6 +823,8 @@ export const dict = { "sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.viewAllSessions": "Ver todas las sesiones", "sidebar.project.clearNotifications": "Borrar notificaciones", + "sidebar.project.unmount": "Desmontar", + "sidebar.project.unmount.failed": "No se pudo desmontar la carpeta", "sidebar.empty.title": "No hay proyectos abiertos", "sidebar.empty.description": "Abre un proyecto para empezar", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index fcb8c5e24d..b860753a3e 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -568,6 +568,12 @@ export function HomeUtilityNav(props: { ) } +// Fork: the two surfaces that offer "Add project" share one label so a sandbox +// never says "Add project" in one place and "Mount project" in the other. +function addProjectLabel(props: { mountProject?: unknown }) { + return props.mountProject ? "sandbox.server.mounts" : "home.project.add" +} + export function HomeServerRow(props: { server: ServerConnection.Any selected: boolean @@ -576,6 +582,9 @@ export function HomeServerRow(props: { controller: ReturnType focusServer: (server: ServerConnection.Any) => void chooseProject: (server: ServerConnection.Any) => void + // Fork: set for a sandbox, where a project is a host folder mounted into the + // guest rather than a directory the picker can browse to. See the button. + mountProject?: (server: ServerConnection.Any) => void openEdit: (server: ServerConnection.Http) => void toggleCollapsed: () => void language: ReturnType @@ -645,15 +654,23 @@ export function HomeServerRow(props: { open={state.menuOpen} onOpenChange={(open) => setState("menuOpen", open)} /> - + {/* Fork: on a sandbox the directory picker browses the guest, so it can + only reach folders that are already mounted — "Add project" there + offers a choice the user cannot make. The sidebar passes + mountProject instead, and the same icon opens the mounts dialog. */} + } - aria-label={props.language.t("home.project.add")} + aria-label={props.language.t(addProjectLabel(props))} disabled={props.health?.healthy === false} - onClick={() => props.chooseProject(props.server)} + onClick={() => (props.mountProject ?? props.chooseProject)(props.server)} /> @@ -669,6 +686,8 @@ export function HomeProjectList(props: { openNewSession: (server: ServerConnection.Any, directory: string) => void editProject: (server: ServerConnection.Any, project: LocalProject) => void closeProject: (server: ServerConnection.Any, directory: string) => void + // Fork: see HomeProjectRow — set for a sandbox, resolved per directory. + unmountProject?: (server: ServerConnection.Any, directory: string) => { unmount?: () => void } | undefined clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void unseenCount: (server: ServerConnection.Any, project: LocalProject) => number language: ReturnType @@ -689,6 +708,7 @@ export function HomeProjectList(props: { openNewSession={props.openNewSession} editProject={props.editProject} closeProject={props.closeProject} + unmountProject={props.unmountProject} clearNotifications={props.clearNotifications} language={props.language} /> @@ -703,6 +723,8 @@ export function HomeProjectEmpty(props: { recentlyClosed: LocalProject[] homedir: string chooseProject: (server: ServerConnection.Any) => void + // Fork: see HomeServerRow — a sandbox mounts folders instead of adding them. + mountProject?: (server: ServerConnection.Any) => void openRecentProject: (server: ServerConnection.Any, directory: string) => void language: ReturnType }) { @@ -712,13 +734,13 @@ export function HomeProjectEmpty(props: {
0}>
@@ -780,11 +802,19 @@ function HomeProjectRow(props: { openNewSession: (server: ServerConnection.Any, directory: string) => void editProject: (server: ServerConnection.Any, project: LocalProject) => void closeProject: (server: ServerConnection.Any, directory: string) => void + // Fork: resolves how this row's destructive action should read. Undefined is + // an ordinary project, which keeps Close. An object means the directory is a + // folder mounted into a sandbox, so the action is Unmount; `unmount` is + // absent when the folder cannot be unmounted — the one the sandbox was + // created with is fixed for its lifetime — and the item is shown disabled + // rather than silently reading differently from the rows beside it. + unmountProject?: (server: ServerConnection.Any, directory: string) => { unmount?: () => void } | undefined clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void language: ReturnType }) { const global = useGlobal() const platform = usePlatform() + const unmount = () => props.unmountProject?.(props.server, props.project.worktree) const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false const [state, setState] = createStore({ menuOpen: false }) const canRevealInFileManager = () => @@ -853,9 +883,28 @@ function HomeProjectRow(props: { {props.language.t("sidebar.project.clearNotifications")} - props.closeProject(props.server, props.project.worktree)}> - {props.language.t("common.close")} - + {/* Fork: unmounting removes the folder from the sandbox rather + than just hiding its row, so it takes the same danger colour + as Delete — set through the item's own `--menu-v2-fg`, which + is what reaches the label span menu-v2.css colours. */} + props.closeProject(props.server, props.project.worktree)}> + {props.language.t("common.close")} + + } + > + {(state) => ( + state().unmount?.()} + > + {props.language.t("sidebar.project.unmount")} + + )} + diff --git a/packages/app/src/sandbox/projects.ts b/packages/app/src/sandbox/projects.ts index 41c5d26ed0..983ac8544e 100644 --- a/packages/app/src/sandbox/projects.ts +++ b/packages/app/src/sandbox/projects.ts @@ -45,6 +45,13 @@ export function seedSandboxProjects( // A project is named once per app run. The rename lands back as a // project.updated event, but the effect can re-run before it arrives. const named = new Set() + // Folders this run has already opened. The `closed` check below cannot carry + // that on its own: the ctx's recentlyClosed is filtered down to directories + // the guest server reports as projects, and a folder that has never held a + // session has no such record — so closing one reads as "never opened" on the + // very next run of this effect, and it would be opened straight back up. + // Seeding a folder at most once is what lets the user close it. + const seeded = new Set() createEffect(() => { for (const conn of connections()) { @@ -56,14 +63,24 @@ export function seedSandboxProjects( { directory: primary, hostPath: conn.projectDir }, ...(conn.mounts ?? []).map((mount) => ({ directory: mount.guestPath, hostPath: mount.path })), ] + // Unmounting a folder forgets that it was seeded, so mounting it again + // seeds it again rather than leaving the user with no project for it. + const live = new Set(mounted.map((entry) => `${conn.name}\n${entry.directory}`)) + for (const key of seeded) { + if (key.startsWith(`${conn.name}\n`) && !live.has(key)) seeded.delete(key) + } for (const entry of mounted) { + const key = `${conn.name}\n${entry.directory}` const open = ctx.projects.list().some((project) => project.worktree === entry.directory) // Reopening a project the user closed on purpose would make it // impossible to remove. A mount removed from the sandbox keeps its // project open until the user closes it — sessions may reference it. const closed = ctx.projects.recentlyClosed().some((project) => project.worktree === entry.directory) - if (!open && !closed) ctx.projects.open(entry.directory) - void adoptFolderName(ctx, entry.hostPath, entry.directory, named, `${conn.name}\n${entry.directory}`) + if (!open && !closed && !seeded.has(key)) { + seeded.add(key) + ctx.projects.open(entry.directory) + } + void adoptFolderName(ctx, entry.hostPath, entry.directory, named, key) } } }) diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx index b17e6d1eb9..46e02dcb0b 100644 --- a/packages/app/src/sandbox/settings.tsx +++ b/packages/app/src/sandbox/settings.tsx @@ -142,7 +142,14 @@ export function SandboxServerSettings(props: { - request.mutate(() => props.controller.handleRemove(key))}> + {/* Same danger hook the session menu uses: menu-v2.css + colours the label span, so a text utility on the item + would not reach it. */} + request.mutate(() => props.controller.handleRemove(key))} + > {language.t("dialog.server.menu.delete")} diff --git a/packages/app/test-browser/sandbox-projects.test.ts b/packages/app/test-browser/sandbox-projects.test.ts index 60c721bfd9..2138c9b854 100644 --- a/packages/app/test-browser/sandbox-projects.test.ts +++ b/packages/app/test-browser/sandbox-projects.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" -import { ServerConnection } from "@/context/server" +import { createStore } from "solid-js/store" +import { createServerProjects, ServerConnection } from "@/context/server" +import { pathKey } from "@/utils/path-key" +import { ServerScope } from "@/utils/server-scope" import { seedSandboxProjects } from "@/sandbox/projects" function sandboxConnection(name: string, projectDir?: string): ServerConnection.Any { @@ -194,3 +197,72 @@ test("seeds a sandbox that becomes ready later", () => { expect(sandbox.opened).toEqual(["/workspace"]) dispose() }) + +// The fakes above hand seedSandboxProjects a friendly ctx. These two drive the +// real project store and the real recentlyClosed shape instead, because that is +// where closing a mounted folder actually broke: the row came back on its own, +// and once the unmount that followed it landed, the menu had fallen back to +// Close. Two independent causes, so two tests. +function realStore(serverProjects: string[]) { + const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const projects = createServerProjects({ scope: () => ServerScope.local, store, setStore }) + // context/global.tsx enriches recentlyClosed by filtering it down to the + // directories the guest server reports as projects. A mounted folder that has + // never held a session has no such record. + const known = new Set(serverProjects.map((worktree) => pathKey(worktree))) + const ctx = { + sdk: { client: { project: { update: async () => {} } } }, + projects: { + ...projects, + recentlyClosed: () => + projects + .recentlyClosed() + .filter((worktree) => known.has(pathKey(worktree))) + .map((worktree) => ({ worktree })), + }, + sync: { + data: { + path: { directory: "/workspace", worktree: "/workspace" }, + project: serverProjects.map((worktree, index) => ({ id: `p${index}`, worktree })), + }, + }, + } + const conn = { + displayName: "work", + type: "sidecar", + variant: "sandbox", + name: "work", + projectDir: "/home/dev/proj", + mounts: [{ path: "/home/dev/shared", guestPath: "/mnt/shared" }], + http: { url: "http://127.0.0.1:1234" }, + } as ServerConnection.Any + const dispose = createRoot((d) => { + seedSandboxProjects( + () => [conn], + () => ctx as never, + ) + return d + }) + return { projects, dispose, open: () => projects.list().map((project) => project.worktree) } +} + +// close() writes the open list and the recently-closed list separately, and +// Solid flushes effects between them: without a batch this effect saw a project +// that was in neither list and seeded it again. +test("closing a mounted folder sticks when the guest knows the project", () => { + const harness = realStore(["/workspace", "/mnt/shared"]) + expect(harness.open()).toContain("/mnt/shared") + harness.projects.close("/mnt/shared") + expect(harness.open()).not.toContain("/mnt/shared") + harness.dispose() +}) + +// The harder half: no server-side project record means the folder never appears +// in recentlyClosed at all, so `closed` can never carry the user's decision. +test("closing a mounted folder sticks when the guest has no project for it", () => { + const harness = realStore(["/workspace"]) + expect(harness.open()).toContain("/mnt/shared") + harness.projects.close("/mnt/shared") + expect(harness.open()).not.toContain("/mnt/shared") + harness.dispose() +})