From 3ad17c8f16b24be5a03fe43d282004fdbc7cf4ff Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:55:50 +0200 Subject: [PATCH 1/2] feat(explorer): common hotkeys in the Explorer and the project tree (#330) The owner, 2026-10-07: "add common hotkeys to the explorer and project so that for example ctrl + A selects all". One pure mapping (lib/listKeys.ts) for the Explorer's list and the project tree: - Ctrl+A selects all; Ctrl+Shift+A and Esc clear. - Shift+Up/Down/Home/End extend from the anchor (rangeSelect), quiet (#263). - Ctrl+Up/Down/Home/End move the keyboard's place without marking; Ctrl+Space marks or unmarks it. Not Ctrl+Left/Right (2026-09-01 rule). - Ctrl+Shift+N makes "New folder" ("(2)" and on, named in main, never in a zip) and starts its rename; undo kind mkdir bins it only while empty, and the rename that follows folds into it, so one Ctrl+Z takes it away. - Ctrl+D and Shift+Delete are Delete: the question, then the Recycle Bin. - Alt+Up is the tree's parent row; type-to-jump in the tree on a match only. - F3 opens the folder search / tree filter; the PDF yields a claimed F3. - Alt+Enter opens Properties; Ctrl+Shift+C copies the full paths; Ctrl+Enter opens a folder in a new Explorer tab. Every key is behind the typing guard and claimed: App leaves Shift/Ctrl arrows to the lists, the archive panel's and the tree's Ctrl+A step aside for the other surface, tree rows ignore chords. New e2e scenario `hotkeys`. Version 0.97.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 27 +- README.md | 9 + package-lock.json | 4 +- package.json | 2 +- src/main/fileOps.test.ts | 61 +++- src/main/fileOps.ts | 52 +++ src/main/index.ts | 29 +- src/preload/index.ts | 7 + src/renderer/src/App.tsx | 74 +++- src/renderer/src/components/ArchiveView.tsx | 3 + src/renderer/src/components/Sidebar.tsx | 154 +++++++- src/renderer/src/components/TreeRows.tsx | 6 +- .../src/components/browse/BrowseList.tsx | 125 ++++++- .../src/components/browse/FolderBrowser.tsx | 76 +++- src/renderer/src/components/browse/types.ts | 6 + src/renderer/src/components/pdf/PdfView.tsx | 6 +- src/renderer/src/lib/clipboardText.ts | 4 + src/renderer/src/lib/listKeys.test.ts | 130 +++++++ src/renderer/src/lib/listKeys.ts | 146 ++++++++ src/renderer/src/lib/selection.ts | 12 + src/renderer/src/lib/treeContext.ts | 4 + src/renderer/src/lib/undo.test.ts | 22 +- src/renderer/src/lib/undo.ts | 22 ++ tools/e2e/run.mjs | 342 ++++++++++++++++++ 24 files changed, 1304 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/lib/listKeys.test.ts create mode 100644 src/renderer/src/lib/listKeys.ts diff --git a/CLAUDE.md b/CLAUDE.md index c75b2fd1..91e4873e 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 @@ -906,6 +907,30 @@ 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); 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); Alt+Up the tree's parent row (Alt+Left/Right claimed, nothing); + type-to-jump in the tree too (OWNER: taken only on a match, else the letter reaches the + viewer); 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. + Fullscreen unmounts both surfaces, so nothing here writes there. The `hotkeys` e2e drives + every key on both surfaces. 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 c403a64c..1d237611 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 24fbe337..c8d7ae2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.96.0", + "version": "0.97.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.96.0", + "version": "0.97.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index f311ca5e..b13392ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.96.0", + "version": "0.97.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 77ca2517..be532b0a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -125,7 +125,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 { @@ -4185,6 +4185,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 dc0da61a..9b92c166 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -383,6 +383,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 be5322bb..a65f0752 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -181,6 +181,7 @@ import { emptyUndo, redone, remember, + rememberRename, undone, type UndoEntry, type UndoState @@ -3024,8 +3025,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 @@ -3034,7 +3039,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] ) /** @@ -3280,6 +3305,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 @@ -3333,6 +3371,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') { @@ -3572,12 +3620,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() @@ -3713,6 +3767,7 @@ 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 + if (inLists && (e.ctrlKey || e.shiftKey)) 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' @@ -4123,6 +4178,7 @@ export default function App(): JSX.Element { onDropInto={onBrowseDropInto} archive={archiveActions} onDuplicated={(source, copy) => noteUndo({ kind: 'duplicate', source, path: copy })} + onNewFolder={makeNewFolder} wash={washed} /> )} @@ -4203,6 +4259,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 590b1154..5bccd0ef 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,9 @@ 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 { listKey, stepTo, typeJump, typedRun } from '../lib/listKeys' +import { clipboardText } from '../lib/clipboardText' import { nearestRow, onRowOwnPart, rowsInBand, sweepSelect } from '../lib/marquee' import { useSweep } from '../hooks/useSweep' import { DRAG_MIME, dragPayload, droppedPaths, setDrag, type DragPayload } from '../lib/dragDrop' @@ -153,6 +156,7 @@ export function Sidebar({ onDropInto, archive, onDuplicated, + onNewFolder, onNav, wash, onOpenFolder, @@ -216,6 +220,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 +338,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 @@ -869,7 +879,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) }) } @@ -906,6 +916,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 @@ -1208,6 +1219,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 @@ -1289,6 +1301,137 @@ 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). + if (searching || e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false + if (e.key.length !== 1 || 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 + 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). + 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 @@ -1313,6 +1456,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() @@ -1517,6 +1666,7 @@ export function Sidebar({ currentPath, dirtyPaths, cursor: at, + cursorMarks: !(apartAt && at && apartAt.toLowerCase() === at.toLowerCase()), size, editing, menuPath: menu?.path ?? null, diff --git a/src/renderer/src/components/TreeRows.tsx b/src/renderer/src/components/TreeRows.tsx index 1f54a712..a3ffe1bd 100644 --- a/src/renderer/src/components/TreeRows.tsx +++ b/src/renderer/src/components/TreeRows.tsx @@ -556,6 +556,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) @@ -649,7 +652,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 +713,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 ce953d53..76a46c36 100644 --- a/src/renderer/src/components/browse/BrowseList.tsx +++ b/src/renderer/src/components/browse/BrowseList.tsx @@ -29,8 +29,23 @@ 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' 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 +115,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 } @@ -361,9 +384,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 @@ -389,7 +455,56 @@ 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) { + 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 ( @@ -409,9 +524,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) @@ -633,6 +754,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 @@ -665,6 +787,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 6835434f..96ae7e13 100644 --- a/src/renderer/src/components/browse/FolderBrowser.tsx +++ b/src/renderer/src/components/browse/FolderBrowser.tsx @@ -10,7 +10,8 @@ import { browseEntries, datesKnown } from './entries' 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' @@ -23,6 +24,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, @@ -194,23 +198,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] : [])) @@ -361,6 +392,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() @@ -514,6 +578,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..74867e90 --- /dev/null +++ b/src/renderer/src/lib/listKeys.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { 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') + }) +}) diff --git a/src/renderer/src/lib/listKeys.ts b/src/renderer/src/lib/listKeys.ts new file mode 100644 index 00000000..48ddec24 --- /dev/null +++ b/src/renderer/src/lib/listKeys.ts @@ -0,0 +1,146 @@ +// 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: a Russian layout's A is 'ф'. */ +const letter = (e: KeyLike, l: string): boolean => + e.key.toLowerCase() === l || 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 +} + +/** 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/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 e07b0260..04b72ba9 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -8134,6 +8134,347 @@ async function marqueeQuietScenario(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']) writeFileSync(join(dir, n), `${n}\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 { 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(() => '')) ?? '' + await until(async () => (await win.locator('aside [role="treeitem"]').count()) >= 7, 10000) + await treeRow('alpha.txt').click() + await sleep(400) + + await win.keyboard.press('Control+a') + await sleep(300) + ok((await treeMarked()).length === 7, `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 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') + // 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()})`) + + // 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()})`) + + // 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') + 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) + + // 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) + + // 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()) === 7, 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 === 7, `explorer: Ctrl+A marks every row (${await exMarked()})`) + ok(/7 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+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('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') }) + } 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 @@ -16358,6 +16699,7 @@ await run(dragScenario) await run(dragLabelScenario) await run(marqueeScenario) await run(marqueeQuietScenario) +await run(hotkeysScenario) await run(markTintScenario) await run(explorerSizeScenario) await run(sidebarPlacesScenario) From ba6d43539ea01748b2d20b363963649f37403f31 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:18:58 +0200 Subject: [PATCH 2/2] fix(explorer): the review of the common file keys (#330) - Alt+Up in the tree no longer walks to the row above first: App leaves Alt+Up/Down to the lists, as it does Shift and Ctrl, so the row above is never opened or played on the way to the parent. - A folder row honours cursorMarks: after Ctrl+Up/Down, Esc or Ctrl+Space only the marks light it, as on a file row. - The marks decide what Delete, Ctrl+D and Shift+Delete take in the tree, the Explorer list's rule: a row the Ctrl keys or Esc left unlit is never binned. - Esc as "clear" yields to a nearer closer (lib/nearerEscape: anything with data-owns-escape, the sidebar peek), so the peek and the PDF find bar close. - Type-to-jump takes letters only: a digit is the player's seek and . , its frame step, and a jump opens what it lands on. - After a rename the keyboard follows the row to its new name, and Escape in the name keeps it on the row (Ctrl+Shift+N always ends in a rename). - A letter chord matches the physical key only when the layout's own key is not a Latin letter (AZERTY's Ctrl+Q is not Ctrl+A). - Shift+Delete is Delete from the viewer too (the tree's window listener and App's Explorer viewer). The hotkeys e2e now covers a paused film under every claimed chord, Ctrl and Shift+Home/End on both surfaces, Alt+Left/Right and a second Ctrl+Space in the tree, Ctrl+Shift+N from a file row, Ctrl+Enter on a file, the inert keys inside a zip, a letter the PDF would take, a digit that is not a jump, Esc yielding to the PDF find bar, Alt+Up past a row that is not the parent, and the bin keys off the marks. sidebarPeek: Esc on a row puts the peek away. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 14 +- src/renderer/src/App.tsx | 11 +- src/renderer/src/components/Sidebar.tsx | 92 +++++++- src/renderer/src/components/TreeRows.tsx | 9 +- .../src/components/browse/BrowseList.tsx | 4 + src/renderer/src/lib/listKeys.test.ts | 23 +- src/renderer/src/lib/listKeys.ts | 11 +- src/renderer/src/lib/nearerEscape.ts | 13 ++ tools/e2e/run.mjs | 211 +++++++++++++++++- 9 files changed, 357 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/lib/nearerEscape.ts diff --git a/CLAUDE.md b/CLAUDE.md index 91e4873e..8ec6c379 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -911,7 +911,7 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: "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); Shift+Up/Down/Home/End mark the run from the anchor (`rangeSelect`, + 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 @@ -919,9 +919,11 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: 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); Alt+Up the tree's parent row (Alt+Left/Right claimed, nothing); + 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); F3 the folder search or the tree filter (OWNER: the PDF keeps F3 only with 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 @@ -929,8 +931,10 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: 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. - Fullscreen unmounts both surfaces, so nothing here writes there. The `hotkeys` e2e drives - every key on both surfaces. + 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/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a65f0752..8b29f5cb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3576,14 +3576,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() @@ -3767,7 +3768,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 - if (inLists && (e.ctrlKey || e.shiftKey)) 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' @@ -4172,7 +4175,7 @@ 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} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 5bccd0ef..7c5fa330 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -39,7 +39,8 @@ import { SortMenu } from './SortMenu' import { formatBytes } from '../lib/format' import { TreeProvider } from '../lib/treeContext' import { clickSelect, emptySelection, rangeSelect, type Selection } from '../lib/selection' -import { listKey, stepTo, typeJump, typedRun } from '../lib/listKeys' +import { isJumpKey, listKey, stepTo, typeJump, typedRun } from '../lib/listKeys' +import { nearerEscape } from '../lib/nearerEscape' import { clipboardText } from '../lib/clipboardText' import { nearestRow, onRowOwnPart, rowsInBand, sweepSelect } from '../lib/marquee' import { useSweep } from '../hooks/useSweep' @@ -139,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, @@ -198,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 @@ -685,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 ---------- */ @@ -1029,6 +1064,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, @@ -1047,14 +1112,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( @@ -1087,7 +1154,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. @@ -1320,9 +1387,10 @@ export function Sidebar({ 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). + // 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 (e.key.length !== 1 || e.key === ' ') return false + if (!isJumpKey(e.key)) return false const run = typedRun(typedTree.current, e.key, performance.now()) typedTree.current = run const i = typeJump( @@ -1345,6 +1413,8 @@ export function Sidebar({ } 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) @@ -1405,6 +1475,7 @@ export function Sidebar({ 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) @@ -1692,12 +1763,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 a3ffe1bd..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() @@ -576,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 @@ -593,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) @@ -618,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} /> ) : ( diff --git a/src/renderer/src/components/browse/BrowseList.tsx b/src/renderer/src/components/browse/BrowseList.tsx index 76a46c36..b8890672 100644 --- a/src/renderer/src/components/browse/BrowseList.tsx +++ b/src/renderer/src/components/browse/BrowseList.tsx @@ -30,6 +30,7 @@ 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). */ @@ -497,6 +498,9 @@ export function BrowseList(props: Props): JSX.Element { 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. diff --git a/src/renderer/src/lib/listKeys.test.ts b/src/renderer/src/lib/listKeys.test.ts index 74867e90..e0740827 100644 --- a/src/renderer/src/lib/listKeys.test.ts +++ b/src/renderer/src/lib/listKeys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { listKey, stepTo, typeJump, typedRun, type KeyLike } from './listKeys' +import { isJumpKey, listKey, stepTo, typeJump, typedRun, type KeyLike } from './listKeys' import { rangeSelect } from './selection' const press = (key: string, mods: Partial = {}): KeyLike => ({ @@ -128,3 +128,24 @@ describe('rangeSelect', () => { 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 index 48ddec24..21dfbfcb 100644 --- a/src/renderer/src/lib/listKeys.ts +++ b/src/renderer/src/lib/listKeys.ts @@ -55,9 +55,11 @@ export interface KeyLike { metaKey: boolean } -/** A letter chord by its PHYSICAL key too: a Russian layout's A is 'ф'. */ +/** 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 || e.code === `Key${l.toUpperCase()}` + e.key.toLowerCase() === l || (!/^[a-z]$/i.test(e.key) && e.code === `Key${l.toUpperCase()}`) const EDGE: Record = { ArrowUp: 'up', @@ -135,6 +137,11 @@ export function typeJump(names: readonly string[], from: number, typed: string): 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( 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/tools/e2e/run.mjs b/tools/e2e/run.mjs index 04b72ba9..8456e03f 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -5757,6 +5757,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) @@ -8152,10 +8163,19 @@ async function hotkeysScenario(fixtures) { 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']) writeFileSync(join(dir, n), `${n}\n`) + 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()) @@ -8184,13 +8204,15 @@ async function hotkeysScenario(fixtures) { 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(() => '')) ?? '' - await until(async () => (await win.locator('aside [role="treeitem"]').count()) >= 7, 10000) + 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 === 7, `tree: Ctrl+A marks every row (${await treeMarked()})`) + 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()})`) @@ -8213,6 +8235,17 @@ async function hotkeysScenario(fixtures) { 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()})`) @@ -8241,6 +8274,12 @@ async function hotkeysScenario(fixtures) { 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) @@ -8260,6 +8299,26 @@ async function hotkeysScenario(fixtures) { 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) @@ -8270,6 +8329,7 @@ async function hotkeysScenario(fixtures) { 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. @@ -8283,6 +8343,14 @@ async function hotkeysScenario(fixtures) { 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) @@ -8290,6 +8358,28 @@ async function hotkeysScenario(fixtures) { 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() @@ -8321,6 +8411,76 @@ async function hotkeysScenario(fixtures) { 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() @@ -8340,7 +8500,7 @@ async function hotkeysScenario(fixtures) { 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()) === 7, 10000), 'explorer: the folder is listed') + 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 } }) @@ -8350,8 +8510,8 @@ async function hotkeysScenario(fixtures) { await win.keyboard.press('Control+a') await sleep(400) - ok((await exMarked()).length === 7, `explorer: Ctrl+A marks every row (${await exMarked()})`) - ok(/7 selected/.test(await status()), `explorer: the status line counts them (${await status()})`) + 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()})`) @@ -8396,6 +8556,28 @@ async function hotkeysScenario(fixtures) { 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') @@ -8463,10 +8645,27 @@ async function hotkeysScenario(fixtures) { 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(() => {})