Skip to content
Closed
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
34 changes: 26 additions & 8 deletions scripts/homeSentinelSnapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';

// The home screen sits in a tab, and that tab's path is the sentinel string
// `'HOME'` rather than a file. `serializeState` filtered on `path !== ''`, so
// the sentinel was written into the session snapshot; `restoreState` accepted
// any non-empty string, so it came back; and startup then asked the backend to
// read a file called `HOME`. The failure left a phantom tab that can never be
// read, and a window whose only tab was the home screen came back empty.
// The home screen used to sit in a tab, and that tab's path was the sentinel
// string `'HOME'` rather than a file. `serializeState` filtered on
// `path !== ''`, so the sentinel was written into the session snapshot;
// `restoreState` accepted any non-empty string, so it came back; and startup
// then asked the backend to read a file called `HOME`. The failure left a
// phantom tab that can never be read, and a window whose only tab was the home
// screen came back empty.
//
// `hasRealFilePath` in utils/tabFileActions.ts is the project's existing answer
// to "is this path a file" — every other caller already used it.
Expand Down Expand Up @@ -41,16 +42,33 @@ g.window.__TAURI_INTERNALS__ = {
};

const { tabManager } = await import('../src/lib/stores/tabs.svelte.js');
const { HOME_TAB_PATH } = await import('../src/lib/utils/homeTab.js');

function reset() {
tabManager.closeAll();
localStore.clear();
}

/**
* A tab bearing the home sentinel.
*
* `TabManager.addHomeTab` used to build one; it lost its last caller when
* Ctrl+T settled on "new file" (#480) and was removed with it. The write-side
* rule it exercised here outlives it, because the two sides are one filter:
* `serializeState` and `restoreState` both reject on `hasRealFilePath`, and the
* read side still meets `HOME` in snapshots this app wrote before the fix.
* Pinning the write side is what keeps a future writer from putting the
* sentinel back into the file the read side is busy defending against.
*/
function openHomeTab() {
tabManager.addNewTab();
tabManager.activeTab!.path = HOME_TAB_PATH;
}

test('a session snapshot never carries the home tab', () => {
reset();
tabManager.addTab('/notes/a.md');
tabManager.addHomeTab();
openHomeTab();

const snapshot = JSON.parse(tabManager.serializeState());

Expand All @@ -62,7 +80,7 @@ test('a session snapshot never carries the home tab', () => {

test('a window showing only the home tab writes an empty snapshot, not a HOME one', () => {
reset();
tabManager.addHomeTab();
openHomeTab();

assert.deepEqual(JSON.parse(tabManager.serializeState()).tabs, []);
});
Expand Down
35 changes: 29 additions & 6 deletions scripts/homeTabRender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,33 @@ function reset() {
localStore.clear();
}

/**
* A tab bearing the home sentinel.
*
* `TabManager.addHomeTab` used to build one and these tests used to call it.
* That method lost its last caller when Ctrl+T settled on "new file" (#480)
* and was removed with it, so the state is assembled here instead.
*
* The gate still has to recognise the sentinel, which is why these tests did
* not go with the constructor: a session snapshot written before #401 can
* still hand a `HOME` path to a running window, and what keeps such a tab out
* is precisely the recognition this file evaluates.
*
* Setting `path` is the whole of it — `isHomePath` is a predicate over `path`
* and nothing else, which is the property the title test below pins. Building
* the state this way states that outright rather than inheriting a title from
* a constructor the gate is required to ignore.
*/
function openHomeTab() {
tabManager.addNewTab();
tabManager.activeTab!.path = HOME_TAB_PATH;
}

// --------------------------------------------------------------- the regression

test('the home tab renders the home screen, not an empty document', () => {
reset();
tabManager.addHomeTab();
openHomeTab();

assert.equal(tabManager.activeTab?.path, HOME_TAB_PATH, 'precondition: the home tab is active');
assert.equal(
Expand All @@ -199,11 +221,12 @@ test('the home tab renders the home screen, not an empty document', () => {
});

test('a home tab opened next to files still renders the home screen', () => {
// Ctrl+T from a window that already has documents open — the reported path.
// Ctrl+T from a window that already had documents open — the path #392 was
// reported from, and the one a pre-#401 snapshot reproduces on startup.
reset();
tabManager.addTab('/notes/a.md');
tabManager.addTab('/notes/b.md');
tabManager.addHomeTab();
openHomeTab();

assert.equal(showsDocumentContainer(), false, `blank home tab alongside open files. Gate: ${gateSource}`);
});
Expand All @@ -214,7 +237,7 @@ test('the home screen is reached by tab kind, never by the tab title', () => {
// `Recents` used to be enough to hide the editor. Nothing about which
// branch runs may depend on it.
reset();
tabManager.addHomeTab();
openHomeTab();
const home = tabManager.activeTab!;

for (const { code } of getSupportedLanguages()) {
Expand Down Expand Up @@ -273,15 +296,15 @@ test('the Home toolbar button still overlays the home screen on any tab', () =>
tabManager.addNewTab();
assert.equal(showsDocumentContainer(true), false, 'an untitled tab with showHome set');

tabManager.addHomeTab();
openHomeTab();
assert.equal(showsDocumentContainer(true), false, 'the home tab with showHome set');
});

test('switching from the home tab back to a file returns to the document', () => {
reset();
tabManager.addTab('/notes/a.md');
const file = tabManager.activeTab!.id;
tabManager.addHomeTab();
openHomeTab();
assert.equal(showsDocumentContainer(), false);

tabManager.setActive(file);
Expand Down
5 changes: 4 additions & 1 deletion scripts/lossyDecodeSaveGuard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ test('the tab carries the fidelity of its buffer', () => {
assert.match(tabs, /hasReplacementChars: boolean;/);
assert.doesNotMatch(tabs, /hasReplacementChars\?: boolean;/);
assert.match(tabs, /setTabDecodedLossy\(id: string, lossy: boolean\)/);
assert.equal(tabs.match(/hasReplacementChars: false/g)?.length, 4);
// One per Tab construction site in this store: restoreState, addTab,
// addNewTab. It was four until addHomeTab lost its last caller and was
// removed (#480). A new site that forgets the field moves this number.
assert.equal(tabs.match(/hasReplacementChars: false/g)?.length, 3);
});

test('every load decides the flag instead of leaving it stale', () => {
Expand Down
3 changes: 1 addition & 2 deletions scripts/renderedHtmlField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,6 @@ test('every Tab construction site starts the rendered-HTML field empty', () => {
tabManager.restoreState(JSON.stringify({ tabs: [{ path: '/notes/b.md', title: 'b.md' }] }));
tabManager.addTab('/notes/a.md', '# a\n');
tabManager.addNewTab();
tabManager.addHomeTab();
tabManager.insertTransferredTab({
path: '/notes/c.md',
title: 'c.md',
Expand All @@ -452,7 +451,7 @@ test('every Tab construction site starts the rendered-HTML field empty', () => {
hasReplacementChars: false,
});

assert.equal(tabManager.tabs.length, 5, 'precondition: every construction site produced a tab');
assert.equal(tabManager.tabs.length, 4, 'precondition: every construction site produced a tab');
for (const tab of tabManager.tabs) {
assert.equal(tab.content, '', `${tab.path || tab.title} was constructed with a non-empty content field`);
}
Expand Down
21 changes: 11 additions & 10 deletions scripts/tabPathIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,14 @@ test('untitled tabs are not files and do not collide', () => {
assert.equal(tabsFor('').length, 3);
});

test('the home tab is a singleton on its own terms, not through the path rule', () => {
reset();
tabManager.addHomeTab();
const home = tabManager.activeTabId;
tabManager.addTab('/notes/a.md');
tabManager.addHomeTab();

assert.equal(tabsFor('HOME').length, 1);
assert.equal(tabManager.activeTabId, home);
});
// There used to be a test here that the home tab was a singleton on its own
// terms rather than through the path rule: `addHomeTab` re-activated the
// existing home tab instead of building a second one, and it had to, because
// `claimPath` returns early for anything that is not a real file path and so
// would never have de-duplicated two of them.
//
// `addHomeTab` was the only way to make a home tab and it is gone (#480), which
// takes the singleton rule with it — there is no longer a second call to make.
// The half that outlives it, that the sentinel is not a file path and is not
// subject to the claim rule, is `hasRealFilePath`'s job and is covered where
// that predicate is: homeSentinelSnapshot.test.ts and homeTabRender.test.ts.
6 changes: 4 additions & 2 deletions scripts/untitledTitle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ test('works with localized bases', () => {
test('new tabs are created with numbered untitled titles', () => {
const tabs = readSource('src/lib/stores/tabs.svelte.ts');
assert.match(tabs, /nextUntitledTitle\(/);
// both creation paths go through the helper
const addNewTab = sliceBetween(tabs, 'addNewTab()', 'addHomeTab()');
// both creation paths go through the helper. The slice ends at the next
// method after `addNewTab`, which used to be `addHomeTab` until that one
// lost its last caller and was removed (#480).
const addNewTab = sliceBetween(tabs, 'addNewTab()', 'insertTransferredTab(');
assert.match(addNewTab, /nextUntitledTitle\(/);
});
1 change: 0 additions & 1 deletion src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3321,7 +3321,6 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
ontoggleEdit={() => toggleEdit()}
ontoggleLive={toggleLiveMode}
ontoggleSplit={() => tabManager.activeTabId && toggleSplitView(tabManager.activeTabId)}
onhome={() => (showHome = true)}
onnextTab={() => tabManager.cycleTab('next')}
onprevTab={() => tabManager.cycleTab('prev')}
onundoClose={handleUndoCloseTab}
Expand Down
2 changes: 0 additions & 2 deletions src/lib/components/Editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
ontoggleEdit,
ontoggleLive,
ontoggleSplit,
onhome,
onnextTab,
onprevTab,
onundoClose,
Expand All @@ -65,7 +64,6 @@
ontoggleEdit?: () => void;
ontoggleLive?: () => void;
ontoggleSplit?: () => void;
onhome?: () => void;
onnextTab?: () => void;
onprevTab?: () => void;
onundoClose?: () => void;
Expand Down
49 changes: 7 additions & 42 deletions src/lib/stores/tabs.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { t } from '../utils/i18n.js';
import { nextUntitledTitle } from '../utils/untitledTitle.js';
import { settings } from './settings.svelte.js';
import { hasRealFilePath } from '../utils/tabFileActions.js';
import { HOME_TAB_PATH, isHomePath } from '../utils/homeTab.js';
import { buildTransferredTab, type TransferableTab } from '../utils/tabTransfer.js';
import { canonicalizePath, isSameFilePath } from '../utils/pathIdentity.js';
import { retainTabModels } from '../utils/tabModels.js';
Expand Down Expand Up @@ -214,9 +213,9 @@ class TabManager {
* Untitled tabs have no disk backing and are resolved at close, so they
* are not persisted.
*
* The filter is `hasRealFilePath`, not `path !== ''`: the home screen sits
* in a tab whose path is `HOME_TAB_PATH`, which passes the non-empty test
* and used to be written into the snapshot. Restoring it then asked the
* The filter is `hasRealFilePath`, not `path !== ''`: the home screen used
* to sit in a tab whose path is `HOME_TAB_PATH`, which passes the non-empty
* test and so was written into the snapshot. Restoring it then asked the
* backend to read a file under that name, and the failure left a
* permanently unreadable phantom tab — or, when the home tab was the only
* one, a window that came back empty.
Expand Down Expand Up @@ -394,10 +393,10 @@ class TabManager {
* `''` for a tab whose file is read afterwards, which is what both callers
* in the app do. It is NOT the rendered `content`: that starts empty here
* as it does at every other construction site (`addNewTab`, `restoreState`,
* `addHomeTab`, `buildTransferredTab`), and the first preview render fills
* it. Seeding it from this argument put Markdown in the field that is
* injected via `{@html}` — sanitized at the sink, so it showed as escaped
* source rather than being a hole, but it is not what that field means.
* `buildTransferredTab`), and the first preview render fills it. Seeding it
* from this argument put Markdown in the field that is injected via
* `{@html}` — sanitized at the sink, so it showed as escaped source rather
* than being a hole, but it is not what that field means.
*/
addTab(path: string, rawContent: string = '', pathKey?: string) {
// Opening a file that is already open activates that tab instead of
Expand Down Expand Up @@ -482,40 +481,6 @@ class TabManager {
this.activeTabId = id;
}

addHomeTab() {
const homeTab = this.tabs.find(t => isHomePath(t.path));
if (homeTab) {
this.activeTabId = homeTab.id;
return;
}

const id = crypto.randomUUID();
this.tabs.push({
id,
path: HOME_TAB_PATH,
title: t('tabs.home', settings.language),
content: '',
rawContent: '',
originalContent: '',
scrollTop: 0,
isDirty: false,
isEditing: false,
history: [],
historyIndex: 0,
editorViewState: null,
scrollPercentage: 0,
anchorLine: 0,
isSplit: false,
splitRatio: 0.5,
isScrollSynced: false,
collapsedHeaders: new Set<string>(),
isTruncated: false,
hasReplacementChars: false
});

this.activeTabId = id;
}

/**
* Insert a tab that arrived from another window (cross-window transfer).
* The snapshot carries the unsaved buffer — see tabTransfer.ts. Rendered
Expand Down
14 changes: 14 additions & 0 deletions src/lib/utils/homeTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@
* A tab kind is not really a path, and nothing stops a document from being
* opened at a relative path spelled exactly `HOME`. Neither is fixed here, but
* both are now a single edit rather than a search-and-replace.
*
* Nothing in the app constructs such a tab any more. `TabManager.addHomeTab`
* was the only constructor, its only caller was the Ctrl+T branch in
* MarkdownViewer.svelte, and that branch now opens a new file instead (#480) —
* so the method went with it.
*
* The sentinel and its reader stay, because a tab carrying it can still arrive
* from outside this build. Snapshots written before #401 have `HOME` in them
* and are sitting on users' disks; what turns those away is `hasRealFilePath`
* in tabFileActions.ts, and that predicate is spelled in terms of `isHomePath`.
* The recognition IS the rejection — drop it and the sentinel is readmitted as
* a phantom tab pointing at a file that can never be read (#401). The gate in
* MarkdownViewer.svelte and the tab-strip guards keep reading it for the same
* reason: they are what a stray home tab would run into (#429).
*/
export const HOME_TAB_PATH = 'HOME';

Expand Down