Skip to content

Commit 0c1ee6b

Browse files
authored
fix/feat: Improve @ file tagging, reduce lag (#241)
1 parent 4611a75 commit 0c1ee6b

File tree

8 files changed

+413
-176
lines changed

8 files changed

+413
-176
lines changed

apps/array/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@posthog/array",
3-
"version": "0.12.0",
3+
"version": "0.13.0",
44
"description": "Array - PostHog desktop task manager",
55
"main": ".vite/build/index.js",
66
"versionHash": "dynamic",

apps/array/src/main/preload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
147147
listRepoFiles: (
148148
repoPath: string,
149149
query?: string,
150+
limit?: number,
150151
): Promise<Array<{ path: string; name: string }>> =>
151-
ipcRenderer.invoke("list-repo-files", repoPath, query),
152+
ipcRenderer.invoke("list-repo-files", repoPath, query, limit),
152153
clearRepoFileCache: (repoPath: string): Promise<void> =>
153154
ipcRenderer.invoke("clear-repo-file-cache", repoPath),
154155
agentStart: async (

apps/array/src/main/services/fs.ts

Lines changed: 90 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -24,77 +24,88 @@ const repoFileCache = new Map<
2424
>();
2525
const CACHE_TTL = 30000; // 30 seconds
2626

27-
async function getGitIgnoredFiles(repoPath: string): Promise<Set<string>> {
27+
/**
28+
* List files using git ls-files - fast and works for any git repository.
29+
* This is much faster than recursive fs.readdir for large codebases.
30+
*/
31+
async function listFilesWithGit(
32+
repoPath: string,
33+
changedFiles: Set<string>,
34+
): Promise<FileEntry[]> {
2835
try {
29-
const { stdout } = await execAsync(
30-
"git ls-files --others --ignored --exclude-standard",
31-
{ cwd: repoPath },
36+
const { stdout: trackedStdout } = await execAsync("git ls-files", {
37+
cwd: repoPath,
38+
maxBuffer: 50 * 1024 * 1024,
39+
});
40+
41+
const { stdout: untrackedStdout } = await execAsync(
42+
"git ls-files --others --exclude-standard",
43+
{ cwd: repoPath, maxBuffer: 50 * 1024 * 1024 },
3244
);
33-
return new Set(
34-
stdout
35-
.split("\n")
36-
.filter(Boolean)
37-
.map((f) => path.join(repoPath, f)),
38-
);
39-
} catch {
40-
// If git command fails, return empty set
41-
return new Set();
45+
46+
const allFiles = [
47+
...trackedStdout.split("\n").filter(Boolean),
48+
...untrackedStdout.split("\n").filter(Boolean),
49+
];
50+
51+
return allFiles.map((relativePath) => ({
52+
path: relativePath,
53+
name: path.basename(relativePath),
54+
changed: changedFiles.has(relativePath),
55+
}));
56+
} catch (error) {
57+
log.error("Error listing files with git:", error);
58+
return [];
4259
}
4360
}
4461

45-
async function listFilesRecursive(
46-
dirPath: string,
47-
ignoredFiles: Set<string>,
48-
baseDir: string,
62+
/**
63+
* List files with early termination using grep and head.
64+
* Returns limited results directly from git without loading all files into memory.
65+
*/
66+
async function listFilesWithQuery(
67+
repoPath: string,
68+
query: string,
69+
limit: number,
4970
changedFiles: Set<string>,
5071
): Promise<FileEntry[]> {
51-
const files: FileEntry[] = [];
52-
5372
try {
54-
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
55-
56-
for (const entry of entries) {
57-
const fullPath = path.join(dirPath, entry.name);
58-
const relativePath = path.relative(baseDir, fullPath);
59-
60-
// Skip .git directory, node_modules, and common build dirs
61-
if (
62-
entry.name === ".git" ||
63-
entry.name === "node_modules" ||
64-
entry.name === "dist" ||
65-
entry.name === "build" ||
66-
entry.name === "__pycache__"
67-
) {
68-
continue;
69-
}
70-
71-
// Skip git-ignored files
72-
if (ignoredFiles.has(fullPath)) {
73-
continue;
74-
}
75-
76-
if (entry.isDirectory()) {
77-
const subFiles = await listFilesRecursive(
78-
fullPath,
79-
ignoredFiles,
80-
baseDir,
81-
changedFiles,
82-
);
83-
files.push(...subFiles);
84-
} else if (entry.isFile()) {
85-
files.push({
86-
path: relativePath,
87-
name: entry.name,
88-
changed: changedFiles.has(relativePath),
89-
});
90-
}
91-
}
73+
// escape special regex characters in the query for grep
74+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75+
76+
// grep -i for case-insensitive matching, head for early termination
77+
const grepCmd = `grep -i "${escapedQuery}" | head -n ${limit}`;
78+
79+
// the || true prevents error when grep finds no matches
80+
const [trackedResult, untrackedResult] = await Promise.all([
81+
execAsync(`git ls-files | ${grepCmd} || true`, {
82+
cwd: repoPath,
83+
maxBuffer: 1024 * 1024,
84+
}),
85+
execAsync(
86+
`git ls-files --others --exclude-standard | ${grepCmd} || true`,
87+
{
88+
cwd: repoPath,
89+
maxBuffer: 1024 * 1024,
90+
},
91+
),
92+
]);
93+
94+
const trackedFiles = trackedResult.stdout.split("\n").filter(Boolean);
95+
const untrackedFiles = untrackedResult.stdout.split("\n").filter(Boolean);
96+
97+
// combine and limit to requested amount (in case both sources have results)
98+
const allFiles = [...trackedFiles, ...untrackedFiles].slice(0, limit);
99+
100+
return allFiles.map((relativePath) => ({
101+
path: relativePath,
102+
name: path.basename(relativePath),
103+
changed: changedFiles.has(relativePath),
104+
}));
92105
} catch (error) {
93-
// Skip directories we can't read
94-
log.error(`Error reading directory ${dirPath}:`, error);
106+
log.error("Error listing files with query:", error);
107+
return [];
95108
}
96-
97-
return files;
98109
}
99110

100111
export function registerFsIpc(): void {
@@ -104,11 +115,26 @@ export function registerFsIpc(): void {
104115
_event: IpcMainInvokeEvent,
105116
repoPath: string,
106117
query?: string,
118+
limit?: number,
107119
): Promise<FileEntry[]> => {
108120
if (!repoPath) return [];
109121

122+
const resultLimit = limit ?? 50;
123+
110124
try {
111-
// Check cache
125+
const changedFiles = await getChangedFilesForRepo(repoPath);
126+
127+
// when there is a query, use early termination with grep + head
128+
// this avoids loading all files into memory for filtered searches
129+
if (query?.trim()) {
130+
return await listFilesWithQuery(
131+
repoPath,
132+
query.trim(),
133+
resultLimit,
134+
changedFiles,
135+
);
136+
}
137+
112138
const cached = repoFileCache.get(repoPath);
113139
const now = Date.now();
114140

@@ -117,40 +143,15 @@ export function registerFsIpc(): void {
117143
if (cached && now - cached.timestamp < CACHE_TTL) {
118144
allFiles = cached.files;
119145
} else {
120-
// Get git-ignored files
121-
const ignoredFiles = await getGitIgnoredFiles(repoPath);
122-
123-
// Get changed files from git
124-
const changedFiles = await getChangedFilesForRepo(repoPath);
146+
allFiles = await listFilesWithGit(repoPath, changedFiles);
125147

126-
// List all files
127-
allFiles = await listFilesRecursive(
128-
repoPath,
129-
ignoredFiles,
130-
repoPath,
131-
changedFiles,
132-
);
133-
134-
// Update cache
135148
repoFileCache.set(repoPath, {
136149
files: allFiles,
137150
timestamp: now,
138151
});
139152
}
140153

141-
// Filter by query if provided
142-
if (query?.trim()) {
143-
const lowerQuery = query.toLowerCase();
144-
return allFiles
145-
.filter(
146-
(f) =>
147-
f.path.toLowerCase().includes(lowerQuery) ||
148-
f.name.toLowerCase().includes(lowerQuery),
149-
)
150-
.slice(0, 50); // Limit search results
151-
}
152-
153-
return allFiles; // Return all files for full tree view
154+
return allFiles.slice(0, resultLimit);
154155
} catch (error) {
155156
log.error("Error listing repo files:", error);
156157
return [];

apps/array/src/renderer/features/sessions/components/MessageEditor.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,20 @@ export const MessageEditor = forwardRef<
231231
}
232232
};
233233
const [mentionItems, setMentionItems] = useState<MentionItem[]>([]);
234-
const mentionItemsRef = useRef(mentionItems);
235234
const repoPathRef = useRef(repoPath);
236235
const onSubmitRef = useRef(onSubmit);
236+
const componentRef = useRef<ReactRenderer<MentionListRef> | null>(null);
237+
const commandRef = useRef<
238+
((item: { id: string; label: string; type?: string }) => void) | null
239+
>(null);
237240

238241
useEffect(() => {
239-
mentionItemsRef.current = mentionItems;
242+
if (componentRef.current && commandRef.current) {
243+
componentRef.current.updateProps({
244+
items: mentionItems,
245+
command: commandRef.current,
246+
});
247+
}
240248
}, [mentionItems]);
241249

242250
useEffect(() => {
@@ -301,10 +309,14 @@ export const MessageEditor = forwardRef<
301309
setMentionItems([]);
302310
return [];
303311
}
312+
313+
setMentionItems([]);
314+
304315
try {
305316
const results = await window.electronAPI?.listRepoFiles(
306317
repoPathRef.current,
307318
query,
319+
10,
308320
);
309321
const items = (results || []).map((file) => ({
310322
path: file.path,
@@ -320,8 +332,6 @@ export const MessageEditor = forwardRef<
320332
}
321333
},
322334
render: () => {
323-
let component: ReactRenderer<MentionListRef> | null = null;
324-
325335
const updatePosition = (ed: Editor, element: HTMLElement) => {
326336
const refRect = posToDOMRect(
327337
ed.view,
@@ -338,36 +348,41 @@ export const MessageEditor = forwardRef<
338348

339349
return {
340350
onStart: (props: SuggestionProps) => {
341-
component = new ReactRenderer(MentionList, {
351+
commandRef.current = props.command;
352+
353+
const component = new ReactRenderer(MentionList, {
342354
props: {
343-
items: mentionItemsRef.current,
355+
items: [],
344356
command: props.command,
345357
},
346358
editor: props.editor,
347359
});
360+
361+
componentRef.current = component;
362+
348363
if (!props.clientRect) return;
349364
component.element.style.position = "absolute";
350365
document.body.appendChild(component.element);
351366
updatePosition(props.editor, component.element);
352367
},
353368
onUpdate: (props: SuggestionProps) => {
354-
component?.updateProps({
355-
items: mentionItemsRef.current,
356-
command: props.command,
357-
});
358-
if (!props.clientRect || !component) return;
359-
updatePosition(props.editor, component.element);
369+
if (!props.clientRect || !componentRef.current) return;
370+
updatePosition(props.editor, componentRef.current.element);
360371
},
361372
onKeyDown: (props: SuggestionKeyDownProps) => {
362373
if (props.event.key === "Escape") {
363-
component?.destroy();
374+
componentRef.current?.destroy();
375+
componentRef.current = null;
376+
commandRef.current = null;
364377
return true;
365378
}
366-
return component?.ref?.onKeyDown?.(props) ?? false;
379+
return componentRef.current?.ref?.onKeyDown?.(props) ?? false;
367380
},
368381
onExit: () => {
369-
component?.element.remove();
370-
component?.destroy();
382+
componentRef.current?.element.remove();
383+
componentRef.current?.destroy();
384+
componentRef.current = null;
385+
commandRef.current = null;
371386
},
372387
};
373388
},

0 commit comments

Comments
 (0)