Add fuzzy file search functionality (#3240)

Co-authored-by: spencrmartin <spencermartin@squareup.com>
This commit is contained in:
Spence
2025-07-03 17:09:39 -04:00
committed by GitHub
parent 7f55459e40
commit 63774ef319
8 changed files with 1040 additions and 202 deletions

View File

@@ -149,7 +149,7 @@ mod tests {
use mcp_core::{content::TextContent, Role};
use std::env;
#[warn(dead_code)]
#[allow(dead_code)]
#[derive(Clone)]
struct MockTestProvider {
name: String,

17
demo.json Normal file
View File

@@ -0,0 +1,17 @@
{
"demo": {
"title": "Goose File Operations Demo",
"version": "1.0.0",
"features": [
"File creation",
"File reading",
"Content modification",
"Multiple formats"
],
"metadata": {
"created_by": "Goose AI Assistant",
"created_at": "2025-07-02T17:33:32Z",
"file_type": "demonstration"
}
}
}

17
demo.txt Normal file
View File

@@ -0,0 +1,17 @@
Hello, World!
This is a demonstration of file operations in Goose.
Here are some key points:
- Files can be created and edited
- Content can be viewed and modified
- Multiple file formats are supported
Current timestamp: 2025-07-02 17:33:32
--- UPDATE ---
This content was added after the initial file creation!
File modification operations include:
- String replacement
- Line insertion
- Content appending

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "goose",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -10,6 +10,7 @@ import { Message } from '../types/message';
import { useWhisper } from '../hooks/useWhisper';
import { WaveformVisualizer } from './WaveformVisualizer';
import { toastError } from '../toasts';
import MentionPopover, { FileItemWithMatch } from './MentionPopover';
interface PastedImage {
id: string;
@@ -65,6 +66,20 @@ export default function ChatInput({
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
const [isFocused, setIsFocused] = useState(false);
const [pastedImages, setPastedImages] = useState<PastedImage[]>([]);
const [mentionPopover, setMentionPopover] = useState<{
isOpen: boolean;
position: { x: number; y: number };
query: string;
mentionStart: number;
selectedIndex: number;
}>({
isOpen: false,
position: { x: 0, y: 0 },
query: '',
mentionStart: -1,
selectedIndex: 0,
});
const mentionPopoverRef = useRef<{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void }>(null);
// Whisper hook for voice dictation
const {
@@ -219,8 +234,48 @@ export default function ChatInput({
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = evt.target.value;
const cursorPosition = evt.target.selectionStart;
setDisplayValue(val); // Update display immediately
debouncedSetValue(val); // Debounce the actual state update
// Check for @ mention
checkForMention(val, cursorPosition, evt.target);
};
const checkForMention = (text: string, cursorPosition: number, textArea: HTMLTextAreaElement) => {
// Find the last @ before the cursor
const beforeCursor = text.slice(0, cursorPosition);
const lastAtIndex = beforeCursor.lastIndexOf('@');
if (lastAtIndex === -1) {
// No @ found, close mention popover
setMentionPopover(prev => ({ ...prev, isOpen: false }));
return;
}
// Check if there's a space between @ and cursor (which would end the mention)
const afterAt = beforeCursor.slice(lastAtIndex + 1);
if (afterAt.includes(' ') || afterAt.includes('\n')) {
setMentionPopover(prev => ({ ...prev, isOpen: false }));
return;
}
// Calculate position for the popover - position it above the chat input
const textAreaRect = textArea.getBoundingClientRect();
setMentionPopover(prev => ({
...prev,
isOpen: true,
position: {
x: textAreaRect.left,
y: textAreaRect.top, // Position at the top of the textarea
},
query: afterAt,
mentionStart: lastAtIndex,
selectedIndex: 0, // Reset selection when query changes
// filteredFiles will be populated by the MentionPopover component
}));
};
const handlePaste = async (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
@@ -425,6 +480,38 @@ export default function ChatInput({
};
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
// If mention popover is open, handle arrow keys and enter
if (mentionPopover.isOpen && mentionPopoverRef.current) {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
const displayFiles = mentionPopoverRef.current.getDisplayFiles();
const maxIndex = Math.max(0, displayFiles.length - 1);
setMentionPopover(prev => ({
...prev,
selectedIndex: Math.min(prev.selectedIndex + 1, maxIndex)
}));
return;
}
if (evt.key === 'ArrowUp') {
evt.preventDefault();
setMentionPopover(prev => ({
...prev,
selectedIndex: Math.max(prev.selectedIndex - 1, 0)
}));
return;
}
if (evt.key === 'Enter') {
evt.preventDefault();
mentionPopoverRef.current.selectFile(mentionPopover.selectedIndex);
return;
}
if (evt.key === 'Escape') {
evt.preventDefault();
setMentionPopover(prev => ({ ...prev, isOpen: false }));
return;
}
}
// Handle history navigation first
handleHistoryNavigation(evt);
@@ -474,223 +561,256 @@ export default function ChatInput({
}
};
const handleMentionFileSelect = (filePath: string) => {
// Replace the @ mention with the file path
const beforeMention = displayValue.slice(0, mentionPopover.mentionStart);
const afterMention = displayValue.slice(mentionPopover.mentionStart + 1 + mentionPopover.query.length);
const newValue = `${beforeMention}${filePath}${afterMention}`;
setDisplayValue(newValue);
setValue(newValue);
setMentionPopover(prev => ({ ...prev, isOpen: false }));
textAreaRef.current?.focus();
// Set cursor position after the inserted file path
setTimeout(() => {
if (textAreaRef.current) {
const newCursorPosition = beforeMention.length + filePath.length;
textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
}
}, 0);
};
const hasSubmittableContent =
displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading);
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
return (
<div
className={`flex flex-col relative h-auto border rounded-lg transition-colors ${
isFocused
? 'border-borderProminent hover:border-borderProminent'
: 'border-borderSubtle hover:border-borderStandard'
} bg-bgApp z-10`}
>
<form onSubmit={onFormSubmit}>
<div className="relative">
<textarea
data-testid="chat-input"
autoFocus
id="dynamic-textarea"
placeholder={isRecording ? '' : 'What can goose help with? ⌘↑/⌘↓'}
value={displayValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
ref={textAreaRef}
rows={1}
style={{
minHeight: `${minHeight}px`,
maxHeight: `${maxHeight}px`,
overflowY: 'auto',
opacity: isRecording ? 0 : 1,
}}
className="w-full pl-4 pr-[108px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
/>
{isRecording && (
<div className="absolute inset-0 flex items-center pl-4 pr-[108px] pt-3 pb-1.5">
<WaveformVisualizer
audioContext={audioContext}
analyser={analyser}
isRecording={isRecording}
/>
<>
<div
className={`flex flex-col relative h-auto border rounded-lg transition-colors ${
isFocused
? 'border-borderProminent hover:border-borderProminent'
: 'border-borderSubtle hover:border-borderStandard'
} bg-bgApp z-10`}
>
<form onSubmit={onFormSubmit}>
<div className="relative">
<textarea
data-testid="chat-input"
autoFocus
id="dynamic-textarea"
placeholder={isRecording ? '' : 'What can goose help with? @ files • ⌘↑/⌘↓'}
value={displayValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
ref={textAreaRef}
rows={1}
style={{
minHeight: `${minHeight}px`,
maxHeight: `${maxHeight}px`,
overflowY: 'auto',
opacity: isRecording ? 0 : 1,
}}
className="w-full pl-4 pr-[108px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
/>
{isRecording && (
<div className="absolute inset-0 flex items-center pl-4 pr-[108px] pt-3 pb-1.5">
<WaveformVisualizer
audioContext={audioContext}
analyser={analyser}
isRecording={isRecording}
/>
</div>
)}
</div>
{pastedImages.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
{pastedImages.map((img) => (
<div key={img.id} className="relative group w-20 h-20">
{img.dataUrl && (
<img
src={img.dataUrl} // Use dataUrl for instant preview
alt={`Pasted image ${img.id}`}
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{img.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{img.error && !img.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
{img.error.substring(0, 50)}
</p>
{img.dataUrl && (
<button
type="button"
onClick={() => handleRetryImageSave(img.id)}
className="bg-blue-600 hover:bg-blue-700 text-white rounded px-1 py-0.5 text-[8px] leading-none"
title="Retry saving image"
>
Retry
</button>
)}
</div>
)}
{!img.isLoading && (
<button
type="button"
onClick={() => handleRemovePastedImage(img.id)}
className="absolute -top-1 -right-1 bg-gray-700 hover:bg-red-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs leading-none opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove image"
>
<Close className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
)}
</div>
{pastedImages.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
{pastedImages.map((img) => (
<div key={img.id} className="relative group w-20 h-20">
{img.dataUrl && (
<img
src={img.dataUrl} // Use dataUrl for instant preview
alt={`Pasted image ${img.id}`}
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{img.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{img.error && !img.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
{img.error.substring(0, 50)}
</p>
{img.dataUrl && (
<button
type="button"
onClick={() => handleRetryImageSave(img.id)}
className="bg-blue-600 hover:bg-blue-700 text-white rounded px-1 py-0.5 text-[8px] leading-none"
title="Retry saving image"
>
Retry
</button>
)}
</div>
)}
{!img.isLoading && (
<button
type="button"
onClick={() => handleRemovePastedImage(img.id)}
className="absolute -top-1 -right-1 bg-gray-700 hover:bg-red-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs leading-none opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove image"
>
<Close className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
)}
{isLoading ? (
<Button
type="button"
size="icon"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStop?.();
}}
className="absolute right-3 top-2 text-textSubtle rounded-full border border-borderSubtle hover:border-borderStandard hover:text-textStandard w-7 h-7 [&_svg]:size-4"
>
<Stop size={24} />
</Button>
) : (
<>
{/* Microphone button - only show if dictation is enabled and configured */}
{canUseDictation && (
<>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}}
disabled={isTranscribing}
className={`absolute right-12 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
isRecording
? 'bg-red-500 text-white hover:bg-red-600'
: isTranscribing
? 'text-textSubtle cursor-not-allowed animate-pulse'
: 'text-textSubtle hover:text-textStandard'
}`}
title={
isRecording
? `Stop recording (${Math.floor(recordingDuration)}s, ~${estimatedSize.toFixed(1)}MB)`
: isTranscribing
? 'Transcribing...'
: 'Start dictation'
}
>
<Microphone />
</Button>
{/* Recording/transcribing status indicator - positioned above the input */}
{(isRecording || isTranscribing) && (
<div className="absolute right-0 -top-8 bg-bgApp px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
{isTranscribing ? (
<span className="text-blue-500 flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
Transcribing...
</span>
) : (
<span
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
>
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
{Math.floor(recordingDuration)}s ~{estimatedSize.toFixed(1)}MB
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
</span>
)}
</div>
)}
</>
)}
{isLoading ? (
<Button
type="submit"
type="button"
size="icon"
variant="ghost"
disabled={
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
}
className={`absolute right-3 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
? 'text-textSubtle cursor-not-allowed'
: 'bg-bgAppInverse text-textProminentInverse hover:cursor-pointer'
}`}
title={
isAnyImageLoading
? 'Waiting for images to save...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: 'Send'
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStop?.();
}}
className="absolute right-3 top-2 text-textSubtle rounded-full border border-borderSubtle hover:border-borderStandard hover:text-textStandard w-7 h-7 [&_svg]:size-4"
>
<Send />
<Stop size={24} />
</Button>
</>
)}
</form>
) : (
<>
{/* Microphone button - only show if dictation is enabled and configured */}
{canUseDictation && (
<>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}}
disabled={isTranscribing}
className={`absolute right-12 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
isRecording
? 'bg-red-500 text-white hover:bg-red-600'
: isTranscribing
? 'text-textSubtle cursor-not-allowed animate-pulse'
: 'text-textSubtle hover:text-textStandard'
}`}
title={
isRecording
? `Stop recording (${Math.floor(recordingDuration)}s, ~${estimatedSize.toFixed(1)}MB)`
: isTranscribing
? 'Transcribing...'
: 'Start dictation'
}
>
<Microphone />
</Button>
{/* Recording/transcribing status indicator - positioned above the input */}
{(isRecording || isTranscribing) && (
<div className="absolute right-0 -top-8 bg-bgApp px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
{isTranscribing ? (
<span className="text-blue-500 flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
Transcribing...
</span>
) : (
<span
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
>
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
{Math.floor(recordingDuration)}s ~{estimatedSize.toFixed(1)}MB
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
</span>
)}
</div>
)}
</>
)}
<Button
type="submit"
size="icon"
variant="ghost"
disabled={
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
}
className={`absolute right-3 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
!hasSubmittableContent || isAnyImageLoading || isRecording || isTranscribing
? 'text-textSubtle cursor-not-allowed'
: 'bg-bgAppInverse text-textProminentInverse hover:cursor-pointer'
}`}
title={
isAnyImageLoading
? 'Waiting for images to save...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: 'Send'
}
>
<Send />
</Button>
</>
)}
</form>
<div className="flex items-center transition-colors text-textSubtle relative text-xs p-2 pr-3 border-t border-borderSubtle gap-2">
<div className="gap-1 flex items-center justify-between w-full">
<Button
type="button"
size="icon"
variant="ghost"
onClick={handleFileSelect}
className="text-textSubtle hover:text-textStandard w-7 h-7 [&_svg]:size-4"
>
<Attach />
</Button>
<div className="flex items-center transition-colors text-textSubtle relative text-xs p-2 pr-3 border-t border-borderSubtle gap-2">
<div className="gap-1 flex items-center justify-between w-full">
<Button
type="button"
size="icon"
variant="ghost"
onClick={handleFileSelect}
className="text-textSubtle hover:text-textStandard w-7 h-7 [&_svg]:size-4"
>
<Attach />
</Button>
<BottomMenu
setView={setView}
numTokens={numTokens}
inputTokens={inputTokens}
outputTokens={outputTokens}
messages={messages}
isLoading={isLoading}
setMessages={setMessages}
sessionCosts={sessionCosts}
/>
<BottomMenu
setView={setView}
numTokens={numTokens}
inputTokens={inputTokens}
outputTokens={outputTokens}
messages={messages}
isLoading={isLoading}
setMessages={setMessages}
sessionCosts={sessionCosts}
/>
</div>
</div>
</div>
</div>
<MentionPopover
ref={mentionPopoverRef}
isOpen={mentionPopover.isOpen}
onClose={() => setMentionPopover(prev => ({ ...prev, isOpen: false }))}
onSelect={handleMentionFileSelect}
position={mentionPopover.position}
query={mentionPopover.query}
selectedIndex={mentionPopover.selectedIndex}
onSelectedIndexChange={(index) => setMentionPopover(prev => ({ ...prev, selectedIndex: index }))}
/>
</>
);
}

View File

@@ -0,0 +1,230 @@
import React from 'react';
interface FileIconProps {
fileName: string;
isDirectory: boolean;
className?: string;
}
export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, className = "w-4 h-4" }) => {
if (isDirectory) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
);
}
const ext = fileName.split('.').pop()?.toLowerCase();
// Image files
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
);
}
// Video files
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
);
}
// Audio files
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>
);
}
// Archive/compressed files
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3"/>
<polyline points="14,2 14,8 20,8"/>
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z"/>
<path d="M10 7h4"/>
<path d="M10 11h4"/>
</svg>
);
}
// PDF files
if (ext === 'pdf') {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<path d="M10 12h4"/>
<path d="M10 16h2"/>
<path d="M10 8h2"/>
</svg>
);
}
// Design files
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<path d="M9 9h6v6h-6z"/>
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z"/>
</svg>
);
}
// JavaScript/TypeScript files
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<path d="M10 13l2 2 4-4"/>
</svg>
);
}
// Python files
if (['py', 'pyw', 'pyc'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<circle cx="10" cy="12" r="2"/>
<circle cx="14" cy="16" r="2"/>
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2"/>
</svg>
);
}
// HTML files
if (['html', 'htm', 'xhtml'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<polyline points="9,13 9,17 15,17 15,13"/>
<line x1="12" y1="13" x2="12" y2="17"/>
</svg>
);
}
// CSS files
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<path d="M8 13h8"/>
<path d="M8 17h8"/>
<circle cx="12" cy="15" r="1"/>
</svg>
);
}
// JSON/Data files
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/>
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/>
</svg>
);
}
// Markdown files
if (['md', 'markdown', 'mdx'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10,9 9,9 8,9"/>
</svg>
);
}
// Database files
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
);
}
// Configuration files
if (['env', 'ini', 'cfg', 'conf', 'config', 'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc'].includes(ext || '') ||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
);
}
// Text files
if (['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10,9 9,9 8,9"/>
</svg>
);
}
// Executable files
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4"/>
<polyline points="14,2 14,8 20,8"/>
<path d="M10 12l2 2 4-4"/>
</svg>
);
}
// Script files
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
);
}
// Default file icon
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10,9 9,9 8,9"/>
</svg>
);
};

View File

@@ -0,0 +1,420 @@
import { useState, useEffect, useRef, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
import { FileIcon } from './FileIcon';
interface FileItem {
path: string;
name: string;
isDirectory: boolean;
relativePath: string;
}
export interface FileItemWithMatch extends FileItem {
matchScore: number;
matches: number[];
matchedText: string;
}
interface MentionPopoverProps {
isOpen: boolean;
onClose: () => void;
onSelect: (filePath: string) => void;
position: { x: number; y: number };
query: string;
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
}
// 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: () => FileItemWithMatch[]; selectFile: (index: number) => void },
MentionPopoverProps
>(({
isOpen,
onClose,
onSelect,
position,
query,
selectedIndex,
onSelectedIndexChange
}, ref) => {
const [files, setFiles] = useState<FileItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Filter and sort files based on query
const displayFiles = useMemo((): FileItemWithMatch[] => {
if (!query.trim()) {
return files.slice(0, 15).map(file => ({
...file,
matchScore: 0,
matches: [],
matchedText: file.name
})); // Show first 15 files when no query
}
const results = files
.map(file => {
const nameMatch = fuzzyMatch(query, file.name);
const pathMatch = fuzzyMatch(query, file.relativePath);
const fullPathMatch = fuzzyMatch(query, file.path);
// Use the best match among name, relative path, and full path
let bestMatch = nameMatch;
let matchedText = file.name;
if (pathMatch.score > bestMatch.score) {
bestMatch = pathMatch;
matchedText = file.relativePath;
}
if (fullPathMatch.score > bestMatch.score) {
bestMatch = fullPathMatch;
matchedText = file.path;
}
return {
...file,
matchScore: bestMatch.score,
matches: bestMatch.matches,
matchedText
};
})
.filter(file => file.matchScore > 0)
.sort((a, b) => {
// Sort by score first, then prefer files over directories, then alphabetically
if (Math.abs(a.matchScore - b.matchScore) < 1) {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? 1 : -1; // Files first
}
return a.name.localeCompare(b.name);
}
return b.matchScore - a.matchScore;
})
.slice(0, 20); // Increase to 20 results
return results;
}, [files, query]);
// Expose methods to parent component
useImperativeHandle(ref, () => ({
getDisplayFiles: () => displayFiles,
selectFile: (index: number) => {
if (displayFiles[index]) {
onSelect(displayFiles[index].path);
onClose();
}
}
}), [displayFiles, onSelect, onClose]);
// Scan files when component opens
useEffect(() => {
if (isOpen && files.length === 0) {
scanFilesFromRoot();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, files.length]); // scanFilesFromRoot intentionally omitted to avoid circular dependency
// Handle clicks outside the popover
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]);
const scanDirectoryFromRoot = useCallback(async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
// Increase depth limit for better file discovery
if (depth > 5) return [];
try {
const items = await window.electron.listFiles(dirPath);
const results: FileItem[] = [];
// 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__', '.vscode', '.idea',
'target', 'dist', 'build', '.cache', '.npm', '.yarn', 'Library',
'System', 'Applications', '.Trash'
];
// Don't skip as many directories at deeper levels to find more files
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 hidden files and common ignore patterns
if (item.startsWith('.') || skipDirsAtDepth.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 files
'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 files
'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc',
// Images and assets
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif',
// Vector and design files
'ai', 'eps', 'sketch', 'fig', 'xd', 'psd',
// Other common files
'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({
path: fullPath,
name: item,
isDirectory: false,
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({
path: fullPath,
name: item,
isDirectory: false,
relativePath: itemRelativePath
});
continue;
}
// Otherwise, try to determine if it's a directory
try {
await window.electron.listFiles(fullPath);
// It's a directory
results.push({
path: fullPath,
name: item,
isDirectory: true,
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 scanFilesFromRoot = useCallback(async () => {
setIsLoading(true);
try {
// Start from common user directories for better performance
let startPath = '/Users'; // Default to macOS
if (window.electron.platform === 'win32') {
startPath = 'C:\\Users';
} else if (window.electron.platform === 'linux') {
startPath = '/home';
}
const scannedFiles = await scanDirectoryFromRoot(startPath);
setFiles(scannedFiles);
} catch (error) {
console.error('Error scanning files from root:', error);
setFiles([]);
} finally {
setIsLoading(false);
}
}, [scanDirectoryFromRoot]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest' });
}
}
}, [selectedIndex]);
const handleItemClick = (index: number) => {
onSelectedIndexChange(index);
onSelect(displayFiles[index].path);
onClose();
};
if (!isOpen) return null;
const displayedFiles = displayFiles.slice(0, 8); // Show up to 8 files
const remainingCount = displayFiles.length - displayedFiles.length;
return (
<div
ref={popoverRef}
className="fixed z-50 bg-bgApp border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg"
style={{
left: position.x,
top: position.y - 10, // Position above the chat input
transform: 'translateY(-100%)', // Move it fully above
}}
>
<div className="p-3">
{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 border-textSubtle"></div>
<span className="ml-2 text-sm text-textSubtle">Scanning files...</span>
</div>
) : (
<>
<div ref={listRef} className="space-y-1">
{displayedFiles.map((file, index) => (
<div
key={file.path}
onClick={() => handleItemClick(index)}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${
index === selectedIndex
? 'bg-bgProminent text-textProminentInverse'
: 'hover:bg-bgSubtle'
}`}
>
<div className="flex-shrink-0 text-textSubtle">
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-textStandard">
{file.name}
</div>
<div className="text-xs text-textSubtle truncate">
{file.path}
</div>
</div>
</div>
))}
{!isLoading && displayedFiles.length === 0 && query && (
<div className="p-4 text-center text-textSubtle text-sm">
No files found matching "{query}"
</div>
)}
{!isLoading && displayedFiles.length === 0 && !query && (
<div className="p-4 text-center text-textSubtle text-sm">
Start typing to search for files
</div>
)}
</div>
{remainingCount > 0 && (
<div className="mt-2 pt-2 border-t border-borderSubtle">
<div className="text-xs text-textSubtle text-center">
Show {remainingCount} more...
</div>
</div>
)}
</>
)}
</div>
</div>
);
});
MentionPopover.displayName = 'MentionPopover';
export default MentionPopover;

View File

@@ -0,0 +1,28 @@
// Handle window movement and docking detection
let isDragging = false;
let startX, startY;
document.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.screenX - window.screenX;
startY = e.screenY - window.screenY;
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
// Check for docking with parent window
if (window.electronFloating) {
window.electronFloating.checkDocking();
}
}
});
// Handle click to focus parent window
document.addEventListener('click', (e) => {
if (!isDragging && window.electronFloating) {
window.electronFloating.focusParent();
}
});
console.log('Floating button script loaded');