diff --git a/scripts/checkedReadMigration.test.ts b/scripts/checkedReadMigration.test.ts index dc088dde..f927e5f8 100644 --- a/scripts/checkedReadMigration.test.ts +++ b/scripts/checkedReadMigration.test.ts @@ -82,6 +82,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/documentEncodingRoundTrip.test.ts b/scripts/documentEncodingRoundTrip.test.ts index e9e60cd3..914fdcc7 100644 --- a/scripts/documentEncodingRoundTrip.test.ts +++ b/scripts/documentEncodingRoundTrip.test.ts @@ -92,6 +92,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/explicitSaveCancelsAutoSave.test.ts b/scripts/explicitSaveCancelsAutoSave.test.ts index 0f06f764..ab92f5f2 100644 --- a/scripts/explicitSaveCancelsAutoSave.test.ts +++ b/scripts/explicitSaveCancelsAutoSave.test.ts @@ -99,6 +99,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/externalChangeReload.test.ts b/scripts/externalChangeReload.test.ts index 5907c808..11dd2d9f 100644 --- a/scripts/externalChangeReload.test.ts +++ b/scripts/externalChangeReload.test.ts @@ -53,6 +53,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/lossySaveRefusalScope.test.ts b/scripts/lossySaveRefusalScope.test.ts index 1faeff67..e9cff2da 100644 --- a/scripts/lossySaveRefusalScope.test.ts +++ b/scripts/lossySaveRefusalScope.test.ts @@ -88,6 +88,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/oneWritePerTabInFlight.test.ts b/scripts/oneWritePerTabInFlight.test.ts index 0ff20b1d..cfb7744c 100644 --- a/scripts/oneWritePerTabInFlight.test.ts +++ b/scripts/oneWritePerTabInFlight.test.ts @@ -116,6 +116,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/pathIdentityCaseFolding.test.ts b/scripts/pathIdentityCaseFolding.test.ts index 2aae2a8d..be6bb0c0 100644 --- a/scripts/pathIdentityCaseFolding.test.ts +++ b/scripts/pathIdentityCaseFolding.test.ts @@ -121,6 +121,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/reopenDirtyDocument.test.ts b/scripts/reopenDirtyDocument.test.ts index c986f2a5..c6a3d9b4 100644 --- a/scripts/reopenDirtyDocument.test.ts +++ b/scripts/reopenDirtyDocument.test.ts @@ -80,6 +80,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/taskToggleLineEndings.test.ts b/scripts/taskToggleLineEndings.test.ts index 022702a1..0de66501 100644 --- a/scripts/taskToggleLineEndings.test.ts +++ b/scripts/taskToggleLineEndings.test.ts @@ -70,6 +70,7 @@ function makeSession() { askClose: async () => 'discard' as const, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/scripts/truncatedBufferGuard.test.ts b/scripts/truncatedBufferGuard.test.ts index 1e24bbaa..13203164 100644 --- a/scripts/truncatedBufferGuard.test.ts +++ b/scripts/truncatedBufferGuard.test.ts @@ -49,6 +49,8 @@ const { createDocumentSession } = await import('../src/lib/sessions/documentSess const viewer = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); const errors: string[] = []; +/** Non-failure notices the session raised, in order. */ +const notices: string[] = []; /** What the close dialog answers next. Set per test. */ let closeAnswer: 'save' | 'discard' | 'cancel' = 'discard'; @@ -71,6 +73,7 @@ function makeSession() { askClose: async () => closeAnswer, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => notices.push('partialCopySaved'), }); } @@ -78,6 +81,7 @@ function reset() { tabManager.closeAll(); invokeCalls = []; errors.length = 0; + notices.length = 0; closeAnswer = 'discard'; } @@ -362,23 +366,49 @@ test('a task checkbox is not toggled into a partial buffer that already carries assert.equal(tab.rawContent, edited, 'and the edits it carries must not be traded for the file’s tail'); }); -test('Save As refuses to copy a partially loaded document', async () => { - // The copy would be silently short, and it is a NEW file — nothing about it - // says it is missing everything past 50KB. The guard sits before the dialog, - // so the user is not asked where to put a document that is not going to be - // written. +test('Save As completes the buffer first, so the copy is whole when it can be', async () => { + // Reaching for Save As is not a reason to write less than the document. The + // tail is readable and the buffer is clean, so there is nothing stopping this + // copy from being the whole file — and nothing to warn about. reset(); - const { session } = await openPartial(); + const { session, tab } = await openPartial(); + handleInvoke = (cmd) => { + if (cmd === 'read_file_content_checked') return [FULL, false, 'UTF-8']; + if (cmd === 'save_file_content') return null; + if (cmd === 'canonicalize_path') return '/docs/copy.md'; + if (cmd === 'plugin:dialog|save') return '/docs/copy.md'; + throw new Error(`unexpected invoke: ${cmd}`); + }; + + assert.equal(await session.saveContentAs(), true); + assert.equal(invokeCalls.find((call) => call.cmd === 'save_file_content')?.args.content, FULL); + assert.deepEqual(notices, [], 'a whole copy is not worth a warning'); + assert.notEqual(tab.isTruncated, true); +}); + +test('Save As is the way out of a partial buffer that carries edits', async () => { + // The one write a partial buffer may make. A copy is a NEW file at a path the + // user chose, so it destroys nothing they had — and refusing it too left the + // edits below with no exit at all, which is a worse answer than a short copy + // the user is told about. `refuseIfLossilyDecoded` has always reasoned this + // way, and this is the same trade. + reset(); + const { session, tab } = await openPartial(); + const edited = `${PARTIAL}edited`; + tabManager.updateTabRawContent(tab.id, edited); + // Completing the buffer is not on the table here: the tail is gone, and even + // if it were readable, reading it would discard the edits this is rescuing. makeTailUnreadable(); - assert.equal(await session.saveContentAs(), false, 'Save As must report failure rather than write a short copy'); - assert.equal(wroteToDisk(), false, 'an incomplete copy must never be written'); - assert.equal( - invokeCalls.some((call) => call.cmd.endsWith('dialog|save')), - false, - 'and the Save As dialog is not opened for a write that cannot happen', - ); - assert.deepEqual(errors, ['Refusing to save a partially loaded document']); + assert.equal(await session.saveContentAs(), true, 'the rescue must be allowed to happen'); + assert.equal(invokeCalls.find((call) => call.cmd === 'save_file_content')?.args.content, edited); + assert.deepEqual(notices, ['partialCopySaved'], 'and the reader is told where the copy stops'); + assert.deepEqual(errors, [], 'it is not a failure'); + // The flag described the file the tab used to point at. It now points at the + // copy and holds all of it, so leaving the flag on would free the text and + // then trap it again — every later save refused, from a whole document. + assert.notEqual(tab.isTruncated, true); + assert.equal(await session.saveContent(tab.id), true, 'and the tab saves normally from here on'); }); test('answering “Save” to the close dialog cannot flush a partial buffer', async () => { diff --git a/scripts/viewModeWithoutSaving.test.ts b/scripts/viewModeWithoutSaving.test.ts index 2dac55d4..f7b69ad0 100644 --- a/scripts/viewModeWithoutSaving.test.ts +++ b/scripts/viewModeWithoutSaving.test.ts @@ -390,6 +390,7 @@ function makeSession(askClose: (title: string) => Promise<'save' | 'discard' | ' askClose, onCloseSaveNewerEdits: () => {}, onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, }); } diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 9a0d7a16..3e4a82f2 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -701,6 +701,9 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu }), onCloseSaveNewerEdits: () => addToast(t('toast.savedNewerEdits', settings.language), 'info'), onCloseAutoSaveFailed: () => addToast(t('toast.autoSaveFailed', settings.language), 'error'), + // Not an error: the copy is the way out of a partial buffer, and it was + // written. What the reader needs is to know where it stops. + onPartialCopySaved: () => addToast(t('toast.partialCopySaved', settings.language), 'info'), }); async function discardPersistedWindowState() { diff --git a/src/lib/sessions/documentSession.svelte.ts b/src/lib/sessions/documentSession.svelte.ts index 05b5f9c4..350f89e7 100644 --- a/src/lib/sessions/documentSession.svelte.ts +++ b/src/lib/sessions/documentSession.svelte.ts @@ -55,6 +55,12 @@ type DocumentSessionOptions = { askClose: (title: string) => Promise<'save' | 'discard' | 'cancel'>; onCloseSaveNewerEdits: () => void; onCloseAutoSaveFailed: () => void; + /** + * A Save As wrote a copy that stops where the load did. Not a failure — the + * copy is the way out of a partial buffer — but the reader has to be told + * what is in the file they just made. + */ + onPartialCopySaved: () => void; }; /** @@ -557,7 +563,7 @@ export function createDocumentSession(options: DocumentSessionOptions) { // a large file completes its buffer first. If one is ever missed, the // write must fail loudly rather than silently truncate the document. if (tab.isTruncated) { - options.onError('Refusing to save a partially loaded document', new Error(tab.path)); + options.onError(t('toast.partialSaveBlocked', settings.language), tab.path); return false; } let targetPath = tab.path; @@ -625,11 +631,21 @@ export function createDocumentSession(options: DocumentSessionOptions) { async function saveContentAs(): Promise { const tab = tabManager.activeTab; if (!tab) return false; - // A partial buffer would produce a silently incomplete copy. - if (tab.isTruncated) { - options.onError('Refusing to save a partially loaded document', new Error(tab.path)); - return false; - } + // A copy is a NEW file at a path the user chose, so unlike the ordinary + // save it cannot destroy anything they already have. That is what makes + // it the one write a partial buffer may make — and refusing it too, as + // this did, left a buffer that could go nowhere: not over the original, + // correctly, and not beside it either, so edits made in that state had + // no exit but the clipboard. The lossy-decode guard reasons the same way + // and has always pointed at Save As for the same reason. + // + // Completed first where that is possible, so the copy is whole whenever + // it can be. `ensureFullContent` declines on a dirty buffer — reading the + // file over unsaved edits would discard the very thing this rescue is + // for — and that is exactly the case where the copy really is short, so + // the user is told once it is written. + if (tab.isTruncated) await ensureFullContent(tab.id); + const copyIsPartial = tab.isTruncated === true; const selected = await save({ filters: [ { name: 'Markdown', extensions: ['md'] }, @@ -667,9 +683,22 @@ export function createDocumentSession(options: DocumentSessionOptions) { tabManager.setTabDecodedLossy(tab.id, false); tabManager.setTabEncoding(tab.id, 'UTF-8'); lossySaveWarnedTabs.delete(tab.id); + // And the same for the truncation flag, for the same reason: it + // described the file this tab used to point at. Whatever the buffer + // was a slice OF, it is the whole of what was just written, and the + // tab now points at that. Leaving it on would refuse every save from + // a tab holding a complete document — the rescue would free the text + // and then trap it again. Assigned rather than going through + // `setTabRawContent`, which would also reset the baseline the two + // lines below set on purpose. + tab.isTruncated = false; options.saveRecentFile(selected); tab.originalContent = snapshot; tab.isDirty = tab.rawContent !== snapshot; + // Said after the write rather than before the dialog: nothing is lost + // by proceeding, and a reader who came here to rescue unsaved text + // still has to know that what landed on disk stops where the load did. + if (copyIsPartial) options.onPartialCopySaved(); return true; } catch (error) { clearSelfWrite(selected); diff --git a/src/lib/utils/i18n.ts b/src/lib/utils/i18n.ts index 0c92f22d..b4a5c9ed 100644 --- a/src/lib/utils/i18n.ts +++ b/src/lib/utils/i18n.ts @@ -273,6 +273,8 @@ export const translations: Record = { openExportedFileFailed: 'Could not open exported file', partialDocument: 'Cannot edit yet — this file is not fully loaded', lossySaveBlocked: 'Not saved: parts of this file could not be read in any encoding and became "�" when it was opened. Saving would destroy the original — use "Save As" to write a copy', + partialSaveBlocked: 'Not saved: only part of this file has loaded, and saving would truncate it — use "Save As" to write a copy', + partialCopySaved: 'Copy saved, but it holds only the part of the document that had loaded', encodingUnmappable: 'Not saved: {{encoding}} cannot represent every character this document now contains (an emoji, most likely). Use "Save As" to write a copy as UTF-8', restoreInterrupted: 'Markpad did not finish restoring your session last time', restoreInterruptedDeferred: 'Markpad did not finish opening {path} last time, so it was skipped. Open it yourself to try again' @@ -603,6 +605,8 @@ export const translations: Record = { openExportedFileFailed: '无法打开导出的文件', partialDocument: '暂时无法编辑 — 文件尚未完整加载', lossySaveBlocked: '未保存:此文件的部分内容无法用任何编码读取,打开时已变成“�”。直接保存会破坏原文件 — 请用“另存为”写入新文件', + partialSaveBlocked: '未保存:此文件只加载了一部分,保存会将其截断 —— 请用“另存为”写一份副本', + partialCopySaved: '副本已保存,但其中只包含已加载的那部分文档', encodingUnmappable: '未保存:{{encoding}} 无法表示文档中新增的部分字符(多半是表情符号)。请用“另存为”写入一份 UTF-8 副本' }, externalChange: { @@ -888,6 +892,8 @@ export const translations: Record = { autoSaveFailed: '自動保存に失敗しました — 未保存の変更はメモリ内に残っています', savedNewerEdits: '保存しました — 新しい編集があるため編集モードを継続します', lossySaveBlocked: '保存しませんでした:このファイルの一部はどの文字コードでも読めず、開いた時点で「�」に置き換わっています。上書き保存すると元のファイルが壊れます —「名前を付けて保存」で新しいファイルに書き出してください', + partialSaveBlocked: '保存しませんでした:このファイルは一部しか読み込まれておらず、上書き保存すると切り詰められます —「名前を付けて保存」で複製を書き出してください', + partialCopySaved: '複製を保存しましたが、読み込めていた範囲のみが含まれています', encodingUnmappable: '保存しませんでした:{{encoding}} ではこの文書に含まれる一部の文字(多くは絵文字)を表現できません。「名前を付けて保存」で UTF-8 の複製を作成してください' }, modal: { @@ -1181,6 +1187,8 @@ export const translations: Record = { openExportedFileFailed: '無法開啟匯出的檔案', partialDocument: '尚無法編輯 — 此檔案尚未完全讀取', lossySaveBlocked: '未儲存:此檔案的部分內容無法用任何編碼讀取,開啟時已變成「�」。直接儲存會破壞原檔案 — 請用「另存新檔」寫入新檔案', + partialSaveBlocked: '未儲存:此檔案只讀取了一部分,儲存會將其截斷 —— 請用「另存新檔」寫一份副本', + partialCopySaved: '副本已儲存,但其中只包含已讀取的那部分文件', encodingUnmappable: '未儲存:{{encoding}} 無法表示此文件新增的部分字元(多半是表情符號)。請用「另存新檔」寫入一份 UTF-8 複本' }, externalChange: { @@ -1496,6 +1504,8 @@ export const translations: Record = { savedNewerEdits: '저장됨 — 새로운 편집이 있어 편집 모드를 유지합니다', openExportedFileFailed: '내보낸 파일을 열 수 없습니다', lossySaveBlocked: '저장하지 않음: 이 파일의 일부는 어떤 인코딩으로도 읽을 수 없어 열 때 "�"로 바뀌었습니다. 덮어쓰면 원본이 손상됩니다 — "다른 이름으로 저장"으로 새 파일에 저장하세요', + partialSaveBlocked: '저장하지 않음: 이 파일은 일부만 불러왔으며, 저장하면 잘려나갑니다 — "다른 이름으로 저장"으로 사본을 만드세요', + partialCopySaved: '사본을 저장했지만, 불러온 부분만 담겨 있습니다', encodingUnmappable: '저장하지 않음: {{encoding}}(으)로는 이 문서에 포함된 일부 문자(대개 이모지)를 표현할 수 없습니다. "다른 이름으로 저장"으로 UTF-8 사본을 만드세요' }, modal: { @@ -1775,6 +1785,8 @@ export const translations: Record = { autoSaveFailed: 'Автосохранение не удалось — несохранённые правки остались только в памяти', savedNewerEdits: 'Сохранено — остаюсь в режиме редактирования, так как есть более новые правки', lossySaveBlocked: 'Не сохранено: часть содержимого файла не читается ни в одной кодировке и при открытии стала «�». Сохранение уничтожит оригинал — используйте «Сохранить как» для записи копии', + partialSaveBlocked: 'Не сохранено: загружена только часть файла, сохранение обрежет его — используйте «Сохранить как» для создания копии', + partialCopySaved: 'Копия сохранена, но содержит только загруженную часть документа', encodingUnmappable: 'Не сохранено: кодировка {{encoding}} не может представить некоторые символы этого документа (скорее всего, эмодзи). Используйте «Сохранить как», чтобы записать копию в UTF-8' }, modal: {