Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8390e42
test: reproduce fold layout resize regression
PathGao Jul 12, 2026
e5ea3d9
fix: keep folded sections aligned after resize
PathGao Jul 12, 2026
4e07675
fix: apply reflow-driven fold heights without animating
PathGao Jul 12, 2026
9bd4b6b
Merge branch 'codex/fix-fold-layout-resize' into integration
PathGao Jul 12, 2026
9205933
fix: close dirty tabs one at a time on window close
PathGao Jul 12, 2026
29dec4b
feat: restore window state only; content always comes from disk
PathGao Jul 12, 2026
60c751f
fix: keep the close walk's dialog matched to the highlighted tab
PathGao Jul 12, 2026
e6f3776
fix: keep v2 window-state snapshots invisible to legacy builds
PathGao Jul 12, 2026
85c129a
feat: number untitled tabs so close dialogs can name them
PathGao Jul 12, 2026
7c0e76d
fix: walk dirty tabs in tab-strip order and name them in Save As
PathGao Jul 12, 2026
f67bdd6
Merge branch 'codex/unsaved-window-close' into integration
PathGao Jul 12, 2026
fe9d771
feat: broker tab transfers, per-window delivery, and Rust window-stat…
PathGao Jul 13, 2026
59a165a
feat: serialize tabs for cross-window transfer
PathGao Jul 13, 2026
112343c
feat: 'Move to New Window' in the tab context menu; window-targeted t…
PathGao Jul 13, 2026
cc57ce9
feat: independent native windows — wiring, persistence gate, detach flow
PathGao Jul 13, 2026
f40bdeb
fix: migrate the localStorage snapshot at startup, write-through first
PathGao Jul 13, 2026
c1a004c
fix: prefer the localStorage snapshot over the Rust file when both exist
PathGao Jul 13, 2026
7a05504
Merge remote-tracking branch 'upstream/master' into codex/unsaved-win…
PathGao Jul 13, 2026
03e981c
Merge branch 'codex/unsaved-window-close' (master conflict resolution)
PathGao Jul 13, 2026
8d6462a
refactor: transferred-tab render goes through renderTabPreviewFromRaw
PathGao Jul 13, 2026
bfd6133
fix: startup-file delivery belongs to the main window only
PathGao Jul 14, 2026
e05e9f7
chore: sync Cargo.lock with the 2.6.12 version bump
PathGao Jul 14, 2026
245e85d
fix: route OS file-opens to the most recently focused window
PathGao Jul 14, 2026
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
219 changes: 219 additions & 0 deletions scripts/tabTransfer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

import type { Tab } from '../src/lib/stores/tabs.svelte.js';
import {
buildTransferredTab,
snapshotTab,
transferredTabTitle,
validateTransferPayload,
type TransferableTab,
} from '../src/lib/utils/tabTransfer.js';

// Cross-window tab transfer: snapshot in the source window → JSON through
// the Rust broker → strict validation → rebuild in the destination. The
// snapshot is independent of serializeState (window shape only, no content):
// a transferred tab MUST carry its unsaved buffer. Validation is strict and
// rejecting — a past bug built tabs with undefined rawContent, the editor
// attributed a stale buffer to them, and auto-save destroyed real files.

function makeTab(overrides: Partial<Tab> = {}): Tab {
return {
id: 'source-id',
path: '/notes/todo.md',
title: 'todo.md',
content: '<p>rendered</p>',
rawContent: '# unsaved edits',
originalContent: '# saved on disk',
scrollTop: 120,
isDirty: true,
isEditing: true,
history: ['/notes/readme.md', '/notes/todo.md'],
historyIndex: 1,
editorViewState: { cursorState: 'monaco-live-object' },
scrollPercentage: 0.42,
anchorLine: 7,
isSplit: true,
splitRatio: 0.3,
isScrollSynced: true,
...overrides,
};
}

function roundTrip(tab: Tab): TransferableTab {
const snap = snapshotTab(tab);
const validated = validateTransferPayload(JSON.stringify(snap));
assert.ok(validated, 'a snapshot of a real tab must validate');
return validated;
}

test('snapshot → JSON → validate → insert preserves the tab, including unsaved content', () => {
const source = makeTab();
const arrived = buildTransferredTab(roundTrip(source), [], 'Untitled');

assert.equal(arrived.path, source.path);
assert.equal(arrived.title, source.title);
assert.equal(arrived.rawContent, source.rawContent);
assert.equal(arrived.originalContent, source.originalContent);
assert.equal(arrived.isDirty, true);
assert.equal(arrived.isEditing, true);
assert.equal(arrived.isSplit, true);
assert.equal(arrived.splitRatio, source.splitRatio);
assert.equal(arrived.isScrollSynced, true);
assert.equal(arrived.scrollTop, source.scrollTop);
assert.equal(arrived.scrollPercentage, source.scrollPercentage);
assert.equal(arrived.anchorLine, source.anchorLine);
assert.deepEqual(arrived.history, source.history);
assert.equal(arrived.historyIndex, source.historyIndex);

// regenerated / non-serializable state starts fresh at the destination
assert.equal(arrived.content, '');
assert.equal(arrived.editorViewState, null);
assert.notEqual(arrived.id, source.id);
});

test('each insert gets a fresh id', () => {
const snap = snapshotTab(makeTab());
const a = buildTransferredTab(snap, [], 'Untitled');
const b = buildTransferredTab(snap, [], 'Untitled');
assert.notEqual(a.id, b.id);
});

test('snapshot never mutates the source tab', () => {
const source = makeTab();
const snap = snapshotTab(source);

snap.history.push('/injected.md');
snap.rawContent = 'clobbered';
snap.title = 'clobbered';

assert.deepEqual(source.history, ['/notes/readme.md', '/notes/todo.md']);
assert.equal(source.rawContent, '# unsaved edits');
assert.equal(source.title, 'todo.md');
assert.equal(source.editorViewState.cursorState, 'monaco-live-object');
});

test('the snapshot excludes rendered content and editorViewState', () => {
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
assert.ok(!('content' in snap));
assert.ok(!('editorViewState' in snap));
assert.ok(!('id' in snap));
});

test('validation rejects invalid JSON', () => {
assert.equal(validateTransferPayload('not json {'), null);
assert.equal(validateTransferPayload(''), null);
});

test('validation rejects non-object payloads', () => {
assert.equal(validateTransferPayload('null'), null);
assert.equal(validateTransferPayload('[]'), null);
assert.equal(validateTransferPayload('"a tab"'), null);
});

test('validation rejects a missing rawContent — no default, no coercion', () => {
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
delete snap.rawContent;
assert.equal(validateTransferPayload(JSON.stringify(snap)), null);
});

test('validation rejects a non-string rawContent', () => {
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
snap.rawContent = 5;
assert.equal(validateTransferPayload(JSON.stringify(snap)), null);
});

test('validation rejects a history that is not an array', () => {
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
snap.history = 'nope';
assert.equal(validateTransferPayload(JSON.stringify(snap)), null);
});

test('validation rejects history entries that are not strings', () => {
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
snap.history = [1, 2];
assert.equal(validateTransferPayload(JSON.stringify(snap)), null);
});

test('validation is strict for every field — even cosmetic numerics get no default', () => {
for (const field of [
'path',
'title',
'originalContent',
'isDirty',
'isEditing',
'isSplit',
'isScrollSynced',
'splitRatio',
'scrollTop',
'scrollPercentage',
'anchorLine',
'historyIndex',
]) {
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
delete snap[field];
assert.equal(validateTransferPayload(JSON.stringify(snap)), null, `missing ${field}`);
}
});

test('validation rejects non-finite numbers (NaN serializes to null)', () => {
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
snap.scrollTop = NaN; // JSON.stringify turns this into null
assert.equal(validateTransferPayload(JSON.stringify(snap)), null);
});

test('untitled arrivals keep their title unless the destination has taken it', () => {
const source = makeTab({ path: '', title: 'Untitled 2' });
const snap = roundTrip(source);

// The title is the document's identity: a fresh detach window (or any
// destination without a clash) never renames.
assert.equal(transferredTabTitle(snap, [], 'Untitled'), 'Untitled 2');
assert.equal(transferredTabTitle(snap, ['Untitled 1'], 'Untitled'), 'Untitled 2');
assert.equal(transferredTabTitle(snap, ['notes.md'], 'Untitled'), 'Untitled 2');

// Only an exact clash re-numbers, to the destination's smallest free
// number per untitledTitle semantics.
assert.equal(
transferredTabTitle(snap, ['Untitled 1', 'Untitled 2'], 'Untitled'),
'Untitled 3',
);
assert.equal(transferredTabTitle(snap, ['Untitled 2'], 'Untitled'), 'Untitled 1');

const arrived = buildTransferredTab(snap, ['Untitled 1', 'Untitled 2'], 'Untitled');
assert.equal(arrived.title, 'Untitled 3');
// the buffer still travels even when the title changed
assert.equal(arrived.rawContent, source.rawContent);
});

test('file-backed arrivals keep their title', () => {
const snap = roundTrip(makeTab());
assert.equal(transferredTabTitle(snap, ['todo.md'], 'Untitled'), 'todo.md');
});

// insertTransferredTab lives in the Svelte store ($state rune, not loadable
// in node), so — like windowStateRestore.test.ts — the thin store wrapper is
// checked statically; all decision logic above is exercised directly.
test('insertTransferredTab is a thin wrapper: build, push, activate, return the id', () => {
const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8');
const start = tabs.indexOf('insertTransferredTab(');
assert.ok(start !== -1, 'insertTransferredTab not found');
const fn = tabs.slice(start, tabs.indexOf('closeTab(', start));

assert.match(fn, /buildTransferredTab\(/);
// untitled re-numbering uses this window's titles and localized base
assert.match(fn, /this\.tabs\.map\(\(tab\) => tab\.title\)/);
assert.match(fn, /t\('tabs\.untitled', settings\.language\)/);
// the new tab is pushed, activated, and its id returned
assert.match(fn, /this\.tabs\.push\(tab\);/);
assert.match(fn, /this\.activeTabId = tab\.id;/);
assert.match(fn, /return tab\.id;/);
});

test('serializeState still persists window shape only (untouched by transfer)', () => {
const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8');
const fn = tabs.slice(tabs.indexOf('serializeState()'), tabs.indexOf('restoreState('));
assert.doesNotMatch(fn, /rawContent/);
assert.doesNotMatch(fn, /TransferableTab/);
});
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/);
});
Loading
Loading