From 3543276709f72f1ba02834c74cacb4c4f9fe8722 Mon Sep 17 00:00:00 2001 From: Shubham Naik Date: Wed, 26 Nov 2025 11:39:55 -0800 Subject: [PATCH] Fix debounce mechanism (#95) Co-authored-by: Shubham Naik --- .gitignore | 2 +- src/cli/components/FileAutocomplete.tsx | 109 ++++++++++++++---------- src/cli/helpers/fileSearch.ts | 53 +++++++++--- src/tests/fileSearch.test.ts | 47 ++++++++++ 4 files changed, 154 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index f8a6c7f..3eb1031 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Letta Code local settings .letta/settings.local.json - +.letta # User-defined skills .skills diff --git a/src/cli/components/FileAutocomplete.tsx b/src/cli/components/FileAutocomplete.tsx index fa9760c..58552c1 100644 --- a/src/cli/components/FileAutocomplete.tsx +++ b/src/cli/components/FileAutocomplete.tsx @@ -1,5 +1,5 @@ import { Box, Text, useInput } from "ink"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { searchFiles } from "../helpers/fileSearch"; import { colors } from "./colors"; @@ -25,6 +25,7 @@ export function FileAutocomplete({ const [isLoading, setIsLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [lastValidQuery, setLastValidQuery] = useState(""); + const debounceTimeout = useRef(null); // Extract the text after the "@" symbol where the cursor is positioned const extractSearchQuery = useCallback( @@ -94,6 +95,11 @@ export function FileAutocomplete({ }); useEffect(() => { + // Clear any existing debounce timeout + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + const result = extractSearchQuery(currentInput, cursorPosition); if (!result) { @@ -138,7 +144,7 @@ export function FileAutocomplete({ return; } - // If query is empty (just typed "@"), show current directory contents + // If query is empty (just typed "@"), show current directory contents (no debounce) if (query.length === 0) { setIsLoading(true); onActiveChange?.(true); @@ -158,7 +164,7 @@ export function FileAutocomplete({ return; } - // Check if it's a URL pattern + // Check if it's a URL pattern (no debounce) if (query.startsWith("http://") || query.startsWith("https://")) { setMatches([{ path: query, type: "url" }]); setSelectedIndex(0); @@ -166,26 +172,38 @@ export function FileAutocomplete({ return; } - // Search for matching files (deep search through subdirectories) + // Debounce the file search (300ms delay) + // Keep existing matches visible while debouncing setIsLoading(true); onActiveChange?.(true); - searchFiles(query, true) // Enable deep search - .then((results) => { - setMatches(results); - setSelectedIndex(0); - setIsLoading(false); - onActiveChange?.(results.length > 0); - // Remember this query had valid matches - if (results.length > 0) { - setLastValidQuery(query); - } - }) - .catch(() => { - setMatches([]); - setSelectedIndex(0); - setIsLoading(false); - onActiveChange?.(false); - }); + + debounceTimeout.current = setTimeout(() => { + // Search for matching files (deep search through subdirectories) + searchFiles(query, true) // Enable deep search + .then((results) => { + setMatches(results); + setSelectedIndex(0); + setIsLoading(false); + onActiveChange?.(results.length > 0); + // Remember this query had valid matches + if (results.length > 0) { + setLastValidQuery(query); + } + }) + .catch(() => { + setMatches([]); + setSelectedIndex(0); + setIsLoading(false); + onActiveChange?.(false); + }); + }, 300); + + // Cleanup function to clear timeout on unmount + return () => { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + }; }, [ currentInput, cursorPosition, @@ -215,31 +233,34 @@ export function FileAutocomplete({ > File/URL autocomplete (↑↓ to navigate, Tab/Enter to select): + {isLoading && " Searching..."} - {isLoading ? ( - Searching... + {matches.length > 0 ? ( + <> + {matches.slice(0, 10).map((item, idx) => ( + + + {idx === selectedIndex ? "▶ " : " "} + {item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"} + + {item.path} + + ))} + {matches.length > 10 && ( + ... and {matches.length - 10} more + )} + ) : ( - matches.slice(0, 10).map((item, idx) => ( - - - {idx === selectedIndex ? "▶ " : " "} - {item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"} - - {item.path} - - )) - )} - {matches.length > 10 && ( - ... and {matches.length - 10} more + isLoading && Searching... )} ); diff --git a/src/cli/helpers/fileSearch.ts b/src/cli/helpers/fileSearch.ts index cac2172..75cfc62 100644 --- a/src/cli/helpers/fileSearch.ts +++ b/src/cli/helpers/fileSearch.ts @@ -6,6 +6,23 @@ interface FileMatch { type: "file" | "dir" | "url"; } +export function debounce unknown>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + + return function (this: unknown, ...args: Parameters) { + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(() => { + func.apply(this, args); + }, wait); + }; +} + /** * Recursively search a directory for files matching a pattern */ @@ -37,16 +54,16 @@ function searchDirectoryRecursive( const fullPath = join(dir, entry); const stats = statSync(fullPath); - // Check if entry matches the pattern + const relativePath = fullPath.startsWith(process.cwd()) + ? fullPath.slice(process.cwd().length + 1) + : fullPath; + + // Check if entry matches the pattern (match against full relative path for partial path support) const matches = pattern.length === 0 || - entry.toLowerCase().includes(pattern.toLowerCase()); + relativePath.toLowerCase().includes(pattern.toLowerCase()); if (matches) { - const relativePath = fullPath.startsWith(process.cwd()) - ? fullPath.slice(process.cwd().length + 1) - : fullPath; - results.push({ path: relativePath, type: stats.isDirectory() ? "dir" : "file", @@ -87,18 +104,30 @@ export async function searchFiles( let searchDir = process.cwd(); let searchPattern = query; - // Handle relative paths like "./src" or "../test" + // Handle explicit relative/absolute paths or directory navigation + // Treat as directory navigation if: + // 1. Starts with ./ or ../ or / (explicit relative/absolute path) + // 2. Contains / and the directory part exists if (query.includes("/")) { const lastSlashIndex = query.lastIndexOf("/"); const dirPart = query.slice(0, lastSlashIndex); - searchPattern = query.slice(lastSlashIndex + 1); + const pattern = query.slice(lastSlashIndex + 1); - // Resolve the directory path + // Try to resolve the directory path try { - searchDir = resolve(process.cwd(), dirPart); + const resolvedDir = resolve(process.cwd(), dirPart); + // Check if the directory exists by trying to read it + try { + statSync(resolvedDir); + // Directory exists, use it as the search directory + searchDir = resolvedDir; + searchPattern = pattern; + } catch { + // Directory doesn't exist, treat the whole query as a search pattern + // This enables partial path matching like "cd/ef" matching "ab/cd/ef" + } } catch { - // If path doesn't exist, return empty results - return []; + // Path resolution failed, treat as pattern } } diff --git a/src/tests/fileSearch.test.ts b/src/tests/fileSearch.test.ts index 544cc68..7876bf3 100644 --- a/src/tests/fileSearch.test.ts +++ b/src/tests/fileSearch.test.ts @@ -144,3 +144,50 @@ test("searchFiles handles relative path queries", async () => { // Check that at least one result contains App.tsx expect(results.some((r) => r.path.includes("App.tsx"))).toBe(true); }); + +test("searchFiles supports partial path matching (deep)", async () => { + const originalCwd = process.cwd(); + process.chdir(TEST_DIR); + + // Search for "components/Button" should match "src/components/Button.tsx" + const results = await searchFiles("components/Button", true); + + process.chdir(originalCwd); + + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some((r) => r.path.includes("components/Button.tsx"))).toBe( + true, + ); +}); + +test("searchFiles supports partial directory path matching (deep)", async () => { + const originalCwd = process.cwd(); + process.chdir(TEST_DIR); + + // Search for "src/components" should match the directory + const results = await searchFiles("src/components", true); + + process.chdir(originalCwd); + + expect(results.length).toBeGreaterThanOrEqual(1); + expect( + results.some((r) => r.path === "src/components" && r.type === "dir"), + ).toBe(true); +}); + +test("searchFiles partial path matching works with subdirectories", async () => { + const originalCwd = process.cwd(); + process.chdir(TEST_DIR); + + // Create nested directory + mkdirSync(join(TEST_DIR, "ab/cd/ef"), { recursive: true }); + writeFileSync(join(TEST_DIR, "ab/cd/ef/test.txt"), "test"); + + // Search for "cd/ef" should match "ab/cd/ef" + const results = await searchFiles("cd/ef", true); + + process.chdir(originalCwd); + + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some((r) => r.path.includes("cd/ef"))).toBe(true); +});