Fix debounce mechanism (#95)

Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
Shubham Naik
2025-11-26 11:39:55 -08:00
committed by GitHub
parent 8b3523c1a3
commit 3543276709
4 changed files with 154 additions and 57 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
# Letta Code local settings # Letta Code local settings
.letta/settings.local.json .letta/settings.local.json
.letta
# User-defined skills # User-defined skills
.skills .skills

View File

@@ -1,5 +1,5 @@
import { Box, Text, useInput } from "ink"; 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 { searchFiles } from "../helpers/fileSearch";
import { colors } from "./colors"; import { colors } from "./colors";
@@ -25,6 +25,7 @@ export function FileAutocomplete({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [lastValidQuery, setLastValidQuery] = useState<string>(""); const [lastValidQuery, setLastValidQuery] = useState<string>("");
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
// Extract the text after the "@" symbol where the cursor is positioned // Extract the text after the "@" symbol where the cursor is positioned
const extractSearchQuery = useCallback( const extractSearchQuery = useCallback(
@@ -94,6 +95,11 @@ export function FileAutocomplete({
}); });
useEffect(() => { useEffect(() => {
// Clear any existing debounce timeout
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
const result = extractSearchQuery(currentInput, cursorPosition); const result = extractSearchQuery(currentInput, cursorPosition);
if (!result) { if (!result) {
@@ -138,7 +144,7 @@ export function FileAutocomplete({
return; 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) { if (query.length === 0) {
setIsLoading(true); setIsLoading(true);
onActiveChange?.(true); onActiveChange?.(true);
@@ -158,7 +164,7 @@ export function FileAutocomplete({
return; return;
} }
// Check if it's a URL pattern // Check if it's a URL pattern (no debounce)
if (query.startsWith("http://") || query.startsWith("https://")) { if (query.startsWith("http://") || query.startsWith("https://")) {
setMatches([{ path: query, type: "url" }]); setMatches([{ path: query, type: "url" }]);
setSelectedIndex(0); setSelectedIndex(0);
@@ -166,26 +172,38 @@ export function FileAutocomplete({
return; return;
} }
// Search for matching files (deep search through subdirectories) // Debounce the file search (300ms delay)
// Keep existing matches visible while debouncing
setIsLoading(true); setIsLoading(true);
onActiveChange?.(true); onActiveChange?.(true);
searchFiles(query, true) // Enable deep search
.then((results) => { debounceTimeout.current = setTimeout(() => {
setMatches(results); // Search for matching files (deep search through subdirectories)
setSelectedIndex(0); searchFiles(query, true) // Enable deep search
setIsLoading(false); .then((results) => {
onActiveChange?.(results.length > 0); setMatches(results);
// Remember this query had valid matches setSelectedIndex(0);
if (results.length > 0) { setIsLoading(false);
setLastValidQuery(query); onActiveChange?.(results.length > 0);
} // Remember this query had valid matches
}) if (results.length > 0) {
.catch(() => { setLastValidQuery(query);
setMatches([]); }
setSelectedIndex(0); })
setIsLoading(false); .catch(() => {
onActiveChange?.(false); setMatches([]);
}); setSelectedIndex(0);
setIsLoading(false);
onActiveChange?.(false);
});
}, 300);
// Cleanup function to clear timeout on unmount
return () => {
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
};
}, [ }, [
currentInput, currentInput,
cursorPosition, cursorPosition,
@@ -215,31 +233,34 @@ export function FileAutocomplete({
> >
<Text dimColor> <Text dimColor>
File/URL autocomplete ( to navigate, Tab/Enter to select): File/URL autocomplete ( to navigate, Tab/Enter to select):
{isLoading && " Searching..."}
</Text> </Text>
{isLoading ? ( {matches.length > 0 ? (
<Text dimColor>Searching...</Text> <>
{matches.slice(0, 10).map((item, idx) => (
<Box key={item.path} flexDirection="row" gap={1}>
<Text
color={
idx === selectedIndex
? colors.status.success
: item.type === "dir"
? colors.status.processing
: undefined
}
bold={idx === selectedIndex}
>
{idx === selectedIndex ? "▶ " : " "}
{item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"}
</Text>
<Text bold={idx === selectedIndex}>{item.path}</Text>
</Box>
))}
{matches.length > 10 && (
<Text dimColor>... and {matches.length - 10} more</Text>
)}
</>
) : ( ) : (
matches.slice(0, 10).map((item, idx) => ( isLoading && <Text dimColor>Searching...</Text>
<Box key={item.path} flexDirection="row" gap={1}>
<Text
color={
idx === selectedIndex
? colors.status.success
: item.type === "dir"
? colors.status.processing
: undefined
}
bold={idx === selectedIndex}
>
{idx === selectedIndex ? "▶ " : " "}
{item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"}
</Text>
<Text bold={idx === selectedIndex}>{item.path}</Text>
</Box>
))
)}
{matches.length > 10 && (
<Text dimColor>... and {matches.length - 10} more</Text>
)} )}
</Box> </Box>
); );

View File

@@ -6,6 +6,23 @@ interface FileMatch {
type: "file" | "dir" | "url"; type: "file" | "dir" | "url";
} }
export function debounce<T extends (...args: never[]) => unknown>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function (this: unknown, ...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func.apply(this, args);
}, wait);
};
}
/** /**
* Recursively search a directory for files matching a pattern * Recursively search a directory for files matching a pattern
*/ */
@@ -37,16 +54,16 @@ function searchDirectoryRecursive(
const fullPath = join(dir, entry); const fullPath = join(dir, entry);
const stats = statSync(fullPath); 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 = const matches =
pattern.length === 0 || pattern.length === 0 ||
entry.toLowerCase().includes(pattern.toLowerCase()); relativePath.toLowerCase().includes(pattern.toLowerCase());
if (matches) { if (matches) {
const relativePath = fullPath.startsWith(process.cwd())
? fullPath.slice(process.cwd().length + 1)
: fullPath;
results.push({ results.push({
path: relativePath, path: relativePath,
type: stats.isDirectory() ? "dir" : "file", type: stats.isDirectory() ? "dir" : "file",
@@ -87,18 +104,30 @@ export async function searchFiles(
let searchDir = process.cwd(); let searchDir = process.cwd();
let searchPattern = query; 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("/")) { if (query.includes("/")) {
const lastSlashIndex = query.lastIndexOf("/"); const lastSlashIndex = query.lastIndexOf("/");
const dirPart = query.slice(0, lastSlashIndex); 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 { 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 { } catch {
// If path doesn't exist, return empty results // Path resolution failed, treat as pattern
return [];
} }
} }

View File

@@ -144,3 +144,50 @@ test("searchFiles handles relative path queries", async () => {
// Check that at least one result contains App.tsx // Check that at least one result contains App.tsx
expect(results.some((r) => r.path.includes("App.tsx"))).toBe(true); 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);
});