fix: make the recent-files list only offer files it can reopen

A file opened without a File System Access handle (no API — Firefox,
Safari, Brave — or a template) is no longer recorded: nothing could
ever reopen it, so every click failed and dropped the entry. A startup
prune clears such stale entries. Reopening now asks for 'read' instead
of 'readwrite' (Save re-prompts on its own), and a declined permission
prompt keeps the entry — only a moved or deleted file drops it.
This commit is contained in:
Steffen Becker committed 2026-08-23 18:54:52 +00:00
1 parent 91a1ecb26d
commit ca29ff780b
6 files changed
+88 -43

No files matched your search

+14 -10
View File
@@ -11,7 +11,7 @@
import { buildOdt, deriveFilename } from './lib/export/odt';
import { exportPdf, printPdf, printRaster } from './lib/export/pdf';
import { supportsFsAccess, saveOdt, saveAsOdt, saveDocx, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile';
import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFiles, type RecentFile } from './lib/storage/recentFiles';
import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFile, forgetRecentFiles, pruneRecentFiles, type RecentFile } from './lib/storage/recentFiles';
import { importOdt } from './lib/import/odt';
import { importDocx } from './lib/import/docx';
import { convertUnsupportedImages } from './lib/import/imageFormats';
@@ -474,10 +474,12 @@
// The file the document is saved to (File System Access API). Session-only: a
// reload restores the doc from localStorage but the first Save re-prompts.
let fileHandle: FileSystemFileHandle | null = $state(null);
// Word's and LibreOffice's recent-documents list; a click reopens the file itself
// where the browser can hand back its handle.
let recentFiles: RecentFile[] = $state(loadRecentFiles());
// Word's and LibreOffice's recent-documents list; a click reopens the file itself.
// Only a browser with the File System Access API has handles to reopen from, so
// elsewhere the list stays empty; a startup prune drops entries whose handle is gone.
const fsSupported = supportsFsAccess();
let recentFiles: RecentFile[] = $state(fsSupported ? loadRecentFiles() : []);
if (fsSupported) void pruneRecentFiles().then((list) => (recentFiles = list));
let fileInput: HTMLInputElement | null = $state(null);
let pdfBusy = $state(false);
let docxBusy = $state(false);
@@ -828,17 +830,19 @@
}
}
// Reopen a file from the recent list. The File System Access API re-prompts for
// permission after a reload, and a file that has moved or been deleted is gone —
// either way the entry is dropped rather than left to fail again.
// Reopen a file from the recent list. Only a file that is really gone drops the
// entry; a permission prompt declined or dismissed keeps it for the next try.
async function handleOpenRecent(entry: RecentFile) {
exportMenuOpen = false;
try {
const r = await readRecentFile(entry.id);
if (!r) {
if (r === 'gone') {
alert(t().dialogs.recentUnavailable(entry.name));
recentFiles = recentFiles.filter((f) => f.id !== entry.id);
localStorage.setItem('edentext-recent-files', JSON.stringify(recentFiles));
recentFiles = forgetRecentFile(entry.id);
return;
}
if (r === 'denied') {
alert(t().dialogs.recentDenied(entry.name));
return;
}
await applyImport(r.bytes, r.handle, r.name);
+3 -1
View File
@@ -916,7 +916,9 @@ const de: Messages = {
couldNotExportDocx: 'Export nach Word (.docx) nicht möglich.',
couldNotExportPdf: 'PDF-Export nicht möglich.',
recentUnavailable: (name) =>
`„${name}“ konnte nicht erneut geöffnet werden — die Datei wurde vielleicht verschoben, oder der Browser hat keine Berechtigung mehr dafür. Öffne sie stattdessen über den Dateidialog.`,
`„${name}“ konnte nicht erneut geöffnet werden — die Datei wurde offenbar verschoben oder gelöscht. Öffne sie stattdessen über den Dateidialog.`,
recentDenied: (name) =>
`Der Browser hat das Lesen von „${name}“ nicht erlaubt. Erlaube den Zugriff im Berechtigungsdialog, oder öffne die Datei über den Dateidialog.`,
couldNotPrint: 'Drucken nicht möglich.',
couldNotPrintPdf: 'Druck als PDF nicht möglich.',
autosaveQuota:
+3 -1
View File
@@ -922,7 +922,9 @@ const en = {
couldNotExportDocx: 'Could not export to Word (.docx).',
couldNotExportPdf: 'Could not export to PDF.',
recentUnavailable: (name: string) =>
`"${name}" could not be opened again — it may have moved, or the browser no longer has permission for it. Open it from the file picker instead.`,
`"${name}" could not be opened again — the file seems to have been moved or deleted. Open it from the file picker instead.`,
recentDenied: (name: string) =>
`The browser did not allow reading "${name}". Allow access in its permission prompt, or open the file from the file picker.`,
couldNotPrint: 'Could not print.',
couldNotPrintPdf: 'Could not print to PDF.',
autosaveQuota:
+1 -1
View File
@@ -28,7 +28,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
All keys share the `edentext-` prefix — a namespace, not a description: on GitHub Pages every repo of an account shares one origin, so a generic prefix would collide.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `edentext-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. Reopening re-asks for permission (the API drops it on reload); a refusal, a moved file or a browser with no picker leaves the entry unopenable, and it is dropped with a message instead of failing again.
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
- **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `edentext-toolbar-expanded` — boolean string.
- **Chrome:** `edentext-chrome` — `'modern' | 'ribbon'` ('classic' read as the modern island's legacy stored name); absent = ribbon.
+41 -20
View File
@@ -1,10 +1,8 @@
import { openDb, idbRequest } from './idb';
// The files this browser has opened or saved, most recent first. The name and time
// go in localStorage so the menu can be drawn synchronously; the FileSystemFileHandle
// that actually reopens one is structured-cloneable but not JSON, so it lives in
// IndexedDB beside it. A browser without the File System Access API keeps the names
// only — there is nothing to reopen from, and the entry says so.
// The files this browser can reopen, most recent first. Name and time go in
// localStorage so the menu draws synchronously; the FileSystemFileHandle that reopens
// one is structured-cloneable but not JSON, so it lives in IndexedDB beside it.
const KEY = 'edentext-recent-files';
const DB_NAME = 'edentext-recent';
const STORE = 'handles';
@@ -35,26 +33,44 @@ function write(list: RecentFile[]): void {
}
/**
* Record a file as just used. A handle already in the list keeps its id — the same
* file opened twice is one entry, moved to the top — and one without a handle is
* matched on its name, which is all a browser with no picker gives us.
* Record a file as just used. Without a handle nothing can ever reopen the entry
* (no File System Access API, or a template), so none is recorded. A handle already
* in the list keeps its id — the same file opened twice is one entry, moved to the top.
*/
export async function rememberRecentFile(name: string, handle: FileSystemFileHandle | null): Promise<RecentFile[]> {
const list = loadRecentFiles();
const existing = handle ? await findByHandle(list, handle) : list.find((f) => f.name === name);
if (!handle) return list;
const existing = (await findByHandle(list, handle)) ?? list.find((f) => f.name === name);
const id = existing?.id ?? `f${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
const next = [{ id, name, at: Date.now() }, ...list.filter((f) => f.id !== id)].slice(0, MAX);
write(next);
if (handle) await putHandle(id, handle);
await putHandle(id, handle);
await sweepHandles(next);
return next;
}
/** Drop one entry (a dead one, on a failed reopen) and its stored handle. */
export function forgetRecentFile(id: string): RecentFile[] {
const next = loadRecentFiles().filter((f) => f.id !== id);
write(next);
void sweepHandles(next);
return next;
}
export function forgetRecentFiles(): void {
write([]);
void sweepHandles([]);
}
/** Drop every entry with no stored handle — nothing can reopen those. Run at startup. */
export async function pruneRecentFiles(): Promise<RecentFile[]> {
const list = loadRecentFiles();
const kept: RecentFile[] = [];
for (const f of list) if (await getHandle(f.id)) kept.push(f);
if (kept.length !== list.length) write(kept);
return kept;
}
// isSameEntry is the only way to compare two handles; it is async, so this walks.
async function findByHandle(list: RecentFile[], handle: FileSystemFileHandle): Promise<RecentFile | undefined> {
for (const f of list) {
@@ -98,21 +114,26 @@ async function sweepHandles(list: RecentFile[]): Promise<void> {
}
/**
* The bytes of a recent file, or null when it can't be reopened — the browser has no
* handle for it, the file is gone, or the user declined the permission prompt the
* File System Access API requires after a reload.
* The bytes of a recent file. 'gone' = no handle, or the file no longer exists — the
* entry is dead; 'denied' = read permission (re-asked after a reload) was not granted
* this time — the entry stays. Opening needs only 'read'; Save re-prompts on its own.
*/
export async function readRecentFile(id: string): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
export async function readRecentFile(id: string): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | 'gone' | 'denied'> {
const handle = await getHandle(id);
if (!handle) return null;
if (!handle) return 'gone';
type Permissioned = FileSystemFileHandle & {
queryPermission?: (d: { mode: string }) => Promise<PermissionState>;
requestPermission?: (d: { mode: string }) => Promise<PermissionState>;
};
const h = handle as Permissioned;
let state = (await h.queryPermission?.({ mode: 'readwrite' })) ?? 'granted';
if (state === 'prompt') state = (await h.requestPermission?.({ mode: 'readwrite' })) ?? 'denied';
if (state !== 'granted') return null;
const file = await handle.getFile();
return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name };
let state = (await h.queryPermission?.({ mode: 'read' })) ?? 'granted';
if (state === 'prompt') state = (await h.requestPermission?.({ mode: 'read' })) ?? 'denied';
if (state !== 'granted') return 'denied';
try {
const file = await handle.getFile();
return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name };
} catch (err) {
if ((err as DOMException)?.name === 'NotFoundError') return 'gone';
throw err;
}
}
+26 -10
View File
@@ -1,11 +1,14 @@
// The recent-files list (storage/recentFiles.ts): dedupe by name, newest first,
// capped at eight, and tolerant of corrupt localStorage. jsdom has no IndexedDB,
// so the handle side no-ops — exactly the browser-without-picker path.
// The recent-files list (storage/recentFiles.ts): dedupe, newest first, capped at
// eight, tolerant of corrupt localStorage, and never recording a handle-less file —
// nothing could reopen it. jsdom has no IndexedDB, so the handle store no-ops.
import { describe, it, expect, beforeEach } from 'vitest';
import { loadRecentFiles, rememberRecentFile, forgetRecentFiles } from '../../src/lib/storage/recentFiles';
import { loadRecentFiles, rememberRecentFile, forgetRecentFile, forgetRecentFiles, readRecentFile } from '../../src/lib/storage/recentFiles';
const KEY = 'edentext-recent-files';
// A stand-in for a picker handle; without IndexedDB it is matched by name only.
const stubHandle = () => ({ isSameEntry: async () => false }) as unknown as FileSystemFileHandle;
describe('recent files', () => {
beforeEach(() => localStorage.clear());
@@ -19,20 +22,26 @@ describe('recent files', () => {
expect(loadRecentFiles().map((f) => f.name)).toEqual(['ok.odt']);
});
it('remembers newest first and dedupes a handleless file by name, keeping its id', async () => {
it('records nothing without a handle — there is nothing to reopen from', async () => {
await rememberRecentFile('a.odt', null);
await rememberRecentFile('b.odt', null);
expect(loadRecentFiles()).toEqual([]);
expect(localStorage.getItem(KEY)).toBeNull();
});
it('remembers newest first and dedupes a same-named file, keeping its id', async () => {
await rememberRecentFile('a.odt', stubHandle());
await rememberRecentFile('b.odt', stubHandle());
expect(loadRecentFiles().map((f) => f.name)).toEqual(['b.odt', 'a.odt']);
const idA = loadRecentFiles()[1].id;
await rememberRecentFile('a.odt', null);
await rememberRecentFile('a.odt', stubHandle());
const list = loadRecentFiles();
expect(list.map((f) => f.name)).toEqual(['a.odt', 'b.odt']);
expect(list[0].id).toBe(idA);
});
it('keeps at most eight entries, dropping the oldest', async () => {
for (let i = 1; i <= 10; i++) await rememberRecentFile(`f${i}.odt`, null);
for (let i = 1; i <= 10; i++) await rememberRecentFile(`f${i}.odt`, stubHandle());
const names = loadRecentFiles().map((f) => f.name);
expect(names).toHaveLength(8);
expect(names[0]).toBe('f10.odt');
@@ -40,10 +49,17 @@ describe('recent files', () => {
expect(names).not.toContain('f2.odt');
});
it('forgetRecentFiles clears the list and removes the key', async () => {
await rememberRecentFile('a.odt', null);
it('forgetRecentFile drops one entry, forgetRecentFiles removes the key', async () => {
await rememberRecentFile('a.odt', stubHandle());
await rememberRecentFile('b.odt', stubHandle());
const idB = loadRecentFiles()[0].id;
expect(forgetRecentFile(idB).map((f) => f.name)).toEqual(['a.odt']);
forgetRecentFiles();
expect(loadRecentFiles()).toEqual([]);
expect(localStorage.getItem(KEY)).toBeNull();
});
it('readRecentFile reports a missing handle as gone', async () => {
expect(await readRecentFile('nope')).toBe('gone');
});
});