1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { isWindowsAbsolutePath } from "@t3tools/shared/path";
4import { ImageIcon } from "lucide-react";
5import { useMemo, useState } from "react";
6
7import { useAssetUrlState } from "~/assets/assetUrls";
8import { primaryServerKeybindingsAtom } from "~/state/server";
9import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils";
10import { CommandPaletteContent } from "../CommandPaletteContent";
11import type { CommandPaletteActionItem } from "../CommandPalette.logic";
12import { CommandPaletteResults } from "../CommandPaletteResults";
13import {
14 getProjectFilePickerMatches,
15 PROJECT_FILE_PICKER_RESULT_LIMIT,
16} from "../files/ProjectFilePicker.logic";
17import { useProjectFilePickerQuery } from "../files/projectFilesQueryState";
18import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "../ui/command";
19import { toastManager } from "../ui/toast";
20
21function emptyMessage(query: string, error: string | null, isPending: boolean): string {
22 if (error) return error;
23 if (isPending) return query.trim() ? "Searching project files…" : "Indexing project files…";
24 return query.trim() ? "No matching image files." : "No image files found.";
25}
26export function canPickExternalProjectFavicon(cwd: string, platform: string): boolean {
27 return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd);
28}
29
30function ProjectIconFilePreview(props: {
31 readonly environmentId: EnvironmentId;
32 readonly cwd: string;
33 readonly path: string;
34}) {
35 const asset = useAssetUrlState(props.environmentId, {
36 _tag: "draft-workspace-file",
37 cwd: props.cwd,
38 path: props.path,
39 });
40 const [failedUrl, setFailedUrl] = useState<string | null>(null);
41 const src = asset._tag === "Success" && asset.url !== failedUrl ? asset.url : null;
42
43 return (
44 <span
45 aria-hidden="true"
46 className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted"
47 style={
48 src
49 ? {
50 backgroundImage:
51 "repeating-conic-gradient(color-mix(in srgb, var(--foreground) 40%, var(--background)) 0 25%, color-mix(in srgb, var(--foreground) 60%, var(--background)) 0 50%)",
52 backgroundSize: "8px 8px",
53 }
54 : undefined
55 }
56 >
57 {src ? (
58 <img
59 src={src}
60 alt=""
61 width={32}
62 height={32}
63 loading="lazy"
64 decoding="async"
65 className="size-full object-contain"
66 onError={() => setFailedUrl(src)}
67 />
68 ) : (
69 <ImageIcon className="size-4 text-muted-foreground" />
70 )}
71 </span>
72 );
73}
74
75export function ProjectFaviconPickerDialog(props: {
76 readonly cwd: string;
77 readonly environmentId: EnvironmentId;
78 readonly onOpenChange: (open: boolean) => void;
79 readonly onPickExternal?: () => Promise<string | null>;
80 readonly onSelect: (path: string) => void;
81 readonly open: boolean;
82 readonly projectName: string;
83}) {
84 const { cwd, environmentId, onSelect } = props;
85 const [query, setQuery] = useState("");
86 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
87 const [isPickingExternal, setIsPickingExternal] = useState(false);
88 const result = useProjectFilePickerQuery(
89 environmentId,
90 cwd,
91 query,
92 PROJECT_FILE_PICKER_RESULT_LIMIT,
93 { imageOnly: true },
94 );
95 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
96 const pickExternal = props.onPickExternal;
97 const fileManagerName = getLocalFileManagerName(
98 typeof navigator === "undefined" ? "" : navigator.platform,
99 );
100 const items = useMemo<CommandPaletteActionItem[]>(
101 () =>
102 getProjectFilePickerMatches(result.entries, result.matchedQuery).map((match) => ({
103 kind: "action",
104 value: `project-favicon:${match.path}`,
105 searchTerms: [match.name, match.path],
106 title: match.name,
107 description: match.path,
108 icon: <ProjectIconFilePreview environmentId={environmentId} cwd={cwd} path={match.path} />,
109 run: async () => onSelect(match.path),
110 })),
111 [environmentId, cwd, onSelect, result.entries, result.matchedQuery],
112 );
113
114 return (
115 <CommandDialog open={props.open} onOpenChange={props.onOpenChange}>
116 {props.open ? (
117 <CommandDialogPopup
118 aria-label="Choose project icon"
119 className="overflow-hidden"
120 onBackdropPointerDown={() => props.onOpenChange(false)}
121 >
122 <CommandPaletteContent
123 aria-label="Choose project icon"
124 autoHighlight="always"
125 escapeLabel="Close"
126 footerActionLabel="Select icon"
127 footerTrailing={
128 pickExternal ? (
129 <CommandFooterAction
130 disabled={isPickingExternal}
131 onClick={() => {
132 setIsPickingExternal(true);
133 void pickExternal()
134 .then((path) => {
135 if (!path) return;
136 props.onOpenChange(false);
137 props.onSelect(path);
138 })
139 .catch((error: unknown) => {
140 toastManager.add({
141 type: "error",
142 title: "Could not open image picker",
143 description:
144 error instanceof Error ? error.message : "An error occurred.",
145 });
146 })
147 .finally(() => setIsPickingExternal(false));
148 }}
149 >
150 {`Open in ${fileManagerName}`}
151 </CommandFooterAction>
152 ) : null
153 }
154 inputProps={{ placeholder: "Search image files…" }}
155 mode="none"
156 onItemHighlighted={(value) => {
157 setHighlightedItemValue(typeof value === "string" ? value : null);
158 }}
159 onValueChange={(value) => {
160 setHighlightedItemValue(null);
161 setQuery(value);
162 }}
163 panelSize="tall-list"
164 testId="project-favicon-picker"
165 value={query}
166 >
167 <CommandPaletteResults
168 groups={
169 items.length > 0
170 ? [{ value: "project-favicon-files", label: props.projectName, items }]
171 : []
172 }
173 highlightedItemValue={highlightedItemValue}
174 isActionsOnly={false}
175 keybindings={keybindings}
176 onExecuteItem={(item) => {
177 if (item.kind !== "action") return;
178 props.onOpenChange(false);
179 void item.run();
180 }}
181 emptyStateMessage={emptyMessage(query, result.error, result.isPending)}
182 />
183 </CommandPaletteContent>
184 </CommandDialogPopup>
185 ) : null}
186 </CommandDialog>
187 );
188}