History

Respect .gitignore in workspace search and inline @path mentions

- Build workspace index from `git ls-files` when in a Git repo, excluding ignored paths - Skip configured ignored top-level directories during filesystem fallback indexing - Update chat composer to insert `@path` directly in prompt and remove separate tagged-path chips - Keep command menu navigation visible by scrolling the active/next item into view

Julius Marminge committed 8 months ago

Commit 9f485641 parent e8556bc3 files · +167 −47Browse files

Unified · Split

3 files
  1. apps/server/src/workspaceEntries.test.ts +25 −0
  2. apps/server/src/workspaceEntries.ts +100 −0
  3. apps/web/src/components/ChatView.tsx +42 −47
apps/server/src/workspaceEntries.test.ts+25−0
@@ -1,6 +1,7 @@
11 import fs from "node:fs";
22 import os from "node:os";
33 import path from "node:path";
4+import { spawnSync } from "node:child_process";
45
56 import { afterEach, describe, expect, it } from "vitest";
67
@@ -20,6 +21,13 @@ function writeFile(cwd: string, relativePath: string, contents = ""): void { · 13 unmodified lines
2021 fs.writeFileSync(absolutePath, contents, "utf8");
2122 }
2223
24+function runGit(cwd: string, args: string[]): void {
25+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
26+ if (result.status !== 0) {
27+ throw new Error(result.stderr || `git ${args.join(" ")} failed`);
28+ }
29+}
30+
2331 describe("searchWorkspaceEntries", () => {
2432 afterEach(() => {
2533 for (const dir of tempDirs.splice(0, tempDirs.length)) {
@@ -59,4 +67,21 @@ describe("searchWorkspaceEntries", () => { · 33 unmodified lines
5967 expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true);
6068 expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true);
6169 });
70+
71+ it("excludes gitignored paths for git repositories", async () => {
72+ const cwd = makeTempDir("t3code-workspace-gitignore-");
73+ runGit(cwd, ["init"]);
74+ writeFile(cwd, ".gitignore", ".convex/\nignored.txt\n");
75+ writeFile(cwd, "src/keep.ts", "export {};");
76+ writeFile(cwd, "ignored.txt", "ignore me");
77+ writeFile(cwd, ".convex/local-storage/data.json", "{}");
78+
79+ const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
80+ const paths = result.entries.map((entry) => entry.path);
81+
82+ expect(paths).toContain("src");
83+ expect(paths).toContain("src/keep.ts");
84+ expect(paths).not.toContain("ignored.txt");
85+ expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
86+ });
6287 });
apps/server/src/workspaceEntries.ts+100−0
@@ -1,5 +1,6 @@
11 import fs from "node:fs/promises";
22 import path from "node:path";
3+import { runProcess } from "./processRunner";
34
45 import {
56 projectSearchEntriesInputSchema,
@@ -70,7 +71,103 @@ function scoreEntry(entry: ProjectEntry, query: string): number { · 64 unmodified lines
7071 return 5;
7172 }
7273
74+function isPathInIgnoredDirectory(relativePath: string): boolean {
75+ const firstSegment = relativePath.split("/")[0];
76+ if (!firstSegment) return false;
77+ return IGNORED_DIRECTORY_NAMES.has(firstSegment);
78+}
79+
80+function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
81+ const parts = input.split("\0");
82+ if (parts.length === 0) return [];
83+
84+ // If output was truncated, the final token can be partial.
85+ if (truncated && parts[parts.length - 1]?.length) {
86+ parts.pop();
87+ }
88+
89+ return parts.filter((value) => value.length > 0);
90+}
91+
92+function directoryAncestorsOf(relativePath: string): string[] {
93+ const segments = relativePath.split("/").filter((segment) => segment.length > 0);
94+ if (segments.length <= 1) return [];
95+ const directories: string[] = [];
96+ for (let index = 1; index < segments.length; index += 1) {
97+ directories.push(segments.slice(0, index).join("/"));
98+ }
99+ return directories;
100+}
101+
102+async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> {
103+ const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
104+ cwd,
105+ allowNonZeroExit: true,
106+ timeoutMs: 5_000,
107+ maxBufferBytes: 4_096,
108+ }).catch(() => null);
109+ if (!insideWorkTree || insideWorkTree.code !== 0 || insideWorkTree.stdout.trim() !== "true") {
110+ return null;
111+ }
112+
113+ const listedFiles = await runProcess(
114+ "git",
115+ ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
116+ {
117+ cwd,
118+ allowNonZeroExit: true,
119+ timeoutMs: 20_000,
120+ maxBufferBytes: 16 * 1024 * 1024,
121+ outputMode: "truncate",
122+ },
123+ ).catch(() => null);
124+ if (!listedFiles || listedFiles.code !== 0) {
125+ return null;
126+ }
127+
128+ const filePaths = splitNullSeparatedPaths(listedFiles.stdout, Boolean(listedFiles.stdoutTruncated))
129+ .map((entry) => toPosixPath(entry))
130+ .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));
131+
132+ const directorySet = new Set<string>();
133+ for (const filePath of filePaths) {
134+ for (const directoryPath of directoryAncestorsOf(filePath)) {
135+ if (!isPathInIgnoredDirectory(directoryPath)) {
136+ directorySet.add(directoryPath);
137+ }
138+ }
139+ }
140+
141+ const directoryEntries: ProjectEntry[] = [...directorySet]
142+ .sort((left, right) => left.localeCompare(right))
143+ .map((directoryPath) => ({
144+ path: directoryPath,
145+ kind: "directory",
146+ parentPath: parentPathOf(directoryPath),
147+ }));
148+ const fileEntries: ProjectEntry[] = [...new Set(filePaths)]
149+ .sort((left, right) => left.localeCompare(right))
150+ .map((filePath) => ({
151+ path: filePath,
152+ kind: "file",
153+ parentPath: parentPathOf(filePath),
154+ }));
155+
156+ const entries = [...directoryEntries, ...fileEntries];
157+ return {
158+ scannedAt: Date.now(),
159+ entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES),
160+ truncated:
161+ Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES,
162+ };
163+}
164+
73165 async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
166+ const gitIndexed = await buildWorkspaceIndexFromGit(cwd);
167+ if (gitIndexed) {
168+ return gitIndexed;
169+ }
170+
74171 let pendingDirectories: string[] = [""];
75172 const entries: ProjectEntry[] = [];
76173 let truncated = false;
@@ -117,6 +214,9 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 40 unmodified lines
117214 const relativePath = toPosixPath(
118215 relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,
119216 );
217+ if (isPathInIgnoredDirectory(relativePath)) {
218+ continue;
219+ }
120220 const entry: ProjectEntry = {
121221 path: relativePath,
122222 kind: dirent.isDirectory() ? "directory" : "file",
apps/web/src/components/ChatView.tsx+42−47
@@ -33,8 +33,6 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; · 32 unmodified lines
3333 import { isElectron } from "../env";
3434 import { buildBootstrapInput } from "../historyBootstrap";
3535 import {
36− buildPromptInput,
37− buildUserVisiblePrompt,
3836 detectComposerTrigger,
3937 replaceTextRange,
4038 } from "../composer-logic";
@@ -92,7 +90,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" · 51 unmodified lines
9290 import { CursorIcon, Icon } from "./Icons";
9391 import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
9492 import { Badge } from "./ui/badge";
95−import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command";
93+import { Command, CommandItem, CommandList } from "./ui/command";
9694
9795 function formatMessageMeta(createdAt: string, duration: string | null): string {
9896 if (!duration) return formatTimestamp(createdAt);
@@ -185,7 +183,6 @@ export default function ChatView() { · 86 unmodified lines
185183 gitCreateWorktreeMutationOptions({ api, queryClient }),
186184 );
187185 const [prompt, setPrompt] = useState("");
188− const [taggedPaths, setTaggedPaths] = useState<string[]>([]);
189186 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
190187 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
191188 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
@@ -204,6 +201,8 @@ export default function ChatView() { · 12 unmodified lines
204201 const messagesEndRef = useRef<HTMLDivElement>(null);
205202 const shouldAutoScrollRef = useRef(true);
206203 const textareaRef = useRef<HTMLTextAreaElement>(null);
204+ const composerMenuRef = useRef<HTMLDivElement>(null);
205+ const previousComposerMenuIndexRef = useRef(0);
207206 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
208207 const dragDepthRef = useRef(0);
209208 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
@@ -341,9 +340,7 @@ export default function ChatView() { · 131 unmodified lines
341340 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
342341 if (!composerTrigger) return [];
343342 if (composerTrigger.kind === "path") {
344− return (workspaceEntriesQuery.data?.entries ?? [])
345− .filter((entry) => !taggedPaths.includes(entry.path))
346− .map((entry) => ({
343+ return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({
347344 id: `path:${entry.kind}:${entry.path}`,
348345 type: "path",
349346 path: entry.path,
@@ -378,7 +375,7 @@ export default function ChatView() { · 28 unmodified lines
378375 label: name,
379376 description: slug,
380377 }));
381− }, [composerTrigger, taggedPaths, workspaceEntriesQuery.data?.entries]);
378+ }, [composerTrigger, workspaceEntriesQuery.data?.entries]);
382379 const composerMenuOpen = Boolean(composerTrigger);
383380 const activeComposerMenuItem =
384381 composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ??
@@ -552,6 +549,28 @@ export default function ChatView() { · 167 unmodified lines
552549 setComposerMenuIndex(0);
553550 }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]);
554551
552+ useEffect(() => {
553+ if (!composerMenuOpen || composerMenuItems.length === 0) {
554+ return;
555+ }
556+ const clampedIndex = Math.min(composerMenuIndex, composerMenuItems.length - 1);
557+ const previousIndex = previousComposerMenuIndexRef.current;
558+ const direction = clampedIndex - previousIndex;
559+ previousComposerMenuIndexRef.current = clampedIndex;
560+
561+ const aheadIndex =
562+ direction > 0
563+ ? Math.min(clampedIndex + 1, composerMenuItems.length - 1)
564+ : direction < 0
565+ ? Math.max(clampedIndex - 1, 0)
566+ : clampedIndex;
567+
568+ const node = composerMenuRef.current?.querySelector<HTMLElement>(
569+ `[data-composer-menu-index="${aheadIndex}"]`,
570+ );
571+ node?.scrollIntoView({ block: "nearest" });
572+ }, [composerMenuIndex, composerMenuItems, composerMenuOpen]);
573+
555574 useEffect(() => {
556575 if (!activeThread?.id || activeThread.terminalOpen) return;
557576 const frame = window.requestAnimationFrame(() => {
@@ -571,7 +590,6 @@ export default function ChatView() { · 13 unmodified lines
571590 revokePreviewUrls(existing);
572591 return [];
573592 });
574− setTaggedPaths([]);
575593 setComposerCursor(0);
576594 setComposerMenuIndex(0);
577595 dragDepthRef.current = 0;
@@ -890,10 +908,7 @@ export default function ChatView() { · 312 unmodified lines
890908 e.preventDefault();
891909 if (!api || !activeThread || isSending || isConnecting) return;
892910 const trimmed = prompt.trim();
893− const taggedPathsSnapshot = [...taggedPaths];
894− const promptForModel = buildPromptInput(trimmed, taggedPathsSnapshot);
895− const userVisiblePrompt = buildUserVisiblePrompt(trimmed, taggedPathsSnapshot);
896− if (!promptForModel && composerImages.length === 0) return;
911+ if (!trimmed && composerImages.length === 0) return;
897912 if (!activeProject) return;
898913 const composerImagesSnapshot = [...composerImages];
899914
@@ -933,7 +948,6 @@ export default function ChatView() { · 33 unmodified lines
933948 if (activeThread.messages.length === 0) {
934949 const titleSeed =
935950 trimmed ||
936− (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") ||
937951 (composerImagesSnapshot.length > 0
938952 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
939953 : "New thread");
@@ -958,13 +972,12 @@ export default function ChatView() { · 18 unmodified lines
958972 type: "PUSH_USER_MESSAGE",
959973 threadId: activeThread.id,
960974 id: crypto.randomUUID(),
961− text: userVisiblePrompt,
975+ text: trimmed,
962976 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
963977 });
964978 const previousMessages = activeThread.messages;
965979 setPrompt("");
966980 setComposerImages([]);
967− setTaggedPaths([]);
968981 setComposerCursor(0);
969982 setComposerMenuIndex(0);
970983
@@ -987,14 +1000,14 @@ export default function ChatView() { · 16 unmodified lines
9871000 const shouldBootstrap =
9881001 previousMessages.length > 0 &&
9891002 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
990− const latestPromptForBootstrap = promptForModel || IMAGE_ONLY_BOOTSTRAP_PROMPT;
1003+ const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT;
9911004 const input = shouldBootstrap
9921005 ? buildBootstrapInput(
9931006 previousMessages,
9941007 latestPromptForBootstrap,
9951008 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
9961009 ).text
997− : promptForModel || undefined;
1010+ : trimmed || undefined;
9981011 await api.providers.sendTurn({
9991012 sessionId: sessionInfo.sessionId,
10001013 ...(input ? { input } : {}),
@@ -1094,10 +1107,11 @@ export default function ChatView() { · 93 unmodified lines
10941107 (item: ComposerCommandItem) => {
10951108 if (!composerTrigger) return;
10961109 if (item.type === "path") {
1097− setTaggedPaths((existing) =>
1098− existing.includes(item.path) ? existing : [...existing, item.path],
1110+ applyPromptReplacement(
1111+ composerTrigger.rangeStart,
1112+ composerTrigger.rangeEnd,
1113+ `@${item.path} `,
10991114 );
1100− applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "");
11011115 return;
11021116 }
11031117 if (item.type === "slash-command") {
@@ -1110,10 +1124,6 @@ export default function ChatView() { · 6 unmodified lines
11101124 [applyPromptReplacement, composerTrigger, onModelSelect],
11111125 );
11121126
1113− const onRemoveTaggedPath = useCallback((pathToRemove: string) => {
1114− setTaggedPaths((existing) => existing.filter((entry) => entry !== pathToRemove));
1115− }, []);
1116−
11171127 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
11181128 setPrompt(nextPrompt);
11191129 setComposerCursor(nextCursor);
@@ -1240,12 +1250,16 @@ export default function ChatView() { · 120 unmodified lines
12401250 {composerMenuOpen && (
12411251 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
12421252 <Command>
1243− <CommandPanel className="rounded-xl border border-border/80 bg-popover/96 backdrop-blur-xs">
1253+ <div
1254+ ref={composerMenuRef}
1255+ className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"
1256+ >
12441257 <CommandList className="max-h-64">
12451258 {composerMenuItems.map((item, index) => (
12461259 <CommandItem
12471260 key={item.id}
12481261 value={item.id}
1262+ data-composer-menu-index={index}
12491263 className={cn(
12501264 "gap-2",
12511265 index === composerMenuIndex && "bg-accent text-accent-foreground",
@@ -1287,30 +1301,11 @@ export default function ChatView() { · 35 unmodified lines
12871301 : "No matching command."}
12881302 </p>
12891303 )}
1290− </CommandPanel>
1304+ </div>
12911305 </Command>
12921306 </div>
12931307 )}
12941308
1295− {taggedPaths.length > 0 && (
1296− <div className="mb-3 flex flex-wrap gap-1.5">
1297− {taggedPaths.map((taggedPath) => (
1298− <button
1299− key={taggedPath}
1300− type="button"
1301− className="inline-flex items-center gap-1 rounded-md border border-border/80 bg-secondary/65 px-2 py-1 text-[11px] text-foreground/85 transition-colors hover:bg-secondary"
1302− onClick={() => onRemoveTaggedPath(taggedPath)}
1303− aria-label={`Remove ${taggedPath}`}
1304− title={taggedPath}
1305− >
1306− <span className="text-muted-foreground/70">@</span>
1307− <span className="max-w-64 truncate">{taggedPath}</span>
1308− <XIcon className="size-3 text-muted-foreground/70" />
1309− </button>
1310− ))}
1311− </div>
1312− )}
1313−
13141309 {composerImages.length > 0 && (
13151310 <div className="mb-3 flex flex-wrap gap-2">
13161311 {composerImages.map((image) => (
@@ -1446,7 +1441,7 @@ export default function ChatView() { · 129 unmodified lines
14461441 disabled={
14471442 isSending ||
14481443 isConnecting ||
1449− (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0)
1444+ (!prompt.trim() && composerImages.length === 0)
14501445 }
14511446 aria-label={
14521447 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"