Skip to content

fix(tags): stop two windows from erasing each other's pinned tags - #424

Merged
PathGao merged 1 commit into
masterfrom
fix/pinned-tag-lost-update
Aug 3, 2026
Merged

fix(tags): stop two windows from erasing each other's pinned tags#424
PathGao merged 1 commit into
masterfrom
fix/pinned-tag-lost-update

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The defect

save_pinned_tag and remove_pinned_tag are unsynchronised read-modify-write cycles over one shared file:

let mut tags = read_pinned_tags(&app);   // read the whole file
...                                       // edit the list
crate::atomic_write(&pinned_tags_path(&app)?, json.as_bytes())   // write the whole file

Tauri dispatches commands on a thread pool and every window can call these. Two windows both read the same list, both write back a full copy, and the second write silently drops what the first recorded.

Why atomic_write does not already cover this

This is the natural assumption, and it is the reason the bug is easy to walk past. atomic_write rules out a torn file — temp file, fsync, rename, so no reader ever sees half a JSON document. A lost update is a different failure: both writers produce a whole, valid file, and the second one is simply built from a snapshot taken before the first one landed.

window A: read [x] ─── add "a" ─────────── write [x, a]
window B:      read [x] ─── add "b" ─────────────────── write [x, b]
on disk:  [x]                             [x, a]        [x, b]   ← "a" gone

Atomicity is a property of each individual write. It says nothing about the interval between a read and the write derived from it.

The trigger is not theoretical

savePinnedTagIfNeeded runs from each window's own close handlerappExit, destroyWindowAfterTabsClosed, and the close-requested path in MarkdownViewer.svelte. Quitting two tagged windows with ⌘Q (or a system shutdown) runs both cycles at once. TitleBar.togglePinnedTag and clearTag also fire their invoke without awaiting it.

Same defect class as #405, different fix, and the difference matters

#405 fixed recent-files being clobbered by re-reading live storage instead of serialising an in-memory snapshot. A re-read alone was sufficient there because localStorage is per-document and single-threaded — an RMW cycle is atomic by construction, so there is no interval to protect. Rust commands have no such property. The same shape here needs an explicit lock, not a second re-read.

The fix

The cycle moves into update_pinned_tags, which holds a new AppState.pinned_tags: Mutex<()> across read → edit → write. Same shape and the same lock_recover as the existing window_registry: Mutex<HashMap<…>> — no new concurrency primitive and no async runtime.

save_pinned_tag / remove_pinned_tag keep their signatures; they resolve the path and the state and hand the edit to the guarded cycle. save_pinned_tag_at / remove_pinned_tag_at take the lock and path directly, which is what makes the race testable without a live AppHandle.

Three decisions worth stating

The lock does not cover plain reads. list_pinned_tags stays unlocked. The file is only ever replaced by atomic_write's rename, so a reader racing a writer opens either the whole previous list or the whole next one — both lists Markpad actually wrote. The read is safe; the cycle is not.

Poisoning is recovered, matching the rest of the file. lock_recover already does this for window_registry, startup_files and the watcher map. The argument extends here even though this mutex guards a file: atomic_write publishes by rename, so a panic inside the cycle leaves the pre-existing pinned-tags.json intact — there is no half-applied state for the next holder to inherit. Propagating the poison would instead disable pinning for the rest of the session.

No other Rust command has this shape. Grepped every read_to_string / fs::write / atomic_write in lib.rs, setup.rs, tab_transfer.rs. save_theme, save_file, save_file_binary, save_window_state and the VSIX theme install all write a whole value handed down from the frontend — no read step in Rust, so no cycle. save_window_state shares a file across windows but windowSession.persistState gates on isMainWindow, so only one window writes it. The broker in tab_transfer.rs is in-memory and already Mutex-guarded. setup.rs runs once at install time. pinned-tags.json is the only one.

Tests

concurrent_edits_do_not_overwrite_one_another runs 8 savers and 8 removers × 4 rounds against one file in a private temp directory (never the real app_config_dir) and asserts the property: the final file contains exactly the keep-* pins the writers asked for and none of the doomed-* ones they removed.

It asserts the surviving set rather than any mechanism on purpose. This repo has been bitten twice by tests that were green on macOS for the wrong reason — a race test that passed because the interleaving did not occur, and an is_err() assertion on a branch macOS never executes. The set of surviving tag names is what the user loses when this breaks, and it is checked identically on every platform.

Counter-proof, with the lock removed and nothing else changed (5 runs, macOS/APFS):

pins that survived unpins that stuck
unlocked (master's shape) 1–4 of 8 1–5 of 8
locked 8 of 8 8 of 8

The unlocked runs also failed outright with File exists (os error 17) and No such file or directory (os error 2). atomic_write names its temp file from the target name, the pid and a nanosecond clock reading; two threads of one process that land on the same reading collide on create_new, and the loser's cleanup then deletes the temp file the winner was about to rename. Serialising the cycle removes that exposure for this file too. Recorded in the doc comment.

Two supporting tests: re-pinning a tag updates it in place rather than appending a duplicate, and a writer that panics mid-cycle does not lock out the next one.

Verification

cargo test    139 / 139   (136 before, +3)
cargo clippy  3 warnings  — the pre-existing baseline, none from this change
npm test      540 / 540
npm run check 0 errors, 0 warnings

cargo fmt --check is clean for window_runtime.rs; the repo's 52 pre-existing diffs are all in lib.rs / setup.rs and were left alone.

Not covered

  • Cross-process. The mutex is per-process. Two Markpad processes editing the file would still race; tauri-plugin-single-instance is what makes that not the normal case, and no file lock was added.
  • The other atomic_write call sites. The temp-name collision above is a property of atomic_write, not of this file. It needs two threads writing the same target at the same nanosecond, which for documents means two tabs on one file — narrowed by fix(tabs): one tab per file path #413 and fix(tabs): ask the filesystem whether two paths name the same file #416 but not proven impossible. Reported here rather than fixed; a fix belongs with atomic_write.
  • read_pinned_tags_at's silent fallback. An unreadable or unparseable file still yields an empty list, which the next write then persists. Under the lock this is only reachable through damage from outside Markpad, so the behaviour is unchanged and documented rather than altered.
  • No live multi-window run. Verified by threads against the real functions, not by quitting two tagged windows by hand.

🤖 Generated with Claude Code

`save_pinned_tag` and `remove_pinned_tag` each read all of
`pinned-tags.json`, edit the list in memory, and write the whole thing
back. Nothing serialised that cycle. Tauri dispatches commands on a
thread pool and every window can call these, so two windows can both
read the same list and both write back a full copy - and the second
write silently drops whatever the first one recorded.

`atomic_write` does not cover this, which is the easy assumption to
make. Its temp-file-fsync-rename ruled out a *torn* file: no reader ever
sees half a JSON document. A lost update produces two whole, valid files
in sequence; the second is simply built from a snapshot taken before the
first one landed.

The realistic trigger is not exotic. Each window saves its pinned tag
from its own close handler (`appExit`, `destroyWindowAfterTabsClosed`,
and the close-requested path), so quitting two tagged windows with Cmd-Q
runs both cycles at once. `TitleBar.togglePinnedTag` and `clearTag` also
fire their invoke without awaiting it.

This is the same defect class as #405, which fixed recent-files being
clobbered by re-reading live storage instead of an in-memory snapshot.
A re-read alone was sufficient there because `localStorage` is
per-document and single-threaded, so an RMW cycle is atomic by
construction. Rust commands have no such property, so the cycle needs an
explicit lock.

The cycle now runs inside `update_pinned_tags`, holding a new
`AppState.pinned_tags: Mutex<()>` - the same shape and the same
`lock_recover` poison handling as the existing `window_registry`.
Recovering from poisoning is right here too: `atomic_write` publishes by
rename, so a panic inside the cycle leaves the previous file intact
rather than a half-applied one, and propagating the poison would instead
disable pinning for the rest of the session.

`list_pinned_tags` deliberately does not lock. A plain read of a file
that is only ever replaced by rename returns either the whole old list
or the whole new one, both of which Markpad wrote. The cycle is what is
unsafe, not the read.

Measured with the lock removed, 8 writers x 4 rounds: 1-4 of 8 pins
survived and 3-7 of 8 unpins came back from under a stale snapshot. The
unlocked runs also failed outright with `File exists` and `No such file
or directory` - concurrent `atomic_write` calls on one target can pick
the same temp name (target name + pid + nanosecond clock), and the
loser's cleanup deletes the file the winner was about to rename.
Serialising removes that exposure for this file as well.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao force-pushed the fix/pinned-tag-lost-update branch from ad1957b to 34fdc23 Compare August 3, 2026 07:13
@PathGao
PathGao merged commit a31af8e into master Aug 3, 2026
4 checks passed
PathGao pushed a commit that referenced this pull request Aug 3, 2026
`in_code_region` is a `binary_search_by`, so `code_region_ranges` must
return its regions in document order. It did — by calling
`sort_unstable()` on the last line, after a second pass had appended
every inline code span behind the fenced regions. Deleting that one line
left `cargo test` at 144 passed, while markers inside a fenced block
(`![[embed]]`, `[[wikilink]]`, `==highlight==`, `^[footnote]`, `$x$`)
were reported as prose and rewritten.

The order is now produced by construction: the scan records each plain
segment's inline spans at the moment it closes that segment, immediately
before the fence that ended it, so every push is at a higher offset than
the last. The sort is gone, the `plain_segments` vector is gone, and a
`debug_assert!` names the invariant at its one construction site. Four
tests cover the consequence — one per consumer of `code_region_ranges`.

Also in this change:

- `convert_markdown` captures its parameter as `raw_buffer` before any
  preprocessing runs, and hands that to `annotate_task_checkboxes`. The
  fail-safe only works while its second argument is the unpreprocessed
  buffer, and the natural way to add a step — `let content = ...` near
  the top — silently retargeted it. A source-level test pins the three
  properties the capture depends on; provenance is not a type, so a
  source check is what is available.

- `annotate_task_checkboxes`'s doc comment claimed the frontend "writes
  a `- [x]` marker into whatever happens to sit on that line". That
  describes the pre-#352 frontend. Rewritten to the current behaviour
  and to the two cases that still corrupt.

- `read_file_content` is deleted: no call site since #379, and its
  defining property is that it hides the lossy-decode verdict. Its
  frontend guard was a hard-coded three-file allowlist; it is now a
  whole-tree scan plus an assertion that the command stays deleted.

- `update_pinned_tags`'s comment said `localStorage` makes an RMW cycle
  atomic by construction. It does not — that claim came from #424, this
  project's own recent work — and the passage now states the real
  asymmetry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao deleted the fix/pinned-tag-lost-update branch August 3, 2026 09:25
PathGao added a commit that referenced this pull request Aug 3, 2026
…ort (#434)

`in_code_region` is a `binary_search_by`, so `code_region_ranges` must
return its regions in document order. It did — by calling
`sort_unstable()` on the last line, after a second pass had appended
every inline code span behind the fenced regions. Deleting that one line
left `cargo test` at 144 passed, while markers inside a fenced block
(`![[embed]]`, `[[wikilink]]`, `==highlight==`, `^[footnote]`, `$x$`)
were reported as prose and rewritten.

The order is now produced by construction: the scan records each plain
segment's inline spans at the moment it closes that segment, immediately
before the fence that ended it, so every push is at a higher offset than
the last. The sort is gone, the `plain_segments` vector is gone, and a
`debug_assert!` names the invariant at its one construction site. Four
tests cover the consequence — one per consumer of `code_region_ranges`.

Also in this change:

- `convert_markdown` captures its parameter as `raw_buffer` before any
  preprocessing runs, and hands that to `annotate_task_checkboxes`. The
  fail-safe only works while its second argument is the unpreprocessed
  buffer, and the natural way to add a step — `let content = ...` near
  the top — silently retargeted it. A source-level test pins the three
  properties the capture depends on; provenance is not a type, so a
  source check is what is available.

- `annotate_task_checkboxes`'s doc comment claimed the frontend "writes
  a `- [x]` marker into whatever happens to sit on that line". That
  describes the pre-#352 frontend. Rewritten to the current behaviour
  and to the two cases that still corrupt.

- `read_file_content` is deleted: no call site since #379, and its
  defining property is that it hides the lossy-decode verdict. Its
  frontend guard was a hard-coded three-file allowlist; it is now a
  whole-tree scan plus an assertion that the command stays deleted.

- `update_pinned_tags`'s comment said `localStorage` makes an RMW cycle
  atomic by construction. It does not — that claim came from #424, this
  project's own recent work — and the passage now states the real
  asymmetry.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 3, 2026
* fix(save): make atomic_write's temp name collision-proof

The temp file was named from the target name, the pid and a nanosecond
clock reading. macOS ticks coarser than a nanosecond, so two threads of
one process writing the same target routinely derive the same name — and
the collision took down both writers, not just the loser: the loser of
`create_new` ran `fs::remove_file(&temp_path)` on a path it had never
created, deleting the file the winner was about to rename, so the winner
then failed with ENOENT.

Uniqueness now comes from a process-wide atomic counter, which no two
callers can be handed the same value from, rather than from a clock that
cannot supply it. `create_new` still arbitrates across processes (a stale
temp left by a dead process whose pid we inherited), so an AlreadyExists
retries with a fresh name. The file handle is acquired before `temp_path`
exists as a binding, so no cleanup can reach a file this call did not
create.

Reported in #424, which fixed the pinned-tags exposure by serialising
that file's read-modify-write cycle and left the underlying weakness to
`atomic_write`. Every other caller was still exposed: save_file,
save_file_binary, save_theme, the VSIX theme install, save_window_state
and the image-drop path.

The new test runs 8 threads against one target and asserts every call
returns Ok and the surviving file is one of the values written. Against
the old naming it fails 20 times in 40 runs; against this, 0 in 40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(save): let saveContent disarm the debounce that races it

An explicit save and the 1.5s auto-save timer can be aimed at one tab.
The timer is armed on the last keystroke and disarmed by the auto-save
effect only once `isDirty` goes false, which happens after the write
resolves — so a timer expiring while an explicit save is in flight
starts a second write of the same file, and the two race to the rename.

`atomic_write` was hardened separately so concurrent writers cannot
corrupt the file or fail each other, but that is a safety property, not
an ordering one. If the older snapshot lands last, the disk holds the
earlier text while the tab records the newer one as saved: the buffer
reads clean and stays a revision behind until the next keystroke.

`cancelPendingAutoSave` already existed for exactly this, but was a
call-site duty, and three of the six explicit-save entry points did not
discharge it — Ctrl+S, the toolbar, and the preview task checkbox. It
moves into `saveContent`, past the Save dialog so it never disarms a
tab on a path the user can still cancel, and the four now-redundant
call-site copies are removed.

The discard branch of `canCloseTab` keeps its own, but as scope rather
than as necessity: no `saveContent` is on that path to do it, and while
the auto-save effect would drop the timer anyway once `isDirty` goes
false three lines later, that route rests on effect flush ordering and
on the effect running at all during teardown. A synchronous cancel rests
on neither. Left alone because this change is about the save paths.

Tests assert the ordering (cancel before write, since cancelling after
would leave the timer free to fire during the very await it protects)
and that no call site takes the duty back. Removing the cancel fails 3
of the 4; re-adding a call-site cancel fails the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 5, 2026
)

* fix(titlebar): let the window-tag editor be dismissed without saving

`tagEditorOpen` went false in exactly two places: `applyTag` (Save, and
Enter) and `clearTag` (Remove Tag). `clearTag`'s button renders under
`{#if tabManager.windowTag}`, so on a window with no tag yet — the state
the popover is most often opened in, from Home > Set window tag — Save
was the only control that closed it. Escape did nothing, a click
elsewhere did nothing, `togglePinnedTag` left it open, and the chip was
not a toggle: `openTagEditor` set `tagEditorOpen = true` unconditionally.
Clearing the field and pressing Save happens to close it, because
`applyTag` then calls `setWindowTag(null)`, but nothing says so.

This was an omission rather than a decision, and the evidence is in the
same component: the Home menu, the theme menu and the kebab menu each
take `Escape` on the container element, each appears in
`handleGlobalDismiss`, and each has a trigger that toggles and calls
`stopPropagation`. The tag editor was the only one of the four popovers
missing from all three. So it now follows them rather than inventing a
fourth pattern:

  * `Escape` on the `.tag-editor` container, alongside the `Enter` case
    that was already there.
  * `tagEditorOpen = false` in `handleGlobalDismiss`, and `tagEditorOpen`
    added to the `$effect` guard, which is what installs that function on
    window `click`, `contextmenu` and `blur`. The popover gets an
    `onclick` that stops propagation for the same reason the three menus
    have one — otherwise picking a colour would dismiss the editor.
  * the chip toggles instead of re-opening, and stops propagation. That
    second part is load-bearing: the effect installs the window listener
    before the opening click has finished bubbling, so without it the
    chip would open the popover into its own dismissal. All three menu
    triggers already do this.

Dismissing DISCARDS the draft name and colour; only Save and Enter write
a tag. Checked rather than assumed, and the sources disagree, so the
reasoning is here. The element is `role="dialog"`, and the WAI-ARIA APG
dialog pattern is "Escape: Closes the dialog", with Escape the standard
mapping for Cancel on both desktop platforms. Apple's HIG points the
other way for popovers — save on automatic close, discard only via an
explicit Cancel — but that guidance is written for popovers that edit
live and have no commit control, where closing is the only moment a
value could be kept. This one has an explicit Save, which makes commit
opt-in by construction; a dismissal that also committed would leave Save
with nothing to do and would silently rename the window on a stray
click. What is discarded is a tag name and a colour swatch, never
document content, and `openTagEditor` already re-seeded both fields from
the stored tag on every open — so the discard needed no new code, only
the exits.

The tag editor has two entry points, and only the chip got a
`stopPropagation` of its own. The other is `Home > Set window tag`, which
runs `homeMenuOpen = false; openTagEditor();` with no `stopPropagation`
— and `homeMenuOpen` being true means the window listener is ALREADY
installed when it runs, so a click that reached the window would now
open the popover and shut it again in one gesture. It does not reach the
window: `.home-dropdown-menu` carries `onclick={(e) =>
e.stopPropagation()}`, which is why none of its dozen items need one
each. That containment was untested and is now load-bearing for this
fix, so a test drives the whole path — listener already installed, then
the item's handler, then the container's — and asserts the editor is
still open. Deleting the container's `stopPropagation` fails that test
and only that test. The menu item is left as it is: giving one of twelve
items its own `stopPropagation` would be redundant with the container
and arbitrary next to its eleven siblings.

The same question was asked of every other popover opener in the file.
Only one other opens from inside another popover — the theme trigger,
which is rendered into the kebab dropdown when the title bar is
collapsed — and it calls `e.stopPropagation()` itself, so it is safe
independently of its container. Every remaining opener (the Home, kebab
and theme triggers, and the chip) stops propagation directly.

Not changed: the three menu triggers do not close the tag editor when
they open. They do not consistently close each other either (the theme
trigger closes neither the Home nor the kebab menu), so completing that
mutual exclusion is a change to the existing menus, not a dismiss path
for this one.

scripts/windowTagDismiss.test.ts runs the handlers rather than looking
for them. A `.svelte` file cannot be imported by the Node test runner, so
following homeTabRender.test.ts it lifts the component's real
`openTagEditor` / `applyTag` / `clearTag` / `handleGlobalDismiss`, the
two markup handlers on `.tag-editor`, whatever `onclick` the chip is
actually wired to, the two handlers on the path from the Home-menu item,
and the body of the `$effect`, into one scope over one set of component
variables and the real `TabManager`, then dispatches events along a
modelled bubble path. It asserts nothing about how any of it is spelled.
Five tests cover the new exits. Four are fences that pass on the unfixed
component: Enter still saves, ordinary typing does not close the popover,
a click inside it does not dismiss it, and the Home-menu entry point
still leaves it open. A popover that merely closed on everything would
fail this file.

Reverting only the component leaves those four green and turns the
other five red on their own claims ("Escape left the popover open", "a
second click on the chip left the popover open"), not on a missing
attribute.

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

* feat(tags): show what a window tag covers, and hold each name once

The chip is a coloured pill at the left end of the tab strip — exactly where
Chrome puts a tab-GROUP chip, where a coloured enclosure marks which tabs
belong to the group. Users read it the same way and ask which tabs are in
this one. A window tag is not per-tab at all: `TabManager.windowTag`
(`tabs.svelte.ts:183`) is a single nullable record, `Tab.svelte` and
`TabList.svelte` never look at it, and every tab in the window is under the
tag — including the one opened a second from now. The answer to "which
tabs?" is "all of them", and nothing on screen says so.

Four changes, none of which introduces per-tab membership. Two close
defects; two are judgement calls about the mismatch above and are marked as
such.

DEFECT — the editor could not be dismissed without writing something. Fixed
in the parent commit on this branch: Escape, a click elsewhere and a second
click on the chip now close the popover, and only Save and Enter write a
tag.

DEFECT — two windows could hold one tag name, and the second one to close
silently replaced the first's pinned document set. `save_pinned_tag_at`
finds the entry in `pinned-tags.json` BY NAME and replaces its whole `files`
list, so one name means one document set no matter how many windows carry
it. #424's `pinned_tags` lock fixed the concurrent form of this — two
interleaved read-modify-writes losing an update — and deliberately says
nothing about two serialized writes each replacing a payload the other
wrote, which is the ordinary outcome of two windows sharing a name and
quitting. A regression test now pins that loss down before the rule that
prevents it.

`is_window_tag_taken` answers "does another live window carry this name?"
out of `AppState.window_registry`, which already stores `tag_name` per
window label and which the frontend keeps current through `set_window_meta`.
No new registry: `WindowEvent::Destroyed` already removes the entry, so
closing a window releases its name with no bookkeeping, and the registry is
in memory, so a crash cannot leave a name blocked that nobody holds. The
editor refuses a taken name in place — a line under the field, cleared as
soon as the name is edited — rather than through a toast or a dialog,
because the correction is made in the field the user is already in.

Two decisions inside that rule:

  * Enforced ONLY at Save/Enter, never at session restore. A snapshot
    written by an older build can hold two windows under one name, and
    rejecting it at restore would silently clear a tag the user set, at a
    moment they are not looking at the popover. A restored duplicate costs a
    confusing pair of chips; clearing it costs state with nothing to
    recreate it from. An IPC failure lets the save through for the same
    reason: the check exists to stop one window overwriting another's pinned
    documents, and a broken command is not evidence that it would.

  * EVERY duplicate is blocked, not only pinned ones. This is deliberately
    broader than the mechanism: strictly, the overwrite only bites tags that
    are pinned, and two unpinned windows sharing a name harm no data. One
    rule is easier to explain and to remember than "duplicates are fine
    until you pin one", and two identical chips are confusing either way.

JUDGEMENT — one scope line under the whole tab strip, in the tag's colour,
rather than a per-tab underline. A per-tab underline makes belonging a
property each tab carries, and there is no such property in the data: it
would have to be invented, and then kept in sync with a window-level value
that can change under it. A single strip-level line cannot get out of sync,
and a newly opened tab is on it by construction rather than by anyone
remembering to mark it. The bottom edge was free — `.tab.active` marks the
active tab with `background-color` and nothing else in the strip uses that
channel — so the two markings do not compete. The chip stays where it is and
in the same colour, where it now reads as the line's legend.

JUDGEMENT — right-click the chip opens the same popover left-click does, and
the popover keeps all three of its controls. The visual borrowing above does
NOT extend to Chrome's left/right division, and an earlier revision of this
change that copied it was wrong. In Chrome, left-click collapses and expands
the group, so the group's commands need a second surface to live on. A
window tag has nothing to collapse — it scopes the whole window — so
left-click has no second job, and moving Pin/Unpin and Remove to a
right-click menu would only hide them behind a gesture that advertises
nothing. The split was borrowed without the state that made it necessary.
So Save, Pin/Unpin and Remove stay together in the popover, styled as #452
styled them, and right-click is a second way in. It differs from left-click
in one respect: it opens but never closes, because `openTagEditor` re-seeds
the draft from the stored tag and would discard a name being typed, and
because a gesture whose purpose is "show me the tag's controls" should not
sometimes hide them. It calls `preventDefault` so the platform menu does not
open over the title bar, and `stopPropagation` for the reason the chip's
left-click already does: `handleGlobalDismiss` is wired to window
`contextmenu` and would otherwise dismiss the popover this just opened.

NOT CHANGED — a new or detached window starts with no tag, and now has tests
saying so. `restore()` returns early for any window that is not `main`, the
transfer payload has no tag field, and `set_window_meta` only ever reports
into the registry; nothing reads a registry `tag_name` back into a window.
The tests drive all three paths, and a fence checks that the main window
still does restore its own tag.

Every behaviour above is tested by running it. `windowTagDismiss.test.ts`'s
lifting of the component's real handlers moved into `windowTagEditor.ts` so
that the scope line, the two entry points, the popover's three controls and
the refusal drive the same running control over the same real `TabManager`,
rather than a second copy free to drift. The two CSS assertions are the
exception and say so in place: there is no layout engine in the runner, so
they query the parsed stylesheet for the declarations that decide where the
line is drawn — including that the active tab still marks itself with a
background.

Falsified per part, tests kept. Reverting the scope line alone turns the two
scope-line tests red ("a tagged window drew no scope line", "no rule draws
the scope line"). Reverting the right-click entry point and the popover's
two commands turns four tests red ("the platform context menu was left to
open over the title bar", "right-click closed the popover it exists to
open", "the pin control does not offer to pin an unpinned tag", "a tagged
window was not offered Pin"). Letting a non-main window restore turns the
isolation test red ("a new window came up wearing the saved window tag").
Removing the exclusivity check turns three exclusivity tests red ("the
duplicate name was written anyway"). Dropping the self-exclusion from the
backend query turns `a_window_never_blocks_its_own_tag_name` red ("renaming
the colour of a tag a window already holds must not be refused"). And making
the pin file merge instead of replace turns the two-windows test red, which
is what shows it executes the loss rather than describing it.

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

* fix(tags): unpin a tag whose name is cleared, as Remove Tag does

There are two ways to take a tag off a window, and they disagreed about the
saved session behind it. `clearTag` — Remove Tag — calls `remove_pinned_tag`
first when the tag is pinned. `applyTag` with an emptied name called
`tabManager.setWindowTag(null)` on its own, so the entry stayed in
`pinned-tags.json`: the Home screen went on offering it as a reusable
session under a name no window held any more, and reopening it handed the
window back a tag the user had removed.

Nothing announced this. The tag disappeared from the title bar, which is the
whole visible result of the gesture, and the orphan only showed up the next
time the user looked at Home. It is recoverable — unpin it there — but only
by someone who works out that the two are the same thing.

The empty-name branch now calls `clearTag()` instead of open-coding half of
it, which is also what stops the two paths drifting apart again. `clearTag`
already only asks the backend when `tag.pinned`, so an unpinned tag still
costs no IPC.

Two tests, both running the real `applyTag` against a recording `invoke`:
one that a cleared pinned tag withdraws its session, one fence that a
cleared unpinned tag withdraws nothing. Falsified by restoring the old two
lines: the first goes red on "clearing the name orphaned the pinned session
under a name no window holds", the fence stays green.

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