feat: file explorer shortcuts + fix worktree switch shortcut hinting (#719)
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
parent
5c664fc0b9
commit
eb8901ce36
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue