agent-ecosystem/src/renderer/hooks/useFileSuggestions.ts

254 lines
7.9 KiB
TypeScript

/**
* Hook for loading and filtering project files and folders as @-mention suggestions.
*
* Uses the Quick Open file list API with a 10s TTL cache.
* Returns up to 8 matching files/folders filtered by name or relative path.
* Folders are derived from file paths (no extra IPC call needed).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getQuickOpenCache,
onQuickOpenCacheInvalidated,
setQuickOpenCache,
} from '@renderer/utils/quickOpenCache';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { QuickOpenFile } from '@shared/types/editor';
const MAX_FILE_SUGGESTIONS = 8;
const MAX_FOLDER_SUGGESTIONS = 5;
const MENTION_PATH_QUOTE_NEEDED = /[\s,)}\]"']/;
export interface UseFileSuggestionsResult {
suggestions: MentionSuggestion[];
loading: boolean;
}
/** Folder entry derived from file paths. */
interface DerivedFolder {
/** Folder name (last segment, e.g. "ui") */
name: string;
/** Relative path with trailing slash, e.g. "src/renderer/components/ui/" */
relativePath: string;
/** Absolute path */
absolutePath: string;
}
export function formatFileMentionPath(relativePath: string): string {
if (!MENTION_PATH_QUOTE_NEEDED.test(relativePath)) {
return relativePath;
}
if (!relativePath.includes('"')) {
return `"${relativePath}"`;
}
if (!relativePath.includes("'")) {
return `'${relativePath}'`;
}
return `"${relativePath.replace(/"/g, '')}"`;
}
/**
* Extracts unique directories from a list of file paths.
* Returns directories sorted by depth (shallower first), then alphabetically.
*/
function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] {
const dirSet = new Set<string>();
for (const f of files) {
// Walk up the directory chain from each file's relative path
const parts = f.relativePath.split('/');
// Remove the file name — keep only directory segments
for (let i = 1; i < parts.length; i++) {
dirSet.add(parts.slice(0, i).join('/'));
}
}
const folders: DerivedFolder[] = [];
for (const relDir of dirSet) {
const segments = relDir.split('/');
const name = segments[segments.length - 1];
folders.push({
name,
relativePath: relDir + '/',
absolutePath: projectPath + '/' + relDir,
});
}
// Sort: shallower first, then alphabetically
folders.sort((a, b) => {
const depthA = a.relativePath.split('/').length;
const depthB = b.relativePath.split('/').length;
if (depthA !== depthB) return depthA - depthB;
return a.relativePath.localeCompare(b.relativePath);
});
return folders;
}
/**
* Filters files by query (name or relative path) and converts to MentionSuggestion[].
* Exported for testing.
*/
export function filterFileSuggestions(files: QuickOpenFile[], query: string): MentionSuggestion[] {
if (!query || files.length === 0) return [];
const lower = query.toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of files) {
if (results.length >= MAX_FILE_SUGGESTIONS) break;
if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
results.push({
id: `file:${f.path}`,
name: f.name,
subtitle: f.relativePath,
type: 'file',
filePath: f.path,
relativePath: f.relativePath,
insertText: formatFileMentionPath(f.relativePath),
});
}
}
return results;
}
/**
* Filters folders by query and converts to MentionSuggestion[].
* Exported for testing.
*/
export function filterFolderSuggestions(
folders: DerivedFolder[],
query: string
): MentionSuggestion[] {
if (!query || folders.length === 0) return [];
// Strip trailing slash from query for matching (e.g. "ui/" -> "ui")
const cleanQuery = query.endsWith('/') ? query.slice(0, -1) : query;
const lower = cleanQuery.toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of folders) {
if (results.length >= MAX_FOLDER_SUGGESTIONS) break;
if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
results.push({
id: `folder:${f.absolutePath}`,
name: f.name + '/',
subtitle: f.relativePath,
type: 'folder',
filePath: f.absolutePath,
relativePath: f.relativePath,
insertText: formatFileMentionPath(f.relativePath),
});
}
}
return results;
}
/**
* Loads project files and returns filtered MentionSuggestion[] with type: 'file' and 'folder'.
*
* @param projectPath - Project root path (null disables)
* @param query - Current @-mention query string
* @param enabled - Whether file suggestions are active (isOpen && enableFiles)
*/
export function useFileSuggestions(
projectPath: string | null,
query: string,
enabled: boolean
): UseFileSuggestionsResult {
// Seed from cache on initial mount (lazy initializer) AND on projectPath change
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>(() => {
if (!projectPath) return [];
return getQuickOpenCache(projectPath)?.files ?? [];
});
const [loading, setLoading] = useState(false);
// Bumped on cache invalidation (file create/delete) to trigger refetch
const [fetchTrigger, setFetchTrigger] = useState(0);
// Re-seed from cache when projectPath changes
useEffect(() => {
if (!projectPath) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync with prop change
setAllFiles([]);
return;
}
const cached = getQuickOpenCache(projectPath);
setAllFiles(cached?.files ?? []);
}, [projectPath]);
// React to cache invalidation from EditorFileWatcher (create/delete events)
useEffect(() => {
return onQuickOpenCacheInvalidated(() => setFetchTrigger((n) => n + 1));
}, []);
// Lazy refetch: when dropdown opens and cache is stale, trigger a reload
const prevEnabledRef = useRef(enabled);
useEffect(() => {
if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional trigger on state transition
setFetchTrigger((n) => n + 1);
}
prevEnabledRef.current = enabled;
}, [enabled, projectPath]);
// Load files from API when cache is empty.
// Uses project:listFiles (not editor:listFiles) — works without editor being open.
const fetchFiles = useCallback(
(projectRoot: string) => {
let cancelled = false;
setLoading(true);
window.electronAPI.project
.listFiles(projectRoot)
.then((files) => {
if (cancelled) return;
setQuickOpenCache(projectRoot, files);
setAllFiles(files);
})
.catch(() => {
// Project path may be invalid — will retry on next trigger
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
},
[] // listFiles API is stable
);
// Fetch only when cache is empty. Cache seeding is handled by:
// - lazy initializer (first mount)
// - effect (projectPath change)
useEffect(() => {
if (!projectPath) return;
const cached = getQuickOpenCache(projectPath);
if (cached) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- setLoading before async fetch is intentional
return fetchFiles(projectPath);
}, [projectPath, fetchTrigger, fetchFiles]);
// Derive folders from file list (memoized)
const allFolders = useMemo(
() => (projectPath ? extractDirectories(allFiles, projectPath) : []),
[allFiles, projectPath]
);
// Filter by query and convert to MentionSuggestion[] — folders first, then files
const suggestions = useMemo(() => {
if (!enabled) return [];
const folders = filterFolderSuggestions(allFolders, query);
const files = filterFileSuggestions(allFiles, query);
return [...folders, ...files];
}, [enabled, query, allFiles, allFolders]);
return { suggestions, loading };
}