diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1baab8ab..ac6fd259a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Sandboxes can be stopped** — a sandbox ran from the moment the app started + until it was deleted, and every sandbox on the machine booted at launch, so + three sandboxes meant three virtual machines holding memory and CPU whether + or not any work was happening in them. Each sandbox now has a **Stop** entry + in its menu (Settings → Servers), which shuts its environment down; files on + its disk are kept, and running terminals and in-flight work stop. A stopped + sandbox stays stopped when the app is relaunched, so a machine only runs the + sandboxes you actually want running. **Start** in the same menu brings it + back, and resetting a stopped sandbox rebuilds it without starting it. +- **Working on one sandbox no longer cancels another one's work** — creating, + resetting, and deleting a sandbox all ran as a single operation with no + regard for which sandbox they belonged to, so starting one cancelled + whichever was already running. Deleting a sandbox part-way through another + sandbox's image download silently abandoned that download, and the half-built + sandbox was never created — with no error to say why. These operations now + queue: each one runs in turn and finishes. A sandbox waiting its turn says + so, and cancelling it (or the **Cancel** button in the Add sandbox dialog) + still takes effect immediately without disturbing whatever is running. - **A stalling disk can no longer freeze the app** — saving any setting (window layout, server list, sandbox folders) used to write to disk synchronously on the app's main thread. If the operating system's disk diff --git a/DIVERGENCES.md b/DIVERGENCES.md index e3731211fc..a065f83149 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -197,11 +197,11 @@ from the squash message first, or use a plain merge. | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. | | Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | -| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family (including the optional per-sandbox CPU/memory fields, macOS-only since only the vz backend applies them; `sandbox.add.projectDirHint`, the standing explanation above the folder field; and the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox); preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. | +| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family (including the optional per-sandbox CPU/memory fields, macOS-only since only the vz backend applies them; `sandbox.add.projectDirHint`, the standing explanation above the folder field; the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; `sandbox.job.queued`, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job; and `sandbox.server.stop` for the menu entry that shuts a sandbox down until it is explicitly started again); preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. | | 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. | +| 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/i18n/en.ts b/packages/app/src/i18n/en.ts index cadc0904a0..13cf45d6f8 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -378,10 +378,12 @@ export const dict = { "workspace.work": "Work", "workspace.empty.none": "No sandbox yet. Create one to work in an isolated environment.", "workspace.empty.starting": "Your sandbox is not running yet. It appears here once it is ready.", + "workspace.empty.stopped": "Your sandbox is stopped. Start it again from Settings → Servers.", "sandbox.server.add": "Add sandbox", "sandbox.server.label": "Sandbox", "sandbox.server.menu.label": "Sandbox", "sandbox.server.start": "Start", + "sandbox.server.stop": "Stop", "sandbox.server.reset": "Reset", "sandbox.server.mounts": "Folders...", "sandbox.status.starting": "Starting...", @@ -391,6 +393,7 @@ export const dict = { "sandbox.job.create": "Creating...", "sandbox.job.destroy": "Removing...", "sandbox.job.reset": "Resetting...", + "sandbox.job.queued": "Waiting for another sandbox...", "sandbox.add.namePlaceholder": "Sandbox name", "sandbox.add.nameInvalid": "Use letters, numbers, dots, dashes, or underscores.", "sandbox.add.projectDir": "Project folder", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 58b6b7cb37..4850bcaa41 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -369,10 +369,12 @@ export const dict = { "workspace.work": "Trabajo", "workspace.empty.none": "Aún no hay ningún sandbox. Crea uno para trabajar en un entorno aislado.", "workspace.empty.starting": "Tu sandbox todavía no está en marcha. Aparecerá aquí cuando esté listo.", + "workspace.empty.stopped": "Tu sandbox está detenido. Vuelve a iniciarlo desde Ajustes → Servidores.", "sandbox.server.add": "Añadir sandbox", "sandbox.server.label": "Sandbox", "sandbox.server.menu.label": "Sandbox", "sandbox.server.start": "Iniciar", + "sandbox.server.stop": "Detener", "sandbox.server.reset": "Restablecer", "sandbox.server.mounts": "Carpetas...", "sandbox.status.starting": "Iniciando...", @@ -382,6 +384,7 @@ export const dict = { "sandbox.job.create": "Creando...", "sandbox.job.destroy": "Eliminando...", "sandbox.job.reset": "Restableciendo...", + "sandbox.job.queued": "Esperando a otro sandbox...", "sandbox.add.namePlaceholder": "Nombre del sandbox", "sandbox.add.nameInvalid": "Usa letras, números, puntos, guiones o guiones bajos.", "sandbox.add.projectDir": "Carpeta del proyecto", diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index 0e3ff43eac..2f2f503f35 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -65,13 +65,15 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => }) } - // The create job's image-download progress, pushed by the main process - // while this dialog's add is in flight. + // This create's image-download progress, pushed by the main process while + // the add is in flight. Jobs are queued, so the create may still be waiting + // behind another sandbox's job: no progress until it starts running. const progress = () => { if (!store.adding) return undefined - const job = sandboxes.state.data?.job - return job?.kind === "create" ? job.progress : undefined + return sandboxes.state.data?.jobs.find((job) => job.kind === "create" && job.status === "running")?.progress } + const queued = () => + store.adding && sandboxes.state.data?.jobs.some((job) => job.kind === "create" && job.status === "queued") const create = async () => { if (!canCreate() || store.adding) return @@ -172,7 +174,11 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => {language.t("sandbox.add.downloadHint")}

} + fallback={ +

+ {queued() ? language.t("sandbox.job.queued") : language.t("sandbox.add.downloadHint")} +

+ } > {(current) => (
diff --git a/packages/app/src/sandbox/dialog-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx index 3fa14a63a7..a3622b75af 100644 --- a/packages/app/src/sandbox/dialog-mounts.tsx +++ b/packages/app/src/sandbox/dialog-mounts.tsx @@ -30,7 +30,7 @@ export function DialogSandboxMounts(props: { id: string }) { saving: false, }) - const busy = () => sandboxes.state.data?.job?.id === props.id + const busy = () => !!sandboxes.state.data?.jobs.some((job) => job.id === props.id) const dirty = () => { const current = (item()?.config.mounts ?? []).map((mount) => mount.path) return current.length !== store.paths.length || current.some((path, index) => path !== store.paths[index]) diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx index 15a162a1d8..b17e6d1eb9 100644 --- a/packages/app/src/sandbox/settings.tsx +++ b/packages/app/src/sandbox/settings.tsx @@ -26,6 +26,12 @@ export function sandboxRuntimeRetryable(runtime: SandboxRuntime) { return runtime.kind === "failed" || runtime.kind === "stopped" } +// Stopping is offered while it is still coming up too: a start that hangs on a +// slow image or a wedged VM is exactly when the user wants out of it. +export function sandboxRuntimeStoppable(runtime: SandboxRuntime) { + return runtime.kind === "ready" || runtime.kind === "starting" +} + export function useFilteredSandboxes(filter: Accessor) { const sandboxes = useSandboxes() return createMemo(() => { @@ -63,11 +69,16 @@ export function SandboxServerSettings(props: { {(item) => { const key = ServerConnection.Key.make(item.config.id) - // Create/reset/destroy jobs are single-flight in the controller, so a - // job on this sandbox means its lifecycle actions must wait. - const busy = () => sandboxes.state.data?.job?.id === item.config.id + // A sandbox has at most one job, running or queued behind another + // sandbox's; either way its lifecycle actions must wait. + const job = () => sandboxes.state.data?.jobs.find((entry) => entry.id === item.config.id) + const busy = () => !!job() const meta = () => { - if (busy()) return language.t(`sandbox.job.${sandboxes.state.data!.job!.kind}`) + const current = job() + if (current) + return current.status === "queued" + ? language.t("sandbox.job.queued") + : language.t(`sandbox.job.${current.kind}`) if (item.runtime.kind === "failed") return item.runtime.message return `${item.config.backend} · ${language.t(`sandbox.status.${item.runtime.kind}`)}` } @@ -106,6 +117,11 @@ export function SandboxServerSettings(props: { {language.t("sandbox.server.start")} + + api && request.mutate(() => api.stop(key))}> + {language.t("sandbox.server.stop")} + + api && request.mutate(() => api.reset(key))}> {language.t("sandbox.server.reset")} diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts index fd052cc960..0ed6370be9 100644 --- a/packages/app/src/sandbox/types.ts +++ b/packages/app/src/sandbox/types.ts @@ -13,6 +13,9 @@ export type SandboxConfig = { // Extra host folders shared into the guest alongside projectDir; absent // means none. Objects so a readOnly flag can be added without migration. mounts?: SandboxMount[] + // Set when the user stopped this sandbox, so it stays down across relaunches + // instead of booting with the rest. + stopped?: boolean } & SandboxResources export type SandboxMount = { @@ -48,13 +51,19 @@ export type SandboxProgress = { bytes: number; totalBytes: number } export type SandboxJob = { kind: "create" | "destroy" | "reset" id: string + // When the job was admitted to the queue, which is not when it started + // running — status says which of those has happened. startedAt: number + status: "running" | "queued" progress?: SandboxProgress } export type SandboxesState = { sandboxes: SandboxItem[] - job: SandboxJob | null + // Create/reset/destroy run one at a time across all sandboxes (they share + // one image cache), so a job can be waiting behind another sandbox's. The + // running job comes first; at most one job per sandbox. + jobs: SandboxJob[] } export type SandboxesEvent = { type: "state"; state: SandboxesState } @@ -74,6 +83,8 @@ export type SandboxesPlatform = { remove(id: string): Promise reset(id: string): Promise start(id: string): Promise + // Shuts the sandbox down until the user starts it again, across relaunches. + stop(id: string): Promise // Replaces the extra-mount list. Applies hot on Windows (WSL); on macOS and // Linux a running sandbox restarts to pick the new list up. updateMounts(id: string, mounts: SandboxMount[]): Promise diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx index 82e4b6588a..446db5a5dc 100644 --- a/packages/app/src/workspace/tabs.tsx +++ b/packages/app/src/workspace/tabs.tsx @@ -99,22 +99,27 @@ export function useWorkspaceSwitch() { } } -// Reachable on Work while its only sandbox is still starting or has failed — -// a sandbox joins the server list only once it is ready — and after the last -// sandbox is removed. Code always has the local server, so it only shows here -// if every server is somehow gone. +// Reachable on Work while its only sandbox is still starting, has failed, or +// was stopped — a sandbox joins the server list only once it is ready — and +// after the last sandbox is removed. Code always has the local server, so it +// only shows here if every server is somehow gone. export function WorkspaceEmpty(props: { side: WorkspaceSide }) { const language = useLanguage() const dialog = useDialog() const sandboxes = useSandboxes() - const configured = () => sandboxes.state.data?.sandboxes.length ?? 0 + const configured = () => sandboxes.state.data?.sandboxes ?? [] + // Nothing here is coming up on its own once every sandbox is stopped, so + // saying "it appears here once it is ready" would be a promise we do not + // keep. One sandbox still starting is enough for the wait to be true. + const message = () => { + if (props.side !== "work" || !configured().length) return "workspace.empty.none" + return configured().every((item) => item.config.stopped) ? "workspace.empty.stopped" : "workspace.empty.starting" + } return (
-

- {language.t(props.side === "work" && configured() > 0 ? "workspace.empty.starting" : "workspace.empty.none")} -

- +

{language.t(message())}

+