feat: file explorer shortcuts + fix worktree switch shortcut hinting (#719)

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
Jingyue Wu 2026-04-16 21:40:34 -04:00 committed by GitHub
parent 5c664fc0b9
commit eb8901ce36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1147 additions and 263 deletions

View File

@ -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)

View File

@ -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 Codestyle).
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()

View File

@ -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<HTMLDivElement>(null)
/** Includes Radix scroll viewport + scrollbar (scrollbar is not a child of the viewport). */
const explorerShellRef = useRef<HTMLDivElement>(null)
const flashTimeoutRef = useRef<number | null>(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 (
<>
<ScrollArea
className={cn(
'h-full min-h-0',
isRootDragOver &&
!(dragSourcePath && dirname(dragSourcePath) === worktreePath) &&
'bg-border',
isNativeDragOver && !nativeDropTargetDir && 'bg-border'
)}
viewportRef={scrollRef}
viewportClassName="h-full min-h-0 py-2"
data-native-file-drop-target="file-explorer"
data-native-file-drop-dir={worktreePath}
onWheelCapture={handleWheelCapture}
onDragOver={rootDragHandlers.onDragOver}
onDragEnter={rootDragHandlers.onDragEnter}
onDragLeave={rootDragHandlers.onDragLeave}
onDrop={rootDragHandlers.onDrop}
onDragEnd={() => {
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 && (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
)}
{hasError && (
<div className="flex h-full items-center justify-center px-4 text-center text-[11px] text-muted-foreground">
Could not load files for this worktree: {rootError}
</div>
)}
{isEmpty && (
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground px-4 text-center">
No files in this worktree
</div>
)}
{showTree && (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{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 (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<InlineInputRow
depth={inlineDepth}
inlineInput={inlineInput!}
onSubmit={handleInlineSubmit}
onCancel={dismissInlineInput}
/>
</div>
)
}
// 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 (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className={cn('absolute left-0 right-0', isInDropTarget && 'bg-border')}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<FileExplorerRow
node={n}
isExpanded={expanded.has(n.path)}
isLoading={n.isDirectory && Boolean(dirCache[n.path]?.loading)}
isSelected={selectedPath === n.path || activeFileId === n.path}
isFlashing={flashingPath === n.path}
nodeStatus={nodeStatus}
statusColor={nodeStatus ? STATUS_COLORS[nodeStatus] : null}
deleteShortcutLabel={deleteShortcutLabel}
targetDir={n.isDirectory ? n.path : dirname(n.path)}
targetDepth={n.isDirectory ? n.depth + 1 : n.depth}
onClick={() => 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}
/>
</div>
)
})}
</div>
)}
</ScrollArea>
<div ref={explorerShellRef} data-orca-explorer-shell className="flex h-full min-h-0 flex-col">
<ScrollArea
className={cn(
'h-full min-h-0',
isRootDragOver &&
!(dragSourcePath && dirname(dragSourcePath) === worktreePath) &&
'bg-border',
isNativeDragOver && !nativeDropTargetDir && 'bg-border'
)}
viewportRef={scrollRef}
viewportTabIndex={-1}
viewportClassName="h-full min-h-0 py-2"
data-native-file-drop-target="file-explorer"
data-native-file-drop-dir={worktreePath}
onWheelCapture={handleWheelCapture}
onDragOver={rootDragHandlers.onDragOver}
onDragEnter={rootDragHandlers.onDragEnter}
onDragLeave={rootDragHandlers.onDragLeave}
onDrop={rootDragHandlers.onDrop}
onDragEnd={() => {
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 && (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
)}
{hasError && (
<div className="flex h-full items-center justify-center px-4 text-center text-[11px] text-muted-foreground">
Could not load files for this worktree: {rootError}
</div>
)}
{isEmpty && (
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground px-4 text-center">
No files in this worktree
</div>
)}
{showTree && (
<FileExplorerVirtualRows
virtualizer={virtualizer}
inlineInputIndex={inlineInputIndex}
flatRows={flatRows}
inlineInput={inlineInput}
handleInlineSubmit={handleInlineSubmit}
dismissInlineInput={dismissInlineInput}
folderStatusByRelativePath={folderStatusByRelativePath}
statusByRelativePath={statusByRelativePath}
expanded={expanded}
dirCache={dirCache}
selectedPath={selectedPath}
activeFileId={activeFileId}
flashingPath={flashingPath}
deleteShortcutLabel={deleteShortcutLabel}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onSelectPath={setSelectedPath}
onStartNew={startNew}
onStartRename={startRename}
onDuplicate={handleDuplicate}
onRequestDelete={requestDelete}
onMoveDrop={handleMoveDrop}
onDragTargetChange={setDropTargetDir}
onDragSourceChange={setDragSourcePath}
onDragExpandDir={handleDragExpandDir}
onNativeDragTargetChange={setNativeDropTargetDir}
onNativeDragExpandDir={handleNativeDragExpandDir}
dropTargetDir={dropTargetDir}
dragSourcePath={dragSourcePath}
nativeDropTargetDir={nativeDropTargetDir}
/>
)}
</ScrollArea>
</div>
<FileExplorerBackgroundMenu
open={bgMenuOpen}

View File

@ -0,0 +1,165 @@
import React from 'react'
import type { Virtualizer } from '@tanstack/react-virtual'
import { dirname, normalizeRelativePath } from '@/lib/path'
import { cn } from '@/lib/utils'
import type { GitFileStatus } from '../../../../shared/types'
import { FileExplorerRow, InlineInputRow, type InlineInput } from './FileExplorerRow'
import { STATUS_COLORS } from './status-display'
import type { DirCache, TreeNode } from './file-explorer-types'
type FileExplorerVirtualRowsProps = {
virtualizer: Virtualizer<HTMLDivElement, Element>
inlineInputIndex: number
flatRows: TreeNode[]
inlineInput: InlineInput | null
handleInlineSubmit: (value: string) => void
dismissInlineInput: () => void
folderStatusByRelativePath: Map<string, GitFileStatus | null>
statusByRelativePath: Map<string, GitFileStatus>
expanded: Set<string>
dirCache: Record<string, DirCache>
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 (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{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 (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<InlineInputRow
depth={inlineDepth}
inlineInput={inlineInput!}
onSubmit={handleInlineSubmit}
onCancel={dismissInlineInput}
/>
</div>
)
}
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 (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className={cn('absolute left-0 right-0', isInDropTarget && 'bg-border')}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<FileExplorerRow
node={n}
isExpanded={expanded.has(n.path)}
isLoading={n.isDirectory && Boolean(dirCache[n.path]?.loading)}
isSelected={selectedPath === n.path || activeFileId === n.path}
isFlashing={flashingPath === n.path}
nodeStatus={nodeStatus}
statusColor={nodeStatus ? STATUS_COLORS[nodeStatus] : null}
deleteShortcutLabel={deleteShortcutLabel}
targetDir={n.isDirectory ? n.path : dirname(n.path)}
targetDepth={n.isDirectory ? n.depth + 1 : n.depth}
onClick={() => 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}
/>
</div>
)
})}
</div>
)
}

View File

@ -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<void>
redo: () => Promise<void>
}
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<boolean> {
const op = past.pop()
if (!op) {
return false
}
await op.undo()
future.push(op)
return true
}
export async function redoFileExplorer(): Promise<boolean> {
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
}

View File

@ -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,

View File

@ -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<void> => {
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,

View File

@ -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<void> => {
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({

View File

@ -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])
}

View File

@ -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'],

View File

@ -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<HTMLDivElement>(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 (
<div
className="flex items-stretch h-full overflow-hidden flex-1 min-w-0"

View File

@ -7,11 +7,14 @@ function ScrollArea({
className,
viewportClassName,
viewportRef,
viewportTabIndex,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
viewportClassName?: string
viewportRef?: React.Ref<HTMLDivElement>
/** Set e.g. -1 so the viewport can receive programmatic focus (explorer keyboard shortcuts after inline rename). */
viewportTabIndex?: number
}) {
return (
<ScrollAreaPrimitive.Root
@ -21,6 +24,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
tabIndex={viewportTabIndex}
data-slot="scroll-area-viewport"
className={cn(
'size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1',

View File

@ -371,3 +371,172 @@ describe('useIpcEvents updater integration', () => {
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<typeof ReactModule>('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')
})
})

View File

@ -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

View File

@ -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)
})
})

View File

@ -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<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey'>
): 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])

View File

@ -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'
)
})
})

View File

@ -452,44 +452,69 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
},
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<AppState, [], [], BrowserSlice> =
},
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

View File

@ -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<OpenFile, 'id' | 'isDirty'>
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<string, ClosedEditorTabSnapshot[]>
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<AppState, [], [], EditorSlice> = (s
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
activeTabType: 'terminal',
recentlyClosedEditorTabsByWorktree: {},
setActiveTabType: (type) =>
set((s) => {
const worktreeId = s.activeWorktreeId
@ -652,6 +661,20 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
markdownViewMode: newMarkdownViewMode,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingEditorReveal: null
pendingEditorReveal: null,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed
}
})
@ -696,6 +720,22 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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) {