Skip to content

Commit e04ca1a

Browse files
authored
feat: Unify the @ file search behavior with the file picker (#629)
1 parent ebe332e commit e04ca1a

6 files changed

Lines changed: 123 additions & 125 deletions

File tree

apps/twig/src/renderer/features/command/components/FilePicker.tsx

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { FileIcon } from "@components/ui/FileIcon";
22
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
3+
import { pathToFileItem, searchFiles, useRepoFiles } from "@hooks/useRepoFiles";
34
import { Popover, Text } from "@radix-ui/themes";
4-
import { trpcReact } from "@renderer/trpc/client";
5-
import { byLengthAsc, Fzf } from "fzf";
65
import { useCallback, useMemo, useState } from "react";
76
import { Command } from "./Command";
87
import "./FilePicker.css";
@@ -14,27 +13,6 @@ interface FilePickerProps {
1413
repoPath: string | undefined;
1514
}
1615

17-
interface FileItem {
18-
path: string;
19-
name: string;
20-
dir: string;
21-
}
22-
23-
const FILE_DISPLAY_LIMIT = 20;
24-
25-
function searchFiles(
26-
fzf: Fzf<FileItem[]>,
27-
files: FileItem[],
28-
query: string,
29-
): FileItem[] {
30-
if (!query.trim()) {
31-
return files.slice(0, FILE_DISPLAY_LIMIT);
32-
}
33-
34-
const results = fzf.find(query);
35-
return results.map((result) => result.item);
36-
}
37-
3816
export function FilePicker({
3917
open,
4018
onOpenChange,
@@ -57,42 +35,11 @@ export function FilePicker({
5735
[onOpenChange],
5836
);
5937

60-
const { data: allFiles } = trpcReact.fs.listRepoFiles.useQuery(
61-
{ repoPath: repoPath ?? "" },
62-
{ enabled: open && !!repoPath },
63-
);
64-
65-
const fileItems: FileItem[] = useMemo(() => {
66-
if (!allFiles) return [];
67-
return allFiles
68-
.filter((file): file is typeof file & { path: string } => !!file.path)
69-
.map((file) => {
70-
const parts = file.path.split("/");
71-
const name = parts.pop() ?? file.path;
72-
const dir = parts.join("/");
73-
return { path: file.path, name, dir };
74-
});
75-
}, [allFiles]);
76-
77-
const fzf = useMemo(
78-
() =>
79-
new Fzf(fileItems, {
80-
selector: (item) => `${item.name} ${item.path}`,
81-
limit: FILE_DISPLAY_LIMIT,
82-
tiebreakers: [byLengthAsc],
83-
}),
84-
[fileItems],
85-
);
38+
const { files: fileItems, fzf } = useRepoFiles(repoPath, open);
8639

8740
const displayedFiles = useMemo(() => {
8841
if (!searchQuery.trim() && recentFiles.length > 0) {
89-
const recentItems: FileItem[] = recentFiles.map((path) => {
90-
const parts = path.split("/");
91-
const name = parts.pop() ?? path;
92-
const dir = parts.join("/");
93-
return { path, name, dir };
94-
});
95-
return recentItems;
42+
return recentFiles.map(pathToFileItem);
9643
}
9744
return searchFiles(fzf, fileItems, searchQuery);
9845
}, [fzf, fileItems, searchQuery, recentFiles]);

apps/twig/src/renderer/features/message-editor/suggestions/getSuggestions.ts

Lines changed: 6 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import type { AvailableCommand } from "@agentclientprotocol/sdk";
22
import { getAvailableCommandsForTask } from "@features/sessions/stores/sessionStore";
3-
import { trpcVanilla } from "@renderer/trpc/client";
4-
import type { MentionItem } from "@shared/types";
3+
import { fetchRepoFiles, searchFiles } from "@hooks/useRepoFiles";
54
import Fuse, { type IFuseOptions } from "fuse.js";
65
import { useDraftStore } from "../stores/draftStore";
76
import type { CommandSuggestionItem, FileSuggestionItem } from "../types";
87

9-
const FILE_DISPLAY_LIMIT = 25;
10-
const FILE_FETCH_LIMIT = 100;
118
const COMMAND_LIMIT = 5;
129

1310
const COMMAND_FUSE_OPTIONS: IFuseOptions<AvailableCommand> = {
@@ -19,20 +16,6 @@ const COMMAND_FUSE_OPTIONS: IFuseOptions<AvailableCommand> = {
1916
includeScore: true,
2017
};
2118

22-
interface FileItem {
23-
path: string;
24-
name: string;
25-
}
26-
27-
const FILE_FUSE_OPTIONS: IFuseOptions<FileItem> = {
28-
keys: [
29-
{ name: "name", weight: 0.7 },
30-
{ name: "path", weight: 0.3 },
31-
],
32-
threshold: 0.4,
33-
includeScore: true,
34-
};
35-
3619
function searchCommands(
3720
commands: AvailableCommand[],
3821
query: string,
@@ -57,27 +40,6 @@ function searchCommands(
5740
return results.slice(0, COMMAND_LIMIT).map((result) => result.item);
5841
}
5942

60-
function searchFiles(files: FileItem[], query: string): FileItem[] {
61-
if (!query.trim()) {
62-
return files.slice(0, FILE_DISPLAY_LIMIT);
63-
}
64-
65-
const fuse = new Fuse(files, FILE_FUSE_OPTIONS);
66-
const results = fuse.search(query, { limit: FILE_DISPLAY_LIMIT * 2 });
67-
68-
const lowerQuery = query.toLowerCase();
69-
results.sort((a, b) => {
70-
const aStartsWithQuery = a.item.name.toLowerCase().startsWith(lowerQuery);
71-
const bStartsWithQuery = b.item.name.toLowerCase().startsWith(lowerQuery);
72-
73-
if (aStartsWithQuery && !bStartsWithQuery) return -1;
74-
if (!aStartsWithQuery && bStartsWithQuery) return 1;
75-
return (a.score ?? 0) - (b.score ?? 0);
76-
});
77-
78-
return results.slice(0, FILE_DISPLAY_LIMIT).map((result) => result.item);
79-
}
80-
8143
export async function getFileSuggestions(
8244
sessionId: string,
8345
query: string,
@@ -88,27 +50,14 @@ export async function getFileSuggestions(
8850
return [];
8951
}
9052

91-
const results = await trpcVanilla.fs.listRepoFiles.query({
92-
repoPath,
93-
query,
94-
limit: FILE_FETCH_LIMIT,
95-
});
96-
97-
const files: FileItem[] = results
98-
.filter(
99-
(file: MentionItem): file is MentionItem & { path: string } =>
100-
!!file.path,
101-
)
102-
.map((file) => ({
103-
path: file.path,
104-
name: file.path.split("/").pop() ?? file.path,
105-
}));
106-
107-
const matched = searchFiles(files, query);
53+
const { files, fzf } = await fetchRepoFiles(repoPath);
54+
const matched = searchFiles(fzf, files, query);
10855

10956
return matched.map((file) => ({
11057
id: file.path,
111-
label: file.path,
58+
label: file.name,
59+
description: file.dir || undefined,
60+
filename: file.name,
11261
path: file.path,
11362
}));
11463
}

apps/twig/src/renderer/features/message-editor/tiptap/FileMention.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ function createSuggestion(
8686

8787
command: ({ editor, range, props }) => {
8888
const item = props as SuggestionItem;
89-
const label = item.label.split("/").pop() ?? item.label;
9089

9190
editor
9291
.chain()
@@ -98,7 +97,7 @@ function createSuggestion(
9897
attrs: {
9998
type: "file",
10099
id: item.id,
101-
label,
100+
label: item.label,
102101
},
103102
},
104103
{ type: "text", text: " " },

apps/twig/src/renderer/features/message-editor/tiptap/SuggestionList.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FileIcon } from "@components/ui/FileIcon";
12
import {
23
forwardRef,
34
useEffect,
@@ -88,22 +89,29 @@ export const SuggestionList = forwardRef<
8889
}}
8990
onClick={() => command(item)}
9091
onMouseEnter={() => hasMouseMoved && setSelectedIndex(index)}
91-
className={`flex w-full flex-col gap-0.5 border-none px-2 text-left ${
92+
className={`flex w-full items-start gap-2 border-none px-2 text-left ${
9293
item.description ? "py-1.5" : "py-1"
9394
} ${isSelected ? "bg-[var(--accent-a4)]" : ""}`}
9495
>
95-
<span
96-
className={`truncate ${isSelected ? "text-[var(--accent-11)]" : "text-[var(--gray-11)]"}`}
97-
>
98-
{item.label}
99-
</span>
100-
{item.description && (
96+
{item.filename && (
97+
<span className="mt-0.5 flex-shrink-0">
98+
<FileIcon filename={item.filename} size={14} />
99+
</span>
100+
)}
101+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
101102
<span
102-
className={`text-[11px] ${isSelected ? "text-[var(--accent-10)]" : "text-[var(--gray-10)]"}`}
103+
className={`truncate ${isSelected ? "text-[var(--accent-11)]" : "text-[var(--gray-11)]"}`}
103104
>
104-
{item.description}
105+
{item.label}
105106
</span>
106-
)}
107+
{item.description && (
108+
<span
109+
className={`truncate text-[11px] ${isSelected ? "text-[var(--accent-10)]" : "text-[var(--gray-10)]"}`}
110+
>
111+
{item.description}
112+
</span>
113+
)}
114+
</span>
107115
</button>
108116
);
109117
})}

apps/twig/src/renderer/features/message-editor/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface SuggestionItem {
1616
id: string;
1717
label: string;
1818
description?: string;
19+
filename?: string;
1920
}
2021

2122
export interface FileSuggestionItem extends SuggestionItem {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { queryClient } from "@renderer/lib/queryClient";
2+
import { trpcReact, trpcVanilla } from "@renderer/trpc/client";
3+
import type { MentionItem } from "@shared/types";
4+
import { byLengthAsc, Fzf } from "fzf";
5+
import { useMemo } from "react";
6+
7+
export interface FileItem {
8+
path: string;
9+
name: string;
10+
dir: string;
11+
}
12+
13+
const FILE_DISPLAY_LIMIT = 20;
14+
15+
export function pathToFileItem(path: string): FileItem {
16+
const parts = path.split("/");
17+
const name = parts.pop() ?? path;
18+
const dir = parts.join("/");
19+
return { path, name, dir };
20+
}
21+
22+
function transformRawFiles(rawFiles: MentionItem[]): FileItem[] {
23+
return rawFiles
24+
.filter((file): file is MentionItem & { path: string } => !!file.path)
25+
.map((file) => pathToFileItem(file.path));
26+
}
27+
28+
function createFzf(files: FileItem[]): Fzf<FileItem[]> {
29+
return new Fzf(files, {
30+
selector: (item) => `${item.name} ${item.path}`,
31+
limit: FILE_DISPLAY_LIMIT,
32+
tiebreakers: [byLengthAsc],
33+
});
34+
}
35+
36+
export function useRepoFiles(repoPath: string | undefined, enabled = true) {
37+
const { data: rawFiles, isLoading } = trpcReact.fs.listRepoFiles.useQuery(
38+
{ repoPath: repoPath ?? "" },
39+
{ enabled: enabled && !!repoPath },
40+
);
41+
42+
const files: FileItem[] = useMemo(() => {
43+
if (!rawFiles) return [];
44+
return transformRawFiles(rawFiles);
45+
}, [rawFiles]);
46+
47+
const fzf = useMemo(() => createFzf(files), [files]);
48+
49+
return { files, fzf, isLoading };
50+
}
51+
52+
export function searchFiles(
53+
fzf: Fzf<FileItem[]>,
54+
files: FileItem[],
55+
query: string,
56+
): FileItem[] {
57+
if (!query.trim()) {
58+
return files.slice(0, FILE_DISPLAY_LIMIT);
59+
}
60+
const results = fzf.find(query);
61+
return results.map((result) => result.item);
62+
}
63+
64+
const fzfCache = new Map<
65+
string,
66+
{ fzf: Fzf<FileItem[]>; filesLength: number }
67+
>();
68+
69+
export async function fetchRepoFiles(repoPath: string): Promise<{
70+
files: FileItem[];
71+
fzf: Fzf<FileItem[]>;
72+
}> {
73+
const queryKey = [
74+
["fs", "listRepoFiles"],
75+
{ input: { repoPath }, type: "query" },
76+
];
77+
78+
const rawFiles = await queryClient.fetchQuery({
79+
queryKey,
80+
queryFn: () => trpcVanilla.fs.listRepoFiles.query({ repoPath }),
81+
staleTime: 1000 * 60 * 5,
82+
});
83+
84+
const files = transformRawFiles(rawFiles as MentionItem[]);
85+
86+
const cached = fzfCache.get(repoPath);
87+
if (cached && cached.filesLength === files.length) {
88+
return { files, fzf: cached.fzf };
89+
}
90+
91+
const fzf = createFzf(files);
92+
fzfCache.set(repoPath, { fzf, filesLength: files.length });
93+
return { files, fzf };
94+
}

0 commit comments

Comments
 (0)