Fix debounce mechanism (#95)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
# Letta Code local settings
|
||||
.letta/settings.local.json
|
||||
|
||||
.letta
|
||||
# User-defined skills
|
||||
.skills
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user