From 588094afa2a685f4b2f1ac7e56f3312096a674bc Mon Sep 17 00:00:00 2001 From: Shreyan Gupta <64853271+RA1NCS@users.noreply.github.com> Date: Fri, 8 May 2026 03:38:24 -0400 Subject: [PATCH 1/2] Polish explorer collapse behavior and terminal paste warnings - keep regular folder close separate from destructive collapse so nested state restores when reopened - refine root row controls, sticky row actions, and folder motion so the tree behaves consistently while scrolling - keep the terminal paste warning flow local to the terminal with a persisted do-not-show-again setting --- .../components/file-explorer-tree-item.tsx | 89 +++- .../components/file-explorer-tree.tsx | 395 +++++++++++++++--- .../hooks/use-file-explorer-context-menu.tsx | 15 +- .../styles/file-explorer-tree.css | 153 ++++++- .../components/tabs/terminal-settings.tsx | 26 ++ .../settings/config/default-settings.ts | 1 + src/features/settings/config/search-index.ts | 8 + src/features/settings/types/settings.ts | 1 + src/features/terminal/components/terminal.tsx | 82 +++- 9 files changed, 682 insertions(+), 88 deletions(-) diff --git a/src/features/file-explorer/components/file-explorer-tree-item.tsx b/src/features/file-explorer/components/file-explorer-tree-item.tsx index 4d5a966da..db784eac5 100644 --- a/src/features/file-explorer/components/file-explorer-tree-item.tsx +++ b/src/features/file-explorer/components/file-explorer-tree-item.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { memo } from "react"; +import { CaretDoubleUp, FolderOpen, Minus } from "@phosphor-icons/react"; import { FILE_TREE_DENSITY_CONFIG, type FileTreeDensity, @@ -21,6 +22,12 @@ export interface FileTreeGuideTarget { isActive: boolean; } +export interface FileTreeRowAnimation { + delay: number; + duration: number; + phase: "opening-block" | "closing-block" | "closing"; +} + function areGuideTargetsEqual( previous: Array, next: Array, @@ -52,12 +59,15 @@ interface FileExplorerTreeItemProps { density: FileTreeDensity; isExpanded: boolean; isActive: boolean; + isWorkspaceRoot: boolean; + rowAnimation?: FileTreeRowAnimation | null; dragOverPath: string | null; isDragging: boolean; editingValue: string; onEditingValueChange: (value: string) => void; onKeyDown: (e: React.KeyboardEvent, file: FileEntry) => void; onBlur: (file: FileEntry) => void; + onCollapseDirectory: (path: string, isWorkspaceRoot: boolean) => void; getGitStatusDecoration: (file: FileEntry) => FileTreeGitStatusDecoration | null; searchQuery?: string; isSearchMatch?: boolean; @@ -96,12 +106,15 @@ function FileExplorerTreeItemComponent({ density, isExpanded, isActive, + isWorkspaceRoot, + rowAnimation, dragOverPath, isDragging, editingValue, onEditingValueChange, onKeyDown, onBlur, + onCollapseDirectory, getGitStatusDecoration, searchQuery, isSearchMatch = false, @@ -145,7 +158,15 @@ function FileExplorerTreeItemComponent({ if (file.isEditing || file.isRenaming) { return ( -
+
{renderTreeGuides()}
{renderTreeGuides()} - + {isWorkspaceRoot ? ( + + ) : ( + + )} + {file.isDir && isExpanded ? ( + + ) : null}
); } @@ -263,12 +331,17 @@ export const FileExplorerTreeItem = memo( prev.density === next.density && prev.isExpanded === next.isExpanded && prev.isActive === next.isActive && + prev.isWorkspaceRoot === next.isWorkspaceRoot && + prev.rowAnimation?.delay === next.rowAnimation?.delay && + prev.rowAnimation?.duration === next.rowAnimation?.duration && + prev.rowAnimation?.phase === next.rowAnimation?.phase && prev.dragOverPath === next.dragOverPath && prev.isDragging === next.isDragging && prev.editingValue === next.editingValue && prev.onEditingValueChange === next.onEditingValueChange && prev.onKeyDown === next.onKeyDown && prev.onBlur === next.onBlur && + prev.onCollapseDirectory === next.onCollapseDirectory && prev.getGitStatusDecoration === next.getGitStatusDecoration && prev.searchQuery === next.searchQuery && prev.isSearchMatch === next.isSearchMatch && diff --git a/src/features/file-explorer/components/file-explorer-tree.tsx b/src/features/file-explorer/components/file-explorer-tree.tsx index 7fccf65f0..7535042e4 100644 --- a/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/src/features/file-explorer/components/file-explorer-tree.tsx @@ -1,9 +1,12 @@ import ignore from "ignore"; import { + CaretDoubleUp, Check, Eye, Funnel, + FolderOpen, GitBranch, + Minus, MagnifyingGlass as Search, Warning as AlertTriangle, } from "@phosphor-icons/react"; @@ -61,7 +64,7 @@ import { useFileExplorerDragDrop } from "../hooks/use-file-explorer-drag-drop"; import { useFileExplorerSync } from "../hooks/use-file-explorer-sync"; import { useFileExplorerVisibleRows } from "../hooks/use-file-explorer-visible-rows"; import { FILE_TREE_BASE_INDENT, FileExplorerTreeItem } from "./file-explorer-tree-item"; -import type { FileTreeGuideTarget } from "./file-explorer-tree-item"; +import type { FileTreeGuideTarget, FileTreeRowAnimation } from "./file-explorer-tree-item"; import { FileExplorerIcon } from "./file-explorer-icon"; import "../styles/file-explorer-tree.css"; @@ -113,8 +116,26 @@ interface OpenAllFilesDialogState { const FILE_TREE_CONTAINER_INSET = 4; const FILE_TREE_HEADER_HEIGHT = 32; +const FOLDER_COLLAPSE_TOTAL_DURATION_MS = 500; +const FOLDER_ROW_ANIMATION_DURATION_MS = 240; const getFileTreeRowId = (path: string) => `file-tree-row-${path.replace(/[^a-zA-Z0-9_-]/g, "_")}`; +function getPathDepth(path: string): number { + return stripTrailingPathSeparators(path).split(/[/\\]/).filter(Boolean).length; +} + +function getCollapseStackDelay(index: number, total: number): number { + if (total <= 1) return 0; + + const progress = index / (total - 1); + const maxDelay = Math.max( + 0, + FOLDER_COLLAPSE_TOTAL_DURATION_MS - FOLDER_ROW_ANIMATION_DURATION_MS, + ); + + return Math.round(progress ** 3 * maxDelay); +} + function FileExplorerTreeComponent({ files, activePath, @@ -142,6 +163,7 @@ function FileExplorerTreeComponent({ const [openAllFilesDialog, setOpenAllFilesDialog] = useState( null, ); + const [rowAnimations, setRowAnimations] = useState>({}); const [isDeletingPath, setIsDeletingPath] = useState(false); const [isOpeningAllFiles, setIsOpeningAllFiles] = useState(false); const [editingValue, setEditingValue] = useState(""); @@ -154,6 +176,8 @@ function FileExplorerTreeComponent({ const searchInputRef = useRef(null); const filterButtonRef = useRef(null); const documentRef = useRef(document); + const collapseAnimationTimeoutsRef = useRef([]); + const pendingOpenAnimationPathRef = useRef(null); const [gitIgnoreRules, setGitIgnoreRules] = useState(null); const workspaceGitStatus = useGitStore((state) => state.workspaceGitStatus); @@ -429,6 +453,10 @@ function FileExplorerTreeComponent({ containerRef, expandedPathsOverride: displayedExpandedPaths, }); + const rootRowPaths = useMemo( + () => new Set(visibleRows.filter((row) => row.depth === 0).map((row) => row.file.path)), + [visibleRows], + ); const keyboardPath = focusedPath || activePath; const highlightedPath = hasTreeFocus ? keyboardPath : activePath; @@ -763,6 +791,199 @@ function FileExplorerTreeComponent({ } }, [openAllFilesDialog, openFilePathsInTabs]); + // Fast path->file lookup for delegation + const pathToFile = useMemo(() => { + const m = new Map(); + for (const r of visibleRows) m.set(r.file.path, r.file); + return m; + }, [visibleRows]); + + const getTargetItem = (target: EventTarget | null) => { + const el = (target as HTMLElement | null)?.closest("[data-file-path]") as + | (HTMLElement & { dataset: { filePath?: string; isDir?: string } }) + | null; + if (!el) return null; + const path = el.dataset.filePath || el.getAttribute("data-file-path") || ""; + const isDir = (el.dataset.isDir || el.getAttribute("data-is-dir")) === "true"; + const file = pathToFile.get(path); + if (!file) return null; + return { path, isDir, file }; + }; + + const toggleDirectory = useCallback( + async (path: string) => { + if (!useFileTreeStore.getState().isExpanded(path)) { + pendingOpenAnimationPathRef.current = path; + } + + await Promise.resolve(onFileSelect(path, true)); + }, + [onFileSelect], + ); + + const clearCollapseAnimation = useCallback(() => { + collapseAnimationTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); + collapseAnimationTimeoutsRef.current = []; + pendingOpenAnimationPathRef.current = null; + setRowAnimations({}); + }, []); + + const closeDirectory = useCallback( + (path: string) => { + const descendantVisiblePaths = visibleRows + .filter((row) => row.file.path !== path && pathStartsWithRoot(row.file.path, path)) + .map((row) => row.file.path); + + if (descendantVisiblePaths.length === 0) { + useFileTreeStore.getState().toggleFolder(path); + return; + } + + clearCollapseAnimation(); + + setRowAnimations( + Object.fromEntries( + descendantVisiblePaths.map((targetPath) => [ + targetPath, + { + delay: 0, + duration: FOLDER_ROW_ANIMATION_DURATION_MS, + phase: "closing-block" as const, + }, + ]), + ), + ); + + const timeoutId = window.setTimeout(() => { + useFileTreeStore.getState().toggleFolder(path); + setRowAnimations({}); + collapseAnimationTimeoutsRef.current = collapseAnimationTimeoutsRef.current.filter( + (currentTimeoutId) => currentTimeoutId !== timeoutId, + ); + }, FOLDER_ROW_ANIMATION_DURATION_MS); + + collapseAnimationTimeoutsRef.current.push(timeoutId); + }, + [clearCollapseAnimation, visibleRows], + ); + + const collapseDirectory = useCallback( + (path: string, isWorkspaceRoot: boolean) => { + const treeState = useFileTreeStore.getState(); + const expandedPaths = Array.from(treeState.getExpandedPaths()).filter( + (expandedPath) => + pathStartsWithRoot(expandedPath, path) && (!isWorkspaceRoot || expandedPath !== path), + ); + const descendantVisibleRows = visibleRows.filter( + (row) => row.file.path !== path && pathStartsWithRoot(row.file.path, path), + ); + const descendantVisiblePaths = descendantVisibleRows.map((row) => row.file.path); + + if (expandedPaths.length === 0 && descendantVisiblePaths.length === 0) return; + + clearCollapseAnimation(); + + const folderCollapseOrder = descendantVisibleRows + .filter((row) => row.file.isDir) + .map((row) => row.file.path) + .sort((left, right) => { + const depthDifference = getPathDepth(right) - getPathDepth(left); + if (depthDifference !== 0) return depthDifference; + return right.length - left.length; + }); + + const closeAnimationByPath = Object.fromEntries( + descendantVisiblePaths.map((targetPath) => [ + targetPath, + { + delay: 0, + duration: FOLDER_ROW_ANIMATION_DURATION_MS, + phase: "closing" as const, + }, + ]), + ); + + folderCollapseOrder.forEach((targetPath, index) => { + closeAnimationByPath[targetPath] = { + delay: getCollapseStackDelay(index, folderCollapseOrder.length), + duration: FOLDER_ROW_ANIMATION_DURATION_MS, + phase: "closing", + }; + }); + + const totalCloseDuration = + folderCollapseOrder.length > 0 + ? getCollapseStackDelay(folderCollapseOrder.length - 1, folderCollapseOrder.length) + + FOLDER_ROW_ANIMATION_DURATION_MS + : descendantVisiblePaths.length > 0 + ? FOLDER_ROW_ANIMATION_DURATION_MS + : 0; + + if (descendantVisiblePaths.length > 0) { + setRowAnimations(closeAnimationByPath); + } + + const timeoutId = window.setTimeout(() => { + if (isWorkspaceRoot) { + const nextExpandedPaths = new Set( + Array.from(useFileTreeStore.getState().getExpandedPaths()).filter( + (expandedPath) => !pathStartsWithRoot(expandedPath, path) || expandedPath === path, + ), + ); + nextExpandedPaths.add(path); + useFileTreeStore.getState().setExpandedPaths(nextExpandedPaths); + } else { + useFileTreeStore.getState().collapsePath(path); + } + + setRowAnimations({}); + collapseAnimationTimeoutsRef.current = collapseAnimationTimeoutsRef.current.filter( + (currentTimeoutId) => currentTimeoutId !== timeoutId, + ); + }, totalCloseDuration); + + collapseAnimationTimeoutsRef.current.push(timeoutId); + }, + [clearCollapseAnimation, visibleRows], + ); + + useEffect(() => () => clearCollapseAnimation(), [clearCollapseAnimation]); + + useEffect(() => { + const openingPath = pendingOpenAnimationPathRef.current; + if (!openingPath) return; + + const descendantVisiblePaths = visibleRows + .filter((row) => row.file.path !== openingPath && pathStartsWithRoot(row.file.path, openingPath)) + .map((row) => row.file.path); + + pendingOpenAnimationPathRef.current = null; + + if (descendantVisiblePaths.length === 0) return; + + setRowAnimations( + Object.fromEntries( + descendantVisiblePaths.map((targetPath) => [ + targetPath, + { + delay: 0, + duration: FOLDER_ROW_ANIMATION_DURATION_MS, + phase: "opening-block" as const, + }, + ]), + ), + ); + + const timeoutId = window.setTimeout(() => { + setRowAnimations({}); + collapseAnimationTimeoutsRef.current = collapseAnimationTimeoutsRef.current.filter( + (currentTimeoutId) => currentTimeoutId !== timeoutId, + ); + }, FOLDER_ROW_ANIMATION_DURATION_MS); + + collapseAnimationTimeoutsRef.current.push(timeoutId); + }, [visibleRows]); + const { setContextMenu, handleContextMenu, contextMenuElement } = useFileExplorerContextMenu({ rootFolderPath, onFileSelect, @@ -783,6 +1004,7 @@ function FileExplorerTreeComponent({ isWorkspaceRootPath: (path) => workspaceRootPaths.includes(path), canRemoveWorkspaceRootPath: (path) => path !== rootFolderPath && workspaceRootPaths.includes(path), + onCollapseDirectory: collapseDirectory, onDeleteRequested: setDeleteCandidate, onStartInlineEditing: startInlineEditing, onOpenAllFilesInDirectory: handleOpenAllFilesInDirectory, @@ -798,32 +1020,6 @@ function FileExplorerTreeComponent({ useEventListener("dragover", (e: DragEvent) => e.preventDefault(), documentRef); - // Fast path->file lookup for delegation - const pathToFile = useMemo(() => { - const m = new Map(); - for (const r of visibleRows) m.set(r.file.path, r.file); - return m; - }, [visibleRows]); - - const getTargetItem = (target: EventTarget | null) => { - const el = (target as HTMLElement | null)?.closest("[data-file-path]") as - | (HTMLElement & { dataset: { filePath?: string; isDir?: string } }) - | null; - if (!el) return null; - const path = el.dataset.filePath || el.getAttribute("data-file-path") || ""; - const isDir = (el.dataset.isDir || el.getAttribute("data-is-dir")) === "true"; - const file = pathToFile.get(path); - if (!file) return null; - return { path, isDir, file }; - }; - - const toggleDirectory = useCallback( - async (path: string) => { - await Promise.resolve(onFileSelect(path, true)); - }, - [onFileSelect], - ); - const handleContainerClick = useCallback( (e: React.MouseEvent) => { const t = getTargetItem(e.target); @@ -841,7 +1037,14 @@ function FileExplorerTreeComponent({ fileOpenBenchmark.mark(t.path, "explorer-click"); } if (t.isDir) { - void toggleDirectory(t.path); + const isWorkspaceRoot = rootRowPaths.has(t.path); + const isExpanded = useFileTreeStore.getState().isExpanded(t.path); + + if (isExpanded && !isWorkspaceRoot) { + closeDirectory(t.path); + } else if (!isExpanded || !isWorkspaceRoot) { + void toggleDirectory(t.path); + } setFocusedPath(t.path); updateActivePath?.(t.path); } else { @@ -849,7 +1052,7 @@ function FileExplorerTreeComponent({ void Promise.resolve(onFileSelect(t.path, false)); } }, - [onFileSelect, toggleDirectory, updateActivePath, pathToFile], + [closeDirectory, onFileSelect, rootRowPaths, toggleDirectory, updateActivePath], ); const handleContainerDoubleClick = useCallback( @@ -1105,8 +1308,12 @@ function FileExplorerTreeComponent({ case "ArrowLeft": { if (!current) break; e.preventDefault(); - if (isDir && useFileTreeStore.getState().isExpanded(current.path)) { - void toggleDirectory(current.path); + if ( + isDir && + useFileTreeStore.getState().isExpanded(current.path) && + !rootRowPaths.has(current.path) + ) { + closeDirectory(current.path); } else { const sep = current.path.includes("\\") ? "\\" : "/"; const parentPath = current.path.split(sep).slice(0, -1).join(sep); @@ -1122,7 +1329,14 @@ function FileExplorerTreeComponent({ if (!current) break; e.preventDefault(); if (isDir) { - void toggleDirectory(current.path); + const isWorkspaceRoot = rootRowPaths.has(current.path); + const isExpanded = useFileTreeStore.getState().isExpanded(current.path); + + if (isExpanded && !isWorkspaceRoot) { + closeDirectory(current.path); + } else if (!isWorkspaceRoot || !isExpanded) { + void toggleDirectory(current.path); + } } else { void Promise.resolve(onFileOpen?.(current.path, false)); } @@ -1240,42 +1454,106 @@ function FileExplorerTreeComponent({ const stickyAncestorLabel = stickyAncestor.displayName ?? stickyAncestor.file.name; const stickyAncestorGitStatus = getGitStatusDecoration(stickyAncestor.file); + const isStickyWorkspaceRoot = stickyAncestor.depth === 0; const stickyAncestorPaddingLeft = FILE_TREE_BASE_INDENT + FILE_TREE_CONTAINER_INSET + stickyAncestor.depth * settings.fileTreeIndentSize; return ( - + {isStickyWorkspaceRoot ? ( + + ) : ( + + )} + + {stickyAncestorLabel} + + + {stickyAncestor.file.isDir && stickyAncestor.isExpanded ? ( + + ) : null} +
); })}
@@ -1316,12 +1594,15 @@ function FileExplorerTreeComponent({ density={fileTreeDensity} isExpanded={row.isExpanded} isActive={highlightedPath === row.file.path} + isWorkspaceRoot={rootRowPaths.has(row.file.path)} + rowAnimation={rowAnimations[row.file.path]} dragOverPath={dragState.dragOverPath} isDragging={dragState.isDragging} editingValue={editingValue} onEditingValueChange={setEditingValue} onKeyDown={handleKeyDown} onBlur={handleBlur} + onCollapseDirectory={collapseDirectory} getGitStatusDecoration={getGitStatusDecoration} rowId={getFileTreeRowId(row.file.path)} searchQuery={isTreeSearchActive ? treeSearchQuery : undefined} diff --git a/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx b/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx index 6eb110d50..deb850c53 100644 --- a/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx +++ b/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx @@ -57,6 +57,7 @@ interface UseFileExplorerContextMenuOptions { onRemoveFolderFromWorkspace?: (path: string) => void; isWorkspaceRootPath?: (path: string) => boolean; canRemoveWorkspaceRootPath?: (path: string) => boolean; + onCollapseDirectory?: (path: string, isWorkspaceRoot: boolean) => void; onDeleteRequested: (candidate: { path: string; isDir: boolean }) => void; onStartInlineEditing: (path: string, isFolder: boolean) => void; onOpenAllFilesInDirectory: (directoryPath: string) => Promise; @@ -107,6 +108,7 @@ export function useFileExplorerContextMenu({ onRemoveFolderFromWorkspace, isWorkspaceRootPath, canRemoveWorkspaceRootPath, + onCollapseDirectory, onDeleteRequested, onStartInlineEditing, onOpenAllFilesInDirectory, @@ -249,9 +251,18 @@ export function useFileExplorerContextMenu({ }, { id: "collapse-all", - label: "Collapse All", + label: isWorkspaceRootPath?.(contextMenu.path) ? "Collapse Descendants" : "Collapse Folder", icon: , - onClick: () => useFileTreeStore.getState().collapsePath(contextMenu.path), + onClick: () => { + if (onCollapseDirectory) { + onCollapseDirectory( + contextMenu.path, + Boolean(isWorkspaceRootPath?.(contextMenu.path)), + ); + return; + } + useFileTreeStore.getState().collapsePath(contextMenu.path); + }, }, { id: "open-terminal", diff --git a/src/features/file-explorer/styles/file-explorer-tree.css b/src/features/file-explorer/styles/file-explorer-tree.css index efc463013..93ea77d5d 100644 --- a/src/features/file-explorer/styles/file-explorer-tree.css +++ b/src/features/file-explorer/styles/file-explorer-tree.css @@ -28,10 +28,17 @@ .file-tree-item { width: 100% !important; min-width: 100% !important; + position: relative; + overflow: hidden; + max-height: var(--file-tree-row-height); + transform-origin: top; + --file-tree-hover-bg: color-mix(in srgb, var(--color-hover) 68%, transparent); + --file-tree-guide-icon-offset: 7px; + --tree-guide-color: color-mix(in srgb, var(--color-text-lighter) 18%, transparent); } -/* All file tree items have same background behavior - ensure full width coverage */ -.file-tree-container .file-tree-item button { +/* All file tree rows have same background behavior - ensure full width coverage */ +.file-tree-container .file-tree-item .file-tree-row { box-sizing: border-box !important; border: 1px solid transparent !important; border-radius: 2px !important; @@ -42,27 +49,20 @@ justify-content: flex-start !important; } -.file-tree-container .file-tree-item button:hover { +.file-tree-container .file-tree-item .file-tree-row:hover { background-color: transparent !important; } -.file-tree-container .file-tree-item button.bg-selected, -.file-tree-container .file-tree-item button.bg-selected:hover { +.file-tree-container .file-tree-item .file-tree-row.bg-selected, +.file-tree-container .file-tree-item .file-tree-row.bg-selected:hover { border-color: color-mix(in srgb, var(--color-border) 78%, transparent) !important; background-color: transparent !important; } -.file-tree-container .file-tree-item button:focus-visible { +.file-tree-container .file-tree-item .file-tree-row:focus-visible { border-color: color-mix(in srgb, var(--color-accent) 42%, var(--color-border)) !important; } -.file-tree-item { - position: relative; - --file-tree-hover-bg: color-mix(in srgb, var(--color-hover) 68%, transparent); - --file-tree-guide-icon-offset: 7px; - --tree-guide-color: color-mix(in srgb, var(--color-text-lighter) 18%, transparent); -} - .file-tree-item::before { position: absolute; inset: 0; @@ -84,6 +84,113 @@ .file-tree-row { position: relative; z-index: 2; + isolation: isolate; +} + +.file-tree-item[data-row-animation="opening-block"] { + animation: file-tree-row-open-block var(--file-tree-row-animation-duration, 240ms) + cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: var(--file-tree-row-animation-delay, 0ms); + pointer-events: none; +} + +.file-tree-item[data-row-animation="closing-block"] { + animation: file-tree-row-close-block var(--file-tree-row-animation-duration, 240ms) + cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: var(--file-tree-row-animation-delay, 0ms); + pointer-events: none; +} + +.file-tree-item[data-row-animation="closing"] { + animation: file-tree-row-close var(--file-tree-row-animation-duration, 240ms) + cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation-delay: var(--file-tree-row-animation-delay, 0ms); + pointer-events: none; +} + +@keyframes file-tree-row-open-block { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes file-tree-row-close-block { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(-4px); + } +} + +@keyframes file-tree-row-close { + from { + max-height: var(--file-tree-row-height); + opacity: 1; + } + + to { + max-height: 0; + opacity: 0; + } +} + +.file-tree-row-action { + position: absolute; + top: 50%; + right: 8px; + z-index: 2; + display: inline-flex; + height: 16px; + width: 16px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + background: transparent; + color: color-mix(in srgb, var(--color-text-lighter) 70%, transparent); + opacity: 0; + pointer-events: none; + transform: translateY(-50%) translateX(2px); + transition: + opacity 400ms cubic-bezier(0.22, 1, 0.36, 1), + transform 400ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 400ms cubic-bezier(0.22, 1, 0.36, 1), + color 400ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.file-tree-row-action svg { + height: 10px; + width: 10px; +} + +.file-tree-row-action:hover { + background-color: color-mix(in srgb, var(--color-hover) 62%, transparent); + color: color-mix(in srgb, var(--color-text) 88%, var(--color-text-lighter)); +} + +.file-tree-item[data-is-dir="true"][data-expanded="true"][data-active="true"] .file-tree-row-action, +.file-tree-item:focus-within .file-tree-row-action { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%) translateX(0); +} + +@media (hover: hover) and (pointer: fine) { + .file-tree-item:hover .file-tree-row-action { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%) translateX(0); + } } .file-tree-context-menu [role="menuitem"], @@ -169,3 +276,23 @@ .file-tree-sticky-ancestor-stack .file-tree-row { height: var(--file-tree-sticky-row-height, 24px); } + +@media (prefers-reduced-motion: reduce) { + .file-tree-item[data-row-animation="opening-block"], + .file-tree-item[data-row-animation="closing-block"], + .file-tree-item[data-row-animation="closing"] { + animation: none; + } + + .file-tree-row-action { + transition: none; + transform: translateY(-50%); + } + + .file-tree-item[data-is-dir="true"][data-expanded="true"][data-active="true"] + .file-tree-row-action, + .file-tree-item:focus-within .file-tree-row-action, + .file-tree-item:hover .file-tree-row-action { + transform: translateY(-50%); + } +} diff --git a/src/features/settings/components/tabs/terminal-settings.tsx b/src/features/settings/components/tabs/terminal-settings.tsx index 0d4238f88..88bdd60b0 100644 --- a/src/features/settings/components/tabs/terminal-settings.tsx +++ b/src/features/settings/components/tabs/terminal-settings.tsx @@ -164,6 +164,32 @@ export const TerminalSettings = () => { +
+ + updateSetting( + "confirmTerminalMultilinePaste", + getDefaultSetting("confirmTerminalMultilinePaste"), + ) + } + canReset={ + settings.confirmTerminalMultilinePaste !== + getDefaultSetting("confirmTerminalMultilinePaste") + } + > + updateSetting("confirmTerminalMultilinePaste", checked)} + size="sm" + /> + +
+
= ({ sessionId, isActive, @@ -55,6 +63,8 @@ export const XtermTerminal: React.FC = ({ const [isInitialized, setIsInitialized] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false); const [searchResults, setSearchResults] = useState({ current: 0, total: 0 }); + const [pendingPaste, setPendingPaste] = useState(null); + const [skipPasteWarning, setSkipPasteWarning] = useState(false); const isInitializingRef = useRef(false); const { updateSession, getSession } = useTerminalStore(); @@ -72,14 +82,19 @@ export const XtermTerminal: React.FC = ({ terminalCursorStyle, terminalCursorBlink, terminalCursorWidth, + confirmTerminalMultilinePaste, } = useSettingsStore((state) => state.settings); + const updateSetting = useSettingsStore((state) => state.updateSetting); const zoomLevel = useZoomStore.use.terminalZoomLevel(); const { rootFolderPath } = useProjectStore(); const { getTerminalTheme } = useTerminalTheme(); + const confirmTerminalMultilinePasteRef = useRef(confirmTerminalMultilinePaste); const effectiveTerminalFontSize = Math.round(terminalFontSize * zoomLevel * 10) / 10; const effectiveTerminalLetterSpacing = terminalLetterSpacing * zoomLevel; const effectiveTerminalCursorWidth = Math.max(1, Math.round(terminalCursorWidth * zoomLevel)); + confirmTerminalMultilinePasteRef.current = confirmTerminalMultilinePaste; + const fitTerminal = useCallback((attempts = 1) => { let attempt = 0; let rafId: number | null = null; @@ -208,15 +223,11 @@ export const XtermTerminal: React.FC = ({ lineCount >= MULTILINE_PASTE_LINE_THRESHOLD || normalizedText.length >= LARGE_PASTE_CHAR_THRESHOLD; - if (requiresConfirmation) { + if (requiresConfirmation && confirmTerminalMultilinePasteRef.current) { event.preventDefault(); event.stopImmediatePropagation(); - void primitiveConfirm( - `Paste ${lineCount} lines into the terminal? This may execute multiple commands.`, - { title: "Paste Into Terminal", confirmLabel: "Paste" }, - ).then((confirmed) => { - if (confirmed) writeBuffered(normalizedText); - }); + setSkipPasteWarning(false); + setPendingPaste({ text: normalizedText, lineCount }); return; } @@ -731,6 +742,61 @@ export const XtermTerminal: React.FC = ({ }} /> + {pendingPaste ? ( + { + setPendingPaste(null); + setSkipPasteWarning(false); + }} + size="sm" + footer={ + <> + + + + } + > +
+

+ Paste {pendingPaste.lineCount} lines into the terminal? This may execute multiple + commands. +

+ +
+
+ ) : null} ); }; From b41b6064a769e5dce71a39ef660eaa0510451295 Mon Sep 17 00:00:00 2001 From: Shreyan Gupta <64853271+RA1NCS@users.noreply.github.com> Date: Mon, 11 May 2026 00:19:09 -0400 Subject: [PATCH 2/2] Format explorer collapse files --- src/features/file-explorer/components/file-explorer-tree.tsx | 4 +++- .../file-explorer/hooks/use-file-explorer-context-menu.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/file-explorer/components/file-explorer-tree.tsx b/src/features/file-explorer/components/file-explorer-tree.tsx index 7535042e4..b38bd5041 100644 --- a/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/src/features/file-explorer/components/file-explorer-tree.tsx @@ -954,7 +954,9 @@ function FileExplorerTreeComponent({ if (!openingPath) return; const descendantVisiblePaths = visibleRows - .filter((row) => row.file.path !== openingPath && pathStartsWithRoot(row.file.path, openingPath)) + .filter( + (row) => row.file.path !== openingPath && pathStartsWithRoot(row.file.path, openingPath), + ) .map((row) => row.file.path); pendingOpenAnimationPathRef.current = null; diff --git a/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx b/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx index deb850c53..0fc453afb 100644 --- a/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx +++ b/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx @@ -251,7 +251,9 @@ export function useFileExplorerContextMenu({ }, { id: "collapse-all", - label: isWorkspaceRootPath?.(contextMenu.path) ? "Collapse Descendants" : "Collapse Folder", + label: isWorkspaceRootPath?.(contextMenu.path) + ? "Collapse Descendants" + : "Collapse Folder", icon: , onClick: () => { if (onCollapseDirectory) {