import React from 'react';
import {
RiArrowLeftSLine,
RiArrowRightSLine,
RiArrowDownSLine,
RiClipboardLine,
RiCloseLine,
RiCodeLine,
RiFileImageLine,
RiFileTextLine,
RiFileCopy2Line,
RiCheckLine,
RiFolder3Fill,
RiFolderOpenFill,
RiFullscreenExitLine,
RiFullscreenLine,
RiLoader4Line,
RiRefreshLine,
RiSearchLine,
RiSave3Line,
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 { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
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 { useLongPress } from '@/hooks/useLongPress';
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 { useUIStore } from '@/stores/useUIStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useGitStatus } from '@/stores/useGitStore';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
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 => {
if (!value) return '';
const raw = value.replace(/\\/g, '/');
const hadUncPrefix = raw.startsWith('//');
let normalized = raw.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
const isUnixRoot = normalized === '/';
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
if (!isUnixRoot && !isWindowsDriveRoot) {
normalized = normalized.replace(/\/+$/, '');
}
return normalized;
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
const toComparablePath = (value: string): string => {
if (/^[A-Za-z]:\//.test(value)) {
return value.toLowerCase();
}
return value;
};
const isPathWithinRoot = (path: string, root: string): boolean => {
const normalizedRoot = normalizePath(root);
const normalizedPath = normalizePath(path);
if (!normalizedRoot || !normalizedPath) return false;
const comparableRoot = toComparablePath(normalizedRoot);
const comparablePath = toComparablePath(normalizedPath);
return comparablePath === comparableRoot || comparablePath.startsWith(`${comparableRoot}/`);
};
const getAncestorPaths = (filePath: string, root: string): string[] => {
const normalizedRoot = normalizePath(root);
const normalizedFile = normalizePath(filePath);
// Ensure file is within root
if (!isPathWithinRoot(normalizedFile, normalizedRoot)) return [];
const relative = normalizedFile.slice(normalizedRoot.length).replace(/^\//, '');
const parts = relative.split('/');
const ancestors: string[] = [];
let current = normalizedRoot;
for (let i = 0; i < parts.length - 1; i++) {
current = current ? `${current}/${parts[i]}` : parts[i];
ancestors.push(current);
}
return ancestors;
};
type BreadcrumbSegment = { label: string; path: string };
const parseBreadcrumbs = (relativePath: string, root: string): BreadcrumbSegment[] => {
const parts = relativePath.split('/');
const segments: BreadcrumbSegment[] = [];
let currentPath = root;
for (const part of parts) {
if (!part) continue;
currentPath = currentPath ? `${currentPath}/${part}` : part;
segments.push({ label: part, path: currentPath });
}
return segments;
};
const FileBreadcrumbs: React.FC<{
path: string;
root: string;
onNavigate: (dirPath: string) => void;
}> = ({ path, root, onNavigate }) => {
const segments = React.useMemo(() => parseBreadcrumbs(path, root), [path, root]);
return (
{segments.map((seg, i) => (
{i > 0 && }
))}
);
};
const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']);
type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted';
const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
const color = {
open: 'var(--status-info)',
modified: 'var(--status-warning)',
'git-modified': 'var(--status-warning)',
'git-added': 'var(--status-success)',
'git-deleted': 'var(--status-error)',
}[status];
return ;
};
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 isDirectoryReadError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error ?? '');
const normalized = message.toLowerCase();
return normalized.includes('is a directory') || normalized.includes('eisdir');
};
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 ;
}
if (ext && DATA_EXTENSIONS.has(ext)) {
return ;
}
if (ext && IMAGE_EXTENSIONS.has(ext)) {
return ;
}
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
return ;
}
return ;
};
const isMarkdownFile = (path: string): boolean => {
if (!path) return false;
const ext = path.toLowerCase().split('.').pop();
return ext === 'md' || ext === 'markdown';
};
interface FileRowProps {
node: FileNode;
isExpanded: boolean;
isActive: boolean;
isLoading: boolean;
isMobile: boolean;
status?: FileStatus | null;
badge?: { modified: number; added: number } | null;
permissions: {
canRename: boolean;
canCreateFile: boolean;
canCreateFolder: boolean;
canDelete: boolean;
};
contextMenuPath: string | null;
setContextMenuPath: (path: string | null) => void;
onSelect: (node: FileNode) => void;
onToggle: (path: string) => void;
onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void;
}
const FileRow: React.FC = ({
node,
isExpanded,
isActive,
isLoading,
isMobile,
status,
badge,
permissions,
contextMenuPath,
setContextMenuPath,
onSelect,
onToggle,
onOpenDialog,
}) => {
const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete } = permissions;
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) {
return;
}
event?.preventDefault();
setContextMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, node.path, setContextMenuPath]);
const handleInteraction = React.useCallback(() => {
if (isDir) {
onToggle(node.path);
} else {
onSelect(node);
}
}, [isDir, node, onSelect, onToggle]);
const longPressHandlers = useLongPress({
onLongPress: handleContextMenu,
onTap: handleInteraction,
enableHaptic: true,
});
const interactionProps = isMobile ? longPressHandlers : {
onClick: handleInteraction,
onContextMenu: handleContextMenu,
};
return (
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
setContextMenuPath(open ? node.path : null)}
>
{!isMobile && (
)}
{isMobile && (
)}
setContextMenuPath(null)}>
{canRename && (
{ e.stopPropagation(); onOpenDialog('rename', node); }}>
Rename
)}
{
e.stopPropagation();
void navigator.clipboard.writeText(node.path);
toast.success('Path copied');
}}>
Copy Path
{isDir && (canCreateFile || canCreateFolder) && (
<>
{canCreateFile && (
{ e.stopPropagation(); onOpenDialog('createFile', node); }}>
New File
)}
{canCreateFolder && (
{ e.stopPropagation(); onOpenDialog('createFolder', node); }}>
New Folder
)}
>
)}
{canDelete && (
<>
{ e.stopPropagation(); onOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
Delete
>
)}
)}
);
};
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.trim());
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const gitStatus = useGitStatus(currentDirectory);
const [searchQuery, setSearchQuery] = React.useState('');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
const searchInputRef = React.useRef(null);
const [showMobilePageContent, setShowMobilePageContent] = React.useState(false);
const [wrapLines, setWrapLines] = React.useState(isMobile);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const EMPTY_PATHS: string[] = React.useMemo(() => [], []);
const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath);
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath);
const expandPaths = useFilesViewTabsStore((state) => state.expandPaths);
const toFileNode = React.useCallback((path: string): FileNode => {
const normalized = normalizePath(path);
const parts = normalized.split('/');
const name = parts[parts.length - 1] || normalized;
const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined;
return {
name,
path: normalized,
type: 'file',
extension,
};
}, []);
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
const [childrenByDir, setChildrenByDir] = React.useState>({});
const loadedDirsRef = React.useRef>(new Set());
const inFlightDirsRef = React.useRef>(new Set());
const [searchResults, setSearchResults] = React.useState([]);
const [searching, setSearching] = React.useState(false);
const [fileContent, setFileContent] = React.useState('');
const [fileLoading, setFileLoading] = React.useState(false);
const [fileError, setFileError] = React.useState(null);
const [desktopImageSrc, setDesktopImageSrc] = React.useState('');
const [loadedFilePath, setLoadedFilePath] = React.useState(null);
const [draftContent, setDraftContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
const pendingSelectFileRef = React.useRef(null);
const pendingTabRef = React.useRef(null);
const pendingClosePathRef = React.useRef(null);
const skipDirtyOnceRef = React.useRef(false);
const copiedContentTimeoutRef = React.useRef(null);
const copiedPathTimeoutRef = React.useRef(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(null);
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
// Markdown view mode (global, not per-file)
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
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(null);
const isSelectingRef = React.useRef(false);
const selectionStartRef = React.useRef(null);
const [isDragging, setIsDragging] = React.useState(false);
// Session/config for sending comments
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
const addDraft = useInlineCommentDraftStore((s) => s.addDraft);
const updateDraft = useInlineCommentDraftStore((s) => s.updateDraft);
const removeDraft = useInlineCommentDraftStore((s) => s.removeDraft);
const allDrafts = useInlineCommentDraftStore((s) => s.drafts);
const [editingDraftId, setEditingDraftId] = React.useState(null);
// Global mouseup to end drag selection
React.useEffect(() => {
const handleGlobalMouseUp = () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, []);
// Clear selection when file changes
React.useEffect(() => {
setLineSelection(null);
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 && !editingDraftId) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Check if click is inside comment UI
if (target.closest('[data-comment-input="true"]') || target.closest('[data-comment-card="true"]')) 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);
setEditingDraftId(null);
};
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 100);
return () => {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [lineSelection, editingDraftId]);
// 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');
}, []);
const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => {
if (!selectedFile) return;
const sessionKey = currentSessionId ?? 'draft';
const finalRange = range || lineSelection;
if (!finalRange) return;
const code = extractSelectedCode(fileContent, { start: finalRange.start, end: finalRange.end });
if (editingDraftId) {
updateDraft(sessionKey, editingDraftId, {
text: text.trim(),
code,
startLine: finalRange.start,
endLine: finalRange.end,
});
} else {
addDraft({
sessionKey,
source: 'file',
fileLabel: selectedFile.path,
startLine: finalRange.start,
endLine: finalRange.end,
code,
language: getLanguageFromExtension(selectedFile.path) || 'text',
text: text.trim(),
});
}
setLineSelection(null);
setEditingDraftId(null);
}, [selectedFile, currentSessionId, lineSelection, fileContent, extractSelectedCode, editingDraftId, updateDraft, addDraft]);
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((entry) => {
const name = entry.name;
const normalizedEntryPath = normalizePath(entry.path || '');
const path = normalizedEntryPath
? (isAbsolutePath(normalizedEntryPath)
? normalizedEntryPath
: normalizePath(`${dirPath}/${normalizedEntryPath}`))
: normalizePath(`${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 () => {
if (!root) {
return;
}
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
await loadDirectory(root);
}, [loadDirectory, root]);
const lastFilesViewDirRef = React.useRef('');
const lastFilesViewTreeKeyRef = React.useRef('');
React.useEffect(() => {
if (!root) {
return;
}
const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`;
const dirChanged = lastFilesViewDirRef.current !== root;
const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey;
if (!dirChanged && !treeKeyChanged) {
return;
}
if (dirChanged) {
lastFilesViewDirRef.current = root;
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(null);
setShowMobilePageContent(false);
}
if (treeKeyChanged) {
lastFilesViewTreeKeyRef.current = treeKey;
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
void loadDirectory(root);
}
}, [loadDirectory, root, showGitignored, showHidden]);
const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode';
// Load markdown view mode preference from localStorage on mount
React.useEffect(() => {
try {
const stored = localStorage.getItem(MD_VIEWER_MODE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed === 'preview' || parsed === 'edit') {
setMdViewMode(parsed);
}
}
} catch {
// Ignore localStorage errors
}
}, []);
// Save markdown view mode preference to localStorage
const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
setMdViewMode(mode);
try {
localStorage.setItem(MD_VIEWER_MODE_KEY, JSON.stringify(mode));
} catch {
// Ignore localStorage errors
}
}, []);
// Get the view mode for a markdown file (from state, default to 'edit')
const getMdViewMode = React.useCallback((): 'preview' | 'edit' => {
return mdViewMode;
}, [mdViewMode]);
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 (root) {
removeOpenPathsByPrefix(root, oldPath);
}
if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) {
if (root) {
setSelectedPath(root, null);
}
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(null);
if (isMobile) {
setShowMobilePageContent(false);
}
}
}
} 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 (root) {
removeOpenPathsByPrefix(root, dialogData.path);
}
if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) {
if (root) {
setSelectedPath(root, null);
}
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(null);
if (isMobile) {
setShowMobilePageContent(false);
}
}
}
} 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, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]);
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) {
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 => {
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') {
e.preventDefault();
if (!isSaving) {
void saveDraft();
}
} else if (e.key.toLowerCase() === 'f') {
e.preventDefault();
setIsSearchOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSaving, saveDraft]);
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(node.path);
const selectedIsImage = isImageFile(node.path);
const isSvg = node.path.toLowerCase().endsWith('.svg');
if (isMobile) {
setShowMobilePageContent(true);
}
// Desktop: binary images are loaded via readFileBinary (data URL).
if (runtime.isDesktop && selectedIsImage && !isSvg) {
setFileContent('');
setDraftContent('');
setFileLoading(true);
return;
}
// Web: binary images should not be read as utf8.
if (!runtime.isDesktop && selectedIsImage && !isSvg) {
setFileContent('');
setDraftContent('');
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) {
if (isDirectoryReadError(error)) {
if (root) {
setSelectedPath(root, null);
}
setFileError(null);
setFileContent('');
setDraftContent('');
setLoadedFilePath(null);
if (searchQuery.trim().length > 0) {
setSearchQuery('');
}
if (isMobile) {
setShowMobilePageContent(false);
}
if (root) {
const ancestors = getAncestorPaths(node.path, root);
const pathsToExpand = [...ancestors, node.path];
if (pathsToExpand.length > 0) {
expandPaths(root, pathsToExpand);
}
for (const path of pathsToExpand) {
if (!loadedDirsRef.current.has(path)) {
void loadDirectory(path);
}
}
}
return;
}
setFileContent('');
setDraftContent('');
setFileError(error instanceof Error ? error.message : 'Failed to read file');
} finally {
setFileLoading(false);
}
}, [expandPaths, isMobile, loadDirectory, readFile, root, runtime.isDesktop, searchQuery, setSelectedPath]);
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
if (!root) {
return;
}
const ancestors = getAncestorPaths(targetPath, root);
const pathsToExpand = includeTarget ? [...ancestors, targetPath] : ancestors;
if (pathsToExpand.length > 0) {
expandPaths(root, pathsToExpand);
}
for (const path of pathsToExpand) {
if (!loadedDirsRef.current.has(path)) {
await loadDirectory(path);
}
}
}, [expandPaths, loadDirectory, root]);
const getNextOpenFile = React.useCallback((path: string, filesList: FileNode[]) => {
const index = filesList.findIndex((file) => file.path === path);
if (index === -1 || filesList.length <= 1) {
return null;
}
return filesList[index + 1] ?? filesList[index - 1] ?? null;
}, []);
const handleSelectFile = React.useCallback(async (node: FileNode) => {
if (skipDirtyOnceRef.current) {
skipDirtyOnceRef.current = false;
} else if (isDirty) {
setConfirmDiscardOpen(true);
pendingSelectFileRef.current = node;
return;
}
if (root) {
setSelectedPath(root, node.path);
addOpenPath(root, node.path);
void ensurePathVisible(node.path, false);
}
setFileError(null);
setDesktopImageSrc('');
setFileContent('');
setDraftContent('');
setLoadedFilePath(null);
if (isMobile) {
setShowMobilePageContent(true);
}
}, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]);
React.useEffect(() => {
if (!selectedFile?.path) {
return;
}
void ensurePathVisible(selectedFile.path, false);
}, [ensurePathVisible, selectedFile?.path]);
React.useEffect(() => {
if (!selectedFile) {
return;
}
if (loadedFilePath === selectedFile.path) {
return;
}
// Selection changes are guarded; this effect is also what restores persisted tabs on mount.
void loadSelectedFile(selectedFile);
}, [loadSelectedFile, loadedFilePath, selectedFile]);
const discardAndContinue = React.useCallback(() => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
const closePath = pendingClosePathRef.current;
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
pendingClosePathRef.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 (closePath) {
if (root) {
removeOpenPath(root, closePath);
}
if (selectedFile?.path === closePath) {
if (nextFile) {
void handleSelectFile(nextFile);
} else {
if (root) {
setSelectedPath(root, null);
}
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(null);
if (isMobile) {
setShowMobilePageContent(false);
}
}
}
return;
}
if (nextFile) {
void handleSelectFile(nextFile);
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]);
const saveAndContinue = React.useCallback(async () => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
const closePath = pendingClosePathRef.current;
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
pendingClosePathRef.current = null;
// We'll proceed after saving; suppress guard reopening.
skipDirtyOnceRef.current = true;
setConfirmDiscardOpen(false);
await saveDraft();
if (closePath) {
if (root) {
removeOpenPath(root, closePath);
}
if (selectedFile?.path === closePath) {
if (nextFile) {
await handleSelectFile(nextFile);
} else {
if (root) {
setSelectedPath(root, null);
}
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(null);
if (isMobile) {
setShowMobilePageContent(false);
}
}
}
return;
}
if (nextFile) {
await handleSelectFile(nextFile);
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]);
const handleCloseFile = React.useCallback((path: string) => {
const isActive = selectedFile?.path === path;
const nextFile = getNextOpenFile(path, openFiles);
if (isActive && isDirty) {
setConfirmDiscardOpen(true);
pendingSelectFileRef.current = nextFile;
pendingClosePathRef.current = path;
return;
}
if (root) {
removeOpenPath(root, path);
}
if (!isActive) {
return;
}
if (nextFile) {
void handleSelectFile(nextFile);
return;
}
if (root) {
setSelectedPath(root, null);
}
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
setLoadedFilePath(null);
if (isMobile) {
setShowMobilePageContent(false);
}
}, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, removeOpenPath, root, selectedFile?.path, setSelectedPath]);
const getFileStatus = React.useCallback((path: string): FileStatus | null => {
// Check open status
if (openPaths.includes(path)) return 'open';
// Check git status
if (gitStatus?.files) {
const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
const file = gitStatus.files.find(f => f.path === relative);
if (file) {
if (file.index === 'A' || file.working_dir === '?') return 'git-added';
if (file.index === 'D') return 'git-deleted';
if (file.index === 'M' || file.working_dir === 'M') return 'git-modified';
}
}
return null;
}, [openPaths, gitStatus, root]);
const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => {
if (!gitStatus?.files) return null;
const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath;
const prefix = relativeDir ? `${relativeDir}/` : '';
let modified = 0, added = 0;
for (const f of gitStatus.files) {
if (f.path.startsWith(prefix)) {
if (f.index === 'M' || f.working_dir === 'M') modified++;
if (f.index === 'A' || f.working_dir === '?') added++;
}
}
return modified + added > 0 ? { modified, added } : null;
}, [gitStatus, root]);
const toggleDirectory = React.useCallback(async (dirPath: string) => {
const normalized = normalizePath(dirPath);
if (!root) return;
toggleExpandedPath(root, normalized);
if (!loadedDirsRef.current.has(normalized)) {
await loadDirectory(normalized);
}
}, [loadDirectory, root, toggleExpandedPath]);
const handleBreadcrumbNavigate = React.useCallback((dirPath: string) => {
if (!root) return;
if (searchQuery.trim().length > 0) {
setSearchQuery('');
}
if (isMobile) {
setShowMobilePageContent(false);
}
void ensurePathVisible(dirPath, true);
}, [ensurePathVisible, isMobile, root, searchQuery]);
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 && expandedPaths.includes(node.path);
const isActive = selectedFile?.path === node.path;
const isLoading = isDir && inFlightDirsRef.current.has(node.path);
const isLast = index === nodes.length - 1;
return (
{depth > 0 && (
<>
{isLast && (
)}
>
)}
{isDir && isExpanded && (
{renderTree(node.path, depth + 1)}
)}
);
});
}, [childrenByDir, expandedPaths, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, setContextMenuPath, isMobile, getFileStatus, getFolderBadge]);
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
const getDisplayPath = React.useCallback((path: string): string => {
if (!path) return '';
const normalizedFilePath = normalizePath(path);
if (root && isPathWithinRoot(normalizedFilePath, root)) {
const relative = normalizedFilePath.slice(root.length);
return relative.startsWith('/') ? relative.slice(1) : relative;
}
return normalizedFilePath;
}, [root]);
const displaySelectedPath = React.useMemo(() => {
if (!selectedFile?.path) return '';
return getDisplayPath(selectedFile.path);
}, [getDisplayPath, selectedFile?.path]);
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 = () => (
);
const blockWidgets = React.useMemo(() => {
if (!selectedFile) return [];
const sessionKey = currentSessionId ?? 'draft';
const sessionDrafts = allDrafts[sessionKey] ?? [];
// Filter drafts for current file
const fileDrafts = sessionDrafts.filter(
(d) => d.source === 'file' && d.fileLabel === selectedFile.path
);
const widgets: BlockWidgetDef[] = [];
// Add cards for existing drafts
fileDrafts.forEach((draft) => {
const isEditing = editingDraftId === draft.id;
if (isEditing) {
widgets.push({
afterLine: draft.endLine,
id: `edit-${draft.id}`,
content: (
handleSaveComment(text, { start: draft.startLine, end: draft.endLine })}
onCancel={() => setEditingDraftId(null)}
isEditing={true}
/>
),
});
} else {
widgets.push({
afterLine: draft.endLine,
id: `card-${draft.id}`,
content: (
{
setEditingDraftId(draft.id);
setLineSelection(null);
}}
onDelete={() => removeDraft(sessionKey, draft.id)}
/>
),
});
}
});
// Add input for new comment
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'files-new-comment-input',
content: (
handleSaveComment(text)}
onCancel={() => setLineSelection(null)}
/>
),
});
}
return widgets;
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft, isDragging]);
const fileViewer = (
{isMobile && showMobilePageContent && (
)}
{isMobile ? (
selectedFile ? (
{openFiles.map((file) => {
const isActive = selectedFile?.path === file.path;
return (
{
const target = event.target as HTMLElement;
if (target.closest('[data-close-open-file]')) {
event.preventDefault();
return;
}
if (!isActive) {
void handleSelectFile(file);
}
}}
className={cn(
'flex items-center justify-between gap-2',
isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]'
)}
>
{file.name}
);
})}
) : (
Select a file
)
) : (
openFiles.length > 0 ? (
{openFiles.map((file) => {
const isActive = selectedFile?.path === file.path;
return (
);
})}
{selectedFile && (
)}
) : (
Select a file
)
)}
{canEdit && (
)}
{canEdit && selectedFile && !isSelectedImage && (
)}
{selectedFile && !isSelectedImage && (
<>
>
)}
{(canCopy || canCopyPath || (selectedFile && isMarkdownFile(selectedFile.path))) && (canEdit || (selectedFile && !isSelectedImage)) && (
)}
{selectedFile && isMarkdownFile(selectedFile.path) && (
saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
)}
{canCopyPath && (
)}
{selectedFile && !isMobile && (
<>
>
)}
{!selectedFile ? (
Pick a file from the tree.
) : fileLoading ? (
Loading…
) : fileError ? (
{fileError}
) : isSelectedImage ? (
) : selectedFile && isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
{fileContent.length > 500 * 1024 && (
⚠️ This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
)}
Preview unavailable
Switch to edit mode to fix the issue.
}
>
) : (
{
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;
setIsDragging(false);
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setIsDragging(true);
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 });
setIsDragging(true);
return false;
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return false;
},
},
}}
/>
)}
);
const hasTree = Boolean(root && childrenByDir[root]);
const treePanel = (
{searching ? (
-
Searching…
) : searchResults.length > 0 ? (
searchResults.map((node) => {
const isActive = selectedFile?.path === node.path;
return (
-
);
})
) : hasTree ? (
renderTree(root, 0)
) : (
- Loading…
)}
);
// Fullscreen file viewer overlay
const fullscreenViewer = isFullscreen && selectedFile && (
{/* Fullscreen header */}
{selectedFile.name}
{displaySelectedPath}
{canEdit && (
)}
{canEdit && !isSelectedImage && (
)}
{!isSelectedImage && (
)}
{(canCopy || canCopyPath || isMarkdownFile(selectedFile.path)) && (canEdit || !isSelectedImage) && (
)}
{isMarkdownFile(selectedFile.path) && (
saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
)}
{canCopyPath && (
)}
{/* Fullscreen content */}
{fileLoading ? (
Loading…
) : fileError ? (
{fileError}
) : isSelectedImage ? (
) : isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
{fileContent.length > 500 * 1024 && (
This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
)}
Preview unavailable
Switch to edit mode to fix the issue.
}
>
) : (
)}
);
return (
{renderDialogs()}
{fullscreenViewer}
{isMobile ? (
showMobilePageContent ? (
fileViewer
) : (
treePanel
)
) : (
{screenWidth >= 700 && (
{treePanel}
)}
{fileViewer}
)}
);
};