From 5990c5f3f1169fde4c452dd6a037b6425a917a87 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:11:45 -0500 Subject: [PATCH 01/26] feat(desktop): add pure workspace source assembly helper --- .../src/lib/utils/workspace-source.test.ts | 122 ++++++++++++++++++ .../src/lib/utils/workspace-source.ts | 65 ++++++++++ 2 files changed, 187 insertions(+) create mode 100644 desktop/src/renderer/src/lib/utils/workspace-source.test.ts create mode 100644 desktop/src/renderer/src/lib/utils/workspace-source.ts diff --git a/desktop/src/renderer/src/lib/utils/workspace-source.test.ts b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts new file mode 100644 index 000000000..9efc74f24 --- /dev/null +++ b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest" +import { buildWorkspaceSource } from "./workspace-source.js" +import type { WorkspaceSourceForm } from "./workspace-source.js" + +function form(overrides: Partial): WorkspaceSourceForm { + return { + sourceType: "git", + repoUrl: "", + localPath: "", + imageRef: "", + refType: "branch", + refValue: "", + subPath: "", + devcontainerPath: "", + prebuildRepository: "", + ...overrides, + } +} + +describe("buildWorkspaceSource", () => { + it("git: bare repo url, no suffixes", () => { + const out = buildWorkspaceSource(form({ repoUrl: "github.com/org/repo" })) + expect(out.source).toBe("github.com/org/repo") + expect(out.devcontainerPath).toBeUndefined() + expect(out.prebuildRepository).toBeUndefined() + }) + + it("git: branch ref appends @branch", () => { + const out = buildWorkspaceSource( + form({ repoUrl: "github.com/org/repo", refType: "branch", refValue: "dev" }), + ) + expect(out.source).toBe("github.com/org/repo@dev") + }) + + it("git: commit ref appends @sha256:", () => { + const out = buildWorkspaceSource( + form({ repoUrl: "github.com/org/repo", refType: "commit", refValue: "abc123" }), + ) + expect(out.source).toBe("github.com/org/repo@sha256:abc123") + }) + + it("git: PR ref appends @pull/N/head", () => { + const out = buildWorkspaceSource( + form({ repoUrl: "github.com/org/repo", refType: "pr", refValue: "42" }), + ) + expect(out.source).toBe("github.com/org/repo@pull/42/head") + }) + + it("git: subpath appends @subpath: after ref", () => { + const out = buildWorkspaceSource( + form({ + repoUrl: "github.com/org/repo", + refType: "branch", + refValue: "main", + subPath: "packages/api", + }), + ) + expect(out.source).toBe("github.com/org/repo@main@subpath:packages/api") + }) + + it("git: empty refValue omits the ref even if refType set", () => { + const out = buildWorkspaceSource( + form({ repoUrl: "github.com/org/repo", refType: "commit", refValue: "" }), + ) + expect(out.source).toBe("github.com/org/repo") + }) + + it("git: trims whitespace from inputs", () => { + const out = buildWorkspaceSource( + form({ repoUrl: " github.com/org/repo ", refValue: " main " }), + ) + expect(out.source).toBe("github.com/org/repo@main") + }) + + it("git: forwards devcontainerPath and prebuildRepository", () => { + const out = buildWorkspaceSource( + form({ + repoUrl: "github.com/org/repo", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepository: "ghcr.io/org/prebuilds", + }), + ) + expect(out.devcontainerPath).toBe(".devcontainer/devcontainer.json") + expect(out.prebuildRepository).toBe("ghcr.io/org/prebuilds") + }) + + it("local: uses localPath as source, supports subpath", () => { + const out = buildWorkspaceSource( + form({ sourceType: "local", localPath: "/home/me/proj", subPath: "sub" }), + ) + expect(out.source).toBe("/home/me/proj@subpath:sub") + }) + + it("local: forwards devcontainerPath and prebuildRepository", () => { + const out = buildWorkspaceSource( + form({ + sourceType: "local", + localPath: "/home/me/proj", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepository: "ghcr.io/org/prebuilds", + }), + ) + expect(out.devcontainerPath).toBe(".devcontainer/devcontainer.json") + expect(out.prebuildRepository).toBe("ghcr.io/org/prebuilds") + }) + + it("image: uses imageRef as source, ignores git/build options", () => { + const out = buildWorkspaceSource( + form({ + sourceType: "image", + imageRef: "mcr.microsoft.com/devcontainers/python:3.12", + refValue: "main", + subPath: "sub", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepository: "ghcr.io/org/prebuilds", + }), + ) + expect(out.source).toBe("mcr.microsoft.com/devcontainers/python:3.12") + expect(out.devcontainerPath).toBeUndefined() + expect(out.prebuildRepository).toBeUndefined() + }) +}) diff --git a/desktop/src/renderer/src/lib/utils/workspace-source.ts b/desktop/src/renderer/src/lib/utils/workspace-source.ts new file mode 100644 index 000000000..3a30226e9 --- /dev/null +++ b/desktop/src/renderer/src/lib/utils/workspace-source.ts @@ -0,0 +1,65 @@ +export type WorkspaceSourceType = "git" | "local" | "image" +export type GitRefType = "branch" | "commit" | "pr" + +export interface WorkspaceSourceForm { + sourceType: WorkspaceSourceType + repoUrl: string + localPath: string + imageRef: string + refType: GitRefType + refValue: string + subPath: string + devcontainerPath: string + prebuildRepository: string +} + +export interface WorkspaceSourceResult { + source: string + devcontainerPath?: string + prebuildRepository?: string +} + +function refSuffix(refType: GitRefType, refValue: string): string { + const value = refValue.trim() + if (!value) return "" + switch (refType) { + case "branch": + return `@${value}` + case "commit": + return `@sha256:${value}` + case "pr": + return `@pull/${value}/head` + } +} + +function subPathSuffix(subPath: string): string { + const value = subPath.trim() + return value ? `@subpath:${value}` : "" +} + +function optional(value: string): string | undefined { + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +export function buildWorkspaceSource( + form: WorkspaceSourceForm, +): WorkspaceSourceResult { + if (form.sourceType === "image") { + return { source: form.imageRef.trim() } + } + + const base = + form.sourceType === "git" ? form.repoUrl.trim() : form.localPath.trim() + + const ref = + form.sourceType === "git" ? refSuffix(form.refType, form.refValue) : "" + + const source = `${base}${ref}${subPathSuffix(form.subPath)}` + + return { + source, + devcontainerPath: optional(form.devcontainerPath), + prebuildRepository: optional(form.prebuildRepository), + } +} From 2d49979c6ab76d5f0c2196b1ce18b29cab3ac8fa Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:14:51 -0500 Subject: [PATCH 02/26] test(desktop): cover subpath-without-ref and whitespace-only optionals --- .../src/lib/utils/workspace-source.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/desktop/src/renderer/src/lib/utils/workspace-source.test.ts b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts index 9efc74f24..b65000d98 100644 --- a/desktop/src/renderer/src/lib/utils/workspace-source.test.ts +++ b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts @@ -58,6 +58,21 @@ describe("buildWorkspaceSource", () => { expect(out.source).toBe("github.com/org/repo@main@subpath:packages/api") }) + it("git: subpath without ref appends @subpath: directly", () => { + const out = buildWorkspaceSource( + form({ repoUrl: "github.com/org/repo", refValue: "", subPath: "packages/api" }), + ) + expect(out.source).toBe("github.com/org/repo@subpath:packages/api") + }) + + it("git: whitespace-only optional fields become undefined", () => { + const out = buildWorkspaceSource( + form({ repoUrl: "github.com/org/repo", devcontainerPath: " ", prebuildRepository: " " }), + ) + expect(out.devcontainerPath).toBeUndefined() + expect(out.prebuildRepository).toBeUndefined() + }) + it("git: empty refValue omits the ref even if refType set", () => { const out = buildWorkspaceSource( form({ repoUrl: "github.com/org/repo", refType: "commit", refValue: "" }), From 3cef1f3a480a653357d1541c2b883c96b4040a22 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:16:59 -0500 Subject: [PATCH 03/26] feat(desktop): add workspaceUp build flags and directory dialog command --- .../src/renderer/src/lib/ipc/commands.test.ts | 25 +++++++++++++++++++ desktop/src/renderer/src/lib/ipc/commands.ts | 6 +++++ 2 files changed, 31 insertions(+) diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index e88670af9..54a9e89d9 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.test.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.test.ts @@ -10,6 +10,7 @@ import { machineCreate, machineDelete, machineStatus, + openDirectoryDialog, providerAdd, providerSetOptions, sshKeyGenerate, @@ -96,6 +97,30 @@ describe("IPC commands", () => { }) expect(result).toBe('{"state":"Running"}') }) + + it("workspaceUp forwards devcontainerPath and prebuildRepositories", async () => { + mockInvoke.mockResolvedValue("cmd-id") + await workspaceUp({ + source: "github.com/org/repo", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepositories: "ghcr.io/org/prebuilds", + }) + expect(mockInvoke).toHaveBeenCalledWith( + "workspace_up", + expect.objectContaining({ + source: "github.com/org/repo", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepositories: "ghcr.io/org/prebuilds", + }), + ) + }) + + it("openDirectoryDialog invokes dialog_open_directory", async () => { + mockInvoke.mockResolvedValue("/home/me/proj") + const result = await openDirectoryDialog() + expect(mockInvoke).toHaveBeenCalledWith("dialog_open_directory") + expect(result).toBe("/home/me/proj") + }) }) describe("provider commands", () => { diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 84d6c38ea..34d3831f9 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -26,6 +26,8 @@ export async function workspaceUp(params: { ideLaunch?: "auto" | "headless" | "skip" debug?: boolean workspaceFolder?: string + devcontainerPath?: string + prebuildRepositories?: string }): Promise { return invoke("workspace_up", params) } @@ -76,6 +78,10 @@ export async function workspaceSetIde( return invoke("workspace_set_ide", { workspaceId, ide }) } +export async function openDirectoryDialog(): Promise { + return invoke("dialog_open_directory") +} + // Provider commands export async function providerList(): Promise { return invoke("provider_list") From 7c27141ef99b40994b96bfe542684bfe3128a675 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:17:31 -0500 Subject: [PATCH 04/26] feat(desktop): forward devcontainer-path and prebuild-repository to workspace up --- desktop/src/main/ipc.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 57308084f..5bef97b22 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -475,6 +475,8 @@ export function registerIpcHandlers(deps: IpcDependencies): { ideLaunch?: "auto" | "headless" | "skip" debug?: boolean workspaceFolder?: string + devcontainerPath?: string + prebuildRepositories?: string }, ) => { trackEvent("workspace_create", { provider: args.provider }) @@ -485,6 +487,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (args.ideLaunch) cliArgs.push("--ide-launch", args.ideLaunch) if (args.debug) cliArgs.push("--debug") if (args.workspaceFolder) cliArgs.push("--workspace-folder", args.workspaceFolder) + if (args.devcontainerPath) + cliArgs.push("--devcontainer-path", args.devcontainerPath) + if (args.prebuildRepositories) + cliArgs.push("--prebuild-repository", args.prebuildRepositories) const wsId = args.workspaceId ?? args.source const cmdId = crypto.randomUUID() From 5a8381fc303960db17edc4761c49af2e6912b01f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:18:02 -0500 Subject: [PATCH 05/26] feat(desktop): add native directory picker IPC --- desktop/src/main/ipc.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 5bef97b22..4f799359f 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" import type { BrowserWindow } from "electron" -import { app, ipcMain } from "electron" +import { app, dialog, ipcMain } from "electron" import type { CLIError } from "../shared/cli-error.js" import { trackEvent } from "./analytics.js" import type { CliRunner } from "./cli.js" @@ -321,6 +321,15 @@ export function registerIpcHandlers(deps: IpcDependencies): { return providerUpdateCache }) + ipcMain.handle("dialog_open_directory", async () => { + const win = deps.getMainWindow() + const result = win + ? await dialog.showOpenDialog(win, { properties: ["openDirectory"] }) + : await dialog.showOpenDialog({ properties: ["openDirectory"] }) + if (result.canceled || result.filePaths.length === 0) return null + return result.filePaths[0] + }) + // ── Machines ── ipcMain.handle("machine_list", () => state.machineList()) From a5be317de022361493864774e39708c4a06f58f8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:24:48 -0500 Subject: [PATCH 06/26] feat(desktop): add image catalog types --- desktop/src/renderer/src/lib/types/index.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 4e9617212..5d6cc547d 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -96,6 +96,27 @@ export interface ProviderVersionCheckResult { error?: string } +export interface CatalogCategory { + id: string + label: string +} + +export interface CatalogImage { + id: string + ref: string + name: string + description?: string + categories: string[] + icon?: string + featured?: boolean +} + +export interface ImageCatalog { + version: number + categories: CatalogCategory[] + images: CatalogImage[] +} + export interface MachineProviderConfig { name?: string } From 4bbb89f636171898ae39e8705f79a82af479cecc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:25:19 -0500 Subject: [PATCH 07/26] feat(desktop): add bundled image catalog seed manifest --- desktop/electron-builder.yml | 2 ++ desktop/resources/image-catalog-seed.json | 44 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 desktop/resources/image-catalog-seed.json diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index a3d232482..550668a6e 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -15,6 +15,8 @@ extraResources: to: bin/ filter: - "**/*" + - from: resources/image-catalog-seed.json + to: image-catalog-seed.json mac: category: public.app-category.developer-tools diff --git a/desktop/resources/image-catalog-seed.json b/desktop/resources/image-catalog-seed.json new file mode 100644 index 000000000..dd2a60808 --- /dev/null +++ b/desktop/resources/image-catalog-seed.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "categories": [ + { "id": "languages", "label": "Languages" }, + { "id": "universal", "label": "Universal" } + ], + "images": [ + { + "id": "universal", + "ref": "mcr.microsoft.com/devcontainers/universal:2", + "name": "Universal", + "description": "Multi-language image with common tools preinstalled.", + "categories": ["universal"], + "icon": "universal", + "featured": true + }, + { + "id": "python-3.12", + "ref": "mcr.microsoft.com/devcontainers/python:3.12", + "name": "Python 3.12", + "description": "Python 3.12 development image.", + "categories": ["languages"], + "icon": "Python", + "featured": true + }, + { + "id": "node-20", + "ref": "mcr.microsoft.com/devcontainers/javascript-node:20", + "name": "Node.js 20", + "description": "Node.js 20 development image.", + "categories": ["languages"], + "icon": "Node.js", + "featured": true + }, + { + "id": "go-1", + "ref": "mcr.microsoft.com/devcontainers/go:1", + "name": "Go", + "description": "Go development image.", + "categories": ["languages"], + "icon": "Go" + } + ] +} From 4a804158232dcae80a898775a92d08959ade1d56 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:26:17 -0500 Subject: [PATCH 08/26] feat(desktop): image catalog fetch with cache and seed fallback --- .../src/main/__tests__/image-catalog.test.ts | 103 ++++++++++++++++++ desktop/src/main/image-catalog.ts | 62 +++++++++++ desktop/src/renderer/src/lib/types/index.ts | 25 +---- desktop/src/shared/image-catalog-types.ts | 20 ++++ 4 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 desktop/src/main/__tests__/image-catalog.test.ts create mode 100644 desktop/src/main/image-catalog.ts create mode 100644 desktop/src/shared/image-catalog-types.ts diff --git a/desktop/src/main/__tests__/image-catalog.test.ts b/desktop/src/main/__tests__/image-catalog.test.ts new file mode 100644 index 000000000..0f9fe77e5 --- /dev/null +++ b/desktop/src/main/__tests__/image-catalog.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { loadCatalog, __setFetchForTest } from "../image-catalog.js" + +const SEED = { + version: 1, + categories: [{ id: "languages", label: "Languages" }], + images: [ + { id: "seed-img", ref: "seed:1", name: "Seed", categories: ["languages"] }, + ], +} +const REMOTE = { + version: 1, + categories: [{ id: "languages", label: "Languages" }], + images: [ + { id: "remote-img", ref: "remote:1", name: "Remote", categories: ["languages"] }, + ], +} + +let dir: string +let cachePath: string +let seedPath: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "catalog-")) + cachePath = join(dir, "image-catalog.json") + seedPath = join(dir, "seed.json") + await writeFile(seedPath, JSON.stringify(SEED)) +}) + +afterEach(async () => { + __setFetchForTest(undefined) + await rm(dir, { recursive: true, force: true }) +}) + +describe("loadCatalog", () => { + it("fetches remote and writes cache when remote succeeds", async () => { + __setFetchForTest(async () => new Response(JSON.stringify(REMOTE))) + const result = await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 1000, + force: true, + }) + expect(result.images[0].id).toBe("remote-img") + const cached = JSON.parse(await readFile(cachePath, "utf8")) + expect(cached.catalog.images[0].id).toBe("remote-img") + expect(typeof cached.fetchedAt).toBe("number") + }) + + it("falls back to on-disk cache when remote fails", async () => { + await writeFile( + cachePath, + JSON.stringify({ fetchedAt: Date.now(), catalog: REMOTE }), + ) + __setFetchForTest(async () => { + throw new Error("network down") + }) + const result = await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 1000, + force: true, + }) + expect(result.images[0].id).toBe("remote-img") + }) + + it("falls back to seed when remote fails and no cache exists", async () => { + __setFetchForTest(async () => { + throw new Error("network down") + }) + const result = await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 1000, + force: true, + }) + expect(result.images[0].id).toBe("seed-img") + }) + + it("returns fresh cache without fetching when within TTL and not forced", async () => { + await writeFile( + cachePath, + JSON.stringify({ fetchedAt: Date.now(), catalog: REMOTE }), + ) + const fetchSpy = vi.fn() + __setFetchForTest(fetchSpy as unknown as typeof fetch) + const result = await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 60_000, + force: false, + }) + expect(fetchSpy).not.toHaveBeenCalled() + expect(result.images[0].id).toBe("remote-img") + }) +}) diff --git a/desktop/src/main/image-catalog.ts b/desktop/src/main/image-catalog.ts new file mode 100644 index 000000000..86c436369 --- /dev/null +++ b/desktop/src/main/image-catalog.ts @@ -0,0 +1,62 @@ +import { readFile, writeFile } from "node:fs/promises" +import type { ImageCatalog } from "../shared/image-catalog-types.js" + +export interface CatalogCacheFile { + fetchedAt: number + catalog: ImageCatalog +} + +export interface LoadCatalogOptions { + url: string + cachePath: string + seedPath: string + ttlMs: number + force: boolean +} + +let fetchImpl: typeof fetch | undefined +export function __setFetchForTest(fn: typeof fetch | undefined): void { + fetchImpl = fn +} +function getFetch(): typeof fetch { + return fetchImpl ?? globalThis.fetch +} + +async function readCache(cachePath: string): Promise { + try { + const raw = await readFile(cachePath, "utf8") + const parsed = JSON.parse(raw) as CatalogCacheFile + if (parsed?.catalog?.images) return parsed + return null + } catch { + return null + } +} + +async function readSeed(seedPath: string): Promise { + const raw = await readFile(seedPath, "utf8") + return JSON.parse(raw) as ImageCatalog +} + +export async function loadCatalog( + opts: LoadCatalogOptions, +): Promise { + const cache = await readCache(opts.cachePath) + const fresh = cache && Date.now() - cache.fetchedAt < opts.ttlMs + if (cache && fresh && !opts.force) { + return cache.catalog + } + + try { + const res = await getFetch()(opts.url) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const catalog = (await res.json()) as ImageCatalog + if (!catalog?.images) throw new Error("malformed catalog") + const toWrite: CatalogCacheFile = { fetchedAt: Date.now(), catalog } + await writeFile(opts.cachePath, JSON.stringify(toWrite)) + return catalog + } catch { + if (cache) return cache.catalog + return readSeed(opts.seedPath) + } +} diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 5d6cc547d..c8ee11fb5 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -96,26 +96,11 @@ export interface ProviderVersionCheckResult { error?: string } -export interface CatalogCategory { - id: string - label: string -} - -export interface CatalogImage { - id: string - ref: string - name: string - description?: string - categories: string[] - icon?: string - featured?: boolean -} - -export interface ImageCatalog { - version: number - categories: CatalogCategory[] - images: CatalogImage[] -} +export type { + CatalogCategory, + CatalogImage, + ImageCatalog, +} from "../../../../shared/image-catalog-types.js" export interface MachineProviderConfig { name?: string diff --git a/desktop/src/shared/image-catalog-types.ts b/desktop/src/shared/image-catalog-types.ts new file mode 100644 index 000000000..2490687c7 --- /dev/null +++ b/desktop/src/shared/image-catalog-types.ts @@ -0,0 +1,20 @@ +export interface CatalogCategory { + id: string + label: string +} + +export interface CatalogImage { + id: string + ref: string + name: string + description?: string + categories: string[] + icon?: string + featured?: boolean +} + +export interface ImageCatalog { + version: number + categories: CatalogCategory[] + images: CatalogImage[] +} From 534b8e101a0bf6fd7bba3d4e8d103456791aa5cb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:27:19 -0500 Subject: [PATCH 09/26] feat(desktop): add image catalog IPC handlers --- desktop/src/main/ipc.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 4f799359f..0fe9d4696 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -7,7 +7,9 @@ import { promisify } from "node:util" import type { BrowserWindow } from "electron" import { app, dialog, ipcMain } from "electron" import type { CLIError } from "../shared/cli-error.js" +import type { ImageCatalog } from "../shared/image-catalog-types.js" import { trackEvent } from "./analytics.js" +import { loadCatalog } from "./image-catalog.js" import type { CliRunner } from "./cli.js" import type { LogStore } from "./log-store.js" import type { PtyManager } from "./pty.js" @@ -39,6 +41,21 @@ type UpdateInfo = { let providerUpdateCache: Record = {} +const IMAGE_CATALOG_URL = + process.env.DEVSY_IMAGE_CATALOG_URL ?? + "https://raw.githubusercontent.com/devsy-org/devsy/main/desktop/resources/image-catalog-seed.json" +const IMAGE_CATALOG_TTL_MS = 24 * 60 * 60 * 1000 +let imageCatalogCache: ImageCatalog | null = null + +function imageCatalogPaths(): { cachePath: string; seedPath: string } { + return { + cachePath: join(app.getPath("userData"), "image-catalog.json"), + seedPath: app.isPackaged + ? join(process.resourcesPath, "image-catalog-seed.json") + : join(app.getAppPath(), "resources", "image-catalog-seed.json"), + } +} + interface SshKeyInfo { name: string keyType: string @@ -321,6 +338,30 @@ export function registerIpcHandlers(deps: IpcDependencies): { return providerUpdateCache }) + ipcMain.handle("image_catalog_get", async () => { + const { cachePath, seedPath } = imageCatalogPaths() + imageCatalogCache = await loadCatalog({ + url: IMAGE_CATALOG_URL, + cachePath, + seedPath, + ttlMs: IMAGE_CATALOG_TTL_MS, + force: false, + }) + return imageCatalogCache + }) + + ipcMain.handle("image_catalog_refresh", async () => { + const { cachePath, seedPath } = imageCatalogPaths() + imageCatalogCache = await loadCatalog({ + url: IMAGE_CATALOG_URL, + cachePath, + seedPath, + ttlMs: IMAGE_CATALOG_TTL_MS, + force: true, + }) + return imageCatalogCache + }) + ipcMain.handle("dialog_open_directory", async () => { const win = deps.getMainWindow() const result = win From b21f80e935b1d5591ff294b3530ef3d0aef5c1d0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:27:41 -0500 Subject: [PATCH 10/26] feat(desktop): add image catalog renderer commands --- desktop/src/renderer/src/lib/ipc/commands.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 34d3831f9..c8a3a8a60 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -1,6 +1,7 @@ import type { AuditEntry, Context, + ImageCatalog, LogEntry, Machine, OptionValue, @@ -161,6 +162,15 @@ export async function providerCheckUpdates() { return invoke>("provider_check_updates") } +// Image catalog commands +export async function imageCatalogGet(): Promise { + return invoke("image_catalog_get") +} + +export async function imageCatalogRefresh(): Promise { + return invoke("image_catalog_refresh") +} + // Machine commands export async function machineList(): Promise { return invoke("machine_list") From a514bea7378013a4e367e2bf2ce202d649164358 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:28:21 -0500 Subject: [PATCH 11/26] feat(desktop): add imageCatalog store with search and category filters --- .../src/lib/stores/imageCatalog.test.ts | 65 ++++++++++++++ .../renderer/src/lib/stores/imageCatalog.ts | 87 +++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 desktop/src/renderer/src/lib/stores/imageCatalog.test.ts create mode 100644 desktop/src/renderer/src/lib/stores/imageCatalog.ts diff --git a/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts b/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts new file mode 100644 index 000000000..d7f81e0b8 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { get } from "svelte/store" + +vi.mock("$lib/ipc/commands.js", () => ({ + imageCatalogGet: vi.fn(), + imageCatalogRefresh: vi.fn(), +})) + +import { + imageCatalog, + loadImageCatalog, + resetImageCatalogStore, + filterImages, +} from "./imageCatalog.js" +import { imageCatalogGet } from "$lib/ipc/commands.js" +import type { CatalogImage } from "$lib/types/index.js" + +const IMAGES: CatalogImage[] = [ + { id: "py", ref: "py:1", name: "Python", categories: ["lang"], featured: true }, + { id: "node", ref: "node:1", name: "Node.js", categories: ["lang"] }, + { id: "tf", ref: "tf:1", name: "Terraform", categories: ["tools"] }, +] + +describe("imageCatalog store", () => { + beforeEach(() => { + resetImageCatalogStore() + vi.clearAllMocks() + }) + + it("loads catalog via loadImageCatalog", async () => { + vi.mocked(imageCatalogGet).mockResolvedValue({ + version: 1, + categories: [{ id: "lang", label: "Languages" }], + images: IMAGES, + }) + await loadImageCatalog() + const state = get(imageCatalog) + expect(state.images).toHaveLength(3) + expect(state.loading).toBe(false) + expect(state.error).toBeUndefined() + }) + + it("records error on failure", async () => { + vi.mocked(imageCatalogGet).mockRejectedValue(new Error("boom")) + await loadImageCatalog() + const state = get(imageCatalog) + expect(state.error).toContain("boom") + expect(state.loading).toBe(false) + }) + + it("filterImages: featured first, then alphabetical", () => { + const out = filterImages(IMAGES, "", "all") + expect(out.map((i) => i.id)).toEqual(["py", "node", "tf"]) + }) + + it("filterImages: search matches name (case-insensitive)", () => { + const out = filterImages(IMAGES, "node", "all") + expect(out.map((i) => i.id)).toEqual(["node"]) + }) + + it("filterImages: category filter", () => { + const out = filterImages(IMAGES, "", "tools") + expect(out.map((i) => i.id)).toEqual(["tf"]) + }) +}) diff --git a/desktop/src/renderer/src/lib/stores/imageCatalog.ts b/desktop/src/renderer/src/lib/stores/imageCatalog.ts new file mode 100644 index 000000000..61c952343 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/imageCatalog.ts @@ -0,0 +1,87 @@ +import { writable } from "svelte/store" +import type { + CatalogCategory, + CatalogImage, + ImageCatalog, +} from "$lib/types/index.js" +import { imageCatalogGet, imageCatalogRefresh } from "$lib/ipc/commands.js" + +type State = { + images: CatalogImage[] + categories: CatalogCategory[] + loading: boolean + error?: string +} + +const initial: State = { + images: [], + categories: [], + loading: false, + error: undefined, +} + +const internal = writable(initial) +export const imageCatalog = { subscribe: internal.subscribe } + +function apply(catalog: ImageCatalog): void { + internal.set({ + images: catalog.images, + categories: catalog.categories, + loading: false, + error: undefined, + }) +} + +export async function loadImageCatalog(): Promise { + internal.update((s) => ({ ...s, loading: true, error: undefined })) + try { + apply(await imageCatalogGet()) + } catch (err) { + internal.update((s) => ({ + ...s, + loading: false, + error: err instanceof Error ? err.message : String(err), + })) + } +} + +export async function refreshImageCatalog(): Promise { + internal.update((s) => ({ ...s, loading: true, error: undefined })) + try { + apply(await imageCatalogRefresh()) + } catch (err) { + internal.update((s) => ({ + ...s, + loading: false, + error: err instanceof Error ? err.message : String(err), + })) + } +} + +export function resetImageCatalogStore(): void { + internal.set(initial) +} + +export function filterImages( + images: CatalogImage[], + search: string, + category: string, +): CatalogImage[] { + const q = search.trim().toLowerCase() + return images + .filter((img) => category === "all" || img.categories.includes(category)) + .filter((img) => { + if (!q) return true + return ( + img.name.toLowerCase().includes(q) || + img.ref.toLowerCase().includes(q) || + (img.description?.toLowerCase().includes(q) ?? false) + ) + }) + .sort((a, b) => { + const fa = a.featured ? 0 : 1 + const fb = b.featured ? 0 : 1 + if (fa !== fb) return fa - fb + return a.name.localeCompare(b.name) + }) +} From 6869a8101811e7ac19718d0adeb05c33122cbf6d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:32:07 -0500 Subject: [PATCH 12/26] refactor(desktop): drop unused imageCatalogCache module var --- desktop/src/main/ipc.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 0fe9d4696..1e00b1198 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -7,7 +7,6 @@ import { promisify } from "node:util" import type { BrowserWindow } from "electron" import { app, dialog, ipcMain } from "electron" import type { CLIError } from "../shared/cli-error.js" -import type { ImageCatalog } from "../shared/image-catalog-types.js" import { trackEvent } from "./analytics.js" import { loadCatalog } from "./image-catalog.js" import type { CliRunner } from "./cli.js" @@ -45,7 +44,6 @@ const IMAGE_CATALOG_URL = process.env.DEVSY_IMAGE_CATALOG_URL ?? "https://raw.githubusercontent.com/devsy-org/devsy/main/desktop/resources/image-catalog-seed.json" const IMAGE_CATALOG_TTL_MS = 24 * 60 * 60 * 1000 -let imageCatalogCache: ImageCatalog | null = null function imageCatalogPaths(): { cachePath: string; seedPath: string } { return { @@ -340,26 +338,26 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle("image_catalog_get", async () => { const { cachePath, seedPath } = imageCatalogPaths() - imageCatalogCache = await loadCatalog({ + const catalog = await loadCatalog({ url: IMAGE_CATALOG_URL, cachePath, seedPath, ttlMs: IMAGE_CATALOG_TTL_MS, force: false, }) - return imageCatalogCache + return catalog }) ipcMain.handle("image_catalog_refresh", async () => { const { cachePath, seedPath } = imageCatalogPaths() - imageCatalogCache = await loadCatalog({ + const catalog = await loadCatalog({ url: IMAGE_CATALOG_URL, cachePath, seedPath, ttlMs: IMAGE_CATALOG_TTL_MS, force: true, }) - return imageCatalogCache + return catalog }) ipcMain.handle("dialog_open_directory", async () => { From 373436ce9b7435cd9076c5e8a7b7057afc38ac15 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:36:31 -0500 Subject: [PATCH 13/26] refactor(desktop): introduce per-type source state in wizard --- .../workspace/WorkspaceWizard.svelte | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index e24209238..df87cd8ee 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -16,13 +16,21 @@ import { Label } from "$lib/components/ui/label/index.js" import * as Popover from "$lib/components/ui/popover/index.js" import * as Dialog from "$lib/components/ui/dialog/index.js" import * as Alert from "$lib/components/ui/alert/index.js" +import * as Tabs from "$lib/components/ui/tabs/index.js" +import * as Select from "$lib/components/ui/select/index.js" import { Progress } from "$lib/components/ui/progress/index.js" import { badgeVariants } from "$lib/components/ui/badge/index.js" import LanguageIcon from "$lib/components/workspace/LanguageIcon.svelte" +import ImagePicker from "$lib/components/workspace/ImagePicker.svelte" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" import LogTable from "$lib/components/log/LogTable.svelte" import { uniqueNamesGenerator, adjectives, animals } from "unique-names-generator" -import { workspaceUp } from "$lib/ipc/commands.js" +import { workspaceUp, openDirectoryDialog } from "$lib/ipc/commands.js" +import { buildWorkspaceSource } from "$lib/utils/workspace-source.js" +import type { + WorkspaceSourceType, + GitRefType, +} from "$lib/utils/workspace-source.js" import { onCommandProgress } from "$lib/ipc/events.js" import type { CommandProgress } from "$lib/types/index.js" import { providers } from "$lib/stores/providers.js" @@ -134,12 +142,42 @@ let currentStep = $state("provider") let selectedProvider = $state( $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "" ) -let source = $state("") +let sourceType = $state("git") +let repoUrl = $state("") +let localPath = $state("") +let imageRef = $state("") +let refType = $state("branch") +let refValue = $state("") +let subPath = $state("") +let devcontainerPath = $state("") +let prebuildRepository = $state("") let workspaceFolder = $state("") let advancedOpen = $state(false) let selectedIde = $state("none") let workspaceName = $state("") +let assembled = $derived( + buildWorkspaceSource({ + sourceType, + repoUrl, + localPath, + imageRef, + refType, + refValue, + subPath, + devcontainerPath, + prebuildRepository, + }), +) + +let primarySourceValue = $derived( + sourceType === "git" + ? repoUrl.trim() + : sourceType === "local" + ? localPath.trim() + : imageRef.trim(), +) + // IDE combobox state let ideComboOpen = $state(false) let ideSearch = $state("") @@ -174,11 +212,12 @@ let filteredIdes = $derived( let resolvedId = $derived( workspaceName.trim() || - source + assembled.source .trim() .split("/") .pop() - ?.replace(/\.git$/, "") || + ?.replace(/\.git$/, "") + ?.replace(/@.*$/, "") || "", ) @@ -229,7 +268,15 @@ function clearWatchdog() { function reset() { currentStep = "provider" selectedProvider = $providers.find((p) => p.isDefault && p.state?.initialized)?.name ?? "" - source = "" + sourceType = "git" + repoUrl = "" + localPath = "" + imageRef = "" + refType = "branch" + refValue = "" + subPath = "" + devcontainerPath = "" + prebuildRepository = "" workspaceFolder = "" advancedOpen = false selectedIde = "none" From 12686c7e3d7b5549aace6767b0861abef59dbfb0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:38:35 -0500 Subject: [PATCH 14/26] feat(desktop): add searchable image picker component --- .../components/workspace/ImagePicker.svelte | 113 ++++++++++++++++++ .../components/workspace/ImagePicker.test.ts | 55 +++++++++ 2 files changed, 168 insertions(+) create mode 100644 desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte create mode 100644 desktop/src/renderer/src/lib/components/workspace/ImagePicker.test.ts diff --git a/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte b/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte new file mode 100644 index 000000000..d8b7175bc --- /dev/null +++ b/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte @@ -0,0 +1,113 @@ + + +
+
+ (search = e.currentTarget.value)} + /> +
+ +
+ + {#each $imageCatalog.categories as cat (cat.id)} + + {/each} +
+ + {#if $imageCatalog.loading} +

Loading catalog…

+ {:else if $imageCatalog.error} +

Failed to load catalog: {$imageCatalog.error}

+ {/if} + +
+ {#each filtered as img (img.id)} + + {/each} + {#if filtered.length === 0 && !$imageCatalog.loading} +

No images match.

+ {/if} +
+ +
+ + { + customRef = e.currentTarget.value + pick(customRef.trim()) + }} + /> +

+ Use an image not in the catalog. +

+
+
diff --git a/desktop/src/renderer/src/lib/components/workspace/ImagePicker.test.ts b/desktop/src/renderer/src/lib/components/workspace/ImagePicker.test.ts new file mode 100644 index 000000000..f1d9d8865 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/workspace/ImagePicker.test.ts @@ -0,0 +1,55 @@ +import { render, fireEvent, cleanup } from "@testing-library/svelte" +import { describe, it, expect, vi, afterEach } from "vitest" + +vi.mock("$lib/stores/imageCatalog.js", async () => { + const { writable } = await import("svelte/store") + const { filterImages } = await vi.importActual< + typeof import("$lib/stores/imageCatalog.js") + >("$lib/stores/imageCatalog.js") + return { + imageCatalog: writable({ + images: [ + { id: "py", ref: "py:1", name: "Python", categories: ["lang"], featured: true }, + { id: "tf", ref: "tf:1", name: "Terraform", categories: ["tools"] }, + ], + categories: [ + { id: "lang", label: "Languages" }, + { id: "tools", label: "Tools" }, + ], + loading: false, + }), + loadImageCatalog: vi.fn(), + filterImages, + } +}) + +import ImagePicker from "./ImagePicker.svelte" + +describe("ImagePicker", () => { + afterEach(() => { + cleanup() + }) + + it("renders catalog images", () => { + const { getByText } = render(ImagePicker, { props: { value: "" } }) + expect(getByText("Python")).toBeTruthy() + expect(getByText("Terraform")).toBeTruthy() + }) + + it("filters by search", async () => { + const { getByPlaceholderText, queryByText } = render(ImagePicker, { + props: { value: "" }, + }) + await fireEvent.input(getByPlaceholderText(/search images/i), { + target: { value: "python" }, + }) + expect(queryByText("Terraform")).toBeNull() + }) + + it("selecting an image emits the ref", async () => { + const onselect = vi.fn() + const { getByText } = render(ImagePicker, { props: { value: "", onselect } }) + await fireEvent.click(getByText("Python")) + expect(onselect).toHaveBeenCalledWith("py:1") + }) +}) From 846ead9a1d49a671e8292f0a8e8809629a2c9989 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:39:40 -0500 Subject: [PATCH 15/26] feat(desktop): 3-way source selector with advanced options in wizard --- .../workspace/WorkspaceWizard.svelte | 189 +++++++++++++----- 1 file changed, 141 insertions(+), 48 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index df87cd8ee..b3117b80e 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -437,14 +437,15 @@ function continueFromProvider() { } function continueFromSource() { - if (!source.trim()) return + if (!primarySourceValue) return if (!workspaceName.trim()) { const derived = - source + assembled.source .trim() .split("/") .pop() - ?.replace(/\.git$/, "") ?? "" + ?.replace(/\.git$/, "") + ?.replace(/@.*$/, "") ?? "" if (derived) workspaceName = uniquifyName(derived) } else { workspaceName = uniquifyName(workspaceName.trim()) @@ -452,6 +453,11 @@ function continueFromSource() { currentStep = "ide" } +async function handleBrowse() { + const picked = await openDirectoryDialog() + if (picked) localPath = picked +} + function continueFromIde() { currentStep = "review" } @@ -463,13 +469,84 @@ function continueFromReview() { } function selectTemplate(t: { name: string; source: string }) { - source = t.source + repoUrl = t.source if (!workspaceName) { workspaceName = uniquifyName(t.name.toLowerCase().replace(/[^a-z0-9]/g, "-")) } } +{#snippet advancedSection(isGit: boolean)} +
+ + {#if advancedOpen} +
+ {#if isGit} +
+
+ + + + {refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull Request"} + + + Branch + Commit + Pull Request + + +
+
+ + (refValue = e.currentTarget.value)} + /> +
+
+ {/if} + +
+ + (subPath = e.currentTarget.value)} + /> +
+ +
+ + (devcontainerPath = e.currentTarget.value)} + /> +
+ +
+ + (prebuildRepository = e.currentTarget.value)} + /> +
+
+ {/if} +
+{/snippet} + @@ -575,61 +652,77 @@ function selectTemplate(t: { name: string; source: string }) {

Choose a Source

-

Pick a template or enter a custom source (git URL, image, or local path).

-
- -
-

Quick Start Templates

-
- {#each TEMPLATES as template (template.name)} - - {/each} -
+

+ Start from a Git repository, a local directory, or a container image. +

-
- - (source = e.currentTarget.value)} - /> -
+ (sourceType = v as WorkspaceSourceType)}> + + Git Repo + Local Directory + Image + + + + +
+

Quick Start Templates

+
+ {#each TEMPLATES as template (template.name)} + + {/each} +
+
-
- - {#if advancedOpen} -
- +
+ (workspaceFolder = e.currentTarget.value)} + placeholder="github.com/org/repo" + value={repoUrl} + oninput={(e) => (repoUrl = e.currentTarget.value)} /> +
+ + {@render advancedSection(true)} + + + + +
+ +
+ (localPath = e.currentTarget.value)} + /> + +

- Optional path inside the repository to use as the workspace root. + Local sources require a provider running on this machine.

- {/if} -
+ + {@render advancedSection(false)} + + + + + + +
- +
From 6f5e5523e8c475cd35b65c4ba30980249af5d7ae Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:39:59 -0500 Subject: [PATCH 16/26] feat(desktop): show source config details in wizard review --- .../workspace/WorkspaceWizard.svelte | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index b3117b80e..5a33027c2 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -824,10 +824,40 @@ function selectTemplate(t: { name: string; source: string }) { Provider {selectedProvider}
+
+ Source type + {sourceType} +
Source - {source} + {assembled.source}
+ {#if sourceType === "git" && refValue.trim()} +
+ + {refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull request"} + + {refValue} +
+ {/if} + {#if subPath.trim()} +
+ Subfolder + {subPath} +
+ {/if} + {#if assembled.devcontainerPath} +
+ devcontainer.json + {assembled.devcontainerPath} +
+ {/if} + {#if assembled.prebuildRepository} +
+ Prebuild repo + {assembled.prebuildRepository} +
+ {/if} {#if workspaceFolder}
Workspace Folder From 2e9091a08dae82d416b3544b4343225b7746663d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:40:21 -0500 Subject: [PATCH 17/26] feat(desktop): forward assembled source and build flags on launch --- .../src/lib/components/workspace/WorkspaceWizard.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 5a33027c2..fb8e7942f 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -360,12 +360,14 @@ async function handleLaunch() { }) const cmdId = await workspaceUp({ - source: source.trim(), + source: assembled.source, workspaceId, provider: selectedProvider || undefined, ide: selectedIde, ideLaunch: "auto", workspaceFolder: workspaceFolder.trim() || undefined, + devcontainerPath: assembled.devcontainerPath, + prebuildRepositories: assembled.prebuildRepository, debug: true, }) From fc2cc7604353dfa839564099cf34d0749d4ac9b3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:41:43 -0500 Subject: [PATCH 18/26] test(desktop): cover source-type selection in wizard --- .../workspace/WorkspaceWizard.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts index 1e46f7da9..a4a1a38af 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts @@ -8,8 +8,25 @@ const onCommandProgress = vi.fn() vi.mock("$lib/ipc/commands.js", () => ({ workspaceUp: (...args: unknown[]) => workspaceUp(...args), + openDirectoryDialog: vi.fn(), })) +vi.mock("$lib/stores/imageCatalog.js", async () => { + const { writable } = await import("svelte/store") + const actual = await vi.importActual< + typeof import("$lib/stores/imageCatalog.js") + >("$lib/stores/imageCatalog.js") + return { + imageCatalog: writable({ + images: [], + categories: [], + loading: false, + }), + loadImageCatalog: vi.fn(), + filterImages: actual.filterImages, + } +}) + vi.mock("$lib/ipc/events.js", () => ({ onCommandProgress: (...args: unknown[]) => onCommandProgress(...args), })) @@ -205,6 +222,56 @@ describe("WorkspaceWizard", () => { unmount() }) + it("defaults to git source type and accepts a repo url", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + expect(getByText("Choose a Source")).toBeTruthy() + + const repoInput = document.querySelector( + 'input[placeholder*="github.com/org/repo"]', + ) as HTMLInputElement + expect(repoInput).toBeTruthy() + await fireEvent.input(repoInput, { + target: { value: "https://github.com/org/repo" }, + }) + await flushAsync() + + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + expect(getByText("Choose an IDE")).toBeTruthy() + unmount() + }) + + it("shows the image picker when the Image tab is selected", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + await fireEvent.click(getByText("Image")) + await flushAsync() + + const searchInput = document.querySelector( + 'input[placeholder*="Search images"]', + ) as HTMLInputElement + expect(searchInput).toBeTruthy() + unmount() + }) + it("review step auto-suggests a unique name when the derived id conflicts", async () => { providers.set([makeProvider("docker")]) workspaces.set([{ id: "python" }]) From b7a3da6c70e0cc5df7d6c95e80b717c6288a281c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:46:37 -0500 Subject: [PATCH 19/26] fix(desktop): prevent custom image input desync with catalog selection --- .../src/lib/components/workspace/ImagePicker.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte b/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte index d8b7175bc..24c63ff7f 100644 --- a/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte @@ -20,7 +20,6 @@ let { let search = $state("") let category = $state("all") -let customRef = $state("") onMount(() => { if ($imageCatalog.images.length === 0) void loadImageCatalog() @@ -28,6 +27,10 @@ onMount(() => { let filtered = $derived(filterImages($imageCatalog.images, search, category)) +let isCatalogValue = $derived( + $imageCatalog.images.some((img) => img.ref === value), +) + function pick(ref: string) { value = ref onselect?.(ref) @@ -100,11 +103,8 @@ function pick(ref: string) { { - customRef = e.currentTarget.value - pick(customRef.trim()) - }} + value={isCatalogValue ? "" : value} + oninput={(e) => pick(e.currentTarget.value.trim())} />

Use an image not in the catalog. From 898db2d74c53a53678582ce0af07e450c86885d3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:49:30 -0500 Subject: [PATCH 20/26] fix(desktop): hide stale subfolder row for image sources in review --- .../src/lib/components/workspace/WorkspaceWizard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index fb8e7942f..14d989d82 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -842,7 +842,7 @@ function selectTemplate(t: { name: string; source: string }) { {refValue}

{/if} - {#if subPath.trim()} + {#if sourceType !== "image" && subPath.trim()}
Subfolder {subPath} From cab7d240977169a0263fc16c6d6ee15386f4ae49 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:54:46 -0500 Subject: [PATCH 21/26] feat(desktop): seed image catalog with original wizard languages Expand the bundled seed to cover all nine languages from the old quick-start templates (Python, Node.js, Go, Rust, Java, PHP, C++, .NET, Ruby) plus Universal, each mapped to its canonical devcontainers image. --- desktop/resources/image-catalog-seed.json | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/desktop/resources/image-catalog-seed.json b/desktop/resources/image-catalog-seed.json index dd2a60808..3e79578db 100644 --- a/desktop/resources/image-catalog-seed.json +++ b/desktop/resources/image-catalog-seed.json @@ -39,6 +39,54 @@ "description": "Go development image.", "categories": ["languages"], "icon": "Go" + }, + { + "id": "rust-1", + "ref": "mcr.microsoft.com/devcontainers/rust:1", + "name": "Rust", + "description": "Rust development image.", + "categories": ["languages"], + "icon": "Rust" + }, + { + "id": "java-21", + "ref": "mcr.microsoft.com/devcontainers/java:21", + "name": "Java", + "description": "Java development image.", + "categories": ["languages"], + "icon": "Java" + }, + { + "id": "php-8", + "ref": "mcr.microsoft.com/devcontainers/php:8", + "name": "PHP", + "description": "PHP development image.", + "categories": ["languages"], + "icon": "PHP" + }, + { + "id": "cpp-1", + "ref": "mcr.microsoft.com/devcontainers/cpp:1", + "name": "C++", + "description": "C++ development image.", + "categories": ["languages"], + "icon": "C++" + }, + { + "id": "dotnet-8", + "ref": "mcr.microsoft.com/devcontainers/dotnet:8", + "name": ".NET", + "description": ".NET development image.", + "categories": ["languages"], + "icon": ".NET" + }, + { + "id": "ruby-3", + "ref": "mcr.microsoft.com/devcontainers/ruby:3", + "name": "Ruby", + "description": "Ruby development image.", + "categories": ["languages"], + "icon": "Ruby" } ] } From 5d012aaa4644ccdbede02fc767208ff2f5ede612 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 17:57:10 -0500 Subject: [PATCH 22/26] feat(desktop): add Universal language icon with dark variant The Universal catalog image fell back to a text glyph; add a layered-box icon (light + dark) and wire it into LanguageIcon's map. --- desktop/src/renderer/public/icons/languages/universal.svg | 5 +++++ .../src/renderer/public/icons/languages/universal_dark.svg | 5 +++++ .../src/lib/components/workspace/LanguageIcon.svelte | 2 ++ 3 files changed, 12 insertions(+) create mode 100644 desktop/src/renderer/public/icons/languages/universal.svg create mode 100644 desktop/src/renderer/public/icons/languages/universal_dark.svg diff --git a/desktop/src/renderer/public/icons/languages/universal.svg b/desktop/src/renderer/public/icons/languages/universal.svg new file mode 100644 index 000000000..e481d73a2 --- /dev/null +++ b/desktop/src/renderer/public/icons/languages/universal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/desktop/src/renderer/public/icons/languages/universal_dark.svg b/desktop/src/renderer/public/icons/languages/universal_dark.svg new file mode 100644 index 000000000..6ebe43ca3 --- /dev/null +++ b/desktop/src/renderer/public/icons/languages/universal_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/desktop/src/renderer/src/lib/components/workspace/LanguageIcon.svelte b/desktop/src/renderer/src/lib/components/workspace/LanguageIcon.svelte index cb63c3498..a7af50956 100644 --- a/desktop/src/renderer/src/lib/components/workspace/LanguageIcon.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/LanguageIcon.svelte @@ -24,12 +24,14 @@ const ICON_MAP: Record = { "c#": "./icons/languages/dotnet.svg", csharp: "./icons/languages/dotnet.svg", ruby: "./icons/languages/ruby.svg", + universal: "./icons/languages/universal.svg", } const DARK_VARIANTS: Record = { "./icons/languages/go.svg": "./icons/languages/go_dark.svg", "./icons/languages/rust.svg": "./icons/languages/rust_dark.svg", "./icons/languages/php.svg": "./icons/languages/php_dark.svg", + "./icons/languages/universal.svg": "./icons/languages/universal_dark.svg", } const src = $derived.by(() => { From 7ff999fec62c4c1b66ec6af74a90cdf60dc580b2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 18:07:39 -0500 Subject: [PATCH 23/26] fix(desktop): restore workspace folder input and gate subfolder to git Address review findings: re-add the Workspace Folder input that was dropped (leaving --workspace-folder unreachable), restrict the git-only Subfolder field to git sources, log image-catalog cache/fetch failures instead of swallowing them, and assert the workspaceUp launch payload. --- desktop/src/main/image-catalog.ts | 19 +++-- .../workspace/WorkspaceWizard.svelte | 19 +++-- .../workspace/WorkspaceWizard.test.ts | 69 +++++++++++++++++++ 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/desktop/src/main/image-catalog.ts b/desktop/src/main/image-catalog.ts index 86c436369..85233fff6 100644 --- a/desktop/src/main/image-catalog.ts +++ b/desktop/src/main/image-catalog.ts @@ -23,12 +23,20 @@ function getFetch(): typeof fetch { } async function readCache(cachePath: string): Promise { + let raw: string try { - const raw = await readFile(cachePath, "utf8") - const parsed = JSON.parse(raw) as CatalogCacheFile - if (parsed?.catalog?.images) return parsed + raw = await readFile(cachePath, "utf8") + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn("[image-catalog] failed to read cache:", err) + } return null - } catch { + } + try { + const parsed = JSON.parse(raw) as CatalogCacheFile + return parsed?.catalog?.images ? parsed : null + } catch (err) { + console.warn("[image-catalog] corrupt cache file, ignoring:", err) return null } } @@ -55,7 +63,8 @@ export async function loadCatalog( const toWrite: CatalogCacheFile = { fetchedAt: Date.now(), catalog } await writeFile(opts.cachePath, JSON.stringify(toWrite)) return catalog - } catch { + } catch (err) { + console.warn("[image-catalog] remote fetch failed, using fallback:", err) if (cache) return cache.catalog return readSeed(opts.seedPath) } diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 14d989d82..74133d2e2 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -518,12 +518,23 @@ function selectTemplate(t: { name: string; source: string }) {
{/if} + {#if isGit} +
+ + (subPath = e.currentTarget.value)} + /> +
+ {/if} +
- + (subPath = e.currentTarget.value)} + placeholder="path opened inside the container (optional)" + value={workspaceFolder} + oninput={(e) => (workspaceFolder = e.currentTarget.value)} />
diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts index a4a1a38af..7dfdd9852 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts @@ -427,6 +427,75 @@ describe("WorkspaceWizard", () => { unmount() }) + it("forwards assembled source plus workspaceFolder and build flags to workspaceUp", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + const repoInput = document.querySelector( + 'input[placeholder*="github.com/org/repo"]', + ) as HTMLInputElement + await fireEvent.input(repoInput, { + target: { value: "github.com/org/repo" }, + }) + await flushAsync() + + const advancedToggle = Array.from( + document.querySelectorAll("button"), + ).find((b) => /advanced options/i.test(b.textContent ?? "")) as HTMLElement + await fireEvent.click(advancedToggle) + await flushAsync() + + const subPathInput = document.querySelector( + 'input[placeholder*="path/within/repo"]', + ) as HTMLInputElement + await fireEvent.input(subPathInput, { target: { value: "pkg/api" } }) + const wsFolderInput = document.querySelector( + 'input[placeholder*="opened inside the container"]', + ) as HTMLInputElement + await fireEvent.input(wsFolderInput, { target: { value: "/workspaces/app" } }) + const devcontainerInput = document.querySelector( + 'input[placeholder*="devcontainer.json"]', + ) as HTMLInputElement + await fireEvent.input(devcontainerInput, { + target: { value: ".devcontainer/devcontainer.json" }, + }) + const prebuildInput = document.querySelector( + 'input[placeholder*="ghcr.io/org/prebuilds"]', + ) as HTMLInputElement + await fireEvent.input(prebuildInput, { + target: { value: "ghcr.io/org/prebuilds" }, + }) + await flushAsync() + + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + expect(workspaceUp).toHaveBeenCalledWith( + expect.objectContaining({ + source: "github.com/org/repo@subpath:pkg/api", + workspaceFolder: "/workspaces/app", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepositories: "ghcr.io/org/prebuilds", + }), + ) + unmount() + }) + it("close-during-launch shows the confirm dialog instead of closing", async () => { providers.set([makeProvider("docker")]) // Hold workspaceUp open so launchRunning stays true From 034d986bfd7b9df80ea9b87ae94af685a0b94cbc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 18:22:54 -0500 Subject: [PATCH 24/26] refactor(desktop): tighten source-config types and catalog provenance Address deferred review items: model WorkspaceSourceForm as a discriminated union on sourceType so image/local sources cannot carry git options; rename the IPC prebuildRepositories param to the singular prebuildRepository to match the form; and have loadCatalog report its origin (remote/cache/seed) so a forced refresh can tell the user when it fell back instead of reaching the server. --- .../src/main/__tests__/image-catalog.test.ts | 47 +++++++++++- desktop/src/main/image-catalog.ts | 18 +++-- desktop/src/main/ipc.ts | 12 ++- .../workspace/WorkspaceWizard.svelte | 31 +++++--- .../workspace/WorkspaceWizard.test.ts | 2 +- .../src/renderer/src/lib/ipc/commands.test.ts | 6 +- desktop/src/renderer/src/lib/ipc/commands.ts | 12 +-- .../src/lib/stores/imageCatalog.test.ts | 31 ++++++-- .../renderer/src/lib/stores/imageCatalog.ts | 12 ++- desktop/src/renderer/src/lib/types/index.ts | 2 + .../src/lib/utils/workspace-source.test.ts | 75 ++++++++++++------- .../src/lib/utils/workspace-source.ts | 37 ++++++--- desktop/src/shared/image-catalog-types.ts | 7 ++ 13 files changed, 206 insertions(+), 86 deletions(-) diff --git a/desktop/src/main/__tests__/image-catalog.test.ts b/desktop/src/main/__tests__/image-catalog.test.ts index 0f9fe77e5..d5d73b056 100644 --- a/desktop/src/main/__tests__/image-catalog.test.ts +++ b/desktop/src/main/__tests__/image-catalog.test.ts @@ -45,7 +45,8 @@ describe("loadCatalog", () => { ttlMs: 1000, force: true, }) - expect(result.images[0].id).toBe("remote-img") + expect(result.catalog.images[0].id).toBe("remote-img") + expect(result.origin).toBe("remote") const cached = JSON.parse(await readFile(cachePath, "utf8")) expect(cached.catalog.images[0].id).toBe("remote-img") expect(typeof cached.fetchedAt).toBe("number") @@ -66,7 +67,8 @@ describe("loadCatalog", () => { ttlMs: 1000, force: true, }) - expect(result.images[0].id).toBe("remote-img") + expect(result.catalog.images[0].id).toBe("remote-img") + expect(result.origin).toBe("cache") }) it("falls back to seed when remote fails and no cache exists", async () => { @@ -80,7 +82,8 @@ describe("loadCatalog", () => { ttlMs: 1000, force: true, }) - expect(result.images[0].id).toBe("seed-img") + expect(result.catalog.images[0].id).toBe("seed-img") + expect(result.origin).toBe("seed") }) it("returns fresh cache without fetching when within TTL and not forced", async () => { @@ -98,6 +101,42 @@ describe("loadCatalog", () => { force: false, }) expect(fetchSpy).not.toHaveBeenCalled() - expect(result.images[0].id).toBe("remote-img") + expect(result.catalog.images[0].id).toBe("remote-img") + expect(result.origin).toBe("cache") + }) + + it("refetches and overwrites when cache is stale even without force", async () => { + await writeFile( + cachePath, + JSON.stringify({ fetchedAt: Date.now() - 10_000, catalog: SEED }), + ) + __setFetchForTest(async () => new Response(JSON.stringify(REMOTE))) + const result = await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 1000, + force: false, + }) + expect(result.catalog.images[0].id).toBe("remote-img") + expect(result.origin).toBe("remote") + const cached = JSON.parse(await readFile(cachePath, "utf8")) + expect(cached.catalog.images[0].id).toBe("remote-img") + }) + + it("ignores a corrupt cache file and falls back to seed", async () => { + await writeFile(cachePath, "{ this is not valid json") + __setFetchForTest(async () => { + throw new Error("network down") + }) + const result = await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 1000, + force: false, + }) + expect(result.catalog.images[0].id).toBe("seed-img") + expect(result.origin).toBe("seed") }) }) diff --git a/desktop/src/main/image-catalog.ts b/desktop/src/main/image-catalog.ts index 85233fff6..7f638b8e2 100644 --- a/desktop/src/main/image-catalog.ts +++ b/desktop/src/main/image-catalog.ts @@ -1,5 +1,9 @@ import { readFile, writeFile } from "node:fs/promises" -import type { ImageCatalog } from "../shared/image-catalog-types.js" +import type { + CatalogOrigin, + ImageCatalog, + LoadCatalogResult, +} from "../shared/image-catalog-types.js" export interface CatalogCacheFile { fetchedAt: number @@ -14,6 +18,8 @@ export interface LoadCatalogOptions { force: boolean } +export type { CatalogOrigin, LoadCatalogResult } + let fetchImpl: typeof fetch | undefined export function __setFetchForTest(fn: typeof fetch | undefined): void { fetchImpl = fn @@ -48,11 +54,11 @@ async function readSeed(seedPath: string): Promise { export async function loadCatalog( opts: LoadCatalogOptions, -): Promise { +): Promise { const cache = await readCache(opts.cachePath) const fresh = cache && Date.now() - cache.fetchedAt < opts.ttlMs if (cache && fresh && !opts.force) { - return cache.catalog + return { catalog: cache.catalog, origin: "cache" } } try { @@ -62,10 +68,10 @@ export async function loadCatalog( if (!catalog?.images) throw new Error("malformed catalog") const toWrite: CatalogCacheFile = { fetchedAt: Date.now(), catalog } await writeFile(opts.cachePath, JSON.stringify(toWrite)) - return catalog + return { catalog, origin: "remote" } } catch (err) { console.warn("[image-catalog] remote fetch failed, using fallback:", err) - if (cache) return cache.catalog - return readSeed(opts.seedPath) + if (cache) return { catalog: cache.catalog, origin: "cache" } + return { catalog: await readSeed(opts.seedPath), origin: "seed" } } } diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 1e00b1198..e7711f661 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -338,26 +338,24 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle("image_catalog_get", async () => { const { cachePath, seedPath } = imageCatalogPaths() - const catalog = await loadCatalog({ + return loadCatalog({ url: IMAGE_CATALOG_URL, cachePath, seedPath, ttlMs: IMAGE_CATALOG_TTL_MS, force: false, }) - return catalog }) ipcMain.handle("image_catalog_refresh", async () => { const { cachePath, seedPath } = imageCatalogPaths() - const catalog = await loadCatalog({ + return loadCatalog({ url: IMAGE_CATALOG_URL, cachePath, seedPath, ttlMs: IMAGE_CATALOG_TTL_MS, force: true, }) - return catalog }) ipcMain.handle("dialog_open_directory", async () => { @@ -524,7 +522,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { debug?: boolean workspaceFolder?: string devcontainerPath?: string - prebuildRepositories?: string + prebuildRepository?: string }, ) => { trackEvent("workspace_create", { provider: args.provider }) @@ -537,8 +535,8 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (args.workspaceFolder) cliArgs.push("--workspace-folder", args.workspaceFolder) if (args.devcontainerPath) cliArgs.push("--devcontainer-path", args.devcontainerPath) - if (args.prebuildRepositories) - cliArgs.push("--prebuild-repository", args.prebuildRepositories) + if (args.prebuildRepository) + cliArgs.push("--prebuild-repository", args.prebuildRepository) const wsId = args.workspaceId ?? args.source const cmdId = crypto.randomUUID() diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 74133d2e2..59cc5aa09 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -157,17 +157,24 @@ let selectedIde = $state("none") let workspaceName = $state("") let assembled = $derived( - buildWorkspaceSource({ - sourceType, - repoUrl, - localPath, - imageRef, - refType, - refValue, - subPath, - devcontainerPath, - prebuildRepository, - }), + sourceType === "image" + ? buildWorkspaceSource({ sourceType: "image", imageRef }) + : sourceType === "local" + ? buildWorkspaceSource({ + sourceType: "local", + localPath, + devcontainerPath, + prebuildRepository, + }) + : buildWorkspaceSource({ + sourceType: "git", + repoUrl, + refType, + refValue, + subPath, + devcontainerPath, + prebuildRepository, + }), ) let primarySourceValue = $derived( @@ -367,7 +374,7 @@ async function handleLaunch() { ideLaunch: "auto", workspaceFolder: workspaceFolder.trim() || undefined, devcontainerPath: assembled.devcontainerPath, - prebuildRepositories: assembled.prebuildRepository, + prebuildRepository: assembled.prebuildRepository, debug: true, }) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts index 7dfdd9852..115f2ef32 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts @@ -490,7 +490,7 @@ describe("WorkspaceWizard", () => { source: "github.com/org/repo@subpath:pkg/api", workspaceFolder: "/workspaces/app", devcontainerPath: ".devcontainer/devcontainer.json", - prebuildRepositories: "ghcr.io/org/prebuilds", + prebuildRepository: "ghcr.io/org/prebuilds", }), ) unmount() diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index 54a9e89d9..19f5df9c6 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.test.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.test.ts @@ -98,19 +98,19 @@ describe("IPC commands", () => { expect(result).toBe('{"state":"Running"}') }) - it("workspaceUp forwards devcontainerPath and prebuildRepositories", async () => { + it("workspaceUp forwards devcontainerPath and prebuildRepository", async () => { mockInvoke.mockResolvedValue("cmd-id") await workspaceUp({ source: "github.com/org/repo", devcontainerPath: ".devcontainer/devcontainer.json", - prebuildRepositories: "ghcr.io/org/prebuilds", + prebuildRepository: "ghcr.io/org/prebuilds", }) expect(mockInvoke).toHaveBeenCalledWith( "workspace_up", expect.objectContaining({ source: "github.com/org/repo", devcontainerPath: ".devcontainer/devcontainer.json", - prebuildRepositories: "ghcr.io/org/prebuilds", + prebuildRepository: "ghcr.io/org/prebuilds", }), ) }) diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index c8a3a8a60..2bbe5a13f 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -1,7 +1,7 @@ import type { AuditEntry, Context, - ImageCatalog, + LoadCatalogResult, LogEntry, Machine, OptionValue, @@ -28,7 +28,7 @@ export async function workspaceUp(params: { debug?: boolean workspaceFolder?: string devcontainerPath?: string - prebuildRepositories?: string + prebuildRepository?: string }): Promise { return invoke("workspace_up", params) } @@ -163,12 +163,12 @@ export async function providerCheckUpdates() { } // Image catalog commands -export async function imageCatalogGet(): Promise { - return invoke("image_catalog_get") +export async function imageCatalogGet(): Promise { + return invoke("image_catalog_get") } -export async function imageCatalogRefresh(): Promise { - return invoke("image_catalog_refresh") +export async function imageCatalogRefresh(): Promise { + return invoke("image_catalog_refresh") } // Machine commands diff --git a/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts b/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts index d7f81e0b8..d9f2c6311 100644 --- a/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts +++ b/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts @@ -16,7 +16,14 @@ import { imageCatalogGet } from "$lib/ipc/commands.js" import type { CatalogImage } from "$lib/types/index.js" const IMAGES: CatalogImage[] = [ - { id: "py", ref: "py:1", name: "Python", categories: ["lang"], featured: true }, + { + id: "py", + ref: "py:1", + name: "Python", + description: "Python tooling", + categories: ["lang"], + featured: true, + }, { id: "node", ref: "node:1", name: "Node.js", categories: ["lang"] }, { id: "tf", ref: "tf:1", name: "Terraform", categories: ["tools"] }, ] @@ -29,15 +36,19 @@ describe("imageCatalog store", () => { it("loads catalog via loadImageCatalog", async () => { vi.mocked(imageCatalogGet).mockResolvedValue({ - version: 1, - categories: [{ id: "lang", label: "Languages" }], - images: IMAGES, + origin: "remote", + catalog: { + version: 1, + categories: [{ id: "lang", label: "Languages" }], + images: IMAGES, + }, }) await loadImageCatalog() const state = get(imageCatalog) expect(state.images).toHaveLength(3) expect(state.loading).toBe(false) expect(state.error).toBeUndefined() + expect(state.origin).toBe("remote") }) it("records error on failure", async () => { @@ -54,10 +65,20 @@ describe("imageCatalog store", () => { }) it("filterImages: search matches name (case-insensitive)", () => { - const out = filterImages(IMAGES, "node", "all") + const out = filterImages(IMAGES, "NODE", "all") expect(out.map((i) => i.id)).toEqual(["node"]) }) + it("filterImages: search matches ref", () => { + const out = filterImages(IMAGES, "tf:1", "all") + expect(out.map((i) => i.id)).toEqual(["tf"]) + }) + + it("filterImages: search matches description without crashing on missing ones", () => { + const out = filterImages(IMAGES, "tooling", "all") + expect(out.map((i) => i.id)).toEqual(["py"]) + }) + it("filterImages: category filter", () => { const out = filterImages(IMAGES, "", "tools") expect(out.map((i) => i.id)).toEqual(["tf"]) diff --git a/desktop/src/renderer/src/lib/stores/imageCatalog.ts b/desktop/src/renderer/src/lib/stores/imageCatalog.ts index 61c952343..62fd5e633 100644 --- a/desktop/src/renderer/src/lib/stores/imageCatalog.ts +++ b/desktop/src/renderer/src/lib/stores/imageCatalog.ts @@ -2,7 +2,8 @@ import { writable } from "svelte/store" import type { CatalogCategory, CatalogImage, - ImageCatalog, + CatalogOrigin, + LoadCatalogResult, } from "$lib/types/index.js" import { imageCatalogGet, imageCatalogRefresh } from "$lib/ipc/commands.js" @@ -11,6 +12,7 @@ type State = { categories: CatalogCategory[] loading: boolean error?: string + origin?: CatalogOrigin } const initial: State = { @@ -18,17 +20,19 @@ const initial: State = { categories: [], loading: false, error: undefined, + origin: undefined, } const internal = writable(initial) export const imageCatalog = { subscribe: internal.subscribe } -function apply(catalog: ImageCatalog): void { +function apply(result: LoadCatalogResult): void { internal.set({ - images: catalog.images, - categories: catalog.categories, + images: result.catalog.images, + categories: result.catalog.categories, loading: false, error: undefined, + origin: result.origin, }) } diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index c8ee11fb5..64e9c8b73 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -99,7 +99,9 @@ export interface ProviderVersionCheckResult { export type { CatalogCategory, CatalogImage, + CatalogOrigin, ImageCatalog, + LoadCatalogResult, } from "../../../../shared/image-catalog-types.js" export interface MachineProviderConfig { diff --git a/desktop/src/renderer/src/lib/utils/workspace-source.test.ts b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts index b65000d98..db0a4444e 100644 --- a/desktop/src/renderer/src/lib/utils/workspace-source.test.ts +++ b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts @@ -1,13 +1,15 @@ import { describe, it, expect } from "vitest" import { buildWorkspaceSource } from "./workspace-source.js" -import type { WorkspaceSourceForm } from "./workspace-source.js" +import type { + GitSourceForm, + LocalSourceForm, + ImageSourceForm, +} from "./workspace-source.js" -function form(overrides: Partial): WorkspaceSourceForm { +function gitForm(overrides: Partial): GitSourceForm { return { sourceType: "git", repoUrl: "", - localPath: "", - imageRef: "", refType: "branch", refValue: "", subPath: "", @@ -17,9 +19,27 @@ function form(overrides: Partial): WorkspaceSourceForm { } } +function localForm(overrides: Partial): LocalSourceForm { + return { + sourceType: "local", + localPath: "", + devcontainerPath: "", + prebuildRepository: "", + ...overrides, + } +} + +function imageForm(overrides: Partial): ImageSourceForm { + return { + sourceType: "image", + imageRef: "", + ...overrides, + } +} + describe("buildWorkspaceSource", () => { it("git: bare repo url, no suffixes", () => { - const out = buildWorkspaceSource(form({ repoUrl: "github.com/org/repo" })) + const out = buildWorkspaceSource(gitForm({ repoUrl: "github.com/org/repo" })) expect(out.source).toBe("github.com/org/repo") expect(out.devcontainerPath).toBeUndefined() expect(out.prebuildRepository).toBeUndefined() @@ -27,28 +47,28 @@ describe("buildWorkspaceSource", () => { it("git: branch ref appends @branch", () => { const out = buildWorkspaceSource( - form({ repoUrl: "github.com/org/repo", refType: "branch", refValue: "dev" }), + gitForm({ repoUrl: "github.com/org/repo", refType: "branch", refValue: "dev" }), ) expect(out.source).toBe("github.com/org/repo@dev") }) it("git: commit ref appends @sha256:", () => { const out = buildWorkspaceSource( - form({ repoUrl: "github.com/org/repo", refType: "commit", refValue: "abc123" }), + gitForm({ repoUrl: "github.com/org/repo", refType: "commit", refValue: "abc123" }), ) expect(out.source).toBe("github.com/org/repo@sha256:abc123") }) it("git: PR ref appends @pull/N/head", () => { const out = buildWorkspaceSource( - form({ repoUrl: "github.com/org/repo", refType: "pr", refValue: "42" }), + gitForm({ repoUrl: "github.com/org/repo", refType: "pr", refValue: "42" }), ) expect(out.source).toBe("github.com/org/repo@pull/42/head") }) it("git: subpath appends @subpath: after ref", () => { const out = buildWorkspaceSource( - form({ + gitForm({ repoUrl: "github.com/org/repo", refType: "branch", refValue: "main", @@ -60,14 +80,14 @@ describe("buildWorkspaceSource", () => { it("git: subpath without ref appends @subpath: directly", () => { const out = buildWorkspaceSource( - form({ repoUrl: "github.com/org/repo", refValue: "", subPath: "packages/api" }), + gitForm({ repoUrl: "github.com/org/repo", refValue: "", subPath: "packages/api" }), ) expect(out.source).toBe("github.com/org/repo@subpath:packages/api") }) it("git: whitespace-only optional fields become undefined", () => { const out = buildWorkspaceSource( - form({ repoUrl: "github.com/org/repo", devcontainerPath: " ", prebuildRepository: " " }), + gitForm({ repoUrl: "github.com/org/repo", devcontainerPath: " ", prebuildRepository: " " }), ) expect(out.devcontainerPath).toBeUndefined() expect(out.prebuildRepository).toBeUndefined() @@ -75,21 +95,21 @@ describe("buildWorkspaceSource", () => { it("git: empty refValue omits the ref even if refType set", () => { const out = buildWorkspaceSource( - form({ repoUrl: "github.com/org/repo", refType: "commit", refValue: "" }), + gitForm({ repoUrl: "github.com/org/repo", refType: "commit", refValue: "" }), ) expect(out.source).toBe("github.com/org/repo") }) it("git: trims whitespace from inputs", () => { const out = buildWorkspaceSource( - form({ repoUrl: " github.com/org/repo ", refValue: " main " }), + gitForm({ repoUrl: " github.com/org/repo ", refValue: " main " }), ) expect(out.source).toBe("github.com/org/repo@main") }) it("git: forwards devcontainerPath and prebuildRepository", () => { const out = buildWorkspaceSource( - form({ + gitForm({ repoUrl: "github.com/org/repo", devcontainerPath: ".devcontainer/devcontainer.json", prebuildRepository: "ghcr.io/org/prebuilds", @@ -99,17 +119,16 @@ describe("buildWorkspaceSource", () => { expect(out.prebuildRepository).toBe("ghcr.io/org/prebuilds") }) - it("local: uses localPath as source, supports subpath", () => { + it("local: uses localPath as source", () => { const out = buildWorkspaceSource( - form({ sourceType: "local", localPath: "/home/me/proj", subPath: "sub" }), + localForm({ localPath: "/home/me/proj" }), ) - expect(out.source).toBe("/home/me/proj@subpath:sub") + expect(out.source).toBe("/home/me/proj") }) it("local: forwards devcontainerPath and prebuildRepository", () => { const out = buildWorkspaceSource( - form({ - sourceType: "local", + localForm({ localPath: "/home/me/proj", devcontainerPath: ".devcontainer/devcontainer.json", prebuildRepository: "ghcr.io/org/prebuilds", @@ -119,19 +138,19 @@ describe("buildWorkspaceSource", () => { expect(out.prebuildRepository).toBe("ghcr.io/org/prebuilds") }) - it("image: uses imageRef as source, ignores git/build options", () => { + it("image: uses imageRef as source, ignores build options", () => { const out = buildWorkspaceSource( - form({ - sourceType: "image", - imageRef: "mcr.microsoft.com/devcontainers/python:3.12", - refValue: "main", - subPath: "sub", - devcontainerPath: ".devcontainer/devcontainer.json", - prebuildRepository: "ghcr.io/org/prebuilds", - }), + imageForm({ imageRef: "mcr.microsoft.com/devcontainers/python:3.12" }), ) expect(out.source).toBe("mcr.microsoft.com/devcontainers/python:3.12") expect(out.devcontainerPath).toBeUndefined() expect(out.prebuildRepository).toBeUndefined() }) + + it("image: trims whitespace from imageRef", () => { + const out = buildWorkspaceSource( + imageForm({ imageRef: " mcr.microsoft.com/devcontainers/go:1 " }), + ) + expect(out.source).toBe("mcr.microsoft.com/devcontainers/go:1") + }) }) diff --git a/desktop/src/renderer/src/lib/utils/workspace-source.ts b/desktop/src/renderer/src/lib/utils/workspace-source.ts index 3a30226e9..11845fcf3 100644 --- a/desktop/src/renderer/src/lib/utils/workspace-source.ts +++ b/desktop/src/renderer/src/lib/utils/workspace-source.ts @@ -1,11 +1,9 @@ export type WorkspaceSourceType = "git" | "local" | "image" export type GitRefType = "branch" | "commit" | "pr" -export interface WorkspaceSourceForm { - sourceType: WorkspaceSourceType +export interface GitSourceForm { + sourceType: "git" repoUrl: string - localPath: string - imageRef: string refType: GitRefType refValue: string subPath: string @@ -13,6 +11,23 @@ export interface WorkspaceSourceForm { prebuildRepository: string } +export interface LocalSourceForm { + sourceType: "local" + localPath: string + devcontainerPath: string + prebuildRepository: string +} + +export interface ImageSourceForm { + sourceType: "image" + imageRef: string +} + +export type WorkspaceSourceForm = + | GitSourceForm + | LocalSourceForm + | ImageSourceForm + export interface WorkspaceSourceResult { source: string devcontainerPath?: string @@ -49,13 +64,15 @@ export function buildWorkspaceSource( return { source: form.imageRef.trim() } } - const base = - form.sourceType === "git" ? form.repoUrl.trim() : form.localPath.trim() - - const ref = - form.sourceType === "git" ? refSuffix(form.refType, form.refValue) : "" + if (form.sourceType === "local") { + return { + source: form.localPath.trim(), + devcontainerPath: optional(form.devcontainerPath), + prebuildRepository: optional(form.prebuildRepository), + } + } - const source = `${base}${ref}${subPathSuffix(form.subPath)}` + const source = `${form.repoUrl.trim()}${refSuffix(form.refType, form.refValue)}${subPathSuffix(form.subPath)}` return { source, diff --git a/desktop/src/shared/image-catalog-types.ts b/desktop/src/shared/image-catalog-types.ts index 2490687c7..0991411ac 100644 --- a/desktop/src/shared/image-catalog-types.ts +++ b/desktop/src/shared/image-catalog-types.ts @@ -18,3 +18,10 @@ export interface ImageCatalog { categories: CatalogCategory[] images: CatalogImage[] } + +export type CatalogOrigin = "remote" | "cache" | "seed" + +export interface LoadCatalogResult { + catalog: ImageCatalog + origin: CatalogOrigin +} From 65643889d187001f98fc3c74b0488f9c20a607f8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 18:32:38 -0500 Subject: [PATCH 25/26] fix(desktop): harden catalog error handling and lock source invariants - Move cache writeFile out of the fetch try so a write failure no longer discards a good remote fetch or logs a false "remote fetch failed". - Log a corrupt/missing bundled seed distinctly as a packaging bug. - Gate the review-screen Subfolder row to git only, clearing the phantom row when switching away from the git tab. - Add tests pinning that an image source forwards a bare ref with workspaceFolder kept separate, and that a local source never gets a @subpath: suffix. --- desktop/src/main/image-catalog.ts | 25 ++++++-- .../workspace/WorkspaceWizard.svelte | 2 +- .../workspace/WorkspaceWizard.test.ts | 62 +++++++++++++++++++ .../src/lib/utils/workspace-source.test.ts | 8 +++ 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/desktop/src/main/image-catalog.ts b/desktop/src/main/image-catalog.ts index 7f638b8e2..bd442db35 100644 --- a/desktop/src/main/image-catalog.ts +++ b/desktop/src/main/image-catalog.ts @@ -48,8 +48,18 @@ async function readCache(cachePath: string): Promise { } async function readSeed(seedPath: string): Promise { - const raw = await readFile(seedPath, "utf8") - return JSON.parse(raw) as ImageCatalog + try { + const raw = await readFile(seedPath, "utf8") + const seed = JSON.parse(raw) as ImageCatalog + if (!seed?.images) throw new Error("seed missing images") + return seed + } catch (err) { + console.error( + "[image-catalog] bundled seed unreadable/corrupt (packaging bug):", + err, + ) + throw err + } } export async function loadCatalog( @@ -66,8 +76,15 @@ export async function loadCatalog( if (!res.ok) throw new Error(`HTTP ${res.status}`) const catalog = (await res.json()) as ImageCatalog if (!catalog?.images) throw new Error("malformed catalog") - const toWrite: CatalogCacheFile = { fetchedAt: Date.now(), catalog } - await writeFile(opts.cachePath, JSON.stringify(toWrite)) + try { + const toWrite: CatalogCacheFile = { fetchedAt: Date.now(), catalog } + await writeFile(opts.cachePath, JSON.stringify(toWrite)) + } catch (err) { + console.warn( + "[image-catalog] failed to write cache (continuing with remote):", + err, + ) + } return { catalog, origin: "remote" } } catch (err) { console.warn("[image-catalog] remote fetch failed, using fallback:", err) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 59cc5aa09..9226306af 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -860,7 +860,7 @@ function selectTemplate(t: { name: string; source: string }) { {refValue} {/if} - {#if sourceType !== "image" && subPath.trim()} + {#if sourceType === "git" && subPath.trim()}
Subfolder {subPath} diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts index 115f2ef32..66a6f72e0 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts @@ -496,6 +496,68 @@ describe("WorkspaceWizard", () => { unmount() }) + it("forwards an image source as a bare ref with workspaceFolder kept separate", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + // Set a workspace folder via the git tab's advanced section; this state + // persists when we switch source types. + const advancedToggle = Array.from( + document.querySelectorAll("button"), + ).find((b) => /advanced options/i.test(b.textContent ?? "")) as HTMLElement + await fireEvent.click(advancedToggle) + await flushAsync() + const wsFolderInput = document.querySelector( + 'input[placeholder*="opened inside the container"]', + ) as HTMLInputElement + await fireEvent.input(wsFolderInput, { target: { value: "/workspaces/app" } }) + await flushAsync() + + // Switch to the Image tab and enter a custom image ref. + await fireEvent.click(getByText("Image")) + await flushAsync() + const customImageInput = document.querySelector( + 'input[placeholder*="registry/image:tag"]', + ) as HTMLInputElement + await fireEvent.input(customImageInput, { target: { value: "ubuntu:22.04" } }) + await flushAsync() + + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + // The image ref derives an id containing ":", so give an explicit name. + const nameInput = document.querySelector( + 'input[placeholder*="derived from source"]', + ) as HTMLInputElement + await fireEvent.input(nameInput, { target: { value: "ubuntu-box" } }) + await flushAsync() + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + await fireEvent.click(launchBtn) + await flushAsync() + + expect(workspaceUp).toHaveBeenCalledWith( + expect.objectContaining({ + source: "ubuntu:22.04", + workspaceFolder: "/workspaces/app", + devcontainerPath: undefined, + prebuildRepository: undefined, + }), + ) + unmount() + }) + it("close-during-launch shows the confirm dialog instead of closing", async () => { providers.set([makeProvider("docker")]) // Hold workspaceUp open so launchRunning stays true diff --git a/desktop/src/renderer/src/lib/utils/workspace-source.test.ts b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts index db0a4444e..8111a8560 100644 --- a/desktop/src/renderer/src/lib/utils/workspace-source.test.ts +++ b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts @@ -126,6 +126,14 @@ describe("buildWorkspaceSource", () => { expect(out.source).toBe("/home/me/proj") }) + it("local: never appends a @subpath: suffix", () => { + const out = buildWorkspaceSource( + localForm({ localPath: "/home/me/proj/packages/api" }), + ) + expect(out.source).toBe("/home/me/proj/packages/api") + expect(out.source).not.toContain("@subpath:") + }) + it("local: forwards devcontainerPath and prebuildRepository", () => { const out = buildWorkspaceSource( localForm({ From 334c8e2e310533e5c426a82eae7663d7fcaebfc5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 5 Jun 2026 19:21:17 -0500 Subject: [PATCH 26/26] fix(desktop): address PR review on catalog robustness and wizard UX - Validate the full catalog contract (id/ref/name/categories) at the cache, remote, and seed boundaries so malformed payloads fall back rather than crashing the renderer. - Add a 10s AbortController timeout to the remote catalog fetch so the cache/seed fallback always runs even on a hung connection. - Sanitize derived workspace ids so image tags and Windows paths produce valid names (ubuntu:22.04 -> ubuntu-22.04) without manual correction. - Surface directory-picker IPC failures via a toast instead of an unhandled rejection. --- .../src/main/__tests__/image-catalog.test.ts | 35 +++++++++++++ desktop/src/main/image-catalog.ts | 50 +++++++++++++++++-- .../workspace/WorkspaceWizard.svelte | 34 ++++++------- .../workspace/WorkspaceWizard.test.ts | 45 ++++++++++++++--- 4 files changed, 136 insertions(+), 28 deletions(-) diff --git a/desktop/src/main/__tests__/image-catalog.test.ts b/desktop/src/main/__tests__/image-catalog.test.ts index d5d73b056..9ec150a5c 100644 --- a/desktop/src/main/__tests__/image-catalog.test.ts +++ b/desktop/src/main/__tests__/image-catalog.test.ts @@ -139,4 +139,39 @@ describe("loadCatalog", () => { expect(result.catalog.images[0].id).toBe("seed-img") expect(result.origin).toBe("seed") }) + + it("rejects a structurally malformed remote payload and falls back to seed", async () => { + // images present but entries miss required fields (no ref/categories). + __setFetchForTest( + async () => + new Response( + JSON.stringify({ version: 1, categories: [], images: [{ id: "x" }] }), + ), + ) + const result = await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 1000, + force: true, + }) + expect(result.catalog.images[0].id).toBe("seed-img") + expect(result.origin).toBe("seed") + }) + + it("passes an abort signal to the remote fetch", async () => { + let receivedSignal: AbortSignal | undefined + __setFetchForTest(async (_url, init) => { + receivedSignal = (init as RequestInit | undefined)?.signal ?? undefined + return new Response(JSON.stringify(REMOTE)) + }) + await loadCatalog({ + url: "https://example/catalog.json", + cachePath, + seedPath, + ttlMs: 1000, + force: true, + }) + expect(receivedSignal).toBeInstanceOf(AbortSignal) + }) }) diff --git a/desktop/src/main/image-catalog.ts b/desktop/src/main/image-catalog.ts index bd442db35..608bc1e02 100644 --- a/desktop/src/main/image-catalog.ts +++ b/desktop/src/main/image-catalog.ts @@ -1,10 +1,14 @@ import { readFile, writeFile } from "node:fs/promises" import type { + CatalogCategory, + CatalogImage, CatalogOrigin, ImageCatalog, LoadCatalogResult, } from "../shared/image-catalog-types.js" +const FETCH_TIMEOUT_MS = 10_000 + export interface CatalogCacheFile { fetchedAt: number catalog: ImageCatalog @@ -28,6 +32,35 @@ function getFetch(): typeof fetch { return fetchImpl ?? globalThis.fetch } +function isCatalogCategory(value: unknown): value is CatalogCategory { + if (!value || typeof value !== "object") return false + const v = value as Partial + return typeof v.id === "string" && typeof v.label === "string" +} + +function isCatalogImage(value: unknown): value is CatalogImage { + if (!value || typeof value !== "object") return false + const v = value as Partial + return ( + typeof v.id === "string" && + typeof v.ref === "string" && + typeof v.name === "string" && + Array.isArray(v.categories) && + v.categories.every((c) => typeof c === "string") + ) +} + +function isImageCatalog(value: unknown): value is ImageCatalog { + if (!value || typeof value !== "object") return false + const v = value as Partial + return ( + Array.isArray(v.categories) && + v.categories.every(isCatalogCategory) && + Array.isArray(v.images) && + v.images.every(isCatalogImage) + ) +} + async function readCache(cachePath: string): Promise { let raw: string try { @@ -40,7 +73,9 @@ async function readCache(cachePath: string): Promise { } try { const parsed = JSON.parse(raw) as CatalogCacheFile - return parsed?.catalog?.images ? parsed : null + return typeof parsed?.fetchedAt === "number" && isImageCatalog(parsed.catalog) + ? parsed + : null } catch (err) { console.warn("[image-catalog] corrupt cache file, ignoring:", err) return null @@ -51,7 +86,7 @@ async function readSeed(seedPath: string): Promise { try { const raw = await readFile(seedPath, "utf8") const seed = JSON.parse(raw) as ImageCatalog - if (!seed?.images) throw new Error("seed missing images") + if (!isImageCatalog(seed)) throw new Error("seed malformed") return seed } catch (err) { console.error( @@ -72,10 +107,17 @@ export async function loadCatalog( } try { - const res = await getFetch()(opts.url) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + let res: Response + try { + res = await getFetch()(opts.url, { signal: controller.signal }) + } finally { + clearTimeout(timeout) + } if (!res.ok) throw new Error(`HTTP ${res.status}`) const catalog = (await res.json()) as ImageCatalog - if (!catalog?.images) throw new Error("malformed catalog") + if (!isImageCatalog(catalog)) throw new Error("malformed catalog") try { const toWrite: CatalogCacheFile = { fetchedAt: Date.now(), catalog } await writeFile(opts.cachePath, JSON.stringify(toWrite)) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 9226306af..26fbce19e 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -218,14 +218,7 @@ let filteredIdes = $derived( ) let resolvedId = $derived( - workspaceName.trim() || - assembled.source - .trim() - .split("/") - .pop() - ?.replace(/\.git$/, "") - ?.replace(/@.*$/, "") || - "", + workspaceName.trim() || deriveWorkspaceNameFromSource(assembled.source), ) let resolvedIdInvalid = $derived( @@ -430,6 +423,15 @@ function randomName(): string { }) } +function deriveWorkspaceNameFromSource(source: string): string { + const leaf = source.trim().split(/[\\/]/).pop() ?? "" + return leaf + .replace(/\.git$/i, "") + .replace(/@.*$/, "") + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") +} + function uniquifyName(base: string): string { const existing = new Set($workspaces.map((ws) => ws.id.toLowerCase())) if (base && !existing.has(base.toLowerCase())) return base @@ -448,13 +450,7 @@ function continueFromProvider() { function continueFromSource() { if (!primarySourceValue) return if (!workspaceName.trim()) { - const derived = - assembled.source - .trim() - .split("/") - .pop() - ?.replace(/\.git$/, "") - ?.replace(/@.*$/, "") ?? "" + const derived = deriveWorkspaceNameFromSource(assembled.source) if (derived) workspaceName = uniquifyName(derived) } else { workspaceName = uniquifyName(workspaceName.trim()) @@ -463,8 +459,12 @@ function continueFromSource() { } async function handleBrowse() { - const picked = await openDirectoryDialog() - if (picked) localPath = picked + try { + const picked = await openDirectoryDialog() + if (picked) localPath = picked + } catch (err) { + toasts.error(`Failed to open directory picker: ${extractErrorMessage(err)}`) + } } function continueFromIde() { diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts index 66a6f72e0..db26791e2 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.test.ts @@ -534,13 +534,6 @@ describe("WorkspaceWizard", () => { await fireEvent.click(getActiveContinue(getByText)) await flushAsync() - // The image ref derives an id containing ":", so give an explicit name. - const nameInput = document.querySelector( - 'input[placeholder*="derived from source"]', - ) as HTMLInputElement - await fireEvent.input(nameInput, { target: { value: "ubuntu-box" } }) - await flushAsync() - const launchBtn = Array.from( document.querySelectorAll("button"), ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement @@ -558,6 +551,44 @@ describe("WorkspaceWizard", () => { unmount() }) + it("auto-derives a valid workspace id from an image tag", async () => { + providers.set([makeProvider("docker")]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await fireEvent.click(getByText("docker")) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + await fireEvent.click(getByText("Image")) + await flushAsync() + const customImageInput = document.querySelector( + 'input[placeholder*="registry/image:tag"]', + ) as HTMLInputElement + await fireEvent.input(customImageInput, { target: { value: "ubuntu:22.04" } }) + await flushAsync() + + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + await fireEvent.click(getActiveContinue(getByText)) + await flushAsync() + + // The colon in the tag is sanitized, so the derived name is valid and + // Launch is enabled without manual correction. + const nameInput = document.querySelector( + 'input[placeholder*="derived from source"]', + ) as HTMLInputElement + expect(nameInput.value).toBe("ubuntu-22.04") + + const launchBtn = Array.from( + document.querySelectorAll("button"), + ).find((b) => b.textContent?.trim() === "Launch") as HTMLButtonElement + expect(launchBtn.disabled).toBe(false) + unmount() + }) + it("close-during-launch shows the confirm dialog instead of closing", async () => { providers.set([makeProvider("docker")]) // Hold workspaceUp open so launchRunning stays true