diff --git a/CLAUDE.md b/CLAUDE.md
index 4154bf04..3cb869bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -567,7 +567,8 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan:
Prism's writes are therefore: rename, bin,
duplicate, the editor's save, and the archive's member verbs (rename/delete inside a
zip, 2026-08-22). Anything further (move, new folder) is a fresh decision, not a
- natural next step - except MOVE, which was decided (2026-08-22, #70) and is reachable
+ natural next step - except NEW FOLDER, decided 2026-10-07 (#330, Ctrl+Shift+N, see THE
+ COMMON FILE KEYS), and MOVE, which was decided (2026-08-22, #70) and is reachable
ONLY by dragging: a row (or a whole multi-selection) dropped on a folder row moves there,
taken names asking cancel / keep both / replace. THE FOLDER DROPPED INTO becomes the marked
row (2026-08-31): what you dragged has left, so a mark on it points at nothing, and clearing
@@ -921,6 +922,34 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan:
collapsed and invisible), or every member of the archive folder you are in.
Behind the typing guard, so the search box, a rename, the editor and the
shell keep their own Ctrl+A.
+ **THE COMMON FILE KEYS, IN THE EXPLORER'S LIST AND THE TREE** (#330; owner, 2026-10-07:
+ "add common hotkeys to the explorer and project so that for example ctrl + A selects all";
+ the list and four decisions picked in the session). ONE mapping, `lib/listKeys.ts` (pure,
+ tested): Ctrl+A all; Ctrl+Shift+A and Esc clear (Esc only when something is marked and
+ nothing nearer owns it: `lib/nearerEscape`, a `data-owns-escape` closer or the peek); Shift+Up/Down/Home/End mark the run from the anchor (`rangeSelect`,
+ a run that shrinks back, Explorer's, unlike the Shift CLICK's merge); Ctrl+Up/Down/Home/End
+ move the keyboard's place and not the marks (the Explorer's `cursor`, the tree's own
+ cursor), Ctrl+Space marks or unmarks it; Ctrl+Shift+N NEW FOLDER (the owner's yes to the
+ fresh decision above: main names it "New folder", "(2)"..., checks the place, never inside
+ a zip; then the rename; undo kind `mkdir` bins it ONLY while empty, and the rename that
+ follows folds into it, `rememberRename`, so one Ctrl+Z takes it away); Ctrl+D and Shift+Del
+ ARE Delete (OWNER: Shift+Del is NOT permanent, nothing changed the "nothing is destroyed"
+ rule; inert inside a zip; the MARKS decide, so a row the Ctrl keys or Esc left unlit is
+ never what a delete key takes, the Explorer's rule in the tree too); Alt+Up the tree's parent row (Alt+Left/Right claimed, nothing;
+ App leaves Alt+Up/Down to the lists, or it walks and OPENS the row above first);
+ type-to-jump in the tree too (OWNER: taken only on a match, else the letter reaches the
+ viewer; LETTERS only, `isJumpKey`, since a digit is the player's seek and a jump opens); F3 the folder search or the tree filter (OWNER: the PDF keeps F3 only with the
+ focus in it; its window listener yields a claimed F3 and letter); Alt+Enter Prism's own
+ Properties (OWNER); Ctrl+Shift+C the full paths as text, one per line, the menu's Copy path
+ (no badge, as the menu); Ctrl+Enter a folder in a new Explorer tab. NOT Ctrl+Left/Right
+ (2026-09-01). All of it is QUIET (#263: marking opens and plays nothing), behind the typing
+ guard, from the rows only, and CLAIMED, since the player, the PDF and the archive panel
+ listen on the window: App leaves Shift/Ctrl+Up/Down to the lists (`inLists`), the archive
+ panel's and the tree's Ctrl+A step aside for the other surface, tree rows ignore chords.
+ A letter chord matches the physical key only when the layout's key is not a Latin letter
+ (AZERTY's Ctrl+Q is not Ctrl+A). After a rename the keyboard follows the row to its new
+ name. Fullscreen unmounts both surfaces, so nothing here writes there. The `hotkeys` e2e
+ drives every key on both surfaces, a paused film under them, and a zip.
Tabs reorder by dragging along the strip (`reorderTabs`, pure and tested), with a
hairline showing where one would land.
Selection is the accent tint (`data-selected`); `aria-selected` still means the OPEN
diff --git a/README.md b/README.md
index c8e81297..6f2511b9 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,15 @@ never shipped: one small app that opens everything and looks good doing it.
open in another app, show in Explorer, copy, duplicate, rename, delete. Drive it entirely
from the keyboard: arrows move through folders and files alike, `Enter` opens or collapses
a folder, and files open as you land on them.
+- **The usual file keys, in the Explorer and the tree**: `Ctrl+A` selects all, `Ctrl+Shift+A`
+ or `Esc` clears; `Shift+Up/Down/Home/End` extend the selection and `Ctrl+Up/Down/Home/End`
+ move without changing it, with `Ctrl+Space` to mark the row you are on (marking never opens
+ or plays anything); `Ctrl+C/X/V` copy, cut and paste files, `Ctrl+Z` / `Ctrl+Y` undo and
+ redo; `Ctrl+Shift+N` makes a new folder and names it; `Del`, `Ctrl+D` and `Shift+Del` all go
+ to the Recycle Bin; `F2` renames; `Alt+Left/Right/Up` go back, forward and up (`Alt+Up` is
+ the parent row in the tree); type a name to jump to it; `Ctrl+F` or `F3` searches;
+ `Alt+Enter` shows Properties; `Ctrl+Shift+C` copies the full paths; `Ctrl+Enter` opens a
+ folder in a new tab.
- **Code, highlighted**: source files open with syntax colouring, line numbers, folding and
their own `Ctrl+F`, across ~150 languages. Where the language has a real grammar, a syntax
error gets the red underline you would expect from an editor.
diff --git a/package-lock.json b/package-lock.json
index 80e7ddc7..b3aaef36 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "prism",
- "version": "0.98.0",
+ "version": "0.99.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prism",
- "version": "0.98.0",
+ "version": "0.99.0",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.11.0",
diff --git a/package.json b/package.json
index 9de15c9a..be122c68 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "prism",
- "version": "0.98.0",
+ "version": "0.99.0",
"description": "A fast, universal media viewer for Windows.",
"author": "Max",
"license": "MIT",
diff --git a/src/main/fileOps.test.ts b/src/main/fileOps.test.ts
index 5f2bc335..30287e2f 100644
--- a/src/main/fileOps.test.ts
+++ b/src/main/fileOps.test.ts
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
-import { existsSync, mkdtempSync, writeFileSync } from 'fs'
+import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
-import { nameError, renameFile, uniqueName } from './fileOps'
+import { binIfEmpty, makeFolder, nameError, renameFile, uniqueName } from './fileOps'
function folder(...names: string[]): string {
const dir = mkdtempSync(join(tmpdir(), 'prism-ops-'))
@@ -112,3 +112,60 @@ describe('renameFile', () => {
expect(r).toEqual({ ok: false, reason: 'missing' })
})
})
+
+describe('makeFolder (#330)', () => {
+ it('makes "New folder", then "New folder (2)" and on', async () => {
+ const dir = folder()
+ expect(await makeFolder(dir)).toBe(join(dir, 'New folder'))
+ expect(await makeFolder(dir)).toBe(join(dir, 'New folder (2)'))
+ expect(await makeFolder(dir)).toBe(join(dir, 'New folder (3)'))
+ expect(existsSync(join(dir, 'New folder (3)'))).toBe(true)
+ })
+
+ it('counts a FILE of that name as taken', async () => {
+ const dir = folder('New folder')
+ expect(await makeFolder(dir)).toBe(join(dir, 'New folder (2)'))
+ })
+
+ it('makes a name it is given again, for a redo', async () => {
+ const dir = folder()
+ expect(await makeFolder(dir, 'Renamed')).toBe(join(dir, 'Renamed'))
+ })
+
+ it('refuses a missing folder, a file, and a name that is a path', async () => {
+ const dir = folder('a.txt')
+ expect(await makeFolder(join(dir, 'nope'))).toBeNull()
+ expect(existsSync(join(dir, 'nope'))).toBe(false)
+ expect(await makeFolder(join(dir, 'a.txt'))).toBeNull()
+ expect(await makeFolder(dir, '..\\out')).toBeNull()
+ })
+})
+
+describe('binIfEmpty (#330)', () => {
+ it('bins an empty folder', async () => {
+ const dir = folder()
+ const made = join(dir, 'New folder')
+ mkdirSync(made)
+ const trash = vi.fn(async () => {})
+ expect(await binIfEmpty(made, trash)).toBe('binned')
+ expect(trash).toHaveBeenCalledWith(made)
+ })
+
+ it('never bins a folder something was put in', async () => {
+ const dir = folder()
+ const made = join(dir, 'New folder')
+ mkdirSync(made)
+ writeFileSync(join(made, 'keep.txt'), 'mine')
+ const trash = vi.fn(async () => {})
+ expect(await binIfEmpty(made, trash)).toBe('not-empty')
+ expect(trash).not.toHaveBeenCalled()
+ })
+
+ it('says missing for a folder already gone, and failed for a file', async () => {
+ const dir = folder('a.txt')
+ const trash = vi.fn(async () => {})
+ expect(await binIfEmpty(join(dir, 'gone'), trash)).toBe('missing')
+ expect(await binIfEmpty(join(dir, 'a.txt'), trash)).toBe('failed')
+ expect(trash).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/fileOps.ts b/src/main/fileOps.ts
index 25da19cd..5ca43a5d 100644
--- a/src/main/fileOps.ts
+++ b/src/main/fileOps.ts
@@ -1,4 +1,5 @@
import { existsSync, renameSync } from 'fs'
+import { mkdir, readdir, stat } from 'fs/promises'
import { dirname, extname, join } from 'path'
import type { OnClash, RenameResult } from '@shared/types'
@@ -82,3 +83,54 @@ export async function renameFile(
return { ok: false, reason: 'failed', message: e instanceof Error ? e.message : String(e) }
}
}
+
+/**
+ * NEW FOLDER (#330; owner, 2026-10-07, Ctrl+Shift+N: the explicit yes to the
+ * "new folder is a fresh decision" rule). Makes "New folder" in `dir`, or
+ * "New folder (2)" and on when that is taken, Explorer's naming. `mkdir` is
+ * never recursive, so it cannot make a parent nobody asked for, and a name
+ * taken between the look and the make (EEXIST) just tries the next one.
+ * Null when `dir` is not a folder or the folder cannot be made.
+ */
+export async function makeFolder(dir: string, name = 'New folder'): Promise {
+ if (nameError(name)) return null
+ try {
+ if (!(await stat(dir)).isDirectory()) return null
+ } catch {
+ return null
+ }
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ const target = join(dir, uniqueName(dir, name))
+ try {
+ await mkdir(target)
+ return target
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') return null
+ }
+ }
+ return null
+}
+
+/**
+ * Undoing a new folder (#330): it goes to the Recycle Bin, and ONLY while it
+ * is still empty. Something put in it since is the user's, and undo never
+ * takes what it did not make ('not-empty' says so). A folder already gone is
+ * 'missing', which undo treats as done.
+ */
+export async function binIfEmpty(
+ path: string,
+ trash: (p: string) => Promise
+): Promise<'binned' | 'not-empty' | 'missing' | 'failed'> {
+ try {
+ if (!(await stat(path)).isDirectory()) return 'failed'
+ } catch {
+ return 'missing'
+ }
+ try {
+ if ((await readdir(path)).length) return 'not-empty'
+ await trash(path)
+ return 'binned'
+ } catch {
+ return 'failed'
+ }
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index c676267a..b2784ad1 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -133,7 +133,7 @@ import { sanitizeDoc } from './docSanitize'
import { encodeText, shapeOf, type TextShape } from './textFile'
import { readTail, startTail, stopAllTails, stopTail } from './fileTail'
import { openComic, type ComicOpen } from './comic'
-import { renameFile, uniqueName } from './fileOps'
+import { binIfEmpty, makeFolder, renameFile, uniqueName } from './fileOps'
import { appsForExt, argsFor, type AppCandidate } from './openWith'
import { readAsVtt, sidecarsFor, type SubTrack } from './subtitles'
import {
@@ -4314,6 +4314,33 @@ if (!app.requestSingleInstanceLock()) {
return null
}
})
+ // NEW FOLDER (#330; owner, 2026-10-07: Ctrl+Shift+N in the Explorer and
+ // the tree, the explicit yes to "new folder is a fresh decision"). The
+ // page names a folder; main checks it is one this window may write in, on
+ // disk and not inside an archive, and picks the name itself ("New folder",
+ // "New folder (2)"...). `name` is only a redo's: the name the undone
+ // folder had, put through the same validation as any rename.
+ ipcMain.handle('file:newFolder', async (_e, dir: string, name?: string): Promise => {
+ if (typeof dir !== 'string' || !dir || (name !== undefined && typeof name !== 'string')) return null
+ if (containerSync(dir) || !insideDesktop(dir)) return null
+ ownWrite(join(dir, 'New folder'))
+ const made = await makeFolder(dir, name)
+ await written(made ?? dir)
+ return made
+ })
+ // Undoing it: the Recycle Bin, and only while the folder is still empty.
+ ipcMain.handle(
+ 'file:binIfEmpty',
+ async (_e, p: string): Promise<'binned' | 'not-empty' | 'missing' | 'failed'> => {
+ if (typeof p !== 'string' || containerSync(p) || !editable(p)) return 'failed'
+ ownWrite(p)
+ try {
+ return await binIfEmpty(p, (t) => shell.trashItem(t))
+ } finally {
+ await written(p)
+ }
+ }
+ )
ipcMain.on('window:minimize', () => mainWindow?.minimize())
ipcMain.on('window:toggle-maximize', () =>
mainWindow?.isMaximized() ? mainWindow.unmaximize() : mainWindow?.maximize()
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 67a17bae..9f792952 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -384,6 +384,13 @@ const api = {
/** Copy the file next to itself as "name (2).ext"; resolves with the new path. */
duplicateFile: (path: string): Promise =>
ipcRenderer.invoke('file:duplicate', path),
+ /** Ctrl+Shift+N (#330): "New folder" (or "(2)" and on) in `dir`; the path
+ * made, or null. `name` is a redo's: the undone folder's own name. */
+ newFolder: (dir: string, name?: string): Promise =>
+ ipcRenderer.invoke('file:newFolder', dir, name),
+ /** Undoing a new folder: the Recycle Bin, only while it is still empty. */
+ binIfEmpty: (path: string): Promise<'binned' | 'not-empty' | 'missing' | 'failed'> =>
+ ipcRenderer.invoke('file:binIfEmpty', path),
/* ----- drag and drop (#70) ----- */
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index ecfb86ce..2d34a645 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -183,6 +183,7 @@ import {
emptyUndo,
redone,
remember,
+ rememberRename,
undone,
type UndoEntry,
type UndoState
@@ -3034,8 +3035,12 @@ export default function App(): JSX.Element {
setRefreshKey((n) => n + 1)
// Unsaved text follows the file to its new name.
rekeyBuffer(path, r.path)
+ // A rename that names a folder Ctrl+Shift+N just made folds into that
+ // entry (#330), so one Ctrl+Z takes the folder away, Explorer's way.
if (track !== false)
- noteUndo({ kind: 'rename', from: path, to: r.path, replaced: r.replaced })
+ setUndoState((u) =>
+ rememberRename(u, { kind: 'rename', from: path, to: r.path, replaced: r.replaced })
+ )
// Follow whatever is on screen: it may have been the thing renamed, or a
// file inside the folder that was, in which case its path just moved.
const cur = file?.path
@@ -3044,7 +3049,27 @@ export default function App(): JSX.Element {
else reopen(cur)
return r.path
},
- [file, noteUndo, reopen, rekeyBuffer]
+ [file, reopen, rekeyBuffer]
+ )
+
+ /**
+ * NEW FOLDER (#330; owner, 2026-10-07, Ctrl+Shift+N in the Explorer and the
+ * project tree: the explicit yes to "new folder is a fresh decision"). Main
+ * picks the name and checks the place; the folder goes on the undo stack,
+ * where Ctrl+Z bins it again while it is still empty. The caller starts the
+ * rename that follows, each surface its own way.
+ */
+ const makeNewFolder = useCallback(
+ async (dir: string): Promise => {
+ const made = await window.prism.newFolder(dir)
+ if (!made) {
+ setAsk({ kind: 'failed', message: 'A new folder could not be made here.' })
+ return null
+ }
+ noteUndo({ kind: 'mkdir', path: made })
+ return made
+ },
+ [noteUndo]
)
/**
@@ -3290,6 +3315,19 @@ export default function App(): JSX.Element {
setAsk({ kind: 'failed', message: 'That copy could not be moved to the Recycle Bin.' })
setRefreshKey((n) => n + 1)
break
+ case 'mkdir': {
+ // Only while it is still empty: what was put in it since is yours.
+ const r = await window.prism.binIfEmpty(entry.path)
+ setRefreshKey((n) => n + 1)
+ if (r === 'not-empty')
+ setAsk({
+ kind: 'failed',
+ message: 'That folder has something in it now, so it was left where it is.'
+ })
+ else if (r === 'failed')
+ setAsk({ kind: 'failed', message: 'That folder could not be moved to the Recycle Bin.' })
+ break
+ }
case 'archive-in': {
// Both halves: the members leave the zip, the originals come back.
// A folder member has no entry of its own, which deleteMember now
@@ -3343,6 +3381,16 @@ export default function App(): JSX.Element {
}
return { ...entry, path: copy }
}
+ case 'mkdir': {
+ // The same name in the same place, or "(2)" when it was taken since.
+ const made = await window.prism.newFolder(parentDir(entry.path), baseName(entry.path))
+ setRefreshKey((n) => n + 1)
+ if (!made) {
+ setAsk({ kind: 'failed', message: 'That folder could not be made again.' })
+ break
+ }
+ return { ...entry, path: made }
+ }
case 'archive-in': {
const r = await window.prism.archiveAdd(entry.zip, entry.originals, entry.dest, true)
if (r === 'encrypted' || r === 'failed') {
@@ -3538,14 +3586,15 @@ export default function App(): JSX.Element {
else if (file) void copyFilePaths([file.path], key === 'x')
return
}
- if (explorerFileFocus && file && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
+ if (explorerFileFocus && file && !e.ctrlKey && !e.altKey && !e.metaKey) {
const entry = { path: file.path, name: file.name, isFolder: false, file }
- if (e.key === 'F2') {
+ if (e.key === 'F2' && !e.shiftKey) {
e.preventDefault()
e.stopPropagation()
setBrowseRename(entry)
return
}
+ // Shift+Delete is Delete, to the bin (owner, #330): never permanent.
if (e.key === 'Delete') {
e.preventDefault()
e.stopPropagation()
@@ -3582,12 +3631,18 @@ export default function App(): JSX.Element {
return
}
}
+ // THE COMMON LIST KEYS (#330) are the list's and the tree's own: Shift
+ // and Ctrl with Up/Down extend the marks or move the keyboard's place
+ // there, and must not also walk the tree (which OPENS what it lands on)
+ // or page the folder behind it. Alt+Up in the tree is its parent row.
+ const inLists = !!el?.closest('.browse-list,[data-project-sidebar]')
if (
active &&
isExplorerTab(active) &&
e.altKey &&
!inTerm &&
!typing &&
+ !el?.closest('[data-project-sidebar]') &&
['ArrowLeft', 'ArrowRight', 'ArrowUp'].includes(e.key)
) {
e.preventDefault()
@@ -3723,6 +3778,9 @@ export default function App(): JSX.Element {
// from the sidebar behaves the same whatever kind of file it lands on.
// (`typing` already covered the text editor's caret, above.)
if (docFocused()) return
+ // Alt too: Alt+Up is the tree's parent row, and walking the tree here
+ // first would open or play the row above on the way (review of #330).
+ if (inLists && (e.ctrlKey || e.shiftKey || e.altKey)) return
// The tree gets first refusal: it walks folders as well as files, and
// says no when it isn't there to walk.
const dir = e.key === 'ArrowDown' ? 'down' : 'up'
@@ -4127,12 +4185,13 @@ export default function App(): JSX.Element {
onOpenFile={openFromPeekableTree}
// Renaming or binning the edited file (or a folder over it) would
// silently drop the editor's unsaved text; those ask first too.
- onRename={(p, name) => void runRename(p, name, 'ask')}
+ onRename={(p, name) => runRename(p, name, 'ask')}
onDelete={(path, name, isFolder) => setAsk({ kind: 'delete', path, name, isFolder })}
onDeleteMany={(paths) => setAsk({ kind: 'delete-many', paths })}
onDropInto={onBrowseDropInto}
archive={archiveActions}
onDuplicated={(source, copy) => noteUndo({ kind: 'duplicate', source, path: copy })}
+ onNewFolder={makeNewFolder}
wash={washed}
/>
)}
@@ -4216,6 +4275,20 @@ export default function App(): JSX.Element {
: setAsk({ kind: 'delete-many', paths })
}
onPaste={(directory) => void pasteFiles(directory)}
+ // Ctrl+Shift+N (#330): made, marked quietly, then renamed in
+ // the same dialog F2 opens. Never inside a zip (FolderBrowser
+ // does not offer it there, and main refuses it).
+ onNewFolder={(directory) =>
+ void makeNewFolder(directory).then((made) => {
+ if (!made) return
+ setRefreshKey((key) => key + 1)
+ browsing.select(made, true)
+ setBrowseRename({ path: made, name: baseName(made), isFolder: true })
+ })
+ }
+ onProperties={(entry) => setBrowseProps(entry)}
+ // Ctrl+Shift+C (#330): the paths as text, the menu's Copy path.
+ onCopyPathText={(paths) => void clipboardText(paths.join('\n'))}
// Inside an archive F2 renames a FILE of a writable zip and
// Delete is the permanent question; both are inert elsewhere
// there (#300).
diff --git a/src/renderer/src/components/ArchiveView.tsx b/src/renderer/src/components/ArchiveView.tsx
index fda1912d..108c00fb 100644
--- a/src/renderer/src/components/ArchiveView.tsx
+++ b/src/renderer/src/components/ArchiveView.tsx
@@ -725,6 +725,9 @@ function ArchiveInner({
if (!hasFocus.current || member) return
const el = e.target as HTMLElement | null
if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return
+ // The keyboard went on to the Explorer's list or the tree since the
+ // last press here (Tab): their own Ctrl+A answers (#330), not both.
+ if (el?.closest('.browse-list,[data-project-sidebar]')) return
e.preventDefault()
setSel({ anchor: order[0] ?? null, items: new Set(order) })
}
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx
index 0d694534..8c7514b9 100644
--- a/src/renderer/src/components/Sidebar.tsx
+++ b/src/renderer/src/components/Sidebar.tsx
@@ -7,6 +7,7 @@ import {
type DragEvent,
type JSX,
type MouseEvent,
+ type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
} from 'react'
import type { ArchiveMeta, OpenWithApp, ViewerFile } from '@shared/types'
@@ -37,7 +38,10 @@ import type { PeekPhase } from '../lib/useSidebarPeek'
import { SortMenu } from './SortMenu'
import { formatBytes } from '../lib/format'
import { TreeProvider } from '../lib/treeContext'
-import { clickSelect, emptySelection, type Selection } from '../lib/selection'
+import { clickSelect, emptySelection, rangeSelect, type Selection } from '../lib/selection'
+import { isJumpKey, listKey, stepTo, typeJump, typedRun } from '../lib/listKeys'
+import { nearerEscape } from '../lib/nearerEscape'
+import { clipboardText } from '../lib/clipboardText'
import { nearestRow, onRowOwnPart, rowsInBox, sweepSelect } from '../lib/marquee'
import { useSweep } from '../hooks/useSweep'
import { DRAG_MIME, dragPayload, droppedPaths, setDrag, type DragPayload } from '../lib/dragDrop'
@@ -136,6 +140,16 @@ const SEARCH_HELP = [
'-raw leave these out'
].join('\n')
+/** Focus a tree row once it is drawn, for up to 40 frames, and only while
+ * nothing else holds the keyboard (review of #330, after a rename). */
+function focusRowIn(box: HTMLElement | null, path: string, tries = 40): void {
+ const a = document.activeElement
+ if (a && a !== document.body) return
+ const el = box?.querySelector(`[data-row="${CSS.escape(path)}"]`)
+ if (el) el.focus({ preventScroll: true })
+ else if (tries > 0) requestAnimationFrame(() => focusRowIn(box, path, tries - 1))
+}
+
export function Sidebar({
open,
sliding = false,
@@ -153,6 +167,7 @@ export function Sidebar({
onDropInto,
archive,
onDuplicated,
+ onNewFolder,
onNav,
wash,
onOpenFolder,
@@ -194,7 +209,9 @@ export function Sidebar({
/** Bumped by App after a rename or delete, to re-read the folders on screen. */
refreshKey: number
onOpenFile: (path: string) => void
- onRename: (path: string, name: string) => void
+ /** The new path once it landed (null when it did not), so the cursor and
+ * the keyboard follow the row to its new name. */
+ onRename: (path: string, name: string) => Promise | void
onDelete: (path: string, name: string, isFolder: boolean) => void
/** A multi-selection's delete: one question, then every path to the bin. */
onDeleteMany: (paths: string[]) => void
@@ -216,6 +233,9 @@ export function Sidebar({
/** A copy was just made: App remembers the source AND the copy, so Ctrl+Z
* can take it away and Ctrl+Y can ask for another one. */
onDuplicated: (source: string, copyPath: string) => void
+ /** Ctrl+Shift+N (#330): a new folder in dir (main names it), already on
+ * the undo stack; its path, or null. The tree then starts its rename. */
+ onNewFolder: (dir: string) => Promise
/** Lends App the tree's arrow keys. The callback returns false when the tree
* has nothing to say, and App pages the folder itself instead. */
onNav: (step: ((dir: 'up' | 'down') => boolean) | null) => void
@@ -331,6 +351,9 @@ export function Sidebar({
// something to view, so landing there must not disturb what's on screen.
// Declared up here because the droppedOn reset below steers it during render.
const [cursor, setCursor] = useState(null)
+ /** Where the Ctrl keys left the cursor apart from the marks (#330): that row
+ * has the focus but not the mark. Any landing or click forgets it. */
+ const [apartAt, setApartAt] = useState(null)
/** What a drop or a paste just LANDED (2026-09-03, owner - Explorer's way:
* the arrived files become the selection, narrowing the 2026-08-31
* folder-mark rule). State and not a ref, because the reset below reads it
@@ -675,13 +698,35 @@ export function Sidebar({
[sel]
)
+ /**
+ * AFTER A RENAME THE KEYBOARD STAYS ON THE ROW (review of #330). The field
+ * unmounts and the focus falls to , and the cursor and the marks held
+ * the old path, which is gone: the next key reached no row. Ctrl+Shift+N
+ * always ends here, so the row it made, under its new name, takes the
+ * cursor and the focus once the tree has drawn it. Escape keeps
+ * the old row and gives it the focus back. Never from a focused field: a
+ * click elsewhere also ends a rename.
+ */
+ const focusRowSoon = useCallback((path: string): void => focusRowIn(panel.current, path), [])
const submitRename = useCallback(
(path: string, name: string) => {
setEditing(null)
- onRename(path, name)
+ void Promise.resolve(onRename(path, name)).then((to) => {
+ if (!to) return focusRowSoon(path)
+ // The marks are the refresh's to clear (a rename is one); the cursor
+ // lights the row under its new name.
+ const lower = path.toLowerCase()
+ setCursor((c) => (c && c.toLowerCase() === lower ? to : c))
+ focusRowSoon(to)
+ })
},
- [onRename]
+ [onRename, focusRowSoon]
)
+ const cancelRename = useCallback((): void => {
+ const was = editing
+ setEditing(null)
+ if (was) requestAnimationFrame(() => focusRowSoon(was))
+ }, [editing, focusRowSoon])
/* ---------- the keyboard cursor ---------- */
@@ -881,7 +926,7 @@ export function Sidebar({
if (!hasFocus.current || !order.length) return
const el = e.target as HTMLElement | null
if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return
- if (el?.closest('.xterm')) return
+ if (el?.closest('.xterm,.browse-list')) return
e.preventDefault()
setSel({ anchor: order[0], items: new Set(order) })
}
@@ -918,6 +963,7 @@ export function Sidebar({
const wasOnlySelection = sel.items.size === 1 && sel.items.has(path)
setSel((s) => clickSelect(order, s, path, { shift: e.shiftKey, ctrl: e.ctrlKey }))
setCursor(path)
+ setApartAt(null)
// A FILE keeps the tree's quick-look reflex: one click opens it, which
// is what the sidebar is for. A FOLDER selects first and expands on the
// second click (owner decision, 2026-08-31) - it is a destination for
@@ -1030,6 +1076,36 @@ export function Sidebar({
[load, onOpenFile, showRow]
)
+ /**
+ * THE MARKS DECIDE WHAT A DELETE KEY BINS (review of #330), the Explorer
+ * list's rule. A row the cursor sits on but the marks do not light (Ctrl+
+ * Up/Down took the keyboard away from them, or Escape cleared them) is not
+ * what Delete, Ctrl+D or Shift+Delete take: they take the marks, or nothing
+ * when there are none. True when this answered the key. A lit cursor row
+ * keeps the old rule (the row, or the whole selection it is in).
+ */
+ const binMarks = useCallback(
+ (path: string): boolean => {
+ const lower = path.toLowerCase()
+ const items = [...selRef.current.items]
+ const marked = items.some((p) => p.toLowerCase() === lower)
+ const apart = !!apartAt && apartAt.toLowerCase() === lower
+ if (marked || !apart) return false
+ // Inside an archive the members are the archive menu's; nothing here.
+ if (items.some((p) => state.children[parentDir(p)]?.archive)) return true
+ if (items.length > 1) onDeleteMany(items)
+ else if (items.length === 1) {
+ const only = items[0]
+ const isFolder = paintRef.current.some(
+ (r) => r.kind === 'folder' && r.path.toLowerCase() === only.toLowerCase()
+ )
+ onDelete(only, only.split(/[\\/]/).filter(Boolean).pop() ?? only, isFolder)
+ }
+ return true
+ },
+ [apartAt, state.children, onDelete, onDeleteMany]
+ )
+
/** Ctrl+C / Ctrl+X / Ctrl+V in the tree (2026-09-03, owner). Behind the same
* surface guard as Ctrl+A: the last press was in the panel, and nothing
* else - search box, rename field, editor, terminal - holds the keyboard,
@@ -1048,14 +1124,16 @@ export function Sidebar({
a.dataset.row === undefined &&
(a.matches('input,textarea,select,[contenteditable]:not([contenteditable="false"])') ||
!!a.closest('.cm-editor,.xterm,[role="dialog"],[role="menu"]'))
- if (e.key === 'Delete' && !e.ctrlKey && !e.shiftKey) {
+ if (e.key === 'Delete' && !e.ctrlKey) {
// Delete on the cursor row from anywhere in the panel's reach. The
// row button handles its own when it is focused; this is for when
- // the viewer took the focus with it.
+ // the viewer took the focus with it. Shift+Delete is Delete (owner,
+ // #330: never permanent).
if (!hasFocus.current || typing || a?.dataset.row !== undefined) return
const cur = at
if (!cur) return
e.preventDefault()
+ if (binMarks(cur)) return
const items = selRef.current.items
if (items.size > 1 && items.has(cur)) return onDeleteMany([...items])
const isFolder = paintRef.current.some(
@@ -1088,7 +1166,7 @@ export function Sidebar({
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
- }, [at, root, copyMark, runPaste, onDelete, onDeleteMany])
+ }, [at, root, copyMark, runPaste, onDelete, onDeleteMany, binMarks])
/**
* Extract a whole archive from its TREE ROW.
@@ -1220,6 +1298,7 @@ export function Sidebar({
const land = useCallback(
(row: { path: string; isFolder: boolean }, keepFocus = false): void => {
setCursor(row.path)
+ setApartAt(null)
setSel({ anchor: row.path, items: new Set([row.path]) })
// The same rule the click follows: landing on a film or a track with the
// arrows is the same intent as pointing at it, so it plays rather than
@@ -1301,6 +1380,141 @@ export function Sidebar({
return () => onNav(null)
}, [onNav, step])
+ /**
+ * THE COMMON KEYS IN THE TREE (#330; owner, 2026-10-07: "add common hotkeys
+ * to the explorer and project"), the Explorer list's set (lib/listKeys).
+ * Reached only with the focus inside the panel and past its typing guard.
+ * Marking is QUIET (#263): Shift and Ctrl with the arrows move the cursor
+ * and the marks and never open or play what they land on, which is why App
+ * leaves those chords to the tree. True when the key was taken; a taken key
+ * is claimed, so the player's window-wide keys (Ctrl+Space, Shift+Home,
+ * Alt+Left) and the PDF's F3 stand aside.
+ */
+ const typedTree = useRef({ text: '', at: 0 })
+ const rowOf = (path: string | null): (typeof rows)[number] | undefined =>
+ path ? rows.find((r) => r.path.toLowerCase() === path.toLowerCase()) : undefined
+ const onTreeKey = (e: ReactKeyboardEvent): boolean => {
+ const k = listKey(e)
+ const searching = !!query.trim()
+ if (!k) {
+ // TYPE TO JUMP (owner: "add it like the Explorer's"): a letter is taken
+ // only when it matches a row on screen; otherwise it still reaches the
+ // viewer (a film's k, j, l and m, a picture's f). Letters only: a digit
+ // is the player's seek and . and , its frame step, and a jump OPENS.
+ if (searching || e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false
+ if (!isJumpKey(e.key)) return false
+ const run = typedRun(typedTree.current, e.key, performance.now())
+ typedTree.current = run
+ const i = typeJump(
+ rows.map((r) => r.name),
+ at ? order.findIndex((p) => p.toLowerCase() === at.toLowerCase()) : -1,
+ run.text
+ )
+ if (i < 0) return false
+ land(rows[i])
+ return true
+ }
+ // Ctrl+A is the window listener's above (it predates this), and plain
+ // Back/Forward have no history here: Alt+Left and Alt+Right do nothing,
+ // but are still claimed, so a film behind the tree does not seek.
+ if (k === 'select-all') return false
+ if (k === 'back' || k === 'forward') return true
+ if (k === 'search') {
+ panel.current?.querySelector('input')?.focus()
+ return true
+ }
+ if (k === 'clear') {
+ if (!sel.items.size) return false
+ // A peek, the PDF's find bar, a menu: Escape is theirs to close first.
+ if (e.key === 'Escape' && nearerEscape()) return false
+ setSel(emptySelection)
+ // Nothing lit, the cursor's row included: it keeps the focus only.
+ setApartAt(at)
+ return true
+ }
+ // The rest act on the tree's own rows; while a search has replaced them
+ // they are claimed and do nothing.
+ if (searching) return true
+ const cur = rowOf(at)
+ const move = /^(extend|focus)-(up|down|home|end)$/.exec(k)
+ if (move) {
+ const from = cur ? rows.indexOf(cur) : -1
+ const to = stepTo(rows.length, from, move[2] as 'up' | 'down' | 'home' | 'end')
+ if (to === null) return true
+ const row = rows[to]
+ setCursor(row.path)
+ setApartAt(move[1] === 'focus' ? row.path : null)
+ if (move[1] === 'extend') setSel((s) => rangeSelect(order, { anchor: s.anchor ?? at, items: s.items }, row.path))
+ requestAnimationFrame(() => showRow(row.path, { focus: true }))
+ return true
+ }
+ if (k === 'toggle-mark') {
+ if (cur) {
+ setSel((s) => clickSelect(order, s, cur.path, { ctrl: true }))
+ // In or out, the row is lit by the marks alone from now on.
+ setApartAt(cur.path)
+ }
+ return true
+ }
+ if (k === 'parent') {
+ // Alt+Up: the row of the folder this one is in, as Backspace does for a
+ // row that is not an open folder. The root has no row of its own.
+ const up = cur ? rowOf(parentDir(cur.path)) : undefined
+ if (up) land(up)
+ return true
+ }
+ if (k === 'copy-paths') {
+ const paths = sel.items.size ? order.filter((p) => sel.items.has(p)) : cur ? [cur.path] : []
+ if (paths.length) void clipboardText(paths.join('\n'))
+ return true
+ }
+ if (!cur) return true
+ // Inside an archive the rows are its members: nothing here makes, bins or
+ // describes them (the archive's own menu does).
+ const member = !!memberMeta(cur.path)
+ if (k === 'open-new-tab') {
+ if (cur.isFolder && !member && fileKind(extOf(cur.name), cur.name) !== 'archive') onOpenNewTab(cur.path)
+ return true
+ }
+ if (member) return true
+ if (k === 'properties') {
+ const file = state.children[parentDir(cur.path)]?.files.find(
+ (f) => f.path.toLowerCase() === cur.path.toLowerCase()
+ )
+ setProps({ path: cur.path, name: cur.name, isFolder: cur.isFolder, size: file?.size })
+ return true
+ }
+ if (k === 'bin') {
+ // Ctrl+D and Shift+Delete are Delete, to the Recycle Bin (owner: Shift+
+ // Delete is not a permanent delete in Prism).
+ if (binMarks(cur.path)) return true
+ const items = selRef.current.items
+ if (items.size > 1 && items.has(cur.path)) onDeleteMany([...items])
+ else onDelete(cur.path, cur.name, cur.isFolder)
+ return true
+ }
+ if (k === 'new-folder') {
+ // In the folder row the cursor is on, or beside the file it is on.
+ const dir = cur.isFolder ? cur.path : parentDir(cur.path)
+ const dirName = dir.split(/[\\/]/).pop() ?? dir
+ if (fileKind(extOf(dirName), dirName) === 'archive' || !dir.toLowerCase().startsWith(root.toLowerCase()))
+ return true
+ void onNewFolder(dir).then((made) => {
+ if (!made) return
+ if (dir.toLowerCase() !== root.toLowerCase())
+ setState((s) => ({ ...s, expanded: new Set([...s.expanded, dir]) }))
+ void load(dir, true).then(() => {
+ setCursor(made)
+ setSel({ anchor: made, items: new Set([made]) })
+ setEditing(made)
+ requestAnimationFrame(() => showRow(made))
+ })
+ })
+ return true
+ }
+ return false
+ }
+
const rootListing = state.children[root]
const overlay = !open && !!peek
@@ -1325,6 +1539,12 @@ export function Sidebar({
return
}
if (target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"])')) return
+ // The common keys (#330), from the rows only.
+ if (scroller.current?.contains(target) && onTreeKey(event)) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
if (event.key === 'F5' && !event.ctrlKey && !event.altKey) {
event.preventDefault()
event.stopPropagation()
@@ -1529,6 +1749,7 @@ export function Sidebar({
currentPath,
dirtyPaths,
cursor: at,
+ cursorMarks: !(apartAt && at && apartAt.toLowerCase() === at.toLowerCase()),
size,
editing,
menuPath: menu?.path ?? null,
@@ -1554,12 +1775,13 @@ export function Sidebar({
else setEditing(path)
},
onSubmitRename: submitRename,
- onCancelRename: () => setEditing(null),
+ onCancelRename: cancelRename,
// Del on a row inside a multi-selection takes the whole
// selection; anywhere else it stays the single-row question.
onDelete: (path, name, isFolder) => {
const meta = memberMeta(path)
const many = sel.items.size > 1 && sel.items.has(path)
+ if (!meta && binMarks(path)) return
if (meta && archive) archive.remove(many ? [...sel.items] : [path], meta)
else if (many) onDeleteMany([...sel.items])
else onDelete(path, name, isFolder)
diff --git a/src/renderer/src/components/TreeRows.tsx b/src/renderer/src/components/TreeRows.tsx
index 1f54a712..58011be8 100644
--- a/src/renderer/src/components/TreeRows.tsx
+++ b/src/renderer/src/components/TreeRows.tsx
@@ -496,6 +496,9 @@ function FolderRow({
const pad = 4 + depth * t.size.indent
// The cursor carries the accent wherever it goes, folders included.
const onCursor = !!t.cursor && t.cursor.toLowerCase() === path.toLowerCase()
+ // Lit by the cursor only while the cursor marks (#330): after Ctrl+Up/Down,
+ // Escape or Ctrl+Space the marks alone light it, as on a file row.
+ const lit = (onCursor && t.cursorMarks !== false) || t.selected.has(path)
// The right-clicked row wears the selection's tint while its menu is up
// (#296; owner, 2026-10-06), File Explorer's look; it is not selected.
const onMenuHl = !!t.menuPath && t.menuPath.toLowerCase() === path.toLowerCase()
@@ -556,6 +559,9 @@ function FolderRow({
onContextMenu={(e) => t.onMenu(e, path, name, !zip)}
data-zip-node={zip ? true : undefined}
onKeyDown={(e) => {
+ // A chord is the panel's (#330): Ctrl+Enter a new tab, Alt+Enter
+ // Properties, Shift+Delete the bin (Sidebar's listKey).
+ if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return
if (e.key === 'Enter') {
e.preventDefault()
t.onToggle(path)
@@ -573,7 +579,7 @@ function FolderRow({
// the accent means selected, and a drop destination is not.
t.dropTarget === path
? 'bg-[var(--p-hover-hi)] text-[var(--p-text)]'
- : onCursor || t.selected.has(path)
+ : lit
? // The tint is in `style` (markedLook); the text keeps its colour.
'text-[var(--p-text-soft)]'
: onMenuHl
@@ -590,7 +596,7 @@ function FolderRow({
opacity: t.cut.has(path.toLowerCase()) ? 0.45 : undefined,
// Contiguous selected rows fuse: shared edges drop their rounding.
// A drop target's grey wins over the tint, as its class does.
- ...(t.dropTarget !== path && (onCursor || t.selected.has(path))
+ ...(t.dropTarget !== path && lit
? markedLook(t.selected.has(path) ? t.selJoin(path) : ALONE)
: t.dropTarget !== path && onMenuHl
? markedLook(ALONE)
@@ -615,7 +621,7 @@ function FolderRow({
ext={zip.ext}
name={zip.name}
color={iconColour('archive')}
- bg={t.selected.has(path) || onCursor ? 'var(--p-sel-tint-side)' : undefined}
+ bg={lit ? 'var(--p-sel-tint-side)' : undefined}
/>
) : (
@@ -649,7 +655,7 @@ function FileRow({ f, depth }: { f: ViewerFile; depth: number }): JSX.Element {
// second highlight competing with the first was more noise than help.
// `aria-selected` still says so for anything reading the tree.
const onCursor = !!t.cursor && f.path.toLowerCase() === t.cursor.toLowerCase()
- const onSel = onCursor || t.selected.has(f.path)
+ const onSel = (onCursor && t.cursorMarks !== false) || t.selected.has(f.path)
// The right-clicked row wears the selection's tint while its menu is up
// (#296; owner, 2026-10-06), File Explorer's look; it is not selected.
const onMenuHl = !!t.menuPath && f.path.toLowerCase() === t.menuPath.toLowerCase()
@@ -710,6 +716,7 @@ function FileRow({ f, depth }: { f: ViewerFile; depth: number }): JSX.Element {
onClick={(e) => t.onRowClick(e, f.path, false)}
onContextMenu={(e) => t.onMenu(e, f.path, f.name, false, f.size)}
onKeyDown={(e) => {
+ if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return // the panel's (#330)
if (e.key === 'Enter') {
e.preventDefault()
t.onOpenFile(f.path)
diff --git a/src/renderer/src/components/browse/BrowseList.tsx b/src/renderer/src/components/browse/BrowseList.tsx
index 650dcee5..36c007d7 100644
--- a/src/renderer/src/components/browse/BrowseList.tsx
+++ b/src/renderer/src/components/browse/BrowseList.tsx
@@ -29,8 +29,24 @@ import type { BrowseEntry, BrowseSort, FolderBrowserProps } from './types'
import type { ListPending } from '../../lib/usePendingHint'
import { divideRows, type DateDivider } from '../../lib/dateGroups'
import { nextSort } from '../../lib/downloadsView'
+import { listKey, stepTo, type ListKey } from '../../lib/listKeys'
+import { nearerEscape } from '../../lib/nearerEscape'
const OVERSCAN = 12
+/** The common keys the list answers itself (#330). */
+const LIST_OWN: ReadonlySet = new Set([
+ 'select-all',
+ 'clear',
+ 'extend-up',
+ 'extend-down',
+ 'extend-home',
+ 'extend-end',
+ 'focus-up',
+ 'focus-down',
+ 'focus-home',
+ 'focus-end',
+ 'toggle-mark'
+])
const columns: Array<{ key: BrowseSort['key']; label: string }> = [
{ key: 'name', label: 'Name' },
{ key: 'type', label: 'Type' },
@@ -100,6 +116,14 @@ type Props = Pick<
archive?: ArchiveMeta | null
/** Drawn above the column header: the archive's strip. */
strip?: ReactNode
+ /** THE COMMON KEYS (#330), each quiet (#263): Shift+arrows mark the run
+ * from the anchor to `path`; Ctrl+Space puts `path` in or out of the marks;
+ * Ctrl+A marks every row; Ctrl+Shift+A and Escape clear, answering whether
+ * there was anything to clear (an Escape with nothing marked is not taken). */
+ onExtend?: (path: string) => void
+ onToggleMark?: (path: string) => void
+ onSelectAll?: () => void
+ onClear?: () => boolean
/** A right press on the list's empty space (inside an archive, #300). */
onEmptyContextMenu?: (event: ReactMouseEvent) => void
}
@@ -374,9 +398,52 @@ export function BrowseList(props: Props): JSX.Element {
pendingFocus.current = null
selectionPosition.current = { path: '', index: -1 }
}, [props.directory, props.query, props.sort])
+ /**
+ * THE KEYBOARD'S PLACE APART FROM THE MARKS (#330). Ctrl+Up/Down/Home/End
+ * move the focus and leave the selection alone, File Explorer's way, so a
+ * Ctrl+Space can then mark a row that is not next to the others. Null while
+ * the focus is on the selected row, which is almost always. Forgotten with
+ * the folder, the search and the sort, and by any plain pick (a click, the
+ * arrows), adjusted while rendering as React's pattern has it.
+ */
+ const [cursor, setCursor] = useState(null)
+ const cursorKey = `${props.directory}\u0000${props.query}\u0000${props.sort.key}${props.sort.direction}`
+ const [cursorFor, setCursorFor] = useState(cursorKey)
+ if (cursorFor !== cursorKey) {
+ setCursorFor(cursorKey)
+ setCursor(null)
+ }
+ /** A path's row in the list as drawn (dividers counted), or -1. */
+ const indexOfPath = (path: string): number => {
+ if (props.indexedRows)
+ return [...props.indexedRows].find(([, entry]) => entry?.path === path)?.[0] ?? -1
+ const at = props.entries.findIndex((entry) => entry.path === path)
+ return divided && at >= 0 ? divided.rowOf(at) : at
+ }
+ /** Bring a row into view, stepping off a divider the way `toward` points;
+ * the row's entry, or null when it is not there to land on. */
+ const reveal = (index: number, toward: 1 | -1): { index: number; entry: BrowseEntry } | null => {
+ const node = scroller.current
+ if (!node) return null
+ while (index >= 0 && index < count && rowAt(index) === null) index += toward
+ if (index < 0 || index >= count) return null
+ const top = (divided?.divider(index - 1) ? index - 1 : index) * rowHeight
+ const visibleTop = node.scrollTop * scale
+ if (top < visibleTop) node.scrollTop = top / scale
+ else if (index * rowHeight + rowHeight > visibleTop + node.clientHeight)
+ node.scrollTop = (index * rowHeight + rowHeight - node.clientHeight) / scale
+ props.onScroll(node.scrollTop)
+ const entry = rowAt(index)
+ if (!entry) return null
+ requestAnimationFrame(() =>
+ node.querySelector(`[data-browse-index="${index}"]`)?.focus({ preventScroll: true })
+ )
+ return { index, entry }
+ }
const focusRow = (index: number): void => {
const node = scroller.current
if (!node) return
+ setCursor(null)
const direction = index === 0 ? 1 : index < selectedIndex || index === count - 1 ? -1 : 1
while (index >= 0 && index < count && rowAt(index) === null) index += direction
if (index < 0 || index >= count) return
@@ -402,7 +469,59 @@ export function BrowseList(props: Props): JSX.Element {
props.onSearchRange?.(Math.floor((node.scrollTop * scale) / rowHeight))
}
}
+ /** The common keys that act on the list's own rows (#330); the rest of them
+ * (new folder, the bin, F3, Properties...) are FolderBrowser's. True when
+ * the key was taken. */
+ const listOwnKey = (k: ListKey): boolean => {
+ if (k === 'clear') return !!props.onClear?.()
+ if (k === 'select-all') {
+ if (!count || !props.onSelectAll) return false
+ setCursor(null)
+ props.onSelectAll()
+ return true
+ }
+ // From the keyboard's place: the cursor while it is apart, else the row
+ // the selection is on (or was on, when it has scrolled away).
+ const here = cursor !== null ? indexOfPath(cursor) : -1
+ const from = here >= 0 ? here : selectedIndex >= 0 ? selectedIndex : -1
+ if (k === 'toggle-mark') {
+ const path = here >= 0 ? cursor : props.selectedPath
+ if (!path || !props.onToggleMark) return false
+ props.onToggleMark(path)
+ // The focus stays on the row whether it went in or out.
+ setCursor(path)
+ return true
+ }
+ const move = /^(extend|focus)-(up|down|home|end)$/.exec(k)
+ if (!move) return false
+ const to = stepTo(count, from, move[2] as 'up' | 'down' | 'home' | 'end')
+ if (to === null) return true
+ const toward: 1 | -1 = move[2] === 'up' || move[2] === 'end' ? -1 : 1
+ const landed = reveal(to, toward)
+ if (!landed) return true
+ if (move[1] === 'focus') {
+ setCursor(landed.entry.path)
+ return true
+ }
+ // Shift: the marks run from the anchor to here, and here is the place.
+ setCursor(null)
+ props.onExtend?.(landed.entry.path)
+ return true
+ }
const onKeyDown = (e: KeyboardEvent): void => {
+ const common = listKey(e)
+ if (common) {
+ // Escape is a nearer closer's first (a peek, the PDF's find bar, a
+ // menu): those listen on the window, after this (review of #330).
+ if (common === 'clear' && e.key === 'Escape' && nearerEscape()) return
+ if (LIST_OWN.has(common) && listOwnKey(common)) {
+ // Claimed: the player's window-wide keys (Ctrl+Space is play, Shift+
+ // Home a seek) yield to a key the list took.
+ e.preventDefault()
+ e.stopPropagation()
+ }
+ return
+ }
if (e.altKey || e.ctrlKey || e.metaKey) return
if (!count) {
if (
@@ -422,9 +541,15 @@ export function BrowseList(props: Props): JSX.Element {
: selectionPosition.current.path === props.selectedPath
? selectionPosition.current.index
: -1
+ // The plain arrows carry on from a cursor the Ctrl keys moved (#330).
+ const cursorIndex = cursor !== null ? indexOfPath(cursor) : -1
const currentIndex =
pendingFocus.current ??
- (rememberedIndex < 0 ? Math.floor(logicalTop / rowHeight) - 1 : rememberedIndex)
+ (cursorIndex >= 0
+ ? cursorIndex
+ : rememberedIndex < 0
+ ? Math.floor(logicalTop / rowHeight) - 1
+ : rememberedIndex)
let next: number
if (e.key === 'ArrowDown') next = Math.min(count - 1, currentIndex + 1)
else if (e.key === 'ArrowUp') next = Math.max(0, currentIndex - 1)
@@ -646,6 +771,7 @@ export function BrowseList(props: Props): JSX.Element {
data-browse-path={entry.path}
data-browse-index={first + offset}
data-selected={selected || undefined}
+ data-cursor={(cursor === entry.path && !primary) || undefined}
data-join-up={joinUp || undefined}
data-join-down={joinDown || undefined}
draggable
@@ -678,6 +804,7 @@ export function BrowseList(props: Props): JSX.Element {
onDragEnd={() => setDrag(null)}
title={searching ? entry.path : entry.name}
onClick={(e: ReactMouseEvent) => {
+ setCursor(null)
if ((e.ctrlKey || e.shiftKey) && props.onPick)
props.onPick(entry, { ctrl: e.ctrlKey, shift: e.shiftKey })
else props.onSelect(entry.path)
diff --git a/src/renderer/src/components/browse/FolderBrowser.tsx b/src/renderer/src/components/browse/FolderBrowser.tsx
index f8abca70..d104d1e2 100644
--- a/src/renderer/src/components/browse/FolderBrowser.tsx
+++ b/src/renderer/src/components/browse/FolderBrowser.tsx
@@ -11,7 +11,8 @@ import { createSortPass } from './sortTiming'
import { dateDividers } from '../../lib/dateGroups'
import { dateView } from '../../lib/downloadsView'
import { useFolderSizes } from '../../hooks/useFolderSizes'
-import { clickSelect } from '../../lib/selection'
+import { clickSelect, rangeSelect } from '../../lib/selection'
+import { listKey } from '../../lib/listKeys'
import { sweepSelect } from '../../lib/marquee'
import { explorerHeadVars, explorerRow, useExplorerSize } from '../../lib/explorerSize'
import type { BrowseEntry, FolderBrowserProps } from './types'
@@ -24,6 +25,9 @@ import { useActiveArea } from './useActiveArea'
import './browse.css'
import './archive.css'
+/** The common keys FolderBrowser answers for the list (#330). */
+const FOLDER_KEYS = new Set(['new-folder', 'bin', 'search', 'properties', 'copy-paths', 'open-new-tab'])
+
export type {
BrowseEntry,
BrowsePlace,
@@ -198,23 +202,50 @@ export function FolderBrowser(props: FolderBrowserProps): JSX.Element {
.sort(([a], [b]) => a - b)
.flatMap(([, entry]) => (entry ? [entry.path] : []))
: entries.map((entry) => entry.path)
- const pick = (entry: BrowseEntry, mods: { ctrl: boolean; shift: boolean }): void => {
+ const pickPath = (path: string, mods: { ctrl: boolean; shift: boolean }): void => {
const now = marked ?? new Set(props.selectedPath ? [props.selectedPath] : [])
const next = clickSelect(
order(),
{ anchor: anchor.current ?? props.selectedPath, items: now },
- entry.path,
+ path,
mods
)
anchor.current = next.anchor
// The clicked row is where the keyboard goes, unless Ctrl just took it
// back out; then any row still marked, or none.
- const primary = next.items.has(entry.path) ? entry.path : ([...next.items][0] ?? null)
+ const primary = next.items.has(path) ? path : ([...next.items][0] ?? null)
setMarks(next.items.size > 1 ? { key: marksKey, items: next.items } : null)
// Quiet, even when it leaves one row (#263): a Ctrl or Shift click marks,
// it does not preview or play what it lands on.
props.onSelect(primary, true)
}
+ const pick = (entry: BrowseEntry, mods: { ctrl: boolean; shift: boolean }): void =>
+ pickPath(entry.path, mods)
+ /* THE COMMON KEYS (#330), every one of them QUIET (#263): marking by
+ keyboard previews, plays and opens nothing, as a Ctrl or Shift click. */
+ /** Shift+arrows, Shift+Home/End: the run from the anchor to `path`. */
+ const extendTo = (path: string): void => {
+ const now = marked ?? new Set(props.selectedPath ? [props.selectedPath] : [])
+ const next = rangeSelect(order(), { anchor: anchor.current ?? props.selectedPath, items: now }, path)
+ anchor.current = next.anchor
+ setMarks(next.items.size > 1 ? { key: marksKey, items: next.items } : null)
+ props.onSelect(path, true)
+ }
+ /** Ctrl+A: every row the list holds (a search's rows as far as they came). */
+ const selectAll = (): void => {
+ const all = order()
+ if (!all.length) return
+ const keep = props.selectedPath && all.includes(props.selectedPath) ? props.selectedPath : all[0]
+ anchor.current = anchor.current && all.includes(anchor.current) ? anchor.current : all[0]
+ setMarks(all.length > 1 ? { key: marksKey, items: new Set(all) } : null)
+ props.onSelect(keep, true)
+ }
+ /** Ctrl+Shift+A and Escape: nothing marked. False when nothing was. */
+ const clearMarks = (): boolean => {
+ if (!props.selectedPath && !marked) return false
+ pickOne(null, true)
+ return true
+ }
const swept = (paths: string[], near: string | null, add: boolean): void => {
const base: ReadonlySet = add
? (marked ?? new Set(props.selectedPath ? [props.selectedPath] : []))
@@ -365,6 +396,39 @@ export function FolderBrowser(props: FolderBrowserProps): JSX.Element {
)
if (target.closest('[role="dialog"],[role="menu"]')) return
const inList = !!target.closest('.browse-list')
+ // THE COMMON KEYS (#330) that act on the folder or a row rather than
+ // on the marks (the list answers those itself). From the list only,
+ // never while typing, and CLAIMED even where they do nothing (inside
+ // a zip), so no window-wide viewer key answers them instead: F3 was
+ // the PDF's find from anywhere.
+ const common = !typing && inList ? listKey(e) : null
+ if (common && FOLDER_KEYS.has(common)) {
+ e.preventDefault()
+ e.stopPropagation()
+ // The row with the keyboard (a cursor the Ctrl keys moved), else
+ // the selected one.
+ const at = target.closest('[data-browse-path]')?.dataset.browsePath
+ const focused =
+ (at && (entries.find((entry) => entry.path === at) ?? [...(indexedRows?.values() ?? [])].find((entry) => entry?.path === at))) ||
+ selected
+ if (common === 'search') openSearch()
+ else if (common === 'copy-paths') {
+ const paths = many ? markedPaths() : focused ? [focused.path] : []
+ if (paths.length) props.onCopyPathText?.(paths)
+ } else if (archive) return
+ else if (common === 'new-folder') props.onNewFolder?.(props.directory)
+ else if (common === 'bin') {
+ // Ctrl+D and Shift+Delete are Delete (owner, 2026-10-07: Shift+
+ // Delete is NOT permanent here): the question, then the bin.
+ if (many && props.onDeleteMany) props.onDeleteMany(markedPaths())
+ else if (selected) props.onDelete?.(selected)
+ } else if (common === 'properties') {
+ if (focused) props.onProperties?.(focused)
+ } else if (common === 'open-new-tab') {
+ if (focused?.isFolder) props.onOpenNewTab?.(focused.path, true)
+ }
+ return
+ }
if (e.key === 'F5' && props.onRefresh) {
e.preventDefault()
e.stopPropagation()
@@ -518,6 +582,10 @@ export function FolderBrowser(props: FolderBrowserProps): JSX.Element {
marked={marked}
onPick={pick}
onSweep={swept}
+ onExtend={extendTo}
+ onToggleMark={(path) => pickPath(path, { ctrl: true, shift: false })}
+ onSelectAll={selectAll}
+ onClear={clearMarks}
// A right press inside several marked rows is a menu for all of them:
// the marks stay lit, so a menu for the one row under the pointer
// would delete one file while four looked chosen (review of #257).
diff --git a/src/renderer/src/components/browse/types.ts b/src/renderer/src/components/browse/types.ts
index 22f526a7..81ee14d4 100644
--- a/src/renderer/src/components/browse/types.ts
+++ b/src/renderer/src/components/browse/types.ts
@@ -130,6 +130,12 @@ export interface FolderBrowserProps {
/** Several rows marked: one question for all of them, the tree's. */
onDeleteMany?: (paths: string[]) => void
onRefresh?: () => void
+ /** Ctrl+Shift+N (#330): a new folder in `directory`, then its rename. */
+ onNewFolder?: (directory: string) => void
+ /** Alt+Enter (#330): Prism's own Properties, as the menu's. */
+ onProperties?: (entry: BrowseEntry) => void
+ /** Ctrl+Shift+C (#330): these full paths as text, one per line. */
+ onCopyPathText?: (paths: string[]) => void
onDropInto?: (directory: string, payload: DragPayload) => void
/** The strip's Extract here (`here`) and Extract to... inside an archive
* (#300). Without it there is no strip. */
diff --git a/src/renderer/src/components/pdf/PdfView.tsx b/src/renderer/src/components/pdf/PdfView.tsx
index 80c1c2d3..3a719712 100644
--- a/src/renderer/src/components/pdf/PdfView.tsx
+++ b/src/renderer/src/components/pdf/PdfView.tsx
@@ -584,6 +584,9 @@ export function PdfView({
return
}
if (e.key === 'F3') {
+ // The Explorer's list and the project tree take F3 for their own
+ // search (#330); with the focus in the PDF, or nowhere, it is find.
+ if (e.defaultPrevented) return
e.preventDefault()
if (findOpen) stepFind(e.shiftKey ? -1 : 1)
else setFindOpen(true)
@@ -604,7 +607,8 @@ export function PdfView({
goToPage(clamp(page + (e.key === 'PageDown' ? 1 : -1), 1, pageCount))
return
}
- if (typing) return
+ // A letter the tree took for type-to-jump (#330) is not a zoom or a fit.
+ if (typing || e.defaultPrevented) return
switch (e.key) {
case '+':
case '=':
diff --git a/src/renderer/src/lib/clipboardText.ts b/src/renderer/src/lib/clipboardText.ts
index 665e5ac9..1fa0f307 100644
--- a/src/renderer/src/lib/clipboardText.ts
+++ b/src/renderer/src/lib/clipboardText.ts
@@ -32,6 +32,9 @@ export function canCopyText(): boolean {
*/
function copyBySelection(text: string): boolean {
if (typeof document === 'undefined' || !('execCommand' in document)) return false
+ // The keyboard goes back where it was (#330): Ctrl+Shift+C from a file
+ // row must leave that row with the focus, or the next key reaches nothing.
+ const had = document.activeElement instanceof HTMLElement ? document.activeElement : null
const box = document.createElement('textarea')
box.value = text
box.readOnly = true
@@ -46,5 +49,6 @@ function copyBySelection(text: string): boolean {
return false
} finally {
box.remove()
+ if (had?.isConnected) had.focus({ preventScroll: true })
}
}
diff --git a/src/renderer/src/lib/listKeys.test.ts b/src/renderer/src/lib/listKeys.test.ts
new file mode 100644
index 00000000..e0740827
--- /dev/null
+++ b/src/renderer/src/lib/listKeys.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it } from 'vitest'
+import { isJumpKey, listKey, stepTo, typeJump, typedRun, type KeyLike } from './listKeys'
+import { rangeSelect } from './selection'
+
+const press = (key: string, mods: Partial = {}): KeyLike => ({
+ key,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ metaKey: false,
+ ...mods
+})
+const ctrl = { ctrlKey: true }
+const shift = { shiftKey: true }
+const alt = { altKey: true }
+
+describe('listKey', () => {
+ it('maps the chosen chords (#330)', () => {
+ expect(listKey(press('a', ctrl))).toBe('select-all')
+ expect(listKey(press('A', { ...ctrl, ...shift }))).toBe('clear')
+ expect(listKey(press('Escape'))).toBe('clear')
+ expect(listKey(press('ArrowDown', shift))).toBe('extend-down')
+ expect(listKey(press('ArrowUp', shift))).toBe('extend-up')
+ expect(listKey(press('Home', shift))).toBe('extend-home')
+ expect(listKey(press('End', shift))).toBe('extend-end')
+ expect(listKey(press('ArrowDown', ctrl))).toBe('focus-down')
+ expect(listKey(press('ArrowUp', ctrl))).toBe('focus-up')
+ expect(listKey(press('Home', ctrl))).toBe('focus-home')
+ expect(listKey(press('End', ctrl))).toBe('focus-end')
+ expect(listKey(press(' ', ctrl))).toBe('toggle-mark')
+ expect(listKey(press('N', { ...ctrl, ...shift }))).toBe('new-folder')
+ expect(listKey(press('d', ctrl))).toBe('bin')
+ expect(listKey(press('Delete', shift))).toBe('bin')
+ expect(listKey(press('ArrowUp', alt))).toBe('parent')
+ expect(listKey(press('ArrowLeft', alt))).toBe('back')
+ expect(listKey(press('ArrowRight', alt))).toBe('forward')
+ expect(listKey(press('F3'))).toBe('search')
+ expect(listKey(press('Enter', alt))).toBe('properties')
+ expect(listKey(press('C', { ...ctrl, ...shift }))).toBe('copy-paths')
+ expect(listKey(press('Enter', ctrl))).toBe('open-new-tab')
+ })
+
+ it('reads a letter chord by its physical key too', () => {
+ expect(listKey(press('ф', { ...ctrl, code: 'KeyA' }))).toBe('select-all')
+ expect(listKey(press('т', { ...ctrl, ...shift, code: 'KeyN' }))).toBe('new-folder')
+ })
+
+ it('leaves Ctrl+Left and Ctrl+Right alone: they are not navigation keys (2026-09-01)', () => {
+ expect(listKey(press('ArrowLeft', ctrl))).toBeNull()
+ expect(listKey(press('ArrowRight', ctrl))).toBeNull()
+ expect(listKey(press('ArrowLeft', shift))).toBeNull()
+ })
+
+ it('leaves what the surfaces already own, and anything with Win', () => {
+ for (const k of ['Delete', 'Enter', 'ArrowDown', 'ArrowUp', 'Home', 'End', 'F2', 'Backspace', 'a', ' '])
+ expect(listKey(press(k))).toBeNull()
+ for (const k of ['c', 'x', 'v', 'z', 'y', 'f'])
+ expect(listKey(press(k, ctrl))).toBeNull()
+ expect(listKey(press('a', { ...ctrl, metaKey: true }))).toBeNull()
+ expect(listKey(press('ArrowDown', { ...ctrl, ...shift }))).toBeNull()
+ expect(listKey(press('ArrowUp', { ...alt, ...ctrl }))).toBeNull()
+ expect(listKey(press('F3', shift))).toBeNull()
+ expect(listKey(press('Delete', ctrl))).toBeNull()
+ })
+})
+
+describe('stepTo', () => {
+ it('steps, stops at the ends, and jumps to them', () => {
+ expect(stepTo(5, 2, 'down')).toBe(3)
+ expect(stepTo(5, 4, 'down')).toBe(4)
+ expect(stepTo(5, 0, 'up')).toBe(0)
+ expect(stepTo(5, 3, 'home')).toBe(0)
+ expect(stepTo(5, 1, 'end')).toBe(4)
+ })
+ it('starts at an end with no row yet, and answers null for no rows', () => {
+ expect(stepTo(5, -1, 'down')).toBe(0)
+ expect(stepTo(5, -1, 'up')).toBe(4)
+ expect(stepTo(0, 2, 'down')).toBeNull()
+ })
+})
+
+describe('typeJump', () => {
+ const names = ['alpha', 'Beta', 'apple', 'zeta', 'apricot']
+ it('a single letter walks every row with it, wrapping round', () => {
+ expect(typeJump(names, -1, 'a')).toBe(0)
+ expect(typeJump(names, 0, 'a')).toBe(2)
+ expect(typeJump(names, 2, 'a')).toBe(4)
+ expect(typeJump(names, 4, 'a')).toBe(0)
+ })
+ it('a longer run keeps a row that already matches, and ignores case', () => {
+ expect(typeJump(names, 2, 'ap')).toBe(2)
+ expect(typeJump(names, 2, 'apr')).toBe(4)
+ expect(typeJump(names, 0, 'B')).toBe(1)
+ })
+ it('answers -1 when nothing matches, so the key is not taken', () => {
+ expect(typeJump(names, 0, 'q')).toBe(-1)
+ expect(typeJump([], 0, 'a')).toBe(-1)
+ expect(typeJump(names, 0, '')).toBe(-1)
+ })
+})
+
+describe('typedRun', () => {
+ it('extends inside 700 ms and starts again after', () => {
+ const first = typedRun({ text: '', at: 0 }, 'A', 1000)
+ expect(first).toEqual({ text: 'a', at: 1000 })
+ expect(typedRun(first, 'p', 1500).text).toBe('ap')
+ expect(typedRun(first, 'p', 1800).text).toBe('p')
+ })
+})
+
+describe('rangeSelect', () => {
+ const order = ['a', 'b', 'c', 'd', 'e']
+ it('marks the run from the anchor, growing and shrinking', () => {
+ const sel = { anchor: 'b', items: new Set(['b']) }
+ const down2 = rangeSelect(order, sel, 'd')
+ expect([...down2.items]).toEqual(['b', 'c', 'd'])
+ expect(down2.anchor).toBe('b')
+ expect([...rangeSelect(order, down2, 'c').items]).toEqual(['b', 'c'])
+ expect([...rangeSelect(order, down2, 'a').items]).toEqual(['a', 'b'])
+ })
+ it('replaces earlier marks, as Explorer does', () => {
+ const sel = { anchor: 'c', items: new Set(['a', 'c']) }
+ expect([...rangeSelect(order, sel, 'd').items]).toEqual(['c', 'd'])
+ })
+ it('starts the run at the row when the anchor has gone', () => {
+ const sel = { anchor: 'gone', items: new Set() }
+ expect(rangeSelect(order, sel, 'c')).toEqual({ anchor: 'c', items: new Set(['c']) })
+ expect(rangeSelect(order, { anchor: null, items: new Set() }, 'e').anchor).toBe('e')
+ })
+})
+
+describe('the physical key (review of #330)', () => {
+ it('answers a non-Latin layout by the key it sits on', () => {
+ expect(listKey(press('ф', { ...ctrl, code: 'KeyA' }))).toBe('select-all')
+ expect(listKey(press('в', { ...ctrl, code: 'KeyD' }))).toBe('bin')
+ })
+ it('gives a Latin layout no second binding (AZERTY)', () => {
+ // AZERTY: the key labelled Q sits where QWERTY's A is, and A where Q is.
+ expect(listKey(press('q', { ...ctrl, code: 'KeyA' }))).toBeNull()
+ expect(listKey(press('a', { ...ctrl, code: 'KeyQ' }))).toBe('select-all')
+ })
+})
+
+describe('isJumpKey', () => {
+ it('takes letters only, never the player keys', () => {
+ expect(isJumpKey('e')).toBe(true)
+ expect(isJumpKey('Æ')).toBe(true)
+ expect(isJumpKey('ø')).toBe(true)
+ for (const k of ['1', '0', '.', ',', '<', '>', '+', '-', ' ', 'Enter']) expect(isJumpKey(k)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/lib/listKeys.ts b/src/renderer/src/lib/listKeys.ts
new file mode 100644
index 00000000..21dfbfcb
--- /dev/null
+++ b/src/renderer/src/lib/listKeys.ts
@@ -0,0 +1,153 @@
+// THE COMMON FILE-LIST KEYS (#330; owner, 2026-10-07: "add common hotkeys to
+// the explorer and project so that for example ctrl + A selects all"). One
+// mapping for the Explorer's list and the project tree, so the two surfaces
+// answer the same chord the same way. Pure: the surfaces own the effects, and
+// each still decides whether the key is its to take (the typing guard, an
+// archive, a search showing).
+//
+// What is NOT here, on purpose: Ctrl+Left and Ctrl+Right (Left and Right are
+// not navigation keys, owner 2026-09-01: they reach the viewer), plain Delete
+// and plain Enter (each surface had them before this and keeps its own), and
+// Ctrl+C/X/V and Ctrl+Z (already the surfaces' and App's).
+
+export type ListKey =
+ /** Ctrl+A. */
+ | 'select-all'
+ /** Ctrl+Shift+A, or Escape when nothing nearer owns it. */
+ | 'clear'
+ /** Shift+Up/Down/Home/End: the marks run from the anchor to here. */
+ | 'extend-up'
+ | 'extend-down'
+ | 'extend-home'
+ | 'extend-end'
+ /** Ctrl+Up/Down/Home/End: the keyboard's place moves, the marks do not. */
+ | 'focus-up'
+ | 'focus-down'
+ | 'focus-home'
+ | 'focus-end'
+ /** Ctrl+Space: the focused row in or out of the marks. */
+ | 'toggle-mark'
+ /** Ctrl+Shift+N. */
+ | 'new-folder'
+ /** Ctrl+D and Shift+Delete: the Recycle Bin, as Delete (owner: Shift+Delete
+ * is NOT a permanent delete in Prism). */
+ | 'bin'
+ /** Alt+Up. */
+ | 'parent'
+ /** Alt+Left and Alt+Right: the Explorer's history; nothing in the tree. */
+ | 'back'
+ | 'forward'
+ /** F3. Ctrl+F stays where each surface already had it. */
+ | 'search'
+ /** Alt+Enter: Prism's own Properties. */
+ | 'properties'
+ /** Ctrl+Shift+C: the full paths as text, one per line. */
+ | 'copy-paths'
+ /** Ctrl+Enter: a folder in a new Explorer tab. */
+ | 'open-new-tab'
+
+export interface KeyLike {
+ key: string
+ code?: string
+ ctrlKey: boolean
+ shiftKey: boolean
+ altKey: boolean
+ metaKey: boolean
+}
+
+/** A letter chord by its PHYSICAL key too, but only when the layout's own
+ * key is not a Latin letter: a Russian layout's A is 'ф' and still selects
+ * all, while AZERTY's Ctrl+Q (physical KeyA) stays Ctrl+Q (review of #330). */
+const letter = (e: KeyLike, l: string): boolean =>
+ e.key.toLowerCase() === l || (!/^[a-z]$/i.test(e.key) && e.code === `Key${l.toUpperCase()}`)
+
+const EDGE: Record = {
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ Home: 'home',
+ End: 'end'
+}
+
+/** Which of the common keys this press is, or null for every other key. */
+export function listKey(e: KeyLike): ListKey | null {
+ if (e.metaKey) return null
+ const { ctrlKey: ctrl, shiftKey: shift, altKey: alt } = e
+ const edge = EDGE[e.key]
+ if (alt) {
+ if (ctrl || shift) return null
+ if (e.key === 'ArrowUp') return 'parent'
+ if (e.key === 'ArrowLeft') return 'back'
+ if (e.key === 'ArrowRight') return 'forward'
+ if (e.key === 'Enter') return 'properties'
+ return null
+ }
+ if (ctrl && shift) {
+ if (letter(e, 'a')) return 'clear'
+ if (letter(e, 'n')) return 'new-folder'
+ if (letter(e, 'c')) return 'copy-paths'
+ return null
+ }
+ if (ctrl) {
+ if (letter(e, 'a')) return 'select-all'
+ if (letter(e, 'd')) return 'bin'
+ if (edge) return `focus-${edge}`
+ if (e.key === ' ' || e.code === 'Space') return 'toggle-mark'
+ if (e.key === 'Enter') return 'open-new-tab'
+ return null
+ }
+ if (shift) {
+ if (edge) return `extend-${edge}`
+ if (e.key === 'Delete') return 'bin'
+ return null
+ }
+ if (e.key === 'Escape') return 'clear'
+ if (e.key === 'F3') return 'search'
+ return null
+}
+
+/** Where a step lands in a list of `count` rows, from `from` (-1: none yet). */
+export function stepTo(
+ count: number,
+ from: number,
+ to: 'up' | 'down' | 'home' | 'end'
+): number | null {
+ if (count <= 0) return null
+ if (to === 'home') return 0
+ if (to === 'end') return count - 1
+ if (from < 0) return to === 'down' ? 0 : count - 1
+ return to === 'down' ? Math.min(count - 1, from + 1) : Math.max(0, from - 1)
+}
+
+/**
+ * TYPE TO JUMP, the tree's (#330; owner: "add it like the Explorer's"). The
+ * row whose name starts with what was typed. A single letter looks from the
+ * row AFTER the current one, so pressing it again walks every row with that
+ * letter; a longer run looks from the current row itself, so typing on keeps
+ * the row that already matches. Wraps round. -1 when nothing matches, and then
+ * the key is not taken: it still reaches the viewer.
+ */
+export function typeJump(names: readonly string[], from: number, typed: string): number {
+ const text = typed.toLowerCase()
+ if (!text || !names.length) return -1
+ const start = text.length === 1 ? from + 1 : Math.max(0, from)
+ for (let i = 0; i < names.length; i++) {
+ const at = (((start + i) % names.length) + names.length) % names.length
+ if (names[at].toLowerCase().startsWith(text)) return at
+ }
+ return -1
+}
+
+/** Whether a plain key may jump the tree: a LETTER (the owner's word), never
+ * a digit or punctuation, which are the player's seek and frame keys (a
+ * numbered album would switch tracks on a seek; review of #330). */
+export const isJumpKey = (key: string): boolean => /^\p{L}$/u.test(key)
+
+/** The run a typed letter adds to: within 700 ms it extends, else it starts
+ * again (the Explorer's own window). */
+export function typedRun(
+ last: { text: string; at: number },
+ key: string,
+ now: number
+): { text: string; at: number } {
+ return { text: now - last.at < 700 ? last.text + key.toLowerCase() : key.toLowerCase(), at: now }
+}
diff --git a/src/renderer/src/lib/nearerEscape.ts b/src/renderer/src/lib/nearerEscape.ts
new file mode 100644
index 00000000..a7963d23
--- /dev/null
+++ b/src/renderer/src/lib/nearerEscape.ts
@@ -0,0 +1,13 @@
+/**
+ * WHETHER SOMETHING NEARER OWNS ESCAPE (review of #330). The Explorer's list
+ * and the project tree take a plain Escape to clear the marks, and something
+ * is nearly always marked (the open file is), so they would take almost every
+ * Escape and stop it there. What closes on Escape from a window listener
+ * would then stay open: the PDF find bar (it carries `data-owns-escape`, App's
+ * own signal to stand down), a menu, a dialog, and the sidebar while it peeks
+ * (`sidebarPeek.ts`: Escape closes the peek). While any of them is up the
+ * lists leave Escape alone; Ctrl+Shift+A still clears.
+ */
+export function nearerEscape(doc: Pick = document): boolean {
+ return !!doc.querySelector('[data-owns-escape],[data-project-sidebar][data-peek]')
+}
diff --git a/src/renderer/src/lib/selection.ts b/src/renderer/src/lib/selection.ts
index f709d77b..48c708d4 100644
--- a/src/renderer/src/lib/selection.ts
+++ b/src/renderer/src/lib/selection.ts
@@ -47,3 +47,15 @@ export function clickSelect(
return { anchor: path, items: new Set([path]) }
}
+/**
+ * SHIFT+ARROW, Shift+Home, Shift+End (#330): the marks are the run from the
+ * anchor to `to`, and only that run, so a Shift+Up after two Shift+Downs gives
+ * a row back, as every file list does. The anchor stays where it is; an anchor
+ * that has left the rows starts the run at `to`. Marking by keyboard is quiet
+ * (#263): the caller selects nothing it would open or play.
+ */
+export function rangeSelect(order: readonly string[], sel: Selection, to: string): Selection {
+ const anchor = sel.anchor && order.includes(sel.anchor) ? sel.anchor : to
+ return { anchor, items: new Set(rangeOf(order, anchor, to)) }
+}
+
diff --git a/src/renderer/src/lib/treeContext.ts b/src/renderer/src/lib/treeContext.ts
index 9947c4fc..502e6854 100644
--- a/src/renderer/src/lib/treeContext.ts
+++ b/src/renderer/src/lib/treeContext.ts
@@ -14,6 +14,10 @@ export interface TreeApi {
/** The row the arrow keys are on. Usually the open file, but it steps onto
* folders too, where there is nothing to open and only a highlight to move. */
cursor: string | null
+ /** False while the Ctrl keys moved the cursor apart from the marks (#330):
+ * the row then has the keyboard's focus and NOT the mark, so Ctrl+Down
+ * does not look like a pick. Optional: absent is true, as before. */
+ cursorMarks?: boolean
size: (typeof TREE_SIZES)[number]
/** Path of the file being renamed right now, if any. */
editing: string | null
diff --git a/src/renderer/src/lib/undo.test.ts b/src/renderer/src/lib/undo.test.ts
index 702fc137..e1b2667c 100644
--- a/src/renderer/src/lib/undo.test.ts
+++ b/src/renderer/src/lib/undo.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { describe as label, emptyUndo, redone, remember, undone, type UndoEntry } from './undo'
+import { describe as label, emptyUndo, redone, remember, rememberRename, undone, type UndoEntry } from './undo'
const move: UndoEntry = { kind: 'move', items: [{ from: 'C:\\a\\f.txt', to: 'C:\\b\\f.txt' }] }
const ren: UndoEntry = { kind: 'rename', from: 'C:\\a\\old.txt', to: 'C:\\a\\new.txt' }
@@ -49,6 +49,10 @@ describe('the undo stack', () => {
expect(undone(s)!.entry).toEqual({ kind: 'duplicate', source: 'C:\\a\\src.txt', path: 'C:\\a\\59.txt' })
})
+ it('names a new folder for the message (#330)', () => {
+ expect(label({ kind: 'mkdir', path: 'C:\\a\\New folder (2)' })).toBe('making New folder (2)')
+ })
+
it('names an archive move for the message too', () => {
expect(
label({ kind: 'archive-in', zip: 'C:\\a\\box.zip', dest: '', entries: ['one.txt'], originals: [] })
@@ -62,3 +66,19 @@ describe('the undo stack', () => {
expect(label({ kind: 'duplicate', source: 'C:\\a\\p.png', path: 'C:\\a\\p (2).png' })).toBe('duplicating p (2).png')
})
})
+
+describe('rememberRename (#330)', () => {
+ const made: UndoEntry = { kind: 'mkdir', path: 'C:\\a\\New folder' }
+ it('folds the rename of a folder just made into its entry, so one undo takes it away', () => {
+ const s = rememberRename(remember(emptyUndo, made), { kind: 'rename', from: 'C:\\a\\new FOLDER', to: 'C:\\a\\Photos' })
+ expect(s.past).toEqual([{ kind: 'mkdir', path: 'C:\\a\\Photos' }])
+ expect(undone(s)!.entry).toEqual({ kind: 'mkdir', path: 'C:\\a\\Photos' })
+ })
+ it('stacks any other rename as before', () => {
+ const s = rememberRename(remember(emptyUndo, made), ren as Extract)
+ expect(s.past).toEqual([made, ren])
+ const over = rememberRename(remember(emptyUndo, made), { kind: 'rename', from: 'C:\\a\\New folder', to: 'C:\\a\\x', replaced: 'C:\\a\\x' })
+ expect(over.past.length).toBe(2)
+ expect(rememberRename(emptyUndo, ren as Extract).past).toEqual([ren])
+ })
+})
diff --git a/src/renderer/src/lib/undo.ts b/src/renderer/src/lib/undo.ts
index 2651ed00..7f3fc2e4 100644
--- a/src/renderer/src/lib/undo.ts
+++ b/src/renderer/src/lib/undo.ts
@@ -19,6 +19,9 @@ export type UndoEntry =
/** Files MOVED into an archive: the members went in and the originals went
* to the bin, so undo takes both halves back. */
| { kind: 'archive-in'; zip: string; dest: string; entries: string[]; originals: string[] }
+ /** A NEW FOLDER (#330, Ctrl+Shift+N): undo bins it, and only while it is
+ * still empty; redo makes one of the same name in the same place. */
+ | { kind: 'mkdir'; path: string }
export interface UndoState {
past: readonly UndoEntry[]
@@ -37,6 +40,23 @@ export function remember(state: UndoState, entry: UndoEntry): UndoState {
return { past: [...state.past, entry].slice(-CAP), future: [] }
}
+/**
+ * A RENAME THAT FINISHES A NEW FOLDER (#330). Ctrl+Shift+N makes "New folder"
+ * and puts it straight into a rename, Explorer's way, and Explorer's Undo then
+ * takes the folder away in one step, under whatever name it was given. So a
+ * rename of the folder the newest entry just made folds into that entry
+ * rather than stacking a second one; any other rename is remembered as usual.
+ */
+export function rememberRename(
+ state: UndoState,
+ entry: Extract
+): UndoState {
+ const top = state.past[state.past.length - 1]
+ if (top?.kind === 'mkdir' && !entry.replaced && top.path.toLowerCase() === entry.from.toLowerCase())
+ return { past: [...state.past.slice(0, -1), { kind: 'mkdir', path: entry.to }], future: [] }
+ return remember(state, entry)
+}
+
/** Take the newest action off the past; the caller reverses it, then it waits
* in the future for a redo. Null when there is nothing to undo. */
export function undone(state: UndoState): { state: UndoState; entry: UndoEntry } | null {
@@ -68,6 +88,8 @@ export function describe(entry: UndoEntry): string {
return entry.paths.length > 1 ? `deleting ${entry.paths.length} items` : `deleting ${baseName(entry.paths[0] ?? '')}`
case 'duplicate':
return `duplicating ${baseName(entry.path)}`
+ case 'mkdir':
+ return `making ${baseName(entry.path)}`
case 'archive-in':
return entry.entries.length > 1
? `moving ${entry.entries.length} items into ${baseName(entry.zip)}`
diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs
index 667cb436..a4e24eef 100644
--- a/tools/e2e/run.mjs
+++ b/tools/e2e/run.mjs
@@ -5761,6 +5761,17 @@ async function sidebarPeekScenario(fixtures) {
ok(await until(async () => (await peeking()) === 'in', 2000, 25), 'out once more')
await win.keyboard.press('Escape')
ok(await until(async () => (await peeking()) === null, 2000, 25), 'Escape puts it away')
+ // Even with the keyboard on a row and the open file marked: the tree's
+ // Escape (clear the marks, #330) yields to the peek's.
+ await win.mouse.move(away.x, away.y)
+ await sleep(100)
+ await win.mouse.move(2, edgeY)
+ ok(await until(async () => (await peeking()) === 'in', 2000, 25), 'out for a row')
+ await sleep(250)
+ await win.locator(`${side} [role="treeitem"]`).first().focus()
+ const peekMarks = await win.locator(`${side} [data-row][data-selected]`).count()
+ await win.keyboard.press('Escape')
+ ok(await until(async () => (await peeking()) === null, 2000, 25), `Escape on a row puts the peek away first (${peekMarks} marked)`)
// Its header toggle pins it: the content moves over and the peek ends.
await win.mouse.move(away.x, away.y)
@@ -8229,6 +8240,535 @@ async function marqueeEdgeScenario(fixtures) {
}
}
+/**
+ * THE COMMON FILE KEYS (#330; owner, 2026-10-07: "add common hotkeys to the
+ * explorer and project so that for example ctrl + A selects all"). Every key
+ * of the chosen list, in the project tree and in the Explorer's list, against
+ * a folder of its own: select all and clear, Shift to extend (opening
+ * nothing), Ctrl to move without marking and Ctrl+Space to mark (a film
+ * marked does not play), Ctrl+Shift+N making and naming a folder that one
+ * Ctrl+Z takes away, Ctrl+D and Shift+Delete to the bin and back, Alt+Up to
+ * the parent row, type-to-jump in the tree, F3 to the search over an open PDF,
+ * Alt+Enter Properties, Ctrl+Shift+C the paths (read back in main, the owner's
+ * clipboard put back), Ctrl+Enter a new tab; and none of it while typing.
+ */
+async function hotkeysScenario(fixtures) {
+ console.log('the common file keys (#330)')
+ const dir = join(fixtures, 'hotkeys')
+ rmSync(dir, { recursive: true, force: true })
+ mkdirSync(join(dir, 'golf'), { recursive: true })
+ mkdirSync(join(dir, 'hotel'), { recursive: true })
+ for (const n of ['alpha.txt', 'bravo.txt', 'charlie.txt', 'foxtrot.txt', '01 one.txt'])
+ writeFileSync(join(dir, n), `${n}\n`)
+ // inner.txt has a row above it that is not golf, so Alt+Up cannot pass
+ // for the parent by landing on the row above (review of #330).
+ writeFileSync(join(dir, 'golf', 'early.txt'), 'early\n')
+ writeFileSync(join(dir, 'golf', 'inner.txt'), 'inner\n')
+ copyFileSync(join(fixtures, 'ep1.mp4'), join(dir, 'delta.mp4'))
+ copyFileSync(join(fixtures, 'sample.pdf'), join(dir, 'echo.pdf'))
+ const AdmZip = (await import('adm-zip')).default
+ const kilo = new AdmZip()
+ kilo.addFile('member.txt', Buffer.from('member\n'))
+ kilo.writeZip(join(dir, 'kilo.zip'))
+ const top = readdirSync(dir).length
+ const { app, win } = await launch(join(dir, 'alpha.txt'))
+ const held = await app.evaluate(({ clipboard }) => clipboard.readText()).catch(() => '')
+ const clip = () => app.evaluate(({ clipboard }) => clipboard.readText())
+ const base = (p) => /[^\\]*$/.exec(p ?? '')?.[0] ?? ''
+ const playing = () =>
+ win.evaluate(() => [...document.querySelectorAll('video,audio')].filter((v) => !v.paused).length)
+ const dialogs = () => win.locator('[role="dialog"]').count()
+ const tabs = () => win.locator('[role="tablist"] [role="tab"]').count()
+ const focusedRow = () =>
+ win.evaluate(() => {
+ const a = document.activeElement
+ return /[^\\]*$/.exec(a?.getAttribute('data-row') ?? a?.getAttribute('data-browse-path') ?? '')?.[0] ?? ''
+ })
+ const confirmDelete = async () => {
+ ok(await until(async () => (await dialogs()) === 1, 5000), 'the bin key asks first, as Delete does')
+ await win.locator('[role="dialog"] button:has-text("Delete")').click()
+ await sleep(300)
+ }
+ const back = (rel) => until(async () => existsSync(join(dir, rel)), 10000)
+ const gone = (rel) => until(async () => !existsSync(join(dir, rel)), 10000)
+ try {
+ /* ================= the project tree ================= */
+ await win.waitForSelector('aside [role="treeitem"]', { timeout: 15000 })
+ const treeRow = (name) =>
+ win.locator(`aside [role="treeitem"][data-row$="\\\\${name}"]`).first()
+ const treeMarked = () =>
+ win.evaluate(() => [...document.querySelectorAll('aside [data-row][data-selected]')].map((r) => /[^\\]*$/.exec(r.getAttribute('data-row') ?? '')?.[0]))
+ const treeOpen = async () => (await win.locator('aside [role="treeitem"][aria-selected="true"]').getAttribute('data-row').catch(() => '')) ?? ''
+ const treeNames = () =>
+ win.evaluate(() => [...document.querySelectorAll('aside [role="treeitem"][data-row]')].map((r) => /[^\\]*$/.exec(r.getAttribute('data-row') ?? '')?.[0]))
+ await until(async () => (await win.locator('aside [role="treeitem"]').count()) >= top, 10000)
+ await treeRow('alpha.txt').click()
+ await sleep(400)
+
+ await win.keyboard.press('Control+a')
+ await sleep(300)
+ ok((await treeMarked()).length === top, `tree: Ctrl+A marks every row (${await treeMarked()})`)
+ await win.keyboard.press('Escape')
+ await sleep(300)
+ ok((await treeMarked()).length === 0, `tree: Escape clears them (${await treeMarked()})`)
+ await win.keyboard.press('Control+a')
+ await sleep(200)
+ await win.keyboard.press('Control+Shift+A')
+ await sleep(300)
+ ok((await treeMarked()).length === 0, 'tree: and so does Ctrl+Shift+A')
+
+ await treeRow('alpha.txt').click()
+ await sleep(300)
+ await win.keyboard.press('Shift+ArrowDown')
+ await win.keyboard.press('Shift+ArrowDown')
+ await sleep(500)
+ ok((await treeMarked()).sort().join() === 'alpha.txt,bravo.txt,charlie.txt', `tree: Shift+Down twice marks a run of three (${await treeMarked()})`)
+ ok(base(await treeOpen()) === 'alpha.txt', `tree: and opens nothing it lands on (${base(await treeOpen())})`)
+ ok((await focusedRow()) === 'charlie.txt', `tree: the keyboard is on the last row of the run (${await focusedRow()})`)
+ await win.keyboard.press('Shift+ArrowUp')
+ await sleep(400)
+ ok((await treeMarked()).sort().join() === 'alpha.txt,bravo.txt', `tree: Shift+Up gives a row back (${await treeMarked()})`)
+
+ await win.keyboard.press('Control+ArrowDown')
+ await sleep(300)
+ // The bin keys take the MARKS, never the unlit row the keyboard is on
+ // (review of #330: the Explorer's rule).
+ await win.keyboard.press('Control+d')
+ ok(await until(async () => (await dialogs()) === 1, 5000), 'tree: Ctrl+D off the marks still asks')
+ const askText = (await win.locator('[role="dialog"]').textContent()) ?? ''
+ ok(!/charlie/.test(askText) && /2/.test(askText), `tree: about the two marked rows, not the unlit one (${askText})`)
+ await win.locator('[role="dialog"] button:has-text("Cancel")').click()
+ await sleep(300)
+ ok(existsSync(join(dir, 'alpha.txt')) && existsSync(join(dir, 'charlie.txt')), 'tree: and Cancel deletes nothing')
+ await treeRow('charlie.txt').focus()
+ await win.keyboard.press('Control+ArrowDown')
+ await sleep(400)
+ ok((await focusedRow()) === 'delta.mp4', `tree: Ctrl+Down moves the keyboard (${await focusedRow()})`)
+ ok((await treeMarked()).sort().join() === 'alpha.txt,bravo.txt', 'tree: and leaves the marks alone')
+ await win.keyboard.press('Control+Space')
+ await sleep(800)
+ ok((await treeMarked()).sort().join() === 'alpha.txt,bravo.txt,delta.mp4', `tree: Ctrl+Space marks the film (${await treeMarked()})`)
+ ok((await playing()) === 0 && base(await treeOpen()) === 'alpha.txt', 'tree: and neither opens nor plays it')
+
+ await win.keyboard.press('Control+Shift+C')
+ await sleep(400)
+ const treeClip = (await clip()).split(/\r?\n/)
+ ok(
+ treeClip.map(base).join() === 'alpha.txt,bravo.txt,delta.mp4' &&
+ treeClip.every((p) => p.toLowerCase().startsWith(dir.toLowerCase() + '\\')),
+ `tree: Ctrl+Shift+C copies the full paths, one per line (${treeClip.join(' | ')})`
+ )
+ await win.keyboard.press('Escape')
+ await sleep(200)
+
+ // Type to jump: a match is taken (and opens, as the arrows do); a letter
+ // with no match is not.
+ await win.keyboard.press('e')
+ await sleep(700)
+ ok((await focusedRow()) === 'echo.pdf', `tree: typing "e" jumps to echo.pdf (${await focusedRow()})`)
+ await win.keyboard.press('q')
+ await sleep(300)
+ ok((await focusedRow()) === 'echo.pdf', 'tree: a letter nothing starts with leaves the row where it is')
+ // A digit is never a jump, though "01 one.txt" starts with one: digits are
+ // the player's seek, and a jump opens what it lands on (review of #330).
+ await sleep(800)
+ await win.keyboard.press('0')
+ await sleep(500)
+ ok((await focusedRow()) === 'echo.pdf' && base(await treeOpen()) === 'echo.pdf', `tree: a digit jumps nowhere (${await focusedRow()}, ${base(await treeOpen())})`)
+ // F3 with a PDF open: the tree's filter, not the PDF's find.
+ await until(async () => base(await treeOpen()) === 'echo.pdf', 8000)
+ await sleep(800)
+ await treeRow('echo.pdf').focus()
+ await win.keyboard.press('F3')
+ await sleep(400)
+ ok(
+ (await win.evaluate(() => document.activeElement?.getAttribute('aria-label'))) === 'Search files',
+ 'tree: F3 focuses the tree filter'
+ )
+ ok((await win.locator('input[aria-label="Find in document"]').count()) === 0, 'tree: and the PDF find bar stays shut')
+ // Nothing while typing: Ctrl+A in the filter is the field's.
+ await win.keyboard.type('a')
+ await win.keyboard.press('Control+a')
+ await sleep(300)
+ await win.keyboard.press('Escape')
+ await sleep(400)
+ ok((await treeMarked()).length <= 1, `tree: Ctrl+A in the filter marks no rows (${await treeMarked()})`)
+
+ // Escape on a row yields to what is nearer (review of #330): the PDF's
+ // find bar, open with the keyboard in the tree, shuts, and the marks stay.
+ await win.evaluate(() => document.activeElement?.blur?.())
+ await win.keyboard.press('F3')
+ ok(await until(async () => (await win.locator('input[aria-label="Find in document"]').count()) === 1, 5000), 'tree: F3 from nowhere is the PDF\'s find')
+ await treeRow('echo.pdf').focus()
+ await sleep(200)
+ const marksAtFind = (await treeMarked()).join()
+ await win.keyboard.press('Escape')
+ ok(await until(async () => (await win.locator('input[aria-label="Find in document"]').count()) === 0, 3000), 'tree: Escape on a row shuts the PDF find bar first')
+ ok((await treeMarked()).join() === marksAtFind && marksAtFind !== '', `tree: and leaves the marks (${marksAtFind} -> ${await treeMarked()})`)
+
+ // A letter the tree takes is not the PDF's: f is its fullscreen.
+ await treeRow('echo.pdf').focus()
+ await sleep(800)
+ await win.keyboard.press('f')
+ await sleep(700)
+ ok((await focusedRow()) === 'foxtrot.txt', `tree: "f" jumps to foxtrot.txt (${await focusedRow()})`)
+ ok((await win.locator('aside[data-project-sidebar]').count()) === 1 && !(await win.evaluate(() => !!document.fullscreenElement)), 'tree: and the PDF did not go fullscreen on it')
+
+ // Alt+Up: the parent row.
+ await treeRow('golf').click()
+ await sleep(300)
+ await treeRow('golf').click()
+ await win.waitForSelector('aside [role="treeitem"][data-row$="inner.txt"]', { timeout: 8000 })
+ await treeRow('inner.txt').click()
+ await sleep(500)
+ await win.keyboard.press('Alt+ArrowUp')
+ await sleep(500)
+ ok((await focusedRow()) === 'golf', `tree: Alt+Up goes to the parent row (${await focusedRow()})`)
+ ok(base(await treeOpen()) === 'inner.txt', `tree: without opening the row above on the way (${base(await treeOpen())})`)
+
+ // Ctrl+Shift+N in the folder on the cursor, then its name; nothing in the
+ // rename field reaches the tree; one Ctrl+Z takes it away.
+ await win.keyboard.press('Control+Shift+N')
+ ok(await until(async () => existsSync(join(dir, 'golf', 'New folder')), 8000), 'tree: Ctrl+Shift+N makes "New folder" in the folder on the cursor')
+ const treeRename = win.locator('aside input:not([aria-label="Search files"])')
+ ok(await until(async () => (await treeRename.count()) === 1, 8000), 'tree: and starts renaming it')
+ await win.keyboard.press('Control+d')
+ await sleep(400)
+ ok((await dialogs()) === 0 && existsSync(join(dir, 'golf', 'New folder')), 'tree: Ctrl+D in the rename field deletes nothing')
+ await treeRename.fill('made-tree')
+ await win.keyboard.press('Enter')
+ ok(await back('golf\\made-tree'), 'tree: the name lands')
+ // The keyboard follows the row to its new name (review of #330).
+ ok(await until(async () => (await focusedRow()) === 'made-tree', 5000), `tree: and the keyboard is on it (${await focusedRow()})`)
+ // Lit by the cursor (the rename's refresh clears the marks, as any does).
+ const madeLit = await win.evaluate(() => {
+ const bg = (n) => getComputedStyle(document.querySelector(`aside [role="treeitem"][data-row$="\\${n}"]`)).backgroundColor
+ return [bg('made-tree'), bg('hotel')]
+ })
+ ok(madeLit[0] !== madeLit[1], `tree: and it is lit under its new name (${madeLit.join(' vs ')})`)
+ await sleep(600)
+ await treeRow('alpha.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Control+z')
+ ok(await gone('golf\\made-tree'), 'tree: one Ctrl+Z takes the new folder away')
+ ok(!existsSync(join(dir, 'golf', 'New folder')), 'tree: under either name')
+ await sleep(600)
+ // From a FILE row the folder goes beside it; Escape in the name keeps the
+ // row and the keyboard on it.
+ await treeRow('alpha.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Control+Shift+N')
+ ok(await until(async () => existsSync(join(dir, 'New folder')), 8000), 'tree: Ctrl+Shift+N on a file makes the folder beside it')
+ ok(await until(async () => (await treeRename.count()) === 1, 8000), 'tree: and starts renaming it')
+ await win.keyboard.press('Escape')
+ ok(await until(async () => (await focusedRow()) === 'New folder', 5000), `tree: Escape in the name leaves the keyboard on the row (${await focusedRow()})`)
+ await win.keyboard.press('Control+z')
+ ok(await gone('New folder'), 'tree: and Ctrl+Z takes it away')
+ await sleep(600)
+
+ // With nothing marked (Escape), the bin keys bin nothing.
+ await treeRow('bravo.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Escape')
+ await sleep(300)
+ await win.keyboard.press('Control+d')
+ await win.keyboard.press('Shift+Delete')
+ await sleep(500)
+ ok((await dialogs()) === 0 && existsSync(join(dir, 'bravo.txt')), 'tree: after Escape Ctrl+D and Shift+Delete ask about nothing')
+
+ // Ctrl+D and Shift+Delete: the question, the bin, and Ctrl+Z.
+ await treeRow('bravo.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Control+d')
+ await confirmDelete()
+ ok(await gone('bravo.txt'), 'tree: Ctrl+D sends it to the Recycle Bin')
+ await treeRow('alpha.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Control+z')
+ ok(await back('bravo.txt'), 'tree: and Ctrl+Z brings it back')
+ await sleep(900)
+ await treeRow('charlie.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Shift+Delete')
+ await confirmDelete()
+ ok(await gone('charlie.txt'), 'tree: Shift+Delete goes to the bin too, never past it')
+ await treeRow('alpha.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Control+z')
+ ok(await back('charlie.txt'), 'tree: and Ctrl+Z brings that back')
+ await sleep(900)
+
+ // Alt+Enter: Properties.
+ await treeRow('alpha.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Alt+Enter')
+ ok(await until(async () => /alpha\.txt/.test((await win.locator('[role="dialog"]').textContent().catch(() => '')) ?? ''), 5000), 'tree: Alt+Enter opens Properties')
+ await win.keyboard.press('Escape')
+ await sleep(400)
+
+ // THE PLAYER IGNORES WHAT THE TREE TOOK (review of #330). Its keys are on
+ // the window and ignore modifiers: unclaimed, Ctrl+Space would play,
+ // Ctrl+Up/Down change the volume, Shift/Ctrl+Home/End and Alt+Left/Right
+ // seek. A paused film, then every one of them from its row.
+ const film = () =>
+ win.evaluate(() => {
+ const v = [...document.querySelectorAll('video')].sort((a, b) => b.clientWidth - a.clientWidth)[0]
+ return v ? { paused: v.paused, t: Math.round(v.currentTime * 10), vol: v.volume, ready: v.readyState } : null
+ })
+ await treeRow('delta.mp4').click()
+ ok(await until(async () => base(await treeOpen()) === 'delta.mp4' && ((await film())?.ready ?? 0) >= 1, 10000), 'tree: the film opens')
+ await sleep(800)
+ await win.evaluate(() => {
+ const v = [...document.querySelectorAll('video')].sort((a, b) => b.clientWidth - a.clientWidth)[0]
+ v.pause()
+ v.currentTime = 1
+ })
+ await sleep(600)
+ const still = await film()
+ await treeRow('delta.mp4').focus()
+ await win.keyboard.press('Control+Space')
+ await sleep(300)
+ ok((await treeMarked()).length === 0, `tree: Ctrl+Space takes the row out of the marks (${await treeMarked()})`)
+ await win.keyboard.press('Control+Space')
+ await sleep(300)
+ ok((await treeMarked()).join() === 'delta.mp4', `tree: and again puts it back (${await treeMarked()})`)
+ const names = await treeNames()
+ await win.keyboard.press('Control+End')
+ await sleep(400)
+ ok((await focusedRow()) === names[names.length - 1] && (await treeMarked()).join() === 'delta.mp4', `tree: Ctrl+End moves the keyboard to the last row only (${await focusedRow()}, ${await treeMarked()})`)
+ await win.keyboard.press('Control+Home')
+ await sleep(400)
+ ok((await focusedRow()) === names[0] && (await treeMarked()).join() === 'delta.mp4', `tree: Ctrl+Home to the first (${await focusedRow()}, ${await treeMarked()})`)
+ // A folder row the keyboard sits on apart from the marks is not lit.
+ const tint = await win.evaluate(
+ ([a, b]) => {
+ const bg = (n) => getComputedStyle(document.querySelector(`aside [role="treeitem"][data-row$="\\${n}"]`)).backgroundColor
+ return [bg(a), bg(b)]
+ },
+ [names[0], 'hotel']
+ )
+ ok(tint[0] === tint[1], `tree: the unmarked folder under the keyboard wears no tint (${tint.join(' vs ')})`)
+ const di = names.indexOf('delta.mp4')
+ await win.keyboard.press('Shift+End')
+ await sleep(400)
+ ok((await treeMarked()).sort().join() === names.slice(di).sort().join(), `tree: Shift+End marks from the anchor to the end (${await treeMarked()})`)
+ await win.keyboard.press('Shift+Home')
+ await sleep(400)
+ ok((await treeMarked()).sort().join() === names.slice(0, di + 1).sort().join(), `tree: Shift+Home from the top to the anchor (${await treeMarked()})`)
+ const placeBefore = await focusedRow()
+ await win.keyboard.press('Alt+ArrowLeft')
+ await win.keyboard.press('Alt+ArrowRight')
+ await win.keyboard.press('Control+ArrowDown')
+ await win.keyboard.press('Control+ArrowUp')
+ await sleep(600)
+ ok((await focusedRow()) === placeBefore, `tree: Alt+Left and Alt+Right do nothing here (${await focusedRow()})`)
+ const after = await film()
+ ok(
+ !!after && after.paused && after.t === still.t && after.vol === still.vol && base(await treeOpen()) === 'delta.mp4',
+ `tree: and the paused film neither played, seeked nor changed volume (${JSON.stringify(still)} -> ${JSON.stringify(after)})`
+ )
+
+ // Ctrl+Enter on a file opens nothing.
+ const onFile = await tabs()
+ await treeRow('alpha.txt').click()
+ await sleep(400)
+ await win.keyboard.press('Control+Enter')
+ await sleep(800)
+ ok((await tabs()) === onFile, 'tree: Ctrl+Enter on a file opens no tab')
+
+ // Ctrl+Enter: a folder in a new tab.
+ const before = await tabs()
+ await treeRow('hotel').click()
+ await sleep(400)
+ await win.keyboard.press('Control+Enter')
+ ok(await until(async () => (await tabs()) === before + 1, 8000), `tree: Ctrl+Enter opens the folder in a new tab (${before} -> ${await tabs()})`)
+
+ /* ================= the Explorer's list ================= */
+ await win.locator('[role="tablist"] [data-pinned] [role="tab"]').click()
+ await win.waitForSelector('[data-testid="browse-list"]', { timeout: 10000 })
+ await win.locator('[data-testid="browse-edit-path"]').click()
+ await win.locator('.browse-path-form input').fill(dir)
+ await win.keyboard.press('Enter')
+ const list = win.locator('[data-testid="browse-list"]')
+ const exRow = (name) => list.locator(`[data-browse-path$="\\\\${name}"]`).first()
+ const exMarked = () =>
+ win.evaluate(() => [...document.querySelectorAll('[data-testid="browse-list"] [data-browse-path][aria-selected="true"]')].map((r) => /[^\\]*$/.exec(r.getAttribute('data-browse-path') ?? '')?.[0]))
+ const status = async () => (await win.locator('.browse-status').textContent()) ?? ''
+ const paneFilm = () => win.evaluate(() => !!document.querySelector('[data-browse-preview] video'))
+ ok(await until(async () => (await list.locator('.browse-row[data-browse-path]').count()) === top, 10000), 'explorer: the folder is listed')
+ const click = async (name) => {
+ const box = await exRow(name).boundingBox()
+ await exRow(name).click({ position: { x: 30, y: box.height / 2 } })
+ await sleep(400)
+ }
+ await click('alpha.txt')
+
+ await win.keyboard.press('Control+a')
+ await sleep(400)
+ ok((await exMarked()).length === top, `explorer: Ctrl+A marks every row (${await exMarked()})`)
+ ok(new RegExp(`${top} selected`).test(await status()), `explorer: the status line counts them (${await status()})`)
+ await win.keyboard.press('Escape')
+ await sleep(400)
+ ok((await exMarked()).length === 0, `explorer: Escape clears them (${await exMarked()})`)
+ await click('alpha.txt')
+ await win.keyboard.press('Control+a')
+ await sleep(200)
+ await win.keyboard.press('Control+Shift+A')
+ await sleep(400)
+ ok((await exMarked()).length === 0, 'explorer: and so does Ctrl+Shift+A')
+
+ await click('alpha.txt')
+ await win.keyboard.press('Shift+ArrowDown')
+ await win.keyboard.press('Shift+ArrowDown')
+ await win.keyboard.press('Shift+ArrowDown')
+ await sleep(700)
+ ok((await exMarked()).sort().join() === 'alpha.txt,bravo.txt,charlie.txt,delta.mp4', `explorer: Shift+Down marks a run (${await exMarked()})`)
+ ok(!(await paneFilm()) && (await playing()) === 0, 'explorer: landing on the film by Shift previews and plays nothing')
+ await win.keyboard.press('Shift+ArrowUp')
+ await sleep(400)
+ ok((await exMarked()).sort().join() === 'alpha.txt,bravo.txt,charlie.txt', `explorer: Shift+Up gives a row back (${await exMarked()})`)
+
+ await win.keyboard.press('Control+ArrowDown')
+ await sleep(400)
+ ok((await focusedRow()) === 'delta.mp4', `explorer: Ctrl+Down moves the keyboard (${await focusedRow()})`)
+ ok((await exMarked()).length === 3, 'explorer: and leaves the marks alone')
+ await win.keyboard.press('Control+Space')
+ await sleep(900)
+ ok((await exMarked()).sort().join() === 'alpha.txt,bravo.txt,charlie.txt,delta.mp4', `explorer: Ctrl+Space marks the film (${await exMarked()})`)
+ ok(!(await paneFilm()) && (await playing()) === 0, 'explorer: and the film neither previews nor plays')
+ await win.keyboard.press('Control+Space')
+ await sleep(400)
+ ok((await exMarked()).length === 3, 'explorer: Ctrl+Space again takes it back out')
+
+ await win.keyboard.press('Control+Shift+C')
+ await sleep(400)
+ const exClip = (await clip()).split(/\r?\n/)
+ ok(
+ exClip.map(base).join() === 'alpha.txt,bravo.txt,charlie.txt' &&
+ exClip.every((p) => p.toLowerCase().startsWith(dir.toLowerCase() + '\\')),
+ `explorer: Ctrl+Shift+C copies the full paths, one per line (${exClip.join(' | ')})`
+ )
+ await win.keyboard.press('Escape')
+ await sleep(300)
+
+ // Ctrl+Home/End move the keyboard only; Shift+Home/End mark to the edge.
+ const exNames = () =>
+ win.evaluate(() => [...document.querySelectorAll('[data-testid="browse-list"] .browse-row[data-browse-path]')].map((r) => /[^\\]*$/.exec(r.getAttribute('data-browse-path') ?? '')?.[0]))
+ await click('alpha.txt')
+ const exAll = await exNames()
+ const ai = exAll.indexOf('alpha.txt')
+ await win.keyboard.press('Control+End')
+ await sleep(400)
+ ok((await focusedRow()) === exAll[exAll.length - 1] && (await exMarked()).join() === 'alpha.txt', `explorer: Ctrl+End moves the keyboard to the last row only (${await focusedRow()}, ${await exMarked()})`)
+ await win.keyboard.press('Control+Home')
+ await sleep(400)
+ ok((await focusedRow()) === exAll[0] && (await exMarked()).join() === 'alpha.txt', `explorer: Ctrl+Home to the first (${await focusedRow()}, ${await exMarked()})`)
+ await win.keyboard.press('Shift+End')
+ await sleep(500)
+ ok((await exMarked()).sort().join() === exAll.slice(ai).sort().join(), `explorer: Shift+End marks from the anchor to the end (${await exMarked()})`)
+ ok(!(await paneFilm()) && (await playing()) === 0, 'explorer: and previews and plays nothing')
+ await win.keyboard.press('Shift+Home')
+ await sleep(500)
+ ok((await exMarked()).sort().join() === exAll.slice(0, ai + 1).sort().join(), `explorer: Shift+Home from the top to the anchor (${await exMarked()})`)
+ await win.keyboard.press('Escape')
+ await sleep(300)
+
+ // Ctrl+Shift+N, its name, nothing from the rename field, one Ctrl+Z.
+ await click('alpha.txt')
+ await win.keyboard.press('Control+Shift+N')
+ ok(await until(async () => existsSync(join(dir, 'New folder')), 8000), 'explorer: Ctrl+Shift+N makes "New folder" in the folder shown')
+ const exRename = win.locator('input[aria-label="New name"]')
+ ok(await until(async () => (await exRename.count()) === 1, 5000), 'explorer: and asks for its name')
+ ok((await exRename.inputValue()) === 'New folder', 'explorer: starting from "New folder"')
+ await win.keyboard.press('Control+d')
+ await win.keyboard.press('Control+Shift+N')
+ await sleep(500)
+ ok(!existsSync(join(dir, 'New folder (2)')) && existsSync(join(dir, 'New folder')), 'explorer: no key in the rename field reaches the list')
+ await exRename.fill('made-ex')
+ await win.keyboard.press('Enter')
+ ok(await back('made-ex'), 'explorer: the name lands')
+ await sleep(800)
+ await click('alpha.txt')
+ await win.keyboard.press('Control+z')
+ ok(await gone('made-ex'), 'explorer: one Ctrl+Z takes the new folder away')
+ await sleep(900)
+
+ await click('bravo.txt')
+ await win.keyboard.press('Control+d')
+ await confirmDelete()
+ ok(await gone('bravo.txt'), 'explorer: Ctrl+D sends it to the Recycle Bin')
+ await click('alpha.txt')
+ await win.keyboard.press('Control+z')
+ ok(await back('bravo.txt'), 'explorer: and Ctrl+Z brings it back')
+ await sleep(900)
+ await until(async () => (await exRow('charlie.txt').count()) === 1, 5000)
+ await click('charlie.txt')
+ await win.keyboard.press('Shift+Delete')
+ await confirmDelete()
+ ok(await gone('charlie.txt'), 'explorer: Shift+Delete goes to the bin too')
+ await click('alpha.txt')
+ await win.keyboard.press('Control+z')
+ ok(await back('charlie.txt'), 'explorer: and Ctrl+Z brings that back')
+ await sleep(900)
+
+ // F3 with a PDF in the preview: the folder search, not the PDF's find.
+ await until(async () => (await exRow('echo.pdf').count()) === 1, 5000)
+ await click('echo.pdf')
+ await sleep(1500)
+ await exRow('echo.pdf').focus()
+ await win.keyboard.press('F3')
+ ok(await until(async () => (await win.locator('[data-search-popup]').count()) === 1, 5000), 'explorer: F3 opens the folder search')
+ ok((await win.locator('input[aria-label="Find in document"]').count()) === 0, 'explorer: and the PDF find bar stays shut')
+ // Nothing while typing in the search field.
+ const marksBefore = (await exMarked()).length
+ await win.keyboard.type('a')
+ await win.keyboard.press('Control+a')
+ await win.keyboard.press('Control+d')
+ await sleep(300)
+ ok(
+ (await exMarked()).length === marksBefore &&
+ (await win.locator('[role="dialog"] button:has-text("Delete")').count()) === 0,
+ 'explorer: Ctrl+A and Ctrl+D in the search field act on no rows'
+ )
+ await win.keyboard.press('Escape')
+ await sleep(400)
+
+ await click('alpha.txt')
+ await win.keyboard.press('Alt+Enter')
+ ok(await until(async () => /alpha\.txt/.test((await win.locator('[role="dialog"]').textContent().catch(() => '')) ?? ''), 5000), 'explorer: Alt+Enter opens Properties')
+ await win.keyboard.press('Escape')
+ await sleep(400)
+
+ const exBefore = await tabs()
+ await click('alpha.txt')
+ await win.keyboard.press('Control+Enter')
+ await sleep(800)
+ ok((await tabs()) === exBefore, 'explorer: Ctrl+Enter on a file opens no tab')
+ await click('golf')
+ await win.keyboard.press('Control+Enter')
+ ok(await until(async () => (await tabs()) === exBefore + 1, 8000), `explorer: Ctrl+Enter opens the folder in a new tab (${exBefore} -> ${await tabs()})`)
+ await win.screenshot({ path: join(SHOTS, 'hotkeys.png') })
+
+ // INSIDE A ZIP the keys that make, bin or describe are inert (#300 rules).
+ await inZip(win, join(dir, 'kilo.zip'))
+ const member = list.locator('[data-browse-path$="member.txt"]').first()
+ await member.click({ position: { x: 30, y: 8 } })
+ await sleep(500)
+ for (const k of ['Control+d', 'Control+Shift+N', 'Alt+Enter']) {
+ await member.focus()
+ await win.keyboard.press(k)
+ await sleep(500)
+ ok((await dialogs()) === 0 && (await win.locator('input[aria-label="New name"]').count()) === 0, `zip: ${k} does nothing`)
+ }
+ ok(!existsSync(join(dir, 'New folder')) && new AdmZip(join(dir, 'kilo.zip')).getEntries().length === 1, 'zip: no folder was made and the member is still there')
+ } finally {
+ await win.evaluate(() => document.querySelectorAll('video,audio').forEach((v) => v.pause())).catch(() => {})
+ await app.evaluate(({ clipboard }, t) => clipboard.writeText(t), held).catch(() => {})
+ await app.close().catch(() => {})
+ rmSync(dir, { recursive: true, force: true })
+ }
+}
+
/**
* TWO HIGHLIGHTS FROM ONE ACCENT (owner, 2026-10-03, with a screenshot of an
* opaque grey slab in the Explorer: "i want more saturated" for the settings
@@ -16939,6 +17479,7 @@ await run(dragLabelScenario)
await run(marqueeScenario)
await run(marqueeQuietScenario)
await run(marqueeEdgeScenario)
+await run(hotkeysScenario)
await run(markTintScenario)
await run(explorerSizeScenario)
await run(sidebarPlacesScenario)