From baf5fb1a9165ebedb9a3f172645817936cb08457 Mon Sep 17 00:00:00 2001 From: Kris Powers <85710701+KrisPowers@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:26:38 -0400 Subject: [PATCH 1/2] feat(editor): implement Expand/Shrink Selection via tree-sitter AST Adds editor.smartSelect IPC op to the C++ backend that walks the tree-sitter parse tree upward from the current selection to find the smallest named node that strictly contains it. Frontend wires Shift+Alt+Right/Left in GpuEditor and the Edit menu commands (editor.action.smartSelect.expand/shrink) in FullscreenIDE. A history stack on the editor enables shrink to reverse each expand step. Works for all tree-sitter-backed languages; falls back to no-op for unsupported files. Closes #71 --- app/frontend/src/components/GpuEditor.tsx | 58 +++++++++++++++-- app/frontend/src/fullscreen/FullscreenIDE.tsx | 2 + cpp/src/editor_buffer.cpp | 63 +++++++++++++++++++ cpp/src/editor_buffer.hpp | 3 + 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/app/frontend/src/components/GpuEditor.tsx b/app/frontend/src/components/GpuEditor.tsx index 7f5b0e7..5ab37d4 100644 --- a/app/frontend/src/components/GpuEditor.tsx +++ b/app/frontend/src/components/GpuEditor.tsx @@ -58,6 +58,8 @@ export interface GpuEditorHandle { selectAll: () => void openFind: (mode: 'find' | 'replace') => void goToLine: (line: number) => void + expandSmartSelect: () => void + shrinkSmartSelect: () => void } interface Props { @@ -481,6 +483,7 @@ const GpuEditor = forwardRef(function GpuEditor({ const leftColRef = useRef(0) const wheelAccumRef = useRef(0) const cursorsRef = useRef([{ line: 0, col: 0 }]) + const smartSelectHistoryRef = useRef([]) const bracketMatchRef = useRef<[[number, number], [number, number]] | null>(null) const visibleRowsRef = useRef(1) const visibleColsRef = useRef(1) @@ -1505,6 +1508,7 @@ const GpuEditor = forwardRef(function GpuEditor({ const moveCursor = useCallback(async (dl: number, dc: number, extend: boolean) => { closeCompletions() + smartSelectHistoryRef.current = [] const cursors = cursorsRef.current const next: Cursor[] = [] for (const c of cursors) { @@ -1661,6 +1665,46 @@ const GpuEditor = forwardRef(function GpuEditor({ void updateBracketMatch() }, [closeCompletions, draw, ensureCursorVisible, ensureLine, fetchVisible, notifyCursor, updateBracketMatch]) + // Shift+Alt+Right/Left — expand or shrink the selection by AST node. + const expandSmartSelect = useCallback(async () => { + if (!bufferIdRef.current) return + const c = cursorsRef.current[0] + // Normalize current selection: [anchorLine/Col, line/col] ordered by position. + let sl = c.anchorLine ?? c.line + let sc = c.anchorCol ?? c.col + let el = c.line + let ec = c.col + if (el < sl || (el === sl && ec < sc)) { + [sl, sc, el, ec] = [el, ec, sl, sc] + } + const resp = await invoke<{ + noop?: boolean; startLine: number; startCol: number; endLine: number; endCol: number + }>('editor.smartSelect', { bufferId: bufferIdRef.current, startLine: sl, startCol: sc, endLine: el, endCol: ec }) + if (resp.noop) return + // Save current cursors so shrink can restore them. + smartSelectHistoryRef.current.push(cursorsRef.current.map(x => ({ ...x }))) + // Anchor at start, active end at end of expanded node. + cursorsRef.current = [{ line: resp.endLine, col: resp.endCol, anchorLine: resp.startLine, anchorCol: resp.startCol }] + cursorVisibleRef.current = true + notifyCursor() + ensureCursorVisible() + fetchVisible() + draw() + void updateBracketMatch() + }, [draw, ensureCursorVisible, fetchVisible, notifyCursor, updateBracketMatch]) + + const shrinkSmartSelect = useCallback(() => { + const prev = smartSelectHistoryRef.current.pop() + if (!prev) return + cursorsRef.current = prev + cursorVisibleRef.current = true + notifyCursor() + ensureCursorVisible() + fetchVisible() + draw() + void updateBracketMatch() + }, [draw, ensureCursorVisible, fetchVisible, notifyCursor, updateBracketMatch]) + // Triple-click — select the entire line. const selectLineAt = useCallback(async (line: number) => { closeCompletions() @@ -1924,8 +1968,12 @@ const GpuEditor = forwardRef(function GpuEditor({ case 'ArrowDown': if (e.ctrlKey && e.altKey) { e.preventDefault(); void addCursorVertical(1); return } e.preventDefault(); void moveCursor(1, 0, shift); return - case 'ArrowLeft': e.preventDefault(); void moveCursor(0, -1, shift); return - case 'ArrowRight': e.preventDefault(); void moveCursor(0, 1, shift); return + case 'ArrowLeft': + if (e.shiftKey && e.altKey) { e.preventDefault(); shrinkSmartSelect(); return } + e.preventDefault(); void moveCursor(0, -1, shift); return + case 'ArrowRight': + if (e.shiftKey && e.altKey) { e.preventDefault(); void expandSmartSelect(); return } + e.preventDefault(); void moveCursor(0, 1, shift); return case 'PageUp': e.preventDefault(); void moveCursor(-visibleRowsRef.current, 0, shift); return case 'PageDown': e.preventDefault(); void moveCursor(visibleRowsRef.current, 0, shift); return case 'Home': e.preventDefault(); void moveCursorsTo(c => ({ line: c.line, col: 0 }), shift); return @@ -1989,7 +2037,7 @@ const GpuEditor = forwardRef(function GpuEditor({ } return } - }, [acceptCompletion, addCursorVertical, advanceSnippetStop, closeCompletions, closeFind, copySelection, deleteBackward, deleteForward, draw, handleEnter, handleTypedChar, insertText, moveCursor, moveCursorsTo, openFind, redo, requestCompletions, save, selectAll, undo]) + }, [acceptCompletion, addCursorVertical, advanceSnippetStop, closeCompletions, closeFind, copySelection, deleteBackward, deleteForward, draw, expandSmartSelect, handleEnter, handleTypedChar, insertText, moveCursor, moveCursorsTo, openFind, redo, requestCompletions, save, selectAll, shrinkSmartSelect, undo]) const onInput = useCallback((e: React.FormEvent) => { const ta = e.currentTarget @@ -2219,7 +2267,9 @@ const GpuEditor = forwardRef(function GpuEditor({ textareaRef.current?.focus() }) }, - }), [save, undo, redo, selectAll, openFind, setCursorTo]) + expandSmartSelect: () => { void expandSmartSelect() }, + shrinkSmartSelect, + }), [save, undo, redo, selectAll, openFind, setCursorTo, expandSmartSelect, shrinkSmartSelect]) return (
diff --git a/app/frontend/src/fullscreen/FullscreenIDE.tsx b/app/frontend/src/fullscreen/FullscreenIDE.tsx index 5ceb5d2..b2e8fa8 100644 --- a/app/frontend/src/fullscreen/FullscreenIDE.tsx +++ b/app/frontend/src/fullscreen/FullscreenIDE.tsx @@ -805,6 +805,8 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW else if (cmd === 'actions.find') handle.openFind('find') else if (cmd === 'editor.action.startFindReplaceAction') handle.openFind('replace') else if (cmd === 'editor.action.toggleMinimap') setMinimapEnabled(v => !v) + else if (cmd === 'editor.action.smartSelect.expand') handle.expandSmartSelect() + else if (cmd === 'editor.action.smartSelect.shrink') handle.shrinkSmartSelect() }, } }, []) diff --git a/cpp/src/editor_buffer.cpp b/cpp/src/editor_buffer.cpp index b29ab11..6641dbe 100644 --- a/cpp/src/editor_buffer.cpp +++ b/cpp/src/editor_buffer.cpp @@ -2911,6 +2911,68 @@ json op_outline(const json& msg) { return {{"symbols", build_outline_tree(syms)}, {"ready", true}}; } +// ── Smart Select (expand/shrink by AST node) ────────────────────────────────── +// editor.smartSelect {bufferId, startLine, startCol, endLine, endCol} +// → {startLine, startCol, endLine, endCol} for the expanded node +// → {noop: true} when no tree is available or selection is already the root + +json op_smart_select(const json& msg) { + int id = msg.value("bufferId", 0); + std::lock_guard lk(g_mu); + Buffer* b = find_buffer(id); + if (!b || !b->tree) return {{"noop", true}}; + + uint32_t sl = msg.value("startLine", 0u); + uint32_t sc = msg.value("startCol", 0u); + uint32_t el = msg.value("endLine", 0u); + uint32_t ec = msg.value("endCol", 0u); + + if (sl >= b->line_count() || el >= b->line_count()) return {{"noop", true}}; + + uint32_t sls, sle, els, ele; + b->line_bytes(sl, sls, sle); + b->line_bytes(el, els, ele); + uint32_t start_byte = u16_col_to_byte(b->text, sls, sle, sc); + uint32_t end_byte = u16_col_to_byte(b->text, els, ele, ec); + + // Walk up the AST to find the smallest named node that strictly contains + // [start_byte, end_byte). Skip anonymous nodes (punctuation) when a named + // parent is available at the same span, so Shift+Alt+Right steps to + // semantically meaningful nodes. + TSNode node = ts_node_named_descendant_for_byte_range( + ts_tree_root_node(b->tree), start_byte, end_byte > start_byte ? end_byte - 1 : start_byte); + + // Walk up until we find a node whose byte span strictly contains the + // current selection (i.e. is wider on at least one side). + while (!ts_node_is_null(node)) { + uint32_t ns = ts_node_start_byte(node); + uint32_t ne = ts_node_end_byte(node); + if (ns < start_byte || ne > end_byte) break; // node is wider + TSNode parent = ts_node_parent(node); + if (ts_node_is_null(parent)) break; // already at root + node = parent; + } + + if (ts_node_is_null(node)) return {{"noop", true}}; + + uint32_t ns = ts_node_start_byte(node); + uint32_t ne = ts_node_end_byte(node); + + // No change — selection already covers this node exactly. + if (ns == start_byte && ne == end_byte) return {{"noop", true}}; + + auto to_pos = [&](uint32_t byte_off) -> std::pair { + TSPoint p = point_for_byte(*b, byte_off); + uint32_t line_start = b->line_offsets[p.row]; + return {p.row, byte_to_u16_col(b->text, line_start, byte_off)}; + }; + + auto [newSL, newSC] = to_pos(ns); + auto [newEL, newEC] = to_pos(ne); + return {{"startLine", newSL}, {"startCol", newSC}, + {"endLine", newEL}, {"endCol", newEC}}; +} + json op_save(const json& msg) { int id = msg.value("bufferId", 0); std::lock_guard lk(g_mu); @@ -2985,6 +3047,7 @@ bool dispatch(const std::string& type, const json& msg, else if (type == "editor.viewstate.set") resp = op_viewstate_set(msg); else if (type == "editor.viewstate.get") resp = op_viewstate_get(msg); else if (type == "editor.buffers") resp = op_buffers(msg); + else if (type == "editor.smartSelect") resp = op_smart_select(msg); else return false; return true; } diff --git a/cpp/src/editor_buffer.hpp b/cpp/src/editor_buffer.hpp index c363e9d..908c440 100644 --- a/cpp/src/editor_buffer.hpp +++ b/cpp/src/editor_buffer.hpp @@ -35,6 +35,9 @@ // viewKey lets two panes showing the same buffer // (editor.open refcounts by path) keep independent // cursor/scroll; omitted = shared "" key. +// editor.smartSelect {bufferId, startLine, startCol, endLine, endCol} +// → {startLine, startCol, endLine, endCol} (expanded node) +// | {noop: true} (unsupported language or already at root) // editor.buffers {} → {buffers: [{bufferId, path, lineCount, dirty}]} // // Columns in the IPC contract are UTF-16 code units (JS string indexing). From 6d481ec9d133af248832eb2dd7d15e9d2675f9bb Mon Sep 17 00:00:00 2001 From: Kris Powers <85710701+KrisPowers@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:29:03 -0400 Subject: [PATCH 2/2] feat(sidebar): add multi-file selection to file explorer Ctrl/Cmd+Click toggles items in the selection; Shift+Click selects a contiguous range from the last-clicked anchor. Selected items render with an accent-tinted highlight distinct from the single active-file highlight. Right-clicking a multi-selection shows a batch context menu (Delete Selected, Copy Paths). Delete/Backspace fires the confirmation dialog for all selected items, which now accepts an array and reports the count when multiple targets are involved. Dragging a selection moves all selected files to the drop folder in parallel. Escape clears the selection. Closes #73 --- .../src/fullscreen/DeleteConfirmDialog.tsx | 30 ++- app/frontend/src/fullscreen/FileExplorer.tsx | 240 +++++++++++++----- app/frontend/src/fullscreen/fullscreen.scss | 6 + 3 files changed, 198 insertions(+), 78 deletions(-) diff --git a/app/frontend/src/fullscreen/DeleteConfirmDialog.tsx b/app/frontend/src/fullscreen/DeleteConfirmDialog.tsx index 78fbe5a..25d6847 100644 --- a/app/frontend/src/fullscreen/DeleteConfirmDialog.tsx +++ b/app/frontend/src/fullscreen/DeleteConfirmDialog.tsx @@ -1,14 +1,12 @@ import React, { useEffect } from 'react' interface Props { - name: string - isDir: boolean + targets: Array<{ name: string; isDir: boolean }> onConfirm: () => void onCancel: () => void } -export default function DeleteConfirmDialog({ name, isDir, onConfirm, onCancel }: Props) { - // Close on Escape +export default function DeleteConfirmDialog({ targets, onConfirm, onCancel }: Props) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel() @@ -18,16 +16,28 @@ export default function DeleteConfirmDialog({ name, isDir, onConfirm, onCancel } return () => document.removeEventListener('keydown', handler) }, [onConfirm, onCancel]) + const isSingle = targets.length === 1 + const title = isSingle + ? `Delete ${targets[0].isDir ? 'Folder' : 'File'}` + : `Delete ${targets.length} Items` + return (
e.stopPropagation()}> -
- Delete {isDir ? 'Folder' : 'File'} -
+
{title}
- Are you sure you want to delete "{name}"? - {isDir && ( - All contents will be removed. + {isSingle ? ( + <> + Are you sure you want to delete "{targets[0].name}"? + {targets[0].isDir && ( + All contents will be removed. + )} + + ) : ( + <> + Are you sure you want to delete {targets.length} items? + Folders and their contents will be removed. + )} This cannot be undone.
diff --git a/app/frontend/src/fullscreen/FileExplorer.tsx b/app/frontend/src/fullscreen/FileExplorer.tsx index 098fa5b..1607929 100644 --- a/app/frontend/src/fullscreen/FileExplorer.tsx +++ b/app/frontend/src/fullscreen/FileExplorer.tsx @@ -27,7 +27,7 @@ interface Props { onAddToGitIgnore?: (node: FileNode) => void } -type CtxKind = 'file' | 'area' +type CtxKind = 'file' | 'area' | 'multi' interface CtxState { x: number @@ -54,21 +54,24 @@ function hasGitChange(code: string | undefined): boolean { export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, onLoadDir, gitStatus, diagnosticErrors, onAddToGitIgnore }: Props) { // ── lazy directory cache ───────────────────────────────────────────────────── - const [dirCache, setDirCache] = useState>(new Map()) - const [expanded, setExpanded] = useState>(new Set()) - const [ctx, setCtx] = useState(null) - const [renaming, setRenaming] = useState(null) - const [renameVal, setRenameVal] = useState('') - const [dragOver, setDragOver] = useState(null) - const [deleteTarget, setDeleteTarget] = useState(null) - const [newItem, setNewItem] = useState<{ kind: 'file' | 'folder'; dir: string } | null>(null) - const [filterQuery, setFilterQuery] = useState('') - const dragSrc = useRef(null) + const [dirCache, setDirCache] = useState>(new Map()) + const [expanded, setExpanded] = useState>(new Set()) + const [ctx, setCtx] = useState(null) + const [renaming, setRenaming] = useState(null) + const [renameVal, setRenameVal] = useState('') + const [dragOver, setDragOver] = useState(null) + const [deleteTargets, setDeleteTargets] = useState([]) + const [newItem, setNewItem] = useState<{ kind: 'file' | 'folder'; dir: string } | null>(null) + const [filterQuery, setFilterQuery] = useState('') + const [multiSelection, setMultiSelection] = useState>(new Set()) + const dragSrc = useRef(null) + const dragSrcs = useRef([]) const dragExpandTimer = useRef | null>(null) - const dragOverTarget = useRef(null) - const scrollRef = useRef(null) - const filterInputRef = useRef(null) - const installedApps = useInstalledApps() + const dragOverTarget = useRef(null) + const scrollRef = useRef(null) + const filterInputRef = useRef(null) + const lastClickedPath = useRef(null) + const installedApps = useInstalledApps() // (Re)fetch a directory's children and store them in the cache. const loadDir = useCallback(async (path: string) => { @@ -81,7 +84,6 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, }, [onLoadDir]) // New root (cwd changed) — reset cache/expansion and load the root's children. - // The root itself is shown as the first (expanded) tree row. useEffect(() => { setExpanded(new Set(root ? [root.path] : [])) setDirCache(new Map()) @@ -101,17 +103,14 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, }) }, [dirCache, loadDir]) - // Re-fetch the root and every expanded directory (e.g. after a mutation - // whose exact target dir we don't want to track individually, or the - // "Refresh" context menu action). + // Re-fetch root and every expanded directory. const refreshAll = useCallback(() => { if (!root) return void loadDir(root.path) for (const path of expanded) void loadDir(path) }, [root, expanded, loadDir]) - // Native file-watcher push ('fs:changed' {dirs:[...]}) — re-fetch any - // directory that's currently cached (i.e. visible/expanded). + // Native file-watcher push ('fs:changed' {dirs:[...]}). const dirCacheRef = useRef(dirCache) useEffect(() => { dirCacheRef.current = dirCache }, [dirCache]) @@ -125,7 +124,7 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, return () => unsub() }, [loadDir]) - // ── flatten the cached tree for rendering — root is the first row ─────────── + // ── flatten the cached tree for rendering ──────────────────────────────────── const flatRows = useMemo(() => { const out: FlatRow[] = [] function walk(path: string, depth: number): void { @@ -189,12 +188,17 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, overscan: 10, }) - // Right-click on a file/folder node + // Right-click on a file/folder node — show batch menu when multiple items are selected. const openFileCtx = useCallback((e: React.MouseEvent, node: FileNode) => { e.preventDefault() e.stopPropagation() - setCtx({ x: e.clientX, y: e.clientY, node, kind: 'file' }) - }, []) + if (multiSelection.size > 1 && multiSelection.has(node.path)) { + setCtx({ x: e.clientX, y: e.clientY, node, kind: 'multi' }) + } else { + if (!multiSelection.has(node.path)) setMultiSelection(new Set()) + setCtx({ x: e.clientX, y: e.clientY, node, kind: 'file' }) + } + }, [multiSelection]) // Right-click on empty tree area or header const openAreaCtx = useCallback((e: React.MouseEvent, node: FileNode) => { @@ -216,18 +220,20 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, onRefresh() }, [renameVal, onRefresh, loadDir]) - // Opens the custom delete confirm dialog + // Opens the delete confirm dialog for one or more nodes. const handleDelete = useCallback((node: FileNode) => { - setDeleteTarget(node) + setDeleteTargets([node]) }, []) const confirmDelete = useCallback(async () => { - if (!deleteTarget) return - await invoke('fs.delete', { path: deleteTarget.path }) - setDeleteTarget(null) - void loadDir(parentDir(deleteTarget.path)) + if (!deleteTargets.length) return + await Promise.all(deleteTargets.map(t => invoke('fs.delete', { path: t.path }).catch(() => {}))) + const dirs = new Set(deleteTargets.map(t => parentDir(t.path))) + for (const dir of dirs) void loadDir(dir) + setDeleteTargets([]) + setMultiSelection(new Set()) onRefresh() - }, [deleteTarget, onRefresh, loadDir]) + }, [deleteTargets, onRefresh, loadDir]) const handleNewFile = useCallback((node: FileNode) => { const dir = node.isDir ? node.path : parentDir(node.path) @@ -291,7 +297,7 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, setExpanded(new Set()) }, []) - // ── File-node context menu — no icons ────────────────────────────────────── + // ── File-node context menu ──────────────────────────────────────────────────── const buildFileMenu = useCallback((node: FileNode): ContextMenuItem[] => { const items: ContextMenuItem[] = [ { label: 'New File', action: () => handleNewFile(node) }, @@ -303,9 +309,7 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, if (!node.isDir) { items.push({ label: 'Duplicate', action: () => { void handleDuplicate(node) } }) } - // Apps contribute their own items here (e.g. Live Preview adds "Open Live - // Preview" for .md/.html files) instead of the host hardcoding knowledge - // of specific apps. + // Apps contribute their own items (e.g. Live Preview adds "Open Live Preview"). for (const app of installedApps) { const contributed = app.contributes?.fileExplorerContextMenu?.({ path: node.path, name: node.name, ext: node.ext, isDir: node.isDir, @@ -321,7 +325,7 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, return items }, [handleNewFile, handleNewFolder, startRename, handleCopyPath, handleDuplicate, handleDelete, onAddToGitIgnore, installedApps]) - // ── Empty-area context menu — acts on root dir ───────────────────────────── + // ── Empty-area context menu ──────────────────────────────────────────────────── const buildAreaMenu = useCallback((node: FileNode): ContextMenuItem[] => [ { label: 'New File', action: () => handleNewFile(node) }, { label: 'New Folder', action: () => handleNewFolder(node) }, @@ -331,22 +335,62 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, { label: 'Collapse All', action: collapseAll }, ], [handleNewFile, handleNewFolder, handleReveal, refreshAll, onRefresh, collapseAll]) - // ── Drag and drop ───────────────────────────────────────────────────────── + // ── Multi-selection batch context menu ──────────────────────────────────────── + const buildBatchMenu = useCallback((): ContextMenuItem[] => { + const count = multiSelection.size + return [ + { + label: `Delete Selected (${count})`, + danger: true, + action: () => { + const targets = displayRows + .filter(r => multiSelection.has(r.node.path)) + .map(r => r.node) + setDeleteTargets(targets) + }, + }, + { + label: 'Copy Paths', + action: () => { + const paths = displayRows + .filter(r => multiSelection.has(r.node.path)) + .map(r => r.node.path) + .join('\n') + void navigator.clipboard.writeText(paths) + }, + }, + ] + }, [multiSelection, displayRows]) + + // ── Drag and drop ───────────────────────────────────────────────────────────── const clearDragExpand = () => { if (dragExpandTimer.current) { clearTimeout(dragExpandTimer.current); dragExpandTimer.current = null } dragOverTarget.current = null } const onDragStart = (e: React.DragEvent, node: FileNode) => { - dragSrc.current = node.path + if (multiSelection.has(node.path) && multiSelection.size > 1) { + dragSrcs.current = Array.from(multiSelection) + dragSrc.current = null + } else { + dragSrcs.current = [] + dragSrc.current = node.path + } e.dataTransfer.effectAllowed = 'move' } const onDragOver = (e: React.DragEvent, node: FileNode) => { if (!node.isDir) return - const src = dragSrc.current - // Reject self-drop and dropping a folder into its own descendant - if (!src || node.path === src || node.path.startsWith(src + '/')) return + if (dragSrcs.current.length > 0) { + const isInvalid = dragSrcs.current.some(s => + node.path === s || node.path.startsWith(s + '/') + ) + if (isInvalid) return + } else { + const src = dragSrc.current + // Reject self-drop and dropping a folder into its own descendant + if (!src || node.path === src || node.path.startsWith(src + '/')) return + } e.preventDefault() e.dataTransfer.dropEffect = 'move' setDragOver(node.path) @@ -367,8 +411,27 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, e.preventDefault() setDragOver(null) clearDragExpand() + if (!node.isDir) return + + if (dragSrcs.current.length > 0) { + // Multi-file move: filter out invalid sources then move all in parallel. + const srcs = dragSrcs.current.filter(s => + s !== node.path && !node.path.startsWith(s + '/') + ) + await Promise.all(srcs.map(src => { + const name = src.split('/').pop()! + return invoke('fs.rename', { from: src, to: `${node.path}/${name}` }).catch(() => {}) + })) + const dirs = new Set([...srcs.map(s => parentDir(s)), node.path]) + for (const dir of dirs) void loadDir(dir) + setMultiSelection(new Set()) + dragSrcs.current = [] + onRefresh() + return + } + const src = dragSrc.current - if (!src || !node.isDir) return + if (!src) return // Reject self-drop and ancestor drops (folder into its own subfolder) if (node.path === src || node.path.startsWith(src + '/')) return const srcName = src.split('/').pop()! @@ -379,34 +442,65 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, onRefresh() } - const onDragEnd = () => { dragSrc.current = null; setDragOver(null); clearDragExpand() } + const onDragEnd = () => { dragSrc.current = null; dragSrcs.current = []; setDragOver(null); clearDragExpand() } - // ── Flat tree row ────────────────────────────────────────────────────────── - function renderNode(node: FileNode, depth: number, style: React.CSSProperties): React.ReactNode { - const isOpen = node.isDir && expanded.has(node.path) - const isSelected = node.path === selectedPath - const isDragTarget = dragOver === node.path - const isRenamingThis = renaming === node.path - const gitCode = gitStatus?.[node.path] - const isIgnored = gitCode === '!' - const isGitChanged = hasGitChange(gitCode) - const isError = !!(diagnosticErrors?.has(node.path)) + // ── Flat tree row ────────────────────────────────────────────────────────────── + function renderNode(node: FileNode, depth: number, style: React.CSSProperties, rowIndex: number): React.ReactNode { + const isOpen = node.isDir && expanded.has(node.path) + const isSelected = node.path === selectedPath + const isMultiSelected = multiSelection.has(node.path) + const isDragTarget = dragOver === node.path + const isRenamingThis = renaming === node.path + const gitCode = gitStatus?.[node.path] + const isIgnored = gitCode === '!' + const isGitChanged = hasGitChange(gitCode) + const isError = !!(diagnosticErrors?.has(node.path)) return (
{ - if (node.isDir) toggle(node) - else onSelect(node) + onClick={(e: React.MouseEvent) => { + const isMeta = e.ctrlKey || e.metaKey + const isShift = e.shiftKey + if (isMeta) { + // Ctrl/Cmd+Click: toggle this item in the selection. + setMultiSelection(prev => { + const next = new Set(prev) + if (next.has(node.path)) next.delete(node.path) + else next.add(node.path) + return next + }) + lastClickedPath.current = node.path + } else if (isShift) { + // Shift+Click: select a contiguous range from the last-clicked item. + const anchorPath = lastClickedPath.current + const anchorIdx = anchorPath + ? displayRows.findIndex(r => r.node.path === anchorPath) + : -1 + if (anchorIdx === -1) { + setMultiSelection(new Set([node.path])) + } else { + const start = Math.min(anchorIdx, rowIndex) + const end = Math.max(anchorIdx, rowIndex) + setMultiSelection(new Set(displayRows.slice(start, end + 1).map(r => r.node.path))) + } + } else { + // Plain click: clear selection, open file or toggle folder. + setMultiSelection(new Set()) + lastClickedPath.current = node.path + if (node.isDir) toggle(node) + else onSelect(node) + } }} onContextMenu={e => openFileCtx(e, node)} draggable @@ -455,7 +549,9 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, } const activeItems = ctx - ? ctx.kind === 'area' ? buildAreaMenu(ctx.node) : buildFileMenu(ctx.node) + ? ctx.kind === 'area' ? buildAreaMenu(ctx.node) + : ctx.kind === 'multi' ? buildBatchMenu() + : buildFileMenu(ctx.node) : [] return ( @@ -466,6 +562,15 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, e.preventDefault() filterInputRef.current?.focus() } + if (e.key === 'Escape' && multiSelection.size > 0) { + setMultiSelection(new Set()) + e.preventDefault() + } + if ((e.key === 'Delete' || e.key === 'Backspace') && multiSelection.size > 0) { + const targets = displayRows.filter(r => multiSelection.has(r.node.path)).map(r => r.node) + if (targets.length) setDeleteTargets(targets) + e.preventDefault() + } }} > {/* Filter input */} @@ -510,7 +615,7 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, return renderNode(node, depth, { position: 'absolute', top: 0, left: 0, right: 0, height: `${item.size}px`, transform: `translateY(${item.start}px)`, - }) + }, item.index) })}
@@ -526,12 +631,11 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, )} {/* Delete confirmation dialog */} - {deleteTarget && ( + {deleteTargets.length > 0 && ( ({ name: t.name, isDir: t.isDir }))} onConfirm={confirmDelete} - onCancel={() => setDeleteTarget(null)} + onCancel={() => setDeleteTargets([])} /> )} diff --git a/app/frontend/src/fullscreen/fullscreen.scss b/app/frontend/src/fullscreen/fullscreen.scss index 293e388..59b5cdf 100644 --- a/app/frontend/src/fullscreen/fullscreen.scss +++ b/app/frontend/src/fullscreen/fullscreen.scss @@ -352,6 +352,12 @@ background: color-mix(in srgb, var(--ide-fg) 12%, transparent) !important; color: var(--ide-fg); } +.fe-node--multi-selected { + background: color-mix(in srgb, var(--ide-accent) 22%, transparent) !important; + color: var(--ide-fg); +} +.fe-node--multi-selected .fe-node__chevron { opacity: 1; } +.fe-node--multi-selected .fe-node__icon { color: var(--ide-text-hi); } .fe-node--dragover { outline: 1px solid var(--ide-accent); outline-offset: -1px;