- render theme-aware VS Code icons for path suggestions in chat composer - exclude `.convex` and gitignored paths more reliably in workspace indexing - add tests plus a sync script and icon manifest/association data
Showing the changes to apps/server/src (2 of 9 files). Show all
Unified · Split
| @@ -3,7 +3,7 @@ import os from "node:os"; · 2 unmodified lines | ||
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | import { spawnSync } from "node:child_process"; |
| 5 | 5 | |
| 6 | −import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | +import { afterEach, assert, describe, it } from "vitest"; | |
| 7 | 7 | |
| 8 | 8 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 9 | 9 | |
| @@ -46,13 +46,13 @@ describe("searchWorkspaceEntries", () => { · 36 unmodified lines | ||
| 46 | 46 | const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); |
| 47 | 47 | const paths = result.entries.map((entry) => entry.path); |
| 48 | 48 | |
| 49 | − expect(paths).toContain("src"); | |
| 50 | − expect(paths).toContain("src/components"); | |
| 51 | − expect(paths).toContain("src/components/Composer.tsx"); | |
| 52 | − expect(paths).toContain("README.md"); | |
| 53 | − expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false); | |
| 54 | − expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); | |
| 55 | − expect(result.truncated).toBe(false); | |
| 49 | + assert.include(paths, "src"); | |
| 50 | + assert.include(paths, "src/components"); | |
| 51 | + assert.include(paths, "src/components/Composer.tsx"); | |
| 52 | + assert.include(paths, "README.md"); | |
| 53 | + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git"))); | |
| 54 | + assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules"))); | |
| 55 | + assert.isFalse(result.truncated); | |
| 56 | 56 | }); |
| 57 | 57 | |
| 58 | 58 | it("filters and ranks entries by query", async () => { |
| @@ -63,25 +63,56 @@ describe("searchWorkspaceEntries", () => { · 4 unmodified lines | ||
| 63 | 63 | |
| 64 | 64 | const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); |
| 65 | 65 | |
| 66 | − expect(result.entries.length).toBeGreaterThan(0); | |
| 67 | − expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); | |
| 68 | − expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true); | |
| 66 | + assert.isAbove(result.entries.length, 0); | |
| 67 | + assert.isTrue(result.entries.some((entry) => entry.path === "src/components")); | |
| 68 | + assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))); | |
| 69 | 69 | }); |
| 70 | 70 | |
| 71 | 71 | it("excludes gitignored paths for git repositories", async () => { |
| 72 | 72 | const cwd = makeTempDir("t3code-workspace-gitignore-"); |
| 73 | 73 | runGit(cwd, ["init"]); |
| 74 | − writeFile(cwd, ".gitignore", ".convex/\nignored.txt\n"); | |
| 74 | + writeFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n"); | |
| 75 | 75 | writeFile(cwd, "src/keep.ts", "export {};"); |
| 76 | 76 | writeFile(cwd, "ignored.txt", "ignore me"); |
| 77 | 77 | writeFile(cwd, ".convex/local-storage/data.json", "{}"); |
| 78 | + writeFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}"); | |
| 78 | 79 | |
| 79 | 80 | const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); |
| 80 | 81 | const paths = result.entries.map((entry) => entry.path); |
| 81 | 82 | |
| 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); | |
| 83 | + assert.include(paths, "src"); | |
| 84 | + assert.include(paths, "src/keep.ts"); | |
| 85 | + assert.notInclude(paths, "ignored.txt"); | |
| 86 | + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); | |
| 87 | + assert.isFalse(paths.some((entryPath) => entryPath.startsWith("convex/"))); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it("excludes tracked paths that match ignore rules", async () => { | |
| 91 | + const cwd = makeTempDir("t3code-workspace-tracked-gitignore-"); | |
| 92 | + runGit(cwd, ["init"]); | |
| 93 | + writeFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 94 | + writeFile(cwd, "src/keep.ts", "export {};"); | |
| 95 | + runGit(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]); | |
| 96 | + writeFile(cwd, ".gitignore", ".convex/\n"); | |
| 97 | + | |
| 98 | + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 99 | + const paths = result.entries.map((entry) => entry.path); | |
| 100 | + | |
| 101 | + assert.include(paths, "src"); | |
| 102 | + assert.include(paths, "src/keep.ts"); | |
| 103 | + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + it("excludes .convex in non-git workspaces", async () => { | |
| 107 | + const cwd = makeTempDir("t3code-workspace-non-git-convex-"); | |
| 108 | + writeFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 109 | + writeFile(cwd, "src/keep.ts", "export {};"); | |
| 110 | + | |
| 111 | + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 112 | + const paths = result.entries.map((entry) => entry.path); | |
| 113 | + | |
| 114 | + assert.include(paths, "src"); | |
| 115 | + assert.include(paths, "src/keep.ts"); | |
| 116 | + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); | |
| 86 | 117 | }); |
| 87 | 118 | }); |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import fs from "node:fs/promises"; |
| 2 | +import type { Dirent } from "node:fs"; | |
| 2 | 3 | import path from "node:path"; |
| 3 | 4 | import { runProcess } from "./processRunner"; |
| 4 | 5 | |
| @@ -14,6 +15,7 @@ const WORKSPACE_CACHE_MAX_KEYS = 4; · 9 unmodified lines | ||
| 14 | 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 15 | 16 | const IGNORED_DIRECTORY_NAMES = new Set([ |
| 16 | 17 | ".git", |
| 18 | + ".convex", | |
| 17 | 19 | "node_modules", |
| 18 | 20 | ".next", |
| 19 | 21 | ".turbo", |
| @@ -99,14 +101,52 @@ function directoryAncestorsOf(relativePath: string): string[] { · 79 unmodified lines | ||
| 99 | 101 | return directories; |
| 100 | 102 | } |
| 101 | 103 | |
| 102 | −async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> { | |
| 104 | +async function isInsideGitWorkTree(cwd: string): Promise<boolean> { | |
| 103 | 105 | const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { |
| 104 | 106 | cwd, |
| 105 | 107 | allowNonZeroExit: true, |
| 106 | 108 | timeoutMs: 5_000, |
| 107 | 109 | maxBufferBytes: 4_096, |
| 108 | 110 | }).catch(() => null); |
| 109 | − if (!insideWorkTree || insideWorkTree.code !== 0 || insideWorkTree.stdout.trim() !== "true") { | |
| 111 | + return Boolean( | |
| 112 | + insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true", | |
| 113 | + ); | |
| 114 | +} | |
| 115 | + | |
| 116 | +async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise<string[]> { | |
| 117 | + if (relativePaths.length === 0) { | |
| 118 | + return relativePaths; | |
| 119 | + } | |
| 120 | + | |
| 121 | + const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], { | |
| 122 | + cwd, | |
| 123 | + allowNonZeroExit: true, | |
| 124 | + timeoutMs: 20_000, | |
| 125 | + maxBufferBytes: 16 * 1024 * 1024, | |
| 126 | + outputMode: "truncate", | |
| 127 | + stdin: `${relativePaths.join("\0")}\0`, | |
| 128 | + }).catch(() => null); | |
| 129 | + if (!checkIgnore) { | |
| 130 | + return relativePaths; | |
| 131 | + } | |
| 132 | + | |
| 133 | + // git-check-ignore exits with 1 when no paths match. | |
| 134 | + if (checkIgnore.code !== 0 && checkIgnore.code !== 1) { | |
| 135 | + return relativePaths; | |
| 136 | + } | |
| 137 | + | |
| 138 | + const ignoredPaths = new Set( | |
| 139 | + splitNullSeparatedPaths(checkIgnore.stdout, Boolean(checkIgnore.stdoutTruncated)), | |
| 140 | + ); | |
| 141 | + if (ignoredPaths.size === 0) { | |
| 142 | + return relativePaths; | |
| 143 | + } | |
| 144 | + | |
| 145 | + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); | |
| 146 | +} | |
| 147 | + | |
| 148 | +async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> { | |
| 149 | + if (!(await isInsideGitWorkTree(cwd))) { | |
| 110 | 150 | return null; |
| 111 | 151 | } |
| 112 | 152 | |
| @@ -125,9 +165,13 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | · 12 unmodified lines | ||
| 125 | 165 | return null; |
| 126 | 166 | } |
| 127 | 167 | |
| 128 | − const filePaths = splitNullSeparatedPaths(listedFiles.stdout, Boolean(listedFiles.stdoutTruncated)) | |
| 168 | + const listedPaths = splitNullSeparatedPaths( | |
| 169 | + listedFiles.stdout, | |
| 170 | + Boolean(listedFiles.stdoutTruncated), | |
| 171 | + ) | |
| 129 | 172 | .map((entry) => toPosixPath(entry)) |
| 130 | 173 | .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); |
| 174 | + const filePaths = await filterGitIgnoredPaths(cwd, listedPaths); | |
| 131 | 175 | |
| 132 | 176 | const directorySet = new Set<string>(); |
| 133 | 177 | for (const filePath of filePaths) { |
| @@ -167,6 +211,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 33 unmodified lines | ||
| 167 | 211 | if (gitIndexed) { |
| 168 | 212 | return gitIndexed; |
| 169 | 213 | } |
| 214 | + const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); | |
| 170 | 215 | |
| 171 | 216 | let pendingDirectories: string[] = [""]; |
| 172 | 217 | const entries: ProjectEntry[] = []; |
| @@ -193,13 +238,12 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 20 unmodified lines | ||
| 193 | 238 | }), |
| 194 | 239 | ); |
| 195 | 240 | |
| 196 | − for (const directoryEntry of directoryEntries) { | |
| 241 | + const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { | |
| 197 | 242 | const { relativeDir, dirents } = directoryEntry; |
| 198 | − if (!dirents) { | |
| 199 | − continue; | |
| 200 | − } | |
| 243 | + if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; | |
| 201 | 244 | |
| 202 | 245 | dirents.sort((left, right) => left.name.localeCompare(right.name)); |
| 246 | + const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; | |
| 203 | 247 | for (const dirent of dirents) { |
| 204 | 248 | if (!dirent.name || dirent.name === "." || dirent.name === "..") { |
| 205 | 249 | continue; |
| @@ -217,15 +261,33 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 11 unmodified lines | ||
| 217 | 261 | if (isPathInIgnoredDirectory(relativePath)) { |
| 218 | 262 | continue; |
| 219 | 263 | } |
| 264 | + candidates.push({ dirent, relativePath }); | |
| 265 | + } | |
| 266 | + return candidates; | |
| 267 | + }); | |
| 268 | + | |
| 269 | + const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => | |
| 270 | + candidateEntries.map((entry) => entry.relativePath), | |
| 271 | + ); | |
| 272 | + const allowedPathSet = shouldFilterWithGitIgnore | |
| 273 | + ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths)) | |
| 274 | + : null; | |
| 275 | + | |
| 276 | + for (const candidateEntries of candidateEntriesByDirectory) { | |
| 277 | + for (const candidate of candidateEntries) { | |
| 278 | + if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { | |
| 279 | + continue; | |
| 280 | + } | |
| 281 | + | |
| 220 | 282 | const entry: ProjectEntry = { |
| 221 | − path: relativePath, | |
| 222 | − kind: dirent.isDirectory() ? "directory" : "file", | |
| 223 | − parentPath: parentPathOf(relativePath), | |
| 283 | + path: candidate.relativePath, | |
| 284 | + kind: candidate.dirent.isDirectory() ? "directory" : "file", | |
| 285 | + parentPath: parentPathOf(candidate.relativePath), | |
| 224 | 286 | }; |
| 225 | 287 | entries.push(entry); |
| 226 | 288 | |
| 227 | − if (dirent.isDirectory()) { | |
| 228 | − pendingDirectories.push(relativePath); | |
| 289 | + if (candidate.dirent.isDirectory()) { | |
| 290 | + pendingDirectories.push(candidate.relativePath); | |
| 229 | 291 | } |
| 230 | 292 | |
| 231 | 293 | if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { |