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..3e79578db --- /dev/null +++ b/desktop/resources/image-catalog-seed.json @@ -0,0 +1,92 @@ +{ + "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" + }, + { + "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" + } + ] +} 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..9ec150a5c --- /dev/null +++ b/desktop/src/main/__tests__/image-catalog.test.ts @@ -0,0 +1,177 @@ +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.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") + }) + + 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.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 () => { + __setFetchForTest(async () => { + throw new Error("network down") + }) + 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("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.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") + }) + + 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 new file mode 100644 index 000000000..608bc1e02 --- /dev/null +++ b/desktop/src/main/image-catalog.ts @@ -0,0 +1,136 @@ +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 +} + +export interface LoadCatalogOptions { + url: string + cachePath: string + seedPath: string + ttlMs: number + force: boolean +} + +export type { CatalogOrigin, LoadCatalogResult } + +let fetchImpl: typeof fetch | undefined +export function __setFetchForTest(fn: typeof fetch | undefined): void { + fetchImpl = fn +} +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 { + 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 + } + try { + const parsed = JSON.parse(raw) as CatalogCacheFile + return typeof parsed?.fetchedAt === "number" && isImageCatalog(parsed.catalog) + ? parsed + : null + } catch (err) { + console.warn("[image-catalog] corrupt cache file, ignoring:", err) + return null + } +} + +async function readSeed(seedPath: string): Promise { + try { + const raw = await readFile(seedPath, "utf8") + const seed = JSON.parse(raw) as ImageCatalog + if (!isImageCatalog(seed)) throw new Error("seed malformed") + return seed + } catch (err) { + console.error( + "[image-catalog] bundled seed unreadable/corrupt (packaging bug):", + err, + ) + throw err + } +} + +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 { catalog: cache.catalog, origin: "cache" } + } + + try { + 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 (!isImageCatalog(catalog)) throw new Error("malformed catalog") + 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) + 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 57308084f..e7711f661 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -5,9 +5,10 @@ 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 { 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 +40,20 @@ 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 + +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 +336,37 @@ export function registerIpcHandlers(deps: IpcDependencies): { return providerUpdateCache }) + ipcMain.handle("image_catalog_get", async () => { + const { cachePath, seedPath } = imageCatalogPaths() + return loadCatalog({ + url: IMAGE_CATALOG_URL, + cachePath, + seedPath, + ttlMs: IMAGE_CATALOG_TTL_MS, + force: false, + }) + }) + + ipcMain.handle("image_catalog_refresh", async () => { + const { cachePath, seedPath } = imageCatalogPaths() + return loadCatalog({ + url: IMAGE_CATALOG_URL, + cachePath, + seedPath, + ttlMs: IMAGE_CATALOG_TTL_MS, + force: true, + }) + }) + + 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()) @@ -475,6 +521,8 @@ export function registerIpcHandlers(deps: IpcDependencies): { ideLaunch?: "auto" | "headless" | "skip" debug?: boolean workspaceFolder?: string + devcontainerPath?: string + prebuildRepository?: string }, ) => { trackEvent("workspace_create", { provider: args.provider }) @@ -485,6 +533,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.prebuildRepository) + cliArgs.push("--prebuild-repository", args.prebuildRepository) const wsId = args.workspaceId ?? args.source const cmdId = crypto.randomUUID() 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/ImagePicker.svelte b/desktop/src/renderer/src/lib/components/workspace/ImagePicker.svelte new file mode 100644 index 000000000..24c63ff7f --- /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} +
+ +
+ + pick(e.currentTarget.value.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") + }) +}) 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(() => { diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index e24209238..26fbce19e 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,49 @@ 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( + 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( + sourceType === "git" + ? repoUrl.trim() + : sourceType === "local" + ? localPath.trim() + : imageRef.trim(), +) + // IDE combobox state let ideComboOpen = $state(false) let ideSearch = $state("") @@ -173,13 +218,7 @@ let filteredIdes = $derived( ) let resolvedId = $derived( - workspaceName.trim() || - source - .trim() - .split("/") - .pop() - ?.replace(/\.git$/, "") || - "", + workspaceName.trim() || deriveWorkspaceNameFromSource(assembled.source), ) let resolvedIdInvalid = $derived( @@ -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" @@ -313,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, + prebuildRepository: assembled.prebuildRepository, debug: true, }) @@ -374,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 @@ -390,14 +448,9 @@ function continueFromProvider() { } function continueFromSource() { - if (!source.trim()) return + if (!primarySourceValue) return if (!workspaceName.trim()) { - const derived = - source - .trim() - .split("/") - .pop() - ?.replace(/\.git$/, "") ?? "" + const derived = deriveWorkspaceNameFromSource(assembled.source) if (derived) workspaceName = uniquifyName(derived) } else { workspaceName = uniquifyName(workspaceName.trim()) @@ -405,6 +458,15 @@ function continueFromSource() { currentStep = "ide" } +async function handleBrowse() { + try { + const picked = await openDirectoryDialog() + if (picked) localPath = picked + } catch (err) { + toasts.error(`Failed to open directory picker: ${extractErrorMessage(err)}`) + } +} + function continueFromIde() { currentStep = "review" } @@ -416,13 +478,95 @@ 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} + + {#if isGit} +
+ + (subPath = e.currentTarget.value)} + /> +
+ {/if} + +
+ + (workspaceFolder = e.currentTarget.value)} + /> +
+ +
+ + (devcontainerPath = e.currentTarget.value)} + /> +
+ +
+ + (prebuildRepository = e.currentTarget.value)} + /> +
+
+ {/if} +
+{/snippet} + @@ -528,61 +672,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)} + + + + + + +
- +
@@ -684,10 +844,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 sourceType === "git" && 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 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..db26791e2 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" }]) @@ -360,6 +427,168 @@ 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", + prebuildRepository: "ghcr.io/org/prebuilds", + }), + ) + 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() + + 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("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 diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index e88670af9..19f5df9c6 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 prebuildRepository", async () => { + mockInvoke.mockResolvedValue("cmd-id") + await workspaceUp({ + source: "github.com/org/repo", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepository: "ghcr.io/org/prebuilds", + }) + expect(mockInvoke).toHaveBeenCalledWith( + "workspace_up", + expect.objectContaining({ + source: "github.com/org/repo", + devcontainerPath: ".devcontainer/devcontainer.json", + prebuildRepository: "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..2bbe5a13f 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, + LoadCatalogResult, LogEntry, Machine, OptionValue, @@ -26,6 +27,8 @@ export async function workspaceUp(params: { ideLaunch?: "auto" | "headless" | "skip" debug?: boolean workspaceFolder?: string + devcontainerPath?: string + prebuildRepository?: string }): Promise { return invoke("workspace_up", params) } @@ -76,6 +79,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") @@ -155,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") 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..d9f2c6311 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/imageCatalog.test.ts @@ -0,0 +1,86 @@ +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", + 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"] }, +] + +describe("imageCatalog store", () => { + beforeEach(() => { + resetImageCatalogStore() + vi.clearAllMocks() + }) + + it("loads catalog via loadImageCatalog", async () => { + vi.mocked(imageCatalogGet).mockResolvedValue({ + 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 () => { + 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: 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 new file mode 100644 index 000000000..62fd5e633 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/imageCatalog.ts @@ -0,0 +1,91 @@ +import { writable } from "svelte/store" +import type { + CatalogCategory, + CatalogImage, + CatalogOrigin, + LoadCatalogResult, +} from "$lib/types/index.js" +import { imageCatalogGet, imageCatalogRefresh } from "$lib/ipc/commands.js" + +type State = { + images: CatalogImage[] + categories: CatalogCategory[] + loading: boolean + error?: string + origin?: CatalogOrigin +} + +const initial: State = { + images: [], + categories: [], + loading: false, + error: undefined, + origin: undefined, +} + +const internal = writable(initial) +export const imageCatalog = { subscribe: internal.subscribe } + +function apply(result: LoadCatalogResult): void { + internal.set({ + images: result.catalog.images, + categories: result.catalog.categories, + loading: false, + error: undefined, + origin: result.origin, + }) +} + +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) + }) +} diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 4e9617212..64e9c8b73 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -96,6 +96,14 @@ export interface ProviderVersionCheckResult { error?: string } +export type { + CatalogCategory, + CatalogImage, + CatalogOrigin, + ImageCatalog, + LoadCatalogResult, +} from "../../../../shared/image-catalog-types.js" + export interface MachineProviderConfig { name?: string } 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..8111a8560 --- /dev/null +++ b/desktop/src/renderer/src/lib/utils/workspace-source.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from "vitest" +import { buildWorkspaceSource } from "./workspace-source.js" +import type { + GitSourceForm, + LocalSourceForm, + ImageSourceForm, +} from "./workspace-source.js" + +function gitForm(overrides: Partial): GitSourceForm { + return { + sourceType: "git", + repoUrl: "", + refType: "branch", + refValue: "", + subPath: "", + devcontainerPath: "", + prebuildRepository: "", + ...overrides, + } +} + +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(gitForm({ 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( + 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( + 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( + 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( + gitForm({ + 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: subpath without ref appends @subpath: directly", () => { + const out = buildWorkspaceSource( + 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( + gitForm({ 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( + 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( + 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( + gitForm({ + 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", () => { + const out = buildWorkspaceSource( + localForm({ localPath: "/home/me/proj" }), + ) + 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({ + 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 build options", () => { + const out = buildWorkspaceSource( + 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 new file mode 100644 index 000000000..11845fcf3 --- /dev/null +++ b/desktop/src/renderer/src/lib/utils/workspace-source.ts @@ -0,0 +1,82 @@ +export type WorkspaceSourceType = "git" | "local" | "image" +export type GitRefType = "branch" | "commit" | "pr" + +export interface GitSourceForm { + sourceType: "git" + repoUrl: string + refType: GitRefType + refValue: string + subPath: string + devcontainerPath: string + 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 + 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() } + } + + if (form.sourceType === "local") { + return { + source: form.localPath.trim(), + devcontainerPath: optional(form.devcontainerPath), + prebuildRepository: optional(form.prebuildRepository), + } + } + + const source = `${form.repoUrl.trim()}${refSuffix(form.refType, form.refValue)}${subPathSuffix(form.subPath)}` + + return { + source, + devcontainerPath: optional(form.devcontainerPath), + prebuildRepository: optional(form.prebuildRepository), + } +} diff --git a/desktop/src/shared/image-catalog-types.ts b/desktop/src/shared/image-catalog-types.ts new file mode 100644 index 000000000..0991411ac --- /dev/null +++ b/desktop/src/shared/image-catalog-types.ts @@ -0,0 +1,27 @@ +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 CatalogOrigin = "remote" | "cache" | "seed" + +export interface LoadCatalogResult { + catalog: ImageCatalog + origin: CatalogOrigin +}