|
| 1 | +import { useDialog } from "@opencode-ai/ui/context/dialog" |
| 2 | +import { showToast } from "@opencode-ai/ui/toast" |
| 3 | +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" |
| 4 | +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2" |
| 5 | +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" |
| 6 | +import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" |
| 7 | +import { Show } from "solid-js" |
| 8 | +import { createStore } from "solid-js/store" |
| 9 | +import { useLanguage } from "@/context/language" |
| 10 | +import { usePlatform } from "@/context/platform" |
| 11 | +import type { SandboxConfig } from "./types" |
| 12 | +import { useSandboxes } from "./context" |
| 13 | +import { sandboxCreatePhase } from "./create-phase" |
| 14 | +import { createSandboxImageField } from "./image-field" |
| 15 | + |
| 16 | +// The sandbox Work offers when there is none: everything DialogAddSandbox asks |
| 17 | +// for has an answer the app can give, so it asks for none of it. The name, the |
| 18 | +// folder and the limits are the main process's business (main/sandbox/ipc.ts); |
| 19 | +// the image stays because supplying one is a real choice on a machine that |
| 20 | +// cannot reach the storage host. |
| 21 | +// |
| 22 | +// DialogAddSandbox is untouched and still reachable from Settings → Servers — |
| 23 | +// that is where a second sandbox, or one pointed at a particular folder, gets |
| 24 | +// made. |
| 25 | +export function DialogAddDefaultSandbox(props: { onCreated?: (config: SandboxConfig) => void }) { |
| 26 | + const language = useLanguage() |
| 27 | + const platform = usePlatform() |
| 28 | + const dialog = useDialog() |
| 29 | + const sandboxes = useSandboxes() |
| 30 | + const api = platform.sandboxes! |
| 31 | + const [store, setStore] = createStore({ adding: false }) |
| 32 | + const image = createSandboxImageField() |
| 33 | + |
| 34 | + const availability = () => sandboxes.availability.data |
| 35 | + const unsupportedMessage = () => { |
| 36 | + const value = availability() |
| 37 | + return value && !value.supported ? value.message : null |
| 38 | + } |
| 39 | + const warning = () => { |
| 40 | + const value = availability() |
| 41 | + return value?.supported ? value.warning : null |
| 42 | + } |
| 43 | + |
| 44 | + const createJob = () => sandboxes.state.data?.jobs.find((entry) => entry.kind === "create") |
| 45 | + const createPhase = () => sandboxCreatePhase({ adding: store.adding, job: createJob() }) |
| 46 | + const downloadProgress = () => (createPhase() === "downloading" ? createJob()?.progress : undefined) |
| 47 | + const statusMessage = () => { |
| 48 | + const phase = createPhase() |
| 49 | + if (phase === "queued") return language.t("sandbox.job.queued") |
| 50 | + if (phase === "creating") return language.t("sandbox.add.creating") |
| 51 | + return null |
| 52 | + } |
| 53 | + |
| 54 | + const create = async () => { |
| 55 | + if (store.adding || availability()?.supported === false || !image.usable()) return |
| 56 | + setStore("adding", true) |
| 57 | + try { |
| 58 | + // Resolves undefined when the download was cancelled: keep the dialog |
| 59 | + // open so the user can try again rather than dropping them back on Code |
| 60 | + // with nothing to show for it. |
| 61 | + const created = await api.addDefault(image.path()) |
| 62 | + if (created) { |
| 63 | + props.onCreated?.(created) |
| 64 | + dialog.close() |
| 65 | + } |
| 66 | + } catch (error) { |
| 67 | + showToast({ |
| 68 | + variant: "error", |
| 69 | + title: language.t("common.requestFailed"), |
| 70 | + description: error instanceof Error ? error.message : String(error), |
| 71 | + }) |
| 72 | + } finally { |
| 73 | + setStore("adding", false) |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + // Its own channel rather than api.cancel(name): the name belongs to the main |
| 78 | + // process, and a sandbox being created is not in the sandbox list yet — the |
| 79 | + // controller only publishes it once the create has finished — so there is |
| 80 | + // nothing here to read it off. |
| 81 | + const cancel = () => { |
| 82 | + if (!store.adding) { |
| 83 | + dialog.close() |
| 84 | + return |
| 85 | + } |
| 86 | + void api.cancelDefault() |
| 87 | + } |
| 88 | + |
| 89 | + return ( |
| 90 | + <Dialog fit> |
| 91 | + <DialogHeader hideClose={true}> |
| 92 | + <DialogTitleGroup |
| 93 | + title={language.t("sandbox.addDefault.title")} |
| 94 | + description={language.t("sandbox.addDefault.intro")} |
| 95 | + /> |
| 96 | + </DialogHeader> |
| 97 | + <DividerV2 /> |
| 98 | + <DialogBody class="flex flex-col gap-3 px-4 pt-3 pb-1"> |
| 99 | + <Show |
| 100 | + when={availability()?.supported !== false} |
| 101 | + fallback={<p class="text-14 text-v2-text-text-muted">{unsupportedMessage()}</p>} |
| 102 | + > |
| 103 | + {/* warn-never-block: the warning is shown, creation stays enabled */} |
| 104 | + <Show when={warning()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show> |
| 105 | + <image.Field disabled={store.adding} /> |
| 106 | + <Show when={downloadProgress()}> |
| 107 | + {(current) => ( |
| 108 | + <div class="flex flex-col gap-1.5"> |
| 109 | + <div class="h-1 w-full overflow-hidden rounded-full bg-v2-background-bg-layer-04"> |
| 110 | + <div |
| 111 | + class="h-full rounded-full bg-v2-icon-icon-base transition-[width] duration-200" |
| 112 | + style={{ width: `${Math.min(100, (current().bytes / current().totalBytes) * 100)}%` }} |
| 113 | + /> |
| 114 | + </div> |
| 115 | + <p class="text-12 text-v2-text-text-muted"> |
| 116 | + {language.t("sandbox.add.downloadProgress", { |
| 117 | + done: Math.round(current().bytes / 1024 / 1024), |
| 118 | + total: Math.round(current().totalBytes / 1024 / 1024), |
| 119 | + })} |
| 120 | + </p> |
| 121 | + </div> |
| 122 | + )} |
| 123 | + </Show> |
| 124 | + <Show when={statusMessage()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show> |
| 125 | + </Show> |
| 126 | + </DialogBody> |
| 127 | + <DialogFooter> |
| 128 | + <ButtonV2 variant="neutral" onClick={cancel}> |
| 129 | + {language.t("common.cancel")} |
| 130 | + </ButtonV2> |
| 131 | + <Show when={availability()?.supported !== false}> |
| 132 | + <ButtonV2 |
| 133 | + variant={store.adding ? "loading" : "contrast"} |
| 134 | + disabled={!store.adding && !image.usable()} |
| 135 | + onClick={() => void create()} |
| 136 | + > |
| 137 | + <Show when={store.adding} fallback={language.t("sandbox.addDefault.create")}> |
| 138 | + <LoaderV2 /> |
| 139 | + </Show> |
| 140 | + </ButtonV2> |
| 141 | + </Show> |
| 142 | + </DialogFooter> |
| 143 | + </Dialog> |
| 144 | + ) |
| 145 | +} |
0 commit comments