Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions DIVERGENCES.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand All @@ -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",
Expand Down
16 changes: 11 additions & 5 deletions packages/app/src/sandbox/dialog-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -172,7 +174,11 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>
</Show>
<Show
when={progress()}
fallback={<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.downloadHint")}</p>}
fallback={
<p class="text-12 text-v2-text-text-muted">
{queued() ? language.t("sandbox.job.queued") : language.t("sandbox.add.downloadHint")}
</p>
}
>
{(current) => (
<div class="flex flex-col gap-1.5">
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/sandbox/dialog-mounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
24 changes: 20 additions & 4 deletions packages/app/src/sandbox/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
const sandboxes = useSandboxes()
return createMemo(() => {
Expand Down Expand Up @@ -63,11 +69,16 @@ export function SandboxServerSettings(props: {
<For each={props.sandboxes()}>
{(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}`)}`
}
Expand Down Expand Up @@ -106,6 +117,11 @@ export function SandboxServerSettings(props: {
{language.t("sandbox.server.start")}
</MenuV2.Item>
</Show>
<Show when={sandboxRuntimeStoppable(item.runtime)}>
<MenuV2.Item disabled={busy()} onSelect={() => api && request.mutate(() => api.stop(key))}>
{language.t("sandbox.server.stop")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={busy()} onSelect={() => api && request.mutate(() => api.reset(key))}>
{language.t("sandbox.server.reset")}
</MenuV2.Item>
Expand Down
13 changes: 12 additions & 1 deletion packages/app/src/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 }
Expand All @@ -74,6 +83,8 @@ export type SandboxesPlatform = {
remove(id: string): Promise<void>
reset(id: string): Promise<void>
start(id: string): Promise<void>
// Shuts the sandbox down until the user starts it again, across relaunches.
stop(id: string): Promise<void>
// 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<void>
Expand Down
23 changes: 14 additions & 9 deletions packages/app/src/workspace/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div class="flex flex-col items-start gap-2 px-1.5 py-3">
<p class="text-12 text-v2-text-text-muted">
{language.t(props.side === "work" && configured() > 0 ? "workspace.empty.starting" : "workspace.empty.none")}
</p>
<Show when={props.side === "work" && configured() === 0}>
<p class="text-12 text-v2-text-text-muted">{language.t(message())}</p>
<Show when={props.side === "work" && !configured().length}>
<button
type="button"
class="text-12-medium text-v2-text-text-base underline"
Expand Down
Loading
Loading