diff --git a/package-lock.json b/package-lock.json index cc326f42..3a834b07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.117.1", + "version": "0.117.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.117.1", + "version": "0.117.2", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index 4dfd1e87..87692c65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.117.1", + "version": "0.117.2", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/main/ipc/files.ts b/src/main/ipc/files.ts index d74c72d8..084d4771 100644 --- a/src/main/ipc/files.ts +++ b/src/main/ipc/files.ts @@ -9,19 +9,21 @@ import type { PasteResult } from '@shared/ipcTypes' import { basename, dirname, extname, join, resolve, sep } from 'path' -import { copyFile, stat, writeFile } from 'fs/promises' +import { copyFile, readFile, stat, writeFile } from 'fs/promises' import { execFile, spawn } from 'child_process' import { copyWindowsFiles, readWindowsFiles } from '../fileClipboard' import { insideDesktop } from '../desktopAccess' import { container } from '../archiveBrowse' import { holders } from '../holders' import { documentImages } from '../docImages' -import { encodeText } from '../textFile' +import { encodeText, fallbackShape, moveShapes } from '../textFile' +import { replaceFile } from '../replaceFile' import { readTail, startTail, stopTail } from '../fileTail' import { binIfEmpty, makeFolder, renameFile, uniqueNameAsync } from '../fileOps' import { appsForExt, argsFor, type AppCandidate } from '../openWith' import { addToArchive, archiveTooLarge, moveMembers } from '../archive' import { insideSelf, moveEntries } from '../moveOps' +import { copyCounted, pastesIntoItself } from '../pasteCopy' import { fileKind, isMarkdown } from '@shared/fileKind' import { samePath } from '@shared/path' import type { OnClash, OpenWithApp, RenameResult, TextRead, WriteResult } from '@shared/types' @@ -87,8 +89,13 @@ export function registerFilesIpc(ctx: MainCtx, ready: ReadyCtx): void { await holders.release([p]) // a rename is refused for the same reason a move is (#127) const r = await renameFile(p, name, onClash, (t) => shell.trashItem(t)) await written(p, r.ok ? r.path : dirname(p)) - // The file's memory follows it (#124): its place and its choices. - if (r.ok) void positions.rename(p, r.path) + // The file's memory follows it (#124): its place and its choices. Its + // encoding and line endings too (2026-10-16), or a dirty buffer renamed + // before its save was written as utf-8 with LF. + if (r.ok) { + void positions.rename(p, r.path) + moveShapes(textShape, p, r.path) + } return r } ) @@ -171,11 +178,18 @@ export function registerFilesIpc(ctx: MainCtx, ready: ReadyCtx): void { // Back in the shape it came in. CodeMirror rejoins its document with // a bare newline whatever it read, so without this one fixed typo in // a .bat was 400 changed lines, and a UTF-16 file came back as UTF-8. - const shape = textShape.get(p.toLowerCase()) ?? { - encoding: 'utf8' as const, - eol: 'lf' as const + // With no remembered read, the file as it is on disk now (2026-10-16: + // the fallback was utf-8 with LF whatever the file held). Capped like + // a read: a file too big to open is not sniffed whole either. + const remembered = textShape.get(p.toLowerCase()) + const onDisk = async (): Promise => { + const st = await stat(p).catch(() => null) + return st?.isFile() && st.size <= TEXT_MAX_BYTES ? readFile(p).catch(() => null) : null } - await writeFile(p, encodeText(text, shape)) + const shape = remembered ?? fallbackShape(await onDisk()) + // Whole or not at all (2026-10-16): a truncating write left the file + // empty on a full or pulled disk (replaceFile.ts). + await replaceFile(p, encodeText(text, shape)) return { ok: true } } catch (e) { // EACCES, EROFS, ENOSPC: the cases worth naming rather than leaving @@ -193,7 +207,7 @@ export function registerFilesIpc(ctx: MainCtx, ready: ReadyCtx): void { /** Explorer's verbs, Open with, the clipboard and paste, file icons, restore. */ export function registerShellIpc(ctx: MainCtx): void { - const { ownWrite, readable, shellVerbSetting, written } = ctx + const { ownWrite, readable, shellVerbSetting, textShape, written } = ctx // "Open file" / "Open as project" in File Explorer's own context menu // (HKCU only). ipcMain.handle(CALL.shellVerbStatus, () => shellVerbSetting.status()) @@ -326,30 +340,6 @@ export function registerShellIpc(ctx: MainCtx): void { return total } - /** Copy a file or a whole folder, streaming files so the byte count can - * tick. fs.cp gives no progress at all, which for a 4GB ISO is minutes - * of nothing (owner, 2026-09-03). */ - async function copyCounted( - src: string, - target: string, - tick: (bytes: number) => void - ): Promise { - const fs = await import('fs/promises') - const st = await fs.stat(src) - if (st.isDirectory()) { - await fs.mkdir(target) - for (const e of await fs.readdir(src)) { - await copyCounted(join(src, e), join(target, e), tick) - } - return - } - const { createReadStream, createWriteStream } = await import('fs') - const { pipeline } = await import('stream/promises') - const reader = createReadStream(src) - reader.on('data', (chunk) => tick(chunk.length)) - await pipeline(reader, createWriteStream(target, { flags: 'wx' })) - } - /** * Paste, with PROGRESS and an answer that names what landed (2026-09-03). * Windows copy/cut metadata is authoritative, including when another app @@ -392,6 +382,13 @@ export function registerShellIpc(ctx: MainCtx): void { let failed = 0 const paths: string[] = [] for (const s of src) { + // A folder pasted into itself or below itself is skipped and counted + // as failed (2026-10-16): it used to copy its own copy until the path + // ran out, and a cut fell through to the same copy (pasteCopy.ts). + if (pastesIntoItself(s, destDir)) { + failed += 1 + continue + } try { const target = join(destDir, await uniqueNameAsync(destDir, basename(s))) if (moving) { @@ -408,6 +405,8 @@ export function registerShellIpc(ctx: MainCtx): void { void moved await fs.rm(s, { recursive: true }) } + // A cut carries the text shape like a drag-move does (2026-10-16). + moveShapes(textShape, s, target) } else { await copyCounted(s, target, tick) } @@ -480,7 +479,7 @@ export function registerShellIpc(ctx: MainCtx): void { /** Drag and drop: move, add to a zip, move members. */ export function registerMoveIpc(ctx: MainCtx, ready: ReadyCtx): void { - const { archiveOk, movable, ownWrite, written } = ctx + const { archiveOk, movable, ownWrite, textShape, written } = ctx const { positions } = ready /* ----- drag and drop (#70): move, add to a zip, extract out of one ----- */ @@ -535,8 +534,11 @@ export function registerMoveIpc(ctx: MainCtx, ready: ReadyCtx): void { const r = await moveEntries(wanted, destDir, onClash === 'ask' ? 'ask' : onClash, (t) => shell.trashItem(t) ).finally(() => written(destDir, ...wanted)) - // Each file's memory follows it (#124). - for (const m of r.moved) void positions.rename(m.from, m.to) + // Each file's memory follows it (#124), its text shape too (2026-10-16). + for (const m of r.moved) { + void positions.rename(m.from, m.to) + moveShapes(textShape, m.from, m.to) + } return r } ) diff --git a/src/main/pasteCopy.test.ts b/src/main/pasteCopy.test.ts new file mode 100644 index 00000000..5db01578 --- /dev/null +++ b/src/main/pasteCopy.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { copyCounted, pastesIntoItself } from './pasteCopy' + +describe('pastesIntoItself (2026-10-16)', () => { + it('refuses a folder pasted into itself or anywhere below it', () => { + expect(pastesIntoItself('C:\\a', 'C:\\a')).toBe(true) + expect(pastesIntoItself('C:\\a', 'C:\\a\\b\\c')).toBe(true) + expect(pastesIntoItself('C:\\A\\', 'c:/a/b')).toBe(true) + }) + + it('lets a copy land beside its source, or in a sibling that shares a prefix', () => { + expect(pastesIntoItself('C:\\a', 'C:\\')).toBe(false) + expect(pastesIntoItself('C:\\a\\f.txt', 'C:\\a')).toBe(false) + expect(pastesIntoItself('C:\\a', 'C:\\ab')).toBe(false) + }) +}) + +describe('copyCounted', () => { + it('copies a folder whole and counts its bytes', async () => { + const root = mkdtempSync(join(tmpdir(), 'prism-paste-')) + mkdirSync(join(root, 'src', 'inner'), { recursive: true }) + writeFileSync(join(root, 'src', 'a.txt'), 'abc') + writeFileSync(join(root, 'src', 'inner', 'b.txt'), 'de') + let bytes = 0 + await copyCounted(join(root, 'src'), join(root, 'out'), (n) => (bytes += n)) + expect(readFileSync(join(root, 'out', 'inner', 'b.txt'), 'utf8')).toBe('de') + expect(bytes).toBe(5) + }) + + it('lists the source before making the target, so a copy into its own child does not copy itself', async () => { + const root = mkdtempSync(join(tmpdir(), 'prism-paste-')) + const a = join(root, 'A') + mkdirSync(a) + writeFileSync(join(a, 'f.txt'), 'x') + await copyCounted(a, join(a, 'A'), () => {}) + expect(readdirSync(join(a, 'A')).sort()).toEqual(['f.txt']) + }, 10_000) +}) diff --git a/src/main/pasteCopy.ts b/src/main/pasteCopy.ts new file mode 100644 index 00000000..75646e65 --- /dev/null +++ b/src/main/pasteCopy.ts @@ -0,0 +1,44 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { mkdir, readdir, stat } from 'node:fs/promises' +import { pipeline } from 'node:stream/promises' +import { join } from 'node:path' +import { insideSelf } from './moveOps' + +/** + * Would pasting `src` into `destDir` put a folder inside itself (2026-10-16)? + * + * Paste never asked, though the drag-move always did (`insideSelf` in + * moveOps). A folder pasted into itself or one of its own subfolders copied + * the copy it was making, level after level (A\A\A...), until the path grew + * past 32k characters, and left a tree Explorer could barely delete. A cut + * took the same road: the rename fails with EINVAL and the copy fallback ran. + */ +export function pastesIntoItself(src: string, destDir: string): boolean { + return insideSelf(src, destDir) +} + +/** Copy a file or a whole folder, streaming files so the byte count can + * tick. fs.cp gives no progress at all, which for a 4GB ISO is minutes + * of nothing (owner, 2026-09-03). */ +export async function copyCounted( + src: string, + target: string, + tick: (bytes: number) => void +): Promise { + const st = await stat(src) + if (st.isDirectory()) { + // Listed BEFORE the new folder is made (2026-10-16): made first, a target + // inside `src` showed up in its own listing and was copied into itself. + // `pastesIntoItself` is the real guard; this keeps one level honest even + // for a caller that forgot it. + const names = await readdir(src) + await mkdir(target) + for (const e of names) { + await copyCounted(join(src, e), join(target, e), tick) + } + return + } + const reader = createReadStream(src) + reader.on('data', (chunk) => tick(chunk.length)) + await pipeline(reader, createWriteStream(target, { flags: 'wx' })) +} diff --git a/src/main/replaceFile.test.ts b/src/main/replaceFile.test.ts new file mode 100644 index 00000000..6696cebe --- /dev/null +++ b/src/main/replaceFile.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// A disk that fills up part way through a write, on demand. +const fault = vi.hoisted(() => ({ + code: '' as string, + when: (() => false) as (p: string) => boolean +})) +vi.mock('node:fs/promises', async (orig) => { + const real = await orig() + return { + ...real, + writeFile: async (p: string, data: Uint8Array) => { + if (fault.code && fault.when(String(p))) { + // Truncate-then-fail, the way a full disk leaves a file. + await real.writeFile(p, data.subarray(0, 1)) + throw Object.assign(new Error(fault.code), { code: fault.code }) + } + return real.writeFile(p, data) + } + } +}) + +const { replaceFile } = await import('./replaceFile') + +let dir = '' +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'prism-replace-')) + fault.code = '' + fault.when = () => false +}) + +describe('replaceFile (2026-10-16)', () => { + it('writes the bytes and leaves no tmp file behind', async () => { + const f = join(dir, 'run.bat') + writeFileSync(f, 'old') + await replaceFile(f, Buffer.from('new\r\n')) + expect(readFileSync(f, 'utf8')).toBe('new\r\n') + expect(readdirSync(dir)).toEqual(['run.bat']) + }) + + it('leaves the original whole when the disk fills mid-write', async () => { + const f = join(dir, 'notes.txt') + writeFileSync(f, 'the original document') + fault.code = 'ENOSPC' + fault.when = () => true + await expect(replaceFile(f, Buffer.from('a much longer replacement'))).rejects.toThrow('ENOSPC') + expect(readFileSync(f, 'utf8')).toBe('the original document') + expect(readdirSync(dir)).toEqual(['notes.txt']) + }) + + it('writes in place where a file may not be made beside it', async () => { + const f = join(dir, 'locked.txt') + writeFileSync(f, 'old') + fault.code = 'EACCES' + // Only the tmp file fails. Matched by its name, not `p !== f`: replaceFile + // writes in place through realpath, which expands an 8.3 TEMP such as the + // CI runner's C:\Users\RUNNER~1, so the in-place path need not equal `f`. + fault.when = (p) => p.endsWith('.tmp') + await replaceFile(f, Buffer.from('new')) + expect(readFileSync(f, 'utf8')).toBe('new') + expect(readdirSync(dir)).toEqual(['locked.txt']) + }) +}) diff --git a/src/main/replaceFile.ts b/src/main/replaceFile.ts new file mode 100644 index 00000000..50329ff1 --- /dev/null +++ b/src/main/replaceFile.ts @@ -0,0 +1,47 @@ +import { randomUUID } from 'node:crypto' +import { basename, dirname, join } from 'node:path' +import { realpath, rename, rm, writeFile } from 'node:fs/promises' + +/** Refusals that mean "not this way", not "the disk is broken": a folder the + * user may not create files in, or a file another program holds open without + * delete sharing. An in-place write still works for those. */ +const IN_PLACE_OK = new Set(['EPERM', 'EACCES', 'EBUSY']) + +const code = (e: unknown): string => String((e as NodeJS.ErrnoException)?.code ?? '') + +/** + * The editor's save, whole or not at all (2026-10-16). + * + * It was `writeFile(p, ...)`, which truncates first: on a full disk or a + * pulled USB stick the document was left empty or half written and all the + * user saw was "Couldn't save". The bytes now go to a hidden tmp file beside + * the file (dot-named, so the watchers ignore it) and a rename swaps them in, + * so a failed write leaves the original exactly as it was. + * + * A link is followed first: renaming over the link would replace the link + * with a plain file. Where the swap is refused rather than failed (a folder + * that allows writing the file but not creating one beside it, a file another + * program holds open without delete sharing), it writes in place, as it + * always did. Everything else throws, for the caller to name. + */ +export async function replaceFile(target: string, bytes: Uint8Array): Promise { + const real = await realpath(target).catch(() => target) + const tmp = join(dirname(real), `.${basename(real)}.${randomUUID()}.tmp`) + try { + try { + await writeFile(tmp, bytes) + } catch (e) { + if (!IN_PLACE_OK.has(code(e))) throw e + await writeFile(real, bytes) + return + } + try { + await rename(tmp, real) + } catch (e) { + if (!IN_PLACE_OK.has(code(e))) throw e + await writeFile(real, bytes) + } + } finally { + await rm(tmp, { force: true }).catch(() => {}) + } +} diff --git a/src/main/textFile.test.ts b/src/main/textFile.test.ts index 2470ac33..33256f95 100644 --- a/src/main/textFile.test.ts +++ b/src/main/textFile.test.ts @@ -1,5 +1,42 @@ import { describe, expect, it } from 'vitest' -import { decodeText, detectEol, encodeText, shapeOf, sniffEncoding } from './textFile' +import { + decodeText, + detectEol, + encodeText, + fallbackShape, + moveShapes, + shapeOf, + sniffEncoding +} from './textFile' + +describe('a shape with no remembered read (2026-10-16)', () => { + it('takes the file on disk as it is', () => { + expect(fallbackShape(utf16le('a\r\nb\r\n'))).toEqual({ encoding: 'utf16le', eol: 'crlf' }) + expect(fallbackShape(Buffer.from('a\nb\nc\n'))).toEqual({ encoding: 'utf8', eol: 'lf' }) + }) + + it('is CRLF for a file that is not there', () => { + expect(fallbackShape(null)).toEqual({ encoding: 'utf8', eol: 'crlf' }) + }) +}) + +describe('shapes follow a rename or a move (2026-10-16)', () => { + it('re-keys the file itself', () => { + const m = new Map([['c:\\d\\run.bat', { encoding: 'utf8' as const, eol: 'crlf' as const }]]) + moveShapes(m, 'C:\\d\\run.bat', 'C:\\d\\Go.bat') + expect([...m.keys()]).toEqual(['c:\\d\\go.bat']) + }) + + it('re-keys everything under a renamed folder, and nothing beside it', () => { + const reg = { encoding: 'utf16le' as const, eol: 'crlf' as const } + const m = new Map([ + ['c:\\d\\keys\\a.reg', reg], + ['c:\\d\\keysx\\b.reg', reg] + ]) + moveShapes(m, 'C:\\d\\Keys', 'E:\\Moved\\Keys') + expect([...m.keys()].sort()).toEqual(['c:\\d\\keysx\\b.reg', 'e:\\moved\\keys\\a.reg']) + }) +}) /** * A save must put the file back the way it found it. diff --git a/src/main/textFile.ts b/src/main/textFile.ts index c78061bf..98e6adbc 100644 --- a/src/main/textFile.ts +++ b/src/main/textFile.ts @@ -101,3 +101,34 @@ export function shapeOf(buf: Buffer): { text: string } & TextShape { const { text, encoding } = decodeText(buf) return { text, encoding, eol: detectEol(text) } } + +/** + * The shape for a save with no remembered read (2026-10-16): what is on disk + * now, or for a file that is not there, utf-8 with CRLF, the same choice + * `detectEol` makes for an empty file. It used to be utf-8 with LF whatever + * the file held, so a CRLF .bat or a UTF-16 .reg written under a path Prism + * had never read came back with every line or every byte changed. + */ +export function fallbackShape(disk: Buffer | null): TextShape { + if (!disk) return { encoding: 'utf8', eol: 'crlf' } + const { encoding, eol } = shapeOf(disk) + return { encoding, eol } +} + +/** + * Carry remembered shapes along with a rename or a move (2026-10-16). The map + * is keyed by the lowercased path that was READ, and nothing re-keyed it, while + * the renderer moves the unsaved buffer to the new name (`rekeyBuffer`). A + * dirty .bat renamed before its save was then written with the fallback. A + * folder carries every file under it. + */ +export function moveShapes(shapes: Map, from: string, to: string): void { + const f = from.toLowerCase().replace(/[\\/]+$/, '') + const t = to.replace(/[\\/]+$/, '') + for (const [k, shape] of [...shapes]) { + const under = k.startsWith(f + '\\') || k.startsWith(f + '/') + if (k !== f && !under) continue + shapes.delete(k) + shapes.set((t + k.slice(f.length)).toLowerCase(), shape) + } +} diff --git a/src/renderer/src/components/CodeView.tsx b/src/renderer/src/components/CodeView.tsx index 7ba4e67c..fd1cb62d 100644 --- a/src/renderer/src/components/CodeView.tsx +++ b/src/renderer/src/components/CodeView.tsx @@ -34,6 +34,7 @@ import { prismCodeTheme } from '../lib/code/codeTheme' import { setWrapPref, useWrapPref, wrapPref, wrapsFor, type WrapPref } from '../lib/code/codePrefs' import { reloadAction, stampChanged, touchesFile, type Stamp } from '../lib/fileReload' import { inNavSurface, isFormField } from '../lib/focusGuards' +import { landSave, saveTarget } from './codeSave' // Text and code, shown and edited in the same surface. There is no "edit mode" // here: a .py or a .txt has no rendered form to toggle away from, so the buffer @@ -236,6 +237,10 @@ export function CodeView({ } if (!u.docChanged) return const disk = saved.current + // Mid-switch the editor still shows the previous file while + // `pathRef` already names the next: typing there must not be + // filed as the next file's buffer (2026-10-16). + if (disk && disk.path !== pathRef.current) return const now = u.state.doc.toString() report(disk !== null && now !== disk.text ? now : null) }) @@ -510,26 +515,45 @@ export function CodeView({ // `saved.current` is null for a file that never loaded, and that is the // guard: nothing may be written over a file whose contents we never had. // A reader has nothing to write with either, whatever the bridge says. - if (readOnly || !v || !saved.current) return false + if (readOnly || !v) return false + // And only the file the editor is SHOWING, to the path its text came + // from (2026-10-16): mid-switch, Ctrl+S wrote the old file over the new + // one (codeSave.ts). + const target = saveTarget(path, loadedPath, saved.current) + if (!target) return false const text = v.state.doc.toString() - const r = await window.prism.writeText(path, text) + const r = await window.prism.writeText(target, text) if (!r.ok) { // The reason is worth carrying: "read-only" and "the folder is gone" // are different problems and the user can act on both. - setFailed(r.reason === 'gone' ? 'gone' : r.message || 'failed') + if (pathRef.current === target) setFailed(r.reason === 'gone' ? 'gone' : r.message || 'failed') return false } - saved.current = { path, text } + // Not over another file's disk text if the user moved on meanwhile. + if (pathRef.current === target) saved.current = { path: target, text } // Re-stamp: our own write emits a `dir:changed` about a second later // (the mute defers it, it does not drop it), and without a fresh stamp // that event would read as somebody else having rewritten the file. - const at = await window.prism.statFile(path) - if (at && !at.isFolder) stamp.current = { path, at: { mtimeMs: at.mtimeMs, size: at.size } } - report(null) - setFailed(null) + const at = await window.prism.statFile(target) + // Clean only if it still IS what was written, and only for the file that + // was saved (2026-10-16): typing during a slow save stays dirty, and a + // file switched to meanwhile keeps its own buffer. + const landing = landSave( + target, + pathRef.current, + text, + () => view.current?.state.doc.toString() ?? text, + () => getPending(target) + ) + if (landing.here) { + if (at && !at.isFolder) + stamp.current = { path: target, at: { mtimeMs: at.mtimeMs, size: at.size } } + report(landing.buffer) + setFailed(null) + } else if (landing.clear) onBuffer(target, null) onSaved() return true - }, [path, report, onSaved, readOnly]) + }, [path, loadedPath, report, onSaved, onBuffer, getPending, readOnly]) // The wrap preference is live: changing it in Settings or the menu must // reach the editor that is already open, not just the next file. diff --git a/src/renderer/src/components/codeSave.test.ts b/src/renderer/src/components/codeSave.test.ts new file mode 100644 index 00000000..76528c00 --- /dev/null +++ b/src/renderer/src/components/codeSave.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { landSave, saveTarget } from './codeSave' + +describe('saveTarget (2026-10-16)', () => { + it('writes the file the editor is showing, to the path its text came from', () => { + expect(saveTarget('C:\\b.txt', 'C:\\b.txt', { path: 'C:\\b.txt' })).toBe('C:\\b.txt') + }) + + it('refuses while the next file is still loading: the editor holds the old text', () => { + // path is B already; the read has not landed, so the view and `saved` are A's. + expect(saveTarget('C:\\b.txt', 'C:\\a.txt', { path: 'C:\\a.txt' })).toBeNull() + }) + + it('refuses when the remembered disk text is another file\'s', () => { + expect(saveTarget('C:\\b.txt', 'C:\\b.txt', { path: 'C:\\a.txt' })).toBeNull() + }) + + it('refuses a file that never loaded', () => { + expect(saveTarget('C:\\b.txt', 'C:\\b.txt', null)).toBeNull() + }) +}) + +describe('landSave (2026-10-16)', () => { + const never = (): string => { + throw new Error('the editor holds another file now') + } + + it('reports clean when the document is what was written', () => { + expect(landSave('a', 'a', 'x', () => 'x', () => undefined)).toEqual({ here: true, buffer: null }) + }) + + it('keeps typing done during the save as unsaved', () => { + expect(landSave('a', 'a', 'x', () => 'xy', () => undefined)).toEqual({ here: true, buffer: 'xy' }) + }) + + it('never reads the editor once the user has moved to another file', () => { + expect(landSave('a', 'b', 'x', never, () => 'x')).toEqual({ here: false, clear: true }) + }) + + it('leaves the saved file dirty if more was typed before leaving it', () => { + expect(landSave('a', 'b', 'x', never, () => 'xy')).toEqual({ here: false, clear: false }) + }) +}) diff --git a/src/renderer/src/components/codeSave.ts b/src/renderer/src/components/codeSave.ts new file mode 100644 index 00000000..f501497b --- /dev/null +++ b/src/renderer/src/components/codeSave.ts @@ -0,0 +1,47 @@ +// The editor's save, the two decisions it makes around its awaits (2026-10-16). +// Pure, so they can be held by a test; CodeView does the writing. + +/** + * Where Ctrl+S may write, or null. + * + * The view outlives the file: for the moment between a new `path` and its read + * landing, the editor still holds the OLD file's text and `saved` still names + * the old file. save() checked only that `saved` existed, so a Ctrl+S in that + * moment wrote file A's text over file B. A save now needs the editor to be + * showing this path AND the remembered disk text to be this path's, and it + * writes to the path that text came from. + */ +export function saveTarget( + path: string, + loadedPath: string | null, + saved: { path: string } | null +): string | null { + if (!saved || loadedPath !== path || saved.path !== path) return null + return saved.path +} + +/** What a save that landed leaves behind. */ +export type SaveLanding = + /** Still on the saved file: report the buffer, null when it is clean. */ + | { here: true; buffer: string | null } + /** The user moved on while it saved: clear App's held buffer for the saved + * file only when that buffer is exactly what was written. */ + | { here: false; clear: boolean } + +/** + * After the write and the stat, the editor may hold more typing, or another + * file. It used to report "clean" regardless, through `pathRef`, which by then + * could name file B: B's unsaved edits were dropped, and characters typed + * during a slow save went untracked, so closing never asked about them. + */ +export function landSave( + saved: string, + current: string, + written: string, + doc: () => string, + pending: () => string | undefined +): SaveLanding { + if (current !== saved) return { here: false, clear: pending() === written } + const now = doc() + return { here: true, buffer: now === written ? null : now } +} diff --git a/src/renderer/src/hooks/useFileOps.test.ts b/src/renderer/src/hooks/useFileOps.test.ts new file mode 100644 index 00000000..8996d5c7 --- /dev/null +++ b/src/renderer/src/hooks/useFileOps.test.ts @@ -0,0 +1,92 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { newBrowse } from '../lib/browse' +import { newTab } from '../lib/tabs/tabs' +import { movedOn } from '../lib/fileOpsFollow' +import { useFileOps, type FileOpsDeps } from './useFileOps' +import type { ViewerFile } from '@shared/types' + +const film: ViewerFile = { + path: 'C:\\films\\clip.mp4', + name: 'clip.mp4', + ext: '.mp4', + kind: 'video', + size: 100, + mtimeMs: 0 +} + +// One render of the hook, effects not run: what the file operations do with +// the state they were started from. +function ops(openBrowseFile: FileOpsDeps['openBrowseFile']): ReturnType { + const browse = newBrowse('C:\\films', 'viewer') + const active = newTab( + { root: 'C:\\films', files: [film], index: 0, browse, role: 'explorer' }, + 'explorer' + ) + const noop = (): void => {} + const deps = { + active, + openBrowseFile, + open: noop, + setAsk: noop, + setRefreshKey: noop, + rekeyBuffer: noop, + setUndoState: noop, + file: film, + noteUndo: noop, + setTabState: noop, + dropBuffers: noop, + view: { files: [film], index: 0 }, + showNothing: noop, + tabs: [active], + buffers: { current: new Map() }, + undoState: { done: [], undone: [] }, + flashUndo: noop, + archiveActions: { drop: noop } + } as unknown as FileOpsDeps + let result!: ReturnType + function Probe(): null { + result = useFileOps(deps) + return null + } + renderToStaticMarkup(createElement(Probe)) + return result +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('a rename or delete elsewhere does not start the film on screen (2026-10-16)', () => { + it('re-shows the paused film without the intent to play after renaming another file', async () => { + vi.stubGlobal('window', { + prism: { + renameFile: async () => ({ ok: true, path: 'C:\\films\\b2.txt' }), + mediaUrl: (p: string) => 'fsmedia://' + p + } + }) + const open = vi.fn(async () => true) + await ops(open).runRename('C:\\films\\b.txt', 'b2.txt', 'ask') + expect(open).toHaveBeenCalledWith(film.path, true, false) + }) + + it('re-shows it without the intent to play after binning another file', async () => { + vi.stubGlobal('window', { + prism: { trashFile: async () => true, mediaUrl: (p: string) => 'fsmedia://' + p } + }) + const open = vi.fn(async () => true) + await ops(open).runDelete('C:\\films\\b.txt') + expect(open).toHaveBeenCalledWith(film.path, true, false) + }) +}) + +describe('movedOn (2026-10-16)', () => { + it('is false while the same file is on screen, or none (a delete steps off it)', () => { + expect(movedOn('C:\\a.txt', 'C:\\a.txt')).toBe(false) + expect(movedOn('C:\\a.txt', undefined)).toBe(false) + }) + + it('is true once another file is on screen', () => { + expect(movedOn('C:\\a.txt', 'C:\\b.txt')).toBe(true) + expect(movedOn(undefined, 'C:\\b.txt')).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/useFileOps.ts b/src/renderer/src/hooks/useFileOps.ts index 762916ab..02cf8446 100644 --- a/src/renderer/src/hooks/useFileOps.ts +++ b/src/renderer/src/hooks/useFileOps.ts @@ -26,7 +26,8 @@ import { movedPath, restoreMoveViews } from '../lib/moveViews' -import { sessionTime, wasPaused, rememberTime, rememberPaused } from '../lib/playState' +import { sessionTime, wasPaused, wasPlaying, rememberTime, rememberPaused } from '../lib/playState' +import { movedOn } from '../lib/fileOpsFollow' import { type UndoOps, undoEntry, redoEntry } from '../lib/undoApply' import type { DragPayload } from '../lib/drag/dragDrop' import { archivePassword } from '../lib/archivePass' @@ -116,17 +117,31 @@ export function useFileOps({ * folder moves everything under it, including possibly the open file. */ const within = (p: string, parent: string): boolean => isInside(parent, p, { orEqual: true }) + /** Show `p` again after a rename or delete. NOT a pick (2026-10-16): it went + * through `openBrowseFile`'s default `play = true`, which is the intent to + * play, so a paused film resumed with sound after an unrelated rename or + * delete in an Explorer tab. Only a caller that knows the file was playing + * passes `play`. */ const reopen = useCallback( - (p: string) => { + (p: string, play = false) => { if (!active) return - if (isExplorerTab(active)) void openBrowseFile(p, active.browse.surface === 'viewer') + if (isExplorerTab(active)) void openBrowseFile(p, active.browse.surface === 'viewer', play) else void window.prism.openWithin(active.root, p).then((payload) => payload && open(payload)) }, [active, open, openBrowseFile] ) + /** What is on screen NOW, for the end of a slow rename or delete (2026-10-16): + * `file` from the render that started it is stale by then, and re-opening + * it pulled the viewer back to the old file after the user had arrowed on. */ + const shown = useRef(file) + useLayoutEffect(() => { + shown.current = file + }, [file]) + const runRename = useCallback( async (path: string, name: string, onClash: OnClash, track = true): Promise => { + const cur = file?.path const r = await window.prism.renameFile(path, name, onClash) if (!r.ok) { if (r.reason === 'clash') @@ -146,9 +161,10 @@ export function useFileOps({ ) // 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 - if (!cur) return r.path - if (within(cur, path)) reopen(r.path + cur.slice(path.length)) + if (!cur || movedOn(cur, shown.current?.path)) return r.path + // The renamed file plays on only if it was playing. + if (within(cur, path)) + reopen(r.path + cur.slice(path.length), wasPlaying(window.prism.mediaUrl(cur))) else reopen(cur) return r.path }, @@ -223,6 +239,7 @@ export function useFileOps({ dropBuffers([path]) noteUndo({ kind: 'trash', paths: [path] }) const cur = file?.path + if (movedOn(cur, shown.current?.path)) return if (!cur || !within(cur, path)) { if (cur) reopen(cur) return @@ -266,7 +283,9 @@ export function useFileOps({ noteUndo({ kind: 'trash', paths: binned }) } const cur = file?.path - if (cur && paths.some((p) => within(cur, p))) { + if (movedOn(cur, shown.current?.path)) { + /* the user is on another file now; leave them there */ + } else if (cur && paths.some((p) => within(cur, p))) { const survivors = view?.files.filter((f) => !paths.some((p) => within(f.path, p))) ?? [] const next = survivors[Math.min(view?.index ?? 0, survivors.length - 1)] if (next) reopen(next.path) diff --git a/src/renderer/src/lib/fileOpsFollow.ts b/src/renderer/src/lib/fileOpsFollow.ts new file mode 100644 index 00000000..524f9173 --- /dev/null +++ b/src/renderer/src/lib/fileOpsFollow.ts @@ -0,0 +1,12 @@ +/** + * Has the user moved on since a slow rename or delete began (2026-10-16)? + * + * `start` is the file on screen when it began, `now` the one on screen when it + * finished. Nothing on screen now is NOT moving on: a delete steps every tab + * off the file it bins first (`releaseFiles`), so the viewer is empty by + * design. Another file is: re-opening the old one then yanked the viewer back + * to it. + */ +export function movedOn(start: string | undefined, now: string | undefined): boolean { + return now !== undefined && now !== start +}