| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 4 | import { ImageIcon } from "lucide-react"; |
| 5 | import { useMemo, useState } from "react"; |
| 6 | |
| 7 | import { useAssetUrlState } from "~/assets/assetUrls"; |
| 8 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 9 | import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils"; |
| 10 | import { CommandPaletteContent } from "../CommandPaletteContent"; |
| 11 | import type { CommandPaletteActionItem } from "../CommandPalette.logic"; |
| 12 | import { CommandPaletteResults } from "../CommandPaletteResults"; |
| 13 | import { |
| 14 | getProjectFilePickerMatches, |
| 15 | PROJECT_FILE_PICKER_RESULT_LIMIT, |
| 16 | } from "../files/ProjectFilePicker.logic"; |
| 17 | import { useProjectFilePickerQuery } from "../files/projectFilesQueryState"; |
| 18 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "../ui/command"; |
| 19 | import { toastManager } from "../ui/toast"; |
| 20 | |
| 21 | function 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 | } |
| 26 | export function canPickExternalProjectFavicon(cwd: string, platform: string): boolean { |
| 27 | return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd); |
| 28 | } |
| 29 | |
| 30 | function 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 | |
| 75 | export 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 | } |