@@ -5,7 +5,7 @@ import type { ContextTarget } from './components'
|
||||
import { Editor, SplitView } from './editor'
|
||||
import type { Cursor, Mode, Selection } from './editor'
|
||||
import { Terminal, lid } from './terminals'
|
||||
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, PassPopup, SearchModal, Toasts } from './overlays'
|
||||
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
|
||||
import { ProjectLauncher } from './launcher'
|
||||
import type { Menu, Toast } from './overlays'
|
||||
import type { FileNode, GitStatus } from './types'
|
||||
@@ -79,7 +79,7 @@ export function App(): React.ReactElement {
|
||||
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
|
||||
const [cursor, setCursor] = useState<Cursor | null>(null)
|
||||
const [selection, setSelection] = useState<Selection | null>(null)
|
||||
const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | null>(null)
|
||||
const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | 'projects' | null>(null)
|
||||
const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection
|
||||
// Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator.
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
@@ -659,8 +659,8 @@ export function App(): React.ReactElement {
|
||||
const meta = e.metaKey || e.ctrlKey
|
||||
const ae = document.activeElement as HTMLElement | null
|
||||
const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')
|
||||
// The history navigator owns the keyboard while open (it listens in capture phase).
|
||||
if (overlay === 'history') return
|
||||
// The history / project navigators own the keyboard while open (capture phase).
|
||||
if (overlay === 'history' || overlay === 'projects') return
|
||||
// An open context menu owns the keyboard (arrows / ↵ / esc handled there).
|
||||
if (menu) return
|
||||
const inPanel = activePanel === 'git' || activePanel === 'tree'
|
||||
@@ -692,6 +692,9 @@ export function App(): React.ReactElement {
|
||||
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
|
||||
setOverlay('history')
|
||||
}
|
||||
// ⇧⌘O opens the recent-project history picker (⌘O — opening a new folder —
|
||||
// is the native File-menu accelerator, so the renderer is free to own ⇧⌘O).
|
||||
else if (meta && e.shiftKey && e.key.toLowerCase() === 'o') { e.preventDefault(); setOverlay('projects') }
|
||||
// ⌘F opens the unified search (it covers file names too), seeded with the
|
||||
// current selection when there is one.
|
||||
else if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
|
||||
@@ -847,6 +850,7 @@ export function App(): React.ReactElement {
|
||||
onCancel={() => setNewFolderPopup(null)} />}
|
||||
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />}
|
||||
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||
{overlay === 'projects' && <ProjectsModal recents={proj.recents} currentRoot={proj.root} onOpen={(p) => actions.openProjectPath(p)} onClose={() => setOverlay(null)} />}
|
||||
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
||||
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
|
||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Overlays: combined search (content + file names), context menu, toast, pass-popup */
|
||||
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useProject } from './project'
|
||||
import type { RecentProject } from './project'
|
||||
import { fuzzy } from './fuzzy'
|
||||
import { FileIcon, Icon } from './components'
|
||||
import type { OpenFile } from './components'
|
||||
@@ -347,6 +348,64 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
|
||||
)
|
||||
}
|
||||
|
||||
/* Project history picker (⇧⌘O). Same keyboard model as the launcher and the
|
||||
* recent-files navigator: ⌘↑/⌘↓ (or plain arrows) move, ↵ switches the current
|
||||
* window to that project, esc closes. The currently-open project is filtered
|
||||
* out — reopening it is a no-op. */
|
||||
export function ProjectsModal({ recents, currentRoot, onOpen, onClose }: {
|
||||
recents: RecentProject[]
|
||||
currentRoot: string | null
|
||||
onOpen: (path: string) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const list = useMemo(() => recents.filter((p) => p.path !== currentRoot), [recents, currentRoot])
|
||||
const [sel, setSel] = useState(0)
|
||||
const selRef = useRef(sel); selRef.current = sel
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, list.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); const p = list[selRef.current]; if (p) { onOpen(p.path); onClose() } }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [list, onOpen, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.reveal({ style: { color: 'var(--fg-3)' } })}
|
||||
<span className="hist-title">Open recent project</span>
|
||||
<span className="mode-chip">{list.length} project{list.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
||||
</div>
|
||||
<div className="hist-list" ref={listRef}>
|
||||
{list.length === 0 && <div className="pempty">No other recent projects</div>}
|
||||
{list.map((p, i) => (
|
||||
<div key={p.path} className={'hist-row' + (i === sel ? ' sel' : '')} title={p.path}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => { onOpen(p.path); onClose() }}>
|
||||
{Icon.reveal()}
|
||||
<div className="hist-txt">
|
||||
<span className="fn">{p.name}</span>
|
||||
<span className="fd">{p.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Keyboard-shortcuts reference (opened from the title-bar ? button). */
|
||||
const SHORTCUTS: { keys: string[]; label: string }[] = [
|
||||
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' },
|
||||
@@ -368,6 +427,7 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
|
||||
{ keys: ['⌘', 'W'], label: 'Close the current file' },
|
||||
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
|
||||
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
|
||||
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
|
||||
{ keys: ['Esc'], label: 'Close an overlay / split view' },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user