Skip to content
Open
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
45 changes: 45 additions & 0 deletions scripts/untitledTitle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

import { nextUntitledTitle } from '../src/lib/utils/untitledTitle.js';

// Untitled tabs get distinct numbered titles ("Untitled 1", "Untitled 2", …)
// so the tab strip — and the per-tab close dialogs — can name exactly which
// tab they are talking about. Two dirty untitled tabs used to both be called
// "Untitled", making the close dialog ambiguous.

test('the first untitled tab is number 1', () => {
assert.equal(nextUntitledTitle([], 'Untitled'), 'Untitled 1');
});

test('numbers increment past the ones in use', () => {
assert.equal(nextUntitledTitle(['Untitled 1', 'Untitled 2'], 'Untitled'), 'Untitled 3');
});

test('freed numbers are reused (smallest available wins)', () => {
assert.equal(nextUntitledTitle(['Untitled 1', 'Untitled 3'], 'Untitled'), 'Untitled 2');
});

test('a legacy unnumbered title occupies slot 1', () => {
assert.equal(nextUntitledTitle(['Untitled'], 'Untitled'), 'Untitled 2');
});

test('titled tabs and lookalike names are ignored', () => {
assert.equal(
nextUntitledTitle(['notes.md', 'Untitled draft', 'Untitled 1x'], 'Untitled'),
'Untitled 1',
);
});

test('works with localized bases', () => {
assert.equal(nextUntitledTitle(['无标题 1'], '无标题'), '无标题 2');
});

test('new tabs are created with numbered untitled titles', () => {
const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8');
assert.match(tabs, /nextUntitledTitle\(/);
// both creation paths go through the helper
const addNewTab = tabs.slice(tabs.indexOf('addNewTab()'), tabs.indexOf('addHomeTab()'));
assert.match(addNewTab, /nextUntitledTitle\(/);
});
86 changes: 86 additions & 0 deletions scripts/windowClosePerTab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');

// Window close (issue #189): instead of one aggregate "you have N unsaved
// files" modal, the red close button walks the dirty tabs one at a time —
// activating each and showing the SAME localized unsaved-changes dialog a
// single tab close shows (canCloseTab). Cancel stops the walk; the window
// stays open with the remaining tabs.

function closeHandler(): string {
const start = viewer.indexOf('appWindow.onCloseRequested');
assert.ok(start !== -1, 'close handler registration not found');
return viewer.slice(start, viewer.indexOf('onDragDropEvent', start));
}

test('the aggregate unsaved-files modal is gone from the close handler', () => {
const handler = closeHandler();
assert.doesNotMatch(handler, /youHaveUnsavedFiles/);
// and the old "clear all dirty flags then close" discard path with it
assert.doesNotMatch(handler, /tabManager\.tabs\.forEach\(\(t\) => \(t\.isDirty = false\)\)/);
});

test('dirty tabs are reviewed one at a time through the existing canCloseTab flow', () => {
const handler = closeHandler();
// activate the tab under review so the user sees what the dialog is about
assert.match(handler, /tabManager\.setActive\(dirty\.id\);/);
// the existing localized per-tab dialog decides save / discard / cancel
assert.match(handler, /await canCloseTab\(dirty\.id\)/);
// a resolved tab actually closes before moving on
assert.match(handler, /tabManager\.closeTab\(dirty\.id\);/);
});

test('cancelling the per-tab dialog stops the walk and keeps the window open', () => {
const handler = closeHandler();
assert.match(handler, /if \(!\(await canCloseTab\(dirty\.id\)\)\) return;/);
});

test('the close is prevented synchronously before the per-tab walk', () => {
const handler = closeHandler();
const branchStart = handler.indexOf('if (dirtyTabs.length > 0) {');
assert.ok(branchStart !== -1, 'dirty branch not found');
const prevent = handler.indexOf('event.preventDefault()', branchStart);
const walk = handler.indexOf('canCloseTab(dirty.id)', branchStart);
assert.ok(prevent !== -1 && walk !== -1 && prevent < walk);
});

test('the window closes only after every dirty tab is resolved', () => {
const handler = closeHandler();
const walk = handler.indexOf('canCloseTab(dirty.id)');
const close = handler.indexOf('appWindow.close()', walk);
assert.ok(walk !== -1 && close !== -1 && walk < close);
});

test('a second close request cannot start a competing walk', () => {
const handler = closeHandler();
// The native red button bypasses the dialog overlay; re-entry must be
// swallowed while a walk is active, or two walks fight over setActive
// and the highlighted tab stops matching the dialog.
assert.match(handler, /if \(isCloseWalkActive\) \{\s*event\.preventDefault\(\);\s*return;\s*\}/);
// and the flag is always released, even when the user cancels mid-walk
assert.match(handler, /finally \{\s*isCloseWalkActive = false;\s*\}/);
});

test('the walk proceeds in strict tab-strip order', () => {
const handler = closeHandler();
// Predictable left-to-right order: always the first dirty tab in the
// array; no active-first shortcut that made the sequence look random.
assert.match(handler, /const dirty = tabManager\.tabs\.find\(\(t\) => t\.isDirty\);/);
assert.doesNotMatch(handler, /active\?\.isDirty/);
});

test('the untitled save dialog prefills the numbered tab title', () => {
const fn = viewer.slice(viewer.indexOf('async function saveContent'));
const scope = fn.slice(0, fn.indexOf('async function saveContentAs'));
assert.match(scope, /defaultPath: tab\.title/);
});

test('the restore-on-reopen branch persists window state via the shared helper', () => {
const handler = closeHandler();
assert.match(handler, /persistWindowState\(\);/);
// no durable-write experiment left behind
assert.doesNotMatch(viewer, /saveSessionState|sessionState\.js/);
});
102 changes: 102 additions & 0 deletions scripts/windowStateRestore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8');
const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');

// Session restore persists WINDOW state only: which files are open, the
// active tab, and per-tab UI (edit mode, split, scroll). Document content
// always lives on disk — the snapshot never carries rawContent, so unsaved
// changes are handled exclusively by the per-tab close dialogs.

function slice(source: string, from: string, to: string): string {
const start = source.indexOf(from);
assert.ok(start !== -1, `${from} not found`);
const end = source.indexOf(to, start);
assert.ok(end !== -1, `${to} not found after ${from}`);
return source.slice(start, end);
}

test('serializeState writes window state only', () => {
const fn = slice(tabs, 'serializeState()', 'restoreState(');
assert.match(fn, /version: 2/);
// untitled tabs have no disk backing; they are resolved at close, never persisted
assert.match(fn, /filter\(\(t\) => t\.path !== ''\)/);
// no full-object spread and no content fields in the snapshot
assert.doesNotMatch(fn, /\.\.\.t/);
assert.doesNotMatch(fn, /rawContent/);
assert.doesNotMatch(fn, /originalContent/);
assert.doesNotMatch(fn, /isDirty/);
assert.doesNotMatch(fn, /history/);
});

test('restoreState rebuilds clean tabs and drops legacy untitled entries', () => {
const fn = slice(tabs, 'restoreState(', 'addTab(');
// path is the identity of a restored tab; entries without one are skipped
assert.match(fn, /saved\.path === ''\) continue;/);
// restored tabs start clean; content is read from disk afterwards
assert.match(fn, /isDirty: false/);
assert.match(fn, /rawContent: ''/);
// a stale activeTabId falls back to the first restored tab
assert.match(fn, /activeTabId/);
});

test('startup restore reads content from disk, not from the snapshot', () => {
const init = slice(viewer, 'localStorage.getItem(WINDOW_STATE_KEY)', 'urlParams');
assert.match(init, /read_file_content/);
// a missing file drops its tab instead of restoring a ghost
assert.match(init, /closeTab\(/);
});

test('the discard choice reverts the tab to its last saved content', () => {
const fn = slice(viewer, 'async function canCloseTab', 'async function toggleEdit');
assert.match(fn, /tab\.rawContent = tab\.originalContent;/);
assert.match(fn, /tab\.isDirty = false;/);
});

test('the close flow resolves dirty tabs before serializing window state', () => {
const handlerStart = viewer.indexOf('appWindow.onCloseRequested');
const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart));
const walk = handler.indexOf('canCloseTab(dirty.id)');
const persist = handler.indexOf('persistWindowState()');
assert.ok(walk !== -1, 'per-tab walk not found');
assert.ok(persist !== -1, 'window-state serialization not found');
assert.ok(walk < persist, 'dirty tabs must be resolved before the snapshot is written');
});

test('v2 snapshots live under their own key, invisible to legacy builds', () => {
// An older build restoring a v2 snapshot ends up with undefined tab
// content; its editor then attributes a stale buffer to the wrong tab and
// auto-save writes it to disk. Separate keys keep the formats apart.
const helper = viewer.slice(viewer.indexOf('function persistWindowState'));
const scope = helper.slice(0, helper.indexOf('\n\t}'));
assert.match(scope, /setItem\(WINDOW_STATE_KEY/);
assert.match(scope, /removeItem\(LEGACY_STATE_KEY\)/);
assert.match(viewer, /const WINDOW_STATE_KEY = 'savedTabsDataV2';/);
// startup prefers the v2 key and falls back to legacy for migration
assert.match(
viewer,
/localStorage\.getItem\(WINDOW_STATE_KEY\) \?\? localStorage\.getItem\(LEGACY_STATE_KEY\)/,
);
// explicit exit clears both
const exitFn = viewer.slice(viewer.indexOf('async function appExit'));
const exitScope = exitFn.slice(0, exitFn.indexOf('\n\t}'));
assert.match(exitScope, /removeItem\(WINDOW_STATE_KEY\)/);
assert.match(exitScope, /removeItem\(LEGACY_STATE_KEY\)/);
});

test('with restore enabled resolved titled tabs stay open for the snapshot', () => {
const handlerStart = viewer.indexOf('appWindow.onCloseRequested');
const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart));
// tabs are closed one-by-one only when restore is off (or untitled)
assert.match(handler, /restoreStateOnReopen \|\| dirty\.path === ''/);
});

test('auto-save fast path silently saves titled tabs before the walk', () => {
const handlerStart = viewer.indexOf('appWindow.onCloseRequested');
const handler = viewer.slice(handlerStart, viewer.indexOf('onDragDropEvent', handlerStart));
const fastPath = handler.indexOf('settings.autoSave && !settings.confirmBeforeSave');
const walk = handler.indexOf('canCloseTab(dirty.id)');
assert.ok(fastPath !== -1 && walk !== -1 && fastPath < walk);
});
Loading
Loading