Skip to content

fix(tabs): one tab per file path - #413

Merged
PathGao merged 1 commit into
fix/reopen-dirty-documentfrom
fix/one-tab-per-path
Aug 3, 2026
Merged

fix(tabs): one tab per file path#413
PathGao merged 1 commit into
fix/reopen-dirty-documentfrom
fix/one-tab-per-path

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

2 / 2. Base: #412 — and it must land first. See "Why this is safe now" below.

The defect

navigate, updateTabPath, renameTab, goBack, goForward and insertTransferredTab all assigned tab.path without checking whether another tab already held it, and openMarkdownTargetInNewTab called addTab unconditionally. So:

  • right-click a link to a file that is already open → Open in new tab, or
  • Save As from an untitled tab onto a file that is already open

…gave you two tabs on one path. Each then carried its own isDirty and its own auto-save timer, and they wrote over each other.

loadMarkdown already de-duplicated, but only on the ordinary open path.

What the invariant should be

Neither VS Code nor Sublime forbids two tabs. Both forbid two buffers.

  • VS Code keys one ITextModel per URI; editors are views onto it, so edits appear in all of them and there is exactly one save path. Two divergent buffers for one URI is not a representable state.
  • Sublime Text: opening an already-open file links you to the existing view. Getting a second tab requires clone_file, which explicitly creates a second view of the same buffer — never a second buffer.

Markpad has no buffer/view split — a tab is the buffer — so the faithful translation of that invariant is one tab per path. Only real file paths are exclusive; several untitled tabs remain normal.

Conflict resolution, by what the loser stands to lose

loser outcome
clean closed — its buffer is a copy of the file the winner now holds, so nothing is lost, and it lands on the reopen-closed-tab stack like any other close
dirty kept, and releases the path — becomes untitled, buffer and title intact. Nothing is discarded, nothing can auto-save over the file behind the user's back, and saving it asks where to put it, which is the one decision only the user can make

Why not "refuse the claim and activate the existing tab"

That is the naive reading of what VS Code does, and here it would be worse than the bug. loadMarkdown captures activeId before calling navigate, then writes the newly read content into that captured tab. Declining the path change leaves the tab pointing at file A while receiving file B's text — and the next save writes B over A.

Why this is safe now, and was not before

De-duplicating means "open link in new tab" resolves to an already-open tab and hands it to a loader that previously would have written disk content into a brand-new one. If that tab has unsaved edits, the de-duplication is what makes them reachable.

#412 closes that. The receipt is a single test, deliberately narrowed to one assertion so its verdict is unambiguous:

state following a link into a new tab never costs the target tab its unsaved edits
master greenaddTab makes a second tab, so nothing is overwritten
this PR without #412 red ← the trade
#412 alone green
both green

I ran that third state explicitly rather than reasoning about it.

Tests

scripts/tabPathIdentity.test.ts (10) plus one assertion added to reopenDirtyDocument.test.ts.

vs #412 8 red / 14 green

By file: tabPathIdentity 7 red / 3 green — the three greens are the don't-over-fire boundaries (untitled tabs do not collide, the HOME sentinel is a singleton on its own terms, and "re-opening doesn't overwrite edits", which passes on #412 precisely because #412 landed). reopenDirtyDocument 1 red / 11 green, the red being the new tab-identity assertion.

npm run check   435 files, 0 errors
npm test        500 / 500
cargo test      131 / 131

Not covered

🤖 Generated with Claude Code

`navigate`, `updateTabPath`, `renameTab`, `goBack`, `goForward` and
`insertTransferredTab` all assigned `tab.path` without checking whether
another tab already held it, and `openMarkdownTargetInNewTab` called
`addTab` unconditionally - so "open link in new tab" on an already-open
file, or "Save As" onto one, produced two tabs on one path. Each then
carried its own dirty flag and its own auto-save timer, and they wrote
over each other.

Neither VS Code nor Sublime forbids two tabs; both forbid two buffers.
VS Code keys one `ITextModel` per URI and lets editors be views onto it;
Sublime's `clone_file` explicitly makes a second view of the same
buffer. Markpad has no buffer/view split - a tab *is* the buffer - so the
faithful translation of that invariant is one tab per path, which is
also what `loadMarkdown` already did on the ordinary open path.

Conflicts are resolved by what the loser stands to lose. A clean loser is
closed: its buffer is a copy of the file the winner now holds, so nothing
is lost, and it lands on the reopen-closed-tab stack like any other
close. A dirty loser is kept and releases the path instead, becoming
untitled with its buffer and title intact - nothing is discarded, nothing
can auto-save over the file behind the user's back, and saving it asks
where to put it, which is the one decision only the user can make.

Refusing the claim and activating the existing tab - the naive reading of
what VS Code does - would be worse than the bug: `loadMarkdown` captures
`activeId` before calling `navigate` and writes the newly read content
into that captured tab, so declining the path change hands one document's
text to a tab pointing at another file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 206c8cd into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the fix/one-tab-per-path branch August 3, 2026 04:40
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