From 9f48564d846e7dfd6df9741a50416af72d96b855 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Feb 2026 11:00:06 -0800 Subject: [PATCH] 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 --- apps/server/src/workspaceEntries.test.ts | 25 ++++++ apps/server/src/workspaceEntries.ts | 100 +++++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 89 ++++++++++---------- 3 files changed, 167 insertions(+), 47 deletions(-) diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index a4cebfbf0f623bf9160f1cee692e0a737057efba..04623526386942b968179155666d32f61c2d8766 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { afterEach, describe, expect, it } from "vitest"; @@ -20,6 +21,13 @@ function writeFile(cwd: string, relativePath: string, contents = ""): void { fs.writeFileSync(absolutePath, contents, "utf8"); } +function runGit(cwd: string, args: string[]): void { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(result.stderr || `git ${args.join(" ")} failed`); + } +} + describe("searchWorkspaceEntries", () => { afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { @@ -59,4 +67,21 @@ describe("searchWorkspaceEntries", () => { expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true); }); + + it("excludes gitignored paths for git repositories", async () => { + const cwd = makeTempDir("t3code-workspace-gitignore-"); + runGit(cwd, ["init"]); + writeFile(cwd, ".gitignore", ".convex/\nignored.txt\n"); + writeFile(cwd, "src/keep.ts", "export {};"); + writeFile(cwd, "ignored.txt", "ignore me"); + writeFile(cwd, ".convex/local-storage/data.json", "{}"); + + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/keep.ts"); + expect(paths).not.toContain("ignored.txt"); + expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); + }); }); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index db584d0589341d7baac28e19dde18dc1b772e0d8..cbb38bc6437a9ad7669d8dde63fb5e590a6fe282 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { runProcess } from "./processRunner"; import { projectSearchEntriesInputSchema, @@ -70,7 +71,103 @@ function scoreEntry(entry: ProjectEntry, query: string): number { return 5; } +function isPathInIgnoredDirectory(relativePath: string): boolean { + const firstSegment = relativePath.split("/")[0]; + if (!firstSegment) return false; + return IGNORED_DIRECTORY_NAMES.has(firstSegment); +} + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + // If output was truncated, the final token can be partial. + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +function directoryAncestorsOf(relativePath: string): string[] { + const segments = relativePath.split("/").filter((segment) => segment.length > 0); + if (segments.length <= 1) return []; + const directories: string[] = []; + for (let index = 1; index < segments.length; index += 1) { + directories.push(segments.slice(0, index).join("/")); + } + return directories; +} + +async function buildWorkspaceIndexFromGit(cwd: string): Promise { + const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxBufferBytes: 4_096, + }).catch(() => null); + if (!insideWorkTree || insideWorkTree.code !== 0 || insideWorkTree.stdout.trim() !== "true") { + return null; + } + + const listedFiles = await runProcess( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + { + cwd, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxBufferBytes: 16 * 1024 * 1024, + outputMode: "truncate", + }, + ).catch(() => null); + if (!listedFiles || listedFiles.code !== 0) { + return null; + } + + const filePaths = splitNullSeparatedPaths(listedFiles.stdout, Boolean(listedFiles.stdoutTruncated)) + .map((entry) => toPosixPath(entry)) + .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); + + const directorySet = new Set(); + for (const filePath of filePaths) { + for (const directoryPath of directoryAncestorsOf(filePath)) { + if (!isPathInIgnoredDirectory(directoryPath)) { + directorySet.add(directoryPath); + } + } + } + + const directoryEntries: ProjectEntry[] = [...directorySet] + .sort((left, right) => left.localeCompare(right)) + .map((directoryPath) => ({ + path: directoryPath, + kind: "directory", + parentPath: parentPathOf(directoryPath), + })); + const fileEntries: ProjectEntry[] = [...new Set(filePaths)] + .sort((left, right) => left.localeCompare(right)) + .map((filePath) => ({ + path: filePath, + kind: "file", + parentPath: parentPathOf(filePath), + })); + + const entries = [...directoryEntries, ...fileEntries]; + return { + scannedAt: Date.now(), + entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), + truncated: + Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, + }; +} + async function buildWorkspaceIndex(cwd: string): Promise { + const gitIndexed = await buildWorkspaceIndexFromGit(cwd); + if (gitIndexed) { + return gitIndexed; + } + let pendingDirectories: string[] = [""]; const entries: ProjectEntry[] = []; let truncated = false; @@ -117,6 +214,9 @@ async function buildWorkspaceIndex(cwd: string): Promise { const relativePath = toPosixPath( relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, ); + if (isPathInIgnoredDirectory(relativePath)) { + continue; + } const entry: ProjectEntry = { path: relativePath, kind: dirent.isDirectory() ? "directory" : "file", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bbc5e5bcf959d938847a4c1e8dfffcb791413531..3549170fc4fb2f65b3f157b76a8b6d62f4ed0b35 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -33,8 +33,6 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; import { - buildPromptInput, - buildUserVisiblePrompt, detectComposerTrigger, replaceTextRange, } from "../composer-logic"; @@ -92,7 +90,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" import { CursorIcon, Icon } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; -import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command"; +import { Command, CommandItem, CommandList } from "./ui/command"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -185,7 +183,6 @@ export default function ChatView() { gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); - const [taggedPaths, setTaggedPaths] = useState([]); const [composerImages, setComposerImages] = useState([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -204,6 +201,8 @@ export default function ChatView() { const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef(null); + const composerMenuRef = useRef(null); + const previousComposerMenuIndexRef = useRef(0); const composerImagesRef = useRef([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); @@ -341,9 +340,7 @@ export default function ChatView() { const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { - return (workspaceEntriesQuery.data?.entries ?? []) - .filter((entry) => !taggedPaths.includes(entry.path)) - .map((entry) => ({ + return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, type: "path", path: entry.path, @@ -378,7 +375,7 @@ export default function ChatView() { label: name, description: slug, })); - }, [composerTrigger, taggedPaths, workspaceEntriesQuery.data?.entries]); + }, [composerTrigger, workspaceEntriesQuery.data?.entries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? @@ -552,6 +549,28 @@ export default function ChatView() { setComposerMenuIndex(0); }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]); + useEffect(() => { + if (!composerMenuOpen || composerMenuItems.length === 0) { + return; + } + const clampedIndex = Math.min(composerMenuIndex, composerMenuItems.length - 1); + const previousIndex = previousComposerMenuIndexRef.current; + const direction = clampedIndex - previousIndex; + previousComposerMenuIndexRef.current = clampedIndex; + + const aheadIndex = + direction > 0 + ? Math.min(clampedIndex + 1, composerMenuItems.length - 1) + : direction < 0 + ? Math.max(clampedIndex - 1, 0) + : clampedIndex; + + const node = composerMenuRef.current?.querySelector( + `[data-composer-menu-index="${aheadIndex}"]`, + ); + node?.scrollIntoView({ block: "nearest" }); + }, [composerMenuIndex, composerMenuItems, composerMenuOpen]); + useEffect(() => { if (!activeThread?.id || activeThread.terminalOpen) return; const frame = window.requestAnimationFrame(() => { @@ -571,7 +590,6 @@ export default function ChatView() { revokePreviewUrls(existing); return []; }); - setTaggedPaths([]); setComposerCursor(0); setComposerMenuIndex(0); dragDepthRef.current = 0; @@ -890,10 +908,7 @@ export default function ChatView() { e.preventDefault(); if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); - const taggedPathsSnapshot = [...taggedPaths]; - const promptForModel = buildPromptInput(trimmed, taggedPathsSnapshot); - const userVisiblePrompt = buildUserVisiblePrompt(trimmed, taggedPathsSnapshot); - if (!promptForModel && composerImages.length === 0) return; + if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const composerImagesSnapshot = [...composerImages]; @@ -933,7 +948,6 @@ export default function ChatView() { if (activeThread.messages.length === 0) { const titleSeed = trimmed || - (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") || (composerImagesSnapshot.length > 0 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` : "New thread"); @@ -958,13 +972,12 @@ export default function ChatView() { type: "PUSH_USER_MESSAGE", threadId: activeThread.id, id: crypto.randomUUID(), - text: userVisiblePrompt, + text: trimmed, ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), }); const previousMessages = activeThread.messages; setPrompt(""); setComposerImages([]); - setTaggedPaths([]); setComposerCursor(0); setComposerMenuIndex(0); @@ -987,14 +1000,14 @@ export default function ChatView() { const shouldBootstrap = previousMessages.length > 0 && (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); - const latestPromptForBootstrap = promptForModel || IMAGE_ONLY_BOOTSTRAP_PROMPT; + const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; const input = shouldBootstrap ? buildBootstrapInput( previousMessages, latestPromptForBootstrap, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ).text - : promptForModel || undefined; + : trimmed || undefined; await api.providers.sendTurn({ sessionId: sessionInfo.sessionId, ...(input ? { input } : {}), @@ -1094,10 +1107,11 @@ export default function ChatView() { (item: ComposerCommandItem) => { if (!composerTrigger) return; if (item.type === "path") { - setTaggedPaths((existing) => - existing.includes(item.path) ? existing : [...existing, item.path], + applyPromptReplacement( + composerTrigger.rangeStart, + composerTrigger.rangeEnd, + `@${item.path} `, ); - applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); return; } if (item.type === "slash-command") { @@ -1110,10 +1124,6 @@ export default function ChatView() { [applyPromptReplacement, composerTrigger, onModelSelect], ); - const onRemoveTaggedPath = useCallback((pathToRemove: string) => { - setTaggedPaths((existing) => existing.filter((entry) => entry !== pathToRemove)); - }, []); - const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { setPrompt(nextPrompt); setComposerCursor(nextCursor); @@ -1240,12 +1250,16 @@ export default function ChatView() { {composerMenuOpen && (
- +
{composerMenuItems.map((item, index) => ( )} - +
)} - {taggedPaths.length > 0 && ( -
- {taggedPaths.map((taggedPath) => ( - - ))} -
- )} - {composerImages.length > 0 && (
{composerImages.map((image) => ( @@ -1446,7 +1441,7 @@ export default function ChatView() { disabled={ isSending || isConnecting || - (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0) + (!prompt.trim() && composerImages.length === 0) } aria-label={ isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" -- 2.54.0