Skip to content

fix(home): render the home screen when the home tab is active - #429

Merged
PathGao merged 1 commit into
masterfrom
fix/home-tab-blank
Aug 3, 2026
Merged

fix(home): render the home screen when the home tab is active#429
PathGao merged 1 commit into
masterfrom
fix/home-tab-blank

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Addresses the blank-render half of #392 (thanks @PathGao):

The Home tab is also broken on arrival … it falls through to the markdown container with empty content — a blank page.

The Ctrl+T half of that issue is deliberately untouched and stays open. No keybinding, no menu wiring, and no change to which tab type Ctrl+T creates — which of the three meanings it should settle on is a product decision, not a bug fix.

One {#if} draws the whole app

The consequent is the markdown container (editor + preview); the {:else} is <HomePage>. The home screen therefore appears only when the condition is false, and the condition was:

tabManager.activeTab && (tabManager.activeTab.path !== '' || tabManager.activeTab.title !== 'Recents') && !showHome

That is an accurate description of a tab that stopped existing in January. git log -S dates it exactly:

0694991 — 24 Jan tabs land. The home tab is { path: '', title: 'Recents' }. Both halves go false, the {:else} runs, the gate is correct
edfb555 — 31 Jan, "roughly match viewer-editor scroll location" the home tab becomes { path: 'HOME', title: 'Home' }. Both halves are now permanently true. The gate is byte-for-byte unchanged and has been wrong ever since
d14bb33 — 31 Mar, i18n title becomes t('tabs.home', lang). The dead half is now dead in 26 languages instead of one

Note what the breaking commit was about. The tab-shape change was incidental to a scroll fix, and the coupling was invisible: a template comparing a user-facing localised string against an English literal is not something npm run check can type, svelte-check can flag, or any store-level test can see. It only shows up by looking at the branch the app actually takes.

showHome could not rescue it either. It is a separate, older route to the same screen — a transient flag set by the Home toolbar button — and an $effect keyed on activeTabId sets it to false on every tab switch, including the one addHomeTab() performs on itself.

The sentinel gets a name

'HOME' is a magic string sitting in the field that otherwise holds a filesystem path. It was spelled at ten comparison sites across five files, and the reader that mattered most was not one of them — it had invented its own idea of what a home tab is. That is the reason this could rot silently, so the fix is to make there be one idea.

New src/lib/utils/homeTab.ts (29 lines) owns HOME_TAB_PATH and isHomePath. Every site now calls one of them, and hasRealFilePath — the project's existing "is this path a file" predicate, already used by serializeState, restoreState, the window-session restore and the tab context menu — is redefined in terms of isHomePath so the two cannot disagree.

The gate asks isHomePath, not hasRealFilePath. That distinction is the whole fix. hasRealFilePath is false for an untitled buffer too, and an untitled buffer is a document — it needs the editor. Gating the container on it would have traded a blank home tab for a blank new file, which is why an untitled buffer renders the document container is in the suite (and is the test that goes red if you try it — verified below).

Four call sites collapsed into hasRealFilePath rather than isHomePath, because path !== '' && path !== 'HOME' is that predicate, spelled out.

Why it cannot rot the same way twice

A new rule in singleImplementationConvention.test.ts pins the literal 'HOME' to homeTab.ts and nowhere else in src. The marker is the string, not the helper: a second site re-implementing the check is precisely a site that spells the string and does not mention isHomePath. Doc comments were reworded to say HOME_TAB_PATH, a symbol a rename carries.

This is the guard the old code lacked. Verified directly: rewriting the fixed gate as tabManager.activeTab.path !== 'HOME' is behaviourally correct and every behaviour test stays green — only the convention rule fails. That is the case the rule exists for.

Tests

scripts/homeTabRender.test.ts parses MarkdownViewer.svelte with the project's own svelte/compiler, locates the {#if} whose {:else} renders <HomePage> (skipping the mode === 'loading' / 'installer' / 'uninstall' ancestors, which contain it too), and evaluates that exact expression against the real TabManager. Free identifiers are read off the parsed expression, so a rewrite using a different helper fails with "the gate references X" rather than a bare ReferenceError.

It asserts nothing about how the condition is spelled. Rewrite it any way that keeps the home tab out of the document container and it stays green.

baseline (git checkout HEAD~1 -- src/) 4 pass / 4 fail
final 8 / 8

Baseline red, verbatim: the active home tab fell into the markdown container instead of the {:else} branch that renders <HomePage> — the user sees a blank page. Gate: tabManager.activeTab && (tabManager.activeTab.path !== '' || …)

The 4 green on both sides are the fence — a gate that simply always chose HomePage would pass the repro and fail all of these: a saved file gets the container, an untitled buffer gets the container, an empty window gets the home screen, and showHome still overlays the home screen on any tab.

Mutation checks

Deliberate break Result
gate uses hasRealFilePath instead of isHomePath 1 red — an untitled buffer renders the document container
isHomePath answers on '' instead of the sentinel 11 red across 4 files
a second site re-spells 'HOME' (behaviour unchanged) 1 red — the new convention rule, and only it
change HOME_TAB_PATH to '__markpad_home__' render tests all green; homeSentinelSnapshot goes red on the two legacy-snapshot cases

That last row is the useful one — see Not covered.

Seen rendering, not only reasoned about

Reasoning about an {#if} from source is weak evidence, so the SvelteKit frontend was driven in a browser behind a temporary local Tauri-bridge stub (not committedsrc/app.html is untouched in this diff). New file → Ctrl+T with focus outside Monaco:

  • pre-fix gate: the 主页 tab activates and the body is empty white — the reported symptom, reproduced
  • this branch: the welcome screen, the Open/New buttons and the Recent Files list render; document.querySelector('.markdown-container') is null
  • home tab as the only tab: same, home screen renders

A second reading of the same stale assumption

Tab.svelte set its tooltip to tab.path || 'Recents', written under the same obsolete premise. In today's app that shows the raw sentinel HOME on the home tab, and the untranslated string Recents on every untitled buffer. Now hasRealFilePath(tab.path) ? tab.path : tab.title — a real path where there is one, the tab's own localised title otherwise. Confirmed in the browser: 主页 and 无标题 1. This is the last occurrence of 'Recents' in src.

npm run check   439 files, 0 errors
npm test        562 / 562   (553 on this base + 8 render + 1 convention rule)
cargo test      not run — no Rust in this change

Not covered

  • The sentinel can still collide with a real path. A document opened at a relative path spelled exactly HOME would be mistaken for the home tab. Every path the app takes in is absolute (dialog, drag-drop, file association, wikilink resolution, Save As), and the consequences are cosmetic — disabled context-menu items, no session persistence — so this is not fixed here. It is now a one-line change instead of a search-and-replace, but not a free one: homeSentinelSnapshot.test.ts rejects the literal 'HOME' out of snapshots written by older builds, so changing the value means keeping the old one on the reject list. The mutation run above confirms that test is what catches it.
  • The real fix is a tab kind, not a path. A kind: 'file' | 'untitled' | 'home' discriminant on Tab would make the collision impossible and let the compiler check exhaustiveness. It touches serializeState / restoreState / the cross-window transfer payload and their migrations — much wider than a render bug warrants. Left for whoever wants it; this change makes it a smaller job than it was.
  • Ctrl+T's three meanings. Untouched, and Ctrl+T means three different things, and the Home tab it opens renders blank #392 stays open for it.
  • The gate is verified by evaluating the parsed expression, not by mounting the component. Combined with the browser run above, but neither is an end-to-end test in the packaged Tauri app.
  • The browser run used a stubbed Tauri bridge; the WebView the app actually ships in was not exercised.

🤖 Generated with Claude Code

The application body is drawn from a single `{#if}`: the consequent is
the markdown container, the `{:else}` is `<HomePage>`. Its condition was

    activeTab && (activeTab.path !== '' || activeTab.title !== 'Recents') && !showHome

which described the home tab as it was first written — `{ path: '',
title: 'Recents' }`. A week later the home tab became `{ path: 'HOME',
title: 'Home' }` and both halves went permanently true, so the home tab
fell into the markdown container and rendered an empty document. The
later i18n pass made the title half unrecoverable in 26 locales rather
than one.

The sentinel path now has a name. `src/lib/utils/homeTab.ts` owns
`HOME_TAB_PATH` and `isHomePath`; `hasRealFilePath` is expressed in
terms of the latter, and the ten sites that spelled the literal call one
of the two. The render gate asks `isHomePath`, not `hasRealFilePath` —
an untitled buffer has no file either, but it is still a document and
still needs the editor.

`singleImplementationConvention` pins the literal to that one file, so a
future reader of `tab.path` cannot invent its own idea of the home tab
in silence, which is how this rotted.

Also fixes a second reading of the same stale assumption: a tab's
tooltip was `tab.path || 'Recents'`, which showed the raw sentinel on
the home tab and "Recents" on every untitled buffer.

Ctrl+T is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 6939137 into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the fix/home-tab-blank branch August 3, 2026 09:24
PathGao added a commit that referenced this pull request Aug 6, 2026
…one meaning for Ctrl+T (#480)

* feat(editor): give the six unbound formatting commands a shortcut

`fmt-inline-code`, `fmt-code-block`, `fmt-quote` and the three headings have
been in the toolbar and the command palette since v2.6.12 and answered no key.
The report (#121) is from a QWERTZ user, for whom the backtick is a dead key:
typing a code fence needs three dead-key escapes, which is why the request is
for a shortcut and not for the command.

Bold, Italic and Underline already had Ctrl/Cmd+B/I/U. These six now have:

| action             | Windows/Linux  | macOS        | why that chord                  |
|--------------------|----------------|--------------|---------------------------------|
| `fmt-heading-1..3` | Ctrl+1/2/3     | Cmd+1/2/3    | Typora, Mark Text               |
| `fmt-inline-code`  | Ctrl+Shift+E   | ⇧⌘E          | GitHub's Ctrl+E, plus Shift     |
| `fmt-code-block`   | Ctrl+Shift+F   | ⇧⌘F          | no precedent survives Monaco    |
| `fmt-quote`        | Ctrl+Shift+.   | ⇧⌘.          | GitHub                          |

Every one of them was checked against standalone Monaco's OWN defaults, dumped
from the installed monaco-editor 0.55.1 rather than read off VS Code's
documentation — the two keymaps are not the same, and `editor.addAction`
registers at weight 1000, above every Monaco default, so a clash does not
error: our action simply wins and Monaco's loses its key.

That check rejected the obvious candidates:

- Ctrl/Cmd+E for inline code (GitHub's chord) is `view-toggle-edit` here
  already — and Ctrl+E for edit/read is itself the mainstream reading, in
  Obsidian and Mark Text. So inline code takes the same letter with Shift.
- Ctrl/Cmd+Shift+K for a code block (Typora, Mark Text) is
  `editor.action.deleteLines` on BOTH platforms, not just on Windows. Their
  macOS alternative, Cmd+Option+C, is `toggleFindCaseSensitive`. No mainstream
  code-fence chord is free, so this one is a deviation and says so.
- Ctrl/Cmd+Shift+Q for a blockquote (Typora, Mark Text) is the macOS system
  Log Out on ⇧⌘Q, which is exactly why both of them use Cmd+Option+Q there.
  GitHub's Ctrl+Shift+. is the same on both platforms, and Shift+. is `>`.

Quote is the one binding that takes a chord away from Monaco:
`editor.action.inPlaceReplace.down`, which has no menu or toolbar presence
here, means nothing in Markdown, and stays in the command palette.

Headings stop at 3 because the app has three heading actions. 4, 5 and 6 are
left unbound rather than given to something else, so completing Typora's range
later moves nothing.

The toolbar hint is the second copy of each of these facts, so it is filled in
too, and the test holds the two together — which it did not for Bold, Italic
and Underline either.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(editor): settle Ctrl+T on new file, wherever the focus is

Ctrl/Cmd+T meant two things (#392). Monaco resolves its keybindings on the
editor's container node and calls stopPropagation() when it consumes one, so
the document-level handler in MarkdownViewer.svelte only ever runs for
keystrokes the editor did not claim. Inside the editor, `file-new` answered
Ctrl+T and opened an Untitled buffer; outside it, the handler opened a Home
tab. Same key, different document, decided by where the caret happened to be.

The issue reported three paths. There are now two: the macOS native menu was
cut back to Settings and Quit by #281 and no longer takes Cmd+T at all, and
this asserts that, so a fourth meaning cannot be added above the other two
without failing.

The app's own labels had already picked a side. The tab strip's + button is
titled "New Tab (Ctrl+T)" and calls `addNewTab`; the app menu prints Ctrl/Cmd+T
beside "New File". The branch was the odd one out, not the labels, which is
what @alecdotdev's "new file seems the common reading" says in #392.

`file-new` also binds Ctrl/Cmd+N, and the document handler knew about only one
of the two — so Ctrl+N did nothing at all outside the editor. Both keys are
handled here now, and the test asserts the two layers agree rather than
asserting either one in isolation.

## The Home tab

`addHomeTab()` had exactly one caller in the app, and it was this branch, so
unifying Ctrl+T does remove the only way to open the home screen AS A TAB.
What it does not remove is the home screen: the app menu's Home item toggles
`showHome`, which renders the same `<HomePage>` with the same recent files,
pinned tags and callbacks. The tab form was already the weaker of the two — it
is dropped on session restore (`hasRealFilePath` is false for the sentinel) and
closed when windows are merged, so it never survived a restart anyway.

`addHomeTab` and the `isHomePath` special cases stay. The sentinel still has to
be recognised for snapshots written by older builds, and #429's render gate is
tested against it. Removing the now-callerless constructor is a separate
cleanup and is deliberately not bundled here.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant