Spec: resolve open design decisions

- Line index: cached on disk in .pad/indices/, invalidated by mtime/size,
  incremental updates during editing sessions
- Atomic writes: temp files live in .pad/tmp/, excluded from Syncthing via .stignore
- State storage: multi-file JSON approach (state.json, cursors.json, undo/*.json)
  over SQLite — simpler, debuggable, sufficient for expected workload
- Undo limits: 1 MB inline cap on deleted field; larger deletions stored in
  separate backup files; 10 MB total disk budget for undo directory
- Added search and word wrap sections to the editor spec
- Updated performance guarantees with search/jump targets
This commit is contained in:
Greg Pomerantz 2026-05-07 20:23:24 -04:00
parent dfc112f5d4
commit 485fc59e06
2 changed files with 184 additions and 38 deletions

View File

@ -75,13 +75,17 @@ Only lines visible on screen (plus a small prefetch buffer) are rendered. Scroll
### Line Index
On first open, the editor builds a line-offset index (mapping line number → byte offset). This index is:
The editor maintains a line-offset index (line number → byte offset in file) for every file it has opened. This index is:
- Built in a single pass over the file (streaming, O(n) time, O(1) memory per line)
- Cached on disk for subsequent opens
- Invalidated when the file is edited (rebuilt on next open, or incrementally updated)
- **Built** in a single streaming pass over the file (O(n) time, O(lines) space for the index array)
- **Cached** on disk inside `.pad/indices/` (one file per indexed file, named by the hash of the original path)
- **Invalidated** when the file's modification time or size changes (detected on open)
- **Updated incrementally** during editing sessions (insert/delete shifts subsequent offsets in the in-memory copy)
- **Evicted** under disk pressure (LRU by last access time)
The index enables O(1) or O(log n) jumps to any line number.
The index enables O(log n) jumps to any line number via binary search. Without it, finding line N in a large file requires scanning from the start — O(N) in bytes. For a 1 GB file with average 50-byte lines, that's ~20M lines and 20 MB of index data (4 bytes per line as uint32). This is acceptable on modern devices.
During an editing session, the in-memory index is updated with each edit. On file close, the updated index is written back. On next open, if the disk file matches the index's mtime/size stamp, the cached index is reused.
### Input
@ -90,6 +94,24 @@ The index enables O(1) or O(log n) jumps to any line number.
- Paste: clipboard content inserted at cursor or replacing selection
- No keyboard shortcuts beyond what the OS input method provides
### Word Wrap
Long lines are wrapped visually at word boundaries within the viewport width. This is a display-only feature — no newlines are inserted into the underlying text or written to disk. The logic layer computes wrapped display lines from the raw text, region width, and estimated character advance.
Word wrap is **on by default**. Users can toggle it off for horizontal scrolling instead.
### Search
The editor has an in-editor search bar (appears below the header when activated). It contains a text field and up/down arrow buttons. Behavior:
1. As the user types, the logic layer finds all occurrences of the query in the loaded buffer
2. The cursor jumps to the first match immediately
3. Up/down arrows cycle through matches (up = previous, down = next)
4. Each keystroke that changes the query re-runs the search and jumps to the first match
5. The search bar displays `Match N of M` so the user knows their position
For large files, search runs over the currently loaded chunks. If a match exists in an unloaded region, the editor loads that chunk and scrolls to it.
## 6. Undo System
The undo system records reversible edit operations with context anchoring, persisted to disk for cross-session survival.
@ -175,12 +197,28 @@ To undo, pop the top chain from the stack and replay in reverse:
|---|---|---|
| Max operations | 1000 | Sufficient for ~1000 typing sessions or individual keystrokes |
| Context window | 50 bytes each side | Unique enough for prose/code, small enough for persistence |
| `deleted` field | **No cap** | Large deletions are the most critical undo case; the philosophy of the editor is no practical limits |
| `deleted` field size | **1 MB inline** | Below: stored directly in the operation. Above: stored in a separate backup file (see below) |
| Total undo disk budget | 10 MB | Per `.pad/undo/` directory; oldest files evicted on excess |
| Redo | Not supported | Can be added later; keeps initial implementation minimal |
**Large deletion handling**: When a single deletion exceeds 1 MB, the deleted text is written to `.pad/undo/<hash>_backup_<seq>.bin` and the operation records a `deleted_file` path instead of inline text. The backup file is cleaned up when the operation is popped (by undo or eviction). This preserves the "no practical limits" philosophy — even a 4 GB file deletion is undoable — while keeping the serialized undo stack small enough for fast persistence.
```json
{
"type": "delete_chain",
"pos": 0,
"after": "...",
"deleted_file": ".pad/undo/abc123_backup_001.bin",
"deleted_size": 4294967296,
"entries": []
}
```
The `deleted_size` field is used for display (e.g., toast: "Undo: 4.0 GB restored") and for eviction decisions.
### 6.6 Persistence
The undo stack is serialized into the state file (see §8) and persisted on every edit, debounced separately from auto-save. On restore, each operation's context is verified; operations that cannot be anchored are silently dropped from the stack.
The undo stack for the active file is serialized to `.pad/undo/<hash>.json` (see §8) and persisted on every edit, debounced separately from auto-save. On restore, each operation's context is verified; operations that cannot be anchored are silently dropped from the stack.
The stack survives:
- Process kill by the OS
@ -191,6 +229,7 @@ The stack survives:
The stack does **not** survive:
- Explicit user action to clear it (if such a feature is added)
- External file changes that make anchoring impossible (text context no longer exists)
- Eviction due to disk budget exceeded (oldest undo files pruned first)
## 7. Auto-save
@ -199,30 +238,54 @@ Every edit to a file is written to disk automatically.
### Behavior
- **Debounce**: 5001000 ms after the last keystroke (configurable)
- **Atomic write**: write to a temporary file, then rename over the target (prevents corruption if killed mid-write)
- **Atomic write**: write to a temporary file inside `.pad/tmp/`, then rename over the target. The `.pad/` directory is excluded from Syncthing sync (via `.stignore`), so temp files are invisible to the sync daemon and do not generate spurious sync events
- **No UI**: there is no save button, no unsaved indicator, no "do you want to save?" dialog
### Interaction with Undo
Auto-save writes the current file state. The undo stack is persisted separately. Undo works transparently across saves — saving does not "commit" or "clear" undo history.
### Syncthing Compatibility
The `.pad/` directory contains editor-internal data (state, indices, temp files) and is excluded from sync. Syncthing ignores it via a `.stignore` file placed in the configured directory:
```
.pad/
```
This ensures:
- Temp files used for atomic writes are never synced
- State and index files remain local to the device
- Only the user's text files are synchronized across devices
## 8. State Management
The editor continuously persists its state to a small file (`.pad/state.json`) inside the configured directory.
The editor persists its state across multiple files inside `.pad/`. This multi-file approach avoids the overhead of a database dependency and keeps each file small enough for fast atomic writes.
### Persisted State
### Directory Layout
```
.pad/
state.json # App state (active file, cursor, scroll, browser)
cursors.json # Per-file cursor map
undo/
<file_hash>.json # Undo stack for a specific file (one per actively edited file)
indices/
<file_hash>.bin # Line-offset index (see §5)
tmp/
# Temp files for atomic writes
```
All `.pad/` contents are excluded from Syncthing sync (see §7).
### App State (`state.json`)
```json
{
"active_file": "path/to/open/file.txt",
"cursor_pos": 1024,
"scroll_offset": 500,
"file_cursors": {
"path/to/file1.txt": 2048,
"path/to/file2.txt": 512
},
"browser_scroll": 1200,
"undo_stack": [ ... ]
"browser_scroll": 1200
}
```
@ -231,24 +294,75 @@ The editor continuously persists its state to a small file (`.pad/state.json`) i
| `active_file` | Path of the currently open file (relative to configured directory) |
| `cursor_pos` | Byte offset of the cursor in the active file |
| `scroll_offset` | Vertical scroll position (line or pixel offset) |
| `file_cursors` | Map of file path → last cursor position, for all visited files |
| `browser_scroll` | Scroll position in the directory browser |
| `undo_stack` | Serialized undo operations (see §6) |
### Cursor Map (`cursors.json`)
```json
{
"path/to/file1.txt": 2048,
"path/to/file2.txt": 512
}
```
Map of file path → last cursor position. Evicts oldest entries to stay under 1000 entries.
### Undo Stacks (`undo/<hash>.json`)
Each actively edited file gets its own undo stack file, named by the SHA-256 hash of the file path. This keeps undo data isolated per-file and avoids serializing all undo stacks into one large blob.
```json
{
"file": "path/to/open/file.txt",
"file_mtime": 1715000000,
"file_size": 50000,
"operations": [
{
"type": "insert_chain",
"pos": 1024,
"before": "...",
"after": "...",
"entries": [
{"inserted": "h"},
{"inserted": "e"},
{"inserted": "l"},
{"inserted": "l"},
{"inserted": "o"}
]
}
]
}
```
The `file_mtime` and `file_size` fields are used to detect if the on-disk file has changed since the undo stack was last used (external update or sync conflict). If they differ, the stack is discarded.
Old undo stack files are evicted when disk usage exceeds a budget (e.g., 10 MB total), using LRU by last access time.
### Why Not SQLite?
SQLite (via `modernc.org/sqlite`) was considered but rejected for the initial implementation:
- **Simplicity**: JSON files are human-readable, trivially debuggable, and require no schema migrations
- **Workload**: The editor performs sequential appends to undo stacks and point reads for state — JSON files handle this well
- **Atomicity**: Temp+rename gives us crash-safe writes without a transaction log
- **Dependency**: One fewer dependency in the Go module
If profiling reveals that JSON serialization or file I/O is a bottleneck (unlikely for the expected workload), SQLite can be adopted later with minimal API changes.
### Write Strategy
- **Continuous**: state is written on every navigation, edit, or cursor movement
- **Debounced**: writes are batched with a short debounce (separate from auto-save debounce)
- **Atomic**: write to temp file, then rename (same as auto-save)
- **Bounded**: `file_cursors` evicts oldest entries to stay under a size limit (e.g., 1000 entries). `undo_stack` is capped at 1000 operations.
- **Atomic**: write to temp file in `.pad/tmp/`, then rename (same as auto-save)
- **Bounded**: `cursors.json` evicts oldest entries (max 1000). Undo stacks are capped at 1000 operations per file and evicted by total disk size.
### Restore Flow
1. App launches → read state file
1. App launches → read `state.json`
2. If `active_file` exists on disk → open it, load chunk around `cursor_pos`, place cursor
3. If `active_file` was deleted → show browser at `browser_scroll` position
4. If no state file exists → show browser at root
5. Undo stack is loaded and verified; unanchorable operations are dropped
5. Load undo stack from `undo/<hash>.json`; verify file mtime/size match; drop stack if mismatched
## 9. File System Monitor
@ -305,6 +419,8 @@ When an external change is applied to the buffer:
| Scroll | 60 fps | Virtual rendering: only visible lines + prefetch buffer |
| Type | < 16 ms per keystroke | In-memory chunk edit, async chunk flush |
| Search in directory | < 100 ms for 100k files | Lazy index built on first browse, cached on disk |
| Search in file | < 50 ms for 1M lines | In-memory scan of loaded chunks; line index for navigation |
| Jump to line | < 10 ms | Binary search on cached line-offset index |
| Undo | < 100 ms | Context search is O(n) in file size but bounded by context window |
| State restore | < 500 ms | Read state file, open file, load chunk, place cursor |
| Auto-save | < 100 ms | Debounced atomic write |
@ -314,15 +430,16 @@ When an external change is applied to the buffer:
| Component | Budget |
|---|---|
| Chunk cache | Configurable, e.g., 1664 MB |
| Undo stack | ~200 KB for 1000 ops with 50B context (excluding large `deleted` fields) |
| State file | ~200 KB typical, bounded by largest deletion in undo window |
| Line index | ~4 bytes per line (stored on disk, paged into memory) |
| Undo stack (in memory) | ~200 KB for 1000 ops with 50B context (excluding large `deleted` fields) |
| Undo stack (on disk) | 10 MB total budget across all files |
| State files | < 1 KB each (`state.json`, `cursors.json`) |
| Line index | ~4 bytes per line (stored on disk, loaded fully for active file) |
## 12. Out of Scope
- Syntax highlighting, themes, or formatting
- Multiple tabs or split views
- Find and replace (may be added later)
- Find and replace (search is in scope; replace may be added later)
- File management (create, delete, rename, move) — handled by external tools
- Cloud sync — handled by external tools (Syncthing, Dropbox, etc.)
- Collaborative editing

View File

@ -91,6 +91,8 @@ type TextField struct {
Multiline bool // true = full-height text area (editor)
ScrollOffset unit.Dp // vertical scroll position (Dp)
VisibleLines []Line // for multiline: the lines to render
WordWrap bool // true = wrap long lines visually (default)
WrapWidth unit.Dp // width at which wrapping occurs (auto if 0)
}
type Line struct {
@ -103,7 +105,28 @@ Used for: search bar (`Multiline: false`), editor buffer (`Multiline: true`).
The editor's `TextField` only contains lines visible in the current viewport. The logic layer determines which lines to include based on scroll offset and viewport height.
### 3.3 Cursor
**Word wrap**: when `WordWrap` is true, long lines are broken visually at word boundaries within the element's region width. This is a display-only feature — no newlines are inserted into the underlying text. The logic layer computes wrapped display lines from the raw text, region width, and estimated character advance. Users can toggle word wrap off (horizontal scroll instead).
### 3.3 Search Bar
```go
type SearchBar struct {
/* region, visible, id — unexported */
Query string // current search text
Match int // current match index (0-based)
Total int // total number of matches
Forward bool // true = last search was forward, false = backward
}
```
Used for: in-editor text search. Appears as a narrow bar below the header. Contains a text field and up/down arrows. The logic layer:
1. Finds all occurrences of `Query` in the visible buffer (or full file for small files)
2. Sets `Match` to the current position in the match list
3. Moves the editor cursor to the matched text on each keystroke or arrow tap
4. Up arrow = previous match, down arrow = next match
### 3.4 Cursor
```go
type Cursor struct {
@ -122,7 +145,7 @@ type Selection struct {
Rendered as an overlay inside a `TextField`. The logic layer computes cursor position from byte offset.
### 3.4 Lists
### 3.5 Lists
```go
type ListView struct {
@ -141,7 +164,7 @@ type ListItem struct {
Used for: directory browser. Only contains items for the current viewport.
### 3.5 Alphabet Index
### 3.6 Alphabet Index
```go
type AlphaIndex struct {
@ -153,7 +176,7 @@ type AlphaIndex struct {
Used for: quick navigation in the directory browser.
### 3.6 Buttons
### 3.7 Buttons
```go
type Button struct {
@ -166,7 +189,7 @@ type Button struct {
Used for: merge resolution (ours/theirs/both), dismiss, apply.
### 3.7 Merge Hunk
### 3.8 Merge Hunk
```go
type MergeHunk struct {
@ -185,17 +208,20 @@ type HunkResolution int // Unresolved, KeepOurs, KeepTheirs, MergeBoth
Used for: conflict resolution UI. The logic layer produces one `MergeHunk` element plus navigation buttons.
### 3.8 Status Bar
### 3.9 Status Bar (Top Bar)
```go
type StatusBar struct {
/* region, visible, id — unexported */
Left string // e.g., "Ln 10, Col 5"
Left string // e.g., "Ln 10, Col 5" or filename
Right string // e.g., "1024 / 50000 bytes"
ConflictIcon bool // true = show conflict warning icon (tap → merge UI)
}
```
### 3.9 Toast / Notification
The status bar appears at the top of the editor page. When a sync conflict is detected for the active file, `ConflictIcon` is set to true. Tapping the icon navigates to the merge resolution page. The icon persists across process death — if a conflict file exists for the active file on restore, the icon appears.
### 3.10 Toast / Notification
```go
type Toast struct {
@ -207,7 +233,7 @@ type Toast struct {
Used for: "undo skipped (text changed)", "file saved", "conflict detected".
### 3.10 Spacer
### 3.11 Spacer
```go
type Spacer struct {
@ -311,12 +337,15 @@ Elements that don't specify explicit colors/sizes use theme defaults. The theme
```
[]Element{
Label{Text: "notes.txt"}, // header (filename)
TextField{Multiline: true, VisibleLines: [...], ScrollOffset: 420},
SearchBar{Query: "foo", Match: 1, Total: 3}, // search (when active)
TextField{Multiline: true, VisibleLines: [...], ScrollOffset: 420, WordWrap: true},
Cursor{Line: 5, Column: 12},
StatusBar{Left: "Ln 47, Col 12", Right: "1024 / 50000"},
}
```
The search bar is only present when the user has activated search. When visible, it sits between the header and the text area. Typing in the search bar moves the cursor to the current match. Up/down arrows cycle through matches.
### 8.3 Merge Page
```