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
29 changes: 3 additions & 26 deletions apps/server/src/projectFaviconRoute.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { PROJECT_ICON_FALLBACK_CANDIDATES } from "@okcode/shared/projectIcons";

const FAVICON_MIME_TYPES: Record<string, string> = {
".png": "image/png",
Expand All @@ -11,30 +12,6 @@ const FAVICON_MIME_TYPES: Record<string, string> = {

const FALLBACK_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;

// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
"favicon.svg",
"favicon.ico",
"favicon.png",
"public/favicon.svg",
"public/favicon.ico",
"public/favicon.png",
"app/favicon.ico",
"app/favicon.png",
"app/icon.svg",
"app/icon.png",
"app/icon.ico",
"src/favicon.ico",
"src/favicon.svg",
"src/app/favicon.ico",
"src/app/icon.svg",
"src/app/icon.png",
"assets/icon.svg",
"assets/icon.png",
"assets/logo.svg",
"assets/logo.png",
];

// Files that may contain a <link rel="icon"> or icon metadata declaration.
const ICON_SOURCE_FILES = [
"index.html",
Expand Down Expand Up @@ -173,11 +150,11 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons
};

const tryCandidates = (index: number): void => {
if (index >= FAVICON_CANDIDATES.length) {
if (index >= PROJECT_ICON_FALLBACK_CANDIDATES.length) {
trySourceFiles(0);
return;
}
const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!);
const candidate = path.join(projectCwd, PROJECT_ICON_FALLBACK_CANDIDATES[index]!);
if (!isPathWithinProject(projectCwd, candidate)) {
tryCandidates(index + 1);
return;
Expand Down
179 changes: 179 additions & 0 deletions apps/web/src/components/ProjectIconEditorDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useEffect, useRef, useState } from "react";
import type { Project } from "~/types";
import { readNativeApi } from "~/nativeApi";

import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "~/lib/projectIcons";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { ProjectIcon } from "./ProjectIcon";

export function ProjectIconEditorDialog({
project,
open,
onOpenChange,
onSave,
}: {
project: Project | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (iconPath: string | null) => Promise<void>;
}) {
const projectId = project?.id ?? null;
const projectCwd = project?.cwd ?? null;
const projectIconPath = normalizeProjectIconPath(project?.iconPath);
const [draft, setDraft] = useState("");
const [suggestedIconPath, setSuggestedIconPath] = useState<string | null>(null);
const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false);
const draftWasTouchedRef = useRef(false);

useEffect(() => {
if (!open || !projectId || !projectCwd) {
setDraft("");
setSuggestedIconPath(null);
setIsLoadingSuggestion(false);
draftWasTouchedRef.current = false;
return;
}

draftWasTouchedRef.current = false;
setDraft(projectIconPath ?? "");
setSuggestedIconPath(null);

if (projectIconPath) {
setIsLoadingSuggestion(false);
return;
}

const api = readNativeApi();
if (!api) {
setIsLoadingSuggestion(false);
return;
}

let cancelled = false;
setIsLoadingSuggestion(true);
void resolveSuggestedProjectIconPath(api, projectCwd)
.then((nextSuggestion) => {
if (cancelled) return;
setSuggestedIconPath(nextSuggestion);
if (!draftWasTouchedRef.current && !projectIconPath && nextSuggestion) {
setDraft(nextSuggestion);
}
})
.catch(() => {
if (!cancelled) {
setSuggestedIconPath(null);
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingSuggestion(false);
}
});

return () => {
cancelled = true;
};
}, [open, projectCwd, projectIconPath, projectId]);

const resolvedDraft = normalizeProjectIconPath(draft);
const currentValue = projectIconPath;
const canSave = Boolean(project) && resolvedDraft !== currentValue;
const effectivePreviewIconPath = resolvedDraft ?? suggestedIconPath ?? currentValue ?? null;

if (!project || !projectId || !projectCwd) {
return null;
}

const commit = async (iconPath: string | null) => {
await onSave(iconPath);
onOpenChange(false);
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Project icon</DialogTitle>
<DialogDescription>
Set a path relative to the project root. Leave it blank to fall back to the detected
favicon or icon file.
</DialogDescription>
</DialogHeader>

<div className="space-y-4 px-6 pb-2">
<div className="flex items-center gap-3 rounded-lg border border-border/70 bg-muted/40 p-3">
<ProjectIcon
cwd={project.cwd}
iconPath={effectivePreviewIconPath}
className="size-10 rounded-md"
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{project.name}</div>
<div className="text-xs text-muted-foreground">
{isLoadingSuggestion
? "Looking for an icon file..."
: suggestedIconPath
? `Suggested: ${suggestedIconPath}`
: "No obvious icon file found. Leave blank to use the fallback icon."}
</div>
</div>
</div>

<div className="space-y-2">
<label
className="text-xs font-medium text-muted-foreground"
htmlFor="project-icon-path"
>
Icon path
</label>
<Input
id="project-icon-path"
value={draft}
onChange={(event) => {
draftWasTouchedRef.current = true;
setDraft(event.target.value);
}}
placeholder={suggestedIconPath ?? "public/favicon.svg"}
autoComplete="off"
spellCheck={false}
/>
</div>
</div>

<DialogFooter>
<Button
variant="outline"
onClick={() => {
void commit(null);
}}
disabled={!project}
>
Use auto-detected
</Button>
<div className="ms-auto flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => {
void commit(resolvedDraft);
}}
disabled={!project || !canSave}
>
Save icon
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
1 change: 1 addition & 0 deletions apps/web/src/components/Sidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("Sidebar file tree shortcut", () => {
it("uses the project context menu for renaming instead of double click", () => {
const src = readFileSync(resolve(import.meta.dirname, "./Sidebar.tsx"), "utf8");

expect(src).toContain('{ id: "edit-icon", label: "Change project icon" }');
expect(src).toContain('{ id: "rename", label: "Rename project" }');
expect(src).toContain("onContextMenu={(event) => {");
expect(src).not.toContain("onDoubleClick={(e) => {");
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from "react";
import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog";
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
import { ProjectIconEditorDialog } from "~/components/ProjectIconEditorDialog";
import { ProjectIcon } from "~/components/ProjectIcon";
import { useClientMode } from "~/hooks/useClientMode";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
Expand All @@ -67,6 +68,8 @@ import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor";
import { useTheme } from "~/hooks/useTheme";
import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
import { resolveImportedProjectScripts } from "~/lib/projectImport";
import { normalizeProjectIconPath } from "~/lib/projectIcons";
import { updateProjectIconOverride } from "~/lib/projectMeta";
import { getProjectColor } from "~/projectColors";
import { useRightPanelStore } from "~/rightPanelStore";
import {
Expand Down Expand Up @@ -586,6 +589,10 @@ export default function Sidebar() {
const [addProjectError, setAddProjectError] = useState<string | null>(null);
const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false);
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
const [projectIconDialogOpen, setProjectIconDialogOpen] = useState(false);
const [projectIconDialogProjectId, setProjectIconDialogProjectId] = useState<ProjectId | null>(
null,
);
const addProjectInputRef = useRef<HTMLInputElement | null>(null);
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet<ProjectId>
Expand Down Expand Up @@ -634,6 +641,9 @@ export default function Sidebar() {
() => new Map(projects.map((project) => [project.id, project] as const)),
[projects],
);
const projectIconDialogProject = projectIconDialogProjectId
? (projectById.get(projectIconDialogProjectId) ?? null)
: null;
const projectCwdById = useMemo(
() => new Map(projects.map((project) => [project.id, project.cwd] as const)),
[projects],
Expand Down Expand Up @@ -708,6 +718,18 @@ export default function Sidebar() {
lastAutoExpandedThreadIdRef.current = routeThreadId;
setProjectExpanded(activeProjectId, true);
}, [activeProjectId, routeThreadId, setProjectExpanded]);

useEffect(() => {
if (!projectIconDialogProjectId) {
return;
}
if (projectById.has(projectIconDialogProjectId)) {
return;
}
setProjectIconDialogOpen(false);
setProjectIconDialogProjectId(null);
}, [projectById, projectIconDialogProjectId]);

const threadGitTargets = useMemo(
() =>
sidebarThreads.map((thread) => ({
Expand Down Expand Up @@ -1241,12 +1263,21 @@ export default function Sidebar() {
if (!api) return;
const clicked = await api.contextMenu.show(
[
{ id: "edit-icon", label: "Change project icon" },
{ id: "rename", label: "Rename project" },
{ id: "delete", label: "Remove project", destructive: true },
],
position,
);

if (clicked === "edit-icon") {
if (projectById.has(projectId)) {
setProjectIconDialogProjectId(projectId);
setProjectIconDialogOpen(true);
}
return;
}

if (clicked === "rename") {
const project = projectById.get(projectId);
if (!project) return;
Expand Down Expand Up @@ -1301,6 +1332,8 @@ export default function Sidebar() {
clearProjectDraftThreadId,
getDraftThreadByProjectId,
projectById,
setProjectIconDialogOpen,
setProjectIconDialogProjectId,
sortedThreadsByProjectId,
startProjectEditing,
],
Expand Down Expand Up @@ -1872,10 +1905,37 @@ export default function Sidebar() {
});
}, []);

const saveProjectIconOverrideFromDialog = useCallback(
async (iconPath: string | null) => {
if (!projectIconDialogProject) {
return;
}
const api = readNativeApi();
if (!api) {
return;
}

const currentIconPath = normalizeProjectIconPath(projectIconDialogProject.iconPath);
const nextIconPath = normalizeProjectIconPath(iconPath);
if (currentIconPath === nextIconPath) {
return;
}

await updateProjectIconOverride(api, projectIconDialogProject.id, nextIconPath);
},
[projectIconDialogProject],
);

const wordmark = <SidebarTrigger className="shrink-0 md:hidden" />;

return (
<>
<ProjectIconEditorDialog
project={projectIconDialogProject}
open={projectIconDialogOpen}
onOpenChange={setProjectIconDialogOpen}
onSave={saveProjectIconOverrideFromDialog}
/>
{isElectron ? (
<>
<SidebarHeader className="drag-region h-[42px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
Expand Down
Loading
Loading