From baad4b609794ff05317bc1200cef54cafb1e5c74 Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Sun, 8 Feb 2026 22:24:25 -0300 Subject: [PATCH] Enhance FilesView with breadcrumbs, drafts, and editor UX (#363) * feat: enable search panel in CodeMirrorEditor Add enableSearch and searchOpen props to toggle search UI Wire CodeMirror search extension and keybindings into the editor Automatically open or close the search panel based on searchOpen * feat(files): add breadcrumb navigation and inline comments Add breadcrumb trail derived from file path and root to navigate directories Introduce inline comment UI with draft storage for per-file discussions Enable long-press gestures on items to reveal quick actions * feat: add useLongPress hook for long-press gestures Provide a reusable long-press hook with onLongPress and onTap callbacks. Support pointer and touch events with movement threshold to cancel. Optionally vibrate on long press for haptic feedback. * feat: add expandedPaths state and expand actions for file tabs Introduce expandedPaths per root to remember expanded folders Add actions to toggle, expand a path, and expand multiple paths Initialize expandedPaths for new roots and clamp state to valid roots * feat: add onSearchOpenChange prop to CodeMirrorEditor Expose onSearchOpenChange callback prop for search state Notify parent when search panel visibility toggles --- .../ui/src/components/ui/CodeMirrorEditor.tsx | 23 +- .../ui/src/components/views/FilesView.tsx | 824 +++++++++++------- packages/ui/src/hooks/useLongPress.ts | 100 +++ .../ui/src/stores/useFilesViewTabsStore.ts | 92 +- 4 files changed, 710 insertions(+), 329 deletions(-) create mode 100644 packages/ui/src/hooks/useLongPress.ts diff --git a/packages/ui/src/components/ui/CodeMirrorEditor.tsx b/packages/ui/src/components/ui/CodeMirrorEditor.tsx index 539de2e5..59b826fe 100644 --- a/packages/ui/src/components/ui/CodeMirrorEditor.tsx +++ b/packages/ui/src/components/ui/CodeMirrorEditor.tsx @@ -5,6 +5,7 @@ import { Compartment, EditorState, RangeSetBuilder, StateField } from '@codemirr import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType, gutters, keymap, lineNumbers } from '@codemirror/view'; import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands'; import { indentUnit } from '@codemirror/language'; +import { search, searchKeymap, openSearchPanel, closeSearchPanel } from '@codemirror/search'; import { createPortal } from 'react-dom'; import { cn } from '@/lib/utils'; @@ -26,6 +27,9 @@ type CodeMirrorEditorProps = { blockWidgets?: BlockWidgetDef[]; onViewReady?: (view: EditorView) => void; onViewDestroy?: () => void; + enableSearch?: boolean; + searchOpen?: boolean; + onSearchOpenChange?: (open: boolean) => void; }; const lineNumbersCompartment = new Compartment(); @@ -33,6 +37,7 @@ const editableCompartment = new Compartment(); const externalExtensionsCompartment = new Compartment(); const highlightLinesCompartment = new Compartment(); const blockWidgetsCompartment = new Compartment(); +const searchCompartment = new Compartment(); // Map to store widget container elements by ID // This allows us to render portals into them even if they are created by CM @@ -153,6 +158,8 @@ export function CodeMirrorEditor({ onViewReady, onViewDestroy, blockWidgets, + enableSearch, + searchOpen, }: CodeMirrorEditorProps) { const hostRef = React.useRef(null); const viewRef = React.useRef(null); @@ -203,6 +210,7 @@ export function CodeMirrorEditor({ externalExtensionsCompartment.of(extensions ?? []), highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)), blockWidgetsCompartment.of(createBlockWidgetsExtension(blockWidgets)), + searchCompartment.of(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []), ], }); @@ -236,9 +244,22 @@ export function CodeMirrorEditor({ externalExtensionsCompartment.reconfigure(extensions ?? []), highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)), blockWidgetsCompartment.reconfigure(createBlockWidgetsExtension(blockWidgets)), + searchCompartment.reconfigure(enableSearch ? [search({ top: true }), keymap.of(searchKeymap)] : []), ], }); - }, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets]); + }, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch]); + + React.useEffect(() => { + const view = viewRef.current; + if (!view || enableSearch === false) { + return; + } + if (searchOpen) { + openSearchPanel(view); + } else { + closeSearchPanel(view); + } + }, [searchOpen, enableSearch]); React.useEffect(() => { const view = viewRef.current; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index a9655376..b44e8518 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { RiArrowLeftSLine, + RiArrowRightSLine, RiArrowDownSLine, RiClipboardLine, RiCloseLine, @@ -18,7 +19,6 @@ import { RiRefreshLine, RiSearchLine, RiSave3Line, - RiSendPlane2Line, RiTextWrap, RiMore2Fill, RiFileAddLine, @@ -39,8 +39,7 @@ import { 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 { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor'; import { PreviewToggleButton } from './PreviewToggleButton'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { languageByExtension } from '@/lib/codemirror/languageByExtension'; @@ -57,18 +56,18 @@ import { 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 { useConfigStore } from '@/stores/useConfigStore'; -import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; -import { useMessageQueueStore } from '@/stores/messageQueueStore'; -import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; 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'; @@ -98,8 +97,87 @@ const sortNodes = (items: FileNode[]) => const normalizePath = (value: string): string => value.replace(/\\/g, '/'); +const getAncestorPaths = (filePath: string, root: string): string[] => { + const normalizedRoot = normalizePath(root); + const normalizedFile = normalizePath(filePath); + + // Ensure file is within root + if (!normalizedFile.startsWith(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 => { @@ -249,6 +327,179 @@ const isMarkdownFile = (path: string): boolean => { 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(); @@ -260,6 +511,7 @@ export const FilesView: React.FC = () => { 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); @@ -268,22 +520,19 @@ export const FilesView: React.FC = () => { const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); const [wrapLines, setWrapLines] = React.useState(isMobile); const [isFullscreen, setIsFullscreen] = React.useState(false); - - const [expandedDirs, setExpandedDirs] = React.useState>(new Set()); - 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 [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 expandPath = useFilesViewTabsStore((state) => state.expandPath); + const expandPaths = useFilesViewTabsStore((state) => state.expandPaths); const toFileNode = React.useCallback((path: string): FileNode => { const normalized = normalizePath(path); @@ -302,6 +551,13 @@ export const FilesView: React.FC = () => { 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); @@ -345,25 +601,18 @@ export const FilesView: React.FC = () => { // Line selection state for commenting const [lineSelection, setLineSelection] = React.useState(null); - const [commentText, setCommentText] = React.useState(''); const isSelectingRef = React.useRef(false); const selectionStartRef = React.useRef(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 = useUIStore((state) => state.inputBarOffset); - const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen); - const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled); - const addToQueue = useMessageQueueStore((state) => state.addToQueue); - const { phase: sessionPhase } = useCurrentSessionActivity(); + 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(() => { @@ -378,7 +627,6 @@ export const FilesView: React.FC = () => { // Clear selection when file changes React.useEffect(() => { setLineSelection(null); - setCommentText(''); setMainTabGuard(null); setDraftContent(''); setIsSaving(false); @@ -397,15 +645,14 @@ export const FilesView: React.FC = () => { // Click outside to dismiss selection React.useEffect(() => { - if (!lineSelection) return; + if (!lineSelection && !editingDraftId) 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; - + 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; @@ -414,7 +661,7 @@ export const FilesView: React.FC = () => { // Clicking anywhere else (including code content) dismisses selection setLineSelection(null); - setCommentText(''); + setEditingDraftId(null); }; const timeoutId = setTimeout(() => { @@ -425,7 +672,7 @@ export const FilesView: React.FC = () => { clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; - }, [lineSelection]); + }, [lineSelection, editingDraftId]); // Extract selected code const extractSelectedCode = React.useCallback((content: string, range: SelectedLineRange): string => { @@ -436,61 +683,40 @@ export const FilesView: React.FC = () => { 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; - } + const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => { + if (!selectedFile) 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; + const sessionKey = currentSessionId ?? 'draft'; + const finalRange = range || lineSelection; + if (!finalRange) return; - if (!effectiveProviderId || !effectiveModelId) { - toast.error('Select a model to send comment'); - return; - } + const code = extractSelectedCode(fileContent, { start: finalRange.start, end: finalRange.end }); - 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'); - - // Check if should queue instead of send - const canQueue = sessionPhase !== 'idle'; - if (queueModeEnabled && canQueue) { - addToQueue(currentSessionId, { content: message }); - } else { - void sendMessage( - message, - effectiveProviderId, - effectiveModelId, - sessionAgent, - undefined, - undefined, - undefined, - effectiveVariant - ).catch((e) => { - console.error('Failed to send comment', e); + if (editingDraftId) { + updateDraft(sessionKey, editingDraftId, { + text: text.trim(), + code, + startLine: finalRange.start, + endLine: finalRange.end, }); + toast.success('Comment updated'); + } else { + addDraft({ + sessionKey, + source: 'file', + fileLabel: selectedFile.name, + startLine: finalRange.start, + endLine: finalRange.end, + code, + language: getLanguageFromExtension(selectedFile.path) || 'text', + text: text.trim(), + }); + toast.success('Comment saved'); } - }, [lineSelection, commentText, selectedFile, fileContent, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, extractSelectedCode, sendMessage, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession, queueModeEnabled, sessionPhase, addToQueue]); + + 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 @@ -568,7 +794,6 @@ export const FilesView: React.FC = () => { loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); - setExpandedDirs((prev) => (prev.size === 0 ? prev : new Set())); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); await loadDirectory(root); @@ -603,7 +828,6 @@ export const FilesView: React.FC = () => { lastFilesViewTreeKeyRef.current = treeKey; loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); - setExpandedDirs((prev) => (prev.size === 0 ? prev : new Set())); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); void loadDirectory(root); } @@ -935,13 +1159,14 @@ export const FilesView: React.FC = () => { return; } - if (e.key.toLowerCase() !== 's') { - return; - } - - e.preventDefault(); - if (!isSaving) { - void saveDraft(); + if (e.key.toLowerCase() === 's') { + e.preventDefault(); + if (!isSaving) { + void saveDraft(); + } + } else if (e.key.toLowerCase() === 'f') { + e.preventDefault(); + setIsSearchOpen(true); } }; @@ -1014,6 +1239,21 @@ export const FilesView: React.FC = () => { if (root) { setSelectedPath(root, node.path); addOpenPath(root, node.path); + + // Auto-expand parents + const ancestors = getAncestorPaths(node.path, root); + if (ancestors.length > 0) { + expandPaths(root, ancestors); + + // Ensure ancestor directories are loaded + for (const ancestor of ancestors) { + if (!loadedDirsRef.current.has(ancestor)) { + // Load sequentially to ensure order (though loadDirectory is async) + // We use void to fire and forget but they will update state when done + void loadDirectory(ancestor); + } + } + } } setFileError(null); @@ -1024,7 +1264,7 @@ export const FilesView: React.FC = () => { if (isMobile) { setShowMobilePageContent(true); } - }, [addOpenPath, isDirty, isMobile, root, setSelectedPath]); + }, [addOpenPath, isDirty, isMobile, root, setSelectedPath, expandPaths, loadDirectory]); React.useEffect(() => { if (!selectedFile) { @@ -1176,29 +1416,61 @@ export const FilesView: React.FC = () => { } }, [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); - setExpandedDirs((prev) => { - const next = new Set(prev); - if (next.has(normalized)) { - next.delete(normalized); - } else { - next.add(normalized); - } - return next; - }); + if (!root) return; + + toggleExpandedPath(root, normalized); if (!loadedDirsRef.current.has(normalized)) { await loadDirectory(normalized); } - }, [loadDirectory]); + }, [loadDirectory, root, toggleExpandedPath]); + + const handleBreadcrumbNavigate = React.useCallback((dirPath: string) => { + if (!root) return; + expandPath(root, dirPath); + void loadDirectory(dirPath); + }, [root, expandPath, 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 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; @@ -1213,103 +1485,21 @@ export const FilesView: React.FC = () => { )} )} -
{ - if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) { - return; - } - event.preventDefault(); - setContextMenuPath(node.path); - }} - > - - {(canRename || canCreateFile || canCreateFolder || canDelete) && ( -
- setContextMenuPath(open ? node.path : null)} - > - - - - setContextMenuPath(null)}> - {canRename && ( - { e.stopPropagation(); handleOpenDialog('rename', node); }}> - Rename - - )} - { - e.stopPropagation(); - void navigator.clipboard.writeText(node.path); - toast.success('Path copied'); - }}> - Copy Path - - {isDir && (canCreateFile || canCreateFolder) && ( - <> - - {canCreateFile && ( - { e.stopPropagation(); handleOpenDialog('createFile', node); }}> - New File - - )} - {canCreateFolder && ( - { e.stopPropagation(); handleOpenDialog('createFolder', node); }}> - New Folder - - )} - - )} - {canDelete && ( - <> - - { e.stopPropagation(); handleOpenDialog('delete', node); }} - className="text-destructive focus:text-destructive" - > - Delete - - - )} - - -
- )} -
+ {isDir && isExpanded && (
    {renderTree(node.path, depth + 1)} @@ -1318,7 +1508,7 @@ export const FilesView: React.FC = () => { ); }); - }, [childrenByDir, expandedDirs, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, setContextMenuPath]); + }, [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')); @@ -1467,90 +1657,75 @@ export const FilesView: React.FC = () => { )} - - - ); - - // Comment UI component - const renderCommentUI = () => { - if (!lineSelection || !selectedFile) return null; - return ( -
    -
    -