Files
tkmind_go/ui/desktop/src/components/MentionPopover.tsx
T

634 lines
19 KiB
TypeScript

import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { ItemIcon } from './ItemIcon';
import { getInitialWorkingDir } from '../utils/workingDir';
import { defineMessages, useIntl } from '../i18n';
import { listAgentMentionItems, listSlashCommandItems } from '../acp/autocomplete';
const i18n = defineMessages({
scanningFiles: {
id: 'mentionPopover.scanningFiles',
defaultMessage: 'Scanning files...',
},
loadingCommands: {
id: 'mentionPopover.loadingCommands',
defaultMessage: 'Loading commands...',
},
itemsFound: {
id: 'mentionPopover.itemsFound',
defaultMessage: '{count, plural, one {# item} other {# items}} found',
},
noItemsFound: {
id: 'mentionPopover.noItemsFound',
defaultMessage: 'No items found matching "{query}"',
},
noCommandsFound: {
id: 'mentionPopover.noCommandsFound',
defaultMessage: 'No commands found matching "{query}"',
},
});
type CommandItemType = 'Builtin' | 'Recipe' | 'Skill' | 'Agent';
type DisplayItemType = CommandItemType | 'Directory' | 'File';
const typeOrder: Record<DisplayItemType, number> = {
Agent: 0,
Directory: 1,
File: 2,
Builtin: 3,
Skill: 4,
Recipe: 5,
};
export interface DisplayItem {
name: string;
extra: string;
itemType: DisplayItemType;
relativePath: string;
insertText?: string;
}
export interface DisplayItemWithMatch extends DisplayItem {
matchScore: number;
matches: number[];
matchedText: string;
}
interface MentionPopoverProps {
isOpen: boolean;
onClose: () => void;
onSelect: (filePath: string) => void;
position: { x: number; y: number };
query: string;
isSlashCommand: boolean;
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
workingDir?: string;
sessionId?: string | null;
}
// Enhanced fuzzy matching algorithm
const fuzzyMatch = (pattern: string, text: string): { score: number; matches: number[] } => {
if (!pattern) return { score: 0, matches: [] };
const patternLower = pattern.toLowerCase();
const textLower = text.toLowerCase();
const matches: number[] = [];
let patternIndex = 0;
let score = 0;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && patternIndex < patternLower.length; i++) {
if (textLower[i] === patternLower[patternIndex]) {
matches.push(i);
patternIndex++;
consecutiveMatches++;
// Bonus for consecutive matches
score += consecutiveMatches * 3;
// Bonus for matches at word boundaries or path separators
if (
i === 0 ||
textLower[i - 1] === '/' ||
textLower[i - 1] === '_' ||
textLower[i - 1] === '-' ||
textLower[i - 1] === '.'
) {
score += 10;
}
// Bonus for matching the start of the filename (after last /)
const lastSlash = textLower.lastIndexOf('/', i);
if (lastSlash !== -1 && i === lastSlash + 1) {
score += 15;
}
} else {
consecutiveMatches = 0;
}
}
// Only return a score if all pattern characters were matched
if (patternIndex === patternLower.length) {
// Less penalty for longer strings to allow nested files to rank well
score -= text.length * 0.05;
// Bonus for exact substring matches
if (textLower.includes(patternLower)) {
score += 20;
}
// Bonus for matching the filename specifically (not just the path)
const fileName = text.split('/').pop()?.toLowerCase() || '';
if (fileName.includes(patternLower)) {
score += 25;
}
return { score, matches };
}
return { score: -1, matches: [] };
};
const MentionPopover = forwardRef<
{ getDisplayFiles: () => DisplayItemWithMatch[]; selectFile: (index: number) => void },
MentionPopoverProps
>(
(
{
isOpen,
onClose,
onSelect,
position,
query,
isSlashCommand,
selectedIndex,
onSelectedIndexChange,
workingDir,
sessionId,
},
ref
) => {
const intl = useIntl();
const [items, setItems] = useState<DisplayItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const currentWorkingDir = workingDir ?? getInitialWorkingDir();
const scanDirectoryFromRoot = useCallback(
async (dirPath: string, relativePath = '', depth = 0): Promise<DisplayItem[]> => {
// Increase depth limit for better file discovery
if (depth > 5) return [];
try {
const items = await window.electron.listFiles(dirPath);
const results: DisplayItem[] = [];
// Common directories to prioritize or skip
const priorityDirs = [
'Desktop',
'Documents',
'Downloads',
'Projects',
'Development',
'Code',
'src',
'components',
'icons',
];
const skipDirs = [
'.git',
'.svn',
'.hg',
'node_modules',
'__pycache__',
'target',
'dist',
'build',
'.cache',
'.npm',
'.yarn',
'Library',
'System',
'Applications',
'.Trash',
];
const allowedHiddenDirs = [
'.github',
'.vscode',
'.idea',
'.config',
'.gitlab',
'.circleci',
'.azure',
'.jenkins',
];
// Don't skip as many directories at deeper levels to find more items
const skipDirsAtDepth =
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
// Sort items to prioritize certain directories
const sortedItems = items.sort((a, b) => {
const aPriority = priorityDirs.includes(a);
const bPriority = priorityDirs.includes(b);
if (aPriority && !bPriority) return -1;
if (!aPriority && bPriority) return 1;
return a.localeCompare(b);
});
// Increase item limit per directory for better coverage
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
for (const item of sortedItems.slice(0, itemLimit)) {
const fullPath = `${dirPath}/${item}`;
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
// Skip items in the skip list
if (skipDirsAtDepth.includes(item)) {
continue;
}
// Skip hidden items except for allowed hidden directories
if (item.startsWith('.') && !allowedHiddenDirs.includes(item)) {
continue;
}
// First, check if this looks like a file based on extension
const hasExtension = item.includes('.');
const ext = item.split('.').pop()?.toLowerCase();
const commonExtensions = [
// Code items
'txt',
'md',
'js',
'ts',
'jsx',
'tsx',
'py',
'java',
'cpp',
'c',
'h',
'css',
'html',
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'cfg',
'sh',
'bat',
'ps1',
'rb',
'go',
'rs',
'php',
'sql',
'r',
'scala',
'swift',
'kt',
'dart',
'vue',
'svelte',
'astro',
'scss',
'less',
// Documentation
'readme',
'license',
'changelog',
'contributing',
// Config items
'gitignore',
'dockerignore',
'editorconfig',
'prettierrc',
'eslintrc',
// Images and assets
'png',
'jpg',
'jpeg',
'gif',
'svg',
'ico',
'webp',
'bmp',
'tiff',
'tif',
// Vector and design items
'ai',
'eps',
'sketch',
'fig',
'xd',
'psd',
// Other common items
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
];
// If it has a known file extension, treat it as a file
if (hasExtension && ext && commonExtensions.includes(ext)) {
results.push({
extra: fullPath,
name: item,
itemType: 'File',
relativePath: itemRelativePath,
});
continue;
}
// If it's a known file without extension (README, LICENSE, etc.)
const knownFiles = [
'readme',
'license',
'changelog',
'contributing',
'dockerfile',
'makefile',
];
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
results.push({
extra: fullPath,
name: item,
itemType: 'File',
relativePath: itemRelativePath,
});
continue;
}
// Otherwise, try to determine if it's a directory
try {
await window.electron.listFiles(fullPath);
results.push({
name: item,
extra: fullPath,
itemType: 'Directory',
relativePath: itemRelativePath,
});
// Recursively scan directories more aggressively
if (depth < 4 || priorityDirs.includes(item)) {
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
results.push(...subFiles);
}
} catch {
// If we can't list it and it doesn't have a known extension, skip it
// This could be a file with an unknown extension or a permission issue
}
}
return results;
} catch (error) {
console.error(`Error scanning directory ${dirPath}:`, error);
return [];
}
},
[]
);
const getDefaultStartPath = (): string => {
if (window.electron.platform === 'win32') return 'C:\\Users';
if (window.electron.platform === 'linux') return '/home';
return '/Users';
};
const compareByType = (a: DisplayItemWithMatch, b: DisplayItemWithMatch) => {
const orderA = typeOrder[a.itemType] ?? Number.MAX_SAFE_INTEGER;
const orderB = typeOrder[b.itemType] ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
};
const displayItems = useMemo((): DisplayItemWithMatch[] => {
if (!query.trim()) {
return items
.map((file) => ({
...file,
matchScore: 0,
matches: [],
matchedText: file.name,
depth: currentWorkingDir
? file.extra.replace(currentWorkingDir, '').split('/').length - 1
: 0,
}))
.sort((a, b) => {
if (a.depth !== b.depth) return a.depth - b.depth;
const typeComparison = compareByType(a, b);
return typeComparison || a.name.localeCompare(b.name);
});
}
return items
.map((file) => {
const matches = [
{ match: fuzzyMatch(query, file.name), text: file.name },
{ match: fuzzyMatch(query, file.relativePath), text: file.relativePath },
{ match: fuzzyMatch(query, file.extra), text: file.extra },
];
const { match: bestMatch, text: matchedText } = matches.reduce((best, current) =>
current.match.score > best.match.score ? current : best
);
let finalScore = bestMatch.score;
if (finalScore > 0 && file.itemType === 'Agent') {
finalScore += 100;
} else if (finalScore > 0 && currentWorkingDir) {
const depth = file.extra.replace(currentWorkingDir, '').split('/').length - 1;
finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0;
}
return {
...file,
matchScore: finalScore,
matches: bestMatch.matches,
matchedText,
};
})
.filter((file) => file.matchScore > 0)
.sort((a, b) => {
// Sort by score first, then prefer items over directories, then alphabetically
const scoreDiff = b.matchScore - a.matchScore;
if (Math.abs(scoreDiff) >= 1) return scoreDiff;
const typeComparison = compareByType(a, b);
return typeComparison || a.name.localeCompare(b.name);
});
}, [items, query, currentWorkingDir]);
const getSelectionText = (item: DisplayItem): string => {
if (item.insertText) {
return item.insertText;
}
if (item.itemType === 'Agent') {
return '@' + item.name + ' ';
}
if (['Builtin', 'Recipe', 'Skill'].includes(item.itemType)) {
return '/' + item.name;
}
return item.extra;
};
// Expose methods to parent component
useImperativeHandle(
ref,
() => ({
getDisplayFiles: () => displayItems,
selectFile: (index: number) => {
if (displayItems[index]) {
onSelect(getSelectionText(displayItems[index]));
onClose();
}
},
}),
[displayItems, onSelect, onClose]
);
useEffect(() => {
let cancelled = false;
const loadData = async () => {
setItems([]);
setIsLoading(true);
try {
if (isSlashCommand) {
const commandItems = await listSlashCommandItems(currentWorkingDir);
if (cancelled) return;
setItems(commandItems);
} else {
// Fetch agents from server and scan files in parallel
const [agentItems, scannedFiles] = await Promise.all([
listAgentMentionItems(currentWorkingDir, sessionId ?? undefined).catch(() => []),
scanDirectoryFromRoot(currentWorkingDir || getDefaultStartPath()),
]);
if (cancelled) return;
setItems([...agentItems, ...scannedFiles]);
}
} catch (error) {
if (!cancelled) {
console.error('Error loading popover items:', error);
setItems([]);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
if (isOpen) {
loadData();
}
return () => {
cancelled = true;
};
}, [isOpen, isSlashCommand, scanDirectoryFromRoot, currentWorkingDir, sessionId]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current && selectedIndex >= 0 && selectedIndex < displayItems.length) {
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}
}, [selectedIndex, displayItems.length]);
const handleItemClick = (index: number) => {
if (index >= 0 && index < displayItems.length) {
onSelectedIndexChange(index);
onSelect(getSelectionText(displayItems[index]));
onClose();
}
};
if (!isOpen) return null;
return (
<div
ref={popoverRef}
className="fixed z-50 bg-background-primary border border-border-primary rounded-lg shadow-lg min-w-96 max-w-lg max-h-80"
style={{
left: position.x,
top: position.y - 10, // Position above the chat input
transform: 'translateY(-100%)', // Move it fully above
}}
>
<div className="p-3 flex flex-col max-h-80">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2"></div>
<span className="ml-2 text-sm text-text-secondary">
{intl.formatMessage(isSlashCommand ? i18n.loadingCommands : i18n.scanningFiles)}
</span>
</div>
) : (
<>
{displayItems.length > 0 && (
<div className="text-xs text-text-secondary mb-2 px-1">
{intl.formatMessage(i18n.itemsFound, { count: displayItems.length })}
</div>
)}
<div
ref={listRef}
className="space-y-1 overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-borderStandard scrollbar-track-transparent"
style={{ maxHeight: '280px' }}
>
{displayItems.map((item, index) => (
<div
key={`${item.itemType}-${item.relativePath}-${item.extra}-${item.insertText ?? ''}`}
onClick={() => handleItemClick(index)}
data-selected={index === selectedIndex}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
index === selectedIndex ? 'bg-sidebar-accent' : 'hover:bg-sidebar-accent/50'
}`}
>
<div className="flex-shrink-0 text-text-secondary">
<ItemIcon item={item} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-text-primary">{item.name}</div>
<div className="text-xs truncate text-text-secondary">{item.extra}</div>
</div>
</div>
))}
{!isLoading && displayItems.length === 0 && query && (
<div className="p-4 text-center text-text-secondary text-sm">
{intl.formatMessage(isSlashCommand ? i18n.noCommandsFound : i18n.noItemsFound, {
query,
})}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
);
MentionPopover.displayName = 'MentionPopover';
export default MentionPopover;