diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 29585838f..bbf62c209 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -37,6 +37,7 @@ import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-sta import { activateAndRevealWorktree } from './lib/worktree-activation' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { findWorktreeById, getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { dispatchClearModifierHints } from './hooks/useModifierHint' const isMac = navigator.userAgent.includes('Mac') @@ -415,11 +416,10 @@ function App(): React.JSX.Element { // Accept Cmd on macOS, Ctrl on other platforms const mod = isMac ? e.metaKey : e.ctrlKey - // Note: Cmd/Ctrl+P (quick-open) and Cmd/Ctrl+1-9 (jump-to-worktree) are - // handled via before-input-event in createMainWindow.ts, which forwards - // them as IPC events. The IPC handlers in useIpcEvents.ts apply the same - // view-state guards (activeView !== 'settings', etc.). This approach - // ensures the shortcuts work even when a browser guest has focus. + // Note: some app-level shortcuts are also intercepted via + // before-input-event in createMainWindow.ts so they still work when a + // browser guest has focus. The renderer keeps matching handlers for + // local-focus cases and to preserve the same guards in one place. if (isEditableTarget(e.target)) { return @@ -430,6 +430,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+B — toggle left sidebar if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b') { + dispatchClearModifierHints() e.preventDefault() actions.toggleSidebar() return @@ -443,6 +444,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+L — toggle right sidebar if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'l') { + dispatchClearModifierHints() e.preventDefault() actions.toggleRightSidebar() return @@ -453,6 +455,7 @@ function App(): React.JSX.Element { if (!repos.some((repo) => isGitRepoKind(repo))) { return } + dispatchClearModifierHints() e.preventDefault() actions.openNewWorkspacePage() return @@ -460,6 +463,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'e') { + dispatchClearModifierHints() e.preventDefault() actions.setRightSidebarTab('explorer') actions.setRightSidebarOpen(true) @@ -468,6 +472,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+Shift+F — toggle right sidebar / search tab if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') { + dispatchClearModifierHints() e.preventDefault() actions.setRightSidebarTab('search') actions.setRightSidebarOpen(true) @@ -483,6 +488,7 @@ function App(): React.JSX.Element { if (document.querySelector('[data-terminal-search-root]')) { return } + dispatchClearModifierHints() e.preventDefault() actions.setRightSidebarTab('source-control') actions.setRightSidebarOpen(true) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 8643659c7..05cd6da33 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -533,6 +533,22 @@ function Terminal(): React.JSX.Element | null { return } + // Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active, + // otherwise reopen the most recently closed editor tab (VS Code–style). + if (mod && e.shiftKey && e.key.toLowerCase() === 't' && !e.repeat) { + e.preventDefault() + const state = useAppStore.getState() + if (state.activeTabType === 'browser') { + const restored = state.reopenClosedBrowserTab(activeWorktreeId) + if (restored === null) { + state.reopenClosedEditorTab(activeWorktreeId) + } + } else { + state.reopenClosedEditorTab(activeWorktreeId) + } + return + } + // Cmd/Ctrl+Shift+B - new browser tab if (mod && e.shiftKey && e.key.toLowerCase() === 'b' && !e.repeat) { e.preventDefault() diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 58edbf1f5..328b7f7d3 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -2,19 +2,20 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Loader2 } from 'lucide-react' import { useAppStore } from '@/store' -import { dirname, normalizeRelativePath } from '@/lib/path' +import { dirname } from '@/lib/path' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import { FileDeleteDialog } from './FileDeleteDialog' import { FileExplorerBackgroundMenu } from './FileExplorerBackgroundMenu' -import { FileExplorerRow, InlineInputRow } from './FileExplorerRow' +import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' import { splitPathSegments } from './path-tree' -import { buildFolderStatusMap, buildStatusMap, STATUS_COLORS } from './status-display' +import { buildFolderStatusMap, buildStatusMap } from './status-display' import { useFileDeletion } from './useFileDeletion' import { useFileExplorerAutoReveal } from './useFileExplorerAutoReveal' import { useFileExplorerHandlers } from './useFileExplorerHandlers' import { useFileExplorerReveal } from './useFileExplorerReveal' import { useFileExplorerInlineInput } from './useFileExplorerInlineInput' +import { clearFileExplorerUndoHistory } from './fileExplorerUndoRedo' import { useFileExplorerKeys } from './useFileExplorerKeys' import { useActiveWorktreePath } from './useActiveWorktreePath' import { useFileDuplicate } from './useFileDuplicate' @@ -63,6 +64,8 @@ function FileExplorerInner(): React.JSX.Element { const [bgMenuOpen, setBgMenuOpen] = useState(false) const [bgMenuPoint, setBgMenuPoint] = useState({ x: 0, y: 0 }) const scrollRef = useRef(null) + /** Includes Radix scroll viewport + scrollbar (scrollbar is not a child of the viewport). */ + const explorerShellRef = useRef(null) const flashTimeoutRef = useRef(null) const isMac = useMemo(() => navigator.userAgent.includes('Mac'), []) const isWindows = useMemo(() => navigator.userAgent.includes('Windows'), []) @@ -131,6 +134,7 @@ function FileExplorerInner(): React.JSX.Element { } setSelectedPath(null) resetAndLoad() + clearFileExplorerUndoHistory() }, [worktreePath]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => clearFlashTimeout, [clearFlashTimeout]) @@ -238,7 +242,7 @@ function FileExplorerInner(): React.JSX.Element { const selectedNode = selectedPath ? (rowsByPath.get(selectedPath) ?? null) : null useFileExplorerKeys({ - containerRef: scrollRef, + containerRef: explorerShellRef, flatRows, inlineInput, selectedNode, @@ -277,142 +281,90 @@ function FileExplorerInner(): React.JSX.Element { return ( <> - { - stopDragEdgeScroll() - setDropTargetDir(null) - }} - onContextMenu={(e) => { - const target = e.target as HTMLElement - if (target.closest('[data-slot="context-menu-trigger"]')) { - return - } - e.preventDefault() - setBgMenuPoint({ x: e.clientX, y: e.clientY }) - setBgMenuOpen(true) - }} - > - {isLoading && ( -
- -
- )} - {hasError && ( -
- Could not load files for this worktree: {rootError} -
- )} - {isEmpty && ( -
- No files in this worktree -
- )} - {showTree && ( -
- {virtualizer.getVirtualItems().map((vItem) => { - const isInlineRow = inlineInputIndex >= 0 && vItem.index === inlineInputIndex - const rowIndex = - !isInlineRow && inlineInputIndex >= 0 && vItem.index > inlineInputIndex - ? vItem.index - 1 - : vItem.index - const node = isInlineRow ? null : flatRows[rowIndex] - if (!isInlineRow && !node) { - return null - } - - const showInline = - isInlineRow || - (inlineInput?.type === 'rename' && node && inlineInput.existingPath === node.path) - const inlineDepth = isInlineRow ? inlineInput!.depth : (node?.depth ?? 0) - - if (showInline) { - return ( -
- -
- ) - } - - // Safe: the isInlineRow/showInline guards above ensure node is non-null here - const n = node! - const normalizedRelativePath = normalizeRelativePath(n.relativePath) - const nodeStatus = n.isDirectory - ? (folderStatusByRelativePath.get(normalizedRelativePath) ?? null) - : (statusByRelativePath.get(normalizedRelativePath) ?? null) - - const rowParentDir = n.isDirectory ? n.path : dirname(n.path) - const sourceParentDir = dragSourcePath ? dirname(dragSourcePath) : null - const isInDropTarget = - (dropTargetDir != null && - dropTargetDir === rowParentDir && - dropTargetDir !== sourceParentDir) || - (nativeDropTargetDir != null && nativeDropTargetDir === rowParentDir) - return ( -
- handleClick(n)} - onDoubleClick={() => handleDoubleClick(n)} - onSelect={() => setSelectedPath(n.path)} - onStartNew={startNew} - onStartRename={startRename} - onDuplicate={handleDuplicate} - onRequestDelete={() => requestDelete(n)} - onMoveDrop={handleMoveDrop} - onDragTargetChange={setDropTargetDir} - onDragSourceChange={setDragSourcePath} - onDragExpandDir={handleDragExpandDir} - onNativeDragTargetChange={setNativeDropTargetDir} - onNativeDragExpandDir={handleNativeDragExpandDir} - /> -
- ) - })} -
- )} -
+
+ { + stopDragEdgeScroll() + setDropTargetDir(null) + }} + onContextMenu={(e) => { + const target = e.target as HTMLElement + if (target.closest('[data-slot="context-menu-trigger"]')) { + return + } + e.preventDefault() + setBgMenuPoint({ x: e.clientX, y: e.clientY }) + setBgMenuOpen(true) + }} + > + {isLoading && ( +
+ +
+ )} + {hasError && ( +
+ Could not load files for this worktree: {rootError} +
+ )} + {isEmpty && ( +
+ No files in this worktree +
+ )} + {showTree && ( + + )} +
+
+ inlineInputIndex: number + flatRows: TreeNode[] + inlineInput: InlineInput | null + handleInlineSubmit: (value: string) => void + dismissInlineInput: () => void + folderStatusByRelativePath: Map + statusByRelativePath: Map + expanded: Set + dirCache: Record + selectedPath: string | null + activeFileId: string | null + flashingPath: string | null + deleteShortcutLabel: string + onClick: (node: TreeNode) => void + onDoubleClick: (node: TreeNode) => void + onSelectPath: (path: string) => void + onStartNew: (type: 'file' | 'folder', parentPath: string, depth: number) => void + onStartRename: (node: TreeNode) => void + onDuplicate: (node: TreeNode) => void + onRequestDelete: (node: TreeNode) => void + onMoveDrop: (sourcePath: string, destDir: string) => void + onDragTargetChange: (dir: string | null) => void + onDragSourceChange: (path: string | null) => void + onDragExpandDir: (dirPath: string) => void + onNativeDragTargetChange: (dir: string | null) => void + onNativeDragExpandDir: (dirPath: string) => void + dropTargetDir: string | null + dragSourcePath: string | null + nativeDropTargetDir: string | null +} + +export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): React.JSX.Element { + const { + virtualizer, + inlineInputIndex, + flatRows, + inlineInput, + handleInlineSubmit, + dismissInlineInput, + folderStatusByRelativePath, + statusByRelativePath, + expanded, + dirCache, + selectedPath, + activeFileId, + flashingPath, + deleteShortcutLabel, + onClick, + onDoubleClick, + onSelectPath, + onStartNew, + onStartRename, + onDuplicate, + onRequestDelete, + onMoveDrop, + onDragTargetChange, + onDragSourceChange, + onDragExpandDir, + onNativeDragTargetChange, + onNativeDragExpandDir, + dropTargetDir, + dragSourcePath, + nativeDropTargetDir + } = props + + return ( +
+ {virtualizer.getVirtualItems().map((vItem) => { + const isInlineRow = inlineInputIndex >= 0 && vItem.index === inlineInputIndex + const rowIndex = + !isInlineRow && inlineInputIndex >= 0 && vItem.index > inlineInputIndex + ? vItem.index - 1 + : vItem.index + const node = isInlineRow ? null : flatRows[rowIndex] + if (!isInlineRow && !node) { + return null + } + + const showInline = + isInlineRow || + (inlineInput?.type === 'rename' && node && inlineInput.existingPath === node.path) + const inlineDepth = isInlineRow ? inlineInput!.depth : (node?.depth ?? 0) + + if (showInline) { + return ( +
+ +
+ ) + } + + const n = node! + const normalizedRelativePath = normalizeRelativePath(n.relativePath) + const nodeStatus = n.isDirectory + ? (folderStatusByRelativePath.get(normalizedRelativePath) ?? null) + : (statusByRelativePath.get(normalizedRelativePath) ?? null) + + const rowParentDir = n.isDirectory ? n.path : dirname(n.path) + const sourceParentDir = dragSourcePath ? dirname(dragSourcePath) : null + const isInDropTarget = + (dropTargetDir != null && + dropTargetDir === rowParentDir && + dropTargetDir !== sourceParentDir) || + (nativeDropTargetDir != null && nativeDropTargetDir === rowParentDir) + return ( +
+ onClick(n)} + onDoubleClick={() => onDoubleClick(n)} + onSelect={() => onSelectPath(n.path)} + onStartNew={onStartNew} + onStartRename={onStartRename} + onDuplicate={onDuplicate} + onRequestDelete={() => onRequestDelete(n)} + onMoveDrop={onMoveDrop} + onDragTargetChange={onDragTargetChange} + onDragSourceChange={onDragSourceChange} + onDragExpandDir={onDragExpandDir} + onNativeDragTargetChange={onNativeDragTargetChange} + onNativeDragExpandDir={onNativeDragExpandDir} + /> +
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts b/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts new file mode 100644 index 000000000..be3c9bd79 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts @@ -0,0 +1,56 @@ +/** + * Linear undo/redo for file explorer mutations (delete, create, rename). + * Uses in-memory closures so each step carries the exact paths/content needed + * to reverse or replay the operation without relying on OS trash restore + * (which is not exposed in a portable way here). + */ +const MAX_STEPS = 50 + +type ExplorerOp = { + undo: () => Promise + redo: () => Promise +} + +const past: ExplorerOp[] = [] +const future: ExplorerOp[] = [] + +export function commitFileExplorerOp(op: ExplorerOp): void { + past.push(op) + if (past.length > MAX_STEPS) { + past.shift() + } + future.length = 0 +} + +export function clearFileExplorerUndoHistory(): void { + past.length = 0 + future.length = 0 +} + +export async function undoFileExplorer(): Promise { + const op = past.pop() + if (!op) { + return false + } + await op.undo() + future.push(op) + return true +} + +export async function redoFileExplorer(): Promise { + const op = future.pop() + if (!op) { + return false + } + await op.redo() + past.push(op) + return true +} + +export function fileExplorerHasUndo(): boolean { + return past.length > 0 +} + +export function fileExplorerHasRedo(): boolean { + return future.length > 0 +} diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 832a933c0..65269e577 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -7,6 +7,7 @@ import { getConnectionId } from '@/lib/connection-context' import { isPathEqualOrDescendant } from './file-explorer-paths' import type { PendingDelete, TreeNode } from './file-explorer-types' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' +import { commitFileExplorerOp } from './fileExplorerUndoRedo' type UseFileDeletionParams = { activeWorktreeId: string | null @@ -91,8 +92,42 @@ export function useFileDeletion({ await Promise.all(filesToClose.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const parentDir = dirname(node.path) + // Why: read file content before deleting so undo can restore it. + // We capture content first but only commit the undo entry after the + // delete succeeds — otherwise a failed delete would poison the stack. + let undoContent: string | undefined + if (!node.isDirectory) { + try { + const rf = await window.api.fs.readFile({ filePath: node.path, connectionId }) + if (!rf.isBinary) { + undoContent = rf.content + } + } catch { + // If we cannot read the file (race, permission), skip undo recording + // so a failed undo cannot restore stale content. + } + } + await window.api.fs.deletePath({ targetPath: node.path, connectionId }) + if (undoContent !== undefined) { + commitFileExplorerOp({ + undo: async () => { + await window.api.fs.writeFile({ + filePath: node.path, + content: undoContent, + connectionId + }) + await refreshDir(parentDir) + }, + redo: async () => { + await window.api.fs.deletePath({ targetPath: node.path, connectionId }) + await refreshDir(parentDir) + } + }) + } + for (const file of filesToClose) { closeFile(file.id) } @@ -150,7 +185,7 @@ export function useFileDeletion({ () => ({ pendingDelete, isDeleting, - deleteShortcutLabel: isMac ? '⌘⌫' : 'Del', + deleteShortcutLabel: isMac ? '⌘⌫ / Del' : 'Del', deleteActionLabel, deleteDescription: getDeleteDescription(pendingDelete, isWindows), requestDelete, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index 9f5e20fad..1c16d8443 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -6,7 +6,7 @@ import { basename, dirname, joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' import { getConnectionId } from '@/lib/connection-context' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' - +import { commitFileExplorerOp } from './fileExplorerUndoRedo' function extractIpcErrorMessage(err: unknown, fallback: string): string { if (!(err instanceof Error)) { return fallback @@ -66,11 +66,6 @@ export function useFileExplorerDragDrop({ scrollRef }: UseFileExplorerDragDropParams): UseFileExplorerDragDropResult { const openFiles = useAppStore((s) => s.openFiles) - const editorDrafts = useAppStore((s) => s.editorDrafts) - const closeFile = useAppStore((s) => s.closeFile) - const openFile = useAppStore((s) => s.openFile) - const setEditorDraft = useAppStore((s) => s.setEditorDraft) - const markFileDirty = useAppStore((s) => s.markFileDirty) const [isRootDragOver, setIsRootDragOver] = useState(false) const rootDragCounterRef = useRef(0) @@ -129,8 +124,8 @@ export function useFileExplorerDragDrop({ if (!worktreePath || !activeWorktreeId) { return } - const fileName = basename(sourcePath) - const sourceDir = dirname(sourcePath) + const fileName = basename(sourcePath), + sourceDir = dirname(sourcePath) setDropTargetDir(null) @@ -146,6 +141,48 @@ export function useFileExplorerDragDrop({ } const newPath = joinPath(destDir, fileName) + const remapOpenTabsForMovedPath = (fromPath: string, toPath: string): void => { + const state = useAppStore.getState() + const filesToMove = state.openFiles.filter((file) => { + if (file.filePath === fromPath) { + return true + } + return ( + file.filePath.startsWith(`${fromPath}/`) || file.filePath.startsWith(`${fromPath}\\`) + ) + }) + // Why: OpenFile.id === absolute path, so moves must close/reopen tabs to migrate + // draft/dirty metadata to the new key (forward move and undo/redo parity). + for (const file of filesToMove) { + const oldFilePath = file.filePath + const suffix = oldFilePath.slice(fromPath.length) + const updatedPath = toPath + suffix + const updatedRelative = updatedPath.slice(worktreePath.length + 1) + const draft = state.editorDrafts[file.id] + const wasDirty = file.isDirty + + state.closeFile(oldFilePath) + + if (file.mode !== 'edit') { + continue + } + + state.openFile({ + filePath: updatedPath, + relativePath: updatedRelative, + worktreeId: file.worktreeId, + language: detectLanguage(basename(updatedPath)), + mode: 'edit' + }) + + if (draft !== undefined) { + state.setEditorDraft(updatedPath, draft) + } + if (wasDirty) { + state.markFileDirty(updatedPath, true) + } + } + } const run = async (): Promise => { const filesToMove = openFiles.filter((file) => { @@ -166,74 +203,29 @@ export function useFileExplorerDragDrop({ try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId }) + + commitFileExplorerOp({ + undo: async () => { + await window.api.fs.rename({ oldPath: newPath, newPath: sourcePath, connectionId }) + await Promise.all([refreshDir(destDir), refreshDir(sourceDir)]) + remapOpenTabsForMovedPath(newPath, sourcePath) + }, + redo: async () => { + await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId }) + await Promise.all([refreshDir(sourceDir), refreshDir(destDir)]) + remapOpenTabsForMovedPath(sourcePath, newPath) + } + }) } catch (err) { toast.error(extractIpcErrorMessage(err, `Failed to move '${fileName}'.`)) return } - await Promise.all([refreshDir(sourceDir), refreshDir(destDir)]) - - // Update any open editor tabs whose paths were under the moved item. - // Since OpenFile.id === filePath, we close the old tab and reopen at - // the new path so all derived state (relativePath, language) stays correct. - for (const file of filesToMove) { - let oldFilePath: string | null = null - if (file.filePath === sourcePath) { - oldFilePath = sourcePath - } else if ( - file.filePath.startsWith(`${sourcePath}/`) || - file.filePath.startsWith(`${sourcePath}\\`) - ) { - oldFilePath = file.filePath - } - if (!oldFilePath) { - continue - } - - const suffix = oldFilePath.slice(sourcePath.length) - const updatedPath = newPath + suffix - const updatedRelative = updatedPath.slice(worktreePath.length + 1) - const draft = editorDrafts[file.id] - const wasDirty = file.isDirty - - closeFile(oldFilePath) - - if (file.mode !== 'edit') { - // Why: diff/conflict tabs encode extra git state in their ids. A - // filesystem move invalidates that state, so closing them is safer - // than silently reopening the path as a normal edit tab. - continue - } - - openFile({ - filePath: updatedPath, - relativePath: updatedRelative, - worktreeId: file.worktreeId, - language: detectLanguage(basename(updatedPath)), - mode: 'edit' - }) - - if (draft !== undefined) { - setEditorDraft(updatedPath, draft) - } - if (wasDirty) { - markFileDirty(updatedPath, true) - } - } + remapOpenTabsForMovedPath(sourcePath, newPath) } void run() }, - [ - worktreePath, - activeWorktreeId, - closeFile, - editorDrafts, - markFileDirty, - openFile, - openFiles, - refreshDir, - setEditorDraft - ] + [worktreePath, activeWorktreeId, openFiles, refreshDir] ) const clearNativeDragState = useCallback(() => { @@ -340,7 +332,6 @@ export function useFileExplorerDragDrop({ }, [activeWorktreeId] ) - return { handleMoveDrop, handleDragExpandDir, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index 9742f92fc..9b431f2c2 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -3,10 +3,12 @@ import type React from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' -import { dirname, joinPath } from '@/lib/path' +import { basename, dirname, joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' import type { InlineInput } from './FileExplorerRow' import type { TreeNode } from './file-explorer-types' +import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' +import { commitFileExplorerOp } from './fileExplorerUndoRedo' /** * Electron's ipcRenderer.invoke wraps errors as: @@ -120,15 +122,83 @@ export function useFileExplorerInlineInput({ return } const run = async (): Promise => { + const remapOpenTabsForRenamedPath = (fromPath: string, toPath: string): void => { + const state = useAppStore.getState() + const filesToMove = state.openFiles.filter((file) => { + if (file.filePath === fromPath) { + return true + } + return ( + file.filePath.startsWith(`${fromPath}/`) || file.filePath.startsWith(`${fromPath}\\`) + ) + }) + + for (const file of filesToMove) { + const oldFilePath = file.filePath + const suffix = oldFilePath.slice(fromPath.length) + const updatedPath = toPath + suffix + const updatedRelative = updatedPath.slice(worktreePath.length + 1) + const draft = state.editorDrafts[file.id] + const wasDirty = file.isDirty + + state.closeFile(oldFilePath) + if (file.mode !== 'edit') { + continue + } + + state.openFile({ + filePath: updatedPath, + relativePath: updatedRelative, + worktreeId: file.worktreeId, + language: detectLanguage(basename(updatedPath)), + mode: 'edit' + }) + + if (draft !== undefined) { + state.setEditorDraft(updatedPath, draft) + } + if (wasDirty) { + state.markFileDirty(updatedPath, true) + } + } + } + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined if (inlineInput.type === 'rename' && inlineInput.existingPath) { const parentDir = dirname(inlineInput.existingPath) + const oldPath = inlineInput.existingPath + const newPath = joinPath(parentDir, name) + // Why: a rename changes the file's path. Let any in-flight autosave + // finish first so a trailing write to the old path cannot recreate it. + const state = useAppStore.getState() + const filesToQuiesce = state.openFiles.filter( + (file) => + file.filePath === oldPath || + file.filePath.startsWith(`${oldPath}/`) || + file.filePath.startsWith(`${oldPath}\\`) + ) + await Promise.all( + filesToQuiesce.map((file) => requestEditorSaveQuiesce({ fileId: file.id })) + ) try { await window.api.fs.rename({ - oldPath: inlineInput.existingPath, - newPath: joinPath(parentDir, name), + oldPath, + newPath, connectionId }) + remapOpenTabsForRenamedPath(oldPath, newPath) + commitFileExplorerOp({ + undo: async () => { + await window.api.fs.rename({ oldPath: newPath, newPath: oldPath, connectionId }) + await refreshDir(parentDir) + remapOpenTabsForRenamedPath(newPath, oldPath) + }, + redo: async () => { + await window.api.fs.rename({ oldPath: oldPath, newPath: newPath, connectionId }) + await refreshDir(parentDir) + remapOpenTabsForRenamedPath(oldPath, newPath) + } + }) } catch (err) { toast.error( extractIpcErrorMessage(err, `Failed to rename '${inlineInput.existingName}'.`) @@ -141,6 +211,30 @@ export function useFileExplorerInlineInput({ await (inlineInput.type === 'folder' ? window.api.fs.createDir({ dirPath: fullPath, connectionId }) : window.api.fs.createFile({ filePath: fullPath, connectionId })) + const parentForRefresh = inlineInput.parentPath + if (inlineInput.type === 'folder') { + commitFileExplorerOp({ + undo: async () => { + await window.api.fs.deletePath({ targetPath: fullPath, connectionId }) + await refreshDir(parentForRefresh) + }, + redo: async () => { + await window.api.fs.createDir({ dirPath: fullPath, connectionId }) + await refreshDir(parentForRefresh) + } + }) + } else { + commitFileExplorerOp({ + undo: async () => { + await window.api.fs.deletePath({ targetPath: fullPath, connectionId }) + await refreshDir(parentForRefresh) + }, + redo: async () => { + await window.api.fs.createFile({ filePath: fullPath, connectionId }) + await refreshDir(parentForRefresh) + } + }) + } await refreshDir(inlineInput.parentPath) if (inlineInput.type === 'file') { openFile({ diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts index 871f27c8a..29d89e388 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts @@ -1,11 +1,39 @@ import { useEffect, useRef } from 'react' import type React from 'react' +import { toast } from 'sonner' import { useAppStore } from '@/store' import type { InlineInput } from './FileExplorerRow' import type { TreeNode } from './file-explorer-types' +import { + fileExplorerHasRedo, + fileExplorerHasUndo, + redoFileExplorer, + undoFileExplorer +} from './fileExplorerUndoRedo' const isMac = navigator.userAgent.includes('Mac') +function isCmdZRedo(e: KeyboardEvent): boolean { + const mod = isMac ? e.metaKey : e.ctrlKey + if (!mod || e.altKey) { + return false + } + if (isMac) { + return e.code === 'KeyZ' && e.shiftKey + } + // Windows/Linux: Ctrl+Shift+Z or Ctrl+Y + return (e.code === 'KeyZ' && e.shiftKey) || (e.code === 'KeyY' && !e.shiftKey) +} + +function isCmdZUndo(e: KeyboardEvent): boolean { + const mod = isMac ? e.metaKey : e.ctrlKey + if (!mod || e.altKey || e.shiftKey) { + return false + } + // Prefer code (layout-independent); fall back to key for edge IME/layout cases. + return e.code === 'KeyZ' || e.key.toLowerCase() === 'z' +} + /** * Keyboard shortcuts for the file explorer. * @@ -52,7 +80,17 @@ export function useFileExplorerKeys(opts: { const focusInExplorer = (): boolean => { const el = document.activeElement - return !!el && !!opts.containerRef.current?.contains(el) + if (!el || !opts.containerRef.current) { + return false + } + if (opts.containerRef.current.contains(el)) { + return true + } + // Fallback: Radix portaled nodes or timing quirks — shell is marked explicitly. + return ( + el instanceof Element && + el.closest('[data-orca-explorer-shell]') === opts.containerRef.current + ) } const onKeyDown = (e: KeyboardEvent): void => { @@ -63,9 +101,24 @@ export function useFileExplorerKeys(opts: { return } + // ── Undo/redo for explorer mutations (only when this panel should own the chord). + // Why: require focus inside the explorer shell (includes the scrollbar, not just + // the viewport — Radix renders the scrollbar as a sibling of the viewport). + const inExplorer = focusInExplorer() + const wantUndo = isCmdZUndo(e) && fileExplorerHasUndo() + const wantRedo = isCmdZRedo(e) && fileExplorerHasRedo() + if (inExplorer && (wantUndo || wantRedo)) { + e.preventDefault() + const run = wantRedo ? redoFileExplorer() : undoFileExplorer() + void run.catch((err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Operation failed') + }) + return + } + // ── Bare-key shortcuts: only when explorer has focus ── if (focusInExplorer()) { - const node = findFocusedNode() + const node = findFocusedNode() ?? selectedNodeRef.current if (node) { // Enter — Rename if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { @@ -73,9 +126,10 @@ export function useFileExplorerKeys(opts: { startRenameRef.current(node) return } - // ⌘⌫ (Mac) / Delete (Win) — Delete + // ⌘⌫ (Mac) / Delete (Win) / Forward Delete (Mac full keyboard) — Delete if ( (isMac && e.key === 'Backspace' && e.metaKey) || + (isMac && e.key === 'Delete' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) || (!isMac && e.key === 'Delete' && !e.metaKey && !e.ctrlKey) ) { e.preventDefault() @@ -111,7 +165,7 @@ export function useFileExplorerKeys(opts: { } } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) + window.addEventListener('keydown', onKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) }, [rightSidebarOpen, rightSidebarTab, opts.containerRef]) } diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index 641d748d4..0103dceac 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -124,6 +124,11 @@ const SHORTCUT_GROUP_DEFINITIONS: ShortcutGroupDefinition[] = [ searchKeywords: ['shortcut', 'close', 'tab', 'pane'], keys: ({ mod }) => [mod, 'W'] }, + { + action: 'Reopen closed tab', + searchKeywords: ['shortcut', 'tab', 'reopen', 'restore', 'closed'], + keys: ({ mod, shift }) => [mod, shift, 'T'] + }, { action: 'Next tab', searchKeywords: ['shortcut', 'tab', 'next'], diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 28345bfd4..ed83c9431 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable' import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react' import type { @@ -185,6 +185,9 @@ function TabBarInner({ // Horizontal wheel scrolling for the tab strip const tabStripRef = useRef(null) + const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null) + const stickToEndRef = useRef(false) + useEffect(() => { const el = tabStripRef.current if (!el) { @@ -200,6 +203,84 @@ function TabBarInner({ return () => el.removeEventListener('wheel', onWheel) }, []) + useEffect(() => { + const el = tabStripRef.current + if (!el) { + return + } + const isAtEnd = (): boolean => { + const max = Math.max(0, el.scrollWidth - el.clientWidth) + return el.scrollLeft >= max - 2 + } + const onScroll = (): void => { + // Only keep sticking while the user hasn't intentionally scrolled away. + stickToEndRef.current = isAtEnd() + } + el.addEventListener('scroll', onScroll, { passive: true }) + // Seed based on initial position. + onScroll() + + const ro = new ResizeObserver(() => { + // If the user is pinned to the right edge, keep it pinned even as tab + // labels (e.g. \"Terminal 5\" → branch name) expand and change scrollWidth. + if (!stickToEndRef.current) { + return + } + el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) + }) + ro.observe(el) + + return () => { + el.removeEventListener('scroll', onScroll) + ro.disconnect() + } + }, []) + + // Why: new and reopened tabs are appended to the right; without this the strip + // keeps its scroll offset and the active tab can sit off-screen until the user + // drags the tab bar horizontally. + useLayoutEffect(() => { + const strip = tabStripRef.current + const len = orderedItems.length + const prev = prevStripLenRef.current + if (!strip) { + prevStripLenRef.current = { worktreeId, len } + return + } + if (!prev || prev.worktreeId !== worktreeId) { + prevStripLenRef.current = { worktreeId, len } + return + } + // If the user is pinned to the right edge, keep the close button visible + // even when tab labels change length (e.g. "Terminal 5" → branch name). + // Why: label changes don't necessarily change the strip element's own size, + // so ResizeObserver won't fire; this effect runs on rerenders instead. + if (stickToEndRef.current) { + const scrollToEnd = (): void => { + const el = tabStripRef.current + if (!el) { + return + } + el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) + } + scrollToEnd() + requestAnimationFrame(scrollToEnd) + } + if (len > prev.len) { + const scrollToEnd = (): void => { + const el = tabStripRef.current + if (!el) { + return + } + el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) + stickToEndRef.current = true + } + scrollToEnd() + requestAnimationFrame(scrollToEnd) + } + prevStripLenRef.current = { worktreeId, len } + }, [orderedItems, worktreeId]) + return (
& { viewportClassName?: string viewportRef?: React.Ref + /** Set e.g. -1 so the viewport can receive programmatic focus (explorer keyboard shortcuts after inline rename). */ + viewportTabIndex?: number }) { return ( { expect(clearTabPtyId).not.toHaveBeenCalledWith('tab-2') }) }) + +describe('useIpcEvents shortcut hint clearing', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + }) + + it('clears modifier hints for main-process-forwarded shortcuts', async () => { + const toggleLeftSidebarRef: { current: (() => void) | null } = { current: null } + const jumpToWorktreeRef: { current: ((index: number) => void) | null } = { current: null } + const toggleSidebar = vi.fn() + const dispatchEvent = vi.fn() + const activateAndRevealWorktree = vi.fn() + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + + vi.doMock('../store', () => ({ + useAppStore: { + getState: () => ({ + toggleSidebar, + toggleRightSidebar: vi.fn(), + activeModal: 'none', + closeModal: vi.fn(), + openModal: vi.fn(), + activeView: 'terminal', + activeWorktreeId: 'wt-1', + statusBarVisible: true, + setStatusBarVisible: vi.fn(), + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + setActiveView: vi.fn(), + setActiveRepo: vi.fn(), + setActiveWorktree: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + setIsFullScreen: vi.fn(), + updateBrowserPageState: vi.fn(), + createBrowserTab: vi.fn(), + browserDefaultUrl: 'about:blank', + createTab: vi.fn(), + setActiveTabType: vi.fn(), + tabsByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + setTabBarOrder: vi.fn(), + activeBrowserTabId: null, + closeBrowserTab: vi.fn(), + activeTabType: 'terminal', + editorFontZoomLevel: 0, + setUpdateStatus: vi.fn(), + setEditorFontZoomLevel: vi.fn(), + setRateLimitsFromPush: vi.fn(), + setSshConnectionState: vi.fn(), + setSshTargetLabels: vi.fn(), + enqueueSshCredentialRequest: vi.fn(), + removeSshCredentialRequest: vi.fn(), + clearTabPtyId: vi.fn(), + tabs: [], + settings: { terminalFontSize: 13 } + }) + } + })) + + vi.doMock('@/lib/ui-zoom', () => ({ + applyUIZoom: vi.fn() + })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree, + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ + getVisibleWorktreeIds: () => ['wt-1', 'wt-2'] + })) + vi.doMock('@/lib/editor-font-zoom', () => ({ + nextEditorFontZoomLevel: vi.fn(() => 0), + computeEditorFontSize: vi.fn(() => 13) + })) + vi.doMock('@/components/settings/SettingsConstants', () => ({ + zoomLevelToPercent: vi.fn(() => 100), + ZOOM_MIN: -3, + ZOOM_MAX: 3 + })) + vi.doMock('@/lib/zoom-events', () => ({ + dispatchZoomLevelChanged: vi.fn() + })) + + vi.stubGlobal('window', { + dispatchEvent, + api: { + repos: { onChanged: () => () => {} }, + worktrees: { onChanged: () => () => {} }, + ui: { + onOpenSettings: () => () => {}, + onToggleLeftSidebar: (listener: () => void) => { + toggleLeftSidebarRef.current = listener + return () => {} + }, + onToggleRightSidebar: () => () => {}, + onToggleWorktreePalette: () => () => {}, + onOpenQuickOpen: () => () => {}, + onJumpToWorktreeIndex: (listener: (index: number) => void) => { + jumpToWorktreeRef.current = listener + return () => {} + }, + onActivateWorktree: () => () => {}, + onNewBrowserTab: () => () => {}, + onNewTerminalTab: () => () => {}, + onCloseActiveTab: () => () => {}, + onSwitchTab: () => () => {}, + onToggleStatusBar: () => () => {}, + onFullscreenChanged: () => () => {}, + onTerminalZoom: () => () => {}, + getZoomLevel: () => 0, + set: vi.fn() + }, + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + browser: { + onGuestLoadFailed: () => () => {}, + onOpenLinkInOrcaTab: () => () => {} + }, + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), + onUpdate: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {} + } + } + }) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof toggleLeftSidebarRef.current !== 'function') { + throw new Error('Expected toggle-left-sidebar listener to be registered') + } + if (typeof jumpToWorktreeRef.current !== 'function') { + throw new Error('Expected jump-to-worktree listener to be registered') + } + + toggleLeftSidebarRef.current() + jumpToWorktreeRef.current(1) + + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'orca:clear-modifier-hints' }) + ) + expect(dispatchEvent).toHaveBeenCalledTimes(2) + expect(toggleSidebar).toHaveBeenCalledTimes(1) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2') + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 2602e1ca6..9996a8769 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -15,6 +15,7 @@ import { zoomLevelToPercent, ZOOM_MIN, ZOOM_MAX } from '@/components/settings/Se import { dispatchZoomLevelChanged } from '@/lib/zoom-events' import { resolveZoomTarget } from './resolve-zoom-target' import { handleSwitchTab } from './ipc-tab-switch' +import { dispatchClearModifierHints } from './useModifierHint' export { resolveZoomTarget } from './resolve-zoom-target' @@ -44,18 +45,21 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onToggleLeftSidebar(() => { + dispatchClearModifierHints() useAppStore.getState().toggleSidebar() }) ) unsubs.push( window.api.ui.onToggleRightSidebar(() => { + dispatchClearModifierHints() useAppStore.getState().toggleRightSidebar() }) ) unsubs.push( window.api.ui.onToggleWorktreePalette(() => { + dispatchClearModifierHints() const store = useAppStore.getState() if (store.activeModal === 'worktree-palette') { store.closeModal() @@ -67,6 +71,7 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onOpenQuickOpen(() => { + dispatchClearModifierHints() const store = useAppStore.getState() if (store.activeView === 'terminal' && store.activeWorktreeId !== null) { store.openModal('quick-open') @@ -76,6 +81,7 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onJumpToWorktreeIndex((index) => { + dispatchClearModifierHints() const store = useAppStore.getState() if (store.activeView !== 'terminal') { return diff --git a/src/renderer/src/hooks/useModifierHint.test.ts b/src/renderer/src/hooks/useModifierHint.test.ts new file mode 100644 index 000000000..446f02e4d --- /dev/null +++ b/src/renderer/src/hooks/useModifierHint.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('useModifierHint helpers', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + }) + + it('starts the timer only for the bare platform modifier', async () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { shouldStartModifierHintTimer } = await import('./useModifierHint') + + expect( + shouldStartModifierHintTimer({ + key: 'Meta', + altKey: false, + shiftKey: false, + ctrlKey: false, + metaKey: true, + repeat: false + } as KeyboardEvent) + ).toBe(true) + + expect( + shouldStartModifierHintTimer({ + key: 'Meta', + altKey: false, + shiftKey: true, + ctrlKey: false, + metaKey: true, + repeat: false + } as KeyboardEvent) + ).toBe(false) + + expect( + shouldStartModifierHintTimer({ + key: 'b', + altKey: false, + shiftKey: false, + ctrlKey: false, + metaKey: true, + repeat: false + } as KeyboardEvent) + ).toBe(false) + }) + + it('clears when the shortcut key is released while the modifier is still held', async () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { shouldClearModifierHintOnKeyUp } = await import('./useModifierHint') + + expect( + shouldClearModifierHintOnKeyUp({ + key: 'b', + ctrlKey: false, + metaKey: true + } as KeyboardEvent) + ).toBe(true) + + expect( + shouldClearModifierHintOnKeyUp({ + key: 'Meta', + ctrlKey: false, + metaKey: false + } as KeyboardEvent) + ).toBe(true) + + expect( + shouldClearModifierHintOnKeyUp({ + key: 'b', + ctrlKey: false, + metaKey: false + } as KeyboardEvent) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useModifierHint.ts b/src/renderer/src/hooks/useModifierHint.ts index cd966fac2..81508a1eb 100644 --- a/src/renderer/src/hooks/useModifierHint.ts +++ b/src/renderer/src/hooks/useModifierHint.ts @@ -1,7 +1,34 @@ import { useState, useEffect, useRef } from 'react' -const isMac = navigator.userAgent.includes('Mac') +const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') const MOD_KEY = isMac ? 'Meta' : 'Control' +export const CLEAR_MODIFIER_HINTS_EVENT = 'orca:clear-modifier-hints' + +type ModifierHintKeyboardEvent = Pick< + KeyboardEvent, + 'key' | 'altKey' | 'shiftKey' | 'ctrlKey' | 'metaKey' | 'repeat' +> + +export function dispatchClearModifierHints(): void { + window.dispatchEvent(new Event(CLEAR_MODIFIER_HINTS_EVENT)) +} + +export function shouldStartModifierHintTimer(e: ModifierHintKeyboardEvent): boolean { + return e.key === MOD_KEY && !e.altKey && !e.shiftKey && (isMac ? !e.ctrlKey : !e.metaKey) +} + +export function shouldClearModifierHintOnKeyUp( + e: Pick +): boolean { + if (e.key === MOD_KEY) { + return true + } + + // Why: some app-level shortcuts are intercepted outside the renderer's + // normal keydown path, so the combo key's keyup can be our first signal that + // a completed Cmd/Ctrl chord is no longer a "show hints" gesture. + return isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey +} /** * Tracks whether the user is holding the platform modifier key (Cmd on Mac, @@ -45,7 +72,7 @@ export function useModifierHint(enabled: boolean = true): { showHints: boolean } // (e.g. Ctrl+Cmd+Q to lock screen); on non-Mac, Meta+Ctrl is similarly not // an intentional hint request. Exclude the other platform modifier to avoid // false-positive hint activation during these combos. - if (e.key === MOD_KEY && !e.altKey && !e.shiftKey && (isMac ? !e.ctrlKey : !e.metaKey)) { + if (shouldStartModifierHintTimer(e)) { if (!timerRef.current) { timerRef.current = setTimeout(() => setShowHints(true), 750) } @@ -59,13 +86,14 @@ export function useModifierHint(enabled: boolean = true): { showHints: boolean } } const onKeyUp = (e: KeyboardEvent): void => { - if (e.key === MOD_KEY) { + if (shouldClearModifierHintOnKeyUp(e)) { clear() } } window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) + window.addEventListener(CLEAR_MODIFIER_HINTS_EVENT, clear) // Why blur: if the user Cmd+Tabs away, the keyup event may never fire // inside this window, leaving hints stuck in the visible state. window.addEventListener('blur', clear) @@ -74,6 +102,7 @@ export function useModifierHint(enabled: boolean = true): { showHints: boolean } clear() window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) + window.removeEventListener(CLEAR_MODIFIER_HINTS_EVENT, clear) window.removeEventListener('blur', clear) } }, [enabled]) diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index 94277b6d6..3d94a2577 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -52,4 +52,62 @@ describe('browser slice', () => { expect(store.getState().browserTabsByWorktree[worktreeId]).toHaveLength(1) expect(store.getState().recentlyClosedBrowserTabsByWorktree[worktreeId]).toHaveLength(0) }) + + it('reopens a multi-page workspace without duplicating the active URL (page order ≠ active first)', () => { + const store = createTestStore() + const worktreeId = 'repo1::/tmp/wt-1' + seedStore(store, { + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabType: 'browser', + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/tmp/wt-1' + }) + ] + }, + groupsByWorktree: { + [worktreeId]: [ + makeTabGroup({ + id: 'group-1', + worktreeId, + activeTabId: null, + tabOrder: [] + }) + ] + }, + activeGroupIdByWorktree: { + [worktreeId]: 'group-1' + }, + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + const ws = store.getState().createBrowserTab(worktreeId, 'https://example.com/a', { + title: 'A' + }) + store + .getState() + .createBrowserPage(ws.id, 'https://example.com/b', { title: 'B', activate: true }) + const beforeClose = store.getState().browserPagesByWorkspace[ws.id] ?? [] + expect(beforeClose).toHaveLength(2) + expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( + 'https://example.com/b' + ) + + store.getState().closeBrowserTab(ws.id) + const reopened = store.getState().reopenClosedBrowserTab(worktreeId) + expect(reopened).not.toBeNull() + const pages = store.getState().browserPagesByWorkspace[reopened!.id] ?? [] + expect(pages).toHaveLength(2) + const urls = new Set(pages.map((p) => p.url)) + expect(urls.has('https://example.com/a')).toBe(true) + expect(urls.has('https://example.com/b')).toBe(true) + expect(store.getState().browserTabsByWorktree[worktreeId]?.[0]?.url).toBe( + 'https://example.com/b' + ) + }) }) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index e44026a23..1ba42bf68 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -452,44 +452,69 @@ export const createBrowserSlice: StateCreator = }, reopenClosedBrowserTab: (worktreeId) => { - const recentlyClosed = get().recentlyClosedBrowserTabsByWorktree[worktreeId] ?? [] - const entryToRestore = recentlyClosed[0] + // Why: read and pop atomically inside set() to prevent a TOCTOU race + // where two rapid Cmd+Shift+T presses both restore the same entry. + let entryToRestore: ClosedBrowserWorkspaceSnapshot | undefined + + set((s) => { + const recentlyClosed = s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? [] + entryToRestore = recentlyClosed[0] + if (!entryToRestore) { + return s + } + return { + recentlyClosedBrowserTabsByWorktree: { + ...s.recentlyClosedBrowserTabsByWorktree, + [worktreeId]: recentlyClosed.slice(1) + } + } + }) + if (!entryToRestore) { return null } - set((s) => ({ - recentlyClosedBrowserTabsByWorktree: { - ...s.recentlyClosedBrowserTabsByWorktree, - [worktreeId]: (s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? []).slice(1) - } - })) + const snap = entryToRestore.workspace + const pages = entryToRestore.pages + const sessionProfileId = snap.sessionProfileId ?? null - const restored = get().createBrowserTab(worktreeId, entryToRestore.workspace.url, { - title: entryToRestore.workspace.title, - activate: true + if (pages.length === 0) { + const restored = get().createBrowserTab(worktreeId, snap.url, { + title: snap.title, + activate: true, + sessionProfileId + }) + return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + } + + // Why: create the tab with the first page, then append the rest in + // original order so multi-page workspaces preserve their page sequence. + const [firstPage, ...restPages] = pages + const restored = get().createBrowserTab(worktreeId, firstPage.url, { + title: firstPage.title, + activate: true, + sessionProfileId }) - const restoredFirstPageId = restored.activePageId - const remainingPages = entryToRestore.pages.slice(1) - for (const page of remainingPages) { - get().createBrowserPage(restored.id, page.url, { + + for (const p of restPages) { + get().createBrowserPage(restored.id, p.url, { activate: false, - title: page.title + title: p.title }) } - if (restoredFirstPageId) { + + // Activate the originally-active page if it wasn't the first one + const activePageId = snap.activePageId + if (activePageId) { const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] - const activeReplacement = restoredPages.find( - (page) => page.url === entryToRestore.workspace.url + const targetPage = restoredPages.find( + (p) => p.url === pages.find((orig) => orig.id === activePageId)?.url ) - const targetActivePage = - restoredPages.find((page) => page.title === entryToRestore.workspace.title) ?? - activeReplacement ?? - restoredPages[0] - if (targetActivePage) { - get().setActiveBrowserPage(restored.id, targetActivePage.id) + if (targetPage && targetPage.id !== restoredPages[0]?.id) { + get().setActiveBrowserPage(restored.id, targetPage.id) } } + return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null }, @@ -659,19 +684,28 @@ export const createBrowserSlice: StateCreator = }, reopenClosedBrowserPage: (workspaceId) => { - const recentlyClosed = get().recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? [] - const pageToRestore = recentlyClosed[0] + // Why: read and pop atomically inside set() to prevent a TOCTOU race + // where two rapid Cmd+Shift+T presses both restore the same page. + let pageToRestore: BrowserPage | undefined + + set((s) => { + const recentlyClosed = s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? [] + pageToRestore = recentlyClosed[0] + if (!pageToRestore) { + return s + } + return { + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspaceId]: recentlyClosed.slice(1) + } + } + }) + if (!pageToRestore) { return null } - set((s) => ({ - recentlyClosedBrowserPagesByWorkspace: { - ...s.recentlyClosedBrowserPagesByWorkspace, - [workspaceId]: (s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? []).slice(1) - } - })) - return get().createBrowserPage(workspaceId, pageToRestore.url, { title: pageToRestore.title, activate: true diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 8a3ffc353..31f75ef54 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -106,6 +106,11 @@ export type ActivityBarPosition = 'top' | 'side' export type MarkdownViewMode = 'source' | 'rich' +/** Enough state to restore a tab via `openFile` after `closeFile` (id is always filePath). */ +export type ClosedEditorTabSnapshot = Omit + +const MAX_RECENT_CLOSED_EDITOR_TABS = 10 + export type EditorSlice = { // Why: #300 originally kept EditorPanel mounted while hidden so unsaved // drafts and autosave timers could survive tab switches. Drafts live in the @@ -157,6 +162,9 @@ export type EditorSlice = { pinFile: (fileId: string, tabId?: string) => void closeFile: (fileId: string) => void closeAllFiles: () => void + /** Most recently closed editor tabs per worktree (for Cmd/Ctrl+Shift+T). */ + recentlyClosedEditorTabsByWorktree: Record + reopenClosedEditorTab: (worktreeId: string) => boolean setActiveFile: (fileId: string) => void reorderFiles: (fileIds: string[]) => void markFileDirty: (fileId: string, dirty: boolean) => void @@ -368,6 +376,7 @@ export const createEditorSlice: StateCreator = (s activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, activeTabType: 'terminal', + recentlyClosedEditorTabsByWorktree: {}, setActiveTabType: (type) => set((s) => { const worktreeId = s.activeWorktreeId @@ -652,6 +661,20 @@ export const createEditorSlice: StateCreator = (s } : s.tabBarOrderByWorktree + let nextRecentlyClosed = s.recentlyClosedEditorTabsByWorktree + const wtRecent = closedFile?.worktreeId + if (closedFile && wtRecent) { + const { id: _id, isDirty: _dirty, ...snap } = closedFile + const stack = s.recentlyClosedEditorTabsByWorktree[wtRecent] ?? [] + nextRecentlyClosed = { + ...s.recentlyClosedEditorTabsByWorktree, + [wtRecent]: [snap as ClosedEditorTabSnapshot, ...stack].slice( + 0, + MAX_RECENT_CLOSED_EDITOR_TABS + ) + } + } + return { openFiles: newFiles, editorDrafts: newEditorDrafts, @@ -672,7 +695,8 @@ export const createEditorSlice: StateCreator = (s activeTabTypeByWorktree: newActiveTabTypeByWorktree, markdownViewMode: newMarkdownViewMode, tabBarOrderByWorktree: nextTabBarOrderByWorktree, - pendingEditorReveal: null + pendingEditorReveal: null, + recentlyClosedEditorTabsByWorktree: nextRecentlyClosed } }) @@ -696,6 +720,22 @@ export const createEditorSlice: StateCreator = (s } }, + reopenClosedEditorTab: (worktreeId) => { + const stack = get().recentlyClosedEditorTabsByWorktree[worktreeId] ?? [] + const next = stack[0] + if (!next) { + return false + } + set((s) => ({ + recentlyClosedEditorTabsByWorktree: { + ...s.recentlyClosedEditorTabsByWorktree, + [worktreeId]: (s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []).slice(1) + } + })) + get().openFile(next) + return true + }, + closeAllFiles: () => { const state = get() const activeWorktreeId = state.activeWorktreeId @@ -758,6 +798,16 @@ export const createEditorSlice: StateCreator = (s } : s.tabBarOrderByWorktree + const closingFiles = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId) + let nextRecentClosed = s.recentlyClosedEditorTabsByWorktree[activeWorktreeId] ?? [] + for (const f of [...closingFiles].reverse()) { + const { id: _id, isDirty: _dirty, ...snap } = f + nextRecentClosed = [snap as ClosedEditorTabSnapshot, ...nextRecentClosed].slice( + 0, + MAX_RECENT_CLOSED_EDITOR_TABS + ) + } + return { openFiles: newFiles, editorDrafts: newEditorDrafts, @@ -783,7 +833,11 @@ export const createEditorSlice: StateCreator = (s // editor mount. If the worktree closes all editor tabs before that // reveal is consumed, keeping it around would make a later reopen jump // to an old match unexpectedly. - pendingEditorReveal: null + pendingEditorReveal: null, + recentlyClosedEditorTabsByWorktree: { + ...s.recentlyClosedEditorTabsByWorktree, + [activeWorktreeId]: nextRecentClosed + } } }) for (const itemId of closingItemIds) {