Skip to content

Commit e90cd6d

Browse files
authored
feat: improve file search (#27)
1 parent 11191c3 commit e90cd6d

File tree

11 files changed

+539
-169
lines changed

11 files changed

+539
-169
lines changed

src/main/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
2828
ipcRenderer.invoke("retrieve-api-key", encryptedKey),
2929
selectDirectory: (): Promise<string | null> =>
3030
ipcRenderer.invoke("select-directory"),
31+
searchDirectories: (query: string, searchRoot?: string): Promise<string[]> =>
32+
ipcRenderer.invoke("search-directories", query, searchRoot),
3133
validateRepo: (directoryPath: string): Promise<boolean> =>
3234
ipcRenderer.invoke("validate-repo", directoryPath),
3335
checkWriteAccess: (directoryPath: string): Promise<boolean> =>

src/main/services/os.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { exec } from "node:child_process";
22
import fs from "node:fs";
3+
import os from "node:os";
34
import path from "node:path";
45
import { promisify } from "node:util";
56
import {
@@ -113,4 +114,48 @@ export function registerOsIpc(getMainWindow: () => BrowserWindow | null): void {
113114
await shell.openExternal(url);
114115
},
115116
);
117+
118+
ipcMain.handle(
119+
"search-directories",
120+
async (_event: IpcMainInvokeEvent, query: string): Promise<string[]> => {
121+
if (!query?.trim()) {
122+
return [];
123+
}
124+
125+
let searchPath = query.trim();
126+
if (searchPath.startsWith("~")) {
127+
searchPath = searchPath.replace(/^~/, os.homedir());
128+
}
129+
130+
const lastSlashIdx = searchPath.lastIndexOf("/");
131+
const basePath =
132+
lastSlashIdx === -1 ? "" : searchPath.substring(0, lastSlashIdx + 1);
133+
const searchTerm =
134+
lastSlashIdx === -1
135+
? searchPath
136+
: searchPath.substring(lastSlashIdx + 1);
137+
const pathToRead = basePath || os.homedir();
138+
139+
try {
140+
const entries = await fsPromises.readdir(pathToRead, {
141+
withFileTypes: true,
142+
});
143+
let directories = entries.filter((entry) => entry.isDirectory());
144+
145+
if (searchTerm) {
146+
const searchLower = searchTerm.toLowerCase();
147+
directories = directories.filter((dir) =>
148+
dir.name.toLowerCase().includes(searchLower),
149+
);
150+
}
151+
152+
return directories
153+
.map((dir) => path.join(pathToRead, dir.name))
154+
.sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
155+
.slice(0, 20);
156+
} catch {
157+
return [];
158+
}
159+
},
160+
);
116161
}

src/renderer/components/Combobox.tsx

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons";
2-
import { Button, Flex, Popover, Text } from "@radix-ui/themes";
2+
import { Box, Button, Flex, Popover, Text, TextField } from "@radix-ui/themes";
33
import { type ReactNode, useMemo, useState } from "react";
44
import { Command } from "./command";
55

@@ -38,6 +38,7 @@ export function Combobox({
3838
align = "start",
3939
}: ComboboxProps) {
4040
const [open, setOpen] = useState(false);
41+
const [search, setSearch] = useState("");
4142

4243
const selectedItem = useMemo(() => {
4344
if (!value) return null;
@@ -49,10 +50,24 @@ export function Combobox({
4950
const handleSelect = (selectedValue: string) => {
5051
onValueChange(selectedValue);
5152
setOpen(false);
53+
setSearch("");
5254
};
5355

56+
const filteredItems = useMemo(() => {
57+
if (!search) return items;
58+
return items.filter((item) =>
59+
item.label.toLowerCase().includes(search.toLowerCase()),
60+
);
61+
}, [items, search]);
62+
5463
return (
55-
<Popover.Root open={open} onOpenChange={setOpen}>
64+
<Popover.Root
65+
open={open}
66+
onOpenChange={(newOpen) => {
67+
setOpen(newOpen);
68+
if (!newOpen) setSearch("");
69+
}}
70+
>
5671
<Popover.Trigger>
5772
<Button variant={variant} size={size} color="gray">
5873
<Flex justify="between" align="center" gap="2" width="100%">
@@ -65,18 +80,31 @@ export function Combobox({
6580
</Button>
6681
</Popover.Trigger>
6782
<Popover.Content side={side} align={align} style={{ padding: 0 }}>
68-
<Command.Root>
69-
<Command.Input placeholder={searchPlaceholder} autoFocus />
70-
<Command.List>
71-
<Command.Empty>{emptyMessage}</Command.Empty>
83+
<Command.Root shouldFilter={false}>
84+
<Box p="2" style={{ borderBottom: "1px solid var(--gray-a5)" }}>
85+
<TextField.Root
86+
placeholder={searchPlaceholder}
87+
value={search}
88+
onChange={(e) => setSearch(e.target.value)}
89+
autoFocus
90+
size={size}
91+
/>
92+
</Box>
93+
<Command.List style={{ maxHeight: "300px", overflowY: "auto" }}>
94+
<Command.Empty>
95+
<Box p="4">
96+
<Text size="2" color="gray">
97+
{emptyMessage}
98+
</Text>
99+
</Box>
100+
</Command.Empty>
72101
<Command.Group>
73-
{items.map((item) => {
102+
{filteredItems.map((item) => {
74103
const isSelected = value === item.value;
75104
return (
76105
<Command.Item
77106
key={item.value}
78107
value={item.value}
79-
keywords={[item.label]}
80108
onSelect={() => handleSelect(item.value)}
81109
>
82110
<Flex justify="between" align="center" gap="2" width="100%">
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { Folder } from "@phosphor-icons/react";
2+
import { ChevronDownIcon } from "@radix-ui/react-icons";
3+
import { Box, Button, Flex, Popover, Text, TextField } from "@radix-ui/themes";
4+
import { useEffect, useRef, useState } from "react";
5+
import { useHotkeys } from "react-hotkeys-hook";
6+
import { useFolderPickerStore } from "../stores/folderPickerStore";
7+
import { Command } from "./command";
8+
9+
interface FolderPickerProps {
10+
value: string;
11+
onChange: (path: string) => void;
12+
placeholder?: string;
13+
size?: "1" | "2" | "3";
14+
}
15+
16+
const HOTKEYS = {
17+
ARROW_UP: "arrowup",
18+
ARROW_DOWN: "arrowdown",
19+
ENTER: "enter",
20+
ESCAPE: "escape",
21+
} as const;
22+
23+
const MAX_RECENT_ITEMS = 5;
24+
const SEARCH_DEBOUNCE_MS = 100;
25+
const MAX_LIST_HEIGHT = "300px";
26+
27+
const displayPath = (path: string): string => {
28+
const homePattern = /^\/Users\/[^/]+|^\/home\/[^/]+/;
29+
const match = path.match(homePattern);
30+
return match ? path.replace(match[0], "~") : path;
31+
};
32+
33+
export function FolderPicker({
34+
value,
35+
onChange,
36+
placeholder = "Select folder...",
37+
size = "2",
38+
}: FolderPickerProps) {
39+
const [open, setOpen] = useState(false);
40+
const [searchValue, setSearchValue] = useState("");
41+
const [directoryPreview, setDirectoryPreview] = useState<string[]>([]);
42+
const [recentPreview, setRecentPreview] = useState<string[]>([]);
43+
const [selectedIndex, setSelectedIndex] = useState(0);
44+
const [isSearching, setIsSearching] = useState(false);
45+
const searchInputRef = useRef<HTMLInputElement>(null);
46+
47+
const { recentDirectories, addRecentDirectory } = useFolderPickerStore();
48+
49+
const displayValue = value ? displayPath(value) : placeholder;
50+
const totalItems = recentPreview.length + directoryPreview.length;
51+
52+
useHotkeys(
53+
Object.values(HOTKEYS).join(","),
54+
(ev, handler) => {
55+
const key = handler.keys?.join("");
56+
57+
if (key === HOTKEYS.ARROW_UP || key === HOTKEYS.ARROW_DOWN) {
58+
ev.preventDefault();
59+
if (totalItems > 0) {
60+
const direction = key === HOTKEYS.ARROW_UP ? -1 : 1;
61+
setSelectedIndex(
62+
(selectedIndex + direction + totalItems) % totalItems,
63+
);
64+
}
65+
return;
66+
}
67+
68+
if (key === HOTKEYS.ENTER) {
69+
ev.preventDefault();
70+
if (totalItems > 0) {
71+
const selectedPath =
72+
selectedIndex < recentPreview.length
73+
? recentPreview[selectedIndex]
74+
: directoryPreview[selectedIndex - recentPreview.length];
75+
76+
if (selectedPath) {
77+
handleSelect(selectedPath);
78+
}
79+
}
80+
return;
81+
}
82+
83+
if (key === HOTKEYS.ESCAPE) {
84+
ev.stopPropagation();
85+
setOpen(false);
86+
}
87+
},
88+
{ enabled: open, enableOnFormTags: true },
89+
);
90+
91+
useEffect(() => {
92+
if (!open) {
93+
setSearchValue("");
94+
setDirectoryPreview([]);
95+
setRecentPreview([]);
96+
setIsSearching(false);
97+
return;
98+
}
99+
100+
if (!searchValue.trim()) {
101+
setRecentPreview(recentDirectories.slice(0, MAX_RECENT_ITEMS));
102+
setDirectoryPreview([]);
103+
setIsSearching(false);
104+
return;
105+
}
106+
107+
setIsSearching(true);
108+
const timer = setTimeout(async () => {
109+
try {
110+
const results = await window.electronAPI.searchDirectories(searchValue);
111+
setDirectoryPreview(results);
112+
113+
const searchLower = searchValue.toLowerCase();
114+
const filtered = recentDirectories
115+
.filter((dir) => dir.toLowerCase().includes(searchLower))
116+
.slice(0, MAX_RECENT_ITEMS);
117+
setRecentPreview(filtered);
118+
} finally {
119+
setIsSearching(false);
120+
}
121+
}, SEARCH_DEBOUNCE_MS);
122+
123+
return () => clearTimeout(timer);
124+
}, [searchValue, recentDirectories, open]);
125+
126+
const handleSelect = (path: string) => {
127+
onChange(path);
128+
addRecentDirectory(path);
129+
setSearchValue("");
130+
setOpen(false);
131+
};
132+
133+
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
134+
setSearchValue(e.target.value);
135+
setSelectedIndex(0);
136+
};
137+
138+
const handleOpenChange = (newOpen: boolean) => {
139+
setOpen(newOpen);
140+
if (!newOpen) {
141+
setSearchValue("");
142+
setSelectedIndex(0);
143+
}
144+
};
145+
146+
const renderItem = (path: string, itemIndex: number) => (
147+
<Command.Item
148+
key={path}
149+
className={selectedIndex === itemIndex ? "!bg-accent-3" : ""}
150+
onSelect={() => handleSelect(path)}
151+
>
152+
<Text
153+
size="2"
154+
style={{
155+
overflow: "hidden",
156+
textOverflow: "ellipsis",
157+
}}
158+
>
159+
{displayPath(path)}
160+
</Text>
161+
</Command.Item>
162+
);
163+
164+
return (
165+
<Popover.Root open={open} onOpenChange={handleOpenChange}>
166+
<Popover.Trigger>
167+
<Button
168+
variant="surface"
169+
size={size}
170+
color="gray"
171+
style={{ width: "100%" }}
172+
>
173+
<Flex justify="between" align="center" gap="2" width="100%">
174+
<Flex align="center" gap="2" style={{ minWidth: 0, flex: 1 }}>
175+
<Folder size={16} weight="regular" style={{ flexShrink: 0 }} />
176+
<Text
177+
size={size}
178+
style={{
179+
overflow: "hidden",
180+
textOverflow: "ellipsis",
181+
whiteSpace: "nowrap",
182+
}}
183+
>
184+
{displayValue}
185+
</Text>
186+
</Flex>
187+
<ChevronDownIcon style={{ flexShrink: 0 }} />
188+
</Flex>
189+
</Button>
190+
</Popover.Trigger>
191+
192+
<Popover.Content
193+
side="bottom"
194+
align="start"
195+
avoidCollisions={false}
196+
style={{ padding: 0, width: "var(--radix-popover-trigger-width)" }}
197+
onOpenAutoFocus={() => {
198+
setTimeout(() => searchInputRef.current?.focus(), 0);
199+
}}
200+
>
201+
<Command.Root shouldFilter={false}>
202+
<Box p="2" style={{ borderBottom: "1px solid var(--gray-a5)" }}>
203+
<TextField.Root
204+
ref={searchInputRef}
205+
placeholder="Search folders..."
206+
value={searchValue}
207+
onChange={handleSearchChange}
208+
size={size}
209+
>
210+
<TextField.Slot>
211+
<Folder size={16} weight="regular" />
212+
</TextField.Slot>
213+
</TextField.Root>
214+
</Box>
215+
216+
<Command.List
217+
style={{ maxHeight: MAX_LIST_HEIGHT, overflowY: "auto" }}
218+
>
219+
{totalItems === 0 && (
220+
<Command.Empty>
221+
<Box p="4">
222+
<Text size="2" color="gray">
223+
{isSearching ? "Searching..." : "No folders found"}
224+
</Text>
225+
</Box>
226+
</Command.Empty>
227+
)}
228+
229+
{recentPreview.length > 0 && (
230+
<Command.Group heading="Recent Directories">
231+
{recentPreview.map((path, idx) => renderItem(path, idx))}
232+
</Command.Group>
233+
)}
234+
235+
{directoryPreview.length > 0 && (
236+
<Command.Group heading="Paths">
237+
{directoryPreview.map((path, idx) =>
238+
renderItem(path, recentPreview.length + idx),
239+
)}
240+
</Command.Group>
241+
)}
242+
</Command.List>
243+
</Command.Root>
244+
</Popover.Content>
245+
</Popover.Root>
246+
);
247+
}

0 commit comments

Comments
 (0)