cf2854a0291012525f5d4151367c9f2135d2df95
311
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cf2854a029 |
ktlint: a multiline .border() left the next '.' orphaned, exactly as #3110 records
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m2s
`standard:chain-method-continuation` on `LinkPreviewRow.kt:83`. The `.border(…)` call took three arguments across four lines, and the `.padding(…)` after it then began a line with a `.` — which the rule only accepts glued to the closing paren, `).padding(…)`. Issue #3110 hit this same rule in `NoteCard.kt` and recorded the fix: do not write the multiline element. Naming `shape`, `padH` and `padV` first collapses `.border` back to one line and removes the duplicated RoundedCornerShape at the same time, which is better than what ktlint was willing to accept. Also did what #3110's verification note says to do rather than fixing only the line the linter named: scanned every Kotlin file this branch touched for the same shape — a multiline chain element followed by a `.` on a new line — and found no others. ktlint reports one violation and stops, so a second would have cost another full Android lane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
62338bb0a4 |
android: a link in a note renders as a link card, not a bare URL
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 11s
Android / Kotlin + Rust (APK) (push) Failing after 3m33s
The web and desktop have shown link previews since #2898; the phone showed the raw address. The data was already on the device — `Note.previews` is populated by the core and carried through the FFI — and nothing under `app/src/main` read the field. The three presentation rules are copied from `NoteCard.vue` rather than re-decided, so the same note reads the same way on every surface: * A note that is NOTHING but a URL renders as its preview and nothing else. Printing the address under a card that already says where it goes is saying the same thing twice, badly. * Links mentioned INSIDE a note get a compact strip at the FOOT of the card. Above the body would put a stranger's headline where the note's first line should be; the web learned that in M13. * Several stack. `LONE_URL` mirrors the web's `LONE_URL_RE` including the tolerated whitespace — if the two regexes disagree, one note reads as a card here and a paragraph there. Falling back to the URL is deliberate in all three of the cases that produce no preview: not a lone URL, not unfurled yet, or never unfurlable. A note written on the phone and not yet synced is permanently in the middle one, because the unfurl is server-side (`unfurl_queue.py`) and arrives on a later pull — so that state has to look deliberate, and showing the link does. No unfurl fetch was added here, and none should be: a phone fetching OG tags would be a second SSRF-hardened fetcher on the surface least able to afford the call. ## No image, and that is a question rather than an omission `LinkPreview.image_url` is a REMOTE third-party address — the web renders it straight from whatever host the link points at. Matching that here would have this app fetch images from arbitrary hosts, on a phone, on possibly metered data, and would make it the first image loading anywhere in this client: there is no loader, no cache, and not one `Image(` in the whole app today. That is a decision about privacy and data use, not a rendering detail, so the text card ships and the image is asked about rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
1a41373347 |
board: a note trashed from search results now leaves the results
Search for something, long-press a hit, Move to trash: the snackbar said it happened and the card sat there until the query next ran. Reachable from the editor's overflow too — both go through `mutate`. `mutate` kept the existing list whenever a search was running, with the reasoning recorded in place: search results are the answer to a query, not a live view, and running the BOARD query underneath them would replace the hits with the whole board. That is right about the board query and wrong about the note. A hit that no longer matches has left the answer, not just moved within it — pinning one and watching it not re-sort is fine; trashing one and watching it stay is not. So the search is re-run instead of the destination loaded. The results are still the answer to the query, just a current one, and it costs one local SQLite query — the same argument the surrounding comment already makes for reloading the board. Creating a note while searching still leaves the list alone: a new note that does not match the query has no business appearing in its results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
729d0dadf1 |
editor: collect the refund — the web editor autosaves on an idle pause
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2971's engine work was already done and its benefit was never taken up here. Both engines coalesce revision snapshots to one per editing session — `src/thoughtsync/revisions.py::should_snapshot` and `store.rs`'s namesake, the server's applied on the PATCH path AND in `sync.py`, with four integration tests covering it. So a write has cost a write, not a write plus a revision, for some time. But this editor still wrote only on `close()`. That save-on-close existed BECAUSE writes were expensive; with the reason gone, all that was left was the cost — a tab closed mid-paragraph lost the paragraph, which is the one thing a notes app must not do. Android already debounces (`BoardViewModel`); the shared Vue editor did not, so web and desktop kept paying for a trade that had been cancelled. Now: a 1s idle pause writes. EDIT MODE ONLY, deliberately. In compose, `dismiss` discards a note that was never persisted so an accidental keystroke or a type-to-compose never litters the board. An autosave there would create the row and quietly take that behaviour away. Materialising a compose on first keystroke is a separate decision (#2967), not a side effect of this one. Three details that decide whether it is safe rather than merely present: * `flush` returns without writing while a save is in flight, so an autosave landing there would silently drop everything typed since that save began. It RE-ARMS instead of skipping. * Errors are swallowed and retried on the next pause. An autosave that interrupts typing with a message is worse than one that waits, and `close` still surfaces a real failure where the person is looking. * The timer is cancelled by `close`, by `dismiss` and on unmount, so nothing fires through a component during its leave animation or after it is gone. Checked and found harmless rather than assumed: `notes.reconcile` replaces the store's item but never touches `useNoteEditor`'s `editing` ref, so the `watch(() => props.note)` that calls `setBody` does not fire on a save. Were that not true, autosaving would have reset the field and the caret every second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
23a61365da |
capture: the suggested shortcut is a UI affordance, so it lives in the UI
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Successful in 17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m31s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`-D warnings` failed the Linux lane on `constant SUGGESTED is never used`, and it was right — the suggestion is implemented in `bridge.ts` as SUGGESTED_CAPTURE_SHORTCUT, and nothing in Rust ever read the copy here. Deleted rather than exposed through a command. This side accepts any combination the OS will take; picking one to put in front of someone as a starting point is a UI decision, and a constant here would only be a second copy of a string one layer reads and the other does not. Worth noting what this run DID prove, since the previous one proved nothing: the lockfile gate passed and the Windows job built the NSIS installer end to end. So `tauri-plugin-global-shortcut`'s handler signature — the thing I could not verify without a toolchain — is correct, and the feature compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
6c0153be1e |
desktop: a global hotkey opens a small window to write in, now with its lockfile
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m22s
Restores |
||
|
|
10ea15bef0 |
Revert the desktop hotkey: a new crate needs a Cargo.lock this machine cannot write
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`42e06da` added `tauri-plugin-global-shortcut` to Cargo.toml without updating
Cargo.lock, and every cargo invocation in CI passes `--locked`. Both desktop
jobs failed on the same line before compiling anything:
error: cannot update the lock file ... because --locked was passed
So this says nothing about whether the code is right — clippy never ran. The
gate did exactly its job.
There is no Rust toolchain on this workstation (rule 10 — CI verifies), and a
lockfile is the one artifact CI is deliberately forbidden to generate. Hand-
writing the entries is not a real option: it needs the exact checksum and the
whole transitive tree, and a wrong checksum fails harder than a missing one.
Reverted rather than left red, because a red `dev` blocks everything behind it
and the Android half of #1899 is green and unaffected at
|
||
|
|
42e06da576 |
desktop: a global hotkey opens a small window to write in, and nothing else
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 35s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 35s
The other half of #1899. Press the combination anywhere and a 520x220 window arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The board never comes forward, which is the whole point — bringing the app up to write one line is the friction this removes. ## There is no default shortcut, deliberately A global shortcut is the one setting here that can collide with software this app knows nothing about. Any default is a key combination taken away from something on somebody's machine, silently, at install time. So the feature is OFF until a combination is chosen, and choosing one is how it turns on. CommandOrControl+Shift+N is offered as a one-click suggestion, never applied on the user's behalf. ## Stored and live are reported separately `CaptureShortcut` carries both `shortcut` and `registered`, because they genuinely disagree: a combination another app grabbed first is saved and does nothing when pressed, and on Wayland a compositor may refuse global grabs outright. Saying only "your shortcut is X" would be a lie with a keystroke attached, so the settings row says "saved but isn't active — something else is holding it". `capture_shortcut_set` registers BEFORE storing, so a combination the system refuses is never written down as though it worked. Registration at startup is best-effort and logged: a shortcut that worked when it was chosen can be taken by something installed later, and the app must still open. ## Two windows, one database, no shared store The capture window runs a second copy of the frontend with its own Pinia stores, so a note saved there is invisible to the board until it is told. It is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The emit failing is cosmetic (the note is already in SQLite) so it is logged, not raised. The window is opened at `index.html?capture=1` rather than at `/capture` because the bundled assets are served as FILES: a path with no file behind it 404s in the production build while routing fine under the dev server. The router turns the query into the route. It is hidden rather than closed on the way out, and it keeps its text. A capture interrupted by something more urgent is still there on the next press, which is what makes Escape safe to press. A failed save also keeps the window open holding the text — hiding it would throw away the only copy of something just written in order to report a problem you could retry your way out of. ## Where the setting lives Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in the desktop's Sync screen beside the update channel, not in admin Settings: that screen is the SERVER's and bounces on desktop anyway, while this is a property of one installation on one machine. Persisted with the same `store::set_pref` the update channel uses. No @tauri-apps/api dependency was added — everything routes through `invoke` and the `withGlobalTauri` global, as the rest of the bridge does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
c8318c323a |
android: Share → ThoughtSync, and a "New note" entry in the selection toolbar
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m21s
Capture without opening the app first — the input half of #1899. Two ways in: the share sheet from anywhere, and the text-selection toolbar in any app's text field. ## The note is created, not pre-filled The obvious build is "open the editor on a draft holding the shared text". That silently loses it. `NoteEditorScreen`'s flush is guarded by `bodyText != note.body`, so a draft handed the text already has nothing to save — share a link, press back without typing, and it is gone. Which is exactly the shape of a share: the common case is walking away. So `captureShared` makes the row first and opens the editor on the real note. A share has already said "keep this"; creating it is what honours that, and back then leaves a saved note rather than a decision. ## launchMode="singleTop" The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own intent, which is why `onNewIntent` already worked there. A share intent is built by the OTHER app and nothing here can add a flag to it, so the activity has to declare it. Without that, every share while the app was running would stack a second MainActivity — a second view model, a second board, and a back press landing on a stale copy of the same app. ## Subject and text, both A browser sends EXTRA_SUBJECT as the page title and EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because the core names a note by its first line — the difference between a board you can scan and a column of identical links. `distinct` because plenty of senders put the same string in both. The extras are removed on read, like the reminder's note id and for the same reason: the activity keeps its launch intent, so without consuming them a rotation would replay the share and mint the note again. ## Not included: images `image/*` is deliberately absent from the filter. Nothing in this app can create an attachment — the core has `delete_attachment` and no counterpart, and the FFI exposes neither. Declaring the mime type would put ThoughtSync in front of people in the share sheet for a job it cannot do, and fail after they had already chosen it. Adding it needs an attachment-creation path through the core, the FFI and sync, which is its own piece of work. The desktop half of #1899 — a global hotkey — is not in this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
cc50812a86 |
ktlint: the Tags imports landed after SyncScreen, and SyncState sorts after that
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m8s
`standard:import-ordering`. The two new imports were inserted by anchoring on `com.fabledsword.thoughtsync.ui.SyncScreen`, which looked like the right neighbour and is not — `SyncState` and `SyncViewModel` both sort after it, so Tags* wedged into the middle of the Sync block. Moved below `SyncViewModel`. Every import block in the five files this branch touched is now confirmed sorted, not just the one ktlint happened to reach first — it reports one violation and stops, so a second would have cost another full Android lane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
1e54b80f15 |
android: a Tags screen, so the phone can do more than attach tags to a note
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m28s
Android could list tags and mint new ones. It could not rename, recolour,
delete or merge one — and since the per-note colour picker was removed with
2949, tag colour is the ONLY colour control in the product, which meant an
Android-only session had no way to change any colour anywhere.
A destination reached from the drawer, not a modal. The web's LabelsModal is
a modal because a desktop can float one over the board; on a phone this is a
place you go to tidy up, and a full screen is what that is.
The manage entry is an action ON the drawer's Tags header rather than a row
in it, so it cannot be mistaken for a sixth lens. The header now renders even
when there are no tags: this screen is where you make the first one, and
hiding the way in until one exists is a door that only appears once you are
already inside.
## The two calls this needed
RENAME and MERGE deliberately do not follow the same rule, and the screen
says so rather than hiding it.
* A rename that lands on an existing name merges, older survives (3324).
That path is accident-prone — it is a text field, and a typo reaches it —
so it needs a rule that cannot depend on which way round it was typed.
The screen catches the collision against the LIST, not from what the core
returns: the survivor may be the tag being renamed, so an unchanged id
afterwards proves nothing. Then it asks before merging.
* An explicit merge keeps its direction. Here the person is choosing, and
the direction IS the intent — folding #grocery into #groceries is a
decision, and overriding it with age would refuse the thing they asked
for. The price is that the direction has to be unmissable, so the body
names the tag that stops existing and every row offered is the survivor.
Delete quotes the note count, because "it is on 40 notes" is a different
decision from "delete this tag?". The count comes from `list_labels`, the
only call the core populates one on. It also says that a tag written as #tag
in a body comes back on that note's next edit — deleting the row cannot
un-write the word, and that is better said than discovered.
## The board had to learn something
`Destination.WithLabel` holds an id, and deleting or merging a tag the board
is currently LOOKING at would strand it on a lens that queries a row which no
longer exists — permanently empty, escapable only via the drawer. So
`loadLabels` became `refreshLabels`: public, and it drops back to Notes when
the current lens is gone. A failed listing deliberately does NOT trigger that
fallback — "I could not read the tags" is not evidence that this one went.
Reused rather than rewritten: `ErrorBanner` (the board and editor already
share it), `MenuItem` from Panel.kt (it closes the menu before acting so a
dialog cannot open under a hanging menu), `PlainTextField`, and the
`NOTE_TINTS` palette — the screen consumes it and does not fork a copy.
`default` stays in the palette on purpose: a tag with that colour gets a hue
derived from its name, so it means "let it pick", and removing it would leave
no way back to that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
|
||
|
|
550a34d8e2 |
fmt: rustfmt budgets macro arguments at 60 chars, not the 100-char line
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m2s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
Both new assertions fit well inside the 100-column limit and both were still rejected. The governing setting is `fn_call_width` (60), applied to a macro's argument list: `survivor.id, older.id, "the older row is the one that survives"` is 62 characters, so rustfmt breaks it and pairs the two values on one line with the message beneath. The neighbouring `assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked")` was accepted at 59 characters of arguments, which is the same rule agreeing rather than a different one. rustfmt's own output, pasted back. Second time this lane has caught the same class of thing in one session — the other was a method chain, budgeted at 60 by `chain_width`. Recorded so the next person reaches for the 60, not the 100. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
193dfb9e94 |
tags: renaming onto an existing tag merges them, and the older row survives
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.
I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.
That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.
Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.
* `store::rename_label` and the server's PATCH now implement one rule.
THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
one that already held the name", so that renaming A→B and B→A land on
the same survivor; otherwise the outcome depends on which way round
someone typed it, and two devices tidying the same pair disagree about
which id still exists. Ties go to the incumbent, so it stays
deterministic.
* The core reuses `merge_labels` rather than reimplementing the move. That
is the only place that knows to mark every affected NOTE dirty before
the delete cascades the membership rows away, which is what makes a
merge reach the server at all.
* The server's rename and its `/merge` route now share one `_merge_into`
helper, for the same reason.
* Both server lookups became case-INSENSITIVE, matching every client. The
create path is included: it was the one actually minting the unstorable
pair, so fixing only the rename would have left the door open.
* The web asks before merging, naming both note counts. A merge cannot be
undone by repeating it and is now reachable by a typo in a text field —
the same reasoning as the delete confirmation in #2116. The confirmation
lives in the shared store, so the desktop gets it too; the FFI does not
ask, because that belongs to the surface with a person in front of it.
* The web store detects the merge from the LIST, not the response: the
survivor may be the row we asked to rename, so an unchanged id proves
nothing.
Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.
Also fixes a straggler from
|
||
|
|
8c7553d619 |
copy: the product says "tags" now, and the schema keeps saying Label
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Two words for one concept cost real comprehension: over a single exchange
the operator concluded that auto-tagging did not exist (it does, in
`derive.rs`) and that a tag-management view did not exist (it does,
`LabelsModal.vue`). The `#` is how most of these get made, so the `#` wins
the noun.
User-visible strings only, on all three surfaces plus the server's errors.
`Label`, `NoteLabel`, `via_tag`, `label_id`, the tables, `/api/labels` and
the FFI names are all untouched — renaming those touches migrations and the
wire format to buy nothing a reader can see.
Two of these were more than a find-and-replace:
* Android's `label_from_tag` said "from #tag", sitting beside a chip that
already renders as `#name`. Once every one of them IS a tag that hint is
circular. What it actually tells you is that the note's BODY owns this
one — which is why it alone has no remove cross — so it now says "from
the text".
* The web's empty state said "No labels yet — create one above" while
Android's already mentioned the `#` route. The web now says it too. That
is the exact fact the operator did not have.
The paired `aria-label`s went with their `title`s; a screen reader saying
"label" while the tooltip says "tag" is the same confusion with a smaller
audience.
Left alone deliberately: `json_error("invalid label")` and
`"label_ids must be a list"` in `notes/__init__.py` name the `?label=` query
parameter and the `label_ids` request field. Those are wire surface, not the
word a person reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
|
||
|
|
d838b27518 |
ffi: Kotlin could list and create a tag but never rename, recolour, delete or merge one
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m0s
`core/src/local/store.rs` implements all seven label operations. The uniffi object exposed three of them, so Android could attach tags to a note and mint new ones, and could do nothing else with them ever. The four additions are pure passthrough, because reading the store showed both of the things #2963 said to check rather than assume are already handled there: * The note count exists. `Label` carries `count: Option<i64>` and `list_labels` computes it per row, excluding trashed notes — which is the number a delete confirmation should show. The single-label returns all end in `load_label` and leave it `None` on purpose, so a screen must read counts from the LIST and never from an operation's result. * Sync is free. `rename_label` and `set_label_color` set `dirty = 1`; `remove_label` records a pending delete; `merge_labels` records one for the source AND marks every note that carried it dirty before the delete cascades the membership rows away, because push sends `label_ids` per note. So no store change, no sync change, no count plumbing — the binding only. One divergence found and documented rather than fixed: renaming a tag onto an existing name is a 409 on the server (`labels.py:94`) and a silent duplicate in the local store. The desktop has always had this, calling the same `store::rename_label`; Android now inherits it. Deciding which side is right belongs with the screen (#2964), not with the binding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
a69159e562 |
fmt: rustfmt breaks the tuple-index chain, and the desktop lane means it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m21s
`cargo fmt --all --check` failed the desktop lane on one hunk in the new `client_headers_identify_app_and_protocol` test. Clippy and every test passed; only the formatter objected. rustfmt splits `client_headers()[0].1.starts_with(..)` across lines because an index followed by a tuple field followed by a call is a three-element chain, and it will not keep one on a single line inside a macro argument regardless of width. This is rustfmt's own output, pasted back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
c40916699b |
sync: the client header said "desktop" from every phone, and named the wrong version
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m37s
`client_headers()` built `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and both
halves were wrong.
This crate is compiled into the Android app as well as the desktop one, so
every phone in the field announced itself as a desktop. And CARGO_PKG_VERSION
here is the CORE crate's version — a number no build stamps and no user has
ever seen — where the thing a reader of that header wants is the app's own
build (note 3127 §5: with no version tags, the artifact's self-report is the
only answer to "which build is this?").
The core cannot know either value, so the host says them. `set_client_agent`
is a OnceLock the desktop fills in `run()` and Android fills in
`ThoughtSyncApplication.onCreate`, before anything can sync. A host that never
introduces itself sends `thoughtsync-unidentified/unknown` rather than a
plausible default: nothing reads this header today, which is exactly why a
wrong value could sit in it for months — the first person to look at a server
log is the first who could catch it, and only if what they see is obviously a
host that never said who it was.
Android's version comes from the INSTALLED package, through a new
`Context.installedVersionName()` that the foot of the Sync screen now shares.
One answer to "which build is on this phone", so the line a person quotes in a
bug report and the line in the server's log cannot disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
|
||
|
|
ef418a8c92 |
buttons: one definition of the shape, worn by a <button> and by an <a>
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The download links added in
|
||
|
|
fd1e4ae487 |
downloads: five clients, and the page leads with the one that fits you
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The Account page offered the APK and nothing else, because the APK was all the server held. Step 3 baked in four more, so the single card had to become a section — and five artifacts is exactly where a downloads page turns into a table of filenames and stops being a product. So it LEADS with what fits the machine asking, from the user agent, and keeps the rest quiet but visible. A wrong guess costs nothing: nothing is behind a disclosure and every other client is one click away. Linux gets all three at once, because the UA says "Linux" and nothing about dpkg or pacman — there is no better answer available. They are named for the distro rather than the package format, since a person knows which system they run and not necessarily which packaging it uses. The AppImage carries one clause of its own: it is 95 MB against 3, and it is also the only bundle that updates itself in place. Both facts belong to the same decision. macOS and iOS lead with nothing and say so. There is no build for either, and "There's no macOS build yet" is the difference between deliberate and broken. The version renders `unknown` rather than blank, and the download stays offered — not knowing which build it is, is not a reason to withhold it. Two things this did NOT do, both deliberate: The task asked for a Tauri case — do not offer the desktop app to someone already running it. That case cannot be reached: `/account` redirects to the board in the desktop app (requiresServer, router/index.ts), because device tokens are a server-side concept. A branch for it would be dead code. `.btn-link` mirrors BaseButton's declarations rather than replacing them. BaseButton is a <button> and cannot carry an href, and unifying the two would have put every button in the app into an operator pass that CI cannot check — for a cosmetic gain. The comment names the pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
8a75e5f340 |
clients: an unquoted 1.0.3504551 is not JSON, and every sidecar was one
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m18s
`fetch-clients.sh` wrote `"version_code": %s` unquoted, which was right when the only ordering key in sight was Android's integer. The desktop's is Tauri's `1.0.<minutes>`, and unquoted that is not valid JSON at all — so `json.loads` raised on all four generated sidecars and the server advertised nothing. A silent zero, not an error: `_read` treats a malformed sidecar as "no client here", which is right for a corrupt drop-in and indistinguishable from this. Caught by running the real fetch against the live dev channel and feeding the result to the real resolver, rather than by reading the printf. Also makes `_resolve` wrap BOTH candidate roots in Path(). Only the first was, and the asymmetry fails the same quiet way: a str `/` str raises TypeError, `_read` catches it, and a perfectly good directory reads as empty. The whole pipeline now resolves end to end against the live channel — five of five platforms, every sidecar valid JSON, one human-readable version across all of them with each artifact keeping its own comparator type: android 2026.08.30.1711 code=3504552 57.6 MB linux-appimage 2026.08.30.1711 code='1.0.3504551' 95.3 MB signed linux-deb 2026.08.30.1711 code='1.0.3504551' 3.3 MB linux-pacman 2026.08.30.1711 code='1.0.3504551' 2.7 MB windows 2026.08.30.1711 code='1.0.3504551' 2.6 MB |
||
|
|
d2f9d316cf |
tests: 300 comes back as "300" from a platform whose key is not an integer
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 41s
The precedence test wrote `version_code=300` for all five platforms and compared the desktop's against the int it wrote. It comes back as `"300"`, because the module preserves each platform's own comparator type instead of flattening both to int — which is the behaviour the change it was testing had just introduced. A `coded()` helper now says which shape to expect and why, and the assertion runs over every non-Android platform rather than spot-checking `linux-deb`. The test caught a real inconsistency in itself precisely because it compared against a concrete value rather than round-tripping what it wrote. |
||
|
|
ff6e99eb62 |
image: bake every client in, not just the phone
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product being complete costs (rule 23): a self-hoster gets a working app for their machine from the server holding their notes, with no account on a forge that is private. The AppImage is not optional within that — it is the only bundle that can replace itself in place, so a server without one cannot serve in-app updates to anybody. `packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names and sidecars `client_dist.py` reads. It never fails: a platform with nothing published means the server advertises nothing for it and the UI hides that download, and eight fetches must not become eight ways to redden a green lane. THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been wrong the easy way. The obvious shortcut is `version.sh display desktop` in the image job — it has the checkout. But this commit may not be the commit the channel is serving: a push touching only `src/` does not rebuild the desktop, so the channel still holds an older build and a locally-derived version would describe those bytes with this commit's number. `client_dist.py`'s size check could not catch it, because size IS measured from the real file — it would sail through and lie about the version alone. So `write-manifest.sh` now publishes `thoughtsync-desktop.json` beside `latest.json`, from the same two values in the same breath, and only size/sha256 are measured at bake time. Which needed the prune's keep-list, or the sidecar would have been uploaded and deleted again in the same run — a fixed name is self-limiting, which is exactly why that list exists. `version_code` is NOT uniformly an integer, and coercing it was a leftover from the days when Android was the only platform. Android's must stay a JSON number: `ClientRelease` in core declares it `i64` and a string fails to deserialize on every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` — the value its updater actually compares — and `int()` would have rejected every desktop sidecar CI writes. The table now says which is which, and tests pin both directions. Also retires the comment above the fetch step, which claimed the APK came from "always the rolling dev release" and mentioned `:<version>` images. M314 step 3 made the channel conditional in the code directly below it, and step 6 removed version-shaped image tags entirely. Verified against the live dev channel before pushing: the Android half resolves and exits 0, the desktop half degrades with a warning because the sidecar does not exist yet, and all five constructed bundle filenames return 200. |
||
|
|
ef8aa9340f |
clients: the server hands out five platforms, not "the Android client"
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 30s
`client_dist.py` was written for one platform and everything structural in it was already right — drop-in beats baked, the pair must describe one build, absence is an ordinary answer, metadata public and bytes authenticated. This widens it to a table rather than building beside it. Its own docstring made the argument years before there was a second platform: a self-hoster should not need an account on someone else's forge to get the app for their own notes. Server side only. CI bakes nothing new until step 3 and the UI reads nothing new until step 4, so this lands green and inert. Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each naming its artifact, sidecar and mimetype. Fixed filenames, version only in the sidecar: a version-stamped name would force a glob, and a glob over a directory an operator drops files into is how you serve the older of two builds, which is the failure write-manifest.sh already carries a comment about. THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames, clients in the field poll /api/client/android, and `android_client` stays on /api/config beside the new `clients` map. Renaming them to match the pattern would buy tidiness and strand every installed phone; retiring the key belongs to a later change made when nothing polls it, not to the change introducing its replacement. Fields were added, not moved — `ClientRelease` in core is a plain serde struct and ignores what it does not know. PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First directory holding anything wins" would mean dropping in an APK silently retracts the four desktop downloads. Pinned by a test. The AppImage needs a third file. It is the only bundle that replaces itself in place, so the updater verifies a minisign signature before it does — and a bundle that cannot be verified cannot be offered. A missing or empty `.sig` therefore makes it absent rather than merely unsigned, and the signature travels WITH the version so an updater can never pair one build's version with another's signature. The tests parametrize over the table instead of testing Android and trusting the rest. The bugs this module can have are not platform-specific, and a suite that only exercised one platform is how the other four would ship untested. |
||
|
|
f992439588 |
version: every surface can say which build it is, and two of them were lying
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.
The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.
Two of the values it was going to show were wrong, which is the part worth
knowing about.
The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).
The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.
Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.
Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.
build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
|
||
|
|
544cf72735 |
install: the stable fallback is dead now that stable publishes its own bundles
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
It existed for one window: `stable` was a manifest-only pointer at whatever `v*` tag had last been cut, and `stable` is the DEFAULT channel, so without the fallback `curl … | sh` was broken for everyone between step 3 landing and the first merge to `main`. That merge happened (`b6673c6`), and `stable` now holds its own signed bundles at 1.0.3503145 — AppImage, deb and pacman, all resolving by the one lookup both channels share. Kept as a fallback it stops being a safety net and becomes a mask: the branch only runs when `stable` has no bundles, which from here on means something is broken, and chasing a `v*` release instead of saying so is the wrong answer. The header now says the transition is finished and that neither channel should be special-cased again, because the shape of that code invites re-adding it. |
||
|
|
6e524ec616 |
guard: an empty channel killed the lane instead of passing it
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m46s
The first merge to `main` took the Android lane down (run 4857): the decide job exited 1 in 0.16 seconds with no output at all, and the image build skipped behind it because a failing lane must not publish. `stable` had never published an APK, which the guard treats as a pass — there is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch of its own. That branch was unreachable. `published="$(published_for ...)"` under `set -e` dies on the substitution before it, and everything the pipeline would have printed goes into the capture rather than the log. What decided which lookups had the bug is the last command in the pipeline. `sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`. Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one — and only on a channel with nothing on it, which is why a week of dev pushes never saw it. The tests now reach the half of the guard that talks to a feed, with `curl` shadowed on PATH so they stay hermetic: an empty channel passes and builds, a lower published version passes, a higher one fails the lane, and an equal Android code is refused because Android will not install it. |
||
|
|
c2fdc05e5c |
release: a tag builds nothing and carries a changelog instead
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Step 7 of M314, the last one. Rule 22 — the old path comes out completely. ## A release stops building `desktop.yml` no longer triggers on `v*`, and its two `Publish release` steps are gone. `ci.yml` lost its tag trigger in step 6. So a tag now reaches exactly one lane: the new `release.yml`, which builds nothing. That is not a simplification for its own sake. The merge to `main` already published everything a user can receive — `:latest` + `:<sha>`, both channel feeds, the updater manifest. A tag rebuilding that source produces identical artifacts under identical names and re-pushes `:<sha>` with different bytes, which rule 145 forbids even when they match. ## So what a release is FOR The changelog (note 3127 §5). Two halves to "what am I running", and the version answers only the first: which build is this (the footer, /api/config, the APK's versionName) and what is in it that was not in the one I ran last month (nothing, until now). `packaging/release-notes.sh` derives it from git rather than a hand-maintained CHANGELOG, which drifts into recording what someone MEANT to ship. Capped at 60 entries with the omitted count stated — the first dated release spans 181 commits since `v0.1.0`, and a truncated list that does not say it is truncated is a lie. It publishes through `publish-release.sh` rather than making its own API calls, for the create-or-PATCH-on-409 path: a fixed-tag release that only ever POSTs keeps whatever body its first run wrote, which is #2182, and reimplementing that correctly in a second place is how it comes back. ## Retired `MANIFEST_TAG` and the whole branch behind it. It let the manifest live on a `stable` pointer release while the bundles sat on a versioned one — a split step 3 removed when `stable` started holding its own bundles. Nothing had passed it since; a parameter that can only ever receive its own default is a branch nobody exercises and a comment that goes stale, and its stale text was still telling readers the installable builds live on the versioned releases. `desktop/src-tauri/Cargo.toml`'s version and `thoughtsync/__init__.py`'s both now say out loud that they are not shipped values. The Cargo one carries the history worth keeping: the old scheme took its base from that line, so `0.2.<run>` on dev outranked a bare `0.2.0` on main, and the remedy was "remember to bump the minor before tagging" — documented in a comment, enforced nowhere. #2183 is what that looked like in the field. **That ritual is now formally dead**, and this is the deliberate act of killing it rather than a side effect. ## Still there on purpose `install.sh`'s transitional stable fallback. It cannot go until `main` has published to `stable` at least once, and that is gated on an operator request. Removing it now would break the DEFAULT install channel. #3147 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fa43c2f4e9 |
ci: a docs-only merge to main produced no image, so no :<sha> for that commit
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 17s
Rule 145 promises every push to `main` publishes a `:<sha>`, so any production
commit is addressable without a release ceremony. `ci.yml`'s `paths:` filter
quietly broke that: a commit touching only docs never triggered the lane, so
that commit had no image and no sha tag.
Pre-existing — the filter has always been there — but it is rule 145's guarantee
and step 6 is where the tag set is being made to match the rule, so it is this
step's to close.
Confirmed live on
|
||
|
|
a0c789b3ba |
docs: the image tag list said something step 6 stopped being true
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
`:<git-sha>` is on `main` only now — a sha tag per dev push was a rollback target nobody had ever pulled — and `:<version>` never existed as an image tag after rule 145 was narrowed. Both were still documented. `docs/android-distribution.md` also said `:dev`, `:latest` and `:<version>` all ship a client, which is now two-thirds true and misses the more useful fact: the channel IS the image you run, so a stable server serves a stable client. Worth saying because until step 3 it was hard-wired to the dev release on every branch and did the opposite. This push is also the skip-if-exists verification. It touches neither client's file set, so both `decide` jobs should report the channel already serving the current version and skip a 6- and a 9-minute build — while the guard still runs on that path (§6.3). #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
22a9a279b1 |
ci: one definition of what ships decides both the version and whether to build
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Step 6 of M314. Two changes that only make sense together.
## The image tag set rule 145 mandates
dev push -> :dev
main push -> :latest + :<sha>
a v* tag -> nothing; the trigger is gone
`:<sha>` was going out on EVERY branch — a rollback target nobody has ever
pulled, accumulating forever, for a channel whose entire contract is that it
moves. It is on main only now, where rollback matters and where gated merges
(rule 2) make it dozens per year rather than one per push.
No version-shaped image tag in any lane. Verified the way rule 145 asks — by
looking for a CONSUMER, not for whether one is imaginable: `docker-compose.yml`
is parameterised for a pin and the docs describe the option, but no compose
file, deploy script or CI job reads one.
## Skip-if-exists, adapted, because §4 assumes a registry §5 removed
Note 3127 §4 says to ask the registry whether that exact version exists. There
is no `:<version>` tag to ask about any more. What there IS, for both clients,
is a channel that publishes the version it serves — and that answers the same
question: if the channel already serves what this source derives, the artifact
would be byte-identical.
So the `paths:` filters are gone from the desktop and Android lanes, replaced
by a `decide` job reading the real file set. That duplication is not
theoretical: `packaging/` was added to the sets and not to the filters, so the
commit that fixed a derivation bug never ran on the two lanes it fixed
(
|
||
|
|
0ab7d94294 |
versioning: refuse to publish a version below what the channel already serves
CI & Build / Python tests (push) Successful in 17s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m19s
Step 5 of M314, note 3127 §6.3. Everything else in this milestone derives a number and trusts it; this compares the derived value against what the channel is actually serving and fails the lane if it went down. Too-low is the unrecoverable direction: every installed client reports "up to date" forever, and no later build fixes it until one climbs back above the bad number. #2183 and #2993 are both that symptom. ## Two hazards, two mechanisms A shallow clone is now tested DIRECTLY, in `version.sh`, via `--is-shallow-repository`. The empty-result guard only caught the case where nothing matched — and run 4796 showed the worse one, where a partial match returned a real six-days-stale answer. Asking the question outright costs no network and covers artifacts with nothing published to compare against. `guard-forward.sh` handles the rest: a squash or rebase merge rewriting the committer date, a rebuild of an older commit, and clock skew between runners. ## The comparison is per artifact, and the operator differs desktop derived >= published commit time, so equality is the ORDINARY no-change case and `<=` would fail every build that changed nothing android derived > published build time, so equality means two builds in one minute — and Android refuses to install an APK whose versionCode does not RISE The server is deliberately unguarded: nothing compares its version, `:latest` moves regardless, and rule 145 removed the version tags that would be the published list. A too-low value there is a wrong date in a footer, not a stranded client. It still gets the shallow-clone check. ## Proved to fire, not assumed Cloned the repo, checked out a commit eight back, ran the guard against the LIVE dev feed: at the tip derived 1.0.3502151, published 1.0.3502151 -> pass eight back derived 1.0.3501535, published 1.0.3502151 -> FAILS android tip derived 3502171, published 3502152 -> pass stable derived 1.0.3502151, published 0.2.0 -> pass That last row is worth keeping: stable still advertises the bare `0.2.0` from the old Cargo.toml scheme, so the transition orders upward on BOTH channels, not just the one being exercised. A channel with nothing published passes rather than failing — otherwise the first publish to a new channel could never happen. The guard runs BEFORE the build in all three lanes, so a bad derivation costs seconds rather than a five-minute compile and a publish to undo. `compare` is exposed as an explicit mode so the ordering is testable without a network and inspectable without a push — 16 cases including `1.0.9 < 1.0.10`, which a string compare gets exactly backwards. #3145 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e891357ff |
ci: the deriver is in the file sets but was not in the path filters
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
`85ead4d` changed `packaging/version.sh` — the script that decides what every artifact claims to be — and the desktop and Android lanes did not run at all. Only CI & Build fired, and only because it happens to watch `tests/**`. So the fix in that commit is unverified on exactly the two lanes whose bug it was fixing. `version.sh` lists `packaging` in all three file sets; the workflows' `paths:` filters did not. Two places holding one decision, with one of them updated — the failure this subsystem keeps producing (#2181-2183, and again in step 3 where `install.sh` still expected stable's bundles on a versioned release). The script's own header already warned about this: "a change here that is not mirrored there means a lane that does not fire — check both." Written, then not followed, in the same commit. Step 6 removes the duplication for real by replacing these filters with skip-if-exists. This is the stopgap until then, and it says so at each site. #3144 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
85ead4d66b |
versioning: anchor at the repo root — a pathspec is relative to the caller's cwd
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
Three failures on
|
||
|
|
c5044339a1 |
versioning: each artifact derives from its own files, with the clock picked per value
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Canceled after 13s
CI & Build / integration (push) Canceled after 13s
CI & Build / Build & push image (push) Canceled after 0s
Android / Kotlin + Rust (APK) (push) Failing after 14s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Failing after 4s
Step 4 of M314. `desktop/packaging/build-version.sh` was one generator feeding the desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`, so a Kotlin-only commit re-versioned the desktop and a Rust-only commit re-versioned the phone. Note 3127 §3 cites this repo as its example of that failure. It is replaced by `packaging/version.sh` — one definition of HOW to derive, three file sets, and the sets in one place. Lives at the repo root rather than under desktop/, because it serves three artifacts now and a shared thing filed under one consumer ends up owned by it. ## Two values, and the clock chosen per value (§2) desktop key 1.0.<minutes since 2020-01-01> commit time desktop display 2026.08.28.0900 commit time (#3181 shows it) android versionName commit time android versionCode <minutes since 2020> BUILD time server version 2026.08.28.0900 commit time, no ordering key Every human-readable version in the repo is now one shape. The two exceptions are not version names at all — they are bare monotonic integers a comparator reads and nobody quotes. The desktop needs a separate key because Tauri parses `latest.json` with the semver crate and `2026.08.28.0900` fails it twice (four segments, and `08` is a leading zero). `1.0.` and not `0.0.`: the minor has to clear the installed `0.2.466` line or every dev user is stranded on "up to date" permanently. Android's code comes from BUILD time while the desktop's key comes from COMMIT time, deliberately. Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so its key must be monotonic by construction; the desktop merely declines to offer an update, which a guard can catch. ## The bug this found in itself The shallow-clone guard `exit 1`-ed inside a function called as `$(...)` — which ends the SUBSHELL, not the script. `display` still failed, but only because `date` then choked on the empty string. `key` printed the error to stderr, emitted `1.0.-26297280`, and exited ZERO. That is precisely the failure the guard exists to prevent: a too-low version on a green lane, and too-low is the direction you cannot recover from. It resolves into a global in the parent shell now. The test is parametrized over both requests, because one path was covered and the other was broken in exactly the way the covered one was meant to rule out. ## Also `fetch-depth: 0` on every job that derives — four of them, and only ci.yml's gate had it. Depth-1 is silently wrong rather than loudly broken (§6.1). The file sets include each artifact's BUILD RECIPE (its workflow, and `packaging/`). A workflow file is not shipped, but change a Gradle flag and the bytes change while the source does not — and once step 6 skips a build whose version already exists, that serves the OLD artifact on a green run. The base images are deliberately NOT resolved at derive time: that is an external lookup, which §7's corollary forbids. `Dockerfile` is already in the server's set, so pinning `FROM` by digest in step 6 puts the base inside the set for free. `build-version.sh` is deleted, its last consumer (the pacman packager) moved over, and the one finding worth keeping out of its header — why not a `-dev.N` prerelease — is preserved in the successor. #3144 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c268ae4f23 |
ci: main publishes, so a tag stops being required — and :latest stops shipping a dev client
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s
Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing sufficient for a user to receive the build" — and here it was not. The desktop and Android lanes BUILT on main and published nothing: `Publish release` was gated on `refs/tags/v*`, the channel publishes on `refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel moved only when somebody cut a tag, which made a `v*` tag load-bearing rather than the optional bookmark the model wants. Both channels are rolling fixed-tag releases now. `dev` from dev, `stable` from main, same machinery — `publish-release.sh` already took RELEASE_TAG, `write-manifest.sh` already pruned, and both already PATCHed a stale description on 409 (#2182). This is wiring, not new mechanism. ## The defect this carried `ci.yml`'s "Fetch the Android client to bake in" read `releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked in the dev APK — `:latest` included — so a stable server served a dev-channel client to anyone who downloaded it from there. That has nothing to do with versioning; it is fixed here because this is the step that finally gives `stable` an APK to point at. It also means Android needs no channel machinery of its own. The APK is served FROM the image, so the channel is already a property of which image you run — note 3127 §7's "nothing to hand off" shape, arrived at here by accident. One branch-conditional line, not a second channel in `client_dist.py` as this milestone first assumed. ## The break this nearly shipped `install.sh --channel stable` read the version out of `stable/latest.json` and then fetched `releases/tags/v<version>` for the bundles — correct while stable was a manifest-only pointer, and broken the moment stable holds its own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for everyone between this commit and the first merge to main. Both channels are one lookup now: fetch the fixed-tag release, install what is on it. A transitional fallback covers the window where `stable` still has no bundles, marked for deletion in step 7 — without it the default channel is broken for however long it takes to merge, and that window is gated on an operator request rather than on this lane. ## The two writers problem `stable`'s manifest was written by tag builds. It is written by main now, and the tag path stops writing it — two writers for one channel is a race with no winner worth having. A `v*` tag still writes its own versioned manifest; its build consequence goes entirely in step 7. Also corrected: `update.rs`'s header still described stable as following `v*` tags. Nothing in that file moved — it only ever read `<channel>/latest.json` — but the comment was a lie, and it is the file somebody reads to understand the feed. #3143 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b7e0e5dbba |
ffi: two items: lines left at the indent of the field above them
`cargo fmt --check`. Deleting `color:` from these two NoteDraft literals left the line after it one level too deep — the sort of thing a formatter exists to catch and an eye does not. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e14d9d340a |
core: the v9 test pinned v8, and a blank line ktlint counted
Two CI failures from the colour removal, both mine. `a_fresh_database_reaches_v8` asserted the version the migration no longer stops at. Renamed to say what it actually guards — the LATEST version — so the next migration updates a number instead of a name that has quietly become wrong. While there, two tests the migration deserved and did not have. One asks SQLite whether `notes.color` is gone rather than reading a row back, because a SELECT that omits the column passes either way; it also asserts `labels.color` is still there, since getting that wrong would take every tag's colour with it. The other seeds three saved views and checks the sweep: one loses its colour key and keeps its query, one without the key is untouched, and one holding text that is not JSON at all comes out unchanged rather than NULL. Writing that third case is what found a real bug in the migration. The guard was `json_valid(params) AND json_extract(params, '$.color') IS NOT NULL`, which is the obvious way to write it and is a trap: SQLite does not promise to short-circuit AND, so `json_extract` can be evaluated against the very rows `json_valid` was there to exclude — and on malformed input it does not return NULL, it RAISES, which would have aborted the whole migration over one corrupt blob. It is a LIKE now, which is total over any text. The ktlint failure is a doubled blank line where `EditorAction.SetColor`'s branch used to be. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fa89da1fab |
notes: color leaves the model, the wire and all three surfaces
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
13a88179b8 |
tags: one ink, chip and inline, and the chip edge solved for 3:1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m1s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
Step 1 left one card surface per theme, so the tag ink is no longer
choosing a value that has to clear twenty backgrounds. Re-measured against
the one it actually lands on, the two tables collapse into one.
`-800` in light, `-300` in dark, for a `#tag` in the prose AND for a chip's
text. Dark needed no decision at all — the two tables already held the same
value for all ten hues, which is most of the argument on its own. Light
collapses onto the INLINE column deliberately: since M311 a tag whose text
is in the body is drawn where it was typed and not repeated as a chip, so
inline is the common case and this leaves what is seen most exactly as it
was. The chip is strictly better for the move:
inline, on the card as a chip, on its own fill
light `-800` 7.09 - 15.13 6.37 - 12.01 (was 4.52 - 8.23)
dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged)
The chip edge goes 0.60 -> 0.65, and this is the first time that number
could be solved rather than judged. 0.60 was picked against a chip sitting
on a card of its own colour, a case that no longer exists; against a known
fill the smallest alpha clearing the 3:1 of WCAG 1.4.11 for all ten hues is
arithmetic. 0.60 gives 2.75-3.82 and misses for six of them, 0.65 gives
3.03-4.36 and misses for none. Dark runs 4.52-5.76.
That edge is doing more work than it looks: a chip's fill measures 1.02-1.26
against the card in light and 1.02-1.73 in dark, and dark red at 1.02 is
invisible. The ring is the pill; the fill only tints it.
`LABEL_CHIP_CLASSES` becomes `LABEL_CHIP_SHELL` — fill and edge, no ink —
and `labelChipClasses(label)` composes shell and ink in one place. The board
and the editor each had their own copy of that composition, with a comment
on one of them asking the other to stay in step. Now it is one call.
Fixed on the way past: the web drew `default`'s chip ring at `black/10`
(1.36 against its own fill) where Compose derived it from the ink (3.21) —
the same chip, visibly different pills. Both are the ink at 65% now.
`chipForeground` stays, narrowed to what it always actually was: the
REMINDER pill's ink, transcribed from NoteCard.vue's literal red-700 /
neutral-600. It is not a tag and must not move with one.
Also gone: `NOTE_NODE_FILL`, a per-hue table of solid hexes for graph nodes
with no consumer anywhere in the repo.
Step 2 of M315. #3149
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b91091caca |
cards: one neutral surface, and the generated fill deleted with it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
A card's fill stops being a function of the note. One neutral per theme on all three surfaces — white in light, neutral-900 in dark, which is what the palette's `default` always was and what both editors already used, so this is a collapse onto a surface everything already had rather than a colour anybody has to like. Measured, against "not the same color as their background but close to it": card vs board 1.04 light / 1.10 dark, edge vs card 1.98 / 1.73, body text 17.93 / 17.17, muted 10.37 / 14.23. The fill is deliberately the weakest number on the card — the edge and the shadow separate it from the board, so a fill that separated on its own would make it a panel. Deleted, since the card was their only consumer: `derivedFill` / `hslHex` and the level tables in colors.ts, `derivedFillArgb` / `hslToArgb` in DerivedTint.kt, `NOTE_CARD_CLASSES_STRONG`, `chosenNoteColor`, `noteCardClasses`, `noteTintVars`, the `.note-tint` rule in style.css, `chosenBackground` / `tintable` / `noteTintFor` / `noteCardColor` / `noteIsStrong` / `firstLabelColor` in NoteTint.kt, and `resolvedNoteColor` / `noteColorIsChosen`. `tintHash` and `derivedTint` STAY, against the plan: a label with no colour of its own still derives one from its name, and that path was never the one that failed. The mirrored pair and its fixture survive intact. The editor follows the card, and its Done button takes the brand — the board's compose FAB is the app's existing statement of "affirmative action here", where Material's default secondaryContainer is a baseline colour this theme never sets. The colour picker is left in place, doing nothing, for exactly one step: removing it here would leave `note.color` written by nothing and read by nothing, which is a worse intermediate than a control that visibly does nothing. #3041 takes the field and the picker together. Step 1 of M315. #3148 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f50204a98b |
editor: detekt counts returns, so the promotion guards collapse into one
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
`promotingTasks` had four returns against ReturnCount's limit of two — three of them the same `return this`. Collapsed into a null-or-task guard and a `changed` flag, which says the contract more plainly anyway: the list comes back untouched unless something was actually promoted. Mirrored in blocks.ts even though nothing lints it there. The two files are kept line-by-line alike on purpose, and letting them drift on shape is how the next person stops trusting that reading one tells you the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a49ae7ea9 |
editor: a - [ ] typed by hand becomes a real item when you leave the line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m15s
Android / Kotlin + Rust (APK) (push) Failing after 5m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`splitBlocks` runs once, when the editor opens. After that the blocks ARE the state and nothing reads the body again — every edit travels the other way, through `joinBlocks`. So a marker typed by hand stayed literal text on screen until the note was closed and reopened, even though it was already a real item in storage and the card was already drawing a checkbox for it. The editor was the only place that disagreed with itself. (#3024) On BLUR, and only the block being left. There is no good moment to convert while someone is typing: re-splitting on a keystroke moves the caret out of the word being written, and converting the instant `- [ ]` is complete does it before the item has any text. Blur is the one moment the person has demonstrably finished with the block. `promotingTasks` / `promoteTasks` return the SAME list when there was nothing to promote, and both call sites compare by identity. Without that, every blur would re-key every field below it — including the blur that fires on first composition, before a field has ever held focus. Both surfaces in one commit, deliberately: blocks.ts is a line-by-line mirror of EditorBlock.kt, and the reason that mirror is worth keeping is that the two editors behave identically. Fixing one would spend its whole value. Non-canonical markers (`- [X]`, an odd bullet) come back canonical — the only case where this changes the body rather than just how it is drawn, and exactly what reopening the note already did. No unit test: `splitBlocks` reaches the core over uniffi for the grammar, so it needs the native library and cannot run in the JVM lane. No existing Android test touches the core for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e7af7a4b77 |
board: the FAB and the undo snackbar rode behind the keyboard
Found by the Scaffold audit #2951 asked for. Three Scaffolds exist; the editor and the sync screen both consume the IME inset, and the board consumed nothing. `enableEdgeToEdge()` makes the manifest's `adjustResize` a no-op on API 30+, so nothing resizes for the keyboard unless the app asks — and `ScaffoldDefaults.contentWindowInsets` is systemBars, which the IME is not part of. The Scaffold positions the FAB and the snackbar host from that value, so with the search field focused both sat under the keyboard. Not theoretical, and newly load-bearing: `3f0eef1` put an UNDO on the trash snackbar, so the one control you could not reach was the one that takes back a note you did not mean to throw away — reachable by searching, long-pressing a hit and trashing it. `union` rather than `add`: the navigation bar and the IME are the same edge, not two stacked ones, and adding them would inset twice under a keyboard that already covers the nav bar. Set once on the Scaffold rather than per-slot, so the content column shrinks with it and the board's cards stay above the keyboard instead of scrolling under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
396e91e609 |
board: ktlint on the long-press menu — a named modifier, three dead imports
Two failures on `3f0eef1`, both ktlint, both mine. `chain-method-continuation`: a multiline element in a Modifier chain wants the next `.` glued to its closing paren — `).background(…)`. Every other multiline chain element in this codebase happens to be LAST in its chain, so nothing had exercised the rule before. `combinedClickable` is now a named `opening` modifier applied with `.then(…)`, which keeps the chain single-line per element and reads better than the shape ktlint was asking for. `no-unused-imports`: lifting the delete-forever dialog into Panel.kt took the last use of `Text`, `stringResource` and `R` out of NoteEditorScreen.kt with it. I had checked AlertDialog and TextButton and stopped there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3f0eef145b |
board: a long press on a card does what the editor's overflow does
Trash existed on Android and was three interactions deep — open the note, tap the overflow, Move to trash — with nothing at all on the board itself. The operator's read of that was not "the actions are in the editor"; it was "there are no long hold context menus in the app I have no way to delete notes." (#2946) The card now takes `combinedClickable` and raises a DropdownMenu holding the same items as the editor's overflow, in the same words, from the same string resources, dispatching the same `EditorAction`s through the same `BoardViewModel.onEditorAction`. A note has one vocabulary of things you can do to it, and reusing the exhaustive dispatcher means the board cannot grow a parallel one that drifts. Gated on `note.trashed` rather than on the board's destination — the same reading the editor uses for read-only, and the only one that survives Reminders and search, which both mix piles. Trash gets an UNDO snackbar rather than a confirmation. A long press is a gesture you can make by accident, so the mistake worth designing for is the one nobody meant to make, and a dialog only helps someone paying attention in the moment they were not. Delete forever keeps its dialog; that one does not undo. `MenuItem` and the delete-forever dialog move to Panel.kt now that two surfaces raise them, so there is one place for the close-before-acting order and one wording of the consequences. Colour is deliberately not in this menu, though #2946 suggested it: `note.color` and its picker come out in #3041, so a swatch row here would be building the one control already known to be leaving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4f351c10ca |
core: rustfmt wraps the chain in the tag-span grammar test
`cargo fmt --all --check`, the only failing gate on
|
||
|
|
d9e5753dc2 |
board: a tag in the prose is coloured where it sits, not printed twice
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a chip — and the duplicate was the loud copy. Now the chip row carries only what the body cannot say (a tag lifted off its own line, a label from the picker), and a `#tag` left mid-sentence is tinted in place. Which characters are a tag is asked of the CORE, the way the card already asks it which lines are checklist items: `extract_tag_spans` keeps the spans `extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are UTF-16 code units, because `AnnotatedString` and JS both index that way and a char index lands mid-token the first time somebody writes an emoji. The web keeps its own matcher in markdown.ts, mirroring `line_tags` case for case. The inline ink is its own table, one Tailwind step deeper than the chip's. A chip brings its own -100 fill and reads against that alone; inline text sits on whatever the card is, including a gray-tagged card at neutral-200 — where the chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5 body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84 dark across every palette and generated fill. Chips now carry the `#` on every surface. The via_tag branch that used to decide it is gone from the card, and Android's row said no hash at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c22425e91 |
M311 step 3 — the core lifts too, so a note never lifts twice
Step 2 rewrote the notes already on disk and, because the sync_revision trigger fires, every client pulls them. So this is not about existing notes. It is about the ones typed from now on. Without it: you type `#todo` on its own line, the core stores it as written, and a second later the push comes back and the text disappears under you. Offline it never lifts at all until you reconnect. Two surfaces disagreeing about what a note says is the thing this codebase mirrors rules to avoid. `lift_standalone_tags` in derive.rs is the mirror of `split_body_tags`, case for case, with the same two guards — a fenced line is code and is never touched, and a note that is nothing but tags keeps its text. ONE SCANNER, not two. `extract_tags` is rewritten over the same `line_tags` the lift uses, so the two cannot disagree about what a tag is. Line-by-line changes nothing, since a line start and a `\n` are both boundaries, and the existing tag tests still pin it. Char indices rather than byte offsets for the spans, because they are used to cut the tags back out of the line and a byte offset can land mid-codepoint. `sync_tags` becomes `lift_and_sync_tags` and is named for the mutation: it now rewrites notes.body, and all three callers write the body immediately before calling, so it overwrites what they wrote on purpose. The graduation case is handled the same way as on the server — flip the row before the delete pass, or the same row is dropped for no longer being in the body and the tag is silently lost. One thing the server needed and this does not: display_title. The core derives it on READ rather than storing it, so there is no persisted copy to go stale. The rename was done with a lookbehind rather than a plain substitution, after the same operation an hour ago turned the function it had just written into `_lift_and_lift_and_reconcile_tags`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9810a75564 |
M311 step 2 — the migration that lifts the notes already written
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 19s
Step 1 made new saves lift; this does the ones already on disk, so a note stops showing its tag twice without having to be opened. Same rule, and a FROZEN copy of it — `split_body_tags` is deliberately not imported, on 0027's principle that a migration has to keep producing what it produced the day it ran. If the app's rule is ever loosened, this file must not loosen with it and start eating prose it previously left alone. `_display_title` is inlined for the same reason, and recomputed only for a note whose body actually moved: a note named after its `#todo` line needs a new name. The label rows graduate in the same transaction, and that is not cosmetic. A `via_tag` row claims "backed by text still in the body", and reconcile detaches any row it cannot find a `#tag` for — so leaving them true would lose every lifted tag on the note's next save. Flipping them to false is also what makes the chip's × appear, which is now the only way to remove a tag whose text is gone. `updated_at` is left alone so a client holding an unpushed edit still wins under LWW. The `sync_revision` trigger does fire, which is wanted here: unlike 0027 the clients do NOT yet apply this rule locally, so the server's copy is the only correct one until step 3. The downgrade is empty and says why. It cannot restore the deleted lines — nothing distinguishes one this migration removed from one that was never there — and flipping the rows back would be actively harmful, since the text that flag claims backs them is gone and the next save would then detach the label for real. Tested on the ten cases that matter, three of which are prose that must come back byte-identical. The test pins the frozen copy against fixed expectations rather than against the app's rule — they are allowed to diverge later, which is the whole point of freezing one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
606e345580 |
Fix the rename that renamed itself
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 16s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 28s
`sed s/_reconcile_tags/_lift_and_reconcile_tags/` ran over tags.py after the new function was already written with the new name, so the definition became `_lift_and_lift_and_reconcile_tags` while all 15 call sites were correct. Twelve test modules failed to import. The check that should have caught it is the reason it got through: the verification grep piped output through `sed 's/:.*_lift/: _lift/'`, which trims to the LAST `_lift` and therefore prints a doubled name identically to a correct one. A filter that can only make wrong output look right is worse than no filter. Same sed also clobbered the docstring's historical reference — it read "it used to be `_lift_and_reconcile_tags`", naming the function after itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |