From 1dd55b345793cca94117fb22d0882d260a753330 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Wed, 1 Jul 2026 22:59:15 +0100 Subject: [PATCH 1/2] feat: improve file and folder navigation panels --- electron/main.ts | 87 +++ electron/preload.ts | 10 + package-lock.json | 27 + package.json | 1 + src/App.css | 20 +- src/App.tsx | 61 +- src/components/file-viewer/fileViewer.css | 37 +- .../file-viewer/modes/TasksViewer.tsx | 25 +- src/components/file-viewer/tasks/taskView.tsx | 91 ++- src/components/folder-viewer/FolderPanel.tsx | 655 ++++++++++++++++-- .../folder-viewer/FolderTasksViewer.tsx | 127 +++- src/components/folder-viewer/folderViewer.css | 241 ++++++- src/components/folder-viewer/types.ts | 9 + src/components/git-panel/GitPanel.tsx | 130 +++- src/components/git-panel/WorktreesPanel.tsx | 26 +- src/fileIcons.tsx | 190 +++++ src/pathUtils.ts | 75 ++ src/types/terminay.ts | 20 + 18 files changed, 1678 insertions(+), 154 deletions(-) create mode 100644 src/fileIcons.tsx create mode 100644 src/pathUtils.ts diff --git a/electron/main.ts b/electron/main.ts index 8de7994..e95f819 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,6 +26,8 @@ import type { EditWindowState, FileExplorerEntry, FileSearchResult, + FolderSizeProgress, + FolderSizeResult, ProjectEditWindowDraft, ProjectEditWindowResult, TerminalRecordingStartMetadata, @@ -616,10 +618,14 @@ async function readDirectoryEntries(dirPath: string): Promise() + +async function runFolderSizeJob( + sender: Electron.WebContents, + payload: { jobId: string; path: string }, +): Promise { + const job = { cancelled: false } + folderSizeJobs.set(payload.jobId, job) + + let size = 0 + let entryCount = 0 + let lastProgressAt = Date.now() + const pendingDirectories = [resolveExplorerPath(payload.path)] + + try { + while (pendingDirectories.length > 0) { + if (job.cancelled) { + return { cancelled: true, entryCount, jobId: payload.jobId, size } + } + + const directoryPath = pendingDirectories.pop() as string + let entries: Dirent[] + try { + entries = await readdir(directoryPath, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + if (job.cancelled) { + return { cancelled: true, entryCount, jobId: payload.jobId, size } + } + + entryCount += 1 + const entryPath = path.join(directoryPath, entry.name) + + // Never follow symlinks: avoids cycles and double counting. + if (entry.isSymbolicLink()) { + continue + } + if (entry.isDirectory()) { + pendingDirectories.push(entryPath) + continue + } + + try { + const stats = await lstat(entryPath) + size += stats.size + } catch { + // unreadable entry: skip it + } + } + + const now = Date.now() + if (now - lastProgressAt >= 100 && !sender.isDestroyed()) { + lastProgressAt = now + sender.send('folder-size:progress', { + entryCount, + jobId: payload.jobId, + size, + } satisfies FolderSizeProgress) + } + } + + return { cancelled: false, entryCount, jobId: payload.jobId, size } + } finally { + folderSizeJobs.delete(payload.jobId) + } +} + const FILE_SEARCH_IGNORED_DIRECTORIES = new Set([ '.git', '.hg', @@ -2444,6 +2520,17 @@ ipcMain.handle('fs:search-files', async (_event, payload: { rootPath: string; qu return searchFiles(payload.rootPath, payload.query, payload.limit) }) +ipcMain.handle('fs:calculate-folder-size', (event, payload: { jobId: string; path: string }) => { + return runFolderSizeJob(event.sender, payload) +}) + +ipcMain.handle('fs:cancel-folder-size', (_event, payload: { jobId: string }) => { + const job = folderSizeJobs.get(payload.jobId) + if (job) { + job.cancelled = true + } +}) + ipcMain.handle('fs:get-git-statuses', async (_event, payload: { dirPath: string }) => { return gitDiffService.getExplorerStatuses(payload.dirPath) }) diff --git a/electron/preload.ts b/electron/preload.ts index 1ee9dce..bd4498d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -40,6 +40,8 @@ import type { QuickPushGenerateRequest, QuickPushPlan, FileExplorerWatchEvent, + FolderSizeProgress, + FolderSizeResult, RemoteAccessStatus, SettingsChangeMessage, TerminalActivityMessage, @@ -63,6 +65,9 @@ type ElectronListener = (_event: Electron.IpcRendererEvent, payload: T) => vo contextBridge.exposeInMainWorld('terminay', { getHomePath: () => ipcRenderer.invoke('fs:get-home-path') as Promise, listDirectory: (dirPath: string) => ipcRenderer.invoke('fs:list-directory', { dirPath }) as Promise, + calculateFolderSize: (payload: { jobId: string; path: string }) => + ipcRenderer.invoke('fs:calculate-folder-size', payload) as Promise, + cancelFolderSize: (jobId: string) => ipcRenderer.invoke('fs:cancel-folder-size', { jobId }) as Promise, searchFiles: (options: { rootPath: string; query: string; limit?: number }) => ipcRenderer.invoke('fs:search-files', options) as Promise, getFileExplorerGitStatuses: (dirPath: string) => @@ -237,6 +242,11 @@ contextBridge.exposeInMainWorld('terminay', { ipcRenderer.on('file-explorer:watch-event', wrapper) return () => ipcRenderer.off('file-explorer:watch-event', wrapper) }, + onFolderSizeProgress: (listener: (message: FolderSizeProgress) => void) => { + const wrapper: ElectronListener = (_event, message) => listener(message) + ipcRenderer.on('folder-size:progress', wrapper) + return () => ipcRenderer.off('folder-size:progress', wrapper) + }, onFileWatchEvent: (listener: (message: FileViewerWatchEvent) => void) => { const wrapper: ElectronListener = (_event, message) => listener(message) ipcRenderer.on('file:watch-event', wrapper) diff --git a/package-lock.json b/package-lock.json index bbcd76f..379c0b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", + "@tabler/icons-react": "^3.44.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode11": "^0.9.0", @@ -1921,6 +1922,32 @@ "node": ">=10" } }, + "node_modules/@tabler/icons": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz", + "integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz", + "integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.44.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", diff --git a/package.json b/package.json index 097c67c..f86f9b8 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", + "@tabler/icons-react": "^3.44.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode11": "^0.9.0", diff --git a/src/App.css b/src/App.css index c4c9896..71655ba 100644 --- a/src/App.css +++ b/src/App.css @@ -6,6 +6,7 @@ color: #dce2f0; --tabbar-bg: #090b0e; + --tab-bg: #0d1014; --tab-bg-active: #0d1014; --terminal-tab-bg-active: #161c25; --tab-bg-hover: rgba(255, 255, 255, 0.05); @@ -1022,9 +1023,9 @@ } .project-tab-sidebar-toggle--active { - color: #bfe7ff; - background: rgba(77, 181, 255, 0.14); - box-shadow: inset 0 0 0 1px rgba(77, 181, 255, 0.2); + color: #ffffff; + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14); } .project-tab-sidebar-toggle:disabled { @@ -1034,6 +1035,19 @@ /* Dockview Overrides */ +/* Dockview defaults to its Abyss theme (blue #000c18 surfaces); repoint its + variables at the app palette so panel voids match the rest of the chrome. */ +.workspace .dv-shell { + --dv-group-view-background-color: var(--tab-bg); + --dv-separator-border: var(--tab-border); + --dv-tab-divider-color: var(--tab-border); + --dv-tabs-and-actions-container-background-color: var(--tabbar-bg); + --dv-activegroup-visiblepanel-tab-background-color: var(--tab-bg); + --dv-activegroup-hiddenpanel-tab-background-color: var(--tabbar-bg); + --dv-inactivegroup-visiblepanel-tab-background-color: var(--tab-bg); + --dv-inactivegroup-hiddenpanel-tab-background-color: var(--tabbar-bg); +} + .workspace .dv-tabs-and-actions-container { background: var(--tabbar-bg) !important; height: 34px !important; diff --git a/src/App.tsx b/src/App.tsx index 8594ae7..bed62d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { } from 'react'; import { ChevronDown, + Copy, Eraser, FileEdit, FolderOpen, @@ -63,6 +64,7 @@ import type { TerminalTabMoveProject, } from './components/TerminalTab'; import { TerminalTab } from './components/TerminalTab'; +import { FileTypeIcon } from './fileIcons'; import { useMacroSettings } from './hooks/useMacroSettings'; import { useTerminalSettings } from './hooks/useTerminalSettings'; import { defaultTerminalSettings } from './terminalSettings'; @@ -73,6 +75,7 @@ import { getCommandShortcutLabel, } from './keyboardShortcuts'; import { renderMacroTemplate, tryRenderMacroTemplate } from './macroSettings'; +import { getPathRelativeToRoot } from './pathUtils'; import { computeDropIndex } from './projectTabDrag'; import { isRemoteAccessPairingPinConfigured, @@ -915,6 +918,8 @@ type FileExplorerTreeProps = { onNewFile: (dirPath: string) => void; onNewFolder: (dirPath: string) => void; onOpenTerminal: (path: string) => void; + onCopyPath: (path: string) => void; + onCopyRelativePath: (path: string) => void; rootPath: string; }; @@ -932,6 +937,8 @@ function FileExplorerTree({ onNewFile, onNewFolder, onOpenTerminal, + onCopyPath, + onCopyRelativePath, rootPath, }: FileExplorerTreeProps) { const activeDragRef = useRef(false); @@ -1247,20 +1254,7 @@ function FileExplorerTree({ /> ) : ( - + )} {entry.name} @@ -1380,7 +1374,18 @@ function FileExplorerTree({ { separator: true }, ]), { - label: 'Open terminal here', + label: 'Copy path', + icon: , + onClick: () => onCopyPath(contextMenu.path), + }, + { + label: 'Copy relative path', + icon: , + onClick: () => onCopyRelativePath(contextMenu.path), + }, + { separator: true }, + { + label: 'Open shell in folder', icon: , onClick: () => onOpenTerminal(contextMenu.path), }, @@ -2729,6 +2734,19 @@ const ProjectWorkspace = forwardRef< [loadDirectory, refreshGitStatuses, requestFileExplorerName], ); + const handleCopyPath = useCallback((path: string) => { + void window.terminay.writeClipboardText(path); + }, []); + + const handleCopyRelativePath = useCallback( + (path: string) => { + void window.terminay.writeClipboardText( + getPathRelativeToRoot(path, project.rootFolder), + ); + }, + [project.rootFolder], + ); + const handleOpenTerminalAt = useCallback( async (path: string) => { const api = dockviewApiRef.current; @@ -2976,6 +2994,9 @@ const ProjectWorkspace = forwardRef< onNewFile?: (dirPath: string) => void; onNewFolder?: (dirPath: string) => void; onOpenTerminal?: (path: string) => void; + onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + projectRootPath?: string; }>({ component: 'folder', id: panelId, @@ -2989,7 +3010,10 @@ const ProjectWorkspace = forwardRef< onNewFile: handleNewFile, onNewFolder: handleNewFolder, onOpenTerminal: handleOpenTerminalAt, + onCopyPath: handleCopyPath, + onCopyRelativePath: handleCopyRelativePath, projectColor: project.color, + projectRootPath: project.rootFolder, }, position: api.activePanel ? { @@ -3007,11 +3031,14 @@ const ProjectWorkspace = forwardRef< }, [ handleDelete, + handleCopyPath, + handleCopyRelativePath, handleNewFile, handleNewFolder, handleOpenTerminalAt, handleRename, project.color, + project.rootFolder, syncPanelFocusState, ], ); @@ -6220,6 +6247,8 @@ const ProjectWorkspace = forwardRef< onNewFile={handleNewFile} onNewFolder={handleNewFolder} onOpenTerminal={handleOpenTerminalAt} + onCopyPath={handleCopyPath} + onCopyRelativePath={handleCopyRelativePath} rootPath={project.rootFolder} /> @@ -6294,6 +6323,7 @@ const ProjectWorkspace = forwardRef< status={gitPanelStatus} viewMode={settings.sidebar.gitPanelViewMode} onOpenEntry={handleOpenGitEntry} + onOpenTerminal={handleOpenTerminalAt} /> } @@ -6320,6 +6350,7 @@ const ProjectWorkspace = forwardRef< onDeleteWorktree={handleDeleteWorktree} onOpenEntry={handleOpenGitEntry} onOpenTerminal={handleOpenTerminalAtWorktree} + onOpenTerminalAtPath={handleOpenTerminalAt} onRenameWorktree={handleRenameWorktree} onRevealWorktree={handleRevealWorktree} onSwitchProjectRoot={handleSwitchProjectRootToWorktree} diff --git a/src/components/file-viewer/fileViewer.css b/src/components/file-viewer/fileViewer.css index 9905f69..cc2c715 100644 --- a/src/components/file-viewer/fileViewer.css +++ b/src/components/file-viewer/fileViewer.css @@ -3,7 +3,7 @@ flex-direction: column; height: 100%; min-height: 0; - background: #0c1016; + background: var(--tab-bg, #0d1014); color: #dce2f0; } @@ -19,7 +19,7 @@ gap: 12px; padding: 12px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(7, 10, 15, 0.55); + background: rgba(9, 11, 14, 0.55); } .file-panel__body { @@ -35,7 +35,7 @@ gap: 12px; padding: 12px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(7, 10, 15, 0.55); + background: rgba(9, 11, 14, 0.55); backdrop-filter: blur(14px); } @@ -147,7 +147,7 @@ padding: 18px; border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(15, 21, 29, 0.78); + background: rgba(17, 19, 22, 0.78); box-shadow: 0 22px 48px rgba(0, 0, 0, 0.24); } @@ -943,6 +943,31 @@ margin-left: auto; } +.file-tasks__sort { + flex: none; + min-width: 112px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: rgba(238, 244, 255, 0.9); + padding: 5px 28px 5px 10px; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + outline: none; +} + +.file-tasks__sort:hover, +.file-tasks__sort:focus { + border-color: rgba(87, 183, 255, 0.35); + background: rgba(87, 183, 255, 0.08); +} + +.file-tasks__sort option { + background: #15171a; + color: #eef4ff; +} + .file-tasks__viewtoggle { display: inline-flex; align-items: center; @@ -1420,7 +1445,7 @@ button.file-kanban__card:hover { margin: 12px 18px; padding: 16px; border-radius: 16px; - background: rgba(10, 14, 20, 0.72); + background: rgba(13, 16, 20, 0.72); border: 1px solid rgba(255, 255, 255, 0.06); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); } @@ -1690,7 +1715,7 @@ button.file-kanban__card:hover { min-height: 34px; padding: 0 14px; border-top: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(7, 10, 15, 0.55); + background: rgba(9, 11, 14, 0.55); font-size: 13px; } diff --git a/src/components/file-viewer/modes/TasksViewer.tsx b/src/components/file-viewer/modes/TasksViewer.tsx index 5253368..c057af1 100644 --- a/src/components/file-viewer/modes/TasksViewer.tsx +++ b/src/components/file-viewer/modes/TasksViewer.tsx @@ -6,11 +6,13 @@ import { NO_COLLAPSE, SectionNode, StatTile, + TASK_SORT_OPTIONS, type TaskFilter, TaskCallout, TaskHero, TaskRow, TaskToolbar, + type TaskSort, type TaskView, buildPredicate, cardMatchesQuery, @@ -18,6 +20,8 @@ import { collectSectionIds, isComplete, sectionHasVisibleTasks, + sortTaskCards, + sortTaskSections, } from '../tasks/taskView' type TasksViewerProps = { @@ -33,6 +37,7 @@ export function TasksViewer({ diff, text }: TasksViewerProps) { const [filter, setFilter] = useState('all') const [query, setQuery] = useState('') const [view, setView] = useState('list') + const [sort, setSort] = useState('progress') const toggle = (id: string) => { setCollapsed((previous) => { @@ -64,10 +69,13 @@ export function TasksViewer({ diff, text }: TasksViewerProps) { const activeCollapsed = isSearching ? NO_COLLAPSE : collapsed const predicate = buildPredicate(filter, query) const rootTasks = tree.root.tasks.filter(predicate) - const rootChildren = tree.root.children.filter((child) => sectionHasVisibleTasks(child, predicate)) + const rootChildren = sortTaskSections( + tree.root.children.filter((child) => sectionHasVisibleTasks(child, predicate)), + sort, + ) const hasVisible = rootTasks.length > 0 || rootChildren.length > 0 const complete = isComplete(stats) - const visibleCards = cards.filter((card) => cardMatchesQuery(card, query)) + const visibleCards = sortTaskCards(cards, sort).filter((card) => cardMatchesQuery(card, query)) const isKanban = view === 'kanban' return ( @@ -103,6 +111,18 @@ export function TasksViewer({ diff, text }: TasksViewerProps) { onQueryChange={setQuery} showFilter={!isKanban} > + {!isKanban && allSectionIds.length > 0 ? ( + ); +} + function dispatchOpenFile(path: string, initialMode?: FileViewerMode) { window.dispatchEvent( new CustomEvent<{ initialMode?: FileViewerMode; path: string }>('terminay-open-file', { @@ -565,6 +803,9 @@ export function FolderPanel( onNewFile?: (dirPath: string) => void; onNewFolder?: (dirPath: string) => void; onOpenTerminal?: (path: string) => void; + onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + projectRootPath?: string; } >, ) { @@ -577,6 +818,9 @@ export function FolderPanel( onNewFile, onNewFolder, onOpenTerminal, + onCopyPath, + onCopyRelativePath, + projectRootPath, } = props.params; const { settings } = useTerminalSettings(); const [treeRoot, setTreeRoot] = useState(null); @@ -596,6 +840,8 @@ export function FolderPanel( }); const previousFocusRef = useRef(null); const treeLoadRequestRef = useRef(0); + const [pathDraft, setPathDraft] = useState(null); + const pathInputRef = useRef(null); const [contextMenu, setContextMenu] = useState<{ x: number; @@ -604,6 +850,143 @@ export function FolderPanel( isDirectory: boolean; } | null>(null); + const [folderSizes, setFolderSizes] = useState>({}); + const [listSort, setListSort] = useState({ direction: 'asc', key: 'name' }); + + const handleToggleListSort = useCallback((key: ListSortKey) => { + setListSort((current) => + current.key === key + ? { direction: current.direction === 'asc' ? 'desc' : 'asc', key } + : { direction: 'asc', key }, + ); + }, []); + + const sortedListNodes = useMemo(() => { + if (!treeRoot) { + return []; + } + return sortListNodes(treeRoot.children, listSort, folderSizes); + }, [folderSizes, listSort, treeRoot]); + const [sizeModal, setSizeModal] = useState<{ + entryCount: number; + jobId: string; + name: string; + path: string; + size: number; + } | null>(null); + const autoSizeTreeRef = useRef(null); + + useEffect(() => { + if (viewMode !== 'list' || !treeRoot || autoSizeTreeRef.current === treeRoot) { + return; + } + autoSizeTreeRef.current = treeRoot; + + const directories = treeRoot.children.filter( + (child): child is FolderDirectoryNode => child.kind === 'directory', + ); + if (directories.length === 0) { + return; + } + + const activeJobIds = new Map(); + setFolderSizes( + Object.fromEntries(directories.map((directory) => [directory.path, { status: 'pending' as const }])), + ); + + for (const directory of directories) { + const jobId = crypto.randomUUID(); + activeJobIds.set(directory.path, jobId); + void window.terminay + .calculateFolderSize({ jobId, path: directory.path }) + .then((result) => { + activeJobIds.delete(directory.path); + setFolderSizes((current) => ({ + ...current, + [directory.path]: result.cancelled + ? { status: 'timeout' } + : { size: result.size, status: 'done' }, + })); + }) + .catch(() => { + activeJobIds.delete(directory.path); + setFolderSizes((current) => ({ + ...current, + [directory.path]: { status: 'timeout' }, + })); + }); + } + + const deadlineId = window.setTimeout(() => { + for (const jobId of activeJobIds.values()) { + void window.terminay.cancelFolderSize(jobId); + } + }, FOLDER_SIZE_DEADLINE_MS); + + return () => { + window.clearTimeout(deadlineId); + for (const jobId of activeJobIds.values()) { + void window.terminay.cancelFolderSize(jobId); + } + }; + }, [treeRoot, viewMode]); + + const handleCancelSizeModal = useCallback(() => { + setSizeModal((current) => { + if (current) { + void window.terminay.cancelFolderSize(current.jobId); + } + return null; + }); + }, []); + + const sizeModalJobId = sizeModal?.jobId; + useEffect(() => { + if (!sizeModalJobId) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + handleCancelSizeModal(); + } + }; + window.addEventListener('keydown', onKeyDown); + const unsubscribe = window.terminay.onFolderSizeProgress((message) => { + if (message.jobId !== sizeModalJobId) { + return; + } + setSizeModal((current) => + current?.jobId === message.jobId + ? { ...current, entryCount: message.entryCount, size: message.size } + : current, + ); + }); + return () => { + window.removeEventListener('keydown', onKeyDown); + unsubscribe(); + }; + }, [handleCancelSizeModal, sizeModalJobId]); + + const handleRecalculateFolderSize = useCallback((node: FolderDirectoryNode) => { + const jobId = crypto.randomUUID(); + setSizeModal({ entryCount: 0, jobId, name: node.name, path: node.path, size: 0 }); + void window.terminay + .calculateFolderSize({ jobId, path: node.path }) + .then((result) => { + if (!result.cancelled) { + setFolderSizes((current) => ({ + ...current, + [node.path]: { size: result.size, status: 'done' }, + })); + } + setSizeModal((current) => (current?.jobId === jobId ? null : current)); + }) + .catch(() => { + setSizeModal((current) => (current?.jobId === jobId ? null : current)); + }); + }, []); + const handleContextMenu = ( event: MouseEvent, path: string, @@ -857,6 +1240,27 @@ export function FolderPanel( [props.api, props.params], ); + const parentPath = useMemo(() => getParentPath(folderPath), [folderPath]); + + const isEditingPath = pathDraft !== null; + useEffect(() => { + if (isEditingPath) { + pathInputRef.current?.focus(); + pathInputRef.current?.select(); + } + }, [isEditingPath]); + + const commitPathDraft = () => { + if (pathDraft === null) { + return; + } + const trimmed = pathDraft.trim(); + setPathDraft(null); + if (trimmed && normalizePath(trimmed) !== normalizePath(folderPath)) { + handleOpenFolder(trimmed); + } + }; + const renderBody = () => { if (viewMode === 'tasks') { return ( @@ -866,6 +1270,8 @@ export function FolderPanel( ignoredDirectoryCount={taskScanStats.ignoredDirectoryCount} isLoading={isTaskScanLoading} onOpenFile={dispatchOpenFile} + onOpenTerminal={onOpenTerminal} + projectRootPath={projectRootPath ?? folderPath} onRefresh={() => setTaskScanRefreshNonce((current) => current + 1)} scannedDirectoryCount={taskScanStats.scannedDirectoryCount} scannedMarkdownCount={taskScanStats.scannedMarkdownCount} @@ -898,7 +1304,8 @@ export function FolderPanel( ); } - if (!treeRoot || treeRoot.children.length === 0) { + const canListWhileEmpty = viewMode === 'list' && parentPath !== null; + if (!treeRoot || (treeRoot.children.length === 0 && !canListWhileEmpty)) { return (
No files found
@@ -933,37 +1340,117 @@ export function FolderPanel( const nodes = treeRoot.children; if (viewMode === 'list') { + const sortButton = (label: string, key: ListSortKey, alignRight = false) => ( + + ); + const suppressDoubleClickSelection = (event: MouseEvent) => { + if (event.detail > 1) { + event.preventDefault(); + } + }; + return (
handleContextMenu(e, folderPath, true)} > - {nodes.map((node) => ( - - ))} + ); + })}
); } @@ -995,9 +1482,39 @@ export function FolderPanel(
{folderTitle}
-
{folderPath}
-
·
-
{summaryText}
+ {isEditingPath ? ( + setPathDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitPathDraft(); + } else if (event.key === 'Escape') { + event.preventDefault(); + setPathDraft(null); + } + }} + onBlur={() => setPathDraft(null)} + spellCheck={false} + aria-label="Folder path" + /> + ) : ( + <> + +
·
+
{summaryText}
+ + )}
@@ -1029,6 +1546,32 @@ export function FolderPanel(
{renderBody()}
+ {sizeModal && ( +
+
+
+ Calculating folder size +
+
{sizeModal.path}
+
+
+
+ +
+
+
+ )} + {contextMenu && ( , + onClick: () => + onCopyPath + ? onCopyPath(contextMenu.path) + : void window.terminay.writeClipboardText(contextMenu.path), + }, + { + label: 'Copy relative path', + icon: , + onClick: () => + onCopyRelativePath + ? onCopyRelativePath(contextMenu.path) + : void window.terminay.writeClipboardText( + getPathRelativeToRoot( + contextMenu.path, + projectRootPath ?? folderPath, + ), + ), + }, + { separator: true }, + { + label: 'Open shell in folder', icon: , onClick: () => onOpenTerminal?.(contextMenu.path), }, diff --git a/src/components/folder-viewer/FolderTasksViewer.tsx b/src/components/folder-viewer/FolderTasksViewer.tsx index 3303650..05ef6f7 100644 --- a/src/components/folder-viewer/FolderTasksViewer.tsx +++ b/src/components/folder-viewer/FolderTasksViewer.tsx @@ -1,5 +1,8 @@ -import { useMemo, useState } from 'react'; +import { Copy, FolderOpen, Terminal } from 'lucide-react'; +import { type MouseEvent, useMemo, useState } from 'react'; import type { FileViewerMode } from '../../types/fileViewer'; +import { getParentPath, getPathRelativeToRoot } from '../../pathUtils'; +import { ContextMenu, type ContextMenuItem } from '../ContextMenu'; import type { TaskSection, TaskStats } from '../file-viewer/tasks/parseTasks'; import { KanbanBoard, @@ -7,6 +10,7 @@ import { SectionNode, StatTile, StatsBadge, + TASK_SORT_OPTIONS, type TaskCard, type TaskFilter, TaskCallout, @@ -14,6 +18,7 @@ import { TaskRow, TaskToolbar, type TaskPredicate, + type TaskSort, type TaskView, buildPredicate, cardMatchesQuery, @@ -22,6 +27,8 @@ import { isComplete, percent, sectionHasVisibleTasks, + sortTaskCards, + sortTaskSections, } from '../file-viewer/tasks/taskView'; import '../file-viewer/fileViewer.css'; @@ -42,18 +49,13 @@ type FolderTasksViewerProps = { ignoredDirectoryCount: number; isLoading: boolean; onOpenFile: (path: string, initialMode?: FileViewerMode) => void; + onOpenTerminal?: (path: string) => void; onRefresh: () => void; + projectRootPath: string; scannedDirectoryCount: number; scannedMarkdownCount: number; }; -type FolderTaskSort = 'progress' | 'name'; - -const FOLDER_TASK_SORT_OPTIONS: { id: FolderTaskSort; label: string }[] = [ - { id: 'progress', label: 'Progress' }, - { id: 'name', label: 'Name' }, -]; - function combineStats(documents: FolderTaskDocument[]): TaskStats { return documents.reduce( (total, document) => ({ @@ -96,7 +98,7 @@ function compareDocumentsByProgress( function sortDocumentsByMode( documents: FolderTaskDocument[], - sort: FolderTaskSort, + sort: TaskSort, ): FolderTaskDocument[] { return [...documents].sort( sort === 'progress' ? compareDocumentsByProgress : compareDocumentsByName, @@ -146,19 +148,26 @@ function FileTaskGroup({ document, predicate, onOpenFile, + onOpenPathContextMenu, + sort, onToggle, }: { collapsed: ReadonlySet; document: FolderTaskDocument; predicate: TaskPredicate; onOpenFile: (path: string, initialMode?: FileViewerMode) => void; + onOpenPathContextMenu: (event: MouseEvent, document: FolderTaskDocument) => void; + sort: TaskSort; onToggle: (id: string) => void; }) { const fileId = `file:${document.path}`; const isCollapsed = collapsed.has(fileId); const rootTasks = document.tree.root.tasks.filter(predicate); - const rootChildren = document.tree.root.children.filter((child) => - sectionHasVisibleTasks(child, predicate), + const rootChildren = sortTaskSections( + document.tree.root.children.filter((child) => + sectionHasVisibleTasks(child, predicate), + ), + sort, ); const hasVisible = rootTasks.length > 0 || rootChildren.length > 0; @@ -179,6 +188,7 @@ function FileTaskGroup({ type="button" className="folder-tasks__file-header" onClick={() => onToggle(fileId)} + onContextMenu={(event) => onOpenPathContextMenu(event, document)} onDoubleClick={() => onOpenFile(document.path, 'tasks')} aria-expanded={!isCollapsed} title={document.path} @@ -231,6 +241,7 @@ function FileTaskGroup({ collapsed={collapsed} predicate={predicate} onToggle={onToggle} + sort={sort} keyPrefix={document.path} documentPath={document.path} onOpenFile={onOpenFile} @@ -248,7 +259,9 @@ export function FolderTasksViewer({ ignoredDirectoryCount, isLoading, onOpenFile, + onOpenTerminal, onRefresh, + projectRootPath, scannedDirectoryCount, scannedMarkdownCount, }: FolderTasksViewerProps) { @@ -284,12 +297,21 @@ export function FolderTasksViewer({ const [filter, setFilter] = useState('all'); const [query, setQuery] = useState(''); const [view, setView] = useState('list'); - const [sort, setSort] = useState('progress'); + const [sort, setSort] = useState('progress'); const [groupByFile, setGroupByFile] = useState(false); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + document: FolderTaskDocument; + } | null>(null); const sortedDocuments = useMemo( () => sortDocumentsByMode(documents, sort), [documents, sort], ); + const sortedCards = useMemo( + () => sortTaskCards(allCards, sort), + [allCards, sort], + ); const toggle = (id: string) => { setCollapsed((previous) => { @@ -303,13 +325,26 @@ export function FolderTasksViewer({ }); }; + const openPathContextMenu = ( + event: MouseEvent, + document: FolderTaskDocument, + ) => { + event.preventDefault(); + event.stopPropagation(); + setContextMenu({ + x: event.clientX, + y: event.clientY, + document, + }); + }; + const allCollapsed = allSectionIds.length > 0 && allSectionIds.every((id) => collapsed.has(id)); const isSearching = query.trim().length > 0; const isKanban = view === 'kanban'; const activeCollapsed = isSearching ? NO_COLLAPSE : collapsed; const predicate = buildPredicate(filter, query); - const visibleCards = allCards.filter((card) => cardMatchesQuery(card, query)); + const visibleCards = sortedCards.filter((card) => cardMatchesQuery(card, query)); const visibleLanes = sortedDocuments .map((document) => { const entry = cardsByDocument.find( @@ -318,7 +353,9 @@ export function FolderTasksViewer({ return entry ? { document, - cards: entry.cards.filter((card) => cardMatchesQuery(card, query)), + cards: sortTaskCards(entry.cards, sort).filter((card) => + cardMatchesQuery(card, query), + ), } : null; }) @@ -415,12 +452,12 @@ export function FolderTasksViewer({ showFilter={!isKanban} >