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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ notes. Entries describe what users experience, not internal refactors.

## [Unreleased]

### Fixed

- Cancelling a save keeps the unsaved-changes warning and recovery status intact.
- Exporting another format, such as PDF, preserves the original document file
and its ordinary save target, including when the export is cancelled or fails.
- Deleting, clearing or expiring local recovery records also forgets their saved
file associations. Unchanged documents no longer create duplicate recovery
versions or repeatedly run a full export.

### Added

- **Keep document structure when writing an assistant draft.** Word headings,
Expand Down
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,13 @@ test/unit/
test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL、localStorage
```

覆盖率阈值(全局):语句 34%、分支 25%、函数 35%、行 35%(当前实际约
45%/49%/49%,具体以 `pnpm run test:coverage` 输出为准,不要在本文件里维护
逐文件数字——它们过期得很快)。
覆盖率统计 `lib/**/*.ts` 与 `packages/*/src/**/*.ts`,排除类型声明。
全局最低门槛保留在 `vitest.config.ts`,历史、shared、converter、agent-core 与 chat-ui
另有独立模块门槛。实际数字以 `pnpm run test:coverage` 为准。

开发与 Vitest 通过 `bin/workspace-aliases.mjs` 从 workspace 的 `exports` 派生源码别名,
避免读取旧 `dist`。生产构建继续使用编译包,并由 `bin/build.sh` 先重建。源码别名
必须精确匹配包入口与子路径;WebLLM 的开发 Worker 响应策略同时覆盖源码路径。

**注意事项:**

Expand Down
2 changes: 2 additions & 0 deletions bin/workspace-aliases.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import type { Alias } from 'vite';
export function workspaceSourceAliases(root: string): Alias[];
27 changes: 27 additions & 0 deletions bin/workspace-aliases.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';

/**
* Resolve local exports to source in dev/tests while production uses built packages.
* @param {string} root
* @returns {import('vite').Alias[]}
*/
export function workspaceSourceAliases(root) {
/** @type {import('vite').Alias[]} */
const aliases = [];
for (const dir of readdirSync(resolve(root, 'packages'), { withFileTypes: true })) {
if (!dir.isDirectory()) continue;
const packageRoot = resolve(root, 'packages', dir.name);
const manifest = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8'));
for (const [subpath, entry] of Object.entries(manifest.exports)) {
const specifier = manifest.name + (subpath === '.' ? '' : subpath.slice(1));
const source = resolve(packageRoot, entry.import.replace('./dist/', './src/').replace(/\.js$/, '.ts'));
const escape = (part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
aliases.push({
find: new RegExp('^' + specifier.split('*').map(escape).join('(.+)') + '$'),
replacement: source.replace('*', '$1'),
});
}
}
return aliases;
}
55 changes: 55 additions & 0 deletions docs/explorations/2026-10-13-save-recovery-test-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Save, recovery and workspace test repairs

Scope: preserve cancellation outcomes, isolate cross-format exports, retire
history file links, avoid duplicate recovery exports, use source in development
and tests, and measure all application/workspace code with module gates.

## Behavior and ownership

- Disk writers return `written`, `cancelled` or `unavailable`. The converter's
download adapter preserves its underlying boolean outcome. Only a committed
write/download clears dirty state or updates the recovery row's disk-save time.
- Another format uses a separate picker and never replaces the ordinary document
target. An initial export is identified by the document store's original format.
Cancelling or failing an export preserves the original target.
- Document metadata, revisions and handles are deleted in one IndexedDB
transaction. In-memory links retire on commit, not on an aborted deletion.
Persisted targets are re-read before saving so another tab's deletion is honored.
Chosen targets register before asynchronous disk I/O; finishing a write never
recreates a link deleted while that write was pending. When persistence is
unavailable, the session-only fallback still works.
- Recovery compares editor frame, undo index, point identity and a captured copy
of undo-item references. SDK changes can append or replace items without moving
the point; item count alone is insufficient (for example spreadsheet sliders).
The signature is captured before export, so changes made while exporting still
need another recovery point. Unknown SDK shapes retain the conservative cadence.
- Development and Vitest aliases derive from workspace exports, including exact
and wildcard subpaths. Production still builds and consumes package output.
Source Worker URLs retain the model Worker response policy.
- Coverage includes `lib` and every workspace `src`, with separate module gates.
Existing global gates and the seven-day recovery retention remain unchanged.

## Regression evidence

Before production fixes, 12 new assertions failed for cancelled-save dirty state,
format overwrite, persisted/session handle cleanup and duplicate recovery.
A further failing export-write test demonstrated loss of the original link.
Two SDK-history cases demonstrated missed appended/replaced undo items.

Automated checks exercise committed bytes, dirty state, saved timestamps,
IndexedDB keys and recovery revisions rather than mirroring implementation text.
The source-resolution fixture contains deliberately stale compiled exports and
loads current source, exact/wildcard subpaths and an edited source module through
Vite. Chromium tests use real OPFS handles and actual editor exports to verify
cancelled-save warnings, PDF/DOCX isolation, subsequent saves and recovery.

Disabling the workspace source aliases made its Vite fixture load the stale
compiled values and fail. Disabling cancellation and export-target guards made
both new Chromium save cases fail on their intended dirty-state and original-file
assertions; restoring the guards passed both against the production build.

Final validation: 176 unit files / 5323 tests passed; expanded line coverage
85.06%, with all module gates passed. Production build and 10 Chromium cases
passed. Lint, TypeScript and formatting passed. Development Worker responses
returned the model policy on their source URL. The full Docker,
Cloudflare Pages and cross-browser suites remain CI responsibilities.
27 changes: 27 additions & 0 deletions lib/history/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { requestSaveDocument } from '../onlyoffice/save-stream';
import { getReadonlyMode } from '../onlyoffice/readonly';
import { getLastEditAt, hasUnsavedChanges } from '../unsaved-guard';
import { isEmbedMode } from '../embed-mode';
import { captureSavePoint, type SavePoint } from '../onlyoffice/save-point';
import { extensionOf, markOpened, putSnapshot } from './store';
import type { HistoryDoc, HistoryOrigin } from './types';

Expand Down Expand Up @@ -130,6 +131,8 @@ interface ActiveSession {
lastSnapshotAt: number;
exporting: boolean;
lastExportMs: number | null;
lastSnapshotPoint: SavePoint | null;
lastSnapshotItems: unknown[] | null;
failures: number;
holdsLock: boolean;
releaseLock: (() => void) | null;
Expand Down Expand Up @@ -187,6 +190,24 @@ export async function takeSnapshot(): Promise<HistoryDoc | null> {
const active = session;
if (!active || active.cached || active.exporting || !active.ext) return null;

const point = captureSavePoint();
const pointItems = (point?.head as { Items?: unknown[] } | undefined)?.Items;
const items = Array.isArray(pointItems) ? [...pointItems] : null;
const previous = active.lastSnapshotPoint;
const previousItems = active.lastSnapshotItems;
if (
point &&
previous &&
point.frame === previous.frame &&
point.index === previous.index &&
point.head === previous.head &&
items &&
previousItems &&
items.length === previousItems.length &&
items.every((item, index) => item === previousItems[index])
)
return null;

active.exporting = true;
const startedAt = Date.now();
try {
Expand Down Expand Up @@ -219,6 +240,10 @@ export async function takeSnapshot(): Promise<HistoryDoc | null> {
// What this export cost sets the next interval: a slow device asks for
// snapshots less often without anyone having to identify it as slow.
active.lastExportMs = active.lastSnapshotAt - startedAt;
// Capture before export, so an edit made during conversion still needs a
// later recovery point. With no SDK history, retain the conservative cadence.
active.lastSnapshotPoint = point;
active.lastSnapshotItems = items;
active.failures = 0;
return doc;
} catch (error) {
Expand Down Expand Up @@ -324,6 +349,8 @@ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise
lastSnapshotAt: Date.now(),
exporting: false,
lastExportMs: null,
lastSnapshotPoint: null,
lastSnapshotItems: null,
failures: 0,
holdsLock: held,
releaseLock: release,
Expand Down
14 changes: 7 additions & 7 deletions lib/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import { setDiskWriter, setSavedToDiskListener } from '../onlyoffice/save-stream';
import { saveToDiskFile } from '../save-target';
import { getAutosaveDocId } from './autosave';
import { markSavedToDisk, pruneExpired } from './store';
import { extensionOf, markSavedToDisk, pruneExpired } from './store';
import { getDocmentObj } from '@ranuts/shared/store';

export function initDocumentHistory(): void {
// Expiry has to happen on its own, not when someone thinks to visit the
Expand All @@ -19,12 +20,11 @@ export function initDocumentHistory(): void {
// is wired here rather than inside the save channel.
setDiskWriter(async (file) => {
const docId = getAutosaveDocId();
if (!docId) return false;
const outcome = await saveToDiskFile(docId, file, 'Document');
// 'cancelled' means the user closed the picker: they decided not to save,
// so downloading a copy anyway would be answering a question they declined.
if (outcome === 'cancelled') return true;
return outcome === 'written';
if (!docId) return 'unavailable';
const sourceName = getDocmentObj()?.fileName || file.name;
return saveToDiskFile(docId, file, 'Document', {
rememberTarget: extensionOf(sourceName) === extensionOf(file.name),
});
});

setSavedToDiskListener(() => {
Expand Down
37 changes: 21 additions & 16 deletions lib/history/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
* Everything here resolves to a null/empty result when IndexedDB is missing;
* see lib/history/db.ts for why.
*/
import { BLOBS_BY_DOC, BLOBS_STORE, DOCS_STORE, requestToPromise, withStores } from './db';
import { BLOBS_BY_DOC, BLOBS_STORE, DOCS_STORE, HANDLES_STORE, requestToPromise, withStores } from './db';
import type { HistoryDoc, HistoryOrigin, HistorySnapshot } from './types';
import { MAX_AGE_MS, expiresAt, hasUnsavedWork } from './types';
import { deleteDocumentSources } from '../active-document-source';
import { clearSessionSaveTargets } from '../save-target';

/** Revisions kept per document: the newest plus two recovery points behind it. */
export const MAX_REVS_PER_DOC = 3;
Expand Down Expand Up @@ -112,11 +113,14 @@ export async function storageBudget(): Promise<number> {
return MAX_TOTAL_BYTES;
}

/** Delete every revision of one document. Caller owns the transaction. */
async function deleteBlobsOf(tx: IDBTransaction, docId: string): Promise<void> {
/** Delete all history data and its disk link atomically. Caller owns the transaction. */
async function deleteDocumentData(tx: IDBTransaction, docId: string): Promise<void> {
const store = tx.objectStore(BLOBS_STORE);
const keys = await requestToPromise(store.index(BLOBS_BY_DOC).getAllKeys(IDBKeyRange.only(docId)));
for (const key of keys) store.delete(key);
tx.objectStore(DOCS_STORE).delete(docId);
tx.objectStore(HANDLES_STORE).delete(docId);
tx.addEventListener('complete', () => clearSessionSaveTargets(docId), { once: true });
}

/** Drop revisions past MAX_REVS_PER_DOC, oldest first. Returns bytes freed. */
Expand Down Expand Up @@ -144,8 +148,7 @@ async function sweepExpired(tx: IDBTransaction, now: number): Promise<string[]>
const docs = await readAllDocs(tx);
const expired = docs.filter((doc) => expiresAt(doc) <= now);
for (const doc of expired) {
await deleteBlobsOf(tx, doc.id);
tx.objectStore(DOCS_STORE).delete(doc.id);
await deleteDocumentData(tx, doc.id);
}
return expired.map((doc) => doc.id);
}
Expand All @@ -164,8 +167,7 @@ async function enforceBudget(tx: IDBTransaction, budget: number, protectedId: st

for (const doc of candidates) {
if (total <= budget) break;
await deleteBlobsOf(tx, doc.id);
tx.objectStore(DOCS_STORE).delete(doc.id);
await deleteDocumentData(tx, doc.id);
total -= doc.totalBytes || 0;
}
}
Expand Down Expand Up @@ -201,7 +203,7 @@ async function writeSnapshot(
const now = stamp();
const title = input.title.trim() || 'Untitled';

return withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => {
return withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => {
if (!watchSnapshotCancellation(tx, signal)) return null;
const docs = tx.objectStore(DOCS_STORE);
const existing = input.id ? ((await requestToPromise(docs.get(input.id))) as HistoryDoc | undefined) : undefined;
Expand Down Expand Up @@ -287,13 +289,12 @@ export async function putSnapshot(input: SnapshotInput, signal?: AbortSignal): P
async function evictColdest(protectedId?: string, signal?: AbortSignal): Promise<boolean> {
try {
return (
(await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => {
(await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => {
if (!watchSnapshotCancellation(tx, signal)) return false;
const docs = (await readAllDocs(tx)).filter((doc) => doc.id !== protectedId);
if (!docs.length) return false;
const coldest = docs.sort((a, b) => a.lastOpenedAt - b.lastOpenedAt)[0];
await deleteBlobsOf(tx, coldest.id);
tx.objectStore(DOCS_STORE).delete(coldest.id);
await deleteDocumentData(tx, coldest.id);
return true;
})) ?? false
);
Expand Down Expand Up @@ -380,9 +381,8 @@ export async function deleteDoc(id: string): Promise<boolean> {
try {
const originalsDeleted = await deleteDocumentSources(id);
const historyDeleted =
(await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => {
await deleteBlobsOf(tx, id);
tx.objectStore(DOCS_STORE).delete(id);
(await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => {
await deleteDocumentData(tx, id);
return true;
})) ?? false;
return originalsDeleted && historyDeleted;
Expand All @@ -400,9 +400,11 @@ export async function clearAllHistory(): Promise<boolean> {
try {
const originalsDeleted = await deleteDocumentSources();
const historyDeleted =
(await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => {
(await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => {
tx.objectStore(DOCS_STORE).clear();
tx.objectStore(BLOBS_STORE).clear();
tx.objectStore(HANDLES_STORE).clear();
tx.addEventListener('complete', () => clearSessionSaveTargets(), { once: true });
return true;
})) ?? false;
return originalsDeleted && historyDeleted;
Expand Down Expand Up @@ -455,7 +457,10 @@ export function resetHistoryClockForTests(): void {

export async function pruneExpired(): Promise<string[]> {
try {
return (await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', (tx) => sweepExpired(tx, Date.now()))) ?? [];
return (
(await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', (tx) => sweepExpired(tx, Date.now()))) ??
[]
);
} catch {
return [];
}
Expand Down
16 changes: 9 additions & 7 deletions lib/onlyoffice/save-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getDocumentOpenError, waitForDocumentContentReady } from './open-state'
import { getReadonlyMode } from './readonly';
import { isEmbedMode } from '../embed-mode';
import { markDocumentSaved } from '../unsaved-guard';
import type { SaveOutcome } from '../save-target';

/**
* Notified when bytes actually reach the user's disk.
Expand All @@ -27,12 +28,12 @@ export function setSavedToDiskListener(listener: (() => void) | null): void {
*
* Injected for the same reason as the listener above -- it needs the document's
* identity, which lives in the history layer, and that layer exports through
* this module. Returns true when the user's document is saved and there is
* nothing left to do; false means "carry on with the download".
* this module. Returns the write outcome: cancellation stops without clearing dirty state,
* and unavailable writers fall back to the download.
*/
let diskWriter: ((file: File) => Promise<boolean>) | null = null;
let diskWriter: ((file: File) => Promise<SaveOutcome>) | null = null;

export function setDiskWriter(writer: ((file: File) => Promise<boolean>) | null): void {
export function setDiskWriter(writer: ((file: File) => Promise<SaveOutcome>) | null): void {
diskWriter = writer;
}

Expand Down Expand Up @@ -139,12 +140,13 @@ function routeSavedFile(file: File): void {
// view: the bytes are on the user's disk.
void (async () => {
try {
if (await diskWriter?.(file)) {
const outcome = await diskWriter?.(file);
if (outcome === 'cancelled') return;
if (outcome === 'written') {
reachedDisk();
return;
}
await saveFileToDisk(file, file.name);
reachedDisk();
if (await saveFileToDisk(file, file.name)) reachedDisk();
} catch (error) {
notifyOperationFailed(error);
}
Expand Down
Loading
Loading