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
1 change: 1 addition & 0 deletions scripts/checkedReadMigration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/documentEncodingRoundTrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/explicitSaveCancelsAutoSave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/externalChangeReload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/lossySaveRefusalScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/oneWritePerTabInFlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/pathIdentityCaseFolding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/reopenDirtyDocument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
1 change: 1 addition & 0 deletions scripts/taskToggleLineEndings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function makeSession() {
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
58 changes: 44 additions & 14 deletions scripts/truncatedBufferGuard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -71,13 +73,15 @@ function makeSession() {
askClose: async () => closeAnswer,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => notices.push('partialCopySaved'),
});
}

function reset() {
tabManager.closeAll();
invokeCalls = [];
errors.length = 0;
notices.length = 0;
closeAnswer = 'discard';
}

Expand Down Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions scripts/viewModeWithoutSaving.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ function makeSession(askClose: (title: string) => Promise<'save' | 'discard' | '
askClose,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
onPartialCopySaved: () => {},
});
}

Expand Down
3 changes: 3 additions & 0 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
41 changes: 35 additions & 6 deletions src/lib/sessions/documentSession.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -625,11 +631,21 @@ export function createDocumentSession(options: DocumentSessionOptions) {
async function saveContentAs(): Promise<boolean> {
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'] },
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/lib/utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ export const translations: Record<LanguageCode, Translation> = {
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'
Expand Down Expand Up @@ -603,6 +605,8 @@ export const translations: Record<LanguageCode, Translation> = {
openExportedFileFailed: '无法打开导出的文件',
partialDocument: '暂时无法编辑 — 文件尚未完整加载',
lossySaveBlocked: '未保存:此文件的部分内容无法用任何编码读取,打开时已变成“�”。直接保存会破坏原文件 — 请用“另存为”写入新文件',
partialSaveBlocked: '未保存:此文件只加载了一部分,保存会将其截断 —— 请用“另存为”写一份副本',
partialCopySaved: '副本已保存,但其中只包含已加载的那部分文档',
encodingUnmappable: '未保存:{{encoding}} 无法表示文档中新增的部分字符(多半是表情符号)。请用“另存为”写入一份 UTF-8 副本'
},
externalChange: {
Expand Down Expand Up @@ -888,6 +892,8 @@ export const translations: Record<LanguageCode, Translation> = {
autoSaveFailed: '自動保存に失敗しました — 未保存の変更はメモリ内に残っています',
savedNewerEdits: '保存しました — 新しい編集があるため編集モードを継続します',
lossySaveBlocked: '保存しませんでした:このファイルの一部はどの文字コードでも読めず、開いた時点で「�」に置き換わっています。上書き保存すると元のファイルが壊れます —「名前を付けて保存」で新しいファイルに書き出してください',
partialSaveBlocked: '保存しませんでした:このファイルは一部しか読み込まれておらず、上書き保存すると切り詰められます —「名前を付けて保存」で複製を書き出してください',
partialCopySaved: '複製を保存しましたが、読み込めていた範囲のみが含まれています',
encodingUnmappable: '保存しませんでした:{{encoding}} ではこの文書に含まれる一部の文字(多くは絵文字)を表現できません。「名前を付けて保存」で UTF-8 の複製を作成してください'
},
modal: {
Expand Down Expand Up @@ -1181,6 +1187,8 @@ export const translations: Record<LanguageCode, Translation> = {
openExportedFileFailed: '無法開啟匯出的檔案',
partialDocument: '尚無法編輯 — 此檔案尚未完全讀取',
lossySaveBlocked: '未儲存:此檔案的部分內容無法用任何編碼讀取,開啟時已變成「�」。直接儲存會破壞原檔案 — 請用「另存新檔」寫入新檔案',
partialSaveBlocked: '未儲存:此檔案只讀取了一部分,儲存會將其截斷 —— 請用「另存新檔」寫一份副本',
partialCopySaved: '副本已儲存,但其中只包含已讀取的那部分文件',
encodingUnmappable: '未儲存:{{encoding}} 無法表示此文件新增的部分字元(多半是表情符號)。請用「另存新檔」寫入一份 UTF-8 複本'
},
externalChange: {
Expand Down Expand Up @@ -1496,6 +1504,8 @@ export const translations: Record<LanguageCode, Translation> = {
savedNewerEdits: '저장됨 — 새로운 편집이 있어 편집 모드를 유지합니다',
openExportedFileFailed: '내보낸 파일을 열 수 없습니다',
lossySaveBlocked: '저장하지 않음: 이 파일의 일부는 어떤 인코딩으로도 읽을 수 없어 열 때 "�"로 바뀌었습니다. 덮어쓰면 원본이 손상됩니다 — "다른 이름으로 저장"으로 새 파일에 저장하세요',
partialSaveBlocked: '저장하지 않음: 이 파일은 일부만 불러왔으며, 저장하면 잘려나갑니다 — "다른 이름으로 저장"으로 사본을 만드세요',
partialCopySaved: '사본을 저장했지만, 불러온 부분만 담겨 있습니다',
encodingUnmappable: '저장하지 않음: {{encoding}}(으)로는 이 문서에 포함된 일부 문자(대개 이모지)를 표현할 수 없습니다. "다른 이름으로 저장"으로 UTF-8 사본을 만드세요'
},
modal: {
Expand Down Expand Up @@ -1775,6 +1785,8 @@ export const translations: Record<LanguageCode, Translation> = {
autoSaveFailed: 'Автосохранение не удалось — несохранённые правки остались только в памяти',
savedNewerEdits: 'Сохранено — остаюсь в режиме редактирования, так как есть более новые правки',
lossySaveBlocked: 'Не сохранено: часть содержимого файла не читается ни в одной кодировке и при открытии стала «�». Сохранение уничтожит оригинал — используйте «Сохранить как» для записи копии',
partialSaveBlocked: 'Не сохранено: загружена только часть файла, сохранение обрежет его — используйте «Сохранить как» для создания копии',
partialCopySaved: 'Копия сохранена, но содержит только загруженную часть документа',
encodingUnmappable: 'Не сохранено: кодировка {{encoding}} не может представить некоторые символы этого документа (скорее всего, эмодзи). Используйте «Сохранить как», чтобы записать копию в UTF-8'
},
modal: {
Expand Down