Skip to content

Commit 964ab10

Browse files
authored
Add project icon context menu and shared icon helpers (#448)
* Add project icon context menu and shared icon helpers - add a sidebar menu action and dialog to edit project icons - share icon fallback/discovery constants between server and web - normalize project icon paths in settings and save flow * Bump workspace versions to 0.23.3 - Update all package manifests recorded in bun.lock - Keep workspace versions aligned across apps and packages
1 parent 56e93c7 commit 964ab10

12 files changed

Lines changed: 376 additions & 49 deletions

File tree

apps/server/src/projectFaviconRoute.ts

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs";
22
import http from "node:http";
33
import path from "node:path";
4+
import { PROJECT_ICON_FALLBACK_CANDIDATES } from "@okcode/shared/projectIcons";
45

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

1213
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>`;
1314

14-
// Well-known favicon paths checked in order.
15-
const FAVICON_CANDIDATES = [
16-
"favicon.svg",
17-
"favicon.ico",
18-
"favicon.png",
19-
"public/favicon.svg",
20-
"public/favicon.ico",
21-
"public/favicon.png",
22-
"app/favicon.ico",
23-
"app/favicon.png",
24-
"app/icon.svg",
25-
"app/icon.png",
26-
"app/icon.ico",
27-
"src/favicon.ico",
28-
"src/favicon.svg",
29-
"src/app/favicon.ico",
30-
"src/app/icon.svg",
31-
"src/app/icon.png",
32-
"assets/icon.svg",
33-
"assets/icon.png",
34-
"assets/logo.svg",
35-
"assets/logo.png",
36-
];
37-
3815
// Files that may contain a <link rel="icon"> or icon metadata declaration.
3916
const ICON_SOURCE_FILES = [
4017
"index.html",
@@ -173,11 +150,11 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons
173150
};
174151

175152
const tryCandidates = (index: number): void => {
176-
if (index >= FAVICON_CANDIDATES.length) {
153+
if (index >= PROJECT_ICON_FALLBACK_CANDIDATES.length) {
177154
trySourceFiles(0);
178155
return;
179156
}
180-
const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!);
157+
const candidate = path.join(projectCwd, PROJECT_ICON_FALLBACK_CANDIDATES[index]!);
181158
if (!isPathWithinProject(projectCwd, candidate)) {
182159
tryCandidates(index + 1);
183160
return;
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import type { Project } from "~/types";
3+
import { readNativeApi } from "~/nativeApi";
4+
5+
import { normalizeProjectIconPath, resolveSuggestedProjectIconPath } from "~/lib/projectIcons";
6+
import { Button } from "./ui/button";
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogDescription,
11+
DialogFooter,
12+
DialogHeader,
13+
DialogTitle,
14+
} from "./ui/dialog";
15+
import { Input } from "./ui/input";
16+
import { ProjectIcon } from "./ProjectIcon";
17+
18+
export function ProjectIconEditorDialog({
19+
project,
20+
open,
21+
onOpenChange,
22+
onSave,
23+
}: {
24+
project: Project | null;
25+
open: boolean;
26+
onOpenChange: (open: boolean) => void;
27+
onSave: (iconPath: string | null) => Promise<void>;
28+
}) {
29+
const projectId = project?.id ?? null;
30+
const projectCwd = project?.cwd ?? null;
31+
const projectIconPath = normalizeProjectIconPath(project?.iconPath);
32+
const [draft, setDraft] = useState("");
33+
const [suggestedIconPath, setSuggestedIconPath] = useState<string | null>(null);
34+
const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false);
35+
const draftWasTouchedRef = useRef(false);
36+
37+
useEffect(() => {
38+
if (!open || !projectId || !projectCwd) {
39+
setDraft("");
40+
setSuggestedIconPath(null);
41+
setIsLoadingSuggestion(false);
42+
draftWasTouchedRef.current = false;
43+
return;
44+
}
45+
46+
draftWasTouchedRef.current = false;
47+
setDraft(projectIconPath ?? "");
48+
setSuggestedIconPath(null);
49+
50+
if (projectIconPath) {
51+
setIsLoadingSuggestion(false);
52+
return;
53+
}
54+
55+
const api = readNativeApi();
56+
if (!api) {
57+
setIsLoadingSuggestion(false);
58+
return;
59+
}
60+
61+
let cancelled = false;
62+
setIsLoadingSuggestion(true);
63+
void resolveSuggestedProjectIconPath(api, projectCwd)
64+
.then((nextSuggestion) => {
65+
if (cancelled) return;
66+
setSuggestedIconPath(nextSuggestion);
67+
if (!draftWasTouchedRef.current && !projectIconPath && nextSuggestion) {
68+
setDraft(nextSuggestion);
69+
}
70+
})
71+
.catch(() => {
72+
if (!cancelled) {
73+
setSuggestedIconPath(null);
74+
}
75+
})
76+
.finally(() => {
77+
if (!cancelled) {
78+
setIsLoadingSuggestion(false);
79+
}
80+
});
81+
82+
return () => {
83+
cancelled = true;
84+
};
85+
}, [open, projectCwd, projectIconPath, projectId]);
86+
87+
const resolvedDraft = normalizeProjectIconPath(draft);
88+
const currentValue = projectIconPath;
89+
const canSave = Boolean(project) && resolvedDraft !== currentValue;
90+
const effectivePreviewIconPath = resolvedDraft ?? suggestedIconPath ?? currentValue ?? null;
91+
92+
if (!project || !projectId || !projectCwd) {
93+
return null;
94+
}
95+
96+
const commit = async (iconPath: string | null) => {
97+
await onSave(iconPath);
98+
onOpenChange(false);
99+
};
100+
101+
return (
102+
<Dialog open={open} onOpenChange={onOpenChange}>
103+
<DialogContent className="max-w-xl">
104+
<DialogHeader>
105+
<DialogTitle>Project icon</DialogTitle>
106+
<DialogDescription>
107+
Set a path relative to the project root. Leave it blank to fall back to the detected
108+
favicon or icon file.
109+
</DialogDescription>
110+
</DialogHeader>
111+
112+
<div className="space-y-4 px-6 pb-2">
113+
<div className="flex items-center gap-3 rounded-lg border border-border/70 bg-muted/40 p-3">
114+
<ProjectIcon
115+
cwd={project.cwd}
116+
iconPath={effectivePreviewIconPath}
117+
className="size-10 rounded-md"
118+
/>
119+
<div className="min-w-0">
120+
<div className="truncate text-sm font-medium">{project.name}</div>
121+
<div className="text-xs text-muted-foreground">
122+
{isLoadingSuggestion
123+
? "Looking for an icon file..."
124+
: suggestedIconPath
125+
? `Suggested: ${suggestedIconPath}`
126+
: "No obvious icon file found. Leave blank to use the fallback icon."}
127+
</div>
128+
</div>
129+
</div>
130+
131+
<div className="space-y-2">
132+
<label
133+
className="text-xs font-medium text-muted-foreground"
134+
htmlFor="project-icon-path"
135+
>
136+
Icon path
137+
</label>
138+
<Input
139+
id="project-icon-path"
140+
value={draft}
141+
onChange={(event) => {
142+
draftWasTouchedRef.current = true;
143+
setDraft(event.target.value);
144+
}}
145+
placeholder={suggestedIconPath ?? "public/favicon.svg"}
146+
autoComplete="off"
147+
spellCheck={false}
148+
/>
149+
</div>
150+
</div>
151+
152+
<DialogFooter>
153+
<Button
154+
variant="outline"
155+
onClick={() => {
156+
void commit(null);
157+
}}
158+
disabled={!project}
159+
>
160+
Use auto-detected
161+
</Button>
162+
<div className="ms-auto flex items-center gap-2">
163+
<Button variant="outline" onClick={() => onOpenChange(false)}>
164+
Cancel
165+
</Button>
166+
<Button
167+
onClick={() => {
168+
void commit(resolvedDraft);
169+
}}
170+
disabled={!project || !canSave}
171+
>
172+
Save icon
173+
</Button>
174+
</div>
175+
</DialogFooter>
176+
</DialogContent>
177+
</Dialog>
178+
);
179+
}

apps/web/src/components/Sidebar.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe("Sidebar file tree shortcut", () => {
1414
it("uses the project context menu for renaming instead of double click", () => {
1515
const src = readFileSync(resolve(import.meta.dirname, "./Sidebar.tsx"), "utf8");
1616

17+
expect(src).toContain('{ id: "edit-icon", label: "Change project icon" }');
1718
expect(src).toContain('{ id: "rename", label: "Rename project" }');
1819
expect(src).toContain("onContextMenu={(event) => {");
1920
expect(src).not.toContain("onDoubleClick={(e) => {");

apps/web/src/components/Sidebar.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
} from "react";
6060
import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog";
6161
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
62+
import { ProjectIconEditorDialog } from "~/components/ProjectIconEditorDialog";
6263
import { ProjectIcon } from "~/components/ProjectIcon";
6364
import { useClientMode } from "~/hooks/useClientMode";
6465
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
@@ -67,6 +68,8 @@ import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor";
6768
import { useTheme } from "~/hooks/useTheme";
6869
import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
6970
import { resolveImportedProjectScripts } from "~/lib/projectImport";
71+
import { normalizeProjectIconPath } from "~/lib/projectIcons";
72+
import { updateProjectIconOverride } from "~/lib/projectMeta";
7073
import { getProjectColor } from "~/projectColors";
7174
import { useRightPanelStore } from "~/rightPanelStore";
7275
import {
@@ -586,6 +589,10 @@ export default function Sidebar() {
586589
const [addProjectError, setAddProjectError] = useState<string | null>(null);
587590
const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false);
588591
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
592+
const [projectIconDialogOpen, setProjectIconDialogOpen] = useState(false);
593+
const [projectIconDialogProjectId, setProjectIconDialogProjectId] = useState<ProjectId | null>(
594+
null,
595+
);
589596
const addProjectInputRef = useRef<HTMLInputElement | null>(null);
590597
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
591598
ReadonlySet<ProjectId>
@@ -634,6 +641,9 @@ export default function Sidebar() {
634641
() => new Map(projects.map((project) => [project.id, project] as const)),
635642
[projects],
636643
);
644+
const projectIconDialogProject = projectIconDialogProjectId
645+
? (projectById.get(projectIconDialogProjectId) ?? null)
646+
: null;
637647
const projectCwdById = useMemo(
638648
() => new Map(projects.map((project) => [project.id, project.cwd] as const)),
639649
[projects],
@@ -708,6 +718,18 @@ export default function Sidebar() {
708718
lastAutoExpandedThreadIdRef.current = routeThreadId;
709719
setProjectExpanded(activeProjectId, true);
710720
}, [activeProjectId, routeThreadId, setProjectExpanded]);
721+
722+
useEffect(() => {
723+
if (!projectIconDialogProjectId) {
724+
return;
725+
}
726+
if (projectById.has(projectIconDialogProjectId)) {
727+
return;
728+
}
729+
setProjectIconDialogOpen(false);
730+
setProjectIconDialogProjectId(null);
731+
}, [projectById, projectIconDialogProjectId]);
732+
711733
const threadGitTargets = useMemo(
712734
() =>
713735
sidebarThreads.map((thread) => ({
@@ -1241,12 +1263,21 @@ export default function Sidebar() {
12411263
if (!api) return;
12421264
const clicked = await api.contextMenu.show(
12431265
[
1266+
{ id: "edit-icon", label: "Change project icon" },
12441267
{ id: "rename", label: "Rename project" },
12451268
{ id: "delete", label: "Remove project", destructive: true },
12461269
],
12471270
position,
12481271
);
12491272

1273+
if (clicked === "edit-icon") {
1274+
if (projectById.has(projectId)) {
1275+
setProjectIconDialogProjectId(projectId);
1276+
setProjectIconDialogOpen(true);
1277+
}
1278+
return;
1279+
}
1280+
12501281
if (clicked === "rename") {
12511282
const project = projectById.get(projectId);
12521283
if (!project) return;
@@ -1315,6 +1346,8 @@ export default function Sidebar() {
13151346
deleteThread,
13161347
getDraftThreadByProjectId,
13171348
projectById,
1349+
setProjectIconDialogOpen,
1350+
setProjectIconDialogProjectId,
13181351
sortedThreadsByProjectId,
13191352
startProjectEditing,
13201353
],
@@ -1886,10 +1919,37 @@ export default function Sidebar() {
18861919
});
18871920
}, []);
18881921

1922+
const saveProjectIconOverrideFromDialog = useCallback(
1923+
async (iconPath: string | null) => {
1924+
if (!projectIconDialogProject) {
1925+
return;
1926+
}
1927+
const api = readNativeApi();
1928+
if (!api) {
1929+
return;
1930+
}
1931+
1932+
const currentIconPath = normalizeProjectIconPath(projectIconDialogProject.iconPath);
1933+
const nextIconPath = normalizeProjectIconPath(iconPath);
1934+
if (currentIconPath === nextIconPath) {
1935+
return;
1936+
}
1937+
1938+
await updateProjectIconOverride(api, projectIconDialogProject.id, nextIconPath);
1939+
},
1940+
[projectIconDialogProject],
1941+
);
1942+
18891943
const wordmark = <SidebarTrigger className="shrink-0 md:hidden" />;
18901944

18911945
return (
18921946
<>
1947+
<ProjectIconEditorDialog
1948+
project={projectIconDialogProject}
1949+
open={projectIconDialogOpen}
1950+
onOpenChange={setProjectIconDialogOpen}
1951+
onSave={saveProjectIconOverrideFromDialog}
1952+
/>
18931953
{isElectron ? (
18941954
<>
18951955
<SidebarHeader className="drag-region h-[42px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">

0 commit comments

Comments
 (0)