feat: enhance file attachment features performance
This commit is contained in:
Generated
+1
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.1.3"
|
version = "1.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { RiAiAgentLine, RiCloseCircleLine, RiFileUploadLine, RiSendPlane2Line } from '@remixicon/react';
|
import {
|
||||||
|
RiAddCircleLine,
|
||||||
|
RiAiAgentLine,
|
||||||
|
RiAttachment2,
|
||||||
|
RiCloseCircleLine,
|
||||||
|
RiFileUploadLine,
|
||||||
|
RiSendPlane2Line,
|
||||||
|
} from '@remixicon/react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||||
import { FileAttachmentButton, AttachedFilesList } from './FileAttachment';
|
import { AttachedFilesList } from './FileAttachment';
|
||||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||||
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
||||||
@@ -20,6 +27,12 @@ import { toast } from 'sonner';
|
|||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||||
|
|
||||||
@@ -643,10 +656,96 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [addServerFile]);
|
}, [addServerFile]);
|
||||||
|
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
||||||
|
let attachedCount = 0;
|
||||||
|
const list = Array.isArray(files) ? files : Array.from(files);
|
||||||
|
|
||||||
|
for (const file of list) {
|
||||||
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||||
|
try {
|
||||||
|
await addAttachedFile(file);
|
||||||
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||||
|
if (sizeAfter > sizeBefore) {
|
||||||
|
attachedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File attach failed', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachedCount > 0) {
|
||||||
|
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
}, [addAttachedFile]);
|
||||||
|
|
||||||
|
const handleVSCodePickFiles = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/vscode/pick-files');
|
||||||
|
const data = await response.json();
|
||||||
|
const picked = Array.isArray(data?.files) ? data.files : [];
|
||||||
|
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
|
||||||
|
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
const summary = skipped
|
||||||
|
.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`)
|
||||||
|
.join('\n');
|
||||||
|
toast.error(`Some files were skipped:\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asFiles = picked
|
||||||
|
.map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
|
||||||
|
if (!file?.dataUrl) return null;
|
||||||
|
try {
|
||||||
|
const [meta, base64] = file.dataUrl.split(',');
|
||||||
|
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
|
||||||
|
if (!base64) return null;
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mime });
|
||||||
|
return new File([blob], file.name || 'file', { type: mime });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to decode VS Code picked file', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as File[];
|
||||||
|
|
||||||
|
if (asFiles.length > 0) {
|
||||||
|
await attachFiles(asFiles);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('VS Code file pick failed', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
|
||||||
|
}
|
||||||
|
}, [attachFiles]);
|
||||||
|
|
||||||
|
const handlePickLocalFiles = React.useCallback(() => {
|
||||||
|
if (isVSCodeRuntime()) {
|
||||||
|
void handleVSCodePickFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, [handleVSCodePickFiles]);
|
||||||
|
|
||||||
|
const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = event.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
await attachFiles(files);
|
||||||
|
event.target.value = '';
|
||||||
|
}, [attachFiles]);
|
||||||
|
|
||||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||||
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : 'px-2.5 py-1.5';
|
const isVSCode = isVSCodeRuntime();
|
||||||
const footerHeightClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
|
||||||
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7');
|
||||||
|
const iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
||||||
|
|
||||||
const iconButtonBaseClass = cn(
|
const iconButtonBaseClass = cn(
|
||||||
footerHeightClass,
|
footerHeightClass,
|
||||||
@@ -669,17 +768,69 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectFileButton = (
|
const attachmentMenu = (
|
||||||
<ServerFilePicker onFilesSelected={handleServerFilesSelected} multiSelect>
|
<>
|
||||||
<button
|
<input
|
||||||
type='button'
|
ref={fileInputRef}
|
||||||
className={iconButtonBaseClass}
|
type="file"
|
||||||
title='Attach files from project'
|
multiple
|
||||||
aria-label='Attach files from project'
|
className="hidden"
|
||||||
>
|
onChange={handleLocalFileSelect}
|
||||||
<RiFileUploadLine className={cn(iconSizeClass, 'text-current')} />
|
accept="*/*"
|
||||||
</button>
|
/>
|
||||||
</ServerFilePicker>
|
|
||||||
|
<div className="relative inline-flex">
|
||||||
|
<ServerFilePicker
|
||||||
|
onFilesSelected={handleServerFilesSelected}
|
||||||
|
multiSelect
|
||||||
|
presentation={isMobile || isVSCode ? 'modal' : 'dropdown'}
|
||||||
|
open={projectFilePickerOpen}
|
||||||
|
onOpenChange={setProjectFilePickerOpen}
|
||||||
|
>
|
||||||
|
{isMobile || isVSCode ? null : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 opacity-0 pointer-events-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ServerFilePicker>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconButtonBaseClass}
|
||||||
|
title="Add attachment"
|
||||||
|
aria-label="Add attachment"
|
||||||
|
>
|
||||||
|
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
requestAnimationFrame(() => handlePickLocalFiles());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiAttachment2 />
|
||||||
|
Attach files
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setProjectFilePickerOpen(true);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiFileUploadLine />
|
||||||
|
Attach from project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const settingsButton = onOpenSettings ? (
|
const settingsButton = onOpenSettings ? (
|
||||||
@@ -696,8 +847,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
const attachmentsControls = (
|
const attachmentsControls = (
|
||||||
<>
|
<>
|
||||||
<FileAttachmentButton />
|
{attachmentMenu}
|
||||||
{projectFileButton}
|
|
||||||
{settingsButton}
|
{settingsButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -795,7 +945,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
{isDragging && (
|
{isDragging && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<FileAttachmentButton />
|
<div className="inline-flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconButtonBaseClass}
|
||||||
|
onClick={() => handlePickLocalFiles()}
|
||||||
|
title="Attach files"
|
||||||
|
aria-label="Attach files"
|
||||||
|
>
|
||||||
|
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
|
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,56 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = candidate.toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
let lastIndex = -1;
|
||||||
|
let consecutive = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < q.length; i += 1) {
|
||||||
|
const ch = q[i];
|
||||||
|
if (!ch || ch === ' ') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = c.indexOf(ch, lastIndex + 1);
|
||||||
|
if (idx === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = idx - lastIndex - 1;
|
||||||
|
if (gap === 0) {
|
||||||
|
consecutive += 1;
|
||||||
|
} else {
|
||||||
|
consecutive = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += 10;
|
||||||
|
score += Math.max(0, 18 - idx);
|
||||||
|
score -= Math.max(0, gap);
|
||||||
|
|
||||||
|
if (idx === 0) {
|
||||||
|
score += 12;
|
||||||
|
} else {
|
||||||
|
const prev = c[idx - 1];
|
||||||
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score += consecutive > 0 ? 12 : 0;
|
||||||
|
lastIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += Math.max(0, 24 - Math.round(c.length / 3));
|
||||||
|
return score;
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
const target = event.target as Node | null;
|
const target = event.target as Node | null;
|
||||||
@@ -61,15 +111,35 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = (debouncedQuery ?? '').trim();
|
||||||
|
const normalizedQueryLower = normalizedQuery.toLowerCase();
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
searchFiles(currentDirectory, debouncedQuery ?? '', 40)
|
searchFiles(currentDirectory, normalizedQueryLower, 80)
|
||||||
.then((hits) => {
|
.then((hits) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFiles(hits.slice(0, 15));
|
|
||||||
|
const ranked = normalizedQueryLower
|
||||||
|
? hits
|
||||||
|
.map((file) => {
|
||||||
|
const label = file.relativePath || file.name || file.path;
|
||||||
|
const score = fuzzyScore(normalizedQueryLower, label);
|
||||||
|
return score === null ? null : { file, score, labelLength: label.length };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ file: FileInfo; score: number; labelLength: number }>
|
||||||
|
: hits.map((file) => ({ file, score: 0, labelLength: (file.relativePath || file.name || file.path).length }));
|
||||||
|
|
||||||
|
ranked.sort((a, b) => (
|
||||||
|
b.score - a.score
|
||||||
|
|| a.labelLength - b.labelLength
|
||||||
|
|| a.file.path.localeCompare(b.file.path)
|
||||||
|
));
|
||||||
|
|
||||||
|
setFiles(ranked.slice(0, 15).map((entry) => entry.file));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -85,7 +155,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentDirectory, debouncedQuery, searchFiles]);
|
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
|
|||||||
@@ -345,11 +345,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
}
|
}
|
||||||
}, [editToggleDisabled]);
|
}, [editToggleDisabled]);
|
||||||
|
|
||||||
const buttonHeight = isCompact ? 'h-9' : 'h-8';
|
const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
|
||||||
const editToggleIconClass = isCompact ? 'h-5 w-5' : 'h-4 w-4';
|
const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
|
||||||
const controlIconSize = isCompact ? 'h-5 w-5' : 'h-4 w-4';
|
const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
|
||||||
|
const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
|
||||||
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
|
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
|
||||||
const inlineGapClass = isCompact ? 'gap-x-2' : 'gap-x-3';
|
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-2' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3';
|
||||||
const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode];
|
const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode];
|
||||||
|
|
||||||
const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
|
const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
|
||||||
|
|||||||
@@ -32,78 +32,140 @@ interface ServerFilePickerProps {
|
|||||||
onFilesSelected: (files: FileInfo[]) => void;
|
onFilesSelected: (files: FileInfo[]) => void;
|
||||||
multiSelect?: boolean;
|
multiSelect?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
presentation?: 'dropdown' | 'modal';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
multiSelect = false,
|
multiSelect = false,
|
||||||
children
|
children,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
presentation = 'dropdown',
|
||||||
}) => {
|
}) => {
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||||
const isCompact = isMobile || isVSCodeRuntime;
|
const isCompact = isMobile || isVSCodeRuntime;
|
||||||
const { currentDirectory } = useDirectoryStore();
|
const { currentDirectory } = useDirectoryStore();
|
||||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
const [open, setOpen] = React.useState(false);
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
|
||||||
const [selectedFiles, setSelectedFiles] = React.useState<Set<string>>(new Set());
|
const [selectedFiles, setSelectedFiles] = React.useState<Set<string>>(new Set());
|
||||||
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
|
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
|
||||||
const [fileTree, setFileTree] = React.useState<FileInfo[]>([]);
|
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileInfo[]>>({});
|
||||||
|
const loadedDirsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
|
||||||
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
|
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
|
||||||
const [searching, setSearching] = React.useState(false);
|
const [searching, setSearching] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [attaching, setAttaching] = React.useState(false);
|
const [attaching, setAttaching] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const open = controlledOpen ?? uncontrolledOpen;
|
||||||
|
const setOpen = onOpenChange ?? setUncontrolledOpen;
|
||||||
|
|
||||||
|
const sortDirectoryItems = React.useCallback((items: FileInfo[]) => (
|
||||||
|
items.slice().sort((a, b) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === 'directory' ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const mapFilesystemEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileInfo[] => (
|
||||||
|
sortDirectoryItems(entries
|
||||||
|
.filter((item) => !item.name.startsWith('.'))
|
||||||
|
.map((item) => {
|
||||||
|
const name = item.name;
|
||||||
|
const extension = !item.isDirectory && name.includes('.')
|
||||||
|
? name.split('.').pop()?.toLowerCase()
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: item.path || `${dirPath}/${name}`,
|
||||||
|
type: item.isDirectory ? 'directory' : 'file',
|
||||||
|
size: 0,
|
||||||
|
extension,
|
||||||
|
};
|
||||||
|
}))
|
||||||
|
), [sortDirectoryItems]);
|
||||||
|
|
||||||
const loadDirectory = React.useCallback(async (dirPath: string) => {
|
const loadDirectory = React.useCallback(async (dirPath: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const tempClient = opencodeClient.getApiClient();
|
const entries = await opencodeClient.listLocalDirectory(dirPath);
|
||||||
const response = await tempClient.file.list({
|
const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({
|
||||||
query: {
|
name: entry.name,
|
||||||
path: '.',
|
path: entry.path,
|
||||||
directory: dirPath
|
isDirectory: entry.isDirectory,
|
||||||
}
|
})));
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.data) {
|
loadedDirsRef.current = new Set([dirPath]);
|
||||||
setFileTree([]);
|
inFlightDirsRef.current = new Set();
|
||||||
return;
|
setChildrenByDir({ [dirPath]: items });
|
||||||
}
|
setExpandedDirs(new Set());
|
||||||
|
|
||||||
const items = response.data
|
|
||||||
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
|
|
||||||
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
|
|
||||||
const extension = item.type === 'file'
|
|
||||||
? item.name.split('.').pop()?.toLowerCase()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: item.name,
|
|
||||||
path: item.absolute || `${dirPath}/${item.name}`,
|
|
||||||
type: item.type as 'file' | 'directory',
|
|
||||||
size: item.size || 0,
|
|
||||||
extension
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a: FileInfo, b: FileInfo) => {
|
|
||||||
if (a.type !== b.type) {
|
|
||||||
return a.type === 'directory' ? -1 : 1;
|
|
||||||
}
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
setFileTree(items);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load directory contents');
|
setError('Failed to load directory contents');
|
||||||
setFileTree([]);
|
loadedDirsRef.current = new Set([dirPath]);
|
||||||
|
inFlightDirsRef.current = new Set();
|
||||||
|
setChildrenByDir({ [dirPath]: [] });
|
||||||
|
setExpandedDirs(new Set());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [mapFilesystemEntries]);
|
||||||
|
|
||||||
|
const loadDirectoryChildren = React.useCallback(async (dirPath: string) => {
|
||||||
|
const normalizedDir = dirPath.trim();
|
||||||
|
if (!normalizedDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadedDirsRef.current.has(normalizedDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inFlightDirsRef.current.has(normalizedDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||||
|
inFlightDirsRef.current.add(normalizedDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await opencodeClient.listLocalDirectory(normalizedDir);
|
||||||
|
const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
isDirectory: entry.isDirectory,
|
||||||
|
})));
|
||||||
|
|
||||||
|
loadedDirsRef.current = new Set(loadedDirsRef.current);
|
||||||
|
loadedDirsRef.current.add(normalizedDir);
|
||||||
|
setChildrenByDir((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedDir]: items,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Keep it unloadded so the user can retry expanding the directory.
|
||||||
|
setChildrenByDir((prev) => {
|
||||||
|
if (prev[normalizedDir]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[normalizedDir]: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||||
|
inFlightDirsRef.current.delete(normalizedDir);
|
||||||
|
}
|
||||||
|
}, [mapFilesystemEntries]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if ((open || mobileOpen) && currentDirectory) {
|
if ((open || mobileOpen) && currentDirectory) {
|
||||||
@@ -214,50 +276,13 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setExpandedDirs(prev => {
|
setExpandedDirs((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(dirPath);
|
next.add(dirPath);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
await loadDirectoryChildren(dirPath);
|
||||||
const tempClient = opencodeClient.getApiClient();
|
|
||||||
const response = await tempClient.file.list({
|
|
||||||
query: {
|
|
||||||
path: '.',
|
|
||||||
directory: dirPath
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
const subItems = response.data
|
|
||||||
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
|
|
||||||
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
|
|
||||||
const extension = item.type === 'file'
|
|
||||||
? item.name.split('.').pop()?.toLowerCase()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: item.name,
|
|
||||||
path: item.absolute || `${dirPath}/${item.name}`,
|
|
||||||
type: item.type as 'file' | 'directory',
|
|
||||||
size: 0,
|
|
||||||
extension
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setFileTree(prev => {
|
|
||||||
const filtered = prev.filter(item => !item.path.startsWith(dirPath + '/'));
|
|
||||||
return [...filtered, ...subItems].sort((a, b) => {
|
|
||||||
const aDepth = a.path.split('/').length;
|
|
||||||
const bDepth = b.path.split('/').length;
|
|
||||||
if (aDepth !== bDepth) return aDepth - bDepth;
|
|
||||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* ignored */ }
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,11 +303,14 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
const treeFileMap = new Map(
|
const treeFileMap = new Map<string, FileInfo>();
|
||||||
fileTree
|
Object.values(childrenByDir).forEach((items) => {
|
||||||
.filter((file) => file.type === 'file')
|
items.forEach((file) => {
|
||||||
.map((file) => [file.path, file])
|
if (file.type === 'file') {
|
||||||
);
|
treeFileMap.set(file.path, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
const searchFileMap = new Map(searchResults.map((file) => [file.path, file]));
|
const searchFileMap = new Map(searchResults.map((file) => [file.path, file]));
|
||||||
|
|
||||||
const selected = Array.from(selectedFiles)
|
const selected = Array.from(selectedFiles)
|
||||||
@@ -304,20 +332,13 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
return childrenByDir[currentDirectory] ?? [];
|
||||||
return fileTree.filter((item) => {
|
}, [childrenByDir, currentDirectory]);
|
||||||
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
|
|
||||||
return itemDir === currentDirectory;
|
|
||||||
});
|
|
||||||
}, [fileTree, currentDirectory]);
|
|
||||||
|
|
||||||
const isSearchActive = searchQuery.trim().length > 0;
|
const isSearchActive = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
const getChildItems = (parentPath: string) => {
|
const getChildItems = (parentPath: string) => {
|
||||||
return fileTree.filter(item => {
|
return childrenByDir[parentPath] ?? [];
|
||||||
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
|
|
||||||
return itemDir === parentPath;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRelativePath = (fullPath: string) => {
|
const getRelativePath = (fullPath: string) => {
|
||||||
@@ -384,6 +405,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
const isDirectory = file.type === 'directory';
|
const isDirectory = file.type === 'directory';
|
||||||
const children = isDirectory ? getChildItems(file.path) : [];
|
const children = isDirectory ? getChildItems(file.path) : [];
|
||||||
const isExpanded = expandedDirs.has(file.path);
|
const isExpanded = expandedDirs.has(file.path);
|
||||||
|
const isLoadingChildren = isDirectory && isExpanded && inFlightDirsRef.current.has(file.path) && children.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={file.path}>
|
<div key={file.path}>
|
||||||
@@ -398,7 +420,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isDirectory) {
|
if (isDirectory) {
|
||||||
toggleDirectory(file.path);
|
void toggleDirectory(file.path);
|
||||||
} else {
|
} else {
|
||||||
toggleFileSelection(file.path);
|
toggleFileSelection(file.path);
|
||||||
}
|
}
|
||||||
@@ -418,6 +440,15 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
{children.map((child) => renderFileTree(child, level + 1))}
|
{children.map((child) => renderFileTree(child, level + 1))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isDirectory && isExpanded && isLoadingChildren && (
|
||||||
|
<div
|
||||||
|
className="px-2 py-1.5 typography-ui-label text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${(level + 1) * 12}px` }}
|
||||||
|
>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -527,6 +558,34 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (presentation === 'modal') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex cursor-pointer"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MobileOverlayPanel
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title="Select Project Files"
|
||||||
|
footer={summarySection}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0">{pickerBody}</div>
|
||||||
|
</MobileOverlayPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -549,9 +608,13 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
|||||||
{children}
|
{children}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-[520px] p-0 overflow-hidden flex flex-col ml-16"
|
className={cn(
|
||||||
align="center"
|
'p-0 overflow-hidden flex flex-col',
|
||||||
|
'w-[min(520px,calc(100vw-24px))]'
|
||||||
|
)}
|
||||||
|
align="start"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
|
collisionPadding={12}
|
||||||
>
|
>
|
||||||
{pickerBody}
|
{pickerBody}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ export const SEMANTIC_TYPOGRAPHY = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const VSCODE_TYPOGRAPHY = {
|
export const VSCODE_TYPOGRAPHY = {
|
||||||
markdown: '0.9375rem',
|
// Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density.
|
||||||
code: '0.9375rem',
|
markdown: '0.9063rem',
|
||||||
uiHeader: '1rem',
|
code: '0.8750rem',
|
||||||
uiLabel: '0.9375rem',
|
uiHeader: '0.9063rem',
|
||||||
meta: '0.9375rem',
|
uiLabel: '0.8438rem',
|
||||||
micro: '0.875rem',
|
meta: '0.8438rem',
|
||||||
|
micro: '0.7813rem',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SEMANTIC_TYPOGRAPHY_CSS = {
|
export const SEMANTIC_TYPOGRAPHY_CSS = {
|
||||||
|
|||||||
@@ -19,6 +19,32 @@ type FileStore = FileState & FileActions;
|
|||||||
|
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
const guessMimeTypeFromName = (filename: string): string => {
|
||||||
|
const name = (filename || "").toLowerCase();
|
||||||
|
const ext = name.includes(".") ? name.split(".").pop() || "" : "";
|
||||||
|
switch (ext) {
|
||||||
|
case "png":
|
||||||
|
return "image/png";
|
||||||
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case "gif":
|
||||||
|
return "image/gif";
|
||||||
|
case "webp":
|
||||||
|
return "image/webp";
|
||||||
|
case "svg":
|
||||||
|
return "image/svg+xml";
|
||||||
|
case "bmp":
|
||||||
|
return "image/bmp";
|
||||||
|
case "ico":
|
||||||
|
return "image/x-icon";
|
||||||
|
case "pdf":
|
||||||
|
return "application/pdf";
|
||||||
|
default:
|
||||||
|
return "text/plain";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const guessMimeType = (file: File): string => {
|
const guessMimeType = (file: File): string => {
|
||||||
if (file.type && file.type.trim().length > 0) {
|
if (file.type && file.type.trim().length > 0) {
|
||||||
return file.type;
|
return file.type;
|
||||||
@@ -65,6 +91,31 @@ const guessMimeType = (file: File): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const base64ByteLength = (base64: string): number => {
|
||||||
|
const cleaned = base64.replace(/\s+/g, "");
|
||||||
|
if (!cleaned) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const padding = cleaned.endsWith("==") ? 2 : cleaned.endsWith("=") ? 1 : 0;
|
||||||
|
return Math.floor((cleaned.length * 3) / 4) - padding;
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64EncodeBytes = (bytes: Uint8Array): string => {
|
||||||
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let output = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const a = bytes[i] ?? 0;
|
||||||
|
const b = bytes[i + 1];
|
||||||
|
const c = bytes[i + 2];
|
||||||
|
const triple = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
|
||||||
|
output += alphabet[(triple >> 18) & 63];
|
||||||
|
output += alphabet[(triple >> 12) & 63];
|
||||||
|
output += typeof b === "number" ? alphabet[(triple >> 6) & 63] : "=";
|
||||||
|
output += typeof c === "number" ? alphabet[triple & 63] : "=";
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
export const useFileStore = create<FileStore>()(
|
export const useFileStore = create<FileStore>()(
|
||||||
|
|
||||||
devtools(
|
devtools(
|
||||||
@@ -153,6 +204,8 @@ export const useFileStore = create<FileStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fileContent = content;
|
let fileContent = content;
|
||||||
|
let encoding: "base64" | undefined;
|
||||||
|
let resolvedMimeType: string | undefined;
|
||||||
if (!fileContent) {
|
if (!fileContent) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -171,6 +224,8 @@ export const useFileStore = create<FileStore>()(
|
|||||||
|
|
||||||
if (response.data && "content" in response.data) {
|
if (response.data && "content" in response.data) {
|
||||||
fileContent = response.data.content;
|
fileContent = response.data.content;
|
||||||
|
encoding = response.data.encoding ?? undefined;
|
||||||
|
resolvedMimeType = response.data.mimeType ?? undefined;
|
||||||
} else {
|
} else {
|
||||||
fileContent = "";
|
fileContent = "";
|
||||||
}
|
}
|
||||||
@@ -181,26 +236,36 @@ export const useFileStore = create<FileStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([fileContent || ""], { type: "text/plain" });
|
const inferredMime = resolvedMimeType || guessMimeTypeFromName(name);
|
||||||
|
const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream";
|
||||||
|
|
||||||
if (blob.size > MAX_ATTACHMENT_SIZE) {
|
const base64 = (() => {
|
||||||
|
if (encoding === "base64") {
|
||||||
|
return fileContent || "";
|
||||||
|
}
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(fileContent || "");
|
||||||
|
return base64EncodeBytes(data);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const sizeBytes = encoding === "base64"
|
||||||
|
? base64ByteLength(base64)
|
||||||
|
: new TextEncoder().encode(fileContent || "").length;
|
||||||
|
|
||||||
|
if (sizeBytes > MAX_ATTACHMENT_SIZE) {
|
||||||
throw new Error(`File "${name}" is too large. Maximum size is 10MB.`);
|
throw new Error(`File "${name}" is too large. Maximum size is 10MB.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = new File([blob], name, { type: "text/plain" });
|
const file = new File([], name, { type: safeMimeType });
|
||||||
|
const dataUrl = `data:${safeMimeType};base64,${base64}`;
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode(fileContent || "");
|
|
||||||
const base64 = btoa(String.fromCharCode(...data));
|
|
||||||
const dataUrl = `data:text/plain;base64,${base64}`;
|
|
||||||
|
|
||||||
const attachedFile: AttachedFile = {
|
const attachedFile: AttachedFile = {
|
||||||
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||||
file,
|
file,
|
||||||
dataUrl,
|
dataUrl,
|
||||||
mimeType: "text/plain",
|
mimeType: safeMimeType,
|
||||||
filename: name,
|
filename: name,
|
||||||
size: blob.size,
|
size: sizeBytes,
|
||||||
source: "server",
|
source: "server",
|
||||||
serverPath: path,
|
serverPath: path,
|
||||||
};
|
};
|
||||||
|
|||||||
+129
-18
@@ -74,30 +74,141 @@ const listDirectoryEntries = async (dirPath: string) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
||||||
|
'node_modules',
|
||||||
|
'.git',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.next',
|
||||||
|
'.turbo',
|
||||||
|
'.cache',
|
||||||
|
'coverage',
|
||||||
|
'tmp',
|
||||||
|
'logs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shouldSkipSearchDirectory = (name: string) => {
|
||||||
|
if (!name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name.startsWith('.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchFilesystemFiles = async (rootPath: string, query: string, limit: number) => {
|
||||||
|
const normalizedQuery = (query || '').trim().toLowerCase();
|
||||||
|
const matchAll = normalizedQuery.length === 0;
|
||||||
|
|
||||||
|
const rootUri = vscode.Uri.file(rootPath);
|
||||||
|
const queue: vscode.Uri[] = [rootUri];
|
||||||
|
const visited = new Set<string>([normalizeFsPath(rootUri.fsPath)]);
|
||||||
|
const results: Array<{ name: string; path: string; relativePath: string; extension?: string }> = [];
|
||||||
|
const MAX_CONCURRENCY = 5;
|
||||||
|
|
||||||
|
while (queue.length > 0 && results.length < limit) {
|
||||||
|
const batch = queue.splice(0, MAX_CONCURRENCY);
|
||||||
|
const dirLists = await Promise.all(
|
||||||
|
batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][]))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let index = 0; index < batch.length; index += 1) {
|
||||||
|
const currentDir = batch[index];
|
||||||
|
const dirents = dirLists[index];
|
||||||
|
|
||||||
|
for (const [entryName, entryType] of dirents) {
|
||||||
|
if (!entryName || entryName.startsWith('.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryUri = vscode.Uri.joinPath(currentDir, entryName);
|
||||||
|
const absolute = normalizeFsPath(entryUri.fsPath);
|
||||||
|
|
||||||
|
if (entryType === vscode.FileType.Directory) {
|
||||||
|
if (shouldSkipSearchDirectory(entryName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!visited.has(absolute)) {
|
||||||
|
visited.add(absolute);
|
||||||
|
queue.push(entryUri);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryType !== vscode.FileType.File) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute));
|
||||||
|
if (!matchAll) {
|
||||||
|
const lowercaseName = entryName.toLowerCase();
|
||||||
|
const lowercasePath = relativePath.toLowerCase();
|
||||||
|
if (!lowercaseName.includes(normalizedQuery) && !lowercasePath.includes(normalizedQuery)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name: entryName,
|
||||||
|
path: absolute,
|
||||||
|
relativePath,
|
||||||
|
extension: entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length >= limit) {
|
||||||
|
queue.length = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
||||||
const rootPath = directory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
const rootPath = directory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
if (!rootPath) return [];
|
if (!rootPath) return [];
|
||||||
|
|
||||||
const sanitizedQuery = query?.trim() || '';
|
const sanitizedQuery = query?.trim() || '';
|
||||||
const pattern = sanitizedQuery ? `**/*${sanitizedQuery}*` : '**/*';
|
if (!sanitizedQuery) {
|
||||||
const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**';
|
return searchFilesystemFiles(rootPath, '', limit);
|
||||||
const results = await vscode.workspace.findFiles(
|
}
|
||||||
new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern),
|
|
||||||
exclude,
|
|
||||||
limit,
|
|
||||||
);
|
|
||||||
|
|
||||||
return results.map((file) => {
|
// Fast-path via VS Code's file index (may be case-sensitive depending on platform/workspace).
|
||||||
const absolute = normalizeFsPath(file.fsPath);
|
try {
|
||||||
const relative = normalizeFsPath(path.relative(rootPath, absolute));
|
const pattern = `**/*${sanitizedQuery}*`;
|
||||||
const name = path.basename(absolute);
|
const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**';
|
||||||
return {
|
const results = await vscode.workspace.findFiles(
|
||||||
name,
|
new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern),
|
||||||
path: absolute,
|
exclude,
|
||||||
relativePath: relative || name,
|
limit,
|
||||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
);
|
||||||
};
|
|
||||||
});
|
if (Array.isArray(results) && results.length > 0) {
|
||||||
|
return results.map((file) => {
|
||||||
|
const absolute = normalizeFsPath(file.fsPath);
|
||||||
|
const relative = normalizeFsPath(path.relative(rootPath, absolute));
|
||||||
|
const name = path.basename(absolute);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: absolute,
|
||||||
|
relativePath: relative || name,
|
||||||
|
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to filesystem traversal.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: deterministic, case-insensitive traversal with early-exit at limit.
|
||||||
|
return searchFilesystemFiles(rootPath, sanitizedQuery, limit);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchModelsMetadata = async () => {
|
const fetchModelsMetadata = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user