Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 1 addition & 17 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -70,23 +71,6 @@ export interface IssueDialogState {
key: number;
}

export function readFileAsDataUrl(file: File): Promise<string> {
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();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ProjectIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export function ProjectIcon({
iconPath?: string | null | undefined;
className?: string;
}) {
const resolvedIconPath = iconPath?.trim();
return (
<img
src={resolveProjectIconUrl({ cwd, iconPath })}
src={resolveProjectIconUrl({ cwd, iconPath: resolvedIconPath })}
alt=""
aria-hidden="true"
loading="lazy"
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/ProjectIconEditorDialog.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import "../index.css";

import { ProjectId, type NativeApi } from "@okcode/contracts";
import type { Project } from "~/types";
import { page } from "vitest/browser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-react";

import { ProjectIconEditorDialog } from "./ProjectIconEditorDialog";

function makeProject(): Project {
return {
id: ProjectId.makeUnsafe("project-1"),
name: "Project One",
cwd: "/repo/project",
model: "codex-gpt-5.2",
expanded: false,
scripts: [],
iconPath: null,
};
}

function mockNativeApi() {
(window as Window & { nativeApi?: NativeApi }).nativeApi = {
projects: {
searchEntries: vi.fn(async () => ({ 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(
<ProjectIconEditorDialog
project={makeProject()}
open
onOpenChange={vi.fn()}
onSave={onSave}
/>,
);

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();
}
});
});
52 changes: 37 additions & 15 deletions apps/web/src/components/ProjectIconEditorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -33,6 +34,12 @@ export function ProjectIconEditorDialog({
const [suggestedIconPath, setSuggestedIconPath] = useState<string | null>(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) {
Expand Down Expand Up @@ -104,8 +111,8 @@ export function ProjectIconEditorDialog({
<DialogHeader>
<DialogTitle>Project icon</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>

Expand Down Expand Up @@ -135,19 +142,34 @@ export function ProjectIconEditorDialog({
>
Icon path
</label>
<Input
id="project-icon-path"
value={draft}
onChange={(event) => {
draftWasTouchedRef.current = true;
setDraft(event.target.value);
}}
placeholder={
suggestedIconPath ?? "public/favicon.svg or https://example.com/icon.png"
}
autoComplete="off"
spellCheck={false}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
id="project-icon-path"
value={draft}
onChange={(event) => {
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}
/>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleFileChange(event);
}}
/>
<Button type="button" variant="outline" onClick={openFilePicker}>
Choose image
</Button>
</div>
</div>
</div>

Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/hooks/useProjectIconFilePicker.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const { onFileSelected } = options;

const openFilePicker = useCallback(() => {
fileInputRef.current?.click();
}, []);

const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
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,
};
}
16 changes: 16 additions & 0 deletions apps/web/src/lib/fileData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function readFileAsDataUrl(file: File): Promise<string> {
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);
});
}
17 changes: 16 additions & 1 deletion apps/web/src/lib/projectIcons.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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") {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/lib/projectIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/routes/_chat.settings.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -1861,6 +1867,23 @@ function SettingsRouteView() {
aria-label="Project icon path"
disabled={!selectedProject}
/>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleFileChange(event);
}}
/>
<Button
size="sm"
variant="outline"
disabled={!selectedProject}
onClick={openFilePicker}
>
Choose image
</Button>
<Button
size="sm"
variant="outline"
Expand Down
Loading