Notes grid, first-line titles, and v2.32.0 (#279, #280) - #281
Conversation
Two requests from the same reporter, both added as choices rather than replacements so nothing moves for anyone who does not opt in. Notes list layout (#279): List keeps the one-column list everyone has; Grid lays the same notes out as cards in as many adaptive columns as the width allows, keeping the Today/Yesterday/Pinned headers, the long-press menu, and the "Show note previews" setting. The row actions are defined once and passed to both, so the list and the grid cannot drift. Note title (#280): Markleaf titled a note by the first heading *anywhere* in it, so a paragraph followed by a heading was titled by the heading — the inconsistency reported. First heading stays the default; First line always takes the first non-empty line, stripping markers when that line is itself a heading. Flipping the setting recomputes stored titles and excerpts (titles are derived at save time, so otherwise the setting would appear to do nothing), leaving updatedAt, empty notes and conflict copies alone. Sync-folder filenames follow via the existing Sync Center action. Verified on markleaf-phone-api36: both layouts, the grid long-press menu, and the title rule in both directions ("Retitled 1 note.", note keeps its place in the list). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version bump, both CHANGELOG editions, six store changelogs (414 chars at the longest, against the 500-char Play cap), and the version strings on the six landing pages and six READMEs. The Notes & search golden is re-anchored on "First line", the section's new last control: it was anchored on "Where I left off", so the layout and title rows added below it were not in the image and the golden kept passing without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The golden now shows the layout and title rows the re-anchored capture reaches. Recorded on the Linux runner via the record_roborazzi dispatch, since a local re-record drifts from CI verify on font hinting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b34704b722
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| val title = TitleExtractor.extractTitle(note.contentMarkdown, source) | ||
| val excerpt = TitleExtractor.generateExcerpt(note.contentMarkdown, source) | ||
| if (title == note.title && excerpt == note.excerpt) continue | ||
| repository.updateNote(note.copy(title = title, excerpt = excerpt)) |
There was a problem hiding this comment.
Retitle without replacing concurrently edited notes
If the user leaves Settings while this non-cancellable pass is running and edits a note that was captured by getAllNotes(), or a folder import updates it concurrently, this full-row update can later restore the stale contentMarkdown and other fields from the snapshot, silently discarding the newer edit. Update only the derived title/excerpt from the current row, or otherwise serialize the pass with note writes instead of copying the stale Note back wholesale.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in fa7890a — the finding is real. The pass is NonCancellable precisely so it survives leaving Settings, which is exactly the window where the editor's debounced save or a foreground folder import can land on a row the pass has already read.
NoteRetitler now calls a new NoteDao.updateDerivedTitle(noteId, title, excerpt) instead of updateNote(note.copy(...)), so only the two derived columns are written and a concurrent body edit survives. Room fires the FTS content triggers on that UPDATE the same way it does for setPinned and the other row-scoped queries already in the DAO. NoteRetitlerTest.retitling rewrites the derived columns and nothing else edits the row underneath the pass and asserts that contentMarkdown, pinned, updatedAt and createdAt all survive.
| onClick = { | ||
| retitleBusy = true | ||
| scope.launch { | ||
| settingsRepository.setNoteTitleSource(source) |
There was a problem hiding this comment.
Make the persisted title setting recover incomplete retitles
If the process is killed or a database update throws after this preference write but before retitleAll finishes, the stored source already indicates the new rule while only some notes have been retitled. On the next launch the matching option is rendered as the selected button with an empty click handler, so there is no automatic retry and the mixed state persists unless the user toggles away and back; persist the preference atomically with the migration or resume/re-run an incomplete pass.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Accurate, but not something this PR will change — recorded on #262 instead.
The premise holds: the preference is written first, and a process death mid-pass leaves the new rule selected with only some notes retitled, with no automatic retry. Two things bound how much that costs, both of them true only after the P1 fix above:
- The pass now writes
titleandexcerptand nothing else, so a partial run is cosmetic drift in the list, not lost or mixed note content. - It is self-healing per note: each note's next save re-derives both columns under the active rule.
Persisting the preference and the migration atomically is not available here — the preference is in DataStore and the notes are in Room, so "atomic" would mean a resume marker plus a startup pass, which is more machinery than the failure earns. Recovery today is toggling the option away and back, and the pass is idempotent.
The precedent points the same way: SidecarMigration rewrites every file in the sync folder and is likewise not resumable, tracked as an accepted limitation on #262 rather than fixed. This item belongs beside it, so I have added it there. Per AGENTS.md this flow does not open new hardening issues — it is issue-response work, and spending the backlog should not grow it.
Codex review on #281, P1. The retitle pass read every note, computed, and wrote the whole row back — so a note edited in the editor, or updated by a folder import, while the pass was running would have had its body restored from the snapshot the pass started with. The pass is NonCancellable and survives leaving Settings, which is exactly when that overlap can happen. `updateDerivedTitle` rewrites `title` and `excerpt` for one id and nothing else. Room fires the FTS content triggers on it the same way, and the test pins the guarantee with the row edited underneath the pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the two feature requests filed by @bit2bold. Both ship as choices with the existing behaviour as the default, so an update changes nothing until the user asks for it.
Notes list layout (#279)
Settings → Notes & search → Notes list layout offers List (default, unchanged) and Grid. Grid uses
LazyVerticalGridwithGridCells.Adaptive(180.dp)rather than a fixed column count, because the same screen is also the tablet's narrow list pane beside the editor. Section headers span the full width; cards give the title two lines and the excerpt four.The per-row actions used to be lambdas written inline in the list body — including the lock action, which deletes the note's mirror file so a locked note leaves no plaintext copy behind. They are now defined once above the layout and passed to both
NoteRowand the newNoteCard, so the two cannot drift. The long-press menu moved intoNoteContextMenuand the shared-element transition into aModifier.noteSharedBoundshelper, both shared by the row and the card.Note title (#280)
TitleExtractorlooked for the first Markdown heading anywhere in a note and only fell back to the first non-empty line when there was none — so a note that opens with a paragraph and has a heading further down was titled by the heading. The reporter asked for a stored title field; that was declined in the issue with reasons (a Markleaf note is its.mdfile, so a stored title would have to live in the frontmatter or the sidecar index and would drift the moment the file was edited elsewhere), and this addresses the unpredictability instead.Settings → Notes & search → Note title offers First heading (default, unchanged) and First line.
NoteTitleSourcelives incore.textbesideTitleExtractorsocorekeeps not depending ondata, and is threaded through the editor's five derivations, both import passes inNoteFolderMirror, and share-intent note creation.Switching the setting runs
NoteRetitler.retitleAllover the database, because titles are derived at save time and the setting would otherwise appear to do nothing until every note had been edited again. It leaves alone:updatedAt, so no note jumps to the top and the sync layer does not read a retitled note as an edited one,The pass is
NonCancellable, so leaving the screen mid-pass cannot strand half the notes under the old rule, and the control is disabled while it runs. Mirror filenames follow through the existing Sync Center → Rename files to note titles, so no new folder-wide pass was added.Verification
testDebugUnitTestandlintReleaselocally. 27 unit tests acrossTitleExtractorTest(both rules, excerpt agreement),NoteRetitlerTest(retitle, idempotence, the three exclusions,updatedAtpreserved),AppSettingsRepositoryListTest(round-trip, independent keys) andAppSettingsTest(defaults).markleaf-phone-api36: grid at two columns with headers spanning and the long-press menu working; the title rule both ways on a note readingPlain first line/## Heading below— "Retitled 1 note." and the note kept its place in the list; list mode unchanged.settings_notes_search_tabletwas re-anchored on "First line", the section's new last control. Anchored on "Where I left off" it kept passing while showing none of the new rows — the blind spot Hardening candidates: v2.30.0 #255 filed, one section deeper. Goldens recorded on the Linux runner.Release
v2.32.0 / versionCode 122, both CHANGELOG editions, six store changelogs (414 chars at the longest against the 500-char cap), landing x6 + README x6,
D:\Buildexport verified byverify-release-export.ps1.🤖 Generated with Claude Code