The review-identified race: saves are async (owner snapshots content,
worker pool writes), nothing serialized per file, and every write of a
file used the SAME deterministic temp ('.<name>.tmp'). Two overlapping
writes (autosave x autosave, retry x autosave, or the synchronous
FlushAll on GoToBrowser/Shutdown x a worker write) interleaved on the
shared temp and could rename a byte-mixture into place; even without
interleaving, last-rename-wins could promote a STALE snapshot.
Owner-side protocol (logic.go, requestSave + result handler):
- at most one write in flight per file (writeInFlight maps filename ->
the file version whose content the in-flight write carries);
- a save requested while one is in flight is deferred (savePending) and
re-issued by the write's result handler with a FRESH snapshot, so
'last rename wins' coincides with 'newest snapshot wins';
- on success the SNAPSHOT version (not the current one) is recorded as
written, so an edit that arrived during the write leaves the file
dirty and triggers the re-issue;
- FlushAll (GoToBrowser, Shutdown) defers via the same protocol instead
of writing concurrently on the shared temp;
- shutdown drain: on done the owner waits (bounded 5 s) for in-flight
writes and armed retries to settle before exiting, so the post-exit
synchronous FlushAll and workerPool.Stop cannot race a straggling
worker write;
- retry timer now sends a non-blocking token (no timer-goroutine stall
on a full channel); emitFrame no longer blocks on a slow/gone main
(frames are snapshots; the next emission wins) - also required so the
drain can never deadlock on frame delivery.
Mechanism (real/filesystem.go):
- WriteFileAtomic uses a unique per-call temp ('.<name>.tmp.<pid>.<seq>'),
making same-file staging-file interleaving structurally impossible even
if the serialization regressed (defense in depth);
- each successful write best-effort removes stale temps of the same file
(crash leftovers, plus the legacy deterministic name for upgraded
installs); a failed write removes its own temp.
Tests:
- write_serialization_test.go (e2e): a counting FS wrapper proves the
peak concurrent same-file saves is 1 across two deliberately
overlapping autosaves (2 s saves; the second edit lands inside the
first save's window and its token is deferred, then re-issued with the
newer content), and that a Flush during an in-flight save adds no
concurrent writer and the newest snapshot still wins. Mutation-verified:
disabling the deferral fails it with peak = 2. (The pool's WriteFileTask
calls FS.WriteFile, not WriteFileAtomic - the real FS is atomic only
because WriteFile delegates to WriteFileAtomic; the wrapper mirrors that
delegation or the overlap window does not exist.)
- filesystem_test.go: stale-temp test updated to the new pattern, also
covering the legacy name and asserting a different file's temp is
untouched.
- real_file_fuzz_test.go: stray-temp check matches both patterns.
Docs: architecture.md 6.5 rewritten (protocol invariants), spec.md
autosave line, development_plan.md v11 + Phase 12.
On-device smoke: open, type, autosave lands exact content on disk, no
temp files left, clean relaunch. Full suite green under -race.
Residuals (documented): no fsync before rename (power-loss window only);
external-change detection absent; a drain-deadline exit with a straggling
write can only lose freshness (unique temps keep every rename a complete
snapshot).
183 lines
9.9 KiB
Markdown
183 lines
9.9 KiB
Markdown
# Pad — Text Editor Specification
|
||
|
||
This is the specification of what Pad **actually is** (v1, current code).
|
||
Behavior that was once spec'd but never built is listed as deferred in §7, not
|
||
described as a requirement. If code and this document disagree, the code wins
|
||
— and this document should be fixed.
|
||
|
||
## 1. Overview
|
||
|
||
Pad is a minimal plain-text editor for Android, written in Go with the Gio UI
|
||
framework. It presents a single root directory of text files (a browser) and a
|
||
full-screen editor with the Android soft keyboard. The design goal is a
|
||
fast, honest editor for a directory that is synced across devices — instant
|
||
open, autosave on every edit, and large files that stay smooth.
|
||
|
||
**Design philosophy:**
|
||
|
||
- **Plain text only** — no formatting, no syntax highlighting.
|
||
- **Automatic everything** — autosave with a 1 s debounce; there is no save
|
||
button.
|
||
- **Minimal surface area** — a browser and an editor, nothing else.
|
||
- **Bounded memory** — a measured file-size limit with an explicit
|
||
"too large" state, instead of silently degrading or OOMing.
|
||
|
||
**Platform:** Android-first. Window `390×844` dp. Default root directory is
|
||
`/storage/emulated/0/Notes` on Android (overridable with `-root`); `.`
|
||
elsewhere.
|
||
|
||
## 2. Implemented behavior
|
||
|
||
### 2.1 File browser
|
||
|
||
- Lists files and directories under the current directory.
|
||
- **Sort:** four modes — name asc/desc, date asc/desc — cycled by the sort
|
||
button. Default: date descending (newest first).
|
||
- **Search:** incremental, case-insensitive filter over entry names via the
|
||
search bar (a Gio `widget.Editor`).
|
||
- **Pagination:** directory entries are loaded asynchronously in pages through
|
||
the worker pool; the first paint does not wait for a full directory read.
|
||
- **Navigation:** tap a directory to enter it, back to return. Single tap
|
||
opens a file — no long-press menus, no file management actions.
|
||
|
||
### 2.2 Editor
|
||
|
||
- **Input via the Android IME:** soft-keyboard typing, composition,
|
||
backspace, and autocorrect replacements all arrive through one IME
|
||
replace-range path and are converted from rune indices to byte offsets.
|
||
(Swipe-typing support follows from the same IME path; final sign-off on a
|
||
physical device is the one open validation item.)
|
||
- **Word wrap:** on by default; wrapped lines are virtual — the buffer stores
|
||
only real newlines.
|
||
- **Virtualized viewport:** only the visible byte range is shaped and drawn
|
||
each frame (typically ~4 KB of a large file), keeping frame cost and shaper
|
||
memory constant regardless of file size.
|
||
- **Cursor + scroll:** tap to place the cursor, drag/scroll to pan; with a
|
||
hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor
|
||
(verified on the Android emulator; Gio's mobile focus-navigation default
|
||
for arrow keys is overridden — see architecture.md §2.1).
|
||
- **Text selection:** two input paths. **Touch** (the Android-native model):
|
||
long-press selects the word under the finger (on a blank spot it places the
|
||
caret and offers a paste-only menu); double-tap selects the word; the
|
||
selection has drag handles at both ends (resize) and the highlighted body
|
||
can be dragged to move it (length preserved); a floating menu offers
|
||
copy/cut/paste when a selection is active and paste alone for a bare caret.
|
||
Any menu tap closes the menu; copy keeps the selection, cut removes it, and
|
||
paste inserts at the caret or replaces the selection. A plain tap still
|
||
places the caret and clears any selection; taps inside the visible menu are
|
||
ignored by the editor. **Hardware keyboard:** shift+arrow extends a
|
||
selection. In both cases insert, backspace, and delete replace the selected
|
||
text, and the IME replaces a selection when the user types over it. The
|
||
selection is highlighted in the editor and pushed to the IME. (Shift state
|
||
is tracked by the app, since Gio's Android bridge drops modifier keys —
|
||
architecture.md §2.1.)
|
||
- **Autosave:** every edit restarts a 1 s debounce; on expiry the full content
|
||
is written to disk by a worker. At most one write per file is in flight at
|
||
any time; saves requested during a write are deferred and re-issued with
|
||
the newer content when it completes ("latest state wins"). Writes stage to
|
||
a unique per-call temp file and rename into place, so a crash or a
|
||
concurrent reader never observes a partial file. Failed writes are retried.
|
||
This is the only persistence mechanism.
|
||
|
||
### 2.3 Large files (measured)
|
||
|
||
- Editable limit: **50 MB** (`MaxEditableFileSize`), measured on-device:
|
||
a 10 MB file (130,954 lines) opens in ~120 ms (stat ~76 ms, read ~27 ms,
|
||
line-index ~18 ms) and idles at **~150 MB PSS / ~230 MB RSS, flat under
|
||
scroll**. 50 MB extrapolates to a few hundred MB — acceptable on a modern
|
||
phone.
|
||
- Files above the limit open into a **"too large to edit"** state: a notice is
|
||
shown, edit operations are no-ops, and the browser still lists the file.
|
||
- The buffer is chunked (64 KB chunks, prefix-sum offsets, full load for
|
||
in-range files); edits splice only affected chunks. Details:
|
||
`architecture.md` §6.
|
||
|
||
## 3. Code organization (actual)
|
||
|
||
```
|
||
cmd/pad/ # Gio entry point, main loop, frameReceiver,
|
||
# Android defaults (impl_android.go)
|
||
internal/
|
||
browser/ # BrowserState, BrowserManager, sort, search,
|
||
# pagination, layout, handlers
|
||
editor/ # Logic goroutine, State, ChunkedBuffer, LineIndex,
|
||
# IME handling, autosave, Frame handoff (frame.go)
|
||
io/pool/ # Worker pool (priority lanes), task types,
|
||
# real/ — real filesystem (rooted at /)
|
||
# mock/ — in-memory FS for tests
|
||
# types/ — shared types (LineIndex, ...)
|
||
ui/ # Element model, Renderer, units (Dp/Px), theme
|
||
ui/icons/ # Vector icons
|
||
test/e2e/ # Logic-level e2e harness + tests
|
||
doc/ # spec.md (this file), architecture.md,
|
||
# development_plan.md
|
||
```
|
||
|
||
## 4. Architecture (summary)
|
||
|
||
The logic goroutine is the **sole owner** of mutable state; the main (Gio)
|
||
goroutine reads only a `Frame` snapshot and writes only via channels; a
|
||
frame-receiver goroutine stores frames and invalidates the window; a
|
||
priority-aware worker pool does all file I/O. No mutex guards application
|
||
state. The IME snippet, search box, and gesture state live on the
|
||
main-goroutine side because Gio mutates them during draw.
|
||
|
||
Full details, channel topology, and ownership rules: [`architecture.md`](./architecture.md).
|
||
|
||
## 5. Invariants to preserve (future development must not break these)
|
||
|
||
1. **Single owner, no locks on state** (architecture.md §1). Any new feature
|
||
adds a channel or an owner-executed callback — never a direct state touch
|
||
from another goroutine.
|
||
2. **Main never reads logic `State`** — only the `Frame` snapshot.
|
||
3. **Never shape the whole file.** The text shaper's internal line storage
|
||
retains the largest layout ever performed; one whole-file layout permanently
|
||
inflates memory (this caused a 1 GB leak, fixed in Phase 3).
|
||
4. **The IME snippet is the visible window**, and rune↔byte conversion happens
|
||
in one place (`HandleReplaceRange` / `RuneIndexToByte`).
|
||
5. **Autosave is the only persistence.** If state persistence (last file,
|
||
cursor, scroll) is added later, it must go through the same
|
||
owner-dispatches-a-task pattern.
|
||
6. **Logic work stays < 16 ms.** Anything that can block or scan more than the
|
||
viewport goes to the worker pool.
|
||
7. **Scroll offset is always clamped to `[0, maxScroll]`,** where
|
||
`maxScroll = contentHeight − viewportHeight` floored at 0. A file whose
|
||
content fits the viewport has `maxScroll = 0` and cannot scroll. Verified
|
||
on-device across 2→130,955 lines (`development_plan.md` Phase 6).
|
||
|
||
## 6. Performance expectations (validated on-device)
|
||
|
||
| Operation | Target | Measured (10 MB file, Android 35 emulator) |
|
||
|---|---|---|
|
||
| Open file | instant feel | ~120 ms (stat + read + line index) |
|
||
| Scroll | 60 fps | logic-frame cadence flat across offsets 0.02→1.0 — **no large-offset degradation**; present fps is emulator-limited (~14–27 on the software-rendered emulator, not a Pad quality metric) |
|
||
| Type | responsive | single IME path; rapid commits land cleanly |
|
||
| Memory | bounded | PSS plateaus ~250 MB at a 10 MB file (bounded high-water mark, no growth under sustained scroll; no OOM) |
|
||
|
||
## 7. Deferred / not implemented (explicit non-goals for v1)
|
||
|
||
These were in the original v1 spec but are **not in the code**. They are
|
||
recorded here so future rounds don't mistake doc text for behavior:
|
||
|
||
| Feature | Status | Notes |
|
||
|---|---|---|
|
||
| Undo (any) | **not implemented** | No undo stack exists; the old `SaveUndoTask` is dead code. |
|
||
| State restoration on relaunch | **not implemented** | Last file / cursor / scroll are not persisted. |
|
||
| External change detection | **not implemented** | No mtime compare on open/resume, no watcher, no Keep/Reload prompt. |
|
||
| Syncthing conflict handling | **not implemented** | No `.sync-conflict-*` file detection or merging. |
|
||
| File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. |
|
||
| Alphabetical index sidebar | **not implemented** | `AlphaIndex` element exists but is unused. |
|
||
| In-file search, tabs, split view | **not implemented** | — |
|
||
| Files > 50 MB | **not supported** | `TooLarge` state instead. |
|
||
| Desktop / other platforms | **not supported** | Android-first. |
|
||
|
||
## 8. Product requirements (standing)
|
||
|
||
| Requirement | Detail |
|
||
|---|---|
|
||
| Instant open | Files open without visible lag; content and line index load async. |
|
||
| Auto-save | Every edit persisted with a 1 s debounce; no save button. |
|
||
| External change awareness | Deferred (§7) — the sync-awareness story is explicitly out of v1. |
|
||
| Plain text only | No formatting, no highlighting, no file management UI. |
|
||
| Bounded memory | 50 MB editable limit with an explicit "too large" state. |
|