From 77c542295104d2555fd49c177aa2efcb648791e7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 23 Aug 2026 16:52:59 -0400 Subject: [PATCH 01/73] ci: a failing lane must not publish an image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build gated on lint + typecheck only, so run 4293 failed its test lane and pushed :dev and :09b5f87 regardless — the deployed server was running a build whose tests were red. The comment justified this by saying DB-backed testing happened manually against the dev image rather than on every push. That was true when it was written and stopped being true at 6f21db8, which added the integration lane. The reason went away; the exception didn't. Gate on test and integration too. A : image is the rollback unit for its commit (family rule 46) — one publishable from a failing run is not something you can roll back to. Co-Authored-By: Claude Opus 5 (1M context) --- .forgejo/workflows/ci.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 1899bc3..5b7c82e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -193,8 +193,7 @@ jobs: # them ever executed by CI — and the schema the migrations build had never been # checked against the models that read it. # - # Runs for visibility and does NOT gate the build, matching the `test` lane and - # FabledScribe's equivalent job. + # Gates the build, along with every other lane — see the `build` job's `needs`. # # Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner # derives the service-container name from the truncated job display name, and the @@ -261,10 +260,16 @@ jobs: build: name: Build & push image - # Build gates on lint + typecheck. The `test` job runs in parallel for - # visibility but does not block dev image builds (DB-backed integration - # testing happens against the dev image manually, not on every push). - needs: [gate, typecheck, lint] + # Every lane gates the build. This once stopped at lint + typecheck, on the + # reasoning that DB-backed testing happened manually against the dev image + # rather than on every push — true until 6f21db8 added the integration lane, + # and false since. + # + # What that gap cost: run 4293 failed `test` and published :dev and : + # anyway, so the deployed server ran a build whose test lane was red. An image + # tag is the rollback substrate (family rule 46); one that can be published + # from a failing run is not a substrate you can roll back TO. + needs: [gate, typecheck, lint, test, integration] if: needs.gate.outputs.build == 'true' runs-on: python-ci container: -- 2.54.0 From 50e2d308eacb570d2958954ee1b6dfe70e8a5919 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 23 Aug 2026 17:40:28 -0400 Subject: [PATCH 02/73] android: the editor toolbar was black icons on a near-black bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "I'm unable to see a toolbar in the editor on android, is there one?" — and it was rendering the whole time. EditorBottomBar passed containerColor but no contentColor, so Material3 defaulted it to contentColorFor(containerColor). That maps a colour-SCHEME ROLE to its `on-` pair and returns Color.Unspecified for anything else. A note tint is never a role: the default note is 0xFF171717 while the dark scheme's surface is 0xFF0A0A0A. So contentColor resolved to Unspecified, Surface published it as LocalContentColor, Icon took it as its tint, and an unspecified tint applies no colour filter — leaving the icons-core vectors their intrinsic black, on a near-black bar. Every note colour, both themes, only visible in dark. The top bar escaped it because topAppBarColors(containerColor = …) overrides the container and leaves the icon colours at their scheme defaults. Also inset the bar for the keyboard. enableEdgeToEdge makes the manifest's adjustResize a no-op and Scaffold does not inset its bottomBar slot, so the bar would sit under the IME the moment anyone typed — a second way to not see it. imePadding moves to the bar; the content Column drops its own, since Scaffold now measures the bar at its lifted height and the inset reaches the content through innerPadding. Co-Authored-By: Claude Opus 5 (1M context) --- .../thoughtsync/ui/EditorChrome.kt | 20 ++++++++++++++++++- .../thoughtsync/ui/NoteEditorScreen.kt | 6 ++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 1dfc04f..f77044b 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -63,7 +64,24 @@ fun EditorBottomBar( onAction: (EditorAction) -> Unit, ) { val dark = isSystemInDarkTheme() - BottomAppBar(containerColor = tint.background(dark)) { + BottomAppBar( + // imePadding so the bar rides above the keyboard. `enableEdgeToEdge` makes + // the manifest's adjustResize a no-op, and Scaffold does not inset its + // bottomBar slot for the IME — without this the bar sits under the keyboard + // the moment anyone types. The content Column deliberately does NOT also + // add imePadding: Scaffold measures this bar at its padded height, so the + // inset already reaches the content through innerPadding. + modifier = Modifier.imePadding(), + containerColor = tint.background(dark), + // EXPLICIT, and not optional. The default is contentColorFor(containerColor), + // which maps a colour-SCHEME ROLE to its `on-` pair and returns Unspecified + // for anything else. A note tint is never a role — the default note is + // 0xFF171717 while the dark scheme's surface is 0xFF0A0A0A — so the default + // resolved to Unspecified, Surface published that as LocalContentColor, and + // Icon drew with no colour filter: black vectors on a near-black bar. The + // toolbar was rendering the whole time and was invisible in dark mode. + contentColor = MaterialTheme.colorScheme.onSurface, + ) { if (!readOnly) { // A dot in the note's CURRENT colour rather than a palette icon: it // shows what the colour is as well as what the button does. diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index cbf5be3..fc446e7 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -122,8 +121,11 @@ fun NoteEditorScreen( modifier = Modifier .fillMaxSize() + // No imePadding here: EditorBottomBar carries it, so Scaffold + // measures that bar at its keyboard-lifted height and the inset + // already reaches this Column through `padding`. Adding it again + // would inset for the keyboard twice. .padding(padding) - .imePadding() .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { -- 2.54.0 From 24685556b7077144c231748ff4a1496202fe2ac4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 23 Aug 2026 21:00:57 -0400 Subject: [PATCH 03/73] android: open an existing note ready to keep writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a note put no cursor anywhere, so carrying on cost a tap into the body and usually a second one to drag the caret past the existing text. The compose sheet has always focused its field on open; the editor never did, and continuing a note is the more common act of the two. Focus the body on open, caret at the end. Not for a trashed note — that renders read-only and a keyboard over a record you cannot edit is noise. Keyed on note.id so the reused editor re-requests when pointed at a different note. The caret position is why the body state moves from String to TextFieldValue: a String field always starts its selection at offset zero, so focusing one lands the cursor before the first character — the wrong end of a note you meant to continue. PlainTextField gains a TextFieldValue overload for it, and the two overloads share one colours definition rather than growing a second copy of the "no box" treatment this file exists to keep in one place. I recorded this backwards in Scribe 2947 — as the keyboard opening unwanted, when the report was the opposite. The source having no FocusRequester was the tell, and I read it as a mystery instead of as evidence I had the direction wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../thoughtsync/ui/NoteEditorScreen.kt | 38 ++++++++-- .../fabledsword/thoughtsync/ui/PlainField.kt | 75 +++++++++++++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index fc446e7..6e4c4a5 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -23,12 +23,17 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.Label @@ -60,7 +65,15 @@ fun NoteEditorScreen( // Keyed by note id: the editor is reused across notes, and without the key the // second note opened would show the first one's text. - var body by remember(note.id) { mutableStateOf(note.body) } + // + // TextFieldValue rather than String so the CARET can start at the end of the + // text. A String field always begins its selection at offset zero, which would + // drop the cursor before the first character — the wrong place for "carry on + // writing this note", which is what opening an existing one usually means. + var body by + remember(note.id) { + mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length))) + } var picker by remember(note.id) { mutableStateOf(Picker.NONE) } var confirmingDelete by remember(note.id) { mutableStateOf(false) } @@ -74,8 +87,8 @@ fun NoteEditorScreen( // would bump `updated_at`, mark the note dirty for sync, and snapshot a // revision identical to the one before it. val flush = { - if (!readOnly && body != note.body) { - onAction(EditorAction.SaveText(body)) + if (!readOnly && body.text != note.body) { + onAction(EditorAction.SaveText(body.text)) } } val leave = { @@ -83,6 +96,18 @@ fun NoteEditorScreen( onAction(EditorAction.Close) } + // Opening an existing note means continuing it. Without this the note arrives + // unfocused, and carrying on costs a tap into the body and often a second to + // drag the caret to the end — the friction the operator reported. + // + // Not for a trashed note: it renders read-only, and a keyboard over a record + // you cannot edit is noise. `note.id` as the key so the request fires again + // when the reused editor is pointed at a different note. + val bodyFocus = remember { FocusRequester() } + LaunchedEffect(note.id) { + if (!readOnly) bodyFocus.requestFocus() + } + BackHandler(onBack = leave) // Leaving the APP is not closing the editor, so the text has to be saved @@ -151,6 +176,7 @@ fun NoteEditorScreen( hint = R.string.editor_body_hint, enabled = !readOnly, minLines = MIN_BODY_LINES, + modifier = Modifier.focusRequester(bodyFocus), ) // Below the body, not instead of it, and only once the note has items — @@ -259,15 +285,17 @@ private fun EditorOverlays( */ @Composable private fun EditorField( - value: String, - onValueChange: (String) -> Unit, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, @StringRes hint: Int, enabled: Boolean, minLines: Int = 1, + modifier: Modifier = Modifier, ) { PlainTextField( value = value, onValueChange = onValueChange, + modifier = modifier, hint = hint, enabled = enabled, minLines = minLines, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt index f78ae64..ec7c19f 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt @@ -9,12 +9,14 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation /** @@ -58,18 +60,65 @@ fun PlainTextField( keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, visualTransformation = visualTransformation, - colors = - TextFieldDefaults.colors( - // Full-strength, not Material's 38%-alpha disabled treatment: a - // trashed note is rendered read-only through this field and its - // text is meant to be READ, not visually retired. - disabledTextColor = MaterialTheme.colorScheme.onSurface, - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), + colors = plainFieldColors(), ) } + +/** + * The same field over a [TextFieldValue], for the one caller that needs to control + * the SELECTION as well as the text — the editor, which opens an existing note with + * the cursor at the end so continuing to write costs no extra taps. + * + * A `String` field cannot express that: Compose starts its internal selection at + * offset zero, so focusing one drops the cursor before the first character. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlainTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + @StringRes hint: Int? = null, + enabled: Boolean = true, + singleLine: Boolean = false, + minLines: Int = 1, + textStyle: TextStyle = LocalTextStyle.current, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth(), + enabled = enabled, + placeholder = hint?.let { { Text(stringResource(it)) } }, + singleLine = singleLine, + minLines = minLines, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + colors = plainFieldColors(), + ) +} + +/** + * One definition of "no box", shared by both overloads. Two copies of this is + * exactly the drift this file exists to prevent. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun plainFieldColors(): TextFieldColors = + TextFieldDefaults.colors( + // Full-strength, not Material's 38%-alpha disabled treatment: a trashed + // note is rendered read-only through this field and its text is meant to + // be READ, not visually retired. + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ) -- 2.54.0 From 2707054563fd05853a27b7ca52678c419dd45efe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 23 Aug 2026 21:47:01 -0400 Subject: [PATCH 04/73] A write should not cost a revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every body change snapshotted into history — core/src/local/store.rs and notes/__init__.py both — so a write was expensive, and the clients compensated by writing as rarely as they could. BoardViewModel says it outright: "Saved on close rather than per keystroke, so a session of typing costs one write and one revision snapshot." That is durability paying for version history. An app kill mid-session lost everything typed, so that the revision list would stay tidy. The safety property is worth more than the feature it was subsidising, and no comparable product makes this trade: Keep and Apple Notes write continuously with no history, Docs and Notion write continuously and coalesce history behind the scenes, Obsidian debounces and snapshots on an interval. Save-on-close is the outlier, and this coupling is why we had it. A body change now earns a snapshot only if it is the first of an editing session — the body actually differs, and the note carries no revision from the last ten minutes. Session granularity falls out of the window rather than being declared. A snapshot stores the body as it was BEFORE the edit, so the first write of a sitting captures the note as you found it and every write after it inside the window adds nothing. One revision per sitting, with no commit flag for a client to send and no wire surface to carry it. That is why it is a time rule and not a protocol one. sync.py applies pushed bodies through the same check, so a client autosaving every second cannot make the server snapshot every second either — which a client-declared commit point could not have guaranteed without a protocol bump. Restoring a revision still snapshots unconditionally: a considered act, not a keystroke, and it stays undoable. Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just now" line the editor is getting. Co-Authored-By: Claude Opus 5 (1M context) --- core/src/local/store.rs | 46 ++++++++++++++++++-- src/thoughtsync/notes/__init__.py | 7 +++- src/thoughtsync/revisions.py | 55 ++++++++++++++++++++++++ src/thoughtsync/sync.py | 10 ++++- tests/test_integration.py | 70 +++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 src/thoughtsync/revisions.py diff --git a/core/src/local/store.rs b/core/src/local/store.rs index e267184..9c98e56 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -375,6 +375,43 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu load_note(conn, &id) } +/// How long one editing session is assumed to last. +/// +/// Inside this window a note's body may be written any number of times and only the +/// FIRST write snapshots. That is what makes an idle-debounced autosave affordable: +/// a write costs a write, not a write plus a revision. +const REVISION_WINDOW_MINUTES: i64 = 10; + +/// Whether a body change earns a snapshot of the pre-edit body. +/// +/// Two conditions. The body must actually differ — re-saving identical text is not a +/// version of anything. And the note must not already carry a revision from this +/// editing session. +/// +/// The session rule is what keeps version history worth reading. Because +/// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write +/// of a session captures the note as you found it, and every write after it inside +/// the window adds nothing. One revision per sitting falls out of the window on its +/// own — no "commit" the client has to declare, and no protocol surface to carry it, +/// which matters because sync-apply takes this same path. +fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result { + let current: String = + conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?; + if current == new_body { + return Ok(false); + } + // String comparison, not date maths: timestamps are RFC3339 UTC with a fixed + // millisecond field (see the module header), so lexical order IS chronological. + let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES)) + .to_rfc3339_opts(SecondsFormat::Millis, true); + let recent: i64 = conn.query_row( + "SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2", + params![id, cutoff], + |r| r.get(0), + )?; + Ok(recent == 0) +} + fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> { let body: String = conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?; @@ -391,9 +428,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re .as_object() .ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?; - // Snapshot the pre-edit body before changing it (version history). - if obj.contains_key("body") { - snapshot_revision(conn, id)?; + // Snapshot the pre-edit body before changing it (version history) — but only + // once per editing session, and only if it actually changed. See should_snapshot. + if let Some(body) = obj.get("body").and_then(|v| v.as_str()) { + if should_snapshot(conn, id, body)? { + snapshot_revision(conn, id)?; + } } for (k, v) in obj { diff --git a/src/thoughtsync/notes/__init__.py b/src/thoughtsync/notes/__init__.py index 82f8490..f8e07a3 100644 --- a/src/thoughtsync/notes/__init__.py +++ b/src/thoughtsync/notes/__init__.py @@ -34,6 +34,7 @@ from ..models.note_attachment import NoteAttachment from ..models.note_item import NoteItem from ..models.note_link_preview import NoteLinkPreview from ..models.note_revision import NoteRevision +from ..revisions import should_snapshot from ..responses import json_error, not_found, parse_uuid from ..retention import purge_note from ..settings import get_setting @@ -485,8 +486,10 @@ async def update_note(note_id: str): if "body" in data: note.display_title = await _name_for(db, note) await _reconcile_tags(db, note) - # Version history: snapshot the PRE-edit body whenever it changed. - if note.body != old_body: + # Version history: snapshot the PRE-edit body, once per editing session + # rather than once per write — see revisions.should_snapshot. Writing often + # is what lets a client autosave instead of hoarding text until it closes. + if await should_snapshot(db, note.id, old_body, note.body): db.add(NoteRevision(note_id=note.id, body=old_body)) await db.commit() await db.refresh(note) diff --git a/src/thoughtsync/revisions.py b/src/thoughtsync/revisions.py new file mode 100644 index 0000000..2b1affb --- /dev/null +++ b/src/thoughtsync/revisions.py @@ -0,0 +1,55 @@ +"""When a body change is worth keeping a version of. + +A note's body used to snapshot into `note_revisions` on EVERY write, which made a +write expensive — and the clients compensated by writing as rarely as they could +get away with, saving only when an editor closed. That is durability paying for +version history: a crash mid-session lost everything typed, so that the revision +list would stay tidy. The safety property is worth more than the feature it was +subsidising. + +The rule here breaks that trade. A body change earns a snapshot only if it is the +first one of an editing session, so a client may write as often as it likes. + +Session granularity falls out of the window rather than being declared. A snapshot +stores the body as it was BEFORE the edit, so the first write of a sitting captures +the note as you found it and every write after it inside the window adds nothing — +one revision per sitting, with no "commit" flag for a client to send and no wire +surface to carry it. That last part is why this is a time rule and not a protocol +one: `sync.py` applies pushed bodies through the same check, so a client autosaving +every second cannot make the server snapshot every second either. + +Deliberately NOT applied to restoring a revision. That is a considered act rather +than a keystroke, and it snapshots unconditionally so restoring is itself undoable. +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from .models.note_revision import NoteRevision + +# How long one editing session is assumed to last. A constant rather than a setting: +# it is not a preference anyone holds, and the value only has to be longer than a +# sitting and shorter than the gap between two of them. Promote it to the settings +# registry (rule 25) if that ever stops being true. +REVISION_WINDOW_MINUTES = 10 + + +async def should_snapshot(db, note_id: uuid.UUID, old_body: str, new_body: str) -> bool: + """Whether `old_body` should be kept as a revision before `new_body` replaces it. + + False when the text did not actually change — re-saving identical bytes is not a + version of anything — and False when this note already has a revision from the + current session. + """ + if old_body == new_body: + return False + cutoff = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES) + recent = await db.scalar( + select(NoteRevision.id) + .where(NoteRevision.note_id == note_id, NoteRevision.created_at >= cutoff) + .limit(1) + ) + return recent is None diff --git a/src/thoughtsync/sync.py b/src/thoughtsync/sync.py index 775e2b3..ad46c6d 100644 --- a/src/thoughtsync/sync.py +++ b/src/thoughtsync/sync.py @@ -26,6 +26,7 @@ from .models.label import Label, NoteLabel from .models.note import Note from .models.note_item import NoteItem from .models.note_revision import NoteRevision +from .revisions import should_snapshot from .notes import ( _reconcile_tags, _serialize_notes, @@ -317,8 +318,13 @@ async def _apply_note(db, ch: dict) -> dict: note.display_title = derive_display_title(note.body, _first_item_text(ch)) if edited_at is not None: note.updated_at = edited_at - # Non-destructive LWW: snapshot the overwritten server body into history. - if not creating and note.body != old_body: + # Non-destructive LWW: snapshot the overwritten server body into history — + # subject to the same session window as a direct edit (revisions.should_snapshot). + # This path is why the window is a time rule rather than a flag on the wire: a + # client autosaving every second pushes a body change every second, and without + # the check the SERVER would snapshot each one no matter how restrained the + # client's own store was being. + if not creating and await should_snapshot(db, note.id, old_body, note.body): db.add(NoteRevision(note_id=note.id, body=old_body)) await db.flush() # assign note.id before items/labels/links await _apply_note_items(db, note, ch) diff --git a/tests/test_integration.py b/tests/test_integration.py index f99cf57..f94d0bf 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -16,6 +16,7 @@ deployment has ever seen. from __future__ import annotations import uuid +from datetime import datetime, timedelta, timezone import pytest import pytest_asyncio @@ -30,6 +31,8 @@ from thoughtsync.models.user import User from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings from thoughtsync.notes.helpers import derive_display_title from thoughtsync.models.note_link_preview import NoteLinkPreview +from thoughtsync.models.note_revision import NoteRevision +from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot from thoughtsync.sync import _apply_note_items from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls @@ -414,3 +417,70 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db): assert row["type"] == "int" assert row["minimum"] is not None and row["maximum"] is not None assert row["description"], f"{row['key']} has no description to explain itself" + + +async def _revision_count(db, note_id) -> int: + rows = (await db.scalars(select(NoteRevision.id).where(NoteRevision.note_id == note_id))).all() + return len(rows) + + +async def test_a_session_of_edits_costs_one_revision(db, owner): + """The change that makes autosave affordable. + + Version history used to snapshot on EVERY body write, so the clients saved as + rarely as they could — only when an editor closed — and a crash mid-session lost + everything typed. Durability was paying for history. Now a sitting earns one + revision no matter how many times it is written, so a client can write whenever + it likes. + """ + note = Note(owner_id=owner.id, body="one", display_title="one") + db.add(note) + await db.commit() + + # A session's worth of autosaves. + for text_ in ("one two", "one two three", "one two three four"): + if await should_snapshot(db, note.id, note.body, text_): + db.add(NoteRevision(note_id=note.id, body=note.body)) + note.body = text_ + await db.commit() + + assert await _revision_count(db, note.id) == 1 + + # And it is the body as it was BEFORE the sitting, not some midpoint — which is + # what makes one-per-session the useful granularity rather than an arbitrary one. + kept = (await db.scalars(select(NoteRevision.body).where(NoteRevision.note_id == note.id))).all() + assert kept == ["one"] + + +async def test_rewriting_the_same_text_is_not_a_version(db, owner): + note = Note(owner_id=owner.id, body="unchanged", display_title="unchanged") + db.add(note) + await db.commit() + + assert await should_snapshot(db, note.id, note.body, "unchanged") is False + assert await _revision_count(db, note.id) == 0 + + +async def test_a_later_sitting_earns_its_own_revision(db, owner): + """The window has to REOPEN, or a note edited daily would keep only its first + version forever — which would be a worse history than the one we replaced.""" + note = Note(owner_id=owner.id, body="today", display_title="today") + db.add(note) + await db.flush() + # A revision from longer ago than one session: the clock is not mocked, the row + # is simply written with an older timestamp, which is what the query reads. + stale = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES + 1) + db.add(NoteRevision(note_id=note.id, body="yesterday", created_at=stale)) + await db.commit() + + assert await should_snapshot(db, note.id, note.body, "tomorrow") is True + + +async def test_a_revision_inside_the_window_blocks_another(db, owner): + note = Note(owner_id=owner.id, body="draft", display_title="draft") + db.add(note) + await db.flush() + db.add(NoteRevision(note_id=note.id, body="earlier", created_at=datetime.now(timezone.utc))) + await db.commit() + + assert await should_snapshot(db, note.id, note.body, "draft revised") is False -- 2.54.0 From ce6a1093a3cc59e218fa1b1ce141b6ad0cc82f99 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 23 Aug 2026 22:14:02 -0400 Subject: [PATCH 05/73] android: writing a note and editing one are the same surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button raised a capture sheet with a single text field. The editor is a screen with a toolbar. So a note being WRITTEN could not be given a colour, a reminder or a checklist — those live on the toolbar, and the sheet had none. To make a checklist you wrote a note, saved it, reopened it, and found a control you had never seen. ComposeSheet is deleted. + opens the editor on an unsaved draft. A draft is a real Note carrying DRAFT_ID (the empty string) rather than a null. Note has eighteen fields and the editor reads eight of them; threading nullability through all of that to express "not saved yet" would spread the concept across a screen that should not have to know about it. A real id is a uuid, so the sentinel cannot collide. It becomes a row on its first save, and the first save is now an autosave: the editor writes a second after typing stops. That is affordable because 2707054 made a body write stop costing a revision — before it, saving this often would have meant a revision per second. Autosave is also what makes materialisation work at all. Creating the note on a toolbar tap instead races: the typed text lives in the field's own state and only reaches the view model on flush, so the tap would create an EMPTY note and lose what was written. With a one-second debounce the note already exists by the time any button is reachable. Three consequences worth naming: - editingSession, bumped only when the editor opens on a DIFFERENT note. The text field keys on it instead of note.id, because a draft's id changes the moment it is first saved and re-keying on that would reset the field to whatever the store just returned — discarding everything typed during the write. - The field is rememberSaveable now. A new note has nothing to fall back on, and the old sheet used rememberSaveable for exactly this reason; the editor inherits the requirement along with the job. - draftDismissed, so a create still in flight cannot reopen an editor the user has already closed. Starting a checklist may create an empty note — a note named from its first item is one this app already has. Colour and reminder are attributes OF a note and need words first. editor_body_hint becomes "Take a note…". It read "Note", which is a label on a blank screen where the sheet's was an invitation. Co-Authored-By: Claude Opus 5 (1M context) --- .../fabledsword/thoughtsync/MainActivity.kt | 28 +-- .../thoughtsync/ui/BoardViewModel.kt | 193 ++++++++++++++---- .../thoughtsync/ui/ComposeSheet.kt | 130 ------------ .../thoughtsync/ui/NoteEditorScreen.kt | 48 ++++- android/app/src/main/res/values/strings.xml | 5 +- 5 files changed, 204 insertions(+), 200 deletions(-) delete mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index d5d35a1..bd691e6 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -26,7 +26,6 @@ import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.thoughtsync.ui.BoardScreen import com.fabledsword.thoughtsync.ui.BoardSync import com.fabledsword.thoughtsync.ui.BoardViewModel -import com.fabledsword.thoughtsync.ui.ComposeSheet import com.fabledsword.thoughtsync.ui.ForegroundTransitions import com.fabledsword.thoughtsync.ui.NoteEditorScreen import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen @@ -141,14 +140,13 @@ private fun App( val sync: SyncViewModel = viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh)) - // Sheet and screen visibility are view STATE, not view-model state: they are - // about what is on the display, and nothing in the store cares. + // Screen visibility is view STATE, not view-model state: it is about what is on + // the display, and nothing in the store cares. Saveable so a rotation does not + // close it. // - // Saveable, though: `remember` alone meant rotating the phone closed whatever - // was open and took the half-written note in the capture sheet with it. The - // editor never had that problem because the note it is on lives in a view - // model; these two are the only screen state that did not. - var composing by rememberSaveable { mutableStateOf(false) } + // The capture sheet used to keep its own flag here too. It is gone: the + button + // opens the editor on an unsaved draft, so writing a note and editing one are the + // same surface with the same toolbar. var showingSync by rememberSaveable { mutableStateOf(false) } val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context)) @@ -192,6 +190,7 @@ private fun App( NoteEditorScreen( // Non-null by construction: `screen` is EDITOR only when it is. note = requireNotNull(editing) { "the editor screen needs a note" }, + sessionKey = board.state.editingSession, labels = board.state.labels, saving = board.state.saving, error = board.state.error, @@ -218,20 +217,9 @@ private fun App( ), onOpenSync = { showingSync = true }, onSearch = board::search, - onCompose = { composing = true }, + onCompose = board::compose, onDismissError = board::dismissError, ) - - if (composing) { - ComposeSheet( - saving = board.state.saving, - onDismiss = { composing = false }, - onSave = { content -> - board.create(content) - composing = false - }, - ) - } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index a76bf5f..eb18701 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -68,6 +68,16 @@ data class BoardState( * has to re-query to see its own change. */ val editing: Note? = null, + /** + * Bumped each time the editor is opened on a DIFFERENT note, and deliberately + * not when the note it is already on changes. + * + * The editor keys its text field on this rather than on `editing.id`, because a + * draft's id changes the instant it is first saved — and re-keying on that would + * reset the field to whatever the store just returned, discarding anything typed + * during the write. That is a data-loss bug rather than a flicker. + */ + val editingSession: Long = 0, ) { /** Search overrides the destination while there is a query to run. */ val searching: Boolean get() = query.isNotBlank() @@ -188,44 +198,6 @@ class BoardViewModel( } } - /** - * Save a new note or list. - * - * Blank input is ignored rather than rejected: an empty save is a slip, not a - * mistake worth interrupting someone over. - */ - fun create(content: String) { - val cleanContent = content.trim() - if (cleanContent.isEmpty()) return - - viewModelScope.launch { - state = state.copy(saving = true) - state = - try { - val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) } - // Prepend rather than reload: the new note belongs at the top - // of the board, and a full re-query would cost a round trip to - // tell us what we already know. Skipped when the board is not - // showing plain notes — a note created while looking at Trash - // does not belong in that list. - val notes = - if (state.destination == Destination.Notes && !state.searching) { - listOf(created) + state.notes - } else { - state.notes - } - // A capture sheet can carry a reminder in its text one day; - // more to the point, this is a store write and the rule here is - // that every store write re-derives the alarm rather than each - // call site deciding whether its particular write could matter. - withContext(Dispatchers.IO) { onRemindersChanged() } - state.copy(notes = notes, saving = false, error = null) - } catch (e: Exception) { - state.copy(saving = false, error = e.message ?: FALLBACK_ERROR) - } - } - } - // ─────────────────────────────── the editor ────────────────────────────── /** @@ -238,12 +210,119 @@ class BoardViewModel( fun openNoteById(id: String) { viewModelScope.launch { runCatching { withContext(Dispatchers.IO) { core.getNote(id) } } - .onSuccess { state = state.copy(editing = it) } + .onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) } } } fun openNote(note: Note) { - state = state.copy(editing = note) + state = state.copy(editing = note, editingSession = state.editingSession + 1) + } + + /** + * Open the editor on a note that does not exist yet. + * + * The + button used to raise a separate capture sheet, which meant a note being + * WRITTEN could not be given a colour, a reminder or a checklist — those live on + * the editor's toolbar, and the sheet had none. Writing and editing are now the + * same surface. + * + * The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the + * editor renders it without knowing that "not saved yet" is a state it can be + * in. It becomes a row on its first save; see [onDraftAction]. + */ + fun compose() { + draftDismissed = false + state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1) + } + + /** + * Set when a draft's editor closes, so a create still in flight does not reopen + * it. The editor flushes its text and then closes, and the flush is a coroutine — + * without this the note would be created, the screen would close, and the create + * would finish and put the screen back. + */ + private var draftDismissed = false + + /** + * The editor's actions, for a note that has no row yet. + * + * Everything a toolbar button does needs an id to act on, so the first action + * that needs one creates the note and replays itself against the real thing. + */ + private fun onDraftAction( + draft: Note, + action: EditorAction, + ) { + when (action) { + // Nothing exists, so leaving leaves nothing behind — which is what makes + // tapping + and changing your mind free. Text typed before this point has + // already gone to createFromDraft via the editor's autosave or its flush. + EditorAction.Close, EditorAction.Trash -> { + draftDismissed = true + state = state.copy(editing = null) + } + EditorAction.DismissError -> dismissError() + is EditorAction.SaveText -> createFromDraft(action.body) + // Starting a checklist is the one toolbar action that means something on + // a note with no text: a note whose whole content is its items is a note + // this app already has (it is named from its first item). So it may + // create an empty one — anything else needs words first. + EditorAction.AddChecklist -> + createFromDraft(draft.body, allowEmpty = true) { created -> + onEditorAction(created, action) + } + // Colour, reminder, pin, labels: attributes OF a note, so there has to be + // a note. With autosave at a second, "typed something" is true by the time + // anyone reaches the toolbar; before that there is nothing to attribute. + else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) } + } + } + + /** + * Turn a draft into a row, and keep the editor on it. + * + * Adopting the created note is what lets a session of autosaves stay one note: + * the second save sees a real id and updates rather than creating again. + */ + private fun createFromDraft( + content: String, + allowEmpty: Boolean = false, + then: (Note) -> Unit = {}, + ) { + val cleanContent = content.trim() + // A blank draft is not a note. Ignored rather than rejected: tapping + and + // walking away is a slip, not a mistake worth interrupting someone over. + if (cleanContent.isEmpty() && !allowEmpty) return + viewModelScope.launch { + state = state.copy(saving = true) + state = + try { + val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) } + // Prepend rather than reload: the new note belongs at the top of + // the board, and a full re-query would cost a round trip to tell + // us what we already know. Skipped when the board is not showing + // plain notes — a note created while looking at Trash does not + // belong in that list. + val notes = + if (state.destination == Destination.Notes && !state.searching) { + listOf(created) + state.notes + } else { + state.notes + } + withContext(Dispatchers.IO) { onRemindersChanged() } + // editingSession is NOT bumped: this is the same sitting, and the + // editor's field must not be re-keyed underneath the typing. + state.copy( + notes = notes, + editing = if (draftDismissed) state.editing else created, + saving = false, + error = null, + ) + } catch (e: Exception) { + state.copy(saving = false, error = e.message ?: FALLBACK_ERROR) + } + if (!draftDismissed) state.editing?.let(then) + } } /** @@ -268,6 +347,10 @@ class BoardViewModel( note: Note, action: EditorAction, ) { + if (note.id == DRAFT_ID) { + onDraftAction(note, action) + return + } val id = note.id when (action) { EditorAction.Close -> state = state.copy(editing = null) @@ -447,3 +530,33 @@ private fun draft(content: String): NoteDraft = // findable without anyone being asked to name it. A checklist is added afterwards, // in the editor — it is something a note HAS, not a different thing to capture. NoteDraft(body = content, color = DEFAULT_COLOR, items = null) + +/** + * The id a note has before it has been saved. + * + * A real id is a uuid, so the empty string cannot collide with one. Using a sentinel + * rather than making the editor's note nullable keeps "not saved yet" out of a screen + * that reads eight fields off the note and should not have to null-check any of them. + */ +internal const val DRAFT_ID = "" + +private fun blankDraft(): Note = + Note( + id = DRAFT_ID, + displayTitle = "", + body = "", + color = DEFAULT_COLOR, + position = 0, + pinned = false, + archived = false, + trashed = false, + deletedAt = null, + remindAt = null, + recurrence = null, + labels = emptyList(), + items = emptyList(), + attachments = emptyList(), + previews = emptyList(), + createdAt = null, + updatedAt = null, + ) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt deleted file mode 100644 index 4d86514..0000000 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.fabledsword.thoughtsync.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.fabledsword.thoughtsync.R - -/** - * The new-note surface, opened by the + button. - * - * A bottom sheet rather than a full screen: capture should feel like a quick aside - * from the board, not a place you navigate to and have to come back from. The - * board stays visible behind it, so the note lands somewhere you can already see. - * - * It asks note-or-list up front rather than making that a mode you discover later, - * because on a phone the two are genuinely different typing tasks and switching - * halfway is worse than choosing at the start. - * - * ## Leaving keeps what you wrote - * - * Every way out of this sheet except Discard SAVES: the save button, tapping the - * board behind it, swiping down, back, and the app being backgrounded. A sheet - * that throws away a typed thought because you touched outside it is a sheet that - * teaches people not to trust the app with a thought — and capture is the one - * place this product cannot afford that. - * - * The same shape the editor settled on, for the same reason, with one difference: - * capture also has to be abandonable, because tapping + and changing your mind is - * a normal thing to do. That is what Discard is, and it is the only path that - * loses anything. An empty draft needs neither — it is simply dropped, since a - * blank note nobody asked for is worse than no note at all. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ComposeSheet( - saving: Boolean, - onDismiss: () -> Unit, - onSave: (String) -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - // Saveable, not just remembered: a rotation mid-sentence is the same lost - // thought as a discarded one, and it was losing it before this. - var content by rememberSaveable { mutableStateOf("") } - val contentFocus = remember { FocusRequester() } - - val written = content.isNotBlank() - val leave = { if (written) onSave(content) else onDismiss() } - - // Straight into the one field there is. A capture is a thought, and every field - // someone has to tab past is the difference between "under a second" and not — - // which is why the title field is gone rather than merely skipped (M13 step 3). - LaunchedEffect(Unit) { contentFocus.requestFocus() } - - // Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped - // + and then got distracted should find the composer where they left it; the - // only reason to act here is that there is something to lose. - FlushOnStop { if (written) onSave(content) } - - ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .imePadding() - .navigationBarsPadding(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // No note/list switch any more: there is one thing to capture. A - // checklist is added to a note in the editor, once there is a note. - PlainTextField( - value = content, - onValueChange = { content = it }, - modifier = Modifier.focusRequester(contentFocus), - hint = R.string.compose_body_hint, - minLines = MIN_CONTENT_LINES, - ) - - SheetActions( - canSave = !saving && written, - onDiscard = onDismiss, - onSave = { onSave(content) }, - ) - } - } -} - -@Composable -private fun SheetActions( - canSave: Boolean, - onDiscard: () -> Unit, - onSave: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.End, - ) { - // "Discard", not "Cancel". Cancel means "undo what I am doing", which is - // precisely what leaving no longer does — the word would now describe the - // one button it is NOT attached to. - TextButton(onClick = onDiscard) { Text(stringResource(R.string.compose_discard)) } - Button(onClick = onSave, enabled = canSave) { - Text(stringResource(R.string.compose_save)) - } - } -} - -private const val MIN_CONTENT_LINES = 4 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 6e4c4a5..6edbf02 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -38,6 +39,7 @@ import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.Label import com.fabledsword.thoughtsync.core.Note +import kotlinx.coroutines.delay /** * The note editor: a full screen, not a sheet. @@ -55,6 +57,7 @@ import com.fabledsword.thoughtsync.core.Note @Composable fun NoteEditorScreen( note: Note, + sessionKey: Long, labels: List