Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5990c5f
feat(desktop): add pure workspace source assembly helper
skevetter Jun 5, 2026
2d49979
test(desktop): cover subpath-without-ref and whitespace-only optionals
skevetter Jun 5, 2026
3cef1f3
feat(desktop): add workspaceUp build flags and directory dialog command
skevetter Jun 5, 2026
7c27141
feat(desktop): forward devcontainer-path and prebuild-repository to w…
skevetter Jun 5, 2026
5a8381f
feat(desktop): add native directory picker IPC
skevetter Jun 5, 2026
a5be317
feat(desktop): add image catalog types
skevetter Jun 5, 2026
4bbb89f
feat(desktop): add bundled image catalog seed manifest
skevetter Jun 5, 2026
4a80415
feat(desktop): image catalog fetch with cache and seed fallback
skevetter Jun 5, 2026
534b8e1
feat(desktop): add image catalog IPC handlers
skevetter Jun 5, 2026
b21f80e
feat(desktop): add image catalog renderer commands
skevetter Jun 5, 2026
a514bea
feat(desktop): add imageCatalog store with search and category filters
skevetter Jun 5, 2026
6869a81
refactor(desktop): drop unused imageCatalogCache module var
skevetter Jun 5, 2026
373436c
refactor(desktop): introduce per-type source state in wizard
skevetter Jun 5, 2026
12686c7
feat(desktop): add searchable image picker component
skevetter Jun 5, 2026
846ead9
feat(desktop): 3-way source selector with advanced options in wizard
skevetter Jun 5, 2026
6f5e552
feat(desktop): show source config details in wizard review
skevetter Jun 5, 2026
2e9091a
feat(desktop): forward assembled source and build flags on launch
skevetter Jun 5, 2026
fc2cc76
test(desktop): cover source-type selection in wizard
skevetter Jun 5, 2026
b7a3da6
fix(desktop): prevent custom image input desync with catalog selection
skevetter Jun 5, 2026
898db2d
fix(desktop): hide stale subfolder row for image sources in review
skevetter Jun 5, 2026
cab7d24
feat(desktop): seed image catalog with original wizard languages
skevetter Jun 5, 2026
5d012aa
feat(desktop): add Universal language icon with dark variant
skevetter Jun 5, 2026
7ff999f
fix(desktop): restore workspace folder input and gate subfolder to git
skevetter Jun 5, 2026
034d986
refactor(desktop): tighten source-config types and catalog provenance
skevetter Jun 5, 2026
6564388
fix(desktop): harden catalog error handling and lock source invariants
skevetter Jun 5, 2026
334c8e2
fix(desktop): address PR review on catalog robustness and wizard UX
skevetter Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions desktop/resources/image-catalog-seed.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
177 changes: 177 additions & 0 deletions desktop/src/main/__tests__/image-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading