Files
openchamber/packages/ui/src/components/views/FilesView.tsx
T
Bohdan Triapitsyn f4da45a8ad Add plan mode and plan view with per-session agent context (#210)
* feat: add plan/build mode switching and Plan view tab

Add PlanView view and integrate plan tab into main layout
Introduce plan_enter and plan_exit tools with icons and status strings
Persist per-session agent selections in context to reduce UI flicker during mode switches

* feat: enchanced plan discovery checks in header and plan view

* fix(ui): align PlanView and Header with sessionDirectory logic

Remove dependency on context store in header and compute sessionDirectory from session or currentDirectory
Compute display and repo paths using sessionDirectory for PlanView and update path resolution
Poll plan content every 3 seconds to reflect changes without heavy retries

* feat(ui): improve header tab switching and add copy buttons for plan

Switch header tab navigation to central UI store and auto-switch when plan is unavailable
Show checkmark icon after copying file contents or path with auto-hide timeout
Extend plan view path resolution to prefer session directory when present
2026-01-25 15:52:02 +02:00

1724 lines
60 KiB
TypeScript

import React from 'react';
import {
RiArrowLeftSLine,
RiClipboardLine,
RiCloseLine,
RiCodeLine,
RiFileImageLine,
RiFileTextLine,
RiFileCopy2Line,
RiCheckLine,
RiFolder3Fill,
RiFolderOpenFill,
RiLoader4Line,
RiRefreshLine,
RiSearchLine,
RiSave3Line,
RiSendPlane2Line,
RiTextWrap,
RiMore2Fill,
RiFileAddLine,
RiFolderAddLine,
RiDeleteBinLine,
RiEditLine,
RiFileCopyLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
type FileNode = {
name: string;
path: string;
type: 'file' | 'directory';
extension?: string;
relativePath?: string;
};
type SelectedLineRange = {
start: number;
end: number;
};
const sortNodes = (items: FileNode[]) =>
items.slice().sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']);
const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name);
const shouldIgnorePath = (path: string): boolean => {
const normalized = normalizePath(path);
return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/');
};
const useEffectiveDirectory = () => {
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
const { currentDirectory: fallbackDirectory } = useDirectoryStore();
const worktreeMetadata = currentSessionId ? worktreeMap.get(currentSessionId) ?? undefined : undefined;
const currentSession = sessions.find((session) => session.id === currentSessionId);
type SessionWithDirectory = { directory?: string };
const sessionDirectory = (currentSession as unknown as SessionWithDirectory | undefined)?.directory;
return worktreeMetadata?.path ?? sessionDirectory ?? fallbackDirectory ?? '';
};
const MAX_VIEW_CHARS = 200_000;
const CODE_EXTENSIONS = new Set([
// JavaScript/TypeScript
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts',
// Web
'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl', 'stylus',
'vue', 'svelte', 'astro',
// Shell/Scripts
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd',
// Python
'py', 'pyw', 'pyx', 'pxd', 'pxi',
// Ruby
'rb', 'erb', 'rake', 'gemspec',
// PHP
'php', 'phtml', 'php3', 'php4', 'php5', 'phps',
// Java/JVM
'java', 'kt', 'kts', 'scala', 'sc', 'groovy', 'gradle',
// C/C++/Objective-C
'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hxx', 'hh', 'm', 'mm',
// C#/F#/.NET
'cs', 'fs', 'fsx', 'fsi',
// Go
'go',
// Rust
'rs',
// Swift
'swift',
// Dart
'dart',
// Lua
'lua',
// Perl
'pl', 'pm', 'pod',
// R
'r', 'R', 'rmd',
// Julia
'jl',
// Haskell
'hs', 'lhs',
// Elixir/Erlang
'ex', 'exs', 'erl', 'hrl',
// Clojure
'clj', 'cljs', 'cljc', 'edn',
// Lisp/Scheme
'lisp', 'cl', 'el', 'scm', 'ss', 'rkt',
// OCaml/ReasonML
'ml', 'mli', 're', 'rei',
// Nim
'nim',
// Zig
'zig',
// V
'v',
// Crystal
'cr',
// Kotlin Script
'main.kts',
// SQL
'sql', 'psql', 'plsql',
// GraphQL
'graphql', 'gql',
// Solidity
'sol',
// Assembly
'asm', 's', 'S',
// Makefile variants
'mk',
// Nix
'nix',
// Terraform
'tf', 'tfvars',
// Puppet
'pp',
// Ansible
'ansible',
]);
const DATA_EXTENSIONS = new Set([
// JSON variants
'json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'geojson',
// YAML
'yaml', 'yml',
// TOML
'toml',
// XML variants
'xml', 'xsl', 'xslt', 'xsd', 'dtd', 'plist',
// Config files
'ini', 'cfg', 'conf', 'config', 'env', 'properties',
// CSV/TSV
'csv', 'tsv',
// Lock files
'lock',
]);
const IMAGE_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns',
'bmp', 'tiff', 'tif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
'heic', 'heif', 'avif', 'jxl',
]);
const DOCUMENT_EXTENSIONS = new Set([
// Markdown
'md', 'mdx', 'markdown', 'mdown', 'mkd',
// Text
'txt', 'text', 'rtf',
// Docs
'doc', 'docx', 'odt', 'pdf',
// ReStructuredText
'rst',
// AsciiDoc
'adoc', 'asciidoc',
// Org
'org',
// LaTeX
'tex', 'latex', 'bib',
]);
const getFileIcon = (extension?: string): React.ReactNode => {
const ext = extension?.toLowerCase();
if (ext && CODE_EXTENSIONS.has(ext)) {
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-blue-500" />;
}
if (ext && DATA_EXTENSIONS.has(ext)) {
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-yellow-500" />;
}
if (ext && IMAGE_EXTENSIONS.has(ext)) {
return <RiFileImageLine className="h-4 w-4 flex-shrink-0 text-green-500" />;
}
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
}
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
};
export const FilesView: React.FC = () => {
const { files, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const { isMobile, screenWidth } = useDeviceInfo();
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const currentDirectory = useEffectiveDirectory();
const root = normalizePath(currentDirectory);
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const [searchQuery, setSearchQuery] = React.useState('');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [showMobilePageContent, setShowMobilePageContent] = React.useState(false);
const [wrapLines, setWrapLines] = React.useState(isMobile);
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
const [searching, setSearching] = React.useState(false);
const [selectedFile, setSelectedFile] = React.useState<FileNode | null>(null);
const [fileContent, setFileContent] = React.useState<string>('');
const [fileLoading, setFileLoading] = React.useState(false);
const [fileError, setFileError] = React.useState<string | null>(null);
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
const [draftContent, setDraftContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null);
const skipDirtyOnceRef = React.useRef(false);
const copiedContentTimeoutRef = React.useRef<number | null>(null);
const copiedPathTimeoutRef = React.useRef<number | null>(null);
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
const [dialogInputValue, setDialogInputValue] = React.useState('');
const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false);
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
const canRename = Boolean(files.rename);
const canDelete = Boolean(files.delete);
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
setActiveDialog(type);
setDialogData(data);
setDialogInputValue(type === 'rename' ? data.name || '' : '');
setIsDialogSubmitting(false);
}, []);
// Line selection state for commenting
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = React.useState('');
const isSelectingRef = React.useRef(false);
const selectionStartRef = React.useRef<number | null>(null);
// Session/config for sending comments
const sendMessage = useSessionStore((state) => state.sendMessage);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore();
const getSessionAgentSelection = useContextStore((state) => state.getSessionAgentSelection);
const getAgentModelForSession = useContextStore((state) => state.getAgentModelForSession);
const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
const { inputBarOffset, isKeyboardOpen } = useUIStore();
// Global mouseup to end drag selection
React.useEffect(() => {
const handleGlobalMouseUp = () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, []);
// Clear selection when file changes
React.useEffect(() => {
setLineSelection(null);
setCommentText('');
setMainTabGuard(null);
setDraftContent('');
setIsSaving(false);
}, [selectedFile?.path, setMainTabGuard]);
React.useEffect(() => {
return () => {
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
};
}, []);
// Click outside to dismiss selection
React.useEffect(() => {
if (!lineSelection) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Check if click is inside comment UI
const commentUI = document.querySelector('[data-comment-ui]');
if (commentUI?.contains(target)) return;
// Check if click is on CM gutter (only gutter should not dismiss)
if (target.closest('.cm-gutterElement')) return;
// Check if click is inside toast (sonner)
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
// Clicking anywhere else (including code content) dismisses selection
setLineSelection(null);
setCommentText('');
};
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 100);
return () => {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [lineSelection]);
// Extract selected code
const extractSelectedCode = React.useCallback((content: string, range: SelectedLineRange): string => {
const lines = content.split('\n');
const startLine = Math.max(1, range.start);
const endLine = Math.min(lines.length, range.end);
if (startLine > endLine) return '';
return lines.slice(startLine - 1, endLine).join('\n');
}, []);
// Send comment handler
const handleSendComment = React.useCallback(async () => {
if (!lineSelection || !commentText.trim() || !selectedFile) return;
if (!currentSessionId) {
toast.error('Select a session to send comment');
return;
}
// Get session-specific agent/model/variant with fallback to config values
const sessionAgent = getSessionAgentSelection(currentSessionId) || currentAgentName;
const sessionModel = sessionAgent ? getAgentModelForSession(currentSessionId, sessionAgent) : null;
const effectiveProviderId = sessionModel?.providerId || currentProviderId;
const effectiveModelId = sessionModel?.modelId || currentModelId;
if (!effectiveProviderId || !effectiveModelId) {
toast.error('Select a model to send comment');
return;
}
const effectiveVariant = sessionAgent && effectiveProviderId && effectiveModelId
? getAgentModelVariantForSession(currentSessionId, sessionAgent, effectiveProviderId, effectiveModelId) ?? currentVariant
: currentVariant;
const code = extractSelectedCode(fileContent, lineSelection);
const language = getLanguageFromExtension(selectedFile.path) || 'text';
const fileName = selectedFile.name;
const startLine = lineSelection.start;
const endLine = lineSelection.end;
const message = `Comment on \`${fileName}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${commentText}`;
// Clear state and switch to chat immediately
setCommentText('');
setLineSelection(null);
setActiveMainTab('chat');
void sendMessage(
message,
effectiveProviderId,
effectiveModelId,
sessionAgent,
undefined,
undefined,
undefined,
effectiveVariant
).catch((e) => {
console.error('Failed to send comment', e);
});
}, [lineSelection, commentText, selectedFile, fileContent, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, extractSelectedCode, sendMessage, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]);
const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => {
const nodes = entries
.filter((entry) => entry && typeof entry.name === 'string' && entry.name.length > 0)
.filter((entry) => showHidden || !entry.name.startsWith('.'))
.filter((entry) => showGitignored || !shouldIgnoreEntryName(entry.name))
.map<FileNode>((entry) => {
const name = entry.name;
const path = normalizePath(entry.path || `${dirPath}/${name}`);
const type = entry.isDirectory ? 'directory' : 'file';
const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined;
return {
name,
path,
type,
extension,
};
});
return sortNodes(nodes);
}, [showGitignored, showHidden]);
const loadDirectory = React.useCallback(async (dirPath: string) => {
const normalizedDir = normalizePath(dirPath.trim());
if (!normalizedDir) {
return;
}
if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) {
return;
}
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.add(normalizedDir);
try {
const respectGitignore = !showGitignored;
let entries: Array<{ name: string; path: string; isDirectory: boolean }>;
if (runtime.isDesktop) {
const result = await files.listDirectory(normalizedDir, { respectGitignore });
entries = result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
}));
} else {
const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore });
entries = result.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
}));
}
const mapped = mapDirectoryEntries(normalizedDir, entries);
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
} catch {
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: prev[normalizedDir] ?? [],
}));
} finally {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
}
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
const refreshRoot = React.useCallback(async () => {
const normalizedRoot = normalizePath(currentDirectory.trim());
if (!normalizedRoot) {
return;
}
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setExpandedDirs(new Set());
setChildrenByDir({});
await loadDirectory(normalizedRoot);
}, [currentDirectory, loadDirectory]);
React.useEffect(() => {
if (!currentDirectory) {
return;
}
void refreshRoot();
setSelectedFile(null);
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
setShowMobilePageContent(false);
}, [currentDirectory, refreshRoot]);
React.useEffect(() => {
if (!currentDirectory) {
return;
}
void refreshRoot();
}, [currentDirectory, refreshRoot, showGitignored]);
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
if (!dialogData || !activeDialog) return;
setIsDialogSubmitting(true);
try {
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) throw new Error('Filename is required');
const parentPath = dialogData.path;
// Handle root path or empty path
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (!files.writeFile) throw new Error('Write not supported');
const result = await files.writeFile(newPath, '');
if (result.success) {
toast.success('File created');
await refreshRoot();
}
} else if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) throw new Error('Folder name is required');
const parentPath = dialogData.path;
const prefix = parentPath ? `${parentPath}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
const result = await files.createDirectory(newPath);
if (result.success) {
toast.success('Folder created');
await refreshRoot();
}
} else if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) throw new Error('Name is required');
const oldPath = dialogData.path;
const parentDir = oldPath.split('/').slice(0, -1).join('/');
const prefix = parentDir ? `${parentDir}/` : '';
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
if (files.rename) {
const result = await files.rename(oldPath, newPath);
if (result.success) {
toast.success('Renamed successfully');
await refreshRoot();
if (selectedFile?.path === oldPath) {
setSelectedFile(null);
}
}
} else {
toast.error("Rename not supported");
}
} else if (activeDialog === 'delete') {
if (files.delete) {
const result = await files.delete(dialogData.path);
if (result.success) {
toast.success('Deleted successfully');
await refreshRoot();
if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) {
setSelectedFile(null);
setFileContent('');
}
}
} else {
toast.error("Delete not supported");
}
}
setActiveDialog(null);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Operation failed');
} finally {
setIsDialogSubmitting(false);
}
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, selectedFile]);
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(() => {
if (!currentDirectory) {
return;
}
void refreshRoot();
}, [currentDirectory, refreshRoot, showGitignored]);
React.useEffect(() => {
if (!currentDirectory) {
setSearchResults([]);
setSearching(false);
return;
}
const trimmedQuery = debouncedSearchQuery.trim();
if (!trimmedQuery) {
setSearchResults([]);
setSearching(false);
return;
}
const normalizedQueryLower = trimmedQuery.toLowerCase();
let cancelled = false;
setSearching(true);
searchFiles(currentDirectory, trimmedQuery, 150, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
})
.then((hits) => {
if (cancelled) {
return;
}
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
// Apply fuzzy scoring and sort by score
const ranked = filtered
.map((hit) => {
const label = hit.relativePath || hit.name || hit.path;
const score = fuzzyScore(normalizedQueryLower, label);
return score === null ? null : { hit, score, labelLength: label.length };
})
.filter(Boolean) as Array<{ hit: typeof hits[0]; score: number; labelLength: number }>;
ranked.sort((a, b) => (
b.score - a.score
|| a.labelLength - b.labelLength
|| a.hit.path.localeCompare(b.hit.path)
));
const mapped: FileNode[] = ranked.map(({ hit }) => ({
name: hit.name,
path: normalizePath(hit.path),
type: 'file',
extension: hit.extension,
relativePath: hit.relativePath,
}));
setSearchResults(mapped);
})
.catch(() => {
if (!cancelled) {
setSearchResults([]);
}
})
.finally(() => {
if (!cancelled) {
setSearching(false);
}
});
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
const readFile = React.useCallback(async (path: string): Promise<string> => {
if (files.readFile) {
const result = await files.readFile(path);
return result.content ?? '';
}
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to read file');
}
return response.text();
}, [files]);
const displayedContent = React.useMemo(() => {
return fileContent.length > MAX_VIEW_CHARS
? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: fileContent;
}, [fileContent]);
const isDirty = React.useMemo(() => draftContent !== displayedContent, [draftContent, displayedContent]);
const saveDraft = React.useCallback(async () => {
if (!selectedFile || !files.writeFile) {
toast.error('Saving not supported');
return;
}
if (!isDirty) {
return;
}
setIsSaving(true);
try {
const result = await files.writeFile(selectedFile.path, draftContent);
if (!result?.success) {
throw new Error('Failed to write file');
}
setFileContent(draftContent);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed');
} finally {
setIsSaving(false);
}
}, [draftContent, files, isDirty, selectedFile]);
React.useEffect(() => {
if (!isDirty) {
setMainTabGuard(null);
return;
}
const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => {
if (skipDirtyOnceRef.current) {
skipDirtyOnceRef.current = false;
return true;
}
setConfirmDiscardOpen(true);
pendingTabRef.current = _nextTab;
return false;
};
setMainTabGuard(guard);
return () => {
const currentGuard = useUIStore.getState().mainTabGuard;
if (currentGuard === guard) {
setMainTabGuard(null);
}
};
}, [isDirty, setMainTabGuard]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!hasModifier(e)) {
return;
}
if (e.key.toLowerCase() !== 's') {
return;
}
e.preventDefault();
if (!isSaving) {
void saveDraft();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSaving, saveDraft]);
const handleSelectFile = React.useCallback(async (node: FileNode) => {
if (skipDirtyOnceRef.current) {
skipDirtyOnceRef.current = false;
} else if (isDirty) {
setConfirmDiscardOpen(true);
pendingSelectFileRef.current = node;
return;
}
setSelectedFile(node);
setFileError(null);
setDesktopImageSrc('');
const selectedIsImage = isImageFile(node.path);
if (isMobile) {
setShowMobilePageContent(true);
}
const isSvg = node.path.toLowerCase().endsWith('.svg');
// Desktop: binary images are loaded via readFileBinary (data URL).
if (runtime.isDesktop && selectedIsImage && !isSvg) {
setFileContent('');
setFileLoading(true);
return;
}
// Web: binary images should not be read as utf8.
if (!runtime.isDesktop && selectedIsImage && !isSvg) {
setFileContent('');
setFileLoading(false);
return;
}
setFileLoading(true);
try {
const content = await readFile(node.path);
setFileContent(content);
setDraftContent(content.length > MAX_VIEW_CHARS
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: content);
} catch (error) {
setFileContent('');
setDraftContent('');
setFileError(error instanceof Error ? error.message : 'Failed to read file');
} finally {
setFileLoading(false);
}
}, [isDirty, isMobile, readFile, runtime.isDesktop]);
const discardAndContinue = React.useCallback(() => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
// Allow one guarded navigation (tab/file) without re-opening dialog.
skipDirtyOnceRef.current = true;
setConfirmDiscardOpen(false);
// Discard draft by reverting back to last loaded content
setDraftContent(displayedContent);
if (nextFile) {
void handleSelectFile(nextFile);
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [displayedContent, handleSelectFile, setMainTabGuard]);
const saveAndContinue = React.useCallback(async () => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
// We'll proceed after saving; suppress guard reopening.
skipDirtyOnceRef.current = true;
setConfirmDiscardOpen(false);
await saveDraft();
if (nextFile) {
await handleSelectFile(nextFile);
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [handleSelectFile, saveDraft, setMainTabGuard]);
const toggleDirectory = React.useCallback(async (dirPath: string) => {
const normalized = normalizePath(dirPath);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(normalized)) {
next.delete(normalized);
} else {
next.add(normalized);
}
return next;
});
if (!loadedDirsRef.current.has(normalized)) {
await loadDirectory(normalized);
}
}, [loadDirectory]);
const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => {
const nodes = childrenByDir[dirPath] ?? [];
return nodes.map((node, index) => {
const isDir = node.type === 'directory';
const isExpanded = isDir && expandedDirs.has(node.path);
const isActive = selectedFile?.path === node.path;
const isLoading = isDir && inFlightDirsRef.current.has(node.path);
const isLast = index === nodes.length - 1;
return (
<li key={node.path} className="relative">
{depth > 0 && (
<>
<span className="absolute top-3.5 left-[-12px] w-3 h-px bg-border/40" />
{isLast && (
<span className="absolute top-3.5 bottom-0 left-[-13px] w-[2px] bg-background" />
)}
</>
)}
<div
className="group relative flex items-center"
onContextMenu={(event) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) {
return;
}
event.preventDefault();
setContextMenuPath(node.path);
}}
>
<button
type="button"
onClick={() => {
if (isDir) {
void toggleDirectory(node.path);
} else {
void handleSelectFile(node);
}
}}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
isActive ? 'bg-accent/70' : 'hover:bg-accent/40'
)}
>
{isDir ? (
isLoading ? (
<RiLoader4Line className="h-4 w-4 flex-shrink-0 animate-spin" />
) : isExpanded ? (
<RiFolderOpenFill className="h-4 w-4 flex-shrink-0 text-primary/60" />
) : (
<RiFolder3Fill className="h-4 w-4 flex-shrink-0 text-primary/60" />
)
) : (
getFileIcon(node.extension)
)}
<span
className="min-w-0 flex-1 truncate typography-meta"
title={node.path}
>
{node.name}
</span>
</button>
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
<DropdownMenu
open={contextMenuPath === node.path}
onOpenChange={(open) => setContextMenuPath(open ? node.path : null)}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<RiMore2Fill className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleOpenDialog('rename', node); }}>
<RiEditLine className="mr-2 h-4 w-4" /> Rename
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void navigator.clipboard.writeText(node.path);
toast.success('Path copied');
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
</DropdownMenuItem>
{isDir && (canCreateFile || canCreateFolder) && (
<>
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleOpenDialog('createFile', node); }}>
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleOpenDialog('createFolder', node); }}>
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
</DropdownMenuItem>
)}
</>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); handleOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{isDir && isExpanded && (
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
{renderTree(node.path, depth + 1)}
</ul>
)}
</li>
);
});
}, [childrenByDir, expandedDirs, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, setContextMenuPath]);
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
const displaySelectedPath = React.useMemo(() => {
if (!selectedFile?.path) return '';
const normalizedFilePath = normalizePath(selectedFile.path);
if (root && normalizedFilePath.startsWith(root)) {
const relative = normalizedFilePath.slice(root.length);
return relative.startsWith('/') ? relative.slice(1) : relative;
}
return normalizedFilePath;
}, [selectedFile?.path, root]);
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0);
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const editorExtensions = React.useMemo(() => {
if (!selectedFile?.path) {
return [createFlexokiCodeMirrorTheme(currentTheme)];
}
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
const language = languageByExtension(selectedFile.path);
if (language) {
extensions.push(language);
}
if (wrapLines) {
extensions.push(EditorView.lineWrapping);
}
return extensions;
}, [currentTheme, selectedFile?.path, wrapLines]);
const imageSrc = selectedFile?.path && isSelectedImage
? (runtime.isDesktop
? (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: desktopImageSrc)
: (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`))
: '';
React.useEffect(() => {
let cancelled = false;
const resolveDesktopImage = async () => {
if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) {
setDesktopImageSrc('');
return;
}
setFileError(null);
try {
if (files.readFileBinary) {
const result = await files.readFileBinary(selectedFile.path);
if (!cancelled) {
setDesktopImageSrc(result.dataUrl);
}
return;
}
const core = await import('@tauri-apps/api/core');
const convertFileSrc = (core as { convertFileSrc?: (path: string, protocol?: string) => string }).convertFileSrc;
if (!convertFileSrc) {
return;
}
const src = convertFileSrc(selectedFile.path, 'asset');
if (!cancelled) {
setDesktopImageSrc(src);
}
} catch (error) {
if (!cancelled) {
setDesktopImageSrc('');
setFileError(error instanceof Error ? error.message : 'Failed to read file');
}
} finally {
if (!cancelled) {
setFileLoading(false);
}
}
};
void resolveDesktopImage();
return () => {
cancelled = true;
};
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path]);
const renderDialogs = () => (
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{activeDialog === 'createFile' && 'Create File'}
{activeDialog === 'createFolder' && 'Create Folder'}
{activeDialog === 'rename' && 'Rename'}
{activeDialog === 'delete' && 'Delete'}
</DialogTitle>
<DialogDescription>
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
{activeDialog !== 'delete' && (
<div className="py-4">
<Input
value={dialogInputValue}
onChange={(e) => setDialogInputValue(e.target.value)}
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleDialogSubmit();
}
}}
autoFocus
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
Cancel
</Button>
<Button
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
onClick={() => void handleDialogSubmit()}
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
>
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
activeDialog === 'delete' ? 'Delete' : 'Confirm'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
// Comment UI component
const renderCommentUI = () => {
if (!lineSelection || !selectedFile) return null;
return (
<div
data-comment-ui
className="flex flex-col items-center gap-2 px-4"
style={{ width: 'min(100vw - 1rem, 42rem)' }}
>
<div className="w-full rounded-xl border bg-background flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
<Textarea
value={commentText}
onChange={(e) => {
setCommentText(e.target.value);
const textarea = e.target;
textarea.style.height = 'auto';
const lineHeight = 20;
const maxHeight = lineHeight * 5 + 8;
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
}}
placeholder="Type your comment..."
className="min-h-[28px] max-h-[108px] resize-none border-0 px-3 pt-2 pb-1 shadow-none rounded-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent dark:bg-transparent focus-visible:outline-none overflow-y-auto"
autoFocus={!isMobile}
rows={1}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSendComment();
}
if (e.key === 'Escape') {
e.preventDefault();
setLineSelection(null);
setCommentText('');
}
}}
/>
<div className="px-2.5 py-1 flex items-center justify-between gap-x-1.5">
<span className="text-xs text-muted-foreground">
{selectedFile.name}:{lineSelection.start}-{lineSelection.end}
</span>
<div className="flex items-center gap-x-1.5">
{!isMobile && (
<span className="text-xs text-muted-foreground">
{getModifierLabel()}+⏎
</span>
)}
<button
type="button"
onTouchEnd={(e) => {
if (commentText.trim()) {
e.preventDefault();
handleSendComment();
}
}}
onClick={() => {
if (!isMobile) {
handleSendComment();
}
}}
disabled={!commentText.trim()}
className={cn(
"h-7 w-7 flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0",
commentText.trim() ? "text-primary hover:text-primary" : "opacity-30"
)}
aria-label="Send comment"
>
<RiSendPlane2Line className="h-[18px] w-[18px]" />
</button>
</div>
</div>
</div>
</div>
);
};
const fileViewer = (
<div
className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden"
>
<Dialog open={confirmDiscardOpen} onOpenChange={(open) => {
// Intentionally no "cancel" action. Keep dialog modal.
if (!open) {
setConfirmDiscardOpen(true);
}
}}>
<DialogContent showCloseButton={false} className="max-w-md">
<DialogHeader>
<DialogTitle>Unsaved changes</DialogTitle>
<DialogDescription>
Save your edits before continuing?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => void saveAndContinue()}
disabled={isSaving}
className="border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)] hover:bg-[rgb(var(--status-success)/0.2)]"
>
Save changes
</Button>
<Button variant="destructive" onClick={discardAndContinue}>Discard</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
{isMobile && showMobilePageContent && (
<button
type="button"
onClick={() => setShowMobilePageContent(false)}
aria-label="Back"
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
)}
<div className="min-w-0 flex-1">
<div className="typography-ui-label font-medium truncate">
{selectedFile ? selectedFile.name : 'Select a file'}
</div>
{selectedFile && !isMobile && (
<div className="typography-meta text-muted-foreground truncate" title={displaySelectedPath}>
{displaySelectedPath}
</div>
)}
</div>
<div className="flex items-center gap-1">
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
disabled={!isDirty || isSaving}
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
title={`Save (${getModifierLabel()}+S)`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
{isSaving ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiSave3Line className="h-4 w-4" />
)}
</Button>
)}
{canEdit && selectedFile && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{selectedFile && !isSelectedImage && (
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
>
<RiTextWrap className="size-4" />
</Button>
)}
{(canCopy || canCopyPath) && (canEdit || (selectedFile && !isSelectedImage)) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(fileContent);
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(displaySelectedPath);
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
<div className="flex-1 min-h-0 min-w-0 relative">
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
) : fileLoading ? (
<div className="p-3 flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading…
</div>
) : fileError ? (
<div className="p-3 typography-ui text-[color:var(--status-error)]">{fileError}</div>
) : isSelectedImage ? (
<div className="flex h-full items-center justify-center p-3">
<img
src={imageSrc}
alt={selectedFile?.name ?? 'Image'}
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
/>
</div>
) : (
<div className="h-full">
<CodeMirrorEditor
value={draftContent}
onChange={setDraftContent}
extensions={editorExtensions}
className="h-full"
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.button !== 0) {
return false;
}
event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number;
// Mobile: tap-to-extend selection
if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
setLineSelection({ start, end });
isSelectingRef.current = false;
selectionStartRef.current = null;
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
} else {
setLineSelection({ start: lineNumber, end: lineNumber });
}
return true;
},
mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.buttons !== 1) {
return false;
}
if (!isSelectingRef.current || selectionStartRef.current === null) {
return false;
}
const lineNumber = view.state.doc.lineAt(line.from).number;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber);
setLineSelection({ start, end });
return false;
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
return false;
},
},
}}
/>
</div>
)}
</ScrollableOverlay>
</div>
{/* Comment UI floating at bottom */}
{lineSelection && (
<div
className="pointer-events-none absolute inset-0 z-50 flex flex-col justify-end"
style={{
paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : '0px'
}}
>
<div
className={cn(
"pointer-events-auto pb-2 transition-none w-full flex justify-center",
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
)}
style={{
marginBottom: isMobile
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
: '16px'
}}
data-keyboard-avoid="true"
>
{renderCommentUI()}
</div>
</div>
)}
</div>
);
const hasTree = Boolean(root && childrenByDir[root]);
const treePanel = (
<section className={cn(
"flex min-h-0 flex-col overflow-hidden",
isMobile ? "h-full w-full bg-background" : "h-full rounded-xl border border-border/60 bg-background/70"
)}>
<div className={cn("flex flex-col gap-2 py-2", isMobile ? "px-3" : "px-2")}>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search files…"
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 && (
<button
type="button"
aria-label="Clear search"
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<RiCloseLine className="h-4 w-4" />
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New File"
>
<RiFileAddLine className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New Folder"
>
<RiFolderAddLine className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0">
<RiRefreshLine className="h-4 w-4" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn("py-2", isMobile ? "px-3" : "px-2")}>
<ul className="flex flex-col">
{searching ? (
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Searching…
</li>
) : searchResults.length > 0 ? (
searchResults.map((node) => {
const isActive = selectedFile?.path === node.path;
return (
<li key={node.path}>
<button
type="button"
onClick={() => void handleSelectFile(node)}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
isActive ? 'bg-accent/70' : 'hover:bg-accent/40'
)}
>
{getFileIcon(node.extension)}
<span
className="min-w-0 flex-1 truncate typography-meta"
style={{ direction: 'rtl', textAlign: 'left' }}
title={node.path}
>
{node.relativePath ?? node.path}
</span>
</button>
</li>
);
})
) : hasTree ? (
renderTree(root, 0)
) : (
<li className="px-2 py-1 typography-meta text-muted-foreground">Loading…</li>
)}
</ul>
</ScrollableOverlay>
</section>
);
return (
<div className="flex h-full min-h-0 overflow-hidden bg-background">
{renderDialogs()}
{isMobile ? (
showMobilePageContent ? (
fileViewer
) : (
treePanel
)
) : (
<div className="flex flex-1 min-h-0 min-w-0 gap-3 px-3 pb-3 pt-2">
{screenWidth >= 1024 && (
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
{treePanel}
</div>
)}
<div className="flex-1 min-h-0 min-w-0 overflow-hidden rounded-xl border border-border/60 bg-background">
{fileViewer}
</div>
</div>
)}
</div>
);
};