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/settings.local.json
.letta
# User-defined skills
.skills

View File

@@ -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<string>("");
const debounceTimeout = useRef<NodeJS.Timeout | null>(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({
>
<Text dimColor>
File/URL autocomplete ( to navigate, Tab/Enter to select):
{isLoading && " Searching..."}
</Text>
{isLoading ? (
<Text dimColor>Searching...</Text>
{matches.length > 0 ? (
<>
{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) => (
<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>
isLoading && <Text dimColor>Searching...</Text>
)}
</Box>
);

View File

@@ -6,6 +6,23 @@ interface FileMatch {
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
*/
@@ -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
}
}

View File

@@ -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);
});