From 79c34962f9764c00d1ab641720ac6b6a9430772b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 17 Apr 2026 11:12:28 -0500 Subject: [PATCH] Add project icon file picker - Allow choosing local image files for project icons - Preserve data URLs in icon resolution and reuse file reading helper - Trim icon paths before resolving URLs --- apps/web/src/components/ChatView.logic.ts | 18 +--- apps/web/src/components/ProjectIcon.tsx | 3 +- .../ProjectIconEditorDialog.browser.tsx | 91 +++++++++++++++++++ .../components/ProjectIconEditorDialog.tsx | 52 ++++++++--- .../web/src/hooks/useProjectIconFilePicker.ts | 36 ++++++++ apps/web/src/lib/fileData.ts | 16 ++++ apps/web/src/lib/projectIcons.test.ts | 17 +++- apps/web/src/lib/projectIcons.ts | 6 +- apps/web/src/routes/_chat.settings.index.tsx | 23 +++++ 9 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/components/ProjectIconEditorDialog.browser.tsx create mode 100644 apps/web/src/hooks/useProjectIconFilePicker.ts create mode 100644 apps/web/src/lib/fileData.ts diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 23f88d51..43c91947 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -8,6 +8,7 @@ import { stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; +export { readFileAsDataUrl } from "~/lib/fileData"; import { type PromptEnhancementId } from "../promptEnhancement"; export { buildLocalDraftThread } from "../draftThreads"; @@ -70,23 +71,6 @@ export interface IssueDialogState { key: number; } -export function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - reject(new Error("Could not read image data.")); - }); - reader.addEventListener("error", () => { - reject(reader.error ?? new Error("Failed to read image.")); - }); - reader.readAsDataURL(file); - }); -} - export function buildTemporaryWorktreeBranchName(): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. const token = randomUUID().slice(0, 8).toLowerCase(); diff --git a/apps/web/src/components/ProjectIcon.tsx b/apps/web/src/components/ProjectIcon.tsx index ae3fed31..6aad2aa2 100644 --- a/apps/web/src/components/ProjectIcon.tsx +++ b/apps/web/src/components/ProjectIcon.tsx @@ -10,9 +10,10 @@ export function ProjectIcon({ iconPath?: string | null | undefined; className?: string; }) { + const resolvedIconPath = iconPath?.trim(); return ( ({ entries: [], truncated: false })), + }, + } as unknown as NativeApi; +} + +afterEach(() => { + delete (window as Window & { nativeApi?: NativeApi }).nativeApi; + document.body.innerHTML = ""; +}); + +describe("ProjectIconEditorDialog", () => { + it("lets the user choose an image file and saves it as a data URL", async () => { + mockNativeApi(); + const onSave = vi.fn(async (_iconPath: string | null) => undefined); + const screen = await render( + , + ); + + try { + const chooseImageButton = page.getByRole("button", { name: "Choose image" }); + await chooseImageButton.click(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement | null; + if (!fileInput) { + throw new Error("Expected the hidden project icon file input to exist."); + } + expect(fileInput.accept).toBe("image/*"); + + const file = new File([new Uint8Array([137, 80, 78, 71])], "project-icon.png", { + type: "image/png", + }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + Object.defineProperty(fileInput, "files", { + configurable: true, + value: dataTransfer.files, + }); + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + + const saveButton = Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes("Save icon"), + ) as HTMLButtonElement | undefined; + if (!saveButton) { + throw new Error("Expected the save icon button to exist."); + } + await vi.waitFor(() => { + expect(saveButton.disabled).toBe(false); + }); + + saveButton.click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + }); + expect(onSave.mock.calls[0]?.[0]).toMatch(/^data:image\/png;base64,/); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/ProjectIconEditorDialog.tsx b/apps/web/src/components/ProjectIconEditorDialog.tsx index e243e37c..00db0665 100644 --- a/apps/web/src/components/ProjectIconEditorDialog.tsx +++ b/apps/web/src/components/ProjectIconEditorDialog.tsx @@ -3,6 +3,7 @@ import type { Project } from "~/types"; import { readNativeApi } from "~/nativeApi"; import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "~/lib/projectIcons"; +import { useProjectIconFilePicker } from "~/hooks/useProjectIconFilePicker"; import { Button } from "./ui/button"; import { Dialog, @@ -33,6 +34,12 @@ export function ProjectIconEditorDialog({ const [suggestedIconPath, setSuggestedIconPath] = useState(null); const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false); const draftWasTouchedRef = useRef(false); + const { fileInputRef, openFilePicker, handleFileChange } = useProjectIconFilePicker({ + onFileSelected: (dataUrl) => { + draftWasTouchedRef.current = true; + setDraft(dataUrl); + }, + }); useEffect(() => { if (!open || !projectId || !projectCwd) { @@ -104,8 +111,8 @@ export function ProjectIconEditorDialog({ Project icon - Set a path relative to the project root or an absolute image URL. Leave it blank to fall - back to the detected favicon or icon file. + Set a path relative to the project root, an absolute image URL, or choose an image file + from your computer. Leave it blank to fall back to the detected favicon or icon file. @@ -135,19 +142,34 @@ export function ProjectIconEditorDialog({ > Icon path - { - draftWasTouchedRef.current = true; - setDraft(event.target.value); - }} - placeholder={ - suggestedIconPath ?? "public/favicon.svg or https://example.com/icon.png" - } - autoComplete="off" - spellCheck={false} - /> +
+ { + draftWasTouchedRef.current = true; + setDraft(event.target.value); + }} + placeholder={ + suggestedIconPath ?? + "public/favicon.svg, https://example.com/icon.png, or choose an image" + } + autoComplete="off" + spellCheck={false} + /> + { + void handleFileChange(event); + }} + /> + +
diff --git a/apps/web/src/hooks/useProjectIconFilePicker.ts b/apps/web/src/hooks/useProjectIconFilePicker.ts new file mode 100644 index 00000000..54a18cc1 --- /dev/null +++ b/apps/web/src/hooks/useProjectIconFilePicker.ts @@ -0,0 +1,36 @@ +import { useCallback, useRef, type ChangeEvent } from "react"; + +import { readFileAsDataUrl } from "~/lib/fileData"; + +export function useProjectIconFilePicker(options: { onFileSelected: (dataUrl: string) => void }) { + const fileInputRef = useRef(null); + const { onFileSelected } = options; + + const openFilePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || !file.type.startsWith("image/")) { + return; + } + + try { + const dataUrl = await readFileAsDataUrl(file); + onFileSelected(dataUrl); + } catch (error) { + console.error("Failed to read project icon image:", error); + } + }, + [onFileSelected], + ); + + return { + fileInputRef, + openFilePicker, + handleFileChange, + }; +} diff --git a/apps/web/src/lib/fileData.ts b/apps/web/src/lib/fileData.ts new file mode 100644 index 00000000..f17bd04f --- /dev/null +++ b/apps/web/src/lib/fileData.ts @@ -0,0 +1,16 @@ +export function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Could not read file data.")); + }); + reader.addEventListener("error", () => { + reject(reader.error ?? new Error("Failed to read file.")); + }); + reader.readAsDataURL(file); + }); +} diff --git a/apps/web/src/lib/projectIcons.test.ts b/apps/web/src/lib/projectIcons.test.ts index 27a8ebd1..a9d16a38 100644 --- a/apps/web/src/lib/projectIcons.test.ts +++ b/apps/web/src/lib/projectIcons.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { NativeApi } from "@okcode/contracts"; -import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "./projectIcons"; +import { + normalizeProjectIconPath, + resolveProjectIconUrl, + resolveSuggestedProjectIconPath, +} from "./projectIcons"; describe("project icon helpers", () => { it("normalizes icon paths by trimming and treating blanks as null", () => { @@ -13,6 +17,17 @@ describe("project icon helpers", () => { expect(normalizeProjectIconPath(null)).toBeNull(); }); + it("returns data URLs directly so attached image previews can render", () => { + const dataUrl = "data:image/png;base64,AAAA"; + + expect( + resolveProjectIconUrl({ + cwd: "/repo", + iconPath: dataUrl, + }), + ).toBe(dataUrl); + }); + it("prefers the first well-known fallback candidate that exists in the workspace", async () => { const searchEntries = vi.fn(async ({ query }: { query: string }) => { if (query === "favicon") { diff --git a/apps/web/src/lib/projectIcons.ts b/apps/web/src/lib/projectIcons.ts index 94b0f259..60f9126e 100644 --- a/apps/web/src/lib/projectIcons.ts +++ b/apps/web/src/lib/projectIcons.ts @@ -8,8 +8,12 @@ export function resolveProjectIconUrl(input: { cwd: string; iconPath?: string | null | undefined; }): string { - const searchParams = new URLSearchParams({ cwd: input.cwd }); const iconPath = input.iconPath?.trim(); + if (iconPath?.startsWith("data:")) { + return iconPath; + } + + const searchParams = new URLSearchParams({ cwd: input.cwd }); if (iconPath) { searchParams.set("icon", iconPath); } diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index dbd3d6bc..bf66f797 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -64,6 +64,7 @@ import { projectEnvironmentVariablesQueryOptions, } from "../lib/environmentVariablesReactQuery"; import { normalizeProjectIconPath } from "../lib/projectIcons"; +import { useProjectIconFilePicker } from "../hooks/useProjectIconFilePicker"; import { updateProjectIconOverride } from "../lib/projectMeta"; import { getSelectableThreadProviders, @@ -444,6 +445,11 @@ function SettingsRouteView() { const activeProjectId = selectedProjectId ?? projects[0]?.id ?? null; const selectedProject = projects.find((project) => project.id === activeProjectId) ?? null; const [projectIconDraft, setProjectIconDraft] = useState(""); + const { fileInputRef, openFilePicker, handleFileChange } = useProjectIconFilePicker({ + onFileSelected: (dataUrl) => { + setProjectIconDraft(dataUrl); + }, + }); const selectedProjectEnvironmentVariablesQuery = useQuery( projectEnvironmentVariablesQueryOptions(activeProjectId), ); @@ -1861,6 +1867,23 @@ function SettingsRouteView() { aria-label="Project icon path" disabled={!selectedProject} /> + { + void handleFileChange(event); + }} + /> +