diff --git a/src/search/windowsSearchAdapter.js b/src/search/windowsSearchAdapter.js index 7716172..30f5c74 100644 --- a/src/search/windowsSearchAdapter.js +++ b/src/search/windowsSearchAdapter.js @@ -137,9 +137,9 @@ function buildWindowsSearchCommand(search) { "if ($scopeClauses.Count -eq 0) { exit 0 }", "$whereClauses = @()", "$whereClauses += '(' + ($scopeClauses -join ' OR ') + ')'", - "$whereClauses += \"System.ItemTypeText <> 'File folder'\"", "foreach ($term in $terms) {", - " $whereClauses += \"System.FileName LIKE '%\" + (Escape-LikeLiteral $term) + \"%'\"", + " $pathTerm = $term.Replace('/', [string][char]92)", + " $whereClauses += \"System.ItemPathDisplay LIKE '%\" + (Escape-LikeLiteral $pathTerm) + \"%'\"", "}", "if ($modifiedWithinDays) {", " $cutoff = (Get-Date).AddDays(-[int]$modifiedWithinDays).ToString('yyyy-MM-ddTHH:mm:ss')", @@ -157,7 +157,6 @@ function buildWindowsSearchCommand(search) { " while ($reader.Read() -and $count -lt $maxResults) {", " $path = [string]$reader.GetValue(0)", " try { $file = Get-Item -LiteralPath $path -ErrorAction Stop } catch { continue }", - " if ($file.PSIsContainer) { continue }", " [Console]::Out.Write($file.FullName + [char]0)", " $count += 1", " }", @@ -294,16 +293,18 @@ function resolveRipgrepPath(options = {}) { return candidates.find((candidate) => candidate && fileExists(candidate)) || "rg"; } +function normalizeFilenameComparable(value) { + return String(value || "").trim().toLowerCase().replace(/\\/g, "/"); +} + function splitSearchTerms(query) { - return String(query || "") - .trim() - .toLowerCase() + return normalizeFilenameComparable(query) .split(/\s+/) .filter(Boolean); } function matchesFilenameSearch(filePath, terms) { - const candidate = String(filePath || "").toLowerCase(); + const candidate = normalizeFilenameComparable(filePath); return terms.every((term) => candidate.includes(term)); } @@ -331,7 +332,7 @@ function directFilePathResult(search, roots) { try { const stats = statSync(filePath); - if (!stats.isFile() || !isModifiedWithin(filePath, search.modifiedWithinDays, stats)) { + if ((!stats.isFile() && !stats.isDirectory()) || !isModifiedWithin(filePath, search.modifiedWithinDays, stats)) { return []; } } catch { @@ -341,6 +342,11 @@ function directFilePathResult(search, roots) { return [{ source: "windows", target: driveTargetForPath(filePath), path: filePath }]; } +function isPathLikeFilenameQuery(query) { + const normalized = String(query || "").trim().replace(/\\/g, "/"); + return normalized.includes("/") || normalized.startsWith("."); +} + function runCommand(command, timeoutMs) { return new Promise((resolve, reject) => { const controller = new AbortController(); @@ -388,7 +394,28 @@ function runFilenameSearch(search, timeoutMs = SEARCH_TIMEOUT_MS) { const results = []; let buffer = ""; let settled = false; - const child = spawn(resolveRipgrepPath(), ["--files", "-0", ...roots], { windowsHide: true }); + const child = spawn( + resolveRipgrepPath(), + [ + "--files", + "-0", + "--hidden", + "--glob", + "!.git", + "--glob", + "!node_modules", + "--glob", + "!.cache", + "--glob", + "!.venv", + "--glob", + "!__pycache__", + "--glob", + "!dist", + ...roots, + ], + { windowsHide: true } + ); const timeout = setTimeout(() => finish(new Error("Command timed out.")), timeoutMs); function finish(error) { @@ -451,7 +478,11 @@ function shouldUseFilesystemFilenameSearch(search, roots) { return false; } const profileRoot = process.env.USERPROFILE ? normalizeComparableWindowsRoot(process.env.USERPROFILE) : ""; - return search.root === ALL_WINDOWS_DRIVES || roots.some((root) => normalizeComparableWindowsRoot(root) !== profileRoot); + return ( + search.root === ALL_WINDOWS_DRIVES || + isPathLikeFilenameQuery(search.query) || + roots.some((root) => normalizeComparableWindowsRoot(root) !== profileRoot) + ); } function createWindowsSearchAdapter(options = {}) { diff --git a/src/search/wslSearchAdapter.js b/src/search/wslSearchAdapter.js index f7fd043..4d5e84a 100644 --- a/src/search/wslSearchAdapter.js +++ b/src/search/wslSearchAdapter.js @@ -15,6 +15,10 @@ function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } +function escapeWslCommandArgument(value) { + return String(value).replace(/\$/g, "\\$"); +} + function parsePositiveInteger(value, fallback) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 1) { @@ -27,13 +31,34 @@ function splitSearchTerms(query) { return query.split(/\s+/).filter(Boolean); } +function parseWslUncPath(value) { + const normalized = String(value || "").trim().replace(/\\/g, "/"); + const match = normalized.match(/^\/\/wsl(?:\.localhost|\$)\/([^/]+)(\/.*)?$/i); + if (!match || !match[1]) { + return null; + } + return { distro: match[1], path: match[2] || "/" }; +} + +function normalizeWslRoot(root) { + if (root === "/") { + return root; + } + return root.replace(/\/+$/, "") || "/"; +} + function buildPruneExpression() { return PRUNED_DIRECTORY_NAMES.map((name) => `-name ${shellQuote(name)}`).join(" -o "); } -function buildFindFilesCommand(root, filePredicates) { +function buildFindPathsCommand(root) { const pruneExpression = buildPruneExpression(); - return `find ${root} \\( -type d \\( ${pruneExpression} \\) -prune \\) -o -type f${filePredicates} -print0 2>/dev/null`; + return `find ${root} \\( -type d \\( ${pruneExpression} \\) -prune \\) -o \\( -type f -o -type d \\) -print0 2>/dev/null`; +} + +function buildFindDirectoriesCommand(root) { + const pruneExpression = buildPruneExpression(); + return `find ${root} \\( -type d \\( ${pruneExpression} \\) -prune \\) -o -type d -print0 2>/dev/null`; } function buildRipgrepFilesCommand(root) { @@ -84,9 +109,11 @@ function buildPythonMtimeFilter(modifiedWithinDays, maxResults) { function validateSearchInput(input) { const root = String(input.root || DEFAULT_ROOT).trim(); - const query = String(input.query || "").trim(); + const rawQuery = String(input.query || "").trim(); + const wslUncPath = parseWslUncPath(rawQuery); + const query = wslUncPath ? wslUncPath.path : rawQuery; const mode = String(input.mode || "content").trim(); - const distro = String(input.distro || ALL_DISTROS).trim(); + const distro = wslUncPath ? wslUncPath.distro : String(input.distro || ALL_DISTROS).trim(); const modifiedWithinDays = String(input.modifiedWithinDays || "").trim(); const maxResults = parsePositiveInteger(input.maxResults, DEFAULT_MAX_RESULTS); @@ -109,6 +136,24 @@ function validateSearchInput(input) { return { distro, root, query, mode, modifiedWithinDays, maxResults }; } +function buildDirectPathPrefix(search) { + const root = shellQuote(normalizeWslRoot(search.root)); + const query = shellQuote(search.query); + const output = search.modifiedWithinDays + ? `find "$candidate" -maxdepth 0 -mtime -${search.modifiedWithinDays} -print0 2>/dev/null` + : `printf '%s\\0' "$candidate"`; + + return [ + `root_path=${root}`, + `candidate=${query}`, + "under_root=0", + "[ \"$root_path\" = \"/\" ] && under_root=1", + "[ \"$candidate\" = \"$root_path\" ] && under_root=1", + "case \"$candidate\" in \"$root_path\"/*) under_root=1 ;; esac", + `if [ -e "$candidate" ] && [ "$under_root" = "1" ]; then ${output}; exit 0; fi`, + ].join("; "); +} + function validateOpenInput(input) { const distro = String(input.distro || "").trim(); const path = String(input.path || "").trim(); @@ -134,26 +179,23 @@ function buildSearchScript(search) { let searchCommand; if (search.mode === "filename") { - const namePredicates = splitSearchTerms(search.query) - .map((term) => `-iname ${shellQuote(`*${term}*`)}`) - .join(" "); - const fastFileList = `${buildRipgrepFilesCommand(root)} | ${buildNullSeparatedTermFilters(search.query)}`; - const fallbackFileList = buildFindFilesCommand(root, ` ${namePredicates}${modifiedPredicate}`); + const fastPathList = `{ ${buildRipgrepFilesCommand(root)}; ${buildFindDirectoriesCommand(root)}; } | ${buildNullSeparatedTermFilters(search.query)}`; + const fallbackPathList = `${buildFindPathsCommand(root)} | ${buildNullSeparatedTermFilters(search.query)}`; const filteredFileList = search.modifiedWithinDays - ? `${fastFileList} | ${buildPythonMtimeFilter(search.modifiedWithinDays, maxResults)}` - : fastFileList; - searchCommand = `if command -v rg >/dev/null 2>&1; then ${filteredFileList}; else ${fallbackFileList}; fi | head -z -n ${maxResults}`; - return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`; + ? `${fastPathList} | ${buildPythonMtimeFilter(search.modifiedWithinDays, maxResults)}` + : fastPathList; + searchCommand = `${buildDirectPathPrefix(search)}; if command -v rg >/dev/null 2>&1; then ${filteredFileList}; else ${fallbackPathList}${modifiedPredicate ? ` | ${buildPythonMtimeFilter(search.modifiedWithinDays, maxResults)}` : ""}; fi | head -z -n ${maxResults}`; + return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`); } if (search.modifiedWithinDays) { const mtimeFilter = buildPythonMtimeFilter(search.modifiedWithinDays, maxResults); searchCommand = `if command -v rg >/dev/null 2>&1; then rg -i -l -0 -F --hidden --glob '!.git' --glob '!node_modules' --glob '!.cache' --glob '!.venv' --glob '!__pycache__' -- ${query} ${root}; else grep -RIlZ --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.cache --exclude-dir=.venv --exclude-dir=__pycache__ -- ${query} ${root} 2>/dev/null; fi | ${mtimeFilter} | head -z -n ${maxResults}`; - return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`; + return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`); } searchCommand = `if command -v rg >/dev/null 2>&1; then rg -i -l -0 -F --hidden --glob '!.git' --glob '!node_modules' --glob '!.cache' -- ${query} ${root}; else grep -RIlZ --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.cache -- ${query} ${root} 2>/dev/null; fi | head -z -n ${maxResults}`; - return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`; + return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`); } function buildSearchCommand(input) { diff --git a/test/search.test.js b/test/search.test.js index 0cec89a..23a49bb 100644 --- a/test/search.test.js +++ b/test/search.test.js @@ -60,7 +60,8 @@ test("buildSearchCommand creates a filename search", () => { }); assert.match(command[6], /find/); - assert.match(command[6], /-iname/); + assert.match(command[6], /-type f -o -type d/); + assert.match(command[6], /grep -z -i -F/); assert.match(command[6], /timeout 12s/); assert.match(command[6], /head -z -n 10/); }); @@ -78,8 +79,9 @@ test("buildSearchCommand adds last edited filter to filename search", () => { assert.match(command[6], /rg --files -0/); assert.match(command[6], /grep -z -i -F/); assert.match(command[6], /-mtime -7/); - assert.match(command[6], /xargs -0 -r -I\{\} find "\{\}" -maxdepth 0 -mtime -7 -print0/); - assert.match(command[6], /\*invoice\*/); + assert.match(command[6], /python3 -c/); + assert.match(command[6], /os\.path\.getmtime/); + assert.match(command[6], /grep -z -i -F -- '\\''invoice'\\''/); }); test("buildSearchCommand adds last edited filter to content search", () => { @@ -108,8 +110,9 @@ test("buildSearchCommand quotes filename search patterns for the shell", () => { maxResults: 10, }); - assert.match(command[6], /\*\$\(touch\*/); - assert.match(command[6], /\*\/tmp\/wsl-search-bad\)\*/); + assert.match(command[6], /grep -z -i -F -- '\\''\\\$\(touch'\\''/); + assert.match(command[6], /grep -z -i -F -- '\\''\/tmp\/wsl-search-bad\)'\\''/); + assert.doesNotMatch(command[6], /\{;/); }); test("buildSearchCommand turns spaced filename searches into required terms", () => { @@ -121,8 +124,49 @@ test("buildSearchCommand turns spaced filename searches into required terms", () maxResults: 10, }); - assert.match(command[6], /\*project\*/); - assert.match(command[6], /\*notes\*/); + assert.match(command[6], /grep -z -i -F -- '\\''project'\\''/); + assert.match(command[6], /grep -z -i -F -- '\\''notes'\\''/); +}); + +test("buildSearchCommand includes directories in WSL filename searches", () => { + const command = buildSearchCommand({ + distro: "OpenCockLab", + root: "/home", + query: "tdd-seam-and-deep-modules-prd-v2.md", + mode: "filename", + maxResults: 10, + }); + + assert.match(command[6], /-type f -o -type d/); + assert.match(command[6], /grep -z -i -F/); + assert.match(command[6], /tdd-seam-and-deep-modules-prd-v2\.md/); +}); + +test("buildSearchCommand resolves exact WSL path-like filename searches before broad search", () => { + const command = buildSearchCommand({ + distro: "OpenCockLab", + root: "/home", + query: "/home/prop_/.codex/plans/tdd-seam-and-deep-modules-prd-v2.md", + mode: "filename", + maxResults: 10, + }); + + assert.match(command[6], /if \[ -e/); + assert.match(command[6], /\\\$candidate/); + assert.match(command[6], /\/home\/prop_\/\.codex\/plans\/tdd-seam-and-deep-modules-prd-v2\.md/); +}); + +test("buildSearchCommand normalizes WSL UNC filename path queries", () => { + const command = buildSearchCommand({ + root: "/home", + query: "\\\\wsl.localhost\\OpenCockLab\\home\\prop_\\.codex\\plans\\tdd-seam-and-deep-modules-prd-v2.md", + mode: "filename", + maxResults: 10, + }); + + assert.deepEqual(command.slice(0, 3), ["wsl.exe", "-d", "OpenCockLab"]); + assert.match(command[6], /\/home\/prop_\/\.codex\/plans\/tdd-seam-and-deep-modules-prd-v2\.md/); + assert.doesNotMatch(command[6], /wsl\.localhost/); }); test("parseSearchOutput preserves paths with spaces", () => { diff --git a/test/windowsSearch.test.js b/test/windowsSearch.test.js index 20e5f62..3abef53 100644 --- a/test/windowsSearch.test.js +++ b/test/windowsSearch.test.js @@ -15,6 +15,7 @@ const { parseWindowsSearchOutput, resolveRipgrepPath, runCommand, + runFilenameSearch, validateWindowsOpenInput, validateWindowsSearchInput, } = require("../src/search/windowsSearchAdapter"); @@ -112,7 +113,7 @@ test("buildWindowsSearchCommand creates an indexed selected-root filename search assert.match(script, /Search\.CollatorDSO/); assert.match(script, /SYSTEMINDEX/); assert.match(script, /SELECT TOP \$maxResults/); - assert.match(script, /System\.FileName/); + assert.match(script, /System\.ItemPathDisplay/); assert.match(script, /Write\(\$file\.FullName \+ \[char\]0\)/); assert.deepEqual(extractCommandConfig(command), { query: "project notes", @@ -136,9 +137,27 @@ test("buildWindowsSearchCommand avoids filesystem walking for filename searches" assert.match(script, /Search\.CollatorDSO/); assert.match(script, /System\.ItemPathDisplay/); + assert.doesNotMatch(script, /System\.ItemTypeText <> 'File folder'/); + assert.doesNotMatch(script, /PSIsContainer\) \{ continue \}/); assert.doesNotMatch(script, /Get-ChildItem -LiteralPath \$current -File/); }); +test("buildWindowsSearchCommand treats path-like filename terms as literal full-path terms", () => { + const command = buildWindowsSearchCommand({ + query: "Downloads/deep-python-module-architecture-prd.md", + mode: "filename", + modifiedWithinDays: "", + maxResults: 10, + roots: ["C:\\Users\\Me"], + }); + + const script = decodePowerShellCommand(command); + + assert.match(script, /\$pathTerm = \$term\.Replace\('\//); + assert.match(script, /System\.ItemPathDisplay LIKE/); + assert.doesNotMatch(script, /System\.FileName LIKE/); +}); + test("buildWindowsSearchCommand applies content and last-edited filters", () => { const command = buildWindowsSearchCommand({ query: "rent ledger", @@ -250,6 +269,40 @@ test("runSearch uses filename filesystem search for all-drive filename searches" ]); }); +test("runSearch uses filename filesystem search for path-like profile filename searches", { skip: !HAS_USERPROFILE }, async () => { + const commandCalls = []; + const filenameCalls = []; + const profileRoot = process.env.USERPROFILE.replace(/\//g, "\\"); + const adapter = createWindowsSearchAdapter({ + runCommand: async (command) => { + commandCalls.push(command); + return "indexed search should not run"; + }, + runFilenameSearch: async (search) => { + filenameCalls.push(search); + return `${profileRoot}\\.codex\\plans\\tdd-seam-and-deep-modules-prd-v2.md\0`; + }, + }); + + assert.deepEqual( + await adapter.runSearch({ + query: ".codex/plans/tdd-seam-and-deep-modules-prd-v2.md", + mode: "filename", + root: profileRoot, + maxResults: "10", + }), + [ + { + source: "windows", + target: driveTargetForTestPath(profileRoot), + path: `${profileRoot}\\.codex\\plans\\tdd-seam-and-deep-modules-prd-v2.md`, + }, + ] + ); + assert.deepEqual(commandCalls, []); + assert.deepEqual(filenameCalls[0].roots, [profileRoot]); +}); + test("runSearch resolves a forward-slash Windows file path with spaces directly", { skip: !IS_WINDOWS }, async () => { const root = mkdtempSync(join(tmpdir(), "wsl-search path test-")); const directory = join(root, "test-driven development"); @@ -282,6 +335,36 @@ test("runSearch resolves a forward-slash Windows file path with spaces directly" } }); +test("runSearch resolves an exact Windows directory path directly", { skip: !IS_WINDOWS }, async () => { + const root = mkdtempSync(join(tmpdir(), "wsl-search-dir-path-test-")); + const directory = join(root, ".codex-plans"); + mkdirSync(directory); + + const adapter = createWindowsSearchAdapter({ + runCommand: async () => { + throw new Error("broad search should not run"); + }, + runFilenameSearch: async () => { + throw new Error("broad search should not run"); + }, + }); + + try { + assert.deepEqual( + await adapter.runSearch({ + source: "windows", + query: directory, + mode: "filename", + root, + maxResults: "10", + }), + [{ source: "windows", target: driveTargetForTestPath(directory), path: directory }] + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("runSearch does not let direct path queries escape a selected Windows root", { skip: !IS_WINDOWS }, async () => { const root = mkdtempSync(join(tmpdir(), "wsl-search-root-test-")); const selectedRoot = join(root, "selected"); @@ -361,6 +444,34 @@ test("listDrives returns discovered Windows drive roots", async () => { assert.deepEqual(await adapter.listDrives(), ["C:\\", "E:\\"]); }); +test("runFilenameSearch finds hidden dot paths and slash path fragments", { skip: !IS_WINDOWS }, async () => { + const root = mkdtempSync(join(tmpdir(), "wsl-search-hidden-path-test-")); + const directory = join(root, ".codex", "plans"); + const filePath = join(directory, "tdd-seam-and-deep-modules-prd-v2.md"); + mkdirSync(directory, { recursive: true }); + writeFileSync(filePath, "prd"); + + try { + const output = await runFilenameSearch( + { + query: ".codex/plans/tdd-seam-and-deep-modules-prd-v2.md", + mode: "filename", + root, + modifiedWithinDays: "", + maxResults: 10, + roots: [root], + }, + 10000 + ); + + assert.deepEqual(parseWindowsSearchOutput(output), [ + { source: "windows", target: driveTargetForTestPath(filePath), path: filePath }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("runSearch parses indexed filename results", { skip: !HAS_USERPROFILE }, async () => { const adapter = createWindowsSearchAdapter({ runCommand: async () => "C:\\Users\\Me\\fairway-alpha.txt\0C:\\Users\\Me\\fairway-beta.txt\0",