diff --git a/scripts/homeSentinelSnapshot.test.ts b/scripts/homeSentinelSnapshot.test.ts index f450ec92..a80e82ea 100644 --- a/scripts/homeSentinelSnapshot.test.ts +++ b/scripts/homeSentinelSnapshot.test.ts @@ -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. @@ -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()); @@ -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, []); }); diff --git a/scripts/homeTabRender.test.ts b/scripts/homeTabRender.test.ts index b2413ac8..8fbf0e74 100644 --- a/scripts/homeTabRender.test.ts +++ b/scripts/homeTabRender.test.ts @@ -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( @@ -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}`); }); @@ -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()) { @@ -273,7 +296,7 @@ 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'); }); @@ -281,7 +304,7 @@ 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); diff --git a/scripts/lossyDecodeSaveGuard.test.ts b/scripts/lossyDecodeSaveGuard.test.ts index c06ed458..94ab3463 100644 --- a/scripts/lossyDecodeSaveGuard.test.ts +++ b/scripts/lossyDecodeSaveGuard.test.ts @@ -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', () => { diff --git a/scripts/renderedHtmlField.test.ts b/scripts/renderedHtmlField.test.ts index 04297471..10f3468c 100644 --- a/scripts/renderedHtmlField.test.ts +++ b/scripts/renderedHtmlField.test.ts @@ -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', @@ -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`); } diff --git a/scripts/tabPathIdentity.test.ts b/scripts/tabPathIdentity.test.ts index 73060928..b991902a 100644 --- a/scripts/tabPathIdentity.test.ts +++ b/scripts/tabPathIdentity.test.ts @@ -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. diff --git a/scripts/untitledTitle.test.ts b/scripts/untitledTitle.test.ts index 4f112f00..c0eb0055 100644 --- a/scripts/untitledTitle.test.ts +++ b/scripts/untitledTitle.test.ts @@ -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\(/); }); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index cf0011fd..004071de 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -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} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 237c59fa..4b81f482 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -47,7 +47,6 @@ ontoggleEdit, ontoggleLive, ontoggleSplit, - onhome, onnextTab, onprevTab, onundoClose, @@ -65,7 +64,6 @@ ontoggleEdit?: () => void; ontoggleLive?: () => void; ontoggleSplit?: () => void; - onhome?: () => void; onnextTab?: () => void; onprevTab?: () => void; onundoClose?: () => void; diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts index e5af2071..cde65bff 100644 --- a/src/lib/stores/tabs.svelte.ts +++ b/src/lib/stores/tabs.svelte.ts @@ -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'; @@ -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. @@ -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 @@ -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(), - 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 diff --git a/src/lib/utils/homeTab.ts b/src/lib/utils/homeTab.ts index 437ac8ed..dead0b74 100644 --- a/src/lib/utils/homeTab.ts +++ b/src/lib/utils/homeTab.ts @@ -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';