Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,8 @@ native `<input type=color>`, 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
Expand Down Expand Up @@ -921,6 +922,34 @@ native `<input type=color>`, no Acrylic or Accent opacity slider. Spec and plan:
collapsed and invisible), or every member of the archive folder you are in.
Behind the typing guard, so the search box, a rename, the editor and the
shell keep their own Ctrl+A.
**THE COMMON FILE KEYS, IN THE EXPLORER'S LIST AND THE TREE** (#330; owner, 2026-10-07:
"add common hotkeys to the explorer and project so that for example ctrl + A selects all";
the list and four decisions picked in the session). ONE mapping, `lib/listKeys.ts` (pure,
tested): Ctrl+A all; Ctrl+Shift+A and Esc clear (Esc only when something is marked and
nothing nearer owns it: `lib/nearerEscape`, a `data-owns-escape` closer or the peek); Shift+Up/Down/Home/End mark the run from the anchor (`rangeSelect`,
a run that shrinks back, Explorer's, unlike the Shift CLICK's merge); Ctrl+Up/Down/Home/End
move the keyboard's place and not the marks (the Explorer's `cursor`, the tree's own
cursor), Ctrl+Space marks or unmarks it; Ctrl+Shift+N NEW FOLDER (the owner's yes to the
fresh decision above: main names it "New folder", "(2)"..., checks the place, never inside
a zip; then the rename; undo kind `mkdir` bins it ONLY while empty, and the rename that
follows folds into it, `rememberRename`, so one Ctrl+Z takes it away); Ctrl+D and Shift+Del
ARE Delete (OWNER: Shift+Del is NOT permanent, nothing changed the "nothing is destroyed"
rule; inert inside a zip; the MARKS decide, so a row the Ctrl keys or Esc left unlit is
never what a delete key takes, the Explorer's rule in the tree too); Alt+Up the tree's parent row (Alt+Left/Right claimed, nothing;
App leaves Alt+Up/Down to the lists, or it walks and OPENS the row above first);
type-to-jump in the tree too (OWNER: taken only on a match, else the letter reaches the
viewer; LETTERS only, `isJumpKey`, since a digit is the player's seek and a jump opens); F3 the folder search or the tree filter (OWNER: the PDF keeps F3 only with the
focus in it; its window listener yields a claimed F3 and letter); Alt+Enter Prism's own
Properties (OWNER); Ctrl+Shift+C the full paths as text, one per line, the menu's Copy path
(no badge, as the menu); Ctrl+Enter a folder in a new Explorer tab. NOT Ctrl+Left/Right
(2026-09-01). All of it is QUIET (#263: marking opens and plays nothing), behind the typing
guard, from the rows only, and CLAIMED, since the player, the PDF and the archive panel
listen on the window: App leaves Shift/Ctrl+Up/Down to the lists (`inLists`), the archive
panel's and the tree's Ctrl+A step aside for the other surface, tree rows ignore chords.
A letter chord matches the physical key only when the layout's key is not a Latin letter
(AZERTY's Ctrl+Q is not Ctrl+A). After a rename the keyboard follows the row to its new
name. Fullscreen unmounts both surfaces, so nothing here writes there. The `hotkeys` e2e
drives every key on both surfaces, a paused film under them, and a zip.
Tabs reorder by dragging along the strip (`reorderTabs`, pure and tested), with a
hairline showing where one would land.
Selection is the accent tint (`data-selected`); `aria-selected` still means the OPEN
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism",
"version": "0.98.0",
"version": "0.99.0",
"description": "A fast, universal media viewer for Windows.",
"author": "Max",
"license": "MIT",
Expand Down
61 changes: 59 additions & 2 deletions src/main/fileOps.test.ts
Original file line number Diff line number Diff line change
@@ -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-'))
Expand Down Expand Up @@ -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()
})
})
52 changes: 52 additions & 0 deletions src/main/fileOps.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<string | null> {
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<void>
): 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'
}
}
29 changes: 28 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ import { sanitizeDoc } from './docSanitize'
import { encodeText, shapeOf, type TextShape } from './textFile'
import { readTail, startTail, stopAllTails, stopTail } from './fileTail'
import { openComic, type ComicOpen } from './comic'
import { renameFile, uniqueName } from './fileOps'
import { binIfEmpty, makeFolder, renameFile, uniqueName } from './fileOps'
import { appsForExt, argsFor, type AppCandidate } from './openWith'
import { readAsVtt, sidecarsFor, type SubTrack } from './subtitles'
import {
Expand Down Expand Up @@ -4314,6 +4314,33 @@ if (!app.requestSingleInstanceLock()) {
return null
}
})
// NEW FOLDER (#330; owner, 2026-10-07: Ctrl+Shift+N in the Explorer and
// the tree, the explicit yes to "new folder is a fresh decision"). The
// page names a folder; main checks it is one this window may write in, on
// disk and not inside an archive, and picks the name itself ("New folder",
// "New folder (2)"...). `name` is only a redo's: the name the undone
// folder had, put through the same validation as any rename.
ipcMain.handle('file:newFolder', async (_e, dir: string, name?: string): Promise<string | null> => {
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()
Expand Down
7 changes: 7 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ const api = {
/** Copy the file next to itself as "name (2).ext"; resolves with the new path. */
duplicateFile: (path: string): Promise<string | null> =>
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<string | null> =>
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) ----- */

Expand Down
Loading
Loading