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 Code local settings
|
||||||
.letta/settings.local.json
|
.letta/settings.local.json
|
||||||
|
.letta
|
||||||
# User-defined skills
|
# User-defined skills
|
||||||
.skills
|
.skills
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user