diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js
index dd54b8e8..af11b098 100644
--- a/desktop-app/resources/js/script.js
+++ b/desktop-app/resources/js/script.js
@@ -2098,18 +2098,12 @@ document.addEventListener("DOMContentLoaded", async function () {
detail: 'Validating backup…'
});
let restoreCommitted = false;
+ let maintenanceStarted = false;
let preferenceSnapshot = null;
try {
const backup = await parseWorkspaceBackup(input);
- suspendWorkspacePersistence = true;
- clearTimeout(saveTabStateTimeout);
- clearTimeout(secretWorkspaceSaveTimeout);
- await Promise.all([
- workspacePersistenceChain.catch(function() {}),
- secretWorkspaceSaveChain.catch(function() {}),
- secretDirtyJournalChain.catch(function() {}),
- organizationPersistenceChain.catch(function() {})
- ]);
+ await beginWorkspaceMaintenance();
+ maintenanceStarted = true;
preferenceSnapshot = captureApplicationPreferences();
try {
await replaceApplicationPreferences(backup.preferences);
@@ -2143,7 +2137,7 @@ document.addEventListener("DOMContentLoaded", async function () {
});
window.setTimeout(function() { window.location.reload(); }, 1100);
} catch (error) {
- if (!restoreCommitted) suspendWorkspacePersistence = false;
+ if (!restoreCommitted && maintenanceStarted && !error.rollbackError) endWorkspaceMaintenance();
console.error('Workspace backup import failed:', error);
if (restoreCommitted) {
finishImportProgress(100, 100, {
@@ -2158,7 +2152,8 @@ document.addEventListener("DOMContentLoaded", async function () {
success: false,
title: 'Import failed',
countText: 'Workspace not restored',
- detail: error && error.message ? error.message : 'The backup could not be imported.'
+ detail: error.rollbackError ? 'Restore and rollback failed. Editing remains blocked; reload before continuing.' :
+ (error && error.message ? error.message : 'The backup could not be imported.')
});
}
}
@@ -4262,6 +4257,8 @@ document.addEventListener("DOMContentLoaded", async function () {
let pendingWorkspacePersistAll = false;
let pendingWorkspacePersistIds = new Set();
let suspendWorkspacePersistence = false;
+ let workspaceMaintenancePending = false;
+ let workspaceMaintenancePreviousInert = false;
let secretWorkspaceEnvelopeCache = null;
const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit");
const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay");
@@ -8346,6 +8343,12 @@ document.addEventListener("DOMContentLoaded", async function () {
function persistReviewThreads(message) {
syncLiveReviewThreadsFromTab();
+ const tab = getActiveReviewTab();
+ if (tab && tab.contentLoaded !== false) {
+ saveCurrentTabState();
+ const journal = writeDirtyDocumentJournal(tab, tab.content);
+ if (!journal) persistEditWithoutDurableJournal(tab, tab.content);
+ }
if (activeTabId) saveTabsToStorage(tabs, [activeTabId]);
decorateReviewTargets();
renderReviewPanel();
@@ -10169,7 +10172,7 @@ document.addEventListener("DOMContentLoaded", async function () {
const input = reviewList && reviewList.querySelector(
'.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input'
);
- if (input) {
+ if (input && document.activeElement !== input) {
input.focus({ preventScroll: true });
input.setSelectionRange(input.value.length, input.value.length);
}
@@ -10592,6 +10595,7 @@ document.addEventListener("DOMContentLoaded", async function () {
savedAt: Date.now(),
metadata: {
title: tab.title || 'Untitled',
+ reviewThreads: tab.reviewThreads || [],
workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID,
folderId: tab.folderId || null,
viewMode: tab.viewMode || 'split',
@@ -10622,6 +10626,7 @@ document.addEventListener("DOMContentLoaded", async function () {
savedAt: Date.now(),
metadata: {
title: tab.title || 'Untitled',
+ reviewThreads: tab.reviewThreads || [],
workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID,
folderId: tab.folderId || null,
viewMode: tab.viewMode || 'split',
@@ -10645,6 +10650,7 @@ document.addEventListener("DOMContentLoaded", async function () {
savedAt: Date.now(),
metadata: {
title: tab.title || 'Untitled',
+ reviewThreads: tab.reviewThreads || [],
workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID,
folderId: tab.folderId || null,
viewMode: tab.viewMode || 'split',
@@ -10826,8 +10832,63 @@ document.addEventListener("DOMContentLoaded", async function () {
return source;
}
- function clearDirtyDocumentJournal(tab) {
+ async function captureDirtyDocumentJournalSnapshot(storageTabs, changedIds) {
+ const snapshots = new Map();
+ changedIds.forEach(function(id) {
+ const tab = storageTabs.find(function(item) { return item.id === id; });
+ // Metadata-only writes do not commit a document body.
+ if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return;
+ const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id))));
+ const snapshot = { content: tab.content, reviewThreads: JSON.stringify(tab.reviewThreads || []), local: {}, session: {}, indexed: {} };
+ [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) {
+ keys.forEach(function(key) {
+ try {
+ const raw = pair[0].getItem(key);
+ const value = raw && JSON.parse(raw);
+ if (value && value.documentId === id && value.content === snapshot.content &&
+ (!value.metadata || !Array.isArray(value.metadata.reviewThreads) || JSON.stringify(value.metadata.reviewThreads) === snapshot.reviewThreads)) pair[1][key] = raw;
+ } catch (_) { /* Unknown recovery data must be retained. */ }
+ });
+ });
+ snapshots.set(id, snapshot);
+ });
+ if (workspaceStorage && typeof workspaceStorage.listDirtyJournals === 'function') {
+ try {
+ (await workspaceStorage.listDirtyJournals()).forEach(function(record) {
+ const snapshot = snapshots.get(record.documentId);
+ if (snapshot && record.journalId && record.content === snapshot.content &&
+ (!record.metadata || !Array.isArray(record.metadata.reviewThreads) || JSON.stringify(record.metadata.reviewThreads) === snapshot.reviewThreads)) {
+ snapshot.indexed[record.journalId] = record;
+ }
+ });
+ } catch (error) {
+ console.warn('Unable to snapshot recovery journals; retaining them:', error);
+ }
+ }
+ return snapshots;
+ }
+
+ function clearDirtyDocumentJournal(tab, committedSnapshot) {
if (!tab) return Promise.resolve();
+ if (committedSnapshot) {
+ // Compare the exact journal, not merely its key or timestamp. A new edit
+ // may reuse the same key while a storage operation is awaiting completion.
+ [[localStorage, committedSnapshot.local], [sessionStorage, committedSnapshot.session]].forEach(function(pair) {
+ Object.keys(pair[1]).forEach(function(key) {
+ try {
+ if (pair[0].getItem(key) === pair[1][key]) pair[0].removeItem(key);
+ } catch (_) {}
+ });
+ });
+ if (workspaceStorage && typeof workspaceStorage.deleteDirtyJournals === 'function') {
+ return workspaceStorage.deleteDirtyJournals(Object.keys(committedSnapshot.indexed), {
+ expectedJournals: committedSnapshot.indexed
+ }).catch(function(error) {
+ console.warn('Unable to clear a committed emergency journal:', error);
+ });
+ }
+ return Promise.resolve();
+ }
const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(tab.id))));
keys.forEach(function(key) {
try { localStorage.removeItem(key); } catch (_) {}
@@ -10899,6 +10960,7 @@ document.addEventListener("DOMContentLoaded", async function () {
existing.content = journal.content;
existing.contentLoaded = true;
existing.lastEditedAt = Number(journal.savedAt) || Date.now();
+ if (journal.metadata && Array.isArray(journal.metadata.reviewThreads)) existing.reviewThreads = normalizeReviewThreads(journal.metadata.reviewThreads);
existing._dirtyJournalKeys = Array.from(new Set((existing._dirtyJournalKeys || []).concat(entry.key)));
existing._dirtyIndexedDbJournalIds = Array.from(new Set((existing._dirtyIndexedDbJournalIds || []).concat(entry.key)));
restoredOriginalIds.add(existing.id);
@@ -10912,6 +10974,7 @@ document.addEventListener("DOMContentLoaded", async function () {
folderId: metadata.folderId || null
});
recovered.favorite = metadata.favorite === true;
+ recovered.reviewThreads = normalizeReviewThreads(metadata.reviewThreads);
recovered.createdAt = Number(metadata.createdAt) || recovered.createdAt;
recovered.lastOpenedAt = Number(metadata.lastOpenedAt) || recovered.lastOpenedAt;
recovered.lastEditedAt = Number(journal.savedAt) || Date.now();
@@ -11095,20 +11158,27 @@ document.addEventListener("DOMContentLoaded", async function () {
}
try {
const storageTabs = getTabsForStorage(tabsArr);
+ const commit = async function() {
+ // A queued save must not resurrect documents removed before it runs.
+ const currentStorageTabs = storageTabs.filter(function(tab) { return tabs.some(function(item) { return item.id === tab.id; }); });
+ const journalSnapshots = await captureDirtyDocumentJournalSnapshot(currentStorageTabs, changedIds);
+ if (workspaceStorage) {
+ await workspaceStorage.saveDocuments(currentStorageTabs, documentOrganization, { changedIds: changedIds });
+ } else {
+ saveStorageItem(STORAGE_KEY, JSON.stringify(currentStorageTabs));
+ }
+ await Promise.all(changedIds.map(function(id) {
+ const tab = tabs.find(function(item) { return item.id === id; });
+ const snapshot = journalSnapshots.get(id);
+ return tab && snapshot ? clearDirtyDocumentJournal(tab, snapshot) : Promise.resolve();
+ }));
+ };
if (workspaceStorage) {
- workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(function() {
- return workspaceStorage.saveDocuments(storageTabs, documentOrganization, {
- changedIds: changedIds
- });
- });
+ workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(commit);
await workspacePersistenceChain;
} else {
- saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs));
+ await commit();
}
- await Promise.all(changedIds.map(function(id) {
- const tab = tabs.find(function(item) { return item.id === id; });
- return tab ? clearDirtyDocumentJournal(tab) : Promise.resolve();
- }));
evictInactiveDocumentContent();
updateSaveStatus('saved');
return true;
@@ -11119,7 +11189,7 @@ document.addEventListener("DOMContentLoaded", async function () {
}
console.warn('Failed to save workspace documents:', e);
updateSaveStatus('error');
- showAppToast('The workspace could not be saved. Existing saved documents were kept.', {
+ showAppToast(e.code === 'WORKSPACE_MOVE_BLOCKED' ? e.message : 'The workspace could not be saved. Existing saved documents were kept.', {
tone: 'error',
title: 'Changes not saved'
});
@@ -11127,7 +11197,10 @@ document.addEventListener("DOMContentLoaded", async function () {
}
}
- async function flushWorkspaceBeforeExit() {
+ async function flushWorkspaceBeforeExit(options) {
+ if ((workspaceMaintenancePending || suspendWorkspacePersistence) && !(options && options.allowMaintenance === true)) {
+ throw new Error('Workspace maintenance is still in progress. Wait for it to complete before closing.');
+ }
saveCurrentTabState();
await secretDirtyJournalChain.catch(function() {});
const normalSaved = await _flushTabsToStorage(tabs);
@@ -11141,6 +11214,36 @@ document.addEventListener("DOMContentLoaded", async function () {
window.MarkdownViewerFlushWorkspace = flushWorkspaceBeforeExit;
+ async function beginWorkspaceMaintenance() {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) throw new Error('Workspace maintenance is already active. Reload or wait before retrying.');
+ if (nativeMarkdownSaveInProgress) throw new Error('A file Save is still in progress. Wait for it to finish, then retry.');
+ if (liveCollaboration) throw new Error('Leave the live collaboration session before replacing the workspace.');
+ workspaceMaintenancePending = true;
+ const container = document.querySelector('.app-container');
+ workspaceMaintenancePreviousInert = !!(container && container.inert);
+ if (container) container.inert = true;
+ clearTimeout(saveTabStateTimeout);
+ clearTimeout(secretWorkspaceSaveTimeout);
+ try {
+ await documentDeletionChain;
+ saveSecondarySplitState();
+ await flushWorkspaceBeforeExit({ allowMaintenance: true });
+ await secretWorkspaceSaveChain;
+ if (await organizationPersistenceChain === false) throw new Error('Workspace organization could not be saved. Maintenance was cancelled.');
+ suspendWorkspacePersistence = true;
+ } catch (error) {
+ endWorkspaceMaintenance();
+ throw error;
+ }
+ }
+
+ function endWorkspaceMaintenance() {
+ suspendWorkspacePersistence = false;
+ workspaceMaintenancePending = false;
+ const container = document.querySelector('.app-container');
+ if (container) container.inert = workspaceMaintenancePreviousInert;
+ }
+
// Browser lifecycle events cannot guarantee that asynchronous storage finishes,
// so ordinary edits are still debounced continuously. The desktop close handler
// explicitly awaits this hook before exiting.
@@ -12462,6 +12565,7 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function newTab(content, title, location) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return false;
closeSlashCommandMenu();
if (content === undefined) content = '';
const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null });
@@ -12481,7 +12585,17 @@ document.addEventListener("DOMContentLoaded", async function () {
return true;
}
- async function deleteTab(tabId) {
+ let documentDeletionChain = Promise.resolve();
+ function deleteTab(tabId) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return Promise.resolve(false);
+ const operation = documentDeletionChain.catch(function() {}).then(function() {
+ return deleteTabSnapshot(tabId);
+ });
+ documentDeletionChain = operation;
+ return operation;
+ }
+
+ async function deleteTabSnapshot(tabId) {
if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) {
cancelReviewDeleteConfirmation();
}
@@ -12494,11 +12608,11 @@ document.addEventListener("DOMContentLoaded", async function () {
return;
}
- const idx = tabs.findIndex(function(t) { return t.id === tabId; });
- if (idx === -1) return;
+ let idx = tabs.findIndex(function(t) { return t.id === tabId; });
+ if (idx === -1) return false;
const tab = tabs[idx];
- const wasActive = activeTabId === tabId;
- const splitPartnerId = wasActive ? secondarySplitTabId : null;
+ let wasActive = activeTabId === tabId;
+ let splitPartnerId = wasActive ? secondarySplitTabId : null;
if (wasActive) saveCurrentTabState();
saveSecondarySplitState();
if (!isTemporaryDocument(tab) && tab.workspaceId !== SECRET_WORKSPACE_ID && workspaceStorage && !isPrivateStorageMode()) {
@@ -12511,7 +12625,14 @@ document.addEventListener("DOMContentLoaded", async function () {
return false;
}
try {
- await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision });
+ // Use the same queue as document saves. Remove the live entry before
+ // subsequent queued saves select their current documents.
+ workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(async function() {
+ await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision });
+ const currentIndex = tabs.indexOf(tab);
+ if (currentIndex >= 0) { idx = currentIndex; tabs.splice(currentIndex, 1); }
+ });
+ await workspacePersistenceChain;
} catch (error) {
if (error && error.name === 'WorkspaceConflictError') {
showAppToast('The document changed in another tab and was not deleted. Reload before trying again.', {
@@ -12528,6 +12649,8 @@ document.addEventListener("DOMContentLoaded", async function () {
return false;
}
}
+ wasActive = activeTabId === tabId;
+ splitPartnerId = wasActive ? secondarySplitTabId : null;
if (wasActive) {
closeReviewComposer();
clearReviewDecorations();
@@ -12543,7 +12666,8 @@ document.addEventListener("DOMContentLoaded", async function () {
delete tabHistories[tabId];
}
- tabs.splice(idx, 1);
+ const remainingIndex = tabs.indexOf(tab);
+ if (remainingIndex >= 0) { idx = remainingIndex; tabs.splice(remainingIndex, 1); }
clearDirtyDocumentJournal(tab);
if (tab.workspaceId === SECRET_WORKSPACE_ID) saveTabsToStorage(tabs, []);
if (wasActive) {
@@ -12555,7 +12679,7 @@ document.addEventListener("DOMContentLoaded", async function () {
} else {
clearActiveDocument();
}
- return;
+ return true;
}
if (selectedDocumentId === tabId) selectedDocumentId = activeTabId;
renderDocumentSplitView();
@@ -12759,21 +12883,15 @@ document.addEventListener("DOMContentLoaded", async function () {
if (closeBtn) closeBtn.disabled = true;
confirmBtn.classList.add('is-loading');
confirmBtn.setAttribute('aria-busy', 'true');
- suspendWorkspacePersistence = true;
- clearTimeout(saveTabStateTimeout);
- clearTimeout(secretWorkspaceSaveTimeout);
showImportProgress(100, {
title: 'Resetting workspace',
detail: 'Preparing a fresh workspace…'
});
let resetStarted = false;
+ let maintenanceStarted = false;
try {
- await Promise.all([
- workspacePersistenceChain.catch(function() {}),
- secretWorkspaceSaveChain.catch(function() {}),
- secretDirtyJournalChain.catch(function() {}),
- organizationPersistenceChain.catch(function() {})
- ]);
+ await beginWorkspaceMaintenance();
+ maintenanceStarted = true;
updateImportProgress(25, 100, 'Clearing workspace settings…');
await replaceApplicationPreferences({});
await clearLegacyWorkspaceStateForReset();
@@ -12803,7 +12921,7 @@ document.addEventListener("DOMContentLoaded", async function () {
cancelBtn.disabled = resetStarted;
if (backupBtn) backupBtn.disabled = resetStarted;
if (closeBtn) closeBtn.disabled = resetStarted;
- if (!resetStarted) suspendWorkspacePersistence = false;
+ if (!resetStarted && maintenanceStarted) endWorkspaceMaintenance();
isResetting = false;
}
}
@@ -13129,6 +13247,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// Late-load callback hook for Neutralino command-line files
window.NL_IMPORT_EXTERNAL_FILE = function(content, name) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return false;
if (typeof tabs === 'undefined') return;
const existing = tabs.find(function(t) { return t.title === name && t.content === content; });
if (existing) {
@@ -15378,6 +15497,7 @@ ${selector} .arrowheadPath {
}
function createImportedDocument(content, title, location) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return null;
const targetLocation = location || (documentOrganization
? getPreferredDocumentLocation()
: { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null });
@@ -19187,10 +19307,9 @@ ${selector} .arrowheadPath {
columnInput.addEventListener('keydown', onKey);
rowInput.addEventListener('keydown', onKey);
- requestAnimationFrame(() => {
- columnInput.focus();
- columnInput.select();
- });
+ // Do not redirect input after the user has moved to the row field.
+ columnInput.focus();
+ columnInput.select();
}
function openEmojiModal() {
@@ -21105,14 +21224,13 @@ ${selector} .arrowheadPath {
uploadFields.style.display = isUpload ? 'flex' : 'none';
urlFields.style.display = isUpload ? 'none' : 'flex';
if (shouldFocus) {
- requestAnimationFrame(function() {
- if (isUpload) {
- fileInput.focus();
- } else {
- urlInput.focus();
- urlInput.select();
- }
- });
+ // Focus immediately so a deferred callback cannot steal subsequent input.
+ if (isUpload) {
+ fileInput.focus();
+ } else {
+ urlInput.focus();
+ urlInput.select();
+ }
}
}
@@ -21261,10 +21379,10 @@ ${selector} .arrowheadPath {
urlInput.addEventListener('keydown', onKey);
titleInput.addEventListener('keydown', onKey);
- requestAnimationFrame(function() {
- numberInput.focus();
- numberInput.select();
- });
+ // The modal is already displayed. Focus synchronously so a deferred callback
+ // cannot steal focus after the user starts filling the URL or title field.
+ numberInput.focus();
+ numberInput.select();
}
function escapeRegExp(text) {
@@ -24564,7 +24682,10 @@ ${selector} .arrowheadPath {
updateStlThemes();
});
+ let nativeMarkdownSaveInProgress = false;
async function nativeSaveMarkdown() {
+ if (nativeMarkdownSaveInProgress || workspaceMaintenancePending || suspendWorkspacePersistence) return;
+ nativeMarkdownSaveInProgress = true;
try {
const content = markdownEditor.value;
const result = await Neutralino.os.showSaveDialog("Save Markdown File", {
@@ -24587,6 +24708,8 @@ ${selector} .arrowheadPath {
} catch (e) {
console.error("Native save failed:", e);
alert("Native save failed: " + e.message);
+ } finally {
+ nativeMarkdownSaveInProgress = false;
}
}
diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js
index df3eb067..b8dc26b1 100644
--- a/desktop-app/resources/js/workspace-storage.js
+++ b/desktop-app/resources/js/workspace-storage.js
@@ -1229,6 +1229,37 @@
tab._storageContentJournalPaths = Array.from(new Set((tab._storageContentJournalPaths || []).concat(journalPath)));
}
+ async _desktopInspectMovePath(path) {
+ try { return await Neutralino.filesystem.getStats(path); }
+ catch (error) {
+ if (!error || error.code !== 'NE_FS_NOPATHE') throw error;
+ // Native stat can report missing for an inaccessible entry. Confirm
+ // absence using its parent; never turn permission errors into absence.
+ const normalized = normalizePathSeparators(path);
+ const separator = normalized.lastIndexOf('/');
+ if (separator < 0) throw error;
+ const entries = await Neutralino.filesystem.readDirectory(normalized.slice(0, separator));
+ const name = normalized.slice(separator + 1);
+ const insensitive = typeof NL_OS !== 'undefined' && NL_OS === 'Windows';
+ if (entries.some(function(entry) {
+ return insensitive ? String(entry.entry).toLowerCase() === name.toLowerCase() : entry.entry === name;
+ })) {
+ throw Object.assign(new Error('Move cancelled: a file exists but cannot be inspected at ' + path + '. No existing file was replaced.'), { code: 'WORKSPACE_MOVE_BLOCKED' });
+ }
+ return null;
+ }
+ }
+
+ async _desktopCheckMovePaths(source, destination) {
+ const stats = await this._desktopInspectMovePath(source);
+ if (!stats || stats.isDirectory || stats.isFile === false) {
+ throw Object.assign(new Error('Move cancelled: the source is missing or is not a file at ' + source + '. No replacement was created.'), { code: 'WORKSPACE_MOVE_BLOCKED' });
+ }
+ if (await this._desktopInspectMovePath(destination)) {
+ throw Object.assign(new Error('Move cancelled: the destination already exists at ' + destination + '. Both files were left unchanged.'), { code: 'WORKSPACE_MOVE_BLOCKED' });
+ }
+ }
+
async _desktopSaveDocument(tab, organization, forceContent) {
requireDocumentId(tab && tab.id);
const existing = this.vaultIndex.documents.find(function (item) { return item.id === tab.id; });
@@ -1254,6 +1285,7 @@
if (existing && existing.vaultRelativePath && existing.vaultRelativePath !== relativePath) {
const oldPath = await this._desktopResolveVaultRelativePath(existing.vaultRelativePath);
+ await this._desktopCheckMovePaths(oldPath, fullPath);
const journalDirectory = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'journal');
const moveJournalPath = await this._pathJoin(journalDirectory, sanitizePathSegment(tab.id, 'document') + '.move.json');
await this._writeJsonFile(moveJournalPath, {
@@ -1266,14 +1298,10 @@
startedAt: Date.now()
});
tab._storageMoveJournalPath = moveJournalPath;
- if (await this._pathExists(oldPath)) {
- if (await this._pathExists(fullPath)) {
- await Neutralino.filesystem.writeFile(fullPath, typeof tab.content === 'string' ? tab.content : await Neutralino.filesystem.readFile(oldPath));
- await Neutralino.filesystem.remove(oldPath);
- } else {
- await Neutralino.filesystem.move(oldPath, fullPath);
- }
- }
+ // Recheck after asynchronous journal writing. Do not overwrite a
+ // collision, delete the source or fall back to a destructive copy.
+ await this._desktopCheckMovePaths(oldPath, fullPath);
+ await Neutralino.filesystem.move(oldPath, fullPath);
}
if (forceContent || tab.contentLoaded !== false) {
const content = typeof tab.content === 'string' ? tab.content : '';
@@ -1861,14 +1889,28 @@
return records;
}
- async deleteDirtyJournals(journalIds) {
+ async deleteDirtyJournals(journalIds, options) {
if (this.desktop) return;
const ids = Array.isArray(journalIds) ? journalIds.filter(Boolean) : [];
if (!ids.length) return;
const transaction = this.db.transaction('journals', 'readwrite');
+ const completion = transactionToPromise(transaction);
const store = transaction.objectStore('journals');
- ids.forEach(function(id) { store.delete(id); });
- await transactionToPromise(transaction);
+ const expected = options && options.expectedJournals;
+ ids.forEach(function(id) {
+ if (!expected) {
+ store.delete(id);
+ return;
+ }
+ if (!Object.prototype.hasOwnProperty.call(expected, id)) return;
+ const request = store.get(id);
+ request.onsuccess = function() {
+ // The read and conditional delete share a readwrite transaction,
+ // so a concurrent replacement cannot be deleted between them.
+ if (JSON.stringify(request.result) === JSON.stringify(expected[id])) store.delete(id);
+ };
+ });
+ await completion;
}
async restoreTrashItem(trashId) {
diff --git a/index.html b/index.html
index c7085e90..6317e012 100644
--- a/index.html
+++ b/index.html
@@ -132,10 +132,10 @@
-
+
-
+
diff --git a/package-lock.json b/package-lock.json
index 48ec9f43..840cd7f3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,14 +11,14 @@
"@highlightjs/cdn-assets": "11.9.0",
"@playwright/test": "1.61.1",
"bootstrap": "5.3.2",
- "dompurify": "3.4.13",
+ "dompurify": "3.4.16",
"entities": "6.0.1",
"esbuild": "0.25.12",
"file-saver": "2.0.5",
"github-markdown-css": "5.3.0",
"github-slugger": "2.0.0",
"highlight.js": "11.9.0",
- "js-yaml": "4.3.1",
+ "js-yaml": "4.3.2",
"jszip": "3.10.1",
"marked": "9.1.6",
"pako": "2.1.0"
@@ -550,11 +550,10 @@
"license": "MIT"
},
"node_modules/dompurify": {
- "version": "3.4.13",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
- "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
+ "version": "3.4.16",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
+ "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"dev": true,
- "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -688,9 +687,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -702,7 +701,6 @@
"url": "https://github.com/sponsors/nodeca"
}
],
- "license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
diff --git a/package.json b/package.json
index 1872f23c..da437478 100644
--- a/package.json
+++ b/package.json
@@ -29,14 +29,14 @@
"@highlightjs/cdn-assets": "11.9.0",
"@playwright/test": "1.61.1",
"bootstrap": "5.3.2",
- "dompurify": "3.4.13",
+ "dompurify": "3.4.16",
"entities": "6.0.1",
"esbuild": "0.25.12",
"file-saver": "2.0.5",
"github-markdown-css": "5.3.0",
"github-slugger": "2.0.0",
"highlight.js": "11.9.0",
- "js-yaml": "4.3.1",
+ "js-yaml": "4.3.2",
"jszip": "3.10.1",
"marked": "9.1.6",
"pako": "2.1.0"
diff --git a/script.js b/script.js
index dd54b8e8..af11b098 100644
--- a/script.js
+++ b/script.js
@@ -2098,18 +2098,12 @@ document.addEventListener("DOMContentLoaded", async function () {
detail: 'Validating backup…'
});
let restoreCommitted = false;
+ let maintenanceStarted = false;
let preferenceSnapshot = null;
try {
const backup = await parseWorkspaceBackup(input);
- suspendWorkspacePersistence = true;
- clearTimeout(saveTabStateTimeout);
- clearTimeout(secretWorkspaceSaveTimeout);
- await Promise.all([
- workspacePersistenceChain.catch(function() {}),
- secretWorkspaceSaveChain.catch(function() {}),
- secretDirtyJournalChain.catch(function() {}),
- organizationPersistenceChain.catch(function() {})
- ]);
+ await beginWorkspaceMaintenance();
+ maintenanceStarted = true;
preferenceSnapshot = captureApplicationPreferences();
try {
await replaceApplicationPreferences(backup.preferences);
@@ -2143,7 +2137,7 @@ document.addEventListener("DOMContentLoaded", async function () {
});
window.setTimeout(function() { window.location.reload(); }, 1100);
} catch (error) {
- if (!restoreCommitted) suspendWorkspacePersistence = false;
+ if (!restoreCommitted && maintenanceStarted && !error.rollbackError) endWorkspaceMaintenance();
console.error('Workspace backup import failed:', error);
if (restoreCommitted) {
finishImportProgress(100, 100, {
@@ -2158,7 +2152,8 @@ document.addEventListener("DOMContentLoaded", async function () {
success: false,
title: 'Import failed',
countText: 'Workspace not restored',
- detail: error && error.message ? error.message : 'The backup could not be imported.'
+ detail: error.rollbackError ? 'Restore and rollback failed. Editing remains blocked; reload before continuing.' :
+ (error && error.message ? error.message : 'The backup could not be imported.')
});
}
}
@@ -4262,6 +4257,8 @@ document.addEventListener("DOMContentLoaded", async function () {
let pendingWorkspacePersistAll = false;
let pendingWorkspacePersistIds = new Set();
let suspendWorkspacePersistence = false;
+ let workspaceMaintenancePending = false;
+ let workspaceMaintenancePreviousInert = false;
let secretWorkspaceEnvelopeCache = null;
const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit");
const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay");
@@ -8346,6 +8343,12 @@ document.addEventListener("DOMContentLoaded", async function () {
function persistReviewThreads(message) {
syncLiveReviewThreadsFromTab();
+ const tab = getActiveReviewTab();
+ if (tab && tab.contentLoaded !== false) {
+ saveCurrentTabState();
+ const journal = writeDirtyDocumentJournal(tab, tab.content);
+ if (!journal) persistEditWithoutDurableJournal(tab, tab.content);
+ }
if (activeTabId) saveTabsToStorage(tabs, [activeTabId]);
decorateReviewTargets();
renderReviewPanel();
@@ -10169,7 +10172,7 @@ document.addEventListener("DOMContentLoaded", async function () {
const input = reviewList && reviewList.querySelector(
'.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input'
);
- if (input) {
+ if (input && document.activeElement !== input) {
input.focus({ preventScroll: true });
input.setSelectionRange(input.value.length, input.value.length);
}
@@ -10592,6 +10595,7 @@ document.addEventListener("DOMContentLoaded", async function () {
savedAt: Date.now(),
metadata: {
title: tab.title || 'Untitled',
+ reviewThreads: tab.reviewThreads || [],
workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID,
folderId: tab.folderId || null,
viewMode: tab.viewMode || 'split',
@@ -10622,6 +10626,7 @@ document.addEventListener("DOMContentLoaded", async function () {
savedAt: Date.now(),
metadata: {
title: tab.title || 'Untitled',
+ reviewThreads: tab.reviewThreads || [],
workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID,
folderId: tab.folderId || null,
viewMode: tab.viewMode || 'split',
@@ -10645,6 +10650,7 @@ document.addEventListener("DOMContentLoaded", async function () {
savedAt: Date.now(),
metadata: {
title: tab.title || 'Untitled',
+ reviewThreads: tab.reviewThreads || [],
workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID,
folderId: tab.folderId || null,
viewMode: tab.viewMode || 'split',
@@ -10826,8 +10832,63 @@ document.addEventListener("DOMContentLoaded", async function () {
return source;
}
- function clearDirtyDocumentJournal(tab) {
+ async function captureDirtyDocumentJournalSnapshot(storageTabs, changedIds) {
+ const snapshots = new Map();
+ changedIds.forEach(function(id) {
+ const tab = storageTabs.find(function(item) { return item.id === id; });
+ // Metadata-only writes do not commit a document body.
+ if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return;
+ const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id))));
+ const snapshot = { content: tab.content, reviewThreads: JSON.stringify(tab.reviewThreads || []), local: {}, session: {}, indexed: {} };
+ [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) {
+ keys.forEach(function(key) {
+ try {
+ const raw = pair[0].getItem(key);
+ const value = raw && JSON.parse(raw);
+ if (value && value.documentId === id && value.content === snapshot.content &&
+ (!value.metadata || !Array.isArray(value.metadata.reviewThreads) || JSON.stringify(value.metadata.reviewThreads) === snapshot.reviewThreads)) pair[1][key] = raw;
+ } catch (_) { /* Unknown recovery data must be retained. */ }
+ });
+ });
+ snapshots.set(id, snapshot);
+ });
+ if (workspaceStorage && typeof workspaceStorage.listDirtyJournals === 'function') {
+ try {
+ (await workspaceStorage.listDirtyJournals()).forEach(function(record) {
+ const snapshot = snapshots.get(record.documentId);
+ if (snapshot && record.journalId && record.content === snapshot.content &&
+ (!record.metadata || !Array.isArray(record.metadata.reviewThreads) || JSON.stringify(record.metadata.reviewThreads) === snapshot.reviewThreads)) {
+ snapshot.indexed[record.journalId] = record;
+ }
+ });
+ } catch (error) {
+ console.warn('Unable to snapshot recovery journals; retaining them:', error);
+ }
+ }
+ return snapshots;
+ }
+
+ function clearDirtyDocumentJournal(tab, committedSnapshot) {
if (!tab) return Promise.resolve();
+ if (committedSnapshot) {
+ // Compare the exact journal, not merely its key or timestamp. A new edit
+ // may reuse the same key while a storage operation is awaiting completion.
+ [[localStorage, committedSnapshot.local], [sessionStorage, committedSnapshot.session]].forEach(function(pair) {
+ Object.keys(pair[1]).forEach(function(key) {
+ try {
+ if (pair[0].getItem(key) === pair[1][key]) pair[0].removeItem(key);
+ } catch (_) {}
+ });
+ });
+ if (workspaceStorage && typeof workspaceStorage.deleteDirtyJournals === 'function') {
+ return workspaceStorage.deleteDirtyJournals(Object.keys(committedSnapshot.indexed), {
+ expectedJournals: committedSnapshot.indexed
+ }).catch(function(error) {
+ console.warn('Unable to clear a committed emergency journal:', error);
+ });
+ }
+ return Promise.resolve();
+ }
const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(tab.id))));
keys.forEach(function(key) {
try { localStorage.removeItem(key); } catch (_) {}
@@ -10899,6 +10960,7 @@ document.addEventListener("DOMContentLoaded", async function () {
existing.content = journal.content;
existing.contentLoaded = true;
existing.lastEditedAt = Number(journal.savedAt) || Date.now();
+ if (journal.metadata && Array.isArray(journal.metadata.reviewThreads)) existing.reviewThreads = normalizeReviewThreads(journal.metadata.reviewThreads);
existing._dirtyJournalKeys = Array.from(new Set((existing._dirtyJournalKeys || []).concat(entry.key)));
existing._dirtyIndexedDbJournalIds = Array.from(new Set((existing._dirtyIndexedDbJournalIds || []).concat(entry.key)));
restoredOriginalIds.add(existing.id);
@@ -10912,6 +10974,7 @@ document.addEventListener("DOMContentLoaded", async function () {
folderId: metadata.folderId || null
});
recovered.favorite = metadata.favorite === true;
+ recovered.reviewThreads = normalizeReviewThreads(metadata.reviewThreads);
recovered.createdAt = Number(metadata.createdAt) || recovered.createdAt;
recovered.lastOpenedAt = Number(metadata.lastOpenedAt) || recovered.lastOpenedAt;
recovered.lastEditedAt = Number(journal.savedAt) || Date.now();
@@ -11095,20 +11158,27 @@ document.addEventListener("DOMContentLoaded", async function () {
}
try {
const storageTabs = getTabsForStorage(tabsArr);
+ const commit = async function() {
+ // A queued save must not resurrect documents removed before it runs.
+ const currentStorageTabs = storageTabs.filter(function(tab) { return tabs.some(function(item) { return item.id === tab.id; }); });
+ const journalSnapshots = await captureDirtyDocumentJournalSnapshot(currentStorageTabs, changedIds);
+ if (workspaceStorage) {
+ await workspaceStorage.saveDocuments(currentStorageTabs, documentOrganization, { changedIds: changedIds });
+ } else {
+ saveStorageItem(STORAGE_KEY, JSON.stringify(currentStorageTabs));
+ }
+ await Promise.all(changedIds.map(function(id) {
+ const tab = tabs.find(function(item) { return item.id === id; });
+ const snapshot = journalSnapshots.get(id);
+ return tab && snapshot ? clearDirtyDocumentJournal(tab, snapshot) : Promise.resolve();
+ }));
+ };
if (workspaceStorage) {
- workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(function() {
- return workspaceStorage.saveDocuments(storageTabs, documentOrganization, {
- changedIds: changedIds
- });
- });
+ workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(commit);
await workspacePersistenceChain;
} else {
- saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs));
+ await commit();
}
- await Promise.all(changedIds.map(function(id) {
- const tab = tabs.find(function(item) { return item.id === id; });
- return tab ? clearDirtyDocumentJournal(tab) : Promise.resolve();
- }));
evictInactiveDocumentContent();
updateSaveStatus('saved');
return true;
@@ -11119,7 +11189,7 @@ document.addEventListener("DOMContentLoaded", async function () {
}
console.warn('Failed to save workspace documents:', e);
updateSaveStatus('error');
- showAppToast('The workspace could not be saved. Existing saved documents were kept.', {
+ showAppToast(e.code === 'WORKSPACE_MOVE_BLOCKED' ? e.message : 'The workspace could not be saved. Existing saved documents were kept.', {
tone: 'error',
title: 'Changes not saved'
});
@@ -11127,7 +11197,10 @@ document.addEventListener("DOMContentLoaded", async function () {
}
}
- async function flushWorkspaceBeforeExit() {
+ async function flushWorkspaceBeforeExit(options) {
+ if ((workspaceMaintenancePending || suspendWorkspacePersistence) && !(options && options.allowMaintenance === true)) {
+ throw new Error('Workspace maintenance is still in progress. Wait for it to complete before closing.');
+ }
saveCurrentTabState();
await secretDirtyJournalChain.catch(function() {});
const normalSaved = await _flushTabsToStorage(tabs);
@@ -11141,6 +11214,36 @@ document.addEventListener("DOMContentLoaded", async function () {
window.MarkdownViewerFlushWorkspace = flushWorkspaceBeforeExit;
+ async function beginWorkspaceMaintenance() {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) throw new Error('Workspace maintenance is already active. Reload or wait before retrying.');
+ if (nativeMarkdownSaveInProgress) throw new Error('A file Save is still in progress. Wait for it to finish, then retry.');
+ if (liveCollaboration) throw new Error('Leave the live collaboration session before replacing the workspace.');
+ workspaceMaintenancePending = true;
+ const container = document.querySelector('.app-container');
+ workspaceMaintenancePreviousInert = !!(container && container.inert);
+ if (container) container.inert = true;
+ clearTimeout(saveTabStateTimeout);
+ clearTimeout(secretWorkspaceSaveTimeout);
+ try {
+ await documentDeletionChain;
+ saveSecondarySplitState();
+ await flushWorkspaceBeforeExit({ allowMaintenance: true });
+ await secretWorkspaceSaveChain;
+ if (await organizationPersistenceChain === false) throw new Error('Workspace organization could not be saved. Maintenance was cancelled.');
+ suspendWorkspacePersistence = true;
+ } catch (error) {
+ endWorkspaceMaintenance();
+ throw error;
+ }
+ }
+
+ function endWorkspaceMaintenance() {
+ suspendWorkspacePersistence = false;
+ workspaceMaintenancePending = false;
+ const container = document.querySelector('.app-container');
+ if (container) container.inert = workspaceMaintenancePreviousInert;
+ }
+
// Browser lifecycle events cannot guarantee that asynchronous storage finishes,
// so ordinary edits are still debounced continuously. The desktop close handler
// explicitly awaits this hook before exiting.
@@ -12462,6 +12565,7 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function newTab(content, title, location) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return false;
closeSlashCommandMenu();
if (content === undefined) content = '';
const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null });
@@ -12481,7 +12585,17 @@ document.addEventListener("DOMContentLoaded", async function () {
return true;
}
- async function deleteTab(tabId) {
+ let documentDeletionChain = Promise.resolve();
+ function deleteTab(tabId) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return Promise.resolve(false);
+ const operation = documentDeletionChain.catch(function() {}).then(function() {
+ return deleteTabSnapshot(tabId);
+ });
+ documentDeletionChain = operation;
+ return operation;
+ }
+
+ async function deleteTabSnapshot(tabId) {
if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) {
cancelReviewDeleteConfirmation();
}
@@ -12494,11 +12608,11 @@ document.addEventListener("DOMContentLoaded", async function () {
return;
}
- const idx = tabs.findIndex(function(t) { return t.id === tabId; });
- if (idx === -1) return;
+ let idx = tabs.findIndex(function(t) { return t.id === tabId; });
+ if (idx === -1) return false;
const tab = tabs[idx];
- const wasActive = activeTabId === tabId;
- const splitPartnerId = wasActive ? secondarySplitTabId : null;
+ let wasActive = activeTabId === tabId;
+ let splitPartnerId = wasActive ? secondarySplitTabId : null;
if (wasActive) saveCurrentTabState();
saveSecondarySplitState();
if (!isTemporaryDocument(tab) && tab.workspaceId !== SECRET_WORKSPACE_ID && workspaceStorage && !isPrivateStorageMode()) {
@@ -12511,7 +12625,14 @@ document.addEventListener("DOMContentLoaded", async function () {
return false;
}
try {
- await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision });
+ // Use the same queue as document saves. Remove the live entry before
+ // subsequent queued saves select their current documents.
+ workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(async function() {
+ await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision });
+ const currentIndex = tabs.indexOf(tab);
+ if (currentIndex >= 0) { idx = currentIndex; tabs.splice(currentIndex, 1); }
+ });
+ await workspacePersistenceChain;
} catch (error) {
if (error && error.name === 'WorkspaceConflictError') {
showAppToast('The document changed in another tab and was not deleted. Reload before trying again.', {
@@ -12528,6 +12649,8 @@ document.addEventListener("DOMContentLoaded", async function () {
return false;
}
}
+ wasActive = activeTabId === tabId;
+ splitPartnerId = wasActive ? secondarySplitTabId : null;
if (wasActive) {
closeReviewComposer();
clearReviewDecorations();
@@ -12543,7 +12666,8 @@ document.addEventListener("DOMContentLoaded", async function () {
delete tabHistories[tabId];
}
- tabs.splice(idx, 1);
+ const remainingIndex = tabs.indexOf(tab);
+ if (remainingIndex >= 0) { idx = remainingIndex; tabs.splice(remainingIndex, 1); }
clearDirtyDocumentJournal(tab);
if (tab.workspaceId === SECRET_WORKSPACE_ID) saveTabsToStorage(tabs, []);
if (wasActive) {
@@ -12555,7 +12679,7 @@ document.addEventListener("DOMContentLoaded", async function () {
} else {
clearActiveDocument();
}
- return;
+ return true;
}
if (selectedDocumentId === tabId) selectedDocumentId = activeTabId;
renderDocumentSplitView();
@@ -12759,21 +12883,15 @@ document.addEventListener("DOMContentLoaded", async function () {
if (closeBtn) closeBtn.disabled = true;
confirmBtn.classList.add('is-loading');
confirmBtn.setAttribute('aria-busy', 'true');
- suspendWorkspacePersistence = true;
- clearTimeout(saveTabStateTimeout);
- clearTimeout(secretWorkspaceSaveTimeout);
showImportProgress(100, {
title: 'Resetting workspace',
detail: 'Preparing a fresh workspace…'
});
let resetStarted = false;
+ let maintenanceStarted = false;
try {
- await Promise.all([
- workspacePersistenceChain.catch(function() {}),
- secretWorkspaceSaveChain.catch(function() {}),
- secretDirtyJournalChain.catch(function() {}),
- organizationPersistenceChain.catch(function() {})
- ]);
+ await beginWorkspaceMaintenance();
+ maintenanceStarted = true;
updateImportProgress(25, 100, 'Clearing workspace settings…');
await replaceApplicationPreferences({});
await clearLegacyWorkspaceStateForReset();
@@ -12803,7 +12921,7 @@ document.addEventListener("DOMContentLoaded", async function () {
cancelBtn.disabled = resetStarted;
if (backupBtn) backupBtn.disabled = resetStarted;
if (closeBtn) closeBtn.disabled = resetStarted;
- if (!resetStarted) suspendWorkspacePersistence = false;
+ if (!resetStarted && maintenanceStarted) endWorkspaceMaintenance();
isResetting = false;
}
}
@@ -13129,6 +13247,7 @@ document.addEventListener("DOMContentLoaded", async function () {
// Late-load callback hook for Neutralino command-line files
window.NL_IMPORT_EXTERNAL_FILE = function(content, name) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return false;
if (typeof tabs === 'undefined') return;
const existing = tabs.find(function(t) { return t.title === name && t.content === content; });
if (existing) {
@@ -15378,6 +15497,7 @@ ${selector} .arrowheadPath {
}
function createImportedDocument(content, title, location) {
+ if (workspaceMaintenancePending || suspendWorkspacePersistence) return null;
const targetLocation = location || (documentOrganization
? getPreferredDocumentLocation()
: { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null });
@@ -19187,10 +19307,9 @@ ${selector} .arrowheadPath {
columnInput.addEventListener('keydown', onKey);
rowInput.addEventListener('keydown', onKey);
- requestAnimationFrame(() => {
- columnInput.focus();
- columnInput.select();
- });
+ // Do not redirect input after the user has moved to the row field.
+ columnInput.focus();
+ columnInput.select();
}
function openEmojiModal() {
@@ -21105,14 +21224,13 @@ ${selector} .arrowheadPath {
uploadFields.style.display = isUpload ? 'flex' : 'none';
urlFields.style.display = isUpload ? 'none' : 'flex';
if (shouldFocus) {
- requestAnimationFrame(function() {
- if (isUpload) {
- fileInput.focus();
- } else {
- urlInput.focus();
- urlInput.select();
- }
- });
+ // Focus immediately so a deferred callback cannot steal subsequent input.
+ if (isUpload) {
+ fileInput.focus();
+ } else {
+ urlInput.focus();
+ urlInput.select();
+ }
}
}
@@ -21261,10 +21379,10 @@ ${selector} .arrowheadPath {
urlInput.addEventListener('keydown', onKey);
titleInput.addEventListener('keydown', onKey);
- requestAnimationFrame(function() {
- numberInput.focus();
- numberInput.select();
- });
+ // The modal is already displayed. Focus synchronously so a deferred callback
+ // cannot steal focus after the user starts filling the URL or title field.
+ numberInput.focus();
+ numberInput.select();
}
function escapeRegExp(text) {
@@ -24564,7 +24682,10 @@ ${selector} .arrowheadPath {
updateStlThemes();
});
+ let nativeMarkdownSaveInProgress = false;
async function nativeSaveMarkdown() {
+ if (nativeMarkdownSaveInProgress || workspaceMaintenancePending || suspendWorkspacePersistence) return;
+ nativeMarkdownSaveInProgress = true;
try {
const content = markdownEditor.value;
const result = await Neutralino.os.showSaveDialog("Save Markdown File", {
@@ -24587,6 +24708,8 @@ ${selector} .arrowheadPath {
} catch (e) {
console.error("Native save failed:", e);
alert("Native save failed: " + e.message);
+ } finally {
+ nativeMarkdownSaveInProgress = false;
}
}
diff --git a/tests/e2e/data-loss-hardening.spec.js b/tests/e2e/data-loss-hardening.spec.js
index 3c5d6c05..d9331dd4 100644
--- a/tests/e2e/data-loss-hardening.spec.js
+++ b/tests/e2e/data-loss-hardening.spec.js
@@ -401,7 +401,7 @@ test('desktop journals recover a new file and a move before their index commits'
path = normalize(path);
if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() };
if (files.has(path)) return { isDirectory: false, size: files.get(path).length, modifiedAt: Date.now() };
- throw new Error('missing');
+ throw Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' });
},
createDirectory: async path => directories.add(normalize(path)),
readFile: async path => {
diff --git a/tests/e2e/data-loss-regressions.spec.js b/tests/e2e/data-loss-regressions.spec.js
index b0355d26..033ba301 100644
--- a/tests/e2e/data-loss-regressions.spec.js
+++ b/tests/e2e/data-loss-regressions.spec.js
@@ -64,6 +64,9 @@ async function replacementBackupBuffer() {
test('a stale tab move cannot delete documents created in another tab', async ({ context, page }) => {
await openApp(page);
+ // Seed a persisted document before the second writer takes its snapshot.
+ await setEditorContent(page, '# Shared document before opening the stale writer');
+ await waitForStoredContent(page, 'Shared document before opening the stale writer');
const staleDocumentId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id');
const secondPage = await context.newPage();
await openApp(secondPage);
diff --git a/tests/e2e/desktop-move-safety.spec.js b/tests/e2e/desktop-move-safety.spec.js
new file mode 100644
index 00000000..1b88f3bd
--- /dev/null
+++ b/tests/e2e/desktop-move-safety.spec.js
@@ -0,0 +1,28 @@
+const { test, expect } = require('@playwright/test');
+const { openApp } = require('../helpers/app');
+const { installDesktopFilesystem } = require('../helpers/desktop-filesystem');
+
+test('stock desktop rename collision preserves both bodies and the committed index', async ({ page }) => {
+ await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);');
+ await openApp(page);
+ const result = await page.evaluate(async () => {
+ const storage = new MarkdownWorkspaceStorage();
+ await storage.init();
+ const tab = { id: 'rename-test', title: 'Before', content: '# original', contentLoaded: true, workspaceId: 'workspace_default' };
+ await storage.saveDocuments([tab], { folders: [] }, { forceContent: true });
+ const original = storage.vaultPath + '/' + tab._vaultRelativePath;
+ const indexPath = storage.vaultPath + '/.markdown-viewer/index.json';
+ const index = __desktopFiles.get(indexPath);
+ tab.title = 'After';
+ const destination = storage.vaultPath + '/' + await storage._desktopDocumentRelativePath(tab, { folders: [] });
+ __desktopFiles.set(destination, '# unrelated body');
+ let error;
+ try { await storage.saveDocuments([tab], { folders: [] }, { changedIds: [tab.id] }); }
+ catch (failure) { error = failure.message; }
+ return { error, original: __desktopFiles.get(original), destination: __desktopFiles.get(destination), indexUnchanged: __desktopFiles.get(indexPath) === index };
+ });
+ expect(result.error).toMatch(/destination.*exists/i);
+ expect(result.original).toBe('# original');
+ expect(result.destination).toBe('# unrelated body');
+ expect(result.indexUnchanged).toBe(true);
+});
diff --git a/tests/e2e/recovery-journal-commit.spec.js b/tests/e2e/recovery-journal-commit.spec.js
new file mode 100644
index 00000000..bf1954e1
--- /dev/null
+++ b/tests/e2e/recovery-journal-commit.spec.js
@@ -0,0 +1,50 @@
+const { test, expect } = require('@playwright/test');
+const { openApp, setEditorContent } = require('../helpers/app');
+
+test('an edit arriving as an older save finishes keeps its browser recovery journal', async ({ page }) => {
+ await openApp(page);
+ await page.evaluate(() => {
+ const prototype = window.MarkdownWorkspaceStorage.prototype;
+ const save = prototype.saveDocuments;
+ prototype.saveDocuments = async function(documents, ...args) {
+ const shouldInject = !window.__newJournalInjected && documents.some(tab => tab.content === '# Older snapshot');
+ const result = await save.call(this, documents, ...args);
+ if (shouldInject) {
+ const editor = document.querySelector('#markdown-editor');
+ editor.value = '# Newer recovery draft';
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
+ window.__newJournalInjected = true;
+ // Observe after the old flush cleanup, before the next debounced save.
+ setTimeout(() => {
+ window.__journalAfterOldSave = Object.keys(localStorage)
+ .filter(key => key.startsWith('markdownViewerDirtyDocument:'))
+ .map(key => { try { return JSON.parse(localStorage.getItem(key)); } catch (_) { return null; } })
+ .filter(Boolean);
+ }, 100);
+ }
+ return result;
+ };
+ });
+ await setEditorContent(page, '# Older snapshot');
+ await page.waitForFunction(() => window.__journalAfterOldSave);
+ const records = await page.evaluate(() => window.__journalAfterOldSave);
+ expect(records.some(record => record.content === '# Newer recovery draft')).toBe(true);
+ await expect(page.locator('#markdown-editor')).toHaveValue('# Newer recovery draft');
+});
+
+test('IndexedDB compare-and-delete retains a replacement record and deletes a matching record', async ({ page }) => {
+ await openApp(page);
+ const result = await page.evaluate(async () => {
+ const storage = new window.MarkdownWorkspaceStorage();
+ await storage.init();
+ const old = { journalId: 'conditional-journal-test', documentId: 'test-document', content: 'OLD', savedAt: 1 };
+ const newer = { ...old, content: 'NEW', savedAt: 2 };
+ await storage.saveDirtyJournal(old);
+ await storage.saveDirtyJournal(newer);
+ await storage.deleteDirtyJournals([old.journalId], { expectedJournals: { [old.journalId]: old } });
+ const retained = (await storage.listDirtyJournals()).find(record => record.journalId === old.journalId);
+ await storage.deleteDirtyJournals([old.journalId], { expectedJournals: { [old.journalId]: newer } });
+ return { retained: retained?.content, remaining: (await storage.listDirtyJournals()).some(record => record.journalId === old.journalId) };
+ });
+ expect(result).toEqual({ retained: 'NEW', remaining: false });
+});
diff --git a/tests/e2e/responsive-storage.spec.js b/tests/e2e/responsive-storage.spec.js
index 3c2cd1df..5b5dd5bb 100644
--- a/tests/e2e/responsive-storage.spec.js
+++ b/tests/e2e/responsive-storage.spec.js
@@ -40,8 +40,8 @@ test('theme switching stores and restores the selected theme', async ({ page })
await expect(themeSwitch).toHaveCSS('width', await privateModeSwitch.evaluate(element => getComputedStyle(element).width));
await expect(themeSwitch).toHaveCSS('height', await privateModeSwitch.evaluate(element => getComputedStyle(element).height));
await expect(themeSwitch.locator(':scope > span')).toHaveCSS('border-radius', '50%');
- await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor));
- await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color));
+ await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await resolveCssColor(page, '--bg-color'));
+ await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await resolveCssColor(page, '--text-color'));
await themeToggle.click();
const toggledTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
@@ -49,8 +49,14 @@ test('theme switching stores and restores the selected theme', async ({ page })
expect(toggledTheme).not.toBe(initialTheme);
await expect(themeToggle).toHaveAttribute('aria-pressed', String(toggledTheme === 'dark'));
await expect(page.locator('#theme-switch-icon')).toHaveClass(new RegExp(`\\b${toggledThemeIcon}\\b`));
- await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor));
- await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color));
+ // Assert the target theme color, not a one-time snapshot halfway through
+ // the body's transition. Playwright still waits for the icon to reach it.
+ const backgroundColor = await resolveCssColor(page, '--bg-color');
+ const textColor = await resolveCssColor(page, '--text-color');
+ await expect(page.locator('body')).toHaveCSS('background-color', backgroundColor);
+ await expect(page.locator('body')).toHaveCSS('color', textColor);
+ await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', backgroundColor);
+ await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', textColor);
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('markdownViewerGlobalState') || '{}').theme)).toBe(toggledTheme);
await page.reload();
diff --git a/tests/e2e/runtime-dependencies.spec.js b/tests/e2e/runtime-dependencies.spec.js
new file mode 100644
index 00000000..73964923
--- /dev/null
+++ b/tests/e2e/runtime-dependencies.spec.js
@@ -0,0 +1,14 @@
+const { test, expect } = require('@playwright/test');
+const { openApp, setEditorContent } = require('../helpers/app');
+
+test('updated YAML and sanitizer preserve frontmatter and reject active HTML', async ({ page }) => {
+ const errors = await openApp(page);
+ await setEditorContent(page, '---\ntitle: Dependency smoke test\ntags:\n - markdown\n - safety\n---\n\n# Safe preview\n\n**Formatted text**\n\n

\n\n
Unsafe link');
+ const preview = page.locator('#markdown-preview');
+ await expect(preview.locator('.frontmatter-table')).toContainText('Dependency smoke test');
+ await expect(preview.locator('h1')).toHaveText('Safe preview');
+ await expect(preview.locator('strong')).toContainText('Formatted text');
+ await expect(preview.locator('[onerror], [href^="javascript:"]')).toHaveCount(0);
+ expect(await page.evaluate(() => window.__unsafePreview)).toBeUndefined();
+ expect(errors).toEqual([]);
+});
diff --git a/tests/e2e/slash-command-actions.spec.js b/tests/e2e/slash-command-actions.spec.js
index 26f37ef2..ced20df8 100644
--- a/tests/e2e/slash-command-actions.spec.js
+++ b/tests/e2e/slash-command-actions.spec.js
@@ -118,6 +118,48 @@ for (const pane of ['primary', 'secondary']) {
await expect(editor).toHaveValue(prefix + expected + suffix);
}
});
+ test('table chooser does not steal focus from an edited row field', async ({ page }) => {
+ await page.evaluate(() => {
+ const nativeFrame = window.requestAnimationFrame;
+ window.__tableFocusFrames = [];
+ window.requestAnimationFrame = function(callback) {
+ if (String(callback).includes('columnInput.focus()')) {
+ window.__tableFocusFrames.push(callback);
+ return 0;
+ }
+ return nativeFrame.call(window, callback);
+ };
+ });
+ await enterCommand(page, editor, 'table', 'table');
+ await page.locator('#table-modal-columns').fill('2');
+ await page.locator('#table-modal-rows').fill('2');
+ await page.evaluate(() => window.__tableFocusFrames.splice(0).forEach(callback => callback()));
+ await expect(page.locator('#table-modal-rows')).toBeFocused();
+ await page.locator('#table-modal-insert').click();
+ await expect(editor).toHaveValue(prefix + '| Column 1 | Column 2 |\n| --- | --- |\n| Value | Value |\n' + suffix);
+ });
+
+ test('media chooser does not steal focus from an edited field', async ({ page }) => {
+ await page.evaluate(() => {
+ const nativeFrame = window.requestAnimationFrame;
+ window.__mediaFocusFrames = [];
+ window.requestAnimationFrame = function(callback) {
+ if (String(callback).includes('fileInput.focus()')) {
+ window.__mediaFocusFrames.push(callback);
+ return 0;
+ }
+ return nativeFrame.call(window, callback);
+ };
+ });
+ await enterCommand(page, editor, 'image', 'image');
+ await page.locator('#image-modal-url').fill('https://example.com/focus.mp4');
+ await page.locator('#image-modal-alt').fill('Kept title');
+ await page.evaluate(() => window.__mediaFocusFrames.splice(0).forEach(callback => callback()));
+ await expect(page.locator('#image-modal-alt')).toBeFocused();
+ await page.locator('#image-modal-insert').click();
+ await expect(editor).toHaveValue(prefix + '
' + suffix);
+ });
+
test('media upload stays in the command target editor (mock upload API)', async ({ page }) => {
await page.route('**/api/image', route => route.fulfill({ json: { id: mediaId } }));
await mockMediaDownload(page);
@@ -154,6 +196,29 @@ for (const pane of ['primary', 'secondary']) {
await expect(editor).toBeFocused();
});
+ test('reference chooser does not steal focus from a field already being edited', async ({ page }) => {
+ // Capture the chooser's deferred focus callback without delaying slash-menu
+ // opening. Flushing it after a user selects another field exposes the race.
+ await page.evaluate(() => {
+ const nativeFrame = window.requestAnimationFrame;
+ window.__referenceFocusFrames = [];
+ window.requestAnimationFrame = function(callback) {
+ if (String(callback).includes('numberInput.focus()')) {
+ window.__referenceFocusFrames.push(callback);
+ return 0;
+ }
+ return nativeFrame.call(window, callback);
+ };
+ });
+ await enterCommand(page, editor, 'reference', 'reference');
+ await page.locator('#reference-modal-url').fill('https://example.com/focus');
+ await page.evaluate(() => window.__referenceFocusFrames.splice(0).forEach(callback => callback()));
+ await expect(page.locator('#reference-modal-url')).toBeFocused();
+ await page.locator('#reference-modal-title-input').fill('Kept title');
+ await page.locator('#reference-modal-apply').click();
+ await expect(editor).toHaveValue(prefix + '[1]' + suffix + '\n[1]: https://example.com/focus "Kept title"');
+ });
+
test('cancels insertion when the target changes during upload', async ({ page }) => {
let releaseUpload;
const pendingUpload = new Promise(resolve => { releaseUpload = resolve; });
diff --git a/tests/e2e/trash.spec.js b/tests/e2e/trash.spec.js
index 02703141..61af4f6b 100644
--- a/tests/e2e/trash.spec.js
+++ b/tests/e2e/trash.spec.js
@@ -148,6 +148,7 @@ test('automatic Trash cleanup removes only valid items older than 30 days', asyn
test('Trash maintenance failure never blocks workspace initialization', async ({ page }) => {
await openApp(page);
+ await page.evaluate(() => window.MarkdownViewerFlushWorkspace());
const result = await page.evaluate(async () => {
const original = window.MarkdownWorkspaceStorage.prototype.purgeExpiredTrash;
window.MarkdownWorkspaceStorage.prototype.purgeExpiredTrash = async function() {
@@ -337,7 +338,7 @@ test('dedicated Trash restores, permanently deletes, and empties selected data',
await page.locator('#trash-delete-button').click();
await expect(page.locator('#document-confirm-modal-title')).toHaveText('Permanently delete 2 files?');
await expect(page.locator('#document-confirm-modal-description')).toHaveText(
- '“Deleted document” and “Delete from Trash” will be permanently removed from Trash immediately. This action cannot be undone.'
+ /^(?:“Deleted document” and “Delete from Trash”|“Delete from Trash” and “Deleted document”) will be permanently removed from Trash immediately\. This action cannot be undone\.$/
);
await page.locator('#document-confirm-modal-cancel').click();
await expect(page.locator('#trash-modal')).toHaveClass(/is-visible/);
diff --git a/tests/e2e/workspace-maintenance.spec.js b/tests/e2e/workspace-maintenance.spec.js
new file mode 100644
index 00000000..a9777716
--- /dev/null
+++ b/tests/e2e/workspace-maintenance.spec.js
@@ -0,0 +1,66 @@
+const { test, expect } = require('@playwright/test');
+const JSZip = require('jszip');
+const { openApp } = require('../helpers/app');
+const { installDesktopFilesystem } = require('../helpers/desktop-filesystem');
+
+async function requestImport(page) {
+ const zip = new JSZip();
+ zip.file('Workspace/New.md', '# replacement');
+ zip.file('.markdown-viewer/documents.json', JSON.stringify([{ metadata: { id: 'replacement', title: 'Replacement', workspaceId: 'workspace_default' }, path: 'Workspace/New.md' }]));
+ zip.file('.markdown-viewer/organization.json', '{}');
+ zip.file('.markdown-viewer/preferences.json', '{}');
+ zip.file('markdown-viewer-backup.json', JSON.stringify({ format: 'markdown-viewer-backup', version: 1, normalDocumentCount: 1, secureRecordCount: 0 }));
+ await page.getByRole('button', { name: 'Open workspace settings' }).click();
+ await page.locator('#storage-settings-button').click();
+ await page.locator('#storage-backup-file-input').setInputFiles({ name: 'maintenance.zip', mimeType: 'application/zip', buffer: await zip.generateAsync({ type: 'nodebuffer' }) });
+ await page.locator('#storage-import-confirm-confirm').click();
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.route('**/libs/jszip.min.js', route => route.fulfill({ contentType: 'application/javascript', body: require('fs').readFileSync(require.resolve('jszip/dist/jszip.min.js')) }));
+ await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);');
+ await openApp(page);
+ await page.evaluate(async () => {
+ __desktopFiles.set('C:/notes.md', '# original');
+ await NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/notes.md');
+ await MarkdownViewerFlushWorkspace();
+ });
+});
+
+test('import gates native launches, persists live drafts, and re-enables after safe failure', async ({ page }) => {
+ await page.locator('#markdown-editor').fill('# newest draft');
+ await page.evaluate(() => {
+ MarkdownWorkspaceStorage.prototype.restoreBackupData = async function () {
+ window.__maintenanceReached = true;
+ await new Promise(resolve => { window.__releaseMaintenance = resolve; });
+ throw new Error('Simulated restore failed safely');
+ };
+ });
+ await requestImport(page);
+ await expect.poll(() => page.evaluate(() => window.__maintenanceReached)).toBe(true);
+ expect(await page.evaluate(async () => {
+ try { await MarkdownViewerFlushWorkspace(); return 'unexpected success'; }
+ catch (error) { return error.message; }
+ })).toContain('maintenance is still in progress');
+ expect(await page.evaluate(async () => {
+ const linked = await NL_IMPORT_EXTERNAL_FILE('# late', 'late', 'C:/late.md');
+ const storage = new MarkdownWorkspaceStorage(); await storage.init();
+ const meta = (await storage.listDocumentMetadata()).find(x => x.title === 'notes');
+ return { linked: !!linked, inert: document.querySelector('.app-container').inert, draft: await storage.loadDocumentContent(meta.id), original: __desktopFiles.get('C:/notes.md') };
+ })).toEqual({ linked: false, inert: true, draft: '# newest draft', original: '# original' });
+ await page.evaluate(() => __releaseMaintenance());
+ await expect(page.locator('#import-progress-detail')).toContainText('Simulated restore failed safely');
+ await expect.poll(() => page.evaluate(() => document.querySelector('.app-container').inert)).toBe(false);
+ await expect(page.locator('#markdown-editor')).toHaveValue('# newest draft');
+});
+
+test('import does not resume editing when restore rollback also failed', async ({ page }) => {
+ await page.evaluate(() => {
+ MarkdownWorkspaceStorage.prototype.restoreBackupData = async () => {
+ const error = new Error('Failed restore'); error.rollbackError = new Error('Failed rollback'); throw error;
+ };
+ });
+ await requestImport(page);
+ await expect(page.locator('#import-progress-detail')).toContainText('Editing remains blocked');
+ expect(await page.evaluate(() => document.querySelector('.app-container').inert)).toBe(true);
+});
diff --git a/tests/helpers/app.js b/tests/helpers/app.js
index f31a45d9..8e25474b 100644
--- a/tests/helpers/app.js
+++ b/tests/helpers/app.js
@@ -14,9 +14,9 @@ const localBrowserAssets = [
['https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js', 'node_modules/marked/marked.min.js', 'application/javascript'],
['https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 'node_modules/@highlightjs/cdn-assets/highlight.min.js', 'application/javascript'],
['https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/powershell.min.js', 'node_modules/@highlightjs/cdn-assets/languages/powershell.min.js', 'application/javascript'],
- ['https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.13/purify.min.js', 'node_modules/dompurify/dist/purify.min.js', 'application/javascript'],
+ ['https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.16/purify.min.js', 'node_modules/dompurify/dist/purify.min.js', 'application/javascript'],
['https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js', 'node_modules/file-saver/dist/FileSaver.min.js', 'application/javascript'],
- ['https://cdn.jsdelivr.net/npm/js-yaml@4.3.1/dist/js-yaml.min.js', 'node_modules/js-yaml/dist/js-yaml.min.js', 'application/javascript'],
+ ['https://cdn.jsdelivr.net/npm/js-yaml@4.3.2/dist/js-yaml.min.js', 'node_modules/js-yaml/dist/js-yaml.min.js', 'application/javascript'],
['https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js', 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', 'application/javascript'],
['https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', 'node_modules/jszip/dist/jszip.min.js', 'application/javascript'],
['https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js', 'node_modules/pako/dist/pako.min.js', 'application/javascript']
diff --git a/tests/helpers/desktop-filesystem.js b/tests/helpers/desktop-filesystem.js
new file mode 100644
index 00000000..b8e8eacf
--- /dev/null
+++ b/tests/helpers/desktop-filesystem.js
@@ -0,0 +1,72 @@
+// In-memory Neutralino filesystem for testing the real desktop storage backend.
+function installDesktopFilesystem(target) {
+ const files = new Map();
+ const directories = new Set(['C:', 'C:/Documents']);
+ const normalize = value => String(value).replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '');
+ const join = (...parts) => normalize(parts.filter(Boolean).join('/'));
+ const storageData = new Map();
+ target.__desktopFiles = files;
+ target.NL_PORT = 1;
+ target.Neutralino = {
+ os: { getPath: async () => 'C:/Documents', open: async () => true },
+ events: { on: async () => true },
+ storage: {
+ getData: async key => {
+ if (!storageData.has(key)) throw new Error('missing');
+ return storageData.get(key);
+ },
+ setData: async (key, value) => storageData.set(key, value),
+ removeData: async key => storageData.delete(key)
+ },
+ filesystem: {
+ getJoinedPath: async (...parts) => join(...parts),
+ getStats: async path => {
+ path = normalize(path);
+ if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() };
+ if (files.has(path)) return { isDirectory: false, isFile: true, size: new TextEncoder().encode(files.get(path)).byteLength, modifiedAt: Date.now() };
+ throw Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' });
+ },
+ createDirectory: async path => directories.add(normalize(path)),
+ readFile: async (path, options) => {
+ path = normalize(path);
+ if (!files.has(path)) throw new Error('missing');
+ const bytes = new TextEncoder().encode(files.get(path));
+ const pos = options && options.pos || 0;
+ return new TextDecoder().decode(bytes.slice(pos, options && options.size !== undefined ? pos + options.size : undefined));
+ },
+ writeFile: async (path, value) => files.set(normalize(path), String(value)),
+ copy: async (source, destination) => files.set(normalize(destination), files.get(normalize(source))),
+ move: async (source, destination) => {
+ source = normalize(source);
+ destination = normalize(destination);
+ if (!files.has(source)) throw new Error('missing');
+ files.set(destination, files.get(source));
+ files.delete(source);
+ },
+ remove: async path => {
+ path = normalize(path);
+ files.delete(path);
+ directories.delete(path);
+ },
+ readDirectory: async path => {
+ const prefix = normalize(path) + '/';
+ const entries = new Map();
+ directories.forEach(candidate => {
+ if (!candidate.startsWith(prefix)) return;
+ const rest = candidate.slice(prefix.length);
+ if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'DIRECTORY' });
+ });
+ files.forEach((_, candidate) => {
+ if (!candidate.startsWith(prefix)) return;
+ const rest = candidate.slice(prefix.length);
+ if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'FILE' });
+ });
+ return Array.from(entries.values());
+ },
+ createWatcher: async () => 1,
+ removeWatcher: async () => true
+ }
+ };
+}
+
+module.exports = { installDesktopFilesystem };
diff --git a/tests/unit/desktop-move-safety.test.mjs b/tests/unit/desktop-move-safety.test.mjs
new file mode 100644
index 00000000..216eae12
--- /dev/null
+++ b/tests/unit/desktop-move-safety.test.mjs
@@ -0,0 +1,69 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import { webcrypto } from 'node:crypto';
+import mock from '../helpers/desktop-filesystem.js';
+const source = fs.readFileSync(new URL('../../workspace-storage.js', import.meta.url), 'utf8');
+async function setup() {
+ const target = {crypto:webcrypto, TextEncoder, console, NL_OS:'Windows', localStorage:{getItem:()=>null,removeItem(){}}};
+ target.window=target; target.self=target; mock.installDesktopFilesystem(target);
+ vm.runInNewContext(source,target);
+ const storage = new target.MarkdownWorkspaceStorage(); await storage.init();
+ const tab = {id:'moving',title:'Before',content:'# original',contentLoaded:true,workspaceId:'workspace_default',_storageRevision:0};
+ await storage.saveDocuments([tab],{folders:[]},{forceContent:true});
+ const original = storage.vaultPath + '/' + tab._vaultRelativePath;
+ const index = target.__desktopFiles.get(storage.vaultPath+'/.markdown-viewer/index.json');
+ tab.title='After';
+ const destination = storage.vaultPath + '/' + await storage._desktopDocumentRelativePath(tab,{folders:[]});
+ return {target,storage,tab,original,destination,index,save:()=>storage.saveDocuments([tab],{folders:[]},{changedIds:[tab.id]})};
+}
+test('occupied move destination preserves both files and committed index', async () => {
+ const s=await setup(); s.target.__desktopFiles.set(s.destination,'# unrelated existing file');
+ await assert.rejects(s.save(),/destination.*exists/i);
+ assert.equal(s.target.__desktopFiles.get(s.original),'# original');
+ assert.equal(s.target.__desktopFiles.get(s.destination),'# unrelated existing file');
+ assert.equal(s.target.__desktopFiles.get(s.storage.vaultPath+'/.markdown-viewer/index.json'),s.index);
+});
+test('missing move source does not silently create a replacement body', async () => {
+ const s=await setup(); s.target.__desktopFiles.delete(s.original);
+ await assert.rejects(s.save(),/source.*missing/i);
+ assert.equal(s.target.__desktopFiles.has(s.destination),false);
+});
+test('stat failure with an existing destination entry must not permit overwrite', async () => {
+ const s=await setup(); s.target.__desktopFiles.set(s.destination,'# private existing file');
+ const stat=s.target.Neutralino.filesystem.getStats;
+ s.target.Neutralino.filesystem.getStats=async p=>{if(p===s.destination)throw {code:'NE_FS_NOPATHE'}; return stat(p);};
+ await assert.rejects(s.save(),/cannot be inspected/i);
+ assert.equal(s.target.__desktopFiles.get(s.original),'# original');
+ assert.equal(s.target.__desktopFiles.get(s.destination),'# private existing file');
+});
+test('destination appearing while move journal is written is not overwritten', async () => {
+ const s=await setup(); const write=s.target.Neutralino.filesystem.writeFile;
+ s.target.Neutralino.filesystem.writeFile=async(p,text)=>{await write(p,text); if(p.endsWith('.move.json'))s.target.__desktopFiles.set(s.destination,'# externally created');};
+ await assert.rejects(s.save(),/destination.*exists/i);
+ assert.equal(s.target.__desktopFiles.get(s.original),'# original');
+ assert.equal(s.target.__desktopFiles.get(s.destination),'# externally created');
+});
+test('normal document rename persists and reopens its body', async () => {
+ const s=await setup(); await s.save();
+ assert.equal(s.target.__desktopFiles.has(s.original),false);
+ assert.equal(s.target.__desktopFiles.get(s.destination),'# original');
+ const reopened=new s.target.MarkdownWorkspaceStorage(); await reopened.init();
+ assert.equal(await reopened.loadDocumentContent(s.tab.id),'# original');
+});
+test('move permission failure leaves source and index unchanged', async () => {
+ const s=await setup(); s.target.Neutralino.filesystem.move=async()=>{throw new Error('Access denied');};
+ await assert.rejects(s.save(),/Access denied/);
+ assert.equal(s.target.__desktopFiles.get(s.original),'# original');
+ assert.equal(s.target.__desktopFiles.has(s.destination),false);
+ assert.equal(s.target.__desktopFiles.get(s.storage.vaultPath+'/.markdown-viewer/index.json'),s.index);
+ const reopened=new s.target.MarkdownWorkspaceStorage(); await reopened.init();
+ assert.equal(await reopened.loadDocumentContent(s.tab.id),'# original');
+});
+test('unavailable parent listing stops move rather than assuming absence', async () => {
+ const s=await setup(); s.target.Neutralino.filesystem.readDirectory=async()=>{throw new Error('Parent access denied');};
+ await assert.rejects(s.save(),/Parent access denied/);
+ assert.equal(s.target.__desktopFiles.get(s.original),'# original');
+ assert.equal(s.target.__desktopFiles.has(s.destination),false);
+});
diff --git a/tests/unit/document-delete-races.test.mjs b/tests/unit/document-delete-races.test.mjs
new file mode 100644
index 00000000..09e7e95a
--- /dev/null
+++ b/tests/unit/document-delete-races.test.mjs
@@ -0,0 +1,55 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+
+function setup() {
+ const source = readFileSync(new URL('../../script.js', import.meta.url), 'utf8');
+ const start = source.indexOf(' let documentDeletionChain =');
+ const end = source.indexOf(' function closeTab(', start);
+ const deleted = [], gates = [];
+ const context = { tabs: [{ id: 'A' }, { id: 'B' }, { id: 'C' }], activeTabId: null,
+ secondarySplitTabId: null, pendingReviewDelete: null, liveCollaboration: null,
+ SECRET_WORKSPACE_ID: 'secret', selectedDocumentId: null,
+ workspacePersistenceChain: Promise.resolve(), workspaceMaintenancePending: false, suspendWorkspacePersistence: false,
+ shareSnapshotViewOnlyTabIds: new Set(), tabHistories: {}, console,
+ workspaceStorage: { deleteDocument: async id => deleted.push(id) },
+ _flushTabsToStorage: () => new Promise(resolve => gates.push(resolve)),
+ isPrivateStorageMode: () => false, isTemporaryDocument: () => false };
+ for (const name of ['saveSecondarySplitState', 'markReleaseNotesSeen', 'clearDirtyDocumentJournal',
+ 'renderDocumentSplitView', 'renderTabBar', 'closeReviewComposer', 'renderReviewPanel', 'renderDocumentSidebar', 'showAppToast']) context[name] = () => {};
+ const remove = vm.runInNewContext(source.slice(start, end) + '\ndeleteTab;', context);
+ return { context, remove, deleted, gates };
+}
+const tick = () => new Promise(resolve => setImmediate(resolve));
+
+test('simultaneous deletions serialize and remove only the requested IDs', async () => {
+ const s = setup();
+ const a = s.remove('A'), b = s.remove('B');
+ await tick(); assert.equal(s.gates.length, 1);
+ s.gates[0](true); assert.equal(await a, true);
+ await tick(); s.gates[1](true); assert.equal(await b, true);
+ assert.deepEqual(s.deleted, ['A', 'B']);
+ assert.deepEqual(s.context.tabs.map(x => x.id), ['C']);
+});
+
+test('another list mutation during a deletion cannot remove a different document', async () => {
+ const s = setup(); const b = s.remove('B'); await tick();
+ s.context.tabs.shift(); s.gates[0](true); await b;
+ assert.deepEqual(s.context.tabs.map(x => x.id), ['C']);
+});
+
+test('duplicate delete requests do not double-delete or remove a neighbour', async () => {
+ const s = setup(); const a = s.remove('A'), again = s.remove('A');
+ await tick(); s.gates[0](true); await a;
+ assert.equal(await again, false);
+ assert.deepEqual(s.deleted, ['A']);
+ assert.deepEqual(s.context.tabs.map(x => x.id), ['B', 'C']);
+});
+
+test('a cancelled delete does not poison the deletion queue', async () => {
+ const s = setup(); const a = s.remove('A'), b = s.remove('B');
+ await tick(); s.gates[0](false); assert.equal(await a, false);
+ await tick(); s.gates[1](true); await b;
+ assert.deepEqual(s.context.tabs.map(x => x.id), ['A', 'C']);
+});
diff --git a/tests/unit/recovery-journal-commit.test.mjs b/tests/unit/recovery-journal-commit.test.mjs
new file mode 100644
index 00000000..4f7e4c31
--- /dev/null
+++ b/tests/unit/recovery-journal-commit.test.mjs
@@ -0,0 +1,125 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+
+const source = fs.readFileSync(new URL('../../script.js', import.meta.url), 'utf8');
+function productionFunction(name) {
+ const start = source.indexOf(` function ${name}(`) >= 0
+ ? source.indexOf(` function ${name}(`) : source.indexOf(` async function ${name}(`);
+ assert.ok(start >= 0, `Missing production function ${name}`);
+ const end = source.indexOf('\n }', start) + 4;
+ return source.slice(start, end);
+}
+function storage() {
+ const records = new Map();
+ return { getItem: key => records.get(key) ?? null, setItem: (key, value) => records.set(key, value), removeItem: key => records.delete(key) };
+}
+function setup() {
+ const tab = { id: 'notes', content: 'OLD', contentLoaded: true };
+ const target = {
+ tabs: [tab], documentOrganization: {}, localStorage: storage(), sessionStorage: storage(),
+ DIRTY_DOCUMENT_JOURNAL_PREFIX: 'journal:', workspaceWriterId: 'writer',
+ saveTabStateTimeout: null, suspendWorkspacePersistence: false,
+ pendingWorkspacePersistAll: false, pendingWorkspacePersistIds: new Set(),
+ workspacePersistenceChain: Promise.resolve(), clearTimeout() {},
+ getTabsForStorage: tabs => tabs, isPrivateStorageMode: () => false,
+ evictInactiveDocumentContent() {}, updateSaveStatus() {},
+ preserveWorkspaceConflict: async () => false, showAppToast() {},
+ console: { warn() {} }, saved: [], journals: new Map()
+ };
+ target.workspaceStorage = {
+ async saveDocuments(tabs) { target.saved.push(tabs[0].content); },
+ async listDirtyJournals() { return [...target.journals.values()]; },
+ async deleteDirtyJournals(ids, options) {
+ for (const id of ids) {
+ const current = target.journals.get(id);
+ if (!options || !options.expectedJournals || JSON.stringify(current) === JSON.stringify(options.expectedJournals[id])) target.journals.delete(id);
+ }
+ }
+ };
+ const names = ['getDirtyDocumentJournalKey', 'clearDirtyDocumentJournal', '_flushTabsToStorage'];
+ if (source.includes('function captureDirtyDocumentJournalSnapshot(')) names.unshift('captureDirtyDocumentJournalSnapshot');
+ vm.createContext(target);
+ vm.runInContext(names.map(productionFunction).join('\n'), target);
+ tab._dirtyJournalKeys = ['journal:writer:notes'];
+ return { target, tab, key: tab._dirtyJournalKeys[0] };
+}
+function journal(content, savedAt = 1) { return { documentId: 'notes', content, savedAt }; }
+
+test('a newer comments-only journal survives an older save of unchanged Markdown', async () => {
+ const { target, tab, key } = setup();
+ tab.reviewThreads = [{ body: 'OLD COMMENT' }];
+ target.localStorage.setItem(key, JSON.stringify({ ...journal('OLD'), metadata: { reviewThreads: [{ body: 'NEW COMMENT' }] } }));
+ await target._flushTabsToStorage([tab], { changedIds: [tab.id] });
+ assert.equal(JSON.parse(target.localStorage.getItem(key)).metadata.reviewThreads[0].body, 'NEW COMMENT');
+});
+
+test('a matching comments journal clears only after its save succeeds', async () => {
+ const { target, tab, key } = setup();
+ tab.reviewThreads = [{ body: 'COMMENT' }];
+ target.localStorage.setItem(key, JSON.stringify({ ...journal('OLD'), metadata: { reviewThreads: tab.reviewThreads } }));
+ await target._flushTabsToStorage([tab], { changedIds: [tab.id] });
+ assert.equal(target.localStorage.getItem(key), null);
+});
+
+for (const location of ['localStorage', 'sessionStorage', 'indexeddb']) {
+ test(`older save must retain newer ${location} recovery journal`, async () => {
+ const { target, tab, key } = setup();
+ if (location === 'indexeddb') target.journals.set(key, { journalId: key, ...journal('OLD') });
+ else target[location].setItem(key, JSON.stringify(journal('OLD')));
+ target.workspaceStorage.saveDocuments = async () => {
+ target.saved.push('OLD');
+ tab.content = 'NEW';
+ if (location === 'indexeddb') target.journals.set(key, { journalId: key, ...journal('NEW', 2) });
+ else target[location].setItem(key, JSON.stringify(journal('NEW', 2)));
+ };
+ assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true);
+ const current = location === 'indexeddb' ? target.journals.get(key) : JSON.parse(target[location].getItem(key));
+ assert.equal(current?.content, 'NEW');
+ });
+}
+
+test('successful save clears matching journals but retains a journal newer than tab.content', async () => {
+ const { target, tab, key } = setup();
+ target.localStorage.setItem(key, JSON.stringify(journal('OLD')));
+ target.sessionStorage.setItem(key, JSON.stringify(journal('NEW', 2)));
+ target.journals.set(key, { journalId: key, ...journal('OLD') });
+ assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true);
+ assert.equal(target.localStorage.getItem(key), null);
+ assert.equal(target.journals.has(key), false);
+ assert.equal(JSON.parse(target.sessionStorage.getItem(key)).content, 'NEW');
+});
+
+test('failed save preserves the existing recovery journal', async () => {
+ const { target, tab, key } = setup();
+ target.localStorage.setItem(key, JSON.stringify(journal('OLD')));
+ target.workspaceStorage.saveDocuments = async () => { throw Error('simulated failure'); };
+ assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), false);
+ assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'OLD');
+});
+
+test('metadata-only save must not discard recovery content', async () => {
+ const { target, tab, key } = setup();
+ tab.contentLoaded = false;
+ tab.content = undefined;
+ target.localStorage.setItem(key, JSON.stringify(journal('RECOVER ME')));
+ assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true);
+ assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'RECOVER ME');
+});
+
+test('a newer journal survives an old save, then is cleared by its own successful save', async () => {
+ const { target, tab, key } = setup();
+ target.localStorage.setItem(key, JSON.stringify(journal('OLD')));
+ const normalSave = target.workspaceStorage.saveDocuments;
+ target.workspaceStorage.saveDocuments = async () => {
+ tab.content = 'NEW';
+ target.localStorage.setItem(key, JSON.stringify(journal('NEW', 2)));
+ };
+ await target._flushTabsToStorage([tab], { changedIds: [tab.id] });
+ assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'NEW');
+ target.workspaceStorage.saveDocuments = normalSave;
+ await target._flushTabsToStorage([tab], { changedIds: [tab.id] });
+ assert.equal(target.localStorage.getItem(key), null);
+ assert.equal(target.saved.at(-1), 'NEW');
+});
diff --git a/tests/unit/runtime-dependencies.test.mjs b/tests/unit/runtime-dependencies.test.mjs
new file mode 100644
index 00000000..4bfe40b6
--- /dev/null
+++ b/tests/unit/runtime-dependencies.test.mjs
@@ -0,0 +1,37 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+import yaml from 'js-yaml';
+
+const root = new URL('../../', import.meta.url);
+const read = relative => fs.readFileSync(new URL(relative, root));
+const manifest = JSON.parse(read('package.json'));
+const lock = JSON.parse(read('package-lock.json'));
+const html = read('index.html').toString();
+const fixtures = read('tests/helpers/app.js').toString();
+
+for (const [name, version, url, asset] of [
+ ['dompurify', '3.4.16', 'https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.16/purify.min.js', 'dist/purify.min.js'],
+ ['js-yaml', '4.3.2', 'https://cdn.jsdelivr.net/npm/js-yaml@4.3.2/dist/js-yaml.min.js', 'dist/js-yaml.min.js']
+]) {
+ test(`${name}: package, lockfile, browser URL and SRI agree`, () => {
+ assert.equal(manifest.devDependencies[name], version);
+ assert.equal(lock.packages[''].devDependencies[name], version);
+ assert.equal(lock.packages[`node_modules/${name}`].version, version);
+ assert.equal(JSON.parse(read(`node_modules/${name}/package.json`)).version, version);
+ const tag = html.match(/