diff --git a/CLAUDE.md b/CLAUDE.md index 9202098..4f75115 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,9 @@ crates/ │ ├── board.rs # Board name handling, normalization, CLI input parsing │ ├── due.rs # Due-date parsing/formatting/status (epoch millis) │ ├── encryption.rs # AES-256-GCM encrypt/decrypt +│ ├── limits.rs # Human-scale size envelope (recommended/supported) +│ ├── links.rs # [[stable-id]] link scanning, save-time rewrite, LinkIndex +│ ├── stable_id.rs # Stable item IDs derived from _timestamp (never stored) │ ├── error.rs # CommonError type │ └── models/ # StorageItem, Task, Note, Item trait │ @@ -73,7 +76,7 @@ crates/ │ │ ├── mod.rs # StorageBackend trait │ │ ├── local.rs # LocalStorage (file-based) │ │ └── remote.rs # RemoteStorage (HTTP + encryption) -│ └── tui/ # Interactive TUI (ratatui + crossterm) +│ └── tui/ # Interactive TUI (ratatui + crossterm; widgets/link_pane.rs = backlinks pane) │ └── taskbook-server/ # Server binary (tb-server) └── src/ @@ -117,7 +120,11 @@ crates/ (honoring `$XDG_CONFIG_HOME`), falling back to the legacy `~/.taskbook.json` when it exists and the XDG file does not. -6. **Storage Structure (Local)**: +6. **Design Envelope (human scale)**: taskbook is a task board a person reads and keeps up with, as the original was; that purpose sets the bound, not storage performance. Recommended: under 512 active items. Supported, as headroom: 1,024 active and 4,096 archived (`taskbook-common/src/limits.rs`). Agent use via MCP is expected to stay within the same bounds. Past the recommendation (or the archive limit), `limits::SizeNotice` drives an informational notice in the TUI header and as an extra text block on MCP `create_task`/`create_note`/`restore_items` results; it never blocks, and suggests clearing only when completed tasks exist. Design features for what a person using the board can produce; scenarios only reachable at machine scale (thousands of active items, bulk writes within one millisecond, very large archives) are unsupported, not edge cases to handle. The JSON storage (whole-file read/rewrite per operation, full-set sync) fits this size, so O(n) work per operation is fine and indexes, caches or incremental storage are out of scope; measured numbers are in README "Limits". Envelope tests in `taskbook.rs` and `tui/widgets/item_row.rs` check correctness at the supported size; the timing check is `#[ignore]`d (`cargo test -- --ignored`). + +7. **Stable IDs and Links**: An item's numeric `_id` changes on archive/restore, so links use a stable ID instead: an invertible mix of `_timestamp` written as 16 letters `k`-`z` (`taskbook-common/src/stable_id.rs`). It is computed on read and never stored; create paths keep `_timestamp` unique, and golden test vectors freeze the mapping (changing it breaks every link). Two items created in the same millisecond on different devices share a stable ID (divergent, shown `??`); that tradeoff is acceptable within the design envelope. Links are `[[stable-id]]` tokens in item text, resolved only in the TUI. + +8. **Storage Structure (Local)**: ``` ~/.taskbook/ ├── storage/storage.json # Active items diff --git a/README.md b/README.md index d9b00e4..d964e69 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,24 @@ Access commands directly from the TUI with `/` or Tab. ![Commands](screenshots/commands.png) +### Links and Backlinks +Link any task or note to another, Workflowy-style. Every item has a stable +ID (shown after its number, e.g. `3 kqzx`) derived from when it was created; +unlike the number, it never changes when items are archived or restored. + +- Type `[[` while writing a task, note or edit to pick a target from a list, + or type `[[12]]` (a number) or `[[kq]]` (a stable-ID prefix): it is saved + as a full `[[stable-id]]` token. A reference that matches nothing, or more + than one item, is not saved and your draft is kept. +- Press `l` on an item to link it to another without editing text. +- On terminals at least 100 columns wide, a side pane lists the selected + item's links and backlinks; `v` toggles it, `o` focuses it, Enter jumps. +- Links to archived items show as broken (`✗`) until the item is restored. + Two items created in the same millisecond by older tools share an ID and + show `??`; they cannot be linked to. + +The CLI, MCP server and sync server show `[[stable-id]]` tokens as raw text. + ### Help Full keyboard shortcut reference available with `?`. @@ -162,6 +180,37 @@ See the [docs](docs/) folder for detailed documentation: This implementation uses the same data format and directory (`~/.taskbook/`) as the original Node.js version, allowing seamless migration. +## Limits + +taskbook is a task board for a person, like the original: something you read +and keep up with yourself. We recommend staying under **512 active items**; +more than that is hard for anyone to keep track of. The supported size, +**1,024 active and 4,096 archived items**, is headroom above that +recommendation, and it applies when agents use taskbook through MCP too. +Larger stores are unsupported, and features are not designed for them. +Past 512 active items (or 4,096 archived), the TUI header and MCP create +results show a notice; it blocks nothing, and suggests `/clear` (`tb --clear`) +only when there are completed tasks to archive. For +example, two items created in the same millisecond on different devices can +share a stable ID (shown as `??`), which a person using the board will not +realistically hit. + +Storage is a JSON file that every command reads and rewrites in full, and +sync uploads every item on each change, which is fast at this size. +Measured on a 4-vCPU 2.8 GHz Xeon with a release build (median of 7 runs): + +| Active / archived | Files | Board view | Create | +|---|---|---|---| +| 256 / 4096 | 2.1 MB | 4 ms | 19 ms | +| 512 / 4096 | 2.2 MB | 9 ms | 18 ms | +| 1024 / 4096 | 2.4 MB | 9 ms | 20 ms | +| 2048 / 8192 | 4.9 MB | 16 ms | 41 ms | + +Cost grows linearly with the number of stored items, about 4–5 ms per +1,000 (active plus archived) per create on this machine. Sync mode transfers the whole store on every change, so its +practical limit depends on your connection. To reproduce: +`cargo build --release -p taskbook-client && python3 scripts/bench-envelope.py target/release/tb`. + ## License MIT - see [LICENSE](LICENSE) diff --git a/crates/taskbook-client/src/config.rs b/crates/taskbook-client/src/config.rs index 4eeb276..ba16a75 100644 --- a/crates/taskbook-client/src/config.rs +++ b/crates/taskbook-client/src/config.rs @@ -377,12 +377,16 @@ impl Config { /// Save the configuration to file pub fn save(&self) -> Result<()> { - let config_path = Self::config_file_path(); + self.save_to(&Self::config_file_path()) + } + + /// Save the configuration to an explicit path + pub fn save_to(&self, config_path: &Path) -> Result<()> { if let Some(parent) = config_path.parent() { fs::create_dir_all(parent)?; } let data = serde_json::to_string_pretty(self)?; - fs::write(&config_path, data)?; + fs::write(config_path, data)?; Ok(()) } diff --git a/crates/taskbook-client/src/editor.rs b/crates/taskbook-client/src/editor.rs index 5323d96..ec8a6d5 100644 --- a/crates/taskbook-client/src/editor.rs +++ b/crates/taskbook-client/src/editor.rs @@ -90,12 +90,24 @@ pub fn create_note_in_editor() -> Result> { edit_in_external_editor(NEW_NOTE_TEMPLATE) } -/// Open editor for editing an existing note +/// Open editor for editing an existing note, optionally leading with a +/// notice comment (for example why the previous save was rejected) pub fn edit_existing_note_in_editor( title: &str, body: Option<&str>, + notice: Option<&str>, ) -> Result> { + edit_in_external_editor(&existing_note_content(title, body, notice)) +} + +/// The editor buffer for an existing note +fn existing_note_content(title: &str, body: Option<&str>, notice: Option<&str>) -> String { let mut content = String::new(); + if let Some(notice) = notice { + content.push_str("// "); + content.push_str(notice); + content.push('\n'); + } content.push_str(title); content.push('\n'); @@ -107,8 +119,7 @@ pub fn edit_existing_note_in_editor( content.push_str("\n\n"); content.push_str("// Lines starting with // are comments and will be ignored.\n"); content.push_str("// Delete all content (or leave only comments) to cancel.\n"); - - edit_in_external_editor(&content) + content } /// Parse editor content into title and body @@ -175,6 +186,19 @@ fn parse_note_content(content: &str) -> Result> { mod tests { use super::*; + #[test] + fn existing_note_content_leads_with_the_notice() { + let content = + existing_note_content("Title", Some("body"), Some("Not saved: [[99]] not found")); + assert!( + content.starts_with("// Not saved: [[99]] not found\n"), + "{content}" + ); + let parsed = parse_note_content(&content).unwrap().unwrap(); + assert_eq!(parsed.title, "Title"); + assert_eq!(parsed.body.as_deref(), Some("body")); + } + #[test] fn test_parse_empty_content() { assert!(parse_note_content("").unwrap().is_none()); diff --git a/crates/taskbook-client/src/mcp/mod.rs b/crates/taskbook-client/src/mcp/mod.rs index e6f644e..dc9dce9 100644 --- a/crates/taskbook-client/src/mcp/mod.rs +++ b/crates/taskbook-client/src/mcp/mod.rs @@ -120,6 +120,39 @@ mod tests { Taskbook::with_storage(Box::new(storage)) } + /// A store holding `pending` open tasks and `done` completed ones. + fn seeded_taskbook(pending: u64, done: u64) -> Taskbook { + use crate::storage::StorageBackend; + use taskbook_common::{StorageItem, Task}; + let dir = std::env::temp_dir().join(format!("tb-mcp-test-{}", uuid::Uuid::new_v4())); + let storage = LocalStorage::new(&dir).unwrap(); + let items = (1..=pending + done) + .map(|id| { + let mut task = Task::new(id, format!("t{id}"), vec!["my board".into()], 1); + task.is_complete = id > pending; + (id.to_string(), StorageItem::Task(task)) + }) + .collect(); + storage.set(&items).unwrap(); + Taskbook::with_storage(Box::new(storage)) + } + + fn create_task_content(taskbook: &Taskbook) -> Vec { + let resp = request( + taskbook, + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"create_task","arguments":{"description":"one more"} + }}), + ); + assert_eq!(resp["result"]["isError"], false); + resp["result"]["content"] + .as_array() + .unwrap() + .iter() + .map(|c| c["text"].as_str().unwrap().to_string()) + .collect() + } + fn request(taskbook: &Taskbook, msg: Value) -> Value { let response = handle_message(taskbook, &msg.to_string()).expect("expected a response"); serde_json::from_str(&response).unwrap() @@ -245,6 +278,33 @@ mod tests { assert_eq!(item["due_date"], "2026-07-01"); } + #[test] + fn create_within_the_recommendation_has_no_notice() { + let tb = seeded_taskbook(511, 0); + assert_eq!(create_task_content(&tb).len(), 1); + } + + #[test] + fn create_past_the_recommendation_adds_a_notice() { + let tb = seeded_taskbook(512, 0); + let content = create_task_content(&tb); + assert_eq!(content.len(), 2, "{content:?}"); + assert!(content[1].contains("513 active items, over the 512 recommended")); + assert!(content[1].contains("do not archive or delete items")); + assert!(!content[1].contains("tb --clear"), "nothing to clear"); + } + + #[test] + fn notice_suggests_clearing_only_completed_tasks() { + let tb = seeded_taskbook(500, 12); + let content = create_task_content(&tb); + assert!( + content[1].contains("12 of the active items are completed tasks"), + "{content:?}" + ); + assert!(content[1].contains("tb --clear")); + } + #[test] fn set_task_state_and_filters() { let tb = test_taskbook(); diff --git a/crates/taskbook-client/src/mcp/tools.rs b/crates/taskbook-client/src/mcp/tools.rs index f110f9b..c8340ed 100644 --- a/crates/taskbook-client/src/mcp/tools.rs +++ b/crates/taskbook-client/src/mcp/tools.rs @@ -3,6 +3,7 @@ use serde_json::{json, Value}; use crate::taskbook::Taskbook; +use taskbook_common::limits::SizeNotice; use taskbook_common::{board, due, StorageItem}; /// JSON Schema definitions for all exposed tools. @@ -135,10 +136,16 @@ pub fn call(taskbook: &Taskbook, params: &Value) -> Result }; Ok(match outcome { - Ok(value) => json!({ - "content": [{ "type": "text", "text": value.to_string() }], - "isError": false - }), + Ok(value) => { + let mut content = vec![json!({ "type": "text", "text": value.to_string() })]; + // Tools that add active items say when the store is past the envelope. + if matches!(name, "create_task" | "create_note" | "restore_items") { + if let Some(notice) = size_notice(taskbook) { + content.push(json!({ "type": "text", "text": notice })); + } + } + json!({ "content": content, "isError": false }) + } Err(message) => json!({ "content": [{ "type": "text", "text": message }], "isError": true @@ -146,6 +153,29 @@ pub fn call(taskbook: &Taskbook, params: &Value) -> Result }) } +/// A notice for the agent to pass on when the store is past the size taskbook +/// is designed for. Read failures skip the notice rather than fail the call. +fn size_notice(taskbook: &Taskbook) -> Option { + let active = taskbook.get_all_items().ok()?; + let archived = taskbook.get_all_archive_items().ok()?.len(); + let notice = SizeNotice::check(active.len(), archived)?; + let mut text = format!( + "Notice: {}. taskbook is a board meant for a person to keep up with.", + notice.summary() + ); + let completed = active + .values() + .filter(|item| item.as_task().is_some_and(|task| task.is_complete)) + .count(); + if notice.active_over_recommended() && completed > 0 { + text.push_str(&format!( + " {completed} of the active items are completed tasks the user can archive with `tb --clear`." + )); + } + text.push_str(" Mention this to the user; do not archive or delete items on their behalf."); + Some(text) +} + fn item_to_json(item: &StorageItem) -> Value { let mut v = json!({ "id": item.id(), diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 186a5e9..f30a29c 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -77,6 +77,32 @@ impl Taskbook { max + 1 } + /// Give a new item a creation timestamp no active or archived item has. + fn stamp_unique( + &self, + item: &mut StorageItem, + active: &HashMap, + ) -> Result<()> { + let archive = self.get_archive()?; + let ts = unique_timestamp(item.timestamp(), active, &archive); + match item { + StorageItem::Task(t) => t.timestamp = ts, + StorageItem::Note(n) => n.timestamp = ts, + } + Ok(()) + } + + /// Insert a newly created item into `data` with a unique creation timestamp. + fn insert_new( + &self, + data: &mut HashMap, + mut item: StorageItem, + ) -> Result<()> { + self.stamp_unique(&mut item, data)?; + data.insert(item.id().to_string(), item); + Ok(()) + } + fn remove_duplicates(&self, ids: &[u64]) -> Vec { let mut seen = HashSet::with_capacity(ids.len()); ids.iter().filter(|id| seen.insert(**id)).copied().collect() @@ -360,6 +386,11 @@ impl Taskbook { self.get_archive() } + /// Get the boards of already-loaded items (for TUI) + pub fn boards_of(&self, data: &HashMap) -> Vec { + self.get_boards(data) + } + /// Get all boards (for TUI) pub fn get_all_boards(&self) -> Result> { let data = self.get_data()?; @@ -396,7 +427,7 @@ impl Taskbook { let id = self.generate_id(&data); let task = Task::new_with_tags(id, description, boards, priority, tags).with_due_date(due_date); - data.insert(id.to_string(), StorageItem::Task(task)); + self.insert_new(&mut data, StorageItem::Task(task))?; self.save(&data)?; Ok(id) } @@ -421,7 +452,7 @@ impl Taskbook { let mut data = self.get_data()?; let id = self.generate_id(&data); let note = Note::new_with_tags(id, description, boards, tags); - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; Ok(id) } @@ -441,7 +472,7 @@ impl Taskbook { let mut data = self.get_data()?; let id = self.generate_id(&data); let note = Note::new_with_body(id, title, body, boards); - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; Ok(id) } @@ -692,7 +723,7 @@ impl Taskbook { let note = Note::new_with_tags(id, description, boards, tags); let mut data = self.get_data()?; - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; self.render.success_create(id, false); Ok(()) @@ -712,7 +743,7 @@ impl Taskbook { note_content.body, vec![DEFAULT_BOARD.to_string()], ); - data.insert(id.to_string(), StorageItem::Note(note)); + self.insert_new(&mut data, StorageItem::Note(note))?; self.save(&data)?; self.render.success_create(id, false); Ok(()) @@ -758,7 +789,7 @@ impl Taskbook { .ok_or_else(|| TaskbookError::General("Item is not a note".to_string()))?; // Open editor with current content - let content = editor::edit_existing_note_in_editor(note.title(), note.body())?; + let content = editor::edit_existing_note_in_editor(note.title(), note.body(), None)?; match content { Some(note_content) => { @@ -796,7 +827,7 @@ impl Taskbook { let task = Task::new_with_tags(id, description, boards, priority, tags).with_due_date(due_date); let mut data = self.get_data()?; - data.insert(id.to_string(), StorageItem::Task(task)); + self.insert_new(&mut data, StorageItem::Task(task))?; self.save(&data)?; self.render.success_create(id, true); Ok(()) @@ -1408,3 +1439,192 @@ impl Taskbook { self.save(&data) } } + +/// The smallest timestamp at or after `candidate` that no active or archived +/// item already has. Stable IDs derive from the creation timestamp, so this is +/// what keeps new items from sharing one. +fn unique_timestamp( + candidate: i64, + active: &HashMap, + archive: &HashMap, +) -> i64 { + let taken: HashSet = active + .values() + .chain(archive.values()) + .map(|item| item.timestamp()) + .filter(|&ts| ts >= candidate) + .collect(); + let mut ts = candidate; + while taken.contains(&ts) { + ts += 1; + } + ts +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn test_taskbook() -> Taskbook { + let dir = std::env::temp_dir().join(format!("tb-taskbook-test-{}", uuid::Uuid::new_v4())); + Taskbook::with_storage(Box::new(LocalStorage::new(&dir).unwrap())) + } + + fn task_at(id: u64, ts: i64) -> StorageItem { + let mut t = Task::new(id, format!("task {id}"), vec![DEFAULT_BOARD.to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn map_of(items: Vec) -> HashMap { + items.into_iter().map(|i| (i.id().to_string(), i)).collect() + } + + #[test] + fn unique_timestamp_keeps_a_free_candidate() { + let active = map_of(vec![task_at(1, 100)]); + let archive = HashMap::new(); + assert_eq!(unique_timestamp(200, &active, &archive), 200); + } + + #[test] + fn unique_timestamp_skips_archived_timestamps() { + let active = HashMap::new(); + let archive = map_of(vec![task_at(1, 500)]); + assert_eq!(unique_timestamp(500, &active, &archive), 501); + } + + #[test] + fn unique_timestamp_skips_runs_across_both_sets() { + let active = map_of(vec![task_at(1, 500), task_at(2, 501)]); + let archive = map_of(vec![task_at(1, 502)]); + assert_eq!(unique_timestamp(500, &active, &archive), 503); + } + + /// Covers AE7 (creation half): rapid creates through every path get + /// distinct timestamps and therefore distinct stable IDs. + /// A store filled to the supported size, with every timestamp packed into + /// one run starting now: the worst case for keeping new creates unique. + fn envelope_taskbook() -> Taskbook { + use taskbook_common::limits::{SUPPORTED_ACTIVE_ITEMS, SUPPORTED_ARCHIVED_ITEMS}; + let tb = test_taskbook(); + let start = chrono::Utc::now().timestamp_millis(); + let (active, archived) = ( + SUPPORTED_ACTIVE_ITEMS as i64, + SUPPORTED_ARCHIVED_ITEMS as i64, + ); + tb.save(&map_of( + (0..active) + .map(|i| task_at(i as u64 + 1, start + i)) + .collect(), + )) + .unwrap(); + tb.save_archive(&map_of( + (0..archived) + .map(|i| task_at(i as u64 + 1, start + active + i)) + .collect(), + )) + .unwrap(); + tb + } + + #[test] + fn creates_stay_unique_at_the_supported_size() { + let tb = envelope_taskbook(); + let board = vec![DEFAULT_BOARD.to_string()]; + for i in 0..5 { + tb.create_task_direct_with_tags(board.clone(), format!("t{i}"), 1, vec![], None) + .unwrap(); + } + + let mut timestamps: Vec = tb + .get_data() + .unwrap() + .values() + .chain(tb.get_archive().unwrap().values()) + .map(|item| item.timestamp()) + .collect(); + let total = timestamps.len(); + timestamps.sort_unstable(); + timestamps.dedup(); + assert_eq!(timestamps.len(), total); + } + + /// Loose latency budget at the supported size. Timing depends on the + /// machine, so it runs only on request: `cargo test -- --ignored`. + #[test] + #[ignore] + fn create_at_the_supported_size_is_fast() { + let tb = envelope_taskbook(); + let board = vec![DEFAULT_BOARD.to_string()]; + let started = std::time::Instant::now(); + tb.create_task_direct_with_tags(board, "timed".into(), 1, vec![], None) + .unwrap(); + let elapsed = started.elapsed(); + assert!(elapsed.as_millis() < 500, "create took {elapsed:?}"); + } + + #[test] + fn rapid_creates_get_distinct_stable_ids() { + let tb = test_taskbook(); + let board = vec![DEFAULT_BOARD.to_string()]; + for i in 0..5 { + tb.create_task_direct_with_tags(board.clone(), format!("t{i}"), 1, vec![], None) + .unwrap(); + tb.create_note_direct_with_tags(board.clone(), format!("n{i}"), vec![]) + .unwrap(); + tb.create_note_with_body_direct(board.clone(), format!("b{i}"), Some("body".into())) + .unwrap(); + tb.create_task(&[format!("cli-task-{i}")]).unwrap(); + tb.create_note(&[format!("cli-note-{i}")]).unwrap(); + } + + let data = tb.get_data().unwrap(); + assert_eq!(data.len(), 25); + let timestamps: HashSet = data.values().map(|i| i.timestamp()).collect(); + let stable_ids: HashSet = data.values().map(|i| i.stable_id()).collect(); + assert_eq!(timestamps.len(), 25); + assert_eq!(stable_ids.len(), 25); + } + + #[test] + fn archive_and_restore_keep_timestamp_and_stable_id() { + let tb = test_taskbook(); + let id = tb + .create_task_direct_with_tags( + vec![DEFAULT_BOARD.to_string()], + "x".into(), + 1, + vec![], + None, + ) + .unwrap(); + // A second item so the restored one gets a different numeric ID. + tb.create_task_direct_with_tags( + vec![DEFAULT_BOARD.to_string()], + "y".into(), + 1, + vec![], + None, + ) + .unwrap(); + let before = tb.get_data().unwrap()[&id.to_string()].clone(); + + tb.delete_items_silent(&[id]).unwrap(); + let archived = tb.get_archive().unwrap(); + let in_archive = archived.values().find(|i| i.description() == "x").unwrap(); + assert_eq!(in_archive.stable_id(), before.stable_id()); + + tb.restore_items_silent(&[in_archive.id()]).unwrap(); + let restored = tb + .get_data() + .unwrap() + .into_values() + .find(|i| i.description() == "x") + .unwrap(); + assert_ne!(restored.id(), before.id()); + assert_eq!(restored.timestamp(), before.timestamp()); + assert_eq!(restored.stable_id(), before.stable_id()); + } +} diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index 574aba9..aa53518 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -3,14 +3,23 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::editor; use crate::error::Result; use taskbook_common::board; +use taskbook_common::links::{self, Rejection, Rewrite, Target}; -use super::app::{App, PendingAction, PopupState, StatusKind, ViewMode}; +use super::app::{ + App, EditBaseline, PendingAction, PopupState, StatusKind, SuggestionKind, ViewMode, + PANE_MIN_WIDTH, +}; use super::autocomplete; use super::command_parser::{self, ParsedCommand}; use super::input_handler::{handle_text_input, InputResult}; /// Handle a key event pub fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<()> { + // A sticky status (e.g. a rejected save) stays until the next key press. + if app.status_message.as_ref().is_some_and(|m| m.sticky) { + app.status_message = None; + } + // 1. Help popup → scroll with j/k/arrows, dismiss with q/Esc/other if let Some(PopupState::Help { ref mut scroll }) = app.popup { match key.code { @@ -37,10 +46,51 @@ pub fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<()> { return handle_command_line_key(app, key); } - // 4. Normal mode shortcuts + // 4. Link pane focused → move, jump, or return to the list. Focus goes + // back to the list if the pane was hidden (toggled off or too narrow). + if app.pane.focused { + if app.pane_shown() { + return handle_pane_key(app, key); + } + app.pane.focused = false; + } + + // 5. Normal mode shortcuts handle_shortcut_key(app, key) } +/// Handle keys while the link pane has focus +fn handle_pane_key(app: &mut App, key: KeyEvent) -> Result<()> { + let content = app.pane_content(); + let cursor = app.pane_cursor(&content); + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + app.pane.cursor = (cursor + 1).min(content.len().saturating_sub(1)); + } + KeyCode::Char('k') | KeyCode::Up => { + app.pane.cursor = cursor.saturating_sub(1); + } + KeyCode::Enter => match content.get(cursor).map(|e| e.target) { + Some(Target::Active { numeric }) => app.jump_to(numeric)?, + Some(Target::Divergent) => { + app.set_status( + "Two items share that ID; cannot jump".to_string(), + StatusKind::Info, + ); + } + Some(Target::Missing) => { + app.set_status("That link is broken".to_string(), StatusKind::Info); + } + None => {} + }, + KeyCode::Esc | KeyCode::Char('o') => { + app.pane.focused = false; + } + _ => {} + } + Ok(()) +} + /// Handle keys when a confirmation is pending fn handle_confirm_key(app: &mut App, key: KeyEvent) -> Result<()> { match key.code { @@ -134,6 +184,40 @@ fn handle_command_line_key(app: &mut App, key: KeyEvent) -> Result<()> { _ => {} } + // Inside an unclosed `[[`, Enter picks a link target instead of + // submitting, and Esc first closes only the dropdown. + let link_query = match key.code { + KeyCode::Enter | KeyCode::Esc => autocomplete::open_link_query(app), + _ => None, + }; + if let Some(link) = link_query { + let has_links = app + .command_line + .suggestions + .iter() + .any(|s| s.kind == SuggestionKind::Link); + match key.code { + KeyCode::Enter => { + if has_links { + accept_suggestion(app); + } else { + app.set_status( + "Pick an item, close the brackets with ]], or press Esc".to_string(), + StatusKind::Info, + ); + } + return Ok(()); + } + KeyCode::Esc if has_links => { + app.command_line.suggestions.clear(); + app.command_line.selected_suggestion = None; + app.command_line.link_dismissed_at = Some(link.start); + return Ok(()); + } + _ => {} + } + } + // Use the existing text input handler for editing let input = app.command_line.input.clone(); let cursor = app.command_line.cursor; @@ -144,10 +228,19 @@ fn handle_command_line_key(app: &mut App, key: KeyEvent) -> Result<()> { } InputResult::Submit => { let input = app.command_line.input.clone(); + let cursor = app.command_line.cursor; + let baseline = app.command_line.link_baseline.take(); app.push_history(input.clone()); app.deactivate_command_line(); if !input.trim().is_empty() { - execute_input(app, &input)?; + if let Some(rejected) = execute_input(app, &input, baseline)? { + // Give the draft back so the user can fix the references. + app.activate_command_line(&input); + app.command_line.cursor = cursor; + app.command_line.link_baseline = rejected.baseline; + let message = format_rejections(&rejected.rejections, app.status_width); + app.set_sticky_status(message, StatusKind::Error); + } } } InputResult::Changed { @@ -177,19 +270,42 @@ fn accept_suggestion(app: &mut App) { } } +/// A save whose added `[[…]]` references could not all be resolved. +struct Rejected { + rejections: Vec, + /// The baseline the draft was diffed against, kept for the retry. + baseline: Option, +} + +/// Rewrite the description of a new item, whose baseline is empty. +fn rewrite_new(app: &App, description: &str) -> std::result::Result { + links::rewrite_on_save("", description, app.links.resolver()).map_err(|rejections| Rejected { + rejections, + baseline: None, + }) +} + /// Parse and execute the command line input -fn execute_input(app: &mut App, input: &str) -> Result<()> { +fn execute_input( + app: &mut App, + input: &str, + baseline: Option, +) -> Result> { match command_parser::parse_command(input) { - Ok(cmd) => execute_command(app, cmd), + Ok(cmd) => execute_command(app, cmd, baseline), Err(e) => { app.set_status(e.message, StatusKind::Error); - Ok(()) + Ok(None) } } } /// Execute a parsed command -fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { +fn execute_command( + app: &mut App, + cmd: ParsedCommand, + baseline: Option, +) -> Result> { match cmd { ParsedCommand::Task { board, @@ -198,51 +314,81 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { tags, due_date, } => { + let rewrite = match rewrite_new(app, &description) { + Ok(rewrite) => rewrite, + Err(rejected) => return Ok(Some(rejected)), + }; let board_name = board .map(|b| board::normalize_board_name(&b)) .or_else(|| app.filter.board_filter.clone()) .unwrap_or_else(|| "my board".to_string()); app.taskbook.create_task_direct_with_tags( vec![board_name.clone()], - description, + rewrite.text.clone(), priority, tags, due_date, )?; app.refresh_items()?; let display = board::display_name(&board_name); - app.set_status(format!("Task created in {}", display), StatusKind::Success); + let message = format!( + "Task created in {}{}", + display, + linked_suffix(app, &rewrite) + ); + app.set_status(message, StatusKind::Success); } ParsedCommand::Note { board, description, tags, } => { + let rewrite = match rewrite_new(app, &description) { + Ok(rewrite) => rewrite, + Err(rejected) => return Ok(Some(rejected)), + }; let board_name = board .map(|b| board::normalize_board_name(&b)) .or_else(|| app.filter.board_filter.clone()) .unwrap_or_else(|| "my board".to_string()); - app.taskbook.create_note_direct_with_tags( + let new_id = app.taskbook.create_note_direct_with_tags( vec![board_name.clone()], - description, + rewrite.text.clone(), tags, )?; app.refresh_items()?; let display = board::display_name(&board_name); - app.set_status(format!("Note created in {}", display), StatusKind::Success); + let message = format!( + "Note created in {}{}", + display, + linked_suffix(app, &rewrite) + ); + app.set_status(message, StatusKind::Success); // Open editor for the newly created note - // Find the highest ID note (just created) - if let Some(max_id) = app.items.values().map(|i| i.id()).max() { - if let Some(item) = app.items.get(&max_id.to_string()) { - if !item.is_task() { - edit_note_external(app, max_id)?; - } - } - } + edit_note_external(app, new_id)?; } ParsedCommand::Edit { id, description } => { - edit_description(app, id, &description)?; + // The baseline is captured once per edit session: from the `e`/`l` + // prefill, or from storage on the first submit. + let base = baseline + .filter(|b| b.id == id) + .map(|b| b.text) + .or_else(|| { + app.active_items + .get(&id.to_string()) + .map(|item| item.description().to_string()) + }) + .unwrap_or_default(); + match links::rewrite_on_save(&base, &description, app.links.resolver()) { + Ok(rewrite) => edit_description(app, id, &rewrite)?, + Err(rejections) => { + return Ok(Some(Rejected { + rejections, + baseline: Some(EditBaseline { id, text: base }), + })) + } + } } ParsedCommand::Move { id, board } => { move_to_board(app, id, &board)?; @@ -326,7 +472,7 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { app.quit(); } } - Ok(()) + Ok(None) } /// Handle shortcut keys in normal (unfocused) mode @@ -379,7 +525,9 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { // Enter to open note in editor or filter by board KeyCode::Enter => { if let Some(item) = app.selected_item() { - if !item.is_task() { + if !item.is_task() && app.view == ViewMode::Archive { + app.set_status("Restore the note to edit it".to_string(), StatusKind::Info); + } else if !item.is_task() { edit_note_external(app, item.id())?; } else if app.view == ViewMode::Board && app.filter.board_filter.is_none() { if let Some(board_name) = app.get_board_for_selected() { @@ -442,6 +590,41 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { let id = item.id(); let desc = item.description().to_string(); app.activate_command_line(&format!("/edit @{} {}", id, desc)); + app.command_line.link_baseline = Some(EditBaseline { id, text: desc }); + } + } + // Link pane: toggle and focus + KeyCode::Char('v') => { + app.pane.visible = !app.pane.visible; + if app.status_width < PANE_MIN_WIDTH { + let state = if app.pane.visible { "on" } else { "off" }; + app.set_status( + format!( + "Link pane needs {} columns (toggle {})", + PANE_MIN_WIDTH, state + ), + StatusKind::Info, + ); + } + } + KeyCode::Char('o') => { + if !app.pane_shown() { + app.set_status("Link pane hidden".to_string(), StatusKind::Info); + } else if app.pane_content().is_empty() { + app.set_status("No links for this item".to_string(), StatusKind::Info); + } else { + app.pane.focused = true; + app.pane.cursor = 0; + } + } + + KeyCode::Char('l') if app.view != ViewMode::Archive => { + if let Some(item) = app.selected_item() { + let id = item.id(); + let desc = item.description().to_string(); + app.activate_command_line(&format!("/edit @{} {} [[", id, desc)); + app.command_line.link_baseline = Some(EditBaseline { id, text: desc }); + autocomplete::update_suggestions(app); } } KeyCode::Char('m') if app.view != ViewMode::Archive => { @@ -552,13 +735,58 @@ fn toggle_star(app: &mut App, id: u64) -> Result<()> { Ok(()) } -fn edit_description(app: &mut App, id: u64, new_desc: &str) -> Result<()> { - app.taskbook.edit_description_silent(id, new_desc)?; +fn edit_description(app: &mut App, id: u64, rewrite: &Rewrite) -> Result<()> { + app.taskbook.edit_description_silent(id, &rewrite.text)?; app.refresh_items()?; - app.set_status(format!("Updated item {}", id), StatusKind::Success); + let message = format!("Updated item {}{}", id, linked_suffix(app, rewrite)); + app.set_status(message, StatusKind::Success); Ok(()) } +/// Name each reference a save rewrote and the title it now links to, so an +/// accidental match is visible at once. +fn linked_suffix(app: &App, rewrite: &Rewrite) -> String { + if rewrite.linked.is_empty() { + return String::new(); + } + let parts: Vec = rewrite + .linked + .iter() + .map(|(reference, id)| { + let title = app.links.title(id).unwrap_or_default(); + format!("{} \u{2192} {}", reference, title) + }) + .collect(); + format!(" \u{00b7} linked {}", parts.join(", ")) +} + +/// "Not saved: …" naming every rejected reference; when that would not fit +/// in `width` columns, later references collapse into "+N more" (the first is +/// always shown in full). +fn format_rejections(rejections: &[Rejection], width: u16) -> String { + let width = usize::from(width); + let mut message = String::from("Not saved: "); + for (i, rejection) in rejections.iter().enumerate() { + let part = rejection.to_string(); + let remaining = rejections.len() - i - 1; + let tail = if remaining > 0 { + format!(" +{} more", remaining) + } else { + String::new() + }; + let separator = if i == 0 { "" } else { ", " }; + let fits = + message.chars().count() + separator.len() + part.chars().count() + tail.len() <= width; + if i > 0 && !fits { + message.push_str(&format!(" +{} more", rejections.len() - i)); + break; + } + message.push_str(separator); + message.push_str(&part); + } + message +} + fn move_to_board(app: &mut App, id: u64, board: &str) -> Result<()> { let board_name = board::normalize_board_name(board); app.taskbook @@ -594,7 +822,8 @@ fn delete_items(app: &mut App, ids: &[u64]) -> Result<()> { fn restore_item(app: &mut App, id: u64) -> Result<()> { app.taskbook.restore_items_silent(&[id])?; - app.set_view(ViewMode::Archive)?; + // Already in Archive view, so set_view would not reload anything. + app.refresh_items()?; app.set_status(format!("Restored item {}", id), StatusKind::Success); Ok(()) } @@ -669,7 +898,8 @@ fn rename_board(app: &mut App, old_name: &str, new_name: &str) -> Result<()> { } fn edit_note_external(app: &mut App, id: u64) -> Result<()> { - let item = app.items.get(&id.to_string()); + // Edits write to active storage, so read the note from there whatever the view. + let item = app.active_items.get(&id.to_string()); let note = match item.and_then(|i| i.as_note()) { Some(n) => n, None => { @@ -678,33 +908,324 @@ fn edit_note_external(app: &mut App, id: u64) -> Result<()> { } }; - let title = note.title().to_string(); - let body = note.body().map(|s| s.to_string()); + // The stored text when the session starts is the baseline for every retry. + let base_title = note.title().to_string(); + let base_body = note.body().map(|s| s.to_string()); + let mut title = base_title.clone(); + let mut body = base_body.clone(); + let mut notice: Option = None; - // Suspend TUI to run external editor - let guard = super::suspend_tui()?; + loop { + // Suspend TUI to run external editor + let guard = super::suspend_tui()?; - // Open external editor - let content = editor::edit_existing_note_in_editor(&title, body.as_deref()); + // Open external editor + let content = + editor::edit_existing_note_in_editor(&title, body.as_deref(), notice.as_deref()); - // Resume TUI - guard.resume()?; + // Resume TUI + guard.resume()?; - // After suspend/resume, ratatui's internal buffer is stale — force full redraw - app.needs_full_redraw = true; + // After suspend/resume, ratatui's internal buffer is stale — force full redraw + app.needs_full_redraw = true; - match content? { - Some(note_content) => { - app.taskbook - .edit_description_silent(id, ¬e_content.title)?; - app.taskbook.edit_note_body_silent(id, note_content.body)?; - app.refresh_items()?; - app.set_status(format!("Updated note {}", id), StatusKind::Success); - } - None => { + let Some(note_content) = content? else { app.set_status("Edit cancelled".to_string(), StatusKind::Info); + return Ok(()); + }; + + let baseline = (base_title.as_str(), base_body.as_deref()); + match save_note_edit( + app, + id, + baseline, + ¬e_content.title, + note_content.body.as_deref(), + )? { + Ok(rewrite) => { + let message = format!("Updated note {}{}", id, linked_suffix(app, &rewrite)); + app.set_status(message, StatusKind::Success); + return Ok(()); + } + Err(rejections) => { + // Reopen on the edited content, explaining what to fix. + notice = Some(format_rejections(&rejections, u16::MAX)); + title = note_content.title; + body = note_content.body; + } } } +} - Ok(()) +/// Validate a note's edited title and body together and, only if every added +/// reference resolves, write both. A rejection writes nothing. +fn save_note_edit( + app: &mut App, + id: u64, + baseline: (&str, Option<&str>), + title: &str, + body: Option<&str>, +) -> Result>> { + let base = links::join_note(baseline.0, baseline.1); + let draft = links::join_note(title, body); + let rewrite = match links::rewrite_on_save(&base, &draft, app.links.resolver()) { + Ok(rewrite) => rewrite, + Err(rejections) => return Ok(Err(rejections)), + }; + let (title, body) = links::split_note(&rewrite.text); + app.taskbook.edit_description_silent(id, &title)?; + app.taskbook.edit_note_body_silent(id, body)?; + app.refresh_items()?; + Ok(Ok(rewrite)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::tests::{key, test_app}; + use taskbook_common::links::{RejectReason, Rejection}; + + fn press(app: &mut App, code: KeyCode) { + handle_key_event(app, KeyEvent::new(code, KeyModifiers::NONE)).unwrap(); + } + + fn submit(app: &mut App, input: &str) { + app.activate_command_line(input); + press(app, KeyCode::Enter); + } + + fn create(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["my board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + fn description(app: &App, id: u64) -> String { + app.active_items[&id.to_string()].description().to_string() + } + + fn status(app: &App) -> String { + app.status_message + .as_ref() + .map(|m| m.text.clone()) + .unwrap_or_default() + } + + /// Covers AE1. + #[test] + fn rejected_create_keeps_the_draft_open() { + let (mut app, _) = test_app(); + submit(&mut app, "/task see [[99]]"); + + assert!(app.command_line.focused); + assert_eq!(app.command_line.input, "/task see [[99]]"); + assert!( + status(&app).contains("[[99]] not found"), + "{}", + status(&app) + ); + assert!(app.active_items.is_empty()); + } + + #[test] + fn edit_rewrites_a_numeric_reference_and_names_it() { + let (mut app, _) = test_app(); + let target = create(&mut app, "Milk run"); + let edited = create(&mut app, "x"); + submit(&mut app, &format!("/edit @{edited} see [[{target}]]")); + + let tid = app.active_items[&target.to_string()].stable_id(); + assert_eq!(description(&app, edited), format!("see [[{tid}]]")); + assert!(!app.command_line.focused); + assert!(status(&app).contains("Milk run"), "{}", status(&app)); + } + + /// Covers AE3. + #[test] + fn editing_cli_text_keeps_numeric_brackets_plain() { + let (mut app, _) = test_app(); + create(&mut app, "other"); + let id = create(&mut app, "see [[1]]"); + app.selected_index = app.display_order.iter().position(|&i| i == id).unwrap(); + + handle_key_event(&mut app, key('e')).unwrap(); + for c in " fixed".chars() { + handle_key_event(&mut app, key(c)).unwrap(); + } + press(&mut app, KeyCode::Enter); + + assert_eq!(description(&app, id), "see [[1]] fixed"); + } + + #[test] + fn retry_after_rejection_uses_the_original_baseline() { + let (mut app, _) = test_app(); + let id = create(&mut app, "a"); + submit(&mut app, &format!("/edit @{id} a [[99]]")); + assert!(app.command_line.focused); + + // Edit the kept draft and submit again: [[99]] is still an addition. + app.command_line.input.push_str(" b"); + press(&mut app, KeyCode::Enter); + assert!(app.command_line.focused); + assert_eq!(description(&app, id), "a"); + } + + #[test] + fn retry_keeps_the_baseline_when_storage_changes_mid_edit() { + let (mut app, _) = test_app(); + let id = create(&mut app, "a"); + submit(&mut app, &format!("/edit @{id} a [[99]]")); + assert!(app.command_line.focused); + + // Another writer adds the same span while the draft is open. Diffed + // against storage it would look pre-existing; it was added here. + app.taskbook + .edit_description_silent(id, "a [[99]]") + .unwrap(); + app.refresh_items().unwrap(); + + press(&mut app, KeyCode::Enter); + assert!(app.command_line.focused); + assert!( + status(&app).contains("[[99]] not found"), + "{}", + status(&app) + ); + } + + #[test] + fn retargeted_edit_takes_the_baseline_from_the_new_item() { + let (mut app, _) = test_app(); + create(&mut app, "other"); + let first = create(&mut app, "x"); + let second = create(&mut app, "see [[1]]"); + app.selected_index = app.display_order.iter().position(|&i| i == first).unwrap(); + + // Prefill an edit of `first`, then point it at `second` instead. + handle_key_event(&mut app, key('e')).unwrap(); + let input = format!("/edit @{second} see [[1]] more"); + app.command_line.cursor = input.chars().count(); + app.command_line.input = input; + press(&mut app, KeyCode::Enter); + + // [[1]] was already in `second`, so it stays plain text. + assert_eq!(description(&app, second), "see [[1]] more"); + } + + #[test] + fn enter_on_an_archived_note_does_not_edit_active_storage() { + let (mut app, _) = test_app(); + let kept = create(&mut app, "keep me"); + let note = app + .taskbook + .create_note_direct_with_tags(vec!["my board".into()], "old note".into(), vec![]) + .unwrap(); + app.taskbook.delete_items_silent(&[note]).unwrap(); + app.set_view(ViewMode::Archive).unwrap(); + assert!(!app.items.is_empty()); + + press(&mut app, KeyCode::Enter); + + assert_eq!(status(&app), "Restore the note to edit it"); + assert_eq!(description(&app, kept), "keep me"); + } + + #[test] + fn delete_still_asks_for_confirmation() { + let (mut app, _) = test_app(); + let id = create(&mut app, "doomed"); + submit(&mut app, &format!("/delete {id}")); + assert!(app.command_line.pending_confirm.is_some()); + + press(&mut app, KeyCode::Enter); + assert!(app.active_items.is_empty()); + } + + #[test] + fn rejection_status_stays_until_the_next_key() { + let (mut app, _) = test_app(); + submit(&mut app, "/task [[98]] [[97]] [[96]]"); + if let Some(msg) = app.status_message.as_mut() { + msg.expires_at = std::time::Instant::now() - std::time::Duration::from_secs(1); + } + app.tick(); + assert!( + app.status_message.is_some(), + "sticky status expired on tick" + ); + + press(&mut app, KeyCode::Left); + assert!(app.status_message.is_none()); + } + + #[test] + fn rejection_messages_collapse_to_fit() { + let rejections: Vec = ["[[98]]", "[[97]]", "[[96]]"] + .iter() + .map(|r| Rejection { + reference: r.to_string(), + reason: RejectReason::NotFound, + }) + .collect(); + let wide = format_rejections(&rejections, 200); + assert!(wide.contains("[[98]] not found") && wide.contains("[[96]] not found")); + + let narrow = format_rejections(&rejections, 30); + assert!(narrow.contains("[[98]] not found"), "{narrow}"); + assert!(narrow.ends_with("+2 more"), "{narrow}"); + } + + #[test] + fn rejected_note_edit_writes_nothing() { + let (mut app, _) = test_app(); + let id = app + .taskbook + .create_note_with_body_direct( + vec!["my board".into()], + "Title".into(), + Some("body".into()), + ) + .unwrap(); + app.refresh_items().unwrap(); + + let outcome = save_note_edit( + &mut app, + id, + ("Title", Some("body")), + "Title", + Some("body [[99]]"), + ) + .unwrap(); + assert!(outcome.is_err()); + let note = app.active_items[&id.to_string()].clone(); + assert_eq!(note.description(), "Title"); + assert_eq!(note.note_body(), Some("body")); + } + + /// Covers AE2. + #[test] + fn note_edit_with_a_link_to_an_archived_item_saves() { + let (mut app, _) = test_app(); + let target = create(&mut app, "gone"); + let tid = app.active_items[&target.to_string()].stable_id(); + let body = format!("see [[{tid}]]"); + let id = app + .taskbook + .create_note_with_body_direct(vec!["my board".into()], "N".into(), Some(body.clone())) + .unwrap(); + app.taskbook.delete_items_silent(&[target]).unwrap(); + app.refresh_items().unwrap(); + + let edited = format!("{body}\nmore"); + let outcome = save_note_edit(&mut app, id, ("N", Some(&body)), "N", Some(&edited)).unwrap(); + assert!(outcome.is_ok()); + assert_eq!( + app.active_items[&id.to_string()].note_body(), + Some(edited.as_str()) + ); + } } diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 27ffa2c..6497bb8 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -9,6 +9,8 @@ use crate::error::Result; use crate::render::Stats; use crate::taskbook::Taskbook; use taskbook_common::board; +use taskbook_common::limits::SizeNotice; +use taskbook_common::links::{self, LinkIndex, Target}; use taskbook_common::StorageItem; /// Sort items by the given method @@ -48,6 +50,10 @@ pub fn sort_items_by(items: &mut [&StorageItem], method: SortMethod) { } use super::theme::TuiTheme; +use super::widgets::item_row::plain_text; + +/// The link pane is only shown on terminals at least this wide. +pub const PANE_MIN_WIDTH: u16 = 100; /// Main application state pub struct App { @@ -59,8 +65,12 @@ pub struct App { pub selected_index: usize, /// List of boards for navigation pub boards: Vec, - /// Cached items grouped by board/date + /// Items shown by the current view (the archive in Archive view) pub items: HashMap, + /// Active items, loaded in every view; the link index is built from these + pub active_items: HashMap, + /// Links and backlinks among the active items + pub links: LinkIndex, /// Active popup/dialog state (Help only) pub popup: Option, /// Command line state @@ -85,12 +95,21 @@ pub struct App { pub needs_full_redraw: bool, /// Last known content area height (updated each render frame) pub content_height: u16, + /// Last known status line width (updated each render frame) + pub status_width: u16, /// Command history (most recent last) pub command_history: Vec, /// Current position when browsing history (None = not browsing) pub history_index: Option, /// Saved input before browsing history pub history_saved_input: String, + /// Where config changes are written (`None` = the user's config file) + config_path: Option, + /// Link side pane state + pub pane: PaneState, + /// Archived item count for the size notice. Read at startup and whenever + /// Archive view loads the archive, so it can lag deletes made since. + pub archived_count: usize, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -123,6 +142,18 @@ pub struct CommandLineState { pub selected_suggestion: Option, /// Pending confirmation action pub pending_confirm: Option, + /// Stored text of the item being edited when this edit session began, + /// so retries after a rejected save diff against the same baseline + pub link_baseline: Option, + /// Char index of the `[[` whose link dropdown the user closed with Esc + pub link_dismissed_at: Option, +} + +/// The stored text of an item when an edit session began +#[derive(Debug, Clone)] +pub struct EditBaseline { + pub id: u64, + pub text: String, } /// An autocomplete suggestion @@ -143,6 +174,8 @@ pub enum SuggestionKind { Command, Board, Item, + /// A `[[` link target + Link, } /// An action waiting for confirmation @@ -161,6 +194,8 @@ pub struct FilterState { pub board_filter: Option, /// Hide completed tasks pub hide_completed: bool, + /// A completed task shown despite `hide_completed`, after a jump to it + pub revealed_completed: Option, } #[derive(Debug, Clone)] @@ -168,6 +203,8 @@ pub struct StatusMessage { pub text: String, pub kind: StatusKind, pub expires_at: Instant, + /// Stays until the next key press instead of expiring + pub sticky: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -182,6 +219,17 @@ impl App { pub fn new(taskbook_dir: Option<&Path>) -> Result { let taskbook = Taskbook::new(taskbook_dir)?; let config = Config::load_or_default(); + Self::build(taskbook, config, None) + } + + /// Build an app over an explicit store, writing config changes to + /// `config_path` instead of the user's config file (for tests). + #[cfg(test)] + pub fn with_taskbook(taskbook: Taskbook, config: Config, config_path: PathBuf) -> Result { + Self::build(taskbook, config, Some(config_path)) + } + + fn build(taskbook: Taskbook, config: Config, config_path: Option) -> Result { let theme = TuiTheme::from(&config.theme.resolve()); let initial_view = config.default_view; @@ -192,6 +240,8 @@ impl App { selected_index: 0, boards: Vec::new(), items: HashMap::new(), + active_items: HashMap::new(), + links: LinkIndex::default(), popup: None, command_line: CommandLineState::default(), status_message: None, @@ -206,9 +256,17 @@ impl App { display_order: Vec::new(), needs_full_redraw: false, content_height: 20, + status_width: 80, command_history: Vec::new(), history_index: None, history_saved_input: String::new(), + config_path, + pane: PaneState { + visible: true, + focused: false, + cursor: 0, + }, + archived_count: 0, cached_stats: Stats { percent: 0, complete: 0, @@ -219,21 +277,25 @@ impl App { }; app.refresh_items()?; - - // If restoring archive view, load archive items instead - if initial_view == ViewMode::Archive { - app.items = app.taskbook.get_all_archive_items()?; - app.update_display_order(); - app.recalculate_stats(); + if app.view != ViewMode::Archive { + app.archived_count = app.taskbook.get_all_archive_items()?.len(); } Ok(app) } - /// Refresh items from storage + /// Refresh items from storage and rebuild the link index pub fn refresh_items(&mut self) -> Result<()> { - self.items = self.taskbook.get_all_items()?; - self.boards = self.taskbook.get_all_boards()?; + self.active_items = self.taskbook.get_all_items()?; + self.links = LinkIndex::build(self.active_items.values()); + self.boards = self.taskbook.boards_of(&self.active_items); + self.items = if self.view == ViewMode::Archive { + let archive = self.taskbook.get_all_archive_items()?; + self.archived_count = archive.len(); + archive + } else { + self.active_items.clone() + }; self.update_display_order(); self.recalculate_stats(); @@ -245,6 +307,22 @@ impl App { Ok(()) } + /// Header text when the store is past the size taskbook is designed for, + /// suggesting `/clear` only when there are completed tasks to archive. + pub fn size_notice(&self) -> Option { + let notice = SizeNotice::check(self.active_items.len(), self.archived_count)?; + let completed = self + .active_items + .values() + .filter(|item| item.as_task().is_some_and(|task| task.is_complete)) + .count(); + let mut text = notice.summary(); + if notice.active_over_recommended() && completed > 0 { + text.push_str(&format!(" · /clear archives {completed} done")); + } + Some(text) + } + /// Recalculate cached statistics fn recalculate_stats(&mut self) { let mut complete = 0; @@ -280,7 +358,7 @@ impl App { /// Check if an item should be shown based on current filters pub fn should_show_item(&self, item: &StorageItem) -> bool { - if self.filter.hide_completed { + if self.filter.hide_completed && self.filter.revealed_completed != Some(item.id()) { if let Some(task) = item.as_task() { if task.is_complete { return false; @@ -358,31 +436,7 @@ impl App { let mut items: Vec<_> = self .items .values() - .filter(|item| { - if self.filter.hide_completed { - if let Some(task) = item.as_task() { - if task.is_complete { - return false; - } - } - } - if let Some(ref term) = self.filter.search_term { - let term_lower = term.to_lowercase(); - let in_desc = item.description().to_lowercase().contains(&term_lower); - let in_body = item - .note_body() - .map(|b| b.to_lowercase().contains(&term_lower)) - .unwrap_or(false); - let in_tags = item - .tags() - .iter() - .any(|t| t.to_lowercase().contains(&term_lower)); - if !in_desc && !in_body && !in_tags { - return false; - } - } - true - }) + .filter(|item| self.should_show_item(item)) .collect(); items.sort_by(|a, b| { b.timestamp() @@ -400,15 +454,16 @@ impl App { pub fn cycle_sort_method(&mut self) { self.sort_method = self.sort_method.next(); self.config.sort_method = self.sort_method; - let _ = self.config.save(); + self.save_config(); self.update_display_order(); } /// Toggle hide completed tasks pub fn toggle_hide_completed(&mut self) { self.filter.hide_completed = !self.filter.hide_completed; + self.filter.revealed_completed = None; self.config.display_complete_tasks = !self.filter.hide_completed; - let _ = self.config.save(); + self.save_config(); self.update_display_order(); // Clamp selection if !self.display_order.is_empty() && self.selected_index >= self.display_order.len() { @@ -491,14 +546,23 @@ impl App { text, kind, expires_at: Instant::now() + Duration::from_secs(3), + sticky: false, }); } + /// Set a status message that stays until the next key press + pub fn set_sticky_status(&mut self, text: String, kind: StatusKind) { + self.set_status(text, kind); + if let Some(msg) = self.status_message.as_mut() { + msg.sticky = true; + } + } + /// Tick - called periodically for time-based updates pub fn tick(&mut self) { // Clear expired status messages if let Some(ref msg) = self.status_message { - if Instant::now() >= msg.expires_at { + if !msg.sticky && Instant::now() >= msg.expires_at { self.status_message = None; } } @@ -512,26 +576,32 @@ impl App { /// Switch view mode pub fn set_view(&mut self, view: ViewMode) -> Result<()> { if self.view != view { - self.view = view; - self.selected_index = 0; - // Persist the view choice self.config.default_view = view; - let _ = self.config.save(); - - // Reload data for archive view - if view == ViewMode::Archive { - self.items = self.taskbook.get_all_archive_items()?; - } else { - self.items = self.taskbook.get_all_items()?; - } + self.save_config(); + self.switch_view(view)?; + } + Ok(()) + } - self.update_display_order(); - self.recalculate_stats(); + /// Switch view without saving it as the default view. + fn switch_view(&mut self, view: ViewMode) -> Result<()> { + if self.view != view { + self.view = view; + self.selected_index = 0; + self.refresh_items()?; } Ok(()) } + /// Persist config changes; failures are not worth interrupting the TUI. + fn save_config(&self) { + let _ = match &self.config_path { + Some(path) => self.config.save_to(path), + None => self.config.save(), + }; + } + /// Activate the command line with an optional prefix pub fn activate_command_line(&mut self, prefix: &str) { self.command_line.input = prefix.to_string(); @@ -540,6 +610,7 @@ impl App { self.command_line.suggestions.clear(); self.command_line.selected_suggestion = None; self.command_line.pending_confirm = None; + self.command_line.link_baseline = None; } /// Deactivate the command line and clear state @@ -567,4 +638,570 @@ impl App { pub fn quit(&mut self) { self.running = false; } + + /// Whether the link pane is toggled on and the terminal is wide enough. + pub fn pane_shown(&self) -> bool { + self.pane.visible && self.status_width >= PANE_MIN_WIDTH + } + + /// Outgoing links and backlinks of the selected item. + pub fn pane_content(&self) -> PaneContent { + let Some(item) = self.selected_item() else { + return PaneContent::default(); + }; + + let outgoing = links::item_link_targets(item) + .into_iter() + .map(|id| { + let target = self.links.target(&id); + let title = match target { + Target::Active { .. } => self.links.title(&id).unwrap_or_default(), + Target::Divergent | Target::Missing => String::new(), + }; + PaneEntry { id, title, target } + }) + .collect(); + + // Backlinks cannot be attributed to one of two items sharing an ID. + let own_id = item.stable_id(); + let divergent = self.links.resolver().is_divergent(&own_id); + let mut backlinks: Vec = if divergent { + Vec::new() + } else { + self.links + .backlinks(&own_id) + .iter() + .filter_map(|n| self.active_items.get(&n.to_string())) + .map(|source| { + let id = source.stable_id(); + PaneEntry { + target: self.links.target(&id), + title: plain_text(self, source.description()), + id, + } + }) + .collect() + }; + // Backlinks follow the main list's order; others keep ID order. + let position = |entry: &PaneEntry| match entry.target { + Target::Active { numeric } => self + .display_order + .iter() + .position(|&id| id == numeric) + .unwrap_or(usize::MAX), + _ => usize::MAX, + }; + backlinks.sort_by_cached_key(position); + + PaneContent { + outgoing, + backlinks, + divergent, + } + } + + /// The pane cursor, clamped to the current entries. + pub fn pane_cursor(&self, content: &PaneContent) -> usize { + self.pane.cursor.min(content.len().saturating_sub(1)) + } + + /// Select the active item `numeric`, switching view and clearing filters + /// as needed without touching saved settings, and say what changed. + pub fn jump_to(&mut self, numeric: u64) -> Result<()> { + let mut changed = Vec::new(); + if self.view == ViewMode::Archive { + self.switch_view(ViewMode::Board)?; + changed.push("switched to board view"); + } + if self.filter.search_term.take().is_some() { + changed.push("cleared search"); + } + if self.filter.board_filter.take().is_some() { + changed.push("cleared board filter"); + } + let completed = self + .active_items + .get(&numeric.to_string()) + .and_then(|item| item.as_task()) + .is_some_and(|task| task.is_complete); + self.filter.revealed_completed = None; + if completed && self.filter.hide_completed { + self.filter.revealed_completed = Some(numeric); + changed.push("showing a completed task"); + } + self.update_display_order(); + if let Some(position) = self.display_order.iter().position(|&id| id == numeric) { + self.selected_index = position; + } + self.pane.focused = false; + self.pane.cursor = 0; + + let mut message = format!("Jumped to {}", numeric); + if !changed.is_empty() { + message.push_str(&format!(" ({})", changed.join(", "))); + } + self.set_status(message, StatusKind::Info); + Ok(()) + } +} + +/// Link side pane state +#[derive(Debug, Clone)] +pub struct PaneState { + /// Toggled on (it is still hidden below `PANE_MIN_WIDTH`) + pub visible: bool, + /// Keys go to the pane instead of the list + pub focused: bool, + /// Index into outgoing links followed by backlinks + pub cursor: usize, +} + +/// One entry in the link pane +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneEntry { + pub id: String, + /// Rendered title (empty for broken and divergent targets) + pub title: String, + pub target: Target, +} + +/// What the link pane shows for the selected item +#[derive(Debug, Clone, Default)] +pub struct PaneContent { + pub outgoing: Vec, + pub backlinks: Vec, + /// The selected item's own ID is divergent, so backlinks are not listed + pub divergent: bool, +} + +impl PaneContent { + pub fn len(&self) -> usize { + self.outgoing.len() + self.backlinks.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The entry at a cursor position over outgoing links then backlinks. + pub fn get(&self, index: usize) -> Option<&PaneEntry> { + self.outgoing + .get(index) + .or_else(|| self.backlinks.get(index.checked_sub(self.outgoing.len())?)) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::storage::LocalStorage; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use taskbook_common::links::Target; + + /// An `App` over a fresh temp store whose config writes go to a temp file. + pub(crate) fn test_app() -> (App, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("tb-app-test-{}", uuid::Uuid::new_v4())); + let taskbook = Taskbook::with_storage(Box::new(LocalStorage::new(&dir).unwrap())); + let config_path = dir.join("config.json"); + let app = App::with_taskbook(taskbook, Config::default(), config_path.clone()).unwrap(); + (app, config_path) + } + + pub(crate) fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + } + + fn create_task(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["My Board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + fn stable_id_of(app: &App, numeric: u64) -> String { + app.items[&numeric.to_string()].stable_id() + } + + /// Covers AE5. + #[test] + fn restoring_a_linked_item_revives_its_backlinks() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + for name in ["A", "B", "C"] { + create_task(&mut app, &format!("{name} [[{tid}]]")); + } + assert_eq!(app.links.backlinks(&tid).len(), 3); + + app.taskbook.delete_items_silent(&[t]).unwrap(); + app.refresh_items().unwrap(); + assert_eq!(app.links.target(&tid), Target::Missing); + assert_eq!(app.links.backlinks(&tid).len(), 3); + + app.set_view(ViewMode::Archive).unwrap(); + let archived = app.display_order[0]; + app.selected_index = 0; + super::super::actions::handle_key_event(&mut app, key('r')).unwrap(); + + assert!(matches!(app.links.target(&tid), Target::Active { .. })); + assert_eq!(app.links.backlinks(&tid).len(), 3); + assert!( + !app.items.contains_key(&archived.to_string()), + "restored row must leave the archive list at once" + ); + } + + #[test] + fn archive_view_keeps_an_index_of_active_items() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + create_task(&mut app, &format!("A [[{tid}]]")); + + app.set_view(ViewMode::Archive).unwrap(); + assert!(app.items.is_empty()); + assert_eq!(app.links.target(&tid), Target::Active { numeric: t }); + assert_eq!(app.links.backlinks(&tid).len(), 1); + + app.refresh_items().unwrap(); + assert!( + app.items.is_empty(), + "refresh in archive view keeps showing the archive" + ); + } + + #[test] + fn config_writes_go_to_the_test_path() { + let (mut app, config_path) = test_app(); + app.set_view(ViewMode::Archive).unwrap(); + let saved = std::fs::read_to_string(&config_path).unwrap(); + assert!(saved.contains("archive"), "{saved}"); + } + // ----- link pane and jump ----- + + fn select(app: &mut App, id: u64) { + app.selected_index = app.display_order.iter().position(|&i| i == id).unwrap(); + } + + fn press(app: &mut App, code: KeyCode) { + super::super::actions::handle_key_event(app, KeyEvent::new(code, KeyModifiers::NONE)) + .unwrap(); + } + + fn wide(app: &mut App) { + app.status_width = 120; + } + + /// Covers F3. + #[test] + fn jumping_from_a_backlink_selects_the_linking_item() { + let (mut app, _) = test_app(); + wide(&mut app); + let b = create_task(&mut app, "B"); + let bid = stable_id_of(&app, b); + let a = create_task(&mut app, &format!("A [[{bid}]]")); + select(&mut app, b); + + let content = app.pane_content(); + assert_eq!(content.backlinks.len(), 1); + press(&mut app, KeyCode::Char('o')); + assert!(app.pane.focused); + press(&mut app, KeyCode::Enter); + + assert_eq!(app.selected_id(), Some(a)); + assert!(!app.pane.focused, "focus returns to the list after a jump"); + } + + #[test] + fn jump_reveals_a_hidden_completed_task_without_saving_config() { + let (mut app, config_path) = test_app(); + wide(&mut app); + let done = create_task(&mut app, "Done thing"); + app.taskbook.check_tasks_silent(&[done]).unwrap(); + app.refresh_items().unwrap(); + app.filter.hide_completed = true; + app.update_display_order(); + assert!(!app.display_order.contains(&done)); + let before = std::fs::read_to_string(&config_path).ok(); + + app.jump_to(done).unwrap(); + assert_eq!(app.selected_id(), Some(done)); + assert!(app.filter.hide_completed); + assert_eq!(std::fs::read_to_string(&config_path).ok(), before); + } + + #[test] + fn jump_from_archive_view_keeps_the_saved_default_view() { + let (mut app, config_path) = test_app(); + let t = create_task(&mut app, "Target"); + app.set_view(ViewMode::Archive).unwrap(); + let saved = std::fs::read_to_string(&config_path).unwrap(); + + app.jump_to(t).unwrap(); + assert_eq!(app.view, ViewMode::Board); + assert_eq!(app.selected_id(), Some(t)); + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), saved); + } + + #[test] + fn jump_clears_search_and_board_filter_and_says_so() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + app.filter.search_term = Some("nomatch".into()); + app.filter.board_filter = Some("elsewhere".into()); + app.update_display_order(); + + app.jump_to(t).unwrap(); + assert_eq!(app.selected_id(), Some(t)); + assert!(app.filter.search_term.is_none() && app.filter.board_filter.is_none()); + let status = app.status_message.as_ref().unwrap().text.clone(); + assert!( + status.contains("search") && status.contains("board"), + "{status}" + ); + } + + #[test] + fn broken_and_divergent_entries_are_not_jumpable() { + let (mut app, _) = test_app(); + wide(&mut app); + let missing = taskbook_common::stable_id::from_timestamp(9_999); + let a = create_task(&mut app, &format!("A [[{missing}]]")); + select(&mut app, a); + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Enter); + assert_eq!(app.selected_id(), Some(a)); + assert!(app.pane.focused); + assert_eq!( + app.status_message.as_ref().unwrap().text, + "That link is broken" + ); + } + + #[test] + fn divergent_entries_say_why_they_cannot_be_jumped_to() { + let (mut app, _) = test_app(); + wide(&mut app); + let twin_id = taskbook_common::stable_id::from_timestamp(1); + let linker = create_task(&mut app, &format!("L [[{twin_id}]]")); + let mut twin = taskbook_common::Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + for t in [twin, twin2] { + app.active_items + .insert(t.id.to_string(), StorageItem::Task(t)); + } + app.items = app.active_items.clone(); + app.links = LinkIndex::build(app.active_items.values()); + app.update_display_order(); + select(&mut app, linker); + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Enter); + assert_eq!(app.selected_id(), Some(linker)); + assert_eq!( + app.status_message.as_ref().unwrap().text, + "Two items share that ID; cannot jump" + ); + } + + #[test] + fn pane_keys_move_the_cursor_jump_and_leave() { + let (mut app, _) = test_app(); + wide(&mut app); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + create_task(&mut app, &format!("A [[{tid}]]")); + create_task(&mut app, &format!("B [[{tid}]]")); + select(&mut app, t); + let expected = match app.pane_content().get(1).map(|e| e.target) { + Some(Target::Active { numeric }) => numeric, + other => panic!("unexpected second entry: {other:?}"), + }; + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Char('j')); + press(&mut app, KeyCode::Char('j')); + assert_eq!(app.pane.cursor, 1, "j stops at the last entry"); + press(&mut app, KeyCode::Char('k')); + assert_eq!(app.pane.cursor, 0); + press(&mut app, KeyCode::Esc); + assert!(!app.pane.focused); + + press(&mut app, KeyCode::Char('o')); + press(&mut app, KeyCode::Char('j')); + press(&mut app, KeyCode::Enter); + assert_eq!(app.selected_id(), Some(expected)); + } + + #[test] + fn hiding_the_pane_returns_keys_to_the_list() { + let (mut app, _) = test_app(); + wide(&mut app); + let first = create_task(&mut app, "first"); + let fid = stable_id_of(&app, first); + create_task(&mut app, &format!("second [[{fid}]]")); + select(&mut app, first); + press(&mut app, KeyCode::Char('o')); + assert!(app.pane.focused); + + app.status_width = 80; + press(&mut app, KeyCode::Char('j')); + assert!(!app.pane.focused); + assert_ne!(app.selected_id(), Some(first)); + } + + #[test] + fn narrow_terminal_keys_explain_the_hidden_pane() { + let (mut app, _) = test_app(); + app.status_width = 80; + create_task(&mut app, "x"); + + press(&mut app, KeyCode::Char('v')); + let status = app.status_message.as_ref().unwrap().text.clone(); + assert!(status.contains("100 columns"), "{status}"); + + press(&mut app, KeyCode::Char('o')); + assert_eq!( + app.status_message.as_ref().unwrap().text, + "Link pane hidden" + ); + assert!(!app.pane.focused); + } + + #[test] + fn pane_cursor_clamps_to_the_current_entries() { + let (mut app, _) = test_app(); + let b = create_task(&mut app, "B"); + let bid = stable_id_of(&app, b); + create_task(&mut app, &format!("A [[{bid}]]")); + select(&mut app, b); + app.pane.cursor = 5; + let content = app.pane_content(); + assert_eq!(app.pane_cursor(&content), 0); + } + + #[test] + fn archived_item_lists_active_backlinks() { + let (mut app, _) = test_app(); + let t = create_task(&mut app, "Target"); + let tid = stable_id_of(&app, t); + for name in ["A", "B", "C"] { + create_task(&mut app, &format!("{name} [[{tid}]]")); + } + app.taskbook.delete_items_silent(&[t]).unwrap(); + app.set_view(ViewMode::Archive).unwrap(); + app.selected_index = 0; + + let content = app.pane_content(); + let titles: Vec = content.backlinks.iter().map(|e| e.title.clone()).collect(); + assert_eq!(titles.len(), 3, "{titles:?}"); + } + + #[test] + fn divergent_selection_shows_no_backlinks() { + let (mut app, _) = test_app(); + let mut twin = taskbook_common::Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + for t in [twin, twin2] { + app.active_items + .insert(t.id.to_string(), StorageItem::Task(t)); + } + app.items = app.active_items.clone(); + app.links = LinkIndex::build(app.active_items.values()); + app.update_display_order(); + app.selected_index = 0; + + let content = app.pane_content(); + assert!(content.divergent); + assert!(content.backlinks.is_empty()); + } + + // ----- size notice ----- + + /// Put `pending` open and `done` completed tasks in the app's active set. + fn fill(app: &mut App, pending: u64, done: u64) { + for id in 1..=pending + done { + let mut task = + taskbook_common::Task::new(id, format!("t{id}"), vec!["my board".into()], 1); + task.is_complete = id > pending; + app.active_items + .insert(id.to_string(), StorageItem::Task(task)); + } + app.items = app.active_items.clone(); + } + + #[test] + fn no_size_notice_within_the_recommendation() { + let (mut app, _) = test_app(); + fill(&mut app, 512, 0); + assert_eq!(app.size_notice(), None); + } + + #[test] + fn size_notice_suggests_clear_only_with_completed_tasks() { + let (mut app, _) = test_app(); + fill(&mut app, 513, 0); + assert_eq!( + app.size_notice().as_deref(), + Some("513 active items, over the 512 recommended") + ); + + let (mut app, _) = test_app(); + fill(&mut app, 500, 13); + assert_eq!( + app.size_notice().as_deref(), + Some("513 active items, over the 512 recommended · /clear archives 13 done") + ); + } + + #[test] + fn archive_only_notice_has_no_action() { + let (mut app, _) = test_app(); + fill(&mut app, 10, 5); + app.archived_count = 5000; + assert_eq!( + app.size_notice().as_deref(), + Some("5000 archived items, over the 4096 supported") + ); + } + + #[test] + fn startup_counts_the_archive() { + let dir = std::env::temp_dir().join(format!("tb-app-test-{}", uuid::Uuid::new_v4())); + let taskbook = Taskbook::with_storage(Box::new(LocalStorage::new(&dir).unwrap())); + let id = taskbook + .create_task_direct_with_tags(vec!["my board".into()], "x".into(), 1, vec![], None) + .unwrap(); + taskbook.delete_items_silent(&[id]).unwrap(); + + let app = App::with_taskbook(taskbook, Config::default(), dir.join("config.json")).unwrap(); + assert_eq!(app.archived_count, 1); + } + + #[test] + fn header_shows_the_size_notice() { + use ratatui::{backend::TestBackend, Terminal}; + let (mut app, _) = test_app(); + fill(&mut app, 513, 0); + app.update_display_order(); + let mut terminal = Terminal::new(TestBackend::new(160, 10)).unwrap(); + terminal + .draw(|f| crate::tui::ui::render(f, &mut app)) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let header: String = (0..buffer.area.width) + .map(|x| buffer[(x, 0)].symbol().to_string()) + .collect(); + assert!( + header.contains("[513 active items, over the 512 recommended]"), + "{header}" + ); + } } diff --git a/crates/taskbook-client/src/tui/autocomplete.rs b/crates/taskbook-client/src/tui/autocomplete.rs index a4bc50d..1647916 100644 --- a/crates/taskbook-client/src/tui/autocomplete.rs +++ b/crates/taskbook-client/src/tui/autocomplete.rs @@ -1,4 +1,6 @@ use super::app::{App, Suggestion, SuggestionKind}; +use super::widgets::item_row::plain_text; +use super::widgets::truncate_chars; use taskbook_common::board; /// Static list of all slash commands with descriptions @@ -43,6 +45,20 @@ pub fn update_suggestions(app: &mut App) { return; } + // An unclosed `[[` in a description suggests link targets, unless the + // user dismissed the dropdown for this `[[` with Esc. + match open_link_query(app) { + Some(link) => { + if app.command_line.link_dismissed_at != Some(link.start) { + app.command_line.link_dismissed_at = None; + suggest_links(app, &link); + } + return; + } + None => app.command_line.link_dismissed_at = None, + } + let input = &app.command_line.input; + // Find the token being typed by scanning backward from cursor let chars: Vec = input.chars().collect(); let cursor = app.command_line.cursor.min(chars.len()); @@ -80,6 +96,132 @@ pub fn update_suggestions(app: &mut App) { } } +/// An unclosed `[[` before the cursor in the description of a command +pub struct OpenLink { + /// Char index of the `[[` in the input + pub start: usize, + /// Text typed after the `[[` + pub query: String, +} + +/// The unclosed `[[` before the cursor in the description of `/task`, +/// `/note` or `/edit`, if any. +pub fn open_link_query(app: &App) -> Option { + let input = &app.command_line.input; + if !input.starts_with('/') { + return None; + } + let cursor = app.command_line.cursor.min(input.chars().count()); + let before: String = input.chars().take(cursor).collect(); + let (command, rest) = before[1..].split_once(' ')?; + let in_description = match command { + "task" | "note" => true, + // /edit @ + "edit" => rest.trim_start().split_once(' ').is_some(), + _ => false, + }; + if !in_description { + return None; + } + let open_byte = before.rfind("[[")?; + let query = &before[open_byte + 2..]; + if query.contains("]]") { + return None; + } + Some(OpenLink { + start: before[..open_byte].chars().count(), + query: query.to_string(), + }) +} + +/// A link target offered by `[[` autocomplete +struct LinkCandidate { + on_current_board: bool, + timestamp: i64, + id: String, + title: String, + board: Option, +} + +/// Suggest active, non-divergent items to link to, newest first with the +/// current board's items ahead; accepting one inserts `[[]]`. +fn suggest_links(app: &mut App, link: &OpenLink) { + let query = link.query.to_lowercase(); + let input_chars: Vec = app.command_line.input.chars().collect(); + let cursor = app.command_line.cursor.min(input_chars.len()); + + // The item being edited cannot link to itself through the picker. + let editing: Option = app + .command_line + .input + .strip_prefix("/edit @") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|id| id.parse().ok()); + let current_board = app + .filter + .board_filter + .clone() + .or_else(|| app.get_board_for_selected()); + let on_board = |boards: &[String]| { + current_board + .as_ref() + .is_some_and(|cb| boards.iter().any(|b| board::board_eq(b, cb))) + }; + + let mut candidates: Vec = app + .active_items + .values() + .filter(|item| Some(item.id()) != editing) + .filter_map(|item| { + let id = item.stable_id(); + if app.links.resolver().is_divergent(&id) { + return None; + } + let title = plain_text(app, item.description()); + let matches = query.is_empty() + || title.to_lowercase().contains(&query) + || item.id().to_string().starts_with(&query) + || id.starts_with(&query); + matches.then(|| LinkCandidate { + on_current_board: on_board(item.boards()), + timestamp: item.timestamp(), + id, + title, + board: item.boards().first().cloned(), + }) + }) + .collect(); + candidates.sort_by(|a, b| { + b.on_current_board + .cmp(&a.on_current_board) + .then(b.timestamp.cmp(&a.timestamp)) + }); + + let before_open: String = input_chars[..link.start].iter().collect(); + let after_cursor: String = input_chars[cursor..].iter().collect(); + for candidate in candidates.into_iter().take(MAX_SUGGESTIONS) { + let display = format!( + "{} {}", + truncate_chars(&candidate.title, 28), + app.links.short_id(&candidate.id) + ); + app.command_line.suggestions.push(Suggestion { + display, + completion: format!("{}[[{}]]{}", before_open, candidate.id, after_cursor), + description: Some( + candidate + .board + .map(|b| board::display_name(&b)) + .unwrap_or_default(), + ), + kind: SuggestionKind::Link, + }); + } + if !app.command_line.suggestions.is_empty() { + app.command_line.selected_suggestion = Some(0); + } +} + /// Determine whether the current argument position should get item suggestions fn should_suggest_items(command: &str, text_to_cursor: &str, _last_space: usize) -> bool { let after_command = text_to_cursor @@ -170,23 +312,19 @@ fn suggest_items(app: &mut App, partial: &str) { .rposition(|c| *c == ' ') .unwrap_or(0); - // Collect and sort by ID for stable ordering - let mut matches: Vec<(u64, &str, bool, bool, bool)> = app + // Collect and sort by ID for stable ordering; match the text as it + // renders, so link tokens are searched by their titles + let mut matches: Vec<(u64, String, bool, bool, bool)> = app .items .values() - .filter(|item| item.description().to_lowercase().contains(&partial_lower)) - .map(|item| { + .map(|item| (item, plain_text(app, item.description()))) + .filter(|(_, desc)| desc.to_lowercase().contains(&partial_lower)) + .map(|(item, desc)| { let (is_complete, in_progress) = match item { taskbook_common::StorageItem::Task(t) => (t.is_complete, t.in_progress), taskbook_common::StorageItem::Note(_) => (false, false), }; - ( - item.id(), - item.description(), - item.is_task(), - is_complete, - in_progress, - ) + (item.id(), desc, item.is_task(), is_complete, in_progress) }) .collect(); matches.sort_by_key(|(id, _, _, _, _)| *id); @@ -203,11 +341,7 @@ fn suggest_items(app: &mut App, partial: &str) { }; // Truncate description for display - let display: String = if desc.len() > 35 { - format!("{}…", desc.chars().take(34).collect::()) - } else { - desc.to_string() - }; + let display = truncate_chars(&desc, 35); // Build completion: replace the partial token with @ let before_token: String = input_chars[..last_space + 1].iter().collect(); @@ -226,3 +360,192 @@ fn suggest_items(app: &mut App, partial: &str) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::actions::handle_key_event; + use crate::tui::app::tests::{key, test_app}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use taskbook_common::links::LinkIndex; + use taskbook_common::{StorageItem, Task}; + + fn press(app: &mut App, code: KeyCode) { + handle_key_event(app, KeyEvent::new(code, KeyModifiers::NONE)).unwrap(); + } + + fn type_text(app: &mut App, text: &str) { + for c in text.chars() { + handle_key_event(app, key(c)).unwrap(); + } + } + + fn create(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["my board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + fn sid(app: &App, id: u64) -> String { + app.active_items[&id.to_string()].stable_id() + } + + fn displays(app: &App) -> Vec { + app.command_line + .suggestions + .iter() + .map(|s| s.display.clone()) + .collect() + } + + /// Covers F1. + #[test] + fn bracket_query_suggests_by_title_and_inserts_the_full_token() { + let (mut app, _) = test_app(); + let milk = create(&mut app, "Milk run"); + create(&mut app, "Other"); + press(&mut app, KeyCode::Char('/')); + type_text(&mut app, "task buy [[mil"); + + assert_eq!( + app.command_line.suggestions.len(), + 1, + "{:?}", + displays(&app) + ); + assert!(app.command_line.suggestions[0].display.contains("Milk run")); + assert_eq!(app.command_line.selected_suggestion, Some(0)); + + press(&mut app, KeyCode::Tab); + assert_eq!( + app.command_line.input, + format!("/task buy [[{}]] ", sid(&app, milk)).trim_end() + ); + } + + #[test] + fn suggestions_match_numeric_id_and_stable_prefix() { + let (mut app, _) = test_app(); + let a = create(&mut app, "Alpha"); + let b = create(&mut app, "Beta"); + press(&mut app, KeyCode::Char('/')); + type_text(&mut app, &format!("task x [[{b}")); + assert!(displays(&app).iter().any(|d| d.contains("Beta"))); + + let prefix: String = sid(&app, a).chars().take(4).collect(); + app.activate_command_line("/"); + type_text(&mut app, &format!("task x [[{prefix}")); + assert!( + displays(&app).iter().any(|d| d.contains("Alpha")), + "{:?}", + displays(&app) + ); + } + + #[test] + fn suggestions_exclude_the_edited_item_and_divergent_items() { + let (mut app, _) = test_app(); + let edited = create(&mut app, "Self"); + create(&mut app, "Kept"); + // Two items sharing a timestamp are divergent. + let mut twin = Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + app.active_items + .insert("90".into(), StorageItem::Task(twin)); + app.active_items + .insert("91".into(), StorageItem::Task(twin2)); + app.links = LinkIndex::build(app.active_items.values()); + + app.activate_command_line(&format!("/edit @{edited} x [[")); + update_suggestions(&mut app); + let shown = displays(&app); + assert!(shown.iter().any(|d| d.contains("Kept")), "{shown:?}"); + assert!( + !shown + .iter() + .any(|d| d.contains("Self") || d.contains("Twin")), + "{shown:?}" + ); + } + + #[test] + fn empty_query_lists_recent_items_with_the_first_selected() { + let (mut app, _) = test_app(); + create(&mut app, "Older"); + create(&mut app, "Newer"); + app.activate_command_line("/task x [["); + update_suggestions(&mut app); + assert!(displays(&app)[0].contains("Newer"), "{:?}", displays(&app)); + assert_eq!(app.command_line.selected_suggestion, Some(0)); + } + + #[test] + fn enter_with_an_open_dropdown_accepts_instead_of_submitting() { + let (mut app, _) = test_app(); + let milk = create(&mut app, "Milk run"); + app.activate_command_line("/task buy [[mil"); + update_suggestions(&mut app); + press(&mut app, KeyCode::Enter); + + assert!(app.command_line.focused); + assert!(app.command_line.input.contains(&sid(&app, milk))); + assert_eq!(app.active_items.len(), 1, "no task was created"); + } + + /// Covers F2. + #[test] + fn link_key_opens_an_edit_with_suggestions() { + let (mut app, _) = test_app(); + create(&mut app, "Target"); + let id = create(&mut app, "Source"); + app.selected_index = app.display_order.iter().position(|&i| i == id).unwrap(); + handle_key_event(&mut app, key('l')).unwrap(); + + assert_eq!(app.command_line.input, format!("/edit @{id} Source [[")); + assert!(displays(&app).iter().any(|d| d.contains("Target"))); + } + + #[test] + fn enter_with_no_match_does_not_save_a_stray_bracket() { + let (mut app, _) = test_app(); + let id = create(&mut app, "Source"); + app.selected_index = 0; + handle_key_event(&mut app, key('l')).unwrap(); + type_text(&mut app, "zzzz"); + assert!(app.command_line.suggestions.is_empty()); + press(&mut app, KeyCode::Enter); + + assert!(app.command_line.focused); + assert_eq!(app.active_items[&id.to_string()].description(), "Source"); + let hint = app + .status_message + .as_ref() + .map(|m| m.text.clone()) + .unwrap_or_default(); + assert!(hint.contains("Esc"), "{hint}"); + } + + #[test] + fn esc_closes_the_dropdown_then_the_command_line() { + let (mut app, _) = test_app(); + create(&mut app, "Target"); + app.activate_command_line("/task x [["); + update_suggestions(&mut app); + assert!(!app.command_line.suggestions.is_empty()); + + press(&mut app, KeyCode::Esc); + assert!(app.command_line.focused); + assert!(app.command_line.suggestions.is_empty()); + // Typing inside the same [[ keeps it closed. + type_text(&mut app, "t"); + assert!(app.command_line.suggestions.is_empty()); + + press(&mut app, KeyCode::Esc); + assert!(!app.command_line.focused); + } +} diff --git a/crates/taskbook-client/src/tui/mod.rs b/crates/taskbook-client/src/tui/mod.rs index 55533c3..0d18cb1 100644 --- a/crates/taskbook-client/src/tui/mod.rs +++ b/crates/taskbook-client/src/tui/mod.rs @@ -163,10 +163,11 @@ fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> app.items = app.taskbook.get_all_archive_items()?; app.update_display_order(); } - (ViewMode::Board | ViewMode::Timeline | ViewMode::Journal, false) => { + // Active items feed the link index in every view. + (_, false) => { app.refresh_items()?; } - _ => {} // Data will be loaded when user switches views + _ => {} // Archive data will be loaded when user switches views } } } diff --git a/crates/taskbook-client/src/tui/theme.rs b/crates/taskbook-client/src/tui/theme.rs index fdeb45f..83f09e1 100644 --- a/crates/taskbook-client/src/tui/theme.rs +++ b/crates/taskbook-client/src/tui/theme.rs @@ -21,6 +21,16 @@ pub struct TuiTheme { pub board_name: Style, pub tag: Style, pub board_tag: Style, + /// A resolved `[[link]]`: the target title + pub link: Style, + /// A link whose target is archived or missing + pub link_broken: Style, + /// A stable ID held by more than one item + pub link_divergent: Style, + /// The unique prefix of a stable ID + pub stable_id: Style, + /// The rest of a displayed stable ID + pub stable_id_rest: Style, } impl From<&ThemeColors> for TuiTheme { @@ -76,6 +86,28 @@ impl From<&ThemeColors> for TuiTheme { .add_modifier(Modifier::BOLD), // Board tags - soft purple board_tag: Style::default().fg(Color::Rgb(170, 130, 200)), + // Links carry a glyph or modifier too, so they survive NO_COLOR + link: Style::default() + .fg(Color::Rgb(colors.info.r, colors.info.g, colors.info.b)) + .add_modifier(Modifier::UNDERLINED), + link_broken: Style::default().fg(Color::Rgb( + colors.error.r, + colors.error.g, + colors.error.b, + )), + link_divergent: Style::default().fg(Color::Rgb( + colors.warning.r, + colors.warning.g, + colors.warning.b, + )), + stable_id: Style::default() + .fg(Color::Rgb(colors.info.r, colors.info.g, colors.info.b)) + .add_modifier(Modifier::BOLD), + stable_id_rest: Style::default().fg(Color::Rgb( + colors.muted.r, + colors.muted.g, + colors.muted.b, + )), } } } diff --git a/crates/taskbook-client/src/tui/ui.rs b/crates/taskbook-client/src/tui/ui.rs index cce842a..15fedc7 100644 --- a/crates/taskbook-client/src/tui/ui.rs +++ b/crates/taskbook-client/src/tui/ui.rs @@ -11,7 +11,7 @@ use super::app::{App, PopupState, ViewMode}; use super::widgets::{ board_view::render_board_view, command_line::render_autocomplete, command_line::render_command_line, help_popup::render_help_popup, - journal_view::render_journal_view, status_bar::render_stats_line, + journal_view::render_journal_view, link_pane::render_link_pane, status_bar::render_stats_line, timeline_view::render_timeline_view, }; @@ -28,8 +28,19 @@ pub fn render(frame: &mut Frame, app: &mut App) { .split(frame.area()); app.content_height = chunks[1].height; + app.status_width = chunks[3].width; render_header(frame, app, chunks[0]); - render_content(frame, app, chunks[1]); + if app.pane_shown() { + let pane_width = (chunks[1].width * 30 / 100).max(32); + let parts = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(1), Constraint::Length(pane_width)]) + .split(chunks[1]); + render_content(frame, app, parts[0]); + render_link_pane(frame, app, parts[1]); + } else { + render_content(frame, app, chunks[1]); + } render_command_line(frame, app, chunks[2]); render_stats_line(frame, app, chunks[3]); @@ -78,6 +89,12 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled("[Hiding completed]", app.theme.warning)); } + // Store past the size taskbook is designed for (informational) + if let Some(notice) = app.size_notice() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(format!("[{notice}]"), app.theme.warning)); + } + let header = Line::from(spans); let paragraph = Paragraph::new(header); frame.render_widget(paragraph, area); diff --git a/crates/taskbook-client/src/tui/widgets/command_line.rs b/crates/taskbook-client/src/tui/widgets/command_line.rs index 3cd4483..238a1f9 100644 --- a/crates/taskbook-client/src/tui/widgets/command_line.rs +++ b/crates/taskbook-client/src/tui/widgets/command_line.rs @@ -158,6 +158,7 @@ pub fn render_autocomplete(frame: &mut Frame, app: &App, content_area: Rect) { SuggestionKind::Command => "/", SuggestionKind::Board => "@", SuggestionKind::Item => "·", + SuggestionKind::Link => "[", }; let mut spans = vec![ diff --git a/crates/taskbook-client/src/tui/widgets/help_popup.rs b/crates/taskbook-client/src/tui/widgets/help_popup.rs index da2e69d..e135d08 100644 --- a/crates/taskbook-client/src/tui/widgets/help_popup.rs +++ b/crates/taskbook-client/src/tui/widgets/help_popup.rs @@ -52,6 +52,24 @@ pub fn render_help_popup(frame: &mut Frame, app: &App, scroll: u16) { Span::styled("Filter board / Edit note", desc_style), ]), Line::from(""), + Line::from(Span::styled(" Links", section_style)), + Line::from(vec![ + Span::styled(" [[ ", key_style), + Span::styled("Link an item while typing", desc_style), + ]), + Line::from(vec![ + Span::styled(" l ", key_style), + Span::styled("Link selected item to another", desc_style), + ]), + Line::from(vec![ + Span::styled(" v ", key_style), + Span::styled("Toggle link pane (100+ columns)", desc_style), + ]), + Line::from(vec![ + Span::styled(" o ", key_style), + Span::styled("Focus link pane (Enter jumps)", desc_style), + ]), + Line::from(""), Line::from(Span::styled(" Quick Actions", section_style)), Line::from(vec![ Span::styled(" c ", key_style), diff --git a/crates/taskbook-client/src/tui/widgets/item_row.rs b/crates/taskbook-client/src/tui/widgets/item_row.rs index b39f8ca..606ab18 100644 --- a/crates/taskbook-client/src/tui/widgets/item_row.rs +++ b/crates/taskbook-client/src/tui/widgets/item_row.rs @@ -6,8 +6,12 @@ use ratatui::{ use crate::tui::app::App; use taskbook_common::board; use taskbook_common::due; +use taskbook_common::links::Segment; use taskbook_common::StorageItem; +/// Width of the stable-ID field shown after the numeric ID. +const STABLE_ID_WIDTH: usize = 6; + /// Options for rendering an item row pub struct ItemRowOptions { pub show_boards: bool, @@ -39,12 +43,14 @@ pub fn render_item_line( ) -> Line<'static> { let mut spans: Vec = Vec::new(); - // Selection indicator + Item ID + // Selection indicator + Item ID + stable ID if is_selected { spans.push(Span::styled(format!(" >{} ", item.id()), app.theme.info)); } else { spans.push(Span::styled(format!(" {} ", item.id()), app.theme.item_id)); } + spans.extend(stable_id_spans(app, item)); + spans.push(Span::raw(" ")); // Icon let (icon, icon_style) = if let Some(task) = item.as_task() { @@ -75,7 +81,7 @@ pub fn render_item_line( } else { Style::default().fg(Color::Rgb(200, 200, 220)) }; - spans.push(Span::styled(desc, desc_style)); + spans.extend(text_spans(app, &desc, desc_style)); // Note body indicator if item.note_has_body() { @@ -159,6 +165,75 @@ pub fn render_item_line( line } +/// Spans for `text`, with `[[id]]` tokens rendered as link titles, broken +/// markers (`✗` + short ID) or divergent markers (short ID + `??`). +pub fn text_spans(app: &App, text: &str, base: Style) -> Vec> { + app.links + .render(text) + .into_iter() + .map(|segment| match segment { + Segment::Text(t) => Span::styled(t, base), + Segment::Link { title, .. } => Span::styled(title, app.theme.link), + Segment::Broken { id } => Span::styled(broken_marker(app, &id), app.theme.link_broken), + Segment::Divergent { id } => { + Span::styled(divergent_marker(app, &id), app.theme.link_divergent) + } + }) + .collect() +} + +/// How a link to an archived or missing item reads: `✗` and its short ID. +pub fn broken_marker(app: &App, id: &str) -> String { + format!("✗{}", app.links.short_id(id)) +} + +/// How a stable ID shared by more than one item reads: short ID and `??`. +pub fn divergent_marker(app: &App, id: &str) -> String { + format!("{}??", app.links.short_id(id)) +} + +/// `text` as it renders, without styling (for matching and truncation). +pub fn plain_text(app: &App, text: &str) -> String { + if !text.contains("[[") { + return text.to_string(); + } + text_spans(app, text, Style::default()) + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +/// The stable-ID field: the unique prefix highlighted, the rest of the first +/// six letters dimmed, and `??` when the ID is divergent. +pub fn stable_id_spans(app: &App, item: &StorageItem) -> Vec> { + let id = item.stable_id(); + let unique = app.links.prefix_len(&id).clamp(1, id.len()); + let shown = unique.max(STABLE_ID_WIDTH).min(id.len()); + let mut spans = vec![ + Span::styled(id[..unique].to_string(), app.theme.stable_id), + Span::styled(id[unique..shown].to_string(), app.theme.stable_id_rest), + ]; + if app.links.resolver().is_divergent(&id) { + spans.push(Span::styled("??", app.theme.link_divergent)); + } + spans +} + +/// First line of a note body as rendered text, cut to 50 characters. +pub fn body_preview(app: &App, body: &str) -> String { + let Some(first_line) = body.lines().next() else { + return String::new(); + }; + let rendered = plain_text(app, first_line); + if rendered.chars().count() > 50 { + format!(" {}…", rendered.chars().take(50).collect::()) + } else if body.lines().count() > 1 { + format!(" {}…", rendered) + } else { + format!(" {}", rendered) + } +} + fn calculate_age(timestamp: i64) -> String { let now = chrono::Utc::now().timestamp_millis(); let diff = now - timestamp; @@ -176,3 +251,127 @@ fn calculate_age(timestamp: i64) -> String { format!("{}y", days / 365) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::tests::test_app; + use taskbook_common::links::LinkIndex; + use taskbook_common::{Note, Task}; + + fn line_text(line: &Line) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + fn task(id: u64, ts: i64, desc: &str) -> StorageItem { + let mut t = Task::new(id, desc.to_string(), vec!["my board".into()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn app_with(items: Vec) -> crate::tui::app::App { + let (mut app, _) = test_app(); + app.active_items = items.into_iter().map(|i| (i.id().to_string(), i)).collect(); + app.links = LinkIndex::build(app.active_items.values()); + app.items = app.active_items.clone(); + app + } + + #[test] + fn link_renders_as_underlined_title() { + let target_id = taskbook_common::stable_id::from_timestamp(2_000); + let app = app_with(vec![ + task(1, 1_000, &format!("see [[{target_id}]]")), + task(2, 2_000, "Milk run"), + ]); + let line = render_item_line( + &app, + &app.items["1"], + false, + &ItemRowOptions::for_board_view(), + ); + let text = line_text(&line); + + assert!(text.contains("see Milk run"), "{text}"); + assert!(!text.contains(&target_id), "{text}"); + let link = line.spans.iter().find(|s| s.content == "Milk run").unwrap(); + assert!(link.style.add_modifier.contains(Modifier::UNDERLINED)); + } + + #[test] + fn broken_and_divergent_links_carry_glyphs() { + let missing = taskbook_common::stable_id::from_timestamp(9_999); + let shared = taskbook_common::stable_id::from_timestamp(3_000); + let app = app_with(vec![ + task(1, 1_000, &format!("a [[{missing}]] b [[{shared}]]")), + task(2, 3_000, "twin one"), + StorageItem::Note({ + let mut n = Note::new(3, "twin two".into(), vec!["my board".into()]); + n.timestamp = 3_000; + n + }), + ]); + let text = line_text(&render_item_line( + &app, + &app.items["1"], + false, + &ItemRowOptions::for_board_view(), + )); + assert!( + text.contains(&format!("✗{}", app.links.short_id(&missing))), + "{text}" + ); + assert!( + text.contains(&format!("{}??", app.links.short_id(&shared))), + "{text}" + ); + } + + #[test] + fn row_shows_a_six_character_stable_id_after_the_numeric_id() { + let app = app_with(vec![task(1, 1_000, "x")]); + let line = render_item_line( + &app, + &app.items["1"], + false, + &ItemRowOptions::for_board_view(), + ); + let sid = taskbook_common::stable_id::from_timestamp(1_000); + assert!( + line_text(&line).starts_with(&format!(" 1 {} ", &sid[..6])), + "{}", + line_text(&line) + ); + } + + #[test] + fn plain_rendering_and_preview_handle_multibyte_text() { + let app = app_with(vec![]); + let body = "ünïcödé ".repeat(10); + let preview = body_preview(&app, &body); + assert!(preview.chars().count() <= 53, "{preview}"); + assert!(preview.ends_with('…')); + } + + /// At the supported size, every active item's unique prefix fits the + /// stable-ID column, so rows stay aligned. Holds for these fixtures; past + /// the envelope a longer prefix widens the column instead. + #[test] + fn unique_prefixes_fit_the_column_at_the_supported_size() { + use taskbook_common::limits::SUPPORTED_ACTIVE_ITEMS; + let n = SUPPORTED_ACTIVE_ITEMS as i64; + // Consecutive milliseconds, and one item a minute. + for step in [1, 60_000] { + let items: Vec = (0..n) + .map(|i| task(i as u64 + 1, 1_790_000_000_000 + i * step, "x")) + .collect(); + let idx = LinkIndex::build(items.iter()); + let widest = items + .iter() + .map(|item| idx.prefix_len(&item.stable_id())) + .max() + .unwrap(); + assert!(widest <= STABLE_ID_WIDTH, "step {step}: prefix {widest}"); + } + } +} diff --git a/crates/taskbook-client/src/tui/widgets/journal_view.rs b/crates/taskbook-client/src/tui/widgets/journal_view.rs index 9ed874c..c8e6fbb 100644 --- a/crates/taskbook-client/src/tui/widgets/journal_view.rs +++ b/crates/taskbook-client/src/tui/widgets/journal_view.rs @@ -11,6 +11,7 @@ use ratatui::{ use crate::tui::app::App; use taskbook_common::{board, StorageItem}; +use super::item_row::{body_preview, text_spans}; use super::render_scrollable_list; pub fn render_journal_view(frame: &mut Frame, app: &App, area: Rect) { @@ -145,21 +146,13 @@ pub fn render_journal_view(frame: &mut Frame, app: &App, area: Rect) { title_spans.push(Span::styled("● ", app.theme.info)); } - title_spans.push(Span::styled(item.description().to_string(), desc_style)); + title_spans.extend(text_spans(app, item.description(), desc_style)); // Inline note body preview (first line, truncated) - if let Some(note) = item.as_note() { - if let Some(body) = note.body() { - if let Some(first_line) = body.lines().next() { - let preview = if first_line.len() > 50 { - format!(" {}…", &first_line[..50]) - } else if body.lines().count() > 1 { - format!(" {}…", first_line) - } else { - format!(" {}", first_line) - }; - title_spans.push(Span::styled(preview, app.theme.muted)); - } + if let Some(body) = item.note_body() { + let preview = body_preview(app, body); + if !preview.is_empty() { + title_spans.push(Span::styled(preview, app.theme.muted)); } } diff --git a/crates/taskbook-client/src/tui/widgets/link_pane.rs b/crates/taskbook-client/src/tui/widgets/link_pane.rs new file mode 100644 index 0000000..2f2d88e --- /dev/null +++ b/crates/taskbook-client/src/tui/widgets/link_pane.rs @@ -0,0 +1,198 @@ +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use super::item_row::{broken_marker, divergent_marker}; +use super::truncate_chars; +use crate::tui::app::{App, PaneEntry}; +use taskbook_common::links::Target; + +/// Indent before each entry. +const INDENT: &str = " "; + +/// Render the link side pane for the selected item. +pub fn render_link_pane(frame: &mut Frame, app: &App, area: Rect) { + let border_style = if app.pane.focused { + app.theme.info + } else { + app.theme.border + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(Span::styled(" Links ", app.theme.title)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let content = app.pane_content(); + let cursor = app.pane_cursor(&content); + let width = usize::from(inner.width); + let mut lines: Vec = Vec::new(); + + lines.push(Line::from(Span::styled("Links to", app.theme.header))); + if content.outgoing.is_empty() { + lines.push(Line::from(Span::styled(" (none)", app.theme.muted))); + } + for (i, entry) in content.outgoing.iter().enumerate() { + lines.push(entry_line( + app, + entry, + width, + app.pane.focused && i == cursor, + )); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("Linked from", app.theme.header))); + if content.divergent { + lines.push(Line::from(Span::styled( + " ?? divergent", + app.theme.link_divergent, + ))); + } else if content.backlinks.is_empty() { + lines.push(Line::from(Span::styled(" (none)", app.theme.muted))); + } + let offset = content.outgoing.len(); + for (i, entry) in content.backlinks.iter().enumerate() { + let selected = app.pane.focused && offset + i == cursor; + lines.push(entry_line(app, entry, width, selected)); + } + + if app.pane.focused { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "j/k move, Enter jump, Esc back", + app.theme.muted, + ))); + } + + frame.render_widget(Paragraph::new(lines), inner); +} + +/// One entry: the title cut to fit with an ellipsis, then the short stable ID, +/// which always stays visible. +fn entry_line(app: &App, entry: &PaneEntry, width: usize, selected: bool) -> Line<'static> { + let short = app.links.short_id(&entry.id).to_string(); + let (label, label_style, id_text, id_style) = match entry.target { + Target::Active { .. } => ( + entry.title.clone(), + Style::default(), + short, + app.theme.stable_id, + ), + Target::Missing => ( + String::new(), + Style::default(), + broken_marker(app, &entry.id), + app.theme.link_broken, + ), + Target::Divergent => ( + String::new(), + Style::default(), + divergent_marker(app, &entry.id), + app.theme.link_divergent, + ), + }; + + // Indent, label, a space, then the ID. + let room = width.saturating_sub(INDENT.len() + 1 + id_text.chars().count()); + let label = truncate_chars(&label, room); + + let mut line = Line::from(vec![ + Span::raw(INDENT), + Span::styled(label, label_style), + Span::raw(" "), + Span::styled(id_text, id_style), + ]); + if selected { + line = line.style(app.theme.selected); + } + line +} + +#[cfg(test)] +mod tests { + use crate::tui::app::tests::test_app; + use crate::tui::app::App; + use crate::tui::ui; + use ratatui::{backend::TestBackend, Terminal}; + use taskbook_common::links::LinkIndex; + use taskbook_common::{StorageItem, Task}; + + fn screen(app: &mut App, width: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, 20)).unwrap(); + terminal.draw(|f| ui::render(f, app)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn create(app: &mut App, desc: &str) -> u64 { + let id = app + .taskbook + .create_task_direct_with_tags(vec!["my board".into()], desc.into(), 1, vec![], None) + .unwrap(); + app.refresh_items().unwrap(); + id + } + + /// Covers AE6. + #[test] + fn pane_shows_only_on_wide_terminals() { + let (mut app, _) = test_app(); + create(&mut app, "x"); + assert!(!screen(&mut app, 80).contains("Linked from")); + assert!(screen(&mut app, 120).contains("Linked from")); + } + + #[test] + fn empty_sections_say_none() { + let (mut app, _) = test_app(); + create(&mut app, "x"); + let s = screen(&mut app, 120); + assert!(s.contains("Links to"), "{s}"); + assert_eq!(s.matches("(none)").count(), 2, "{s}"); + } + + #[test] + fn long_titles_truncate_but_keep_the_short_id() { + let (mut app, _) = test_app(); + let long = create(&mut app, "An extremely long target title that cannot fit"); + let lid = app.active_items[&long.to_string()].stable_id(); + let src = create(&mut app, &format!("see [[{lid}]]")); + app.selected_index = app.display_order.iter().position(|&i| i == src).unwrap(); + + let s = screen(&mut app, 100); + let short = app.links.short_id(&lid).to_string(); + let line = s.lines().find(|l| l.contains('…')).expect(&s); + assert!(line.contains(&short), "{line}"); + } + + #[test] + fn divergent_selection_shows_a_notice() { + let (mut app, _) = test_app(); + let mut twin = Task::new(90, "Twin".into(), vec!["my board".into()], 1); + twin.timestamp = 1; + let mut twin2 = twin.clone(); + twin2.id = 91; + for t in [twin, twin2] { + app.active_items + .insert(t.id.to_string(), StorageItem::Task(t)); + } + app.items = app.active_items.clone(); + app.links = LinkIndex::build(app.active_items.values()); + app.update_display_order(); + + assert!(screen(&mut app, 120).contains("?? divergent")); + } +} diff --git a/crates/taskbook-client/src/tui/widgets/mod.rs b/crates/taskbook-client/src/tui/widgets/mod.rs index 196bcc2..12aa028 100644 --- a/crates/taskbook-client/src/tui/widgets/mod.rs +++ b/crates/taskbook-client/src/tui/widgets/mod.rs @@ -3,6 +3,7 @@ pub mod command_line; pub mod help_popup; pub mod item_row; pub mod journal_view; +pub mod link_pane; pub mod status_bar; pub mod timeline_view; @@ -45,3 +46,13 @@ pub(crate) fn render_scrollable_list( frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state); } } + +/// `s` cut to at most `max` characters, ending in `…` when cut. +pub(crate) fn truncate_chars(s: &str, max: usize) -> String { + if s.chars().count() > max { + let kept: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{}…", kept) + } else { + s.to_string() + } +} diff --git a/crates/taskbook-common/src/lib.rs b/crates/taskbook-common/src/lib.rs index b2a7a4e..23f0913 100644 --- a/crates/taskbook-common/src/lib.rs +++ b/crates/taskbook-common/src/lib.rs @@ -3,7 +3,10 @@ pub mod board; pub mod due; pub mod encryption; pub mod error; +pub mod limits; +pub mod links; pub mod models; +pub mod stable_id; pub use error::{CommonError, CommonResult}; pub use models::{Item, Note, StorageItem, Task}; diff --git a/crates/taskbook-common/src/limits.rs b/crates/taskbook-common/src/limits.rs new file mode 100644 index 0000000..5ce44b5 --- /dev/null +++ b/crates/taskbook-common/src/limits.rs @@ -0,0 +1,127 @@ +//! The size envelope taskbook is designed for. +//! +//! taskbook is a task board for a person: someone reads it, triages it and +//! keeps up with it. That purpose, not storage performance, sets the bound. +//! A few hundred active items is already more than one person can keep up +//! with; the supported limits sit above that as headroom, and they apply to +//! agent use (MCP) too. +//! +//! Features should handle what a person using the board can produce, and no +//! more. Scenarios only reachable at machine scale (thousands of active +//! items, bulk writes within one millisecond, very large archives) are +//! unsupported, not edge cases to design for. That is why, for example, +//! two items created in the same millisecond on two devices may share a +//! stable ID. +//! +//! The JSON storage fits this: every operation reads, parses and rewrites +//! the whole file, and sync uploads every item, which is fast at this size +//! (measured numbers in the README's "Limits" section). + +/// Active items we recommend staying under: the human-scale board. +pub const RECOMMENDED_ACTIVE_ITEMS: usize = 512; + +/// Active items supported, as headroom above the recommendation. +pub const SUPPORTED_ACTIVE_ITEMS: usize = 1024; + +/// Archived items supported. The archive grows over the years, and every +/// create reads it to keep creation timestamps unique. +pub const SUPPORTED_ARCHIVED_ITEMS: usize = 4096; + +/// How a store compares with the envelope, once it is past the recommended +/// active count or a supported limit. Informational: nothing is blocked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SizeNotice { + pub active: usize, + pub archived: usize, +} + +impl SizeNotice { + /// A notice for a store with these counts, or `None` while it is within + /// the recommendation and the supported archive size. + pub fn check(active: usize, archived: usize) -> Option { + let notice = Self { active, archived }; + (notice.active_over_recommended() || notice.archive_over_supported()).then_some(notice) + } + + pub fn active_over_recommended(&self) -> bool { + self.active > RECOMMENDED_ACTIVE_ITEMS + } + + pub fn archive_over_supported(&self) -> bool { + self.archived > SUPPORTED_ARCHIVED_ITEMS + } + + /// One line naming each limit the store is past. + pub fn summary(&self) -> String { + let mut parts = Vec::new(); + if self.active > SUPPORTED_ACTIVE_ITEMS { + parts.push(format!( + "{} active items, over the {SUPPORTED_ACTIVE_ITEMS} supported \ + ({RECOMMENDED_ACTIVE_ITEMS} recommended)", + self.active + )); + } else if self.active_over_recommended() { + parts.push(format!( + "{} active items, over the {RECOMMENDED_ACTIVE_ITEMS} recommended", + self.active + )); + } + if self.archive_over_supported() { + parts.push(format!( + "{} archived items, over the {SUPPORTED_ARCHIVED_ITEMS} supported", + self.archived + )); + } + parts.join("; ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_notice_at_the_limits() { + assert_eq!( + SizeNotice::check(RECOMMENDED_ACTIVE_ITEMS, SUPPORTED_ARCHIVED_ITEMS), + None + ); + } + + #[test] + fn active_past_the_recommendation() { + let notice = SizeNotice::check(RECOMMENDED_ACTIVE_ITEMS + 1, 0).unwrap(); + assert_eq!( + notice.summary(), + "513 active items, over the 512 recommended" + ); + } + + #[test] + fn active_past_the_supported_size() { + let notice = SizeNotice::check(SUPPORTED_ACTIVE_ITEMS + 1, 0).unwrap(); + assert_eq!( + notice.summary(), + "1025 active items, over the 1024 supported (512 recommended)" + ); + } + + #[test] + fn archive_past_the_supported_size_alone() { + let notice = SizeNotice::check(10, SUPPORTED_ARCHIVED_ITEMS + 1).unwrap(); + assert!(!notice.active_over_recommended()); + assert_eq!( + notice.summary(), + "4097 archived items, over the 4096 supported" + ); + } + + #[test] + fn both_limits_are_named() { + let notice = SizeNotice::check(600, 5000).unwrap(); + assert_eq!( + notice.summary(), + "600 active items, over the 512 recommended; 5000 archived items, over the 4096 supported" + ); + } +} diff --git a/crates/taskbook-common/src/links.rs b/crates/taskbook-common/src/links.rs new file mode 100644 index 0000000..5d0f92c --- /dev/null +++ b/crates/taskbook-common/src/links.rs @@ -0,0 +1,828 @@ +//! Inline `[[…]]` links between items. +//! +//! A link is a `[[]]` token in an item's text (task +//! description, note title, note body); the text is the only place links are +//! stored. When the TUI saves text, references the user *added in this edit* +//! as `[[12]]` (numeric ID) or `[[kq]]` (stable-ID prefix) are rewritten to full +//! tokens, and unresolvable ones reject the save. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::ops::Range; + +use crate::models::StorageItem; +use crate::stable_id; + +/// How the text between `[[` and `]]` is interpreted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpanKind { + /// Exactly 16 lowercase stable-ID letters: a link. + Full, + /// Digits only: a numeric-ID reference, resolved on save. + Numeric, + /// 1-15 lowercase stable-ID letters: a prefix reference, resolved on save. + Prefix, + /// Anything else, including uppercase or other letters: plain text. + Plain, +} + +/// A closed `[[…]]` span within a text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Span { + /// Byte range of the whole span, brackets included. + pub range: Range, + /// Byte range of the inner text. + pub inner: Range, + pub kind: SpanKind, +} + +/// Find every closed `[[…]]` span. Spans never contain a newline. +pub fn scan(text: &str) -> Vec { + let mut spans = Vec::new(); + let mut i = 0; + while let Some(offset) = text[i..].find("[[") { + let start = i + offset; + let inner_start = start + 2; + let Some(close) = text[inner_start..].find("]]") else { + break; + }; + let inner = &text[inner_start..inner_start + close]; + if let Some(nested) = inner.rfind("[[") { + // A later `[[` opens the span that this `]]` closes. + i = inner_start + nested; + continue; + } + if let Some(newline) = inner.find('\n') { + i = inner_start + newline + 1; + continue; + } + let end = inner_start + close + 2; + spans.push(Span { + range: start..end, + inner: inner_start..inner_start + close, + kind: classify(inner), + }); + i = end; + } + spans +} + +fn classify(inner: &str) -> SpanKind { + if stable_id::is_full_id(inner) { + SpanKind::Full + } else if !inner.is_empty() && inner.bytes().all(|b| b.is_ascii_digit()) { + SpanKind::Numeric + } else if stable_id::is_prefix(inner) { + SpanKind::Prefix + } else { + SpanKind::Plain + } +} + +/// Lookups the save-time rewrite needs over the active items. +#[derive(Debug, Default)] +pub struct Resolver { + by_numeric: HashMap, + ids: Vec, + divergent: HashSet, +} + +impl Resolver { + pub fn from_items<'a, I>(items: I) -> Self + where + I: IntoIterator, + { + let mut by_numeric = HashMap::new(); + let mut counts: HashMap = HashMap::new(); + for item in items { + let id = item.stable_id(); + by_numeric.insert(item.id(), id.clone()); + *counts.entry(id).or_default() += 1; + } + let divergent = counts + .iter() + .filter(|(_, &n)| n > 1) + .map(|(id, _)| id.clone()) + .collect(); + let mut ids: Vec = counts.into_keys().collect(); + ids.sort_unstable(); + Self { + by_numeric, + ids, + divergent, + } + } + + /// Distinct active stable IDs starting with `prefix`, in sorted order. + pub fn ids_with_prefix(&self, prefix: &str) -> &[String] { + let start = self.ids.partition_point(|id| id.as_str() < prefix); + let len = self.ids[start..] + .iter() + .take_while(|id| id.starts_with(prefix)) + .count(); + &self.ids[start..start + len] + } + + /// The stable ID of the active item with this numeric ID. + pub fn stable_id_for(&self, numeric: u64) -> Option<&str> { + self.by_numeric.get(&numeric).map(String::as_str) + } + + fn resolve(&self, kind: SpanKind, inner: &str) -> Result { + let id = match kind { + SpanKind::Numeric => inner + .parse::() + .ok() + .and_then(|n| self.stable_id_for(n)) + .ok_or(RejectReason::NotFound)?, + SpanKind::Prefix => match self.ids_with_prefix(inner) { + [] => return Err(RejectReason::NotFound), + [one] => one.as_str(), + many => return Err(RejectReason::Ambiguous(many.len())), + }, + SpanKind::Full | SpanKind::Plain => unreachable!("only references are resolved"), + }; + if self.is_divergent(id) { + Err(RejectReason::Divergent) + } else { + Ok(id.to_string()) + } + } + + /// True when more than one active item has this stable ID. + pub fn is_divergent(&self, id: &str) -> bool { + self.divergent.contains(id) + } +} + +/// Why a reference added in this edit could not be resolved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RejectReason { + NotFound, + Ambiguous(usize), + Divergent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rejection { + /// The span as written, e.g. `[[99]]`. + pub reference: String, + pub reason: RejectReason, +} + +impl fmt::Display for Rejection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.reason { + RejectReason::NotFound => write!(f, "{} not found", self.reference), + RejectReason::Ambiguous(n) => write!(f, "{} matches {} items", self.reference, n), + RejectReason::Divergent => write!(f, "{} is divergent", self.reference), + } + } +} + +/// The text to store, plus each reference that was rewritten. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rewrite { + pub text: String, + /// `(reference as written, full stable ID it now links to)`. + pub linked: Vec<(String, String)>, +} + +/// Rewrite the numeric and prefix references `draft` adds relative to +/// `baseline` (the stored text when the edit began; empty for a create). +/// +/// For each span text, the first k occurrences in `draft` (k = its count in +/// `baseline`) are pre-existing and stay verbatim; later ones are added. +/// Full tokens and plain spans are never changed or rejected. +pub fn rewrite_on_save( + baseline: &str, + draft: &str, + resolver: &Resolver, +) -> Result> { + let is_reference = |kind| matches!(kind, SpanKind::Numeric | SpanKind::Prefix); + + let mut baseline_counts: HashMap<&str, usize> = HashMap::new(); + for span in scan(baseline).into_iter().filter(|s| is_reference(s.kind)) { + *baseline_counts.entry(&baseline[span.range]).or_default() += 1; + } + + let mut seen: HashMap<&str, usize> = HashMap::new(); + let mut text = String::with_capacity(draft.len()); + let mut copied_to = 0; + let mut linked = Vec::new(); + let mut rejections = Vec::new(); + + for span in scan(draft).into_iter().filter(|s| is_reference(s.kind)) { + let reference = &draft[span.range.clone()]; + let occurrence = seen.entry(reference).or_default(); + *occurrence += 1; + if *occurrence <= baseline_counts.get(reference).copied().unwrap_or(0) { + continue; + } + + match resolver.resolve(span.kind, &draft[span.inner.clone()]) { + Ok(id) => { + text.push_str(&draft[copied_to..span.range.start]); + text.push_str("[["); + text.push_str(&id); + text.push_str("]]"); + copied_to = span.range.end; + linked.push((reference.to_string(), id)); + } + Err(reason) => rejections.push(Rejection { + reference: reference.to_string(), + reason, + }), + } + } + + if !rejections.is_empty() { + return Err(rejections); + } + text.push_str(&draft[copied_to..]); + Ok(Rewrite { text, linked }) +} + +/// Full stable IDs that `text` links to, deduplicated, in order of first +/// appearance. +pub fn link_targets(text: &str) -> Vec { + let mut targets: Vec = Vec::new(); + for span in scan(text).into_iter().filter(|s| s.kind == SpanKind::Full) { + let id = &text[span.inner]; + if !targets.iter().any(|t| t == id) { + targets.push(id.to_string()); + } + } + targets +} + +/// Stable IDs an item links to from its description and, for a note, its +/// body: deduplicated, in order of first appearance. +pub fn item_link_targets(item: &StorageItem) -> Vec { + let mut targets = link_targets(item.description()); + if let Some(body) = item.note_body() { + for id in link_targets(body) { + if !targets.contains(&id) { + targets.push(id); + } + } + } + targets +} + +/// What a link's target resolves to among the active items. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target { + Active { numeric: u64 }, + Divergent, + Missing, +} + +/// One piece of rendered text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Text(String), + /// A resolved link: the target's current title, with tokens inside that + /// title shown as short stable IDs (one level, so cycles cannot recurse). + Link { + id: String, + title: String, + }, + Broken { + id: String, + }, + Divergent { + id: String, + }, +} + +/// Backlinks, targets, display prefixes and rendering over the active +/// items, rebuilt whenever the item set changes. +#[derive(Debug, Default)] +pub struct LinkIndex { + resolver: Resolver, + titles: HashMap, + numeric_by_id: HashMap, + prefix_lens: HashMap, + inbound: HashMap>, +} + +impl LinkIndex { + pub fn build<'a, I>(items: I) -> Self + where + I: IntoIterator, + { + let items: Vec<&StorageItem> = items.into_iter().collect(); + let resolver = Resolver::from_items(items.iter().copied()); + + let mut titles = HashMap::new(); + let mut numeric_by_id = HashMap::new(); + let mut inbound: HashMap> = HashMap::new(); + for item in &items { + let own_id = item.stable_id(); + titles.insert(own_id.clone(), item.description().to_string()); + numeric_by_id.insert(own_id.clone(), item.id()); + + for id in item_link_targets(item) + .into_iter() + .filter(|id| *id != own_id) + { + inbound.entry(id).or_default().push(item.id()); + } + } + for sources in inbound.values_mut() { + sources.sort_unstable(); + sources.dedup(); + } + + let prefix_lens = + stable_id::shortest_unique_prefix_lengths(resolver.ids.iter().map(String::as_str)); + + Self { + resolver, + titles, + numeric_by_id, + prefix_lens, + inbound, + } + } + + pub fn resolver(&self) -> &Resolver { + &self.resolver + } + + pub fn target(&self, id: &str) -> Target { + if self.resolver.is_divergent(id) { + return Target::Divergent; + } + self.numeric_by_id + .get(id) + .map_or(Target::Missing, |&numeric| Target::Active { numeric }) + } + + /// Active items (numeric IDs, ascending) whose text links to `id`, + /// excluding the item that holds `id` itself. Works for IDs with no active + /// item, such as an archived one. + pub fn backlinks(&self, id: &str) -> &[u64] { + self.inbound.get(id).map_or(&[], Vec::as_slice) + } + + /// Length of the shortest prefix that tells `id` apart from every active + /// item's ID (computed against the active set plus `id` itself). + pub fn prefix_len(&self, id: &str) -> usize { + if let Some(&len) = self.prefix_lens.get(id) { + return len; + } + let ids = &self.resolver.ids; + let at = ids.partition_point(|other| other.as_str() < id); + let prev = at.checked_sub(1).map(|i| ids[i].as_str()); + stable_id::unique_prefix_len(prev, ids.get(at).map(String::as_str), id) + } + + /// `id` cut to its shortest unique prefix. + pub fn short_id<'a>(&self, id: &'a str) -> &'a str { + &id[..self.prefix_len(id).min(id.len())] + } + + /// Split `text` into plain text and link segments. + pub fn render(&self, text: &str) -> Vec { + let mut segments = Vec::new(); + let mut copied_to = 0; + for span in scan(text).into_iter().filter(|s| s.kind == SpanKind::Full) { + if span.range.start > copied_to { + segments.push(Segment::Text(text[copied_to..span.range.start].to_string())); + } + let id = text[span.inner.clone()].to_string(); + segments.push(match self.target(&id) { + Target::Active { .. } => Segment::Link { + title: self.title_with_short_ids(&id), + id, + }, + Target::Divergent => Segment::Divergent { id }, + Target::Missing => Segment::Broken { id }, + }); + copied_to = span.range.end; + } + if copied_to < text.len() { + segments.push(Segment::Text(text[copied_to..].to_string())); + } + segments + } + + /// The current title of the active item with this stable ID, with its own + /// tokens shown as short IDs. + pub fn title(&self, id: &str) -> Option { + self.titles + .contains_key(id) + .then(|| self.title_with_short_ids(id)) + } + + /// A target's title with its own tokens shown as short IDs. + fn title_with_short_ids(&self, id: &str) -> String { + let title = self.titles.get(id).map_or("", String::as_str); + let mut flat = String::with_capacity(title.len()); + let mut copied_to = 0; + for span in scan(title).into_iter().filter(|s| s.kind == SpanKind::Full) { + flat.push_str(&title[copied_to..span.range.start]); + flat.push_str(self.short_id(&title[span.inner.clone()])); + copied_to = span.range.end; + } + flat.push_str(&title[copied_to..]); + flat + } +} + +/// Separator used to diff a note's title and body as one text, so a reference +/// moved between the two is not treated as added. It never occurs in a span's +/// reference forms, so no reference can straddle the two fields. +const NOTE_FIELD_SEPARATOR: char = '\u{1e}'; + +/// Join a note's title and body for [`rewrite_on_save`]. +pub fn join_note(title: &str, body: Option<&str>) -> String { + match body { + Some(body) => format!("{title}{NOTE_FIELD_SEPARATOR}{body}"), + None => title.to_string(), + } +} + +/// Split text produced from [`join_note`] back into title and body. +pub fn split_note(text: &str) -> (String, Option) { + match text.split_once(NOTE_FIELD_SEPARATOR) { + Some((title, body)) => (title.to_string(), Some(body.to_string())), + None => (text.to_string(), None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Note, Task}; + + fn task(id: u64, ts: i64) -> StorageItem { + let mut t = Task::new(id, format!("task {id}"), vec!["My Board".to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn note(id: u64, ts: i64) -> StorageItem { + let mut n = Note::new(id, format!("note {id}"), vec!["My Board".to_string()]); + n.timestamp = ts; + StorageItem::Note(n) + } + + /// Find a timestamp whose stable ID starts with `prefix`, at or after `from`. + fn ts_with_prefix(prefix: &str, from: i64) -> i64 { + (from..) + .find(|&ts| stable_id::from_timestamp(ts).starts_with(prefix)) + .unwrap() + } + + fn kinds(text: &str) -> Vec<(String, SpanKind)> { + scan(text) + .into_iter() + .map(|s| (text[s.range.clone()].to_string(), s.kind)) + .collect() + } + + #[test] + fn scan_classifies_spans() { + let full = stable_id::from_timestamp(1); + let text = format!("a [[12]] b [[kq]] c [[{full}]] d [[todo]] e [[KQZ]] f [[]] g [[12a]]"); + assert_eq!( + kinds(&text), + vec![ + ("[[12]]".to_string(), SpanKind::Numeric), + ("[[kq]]".to_string(), SpanKind::Prefix), + (format!("[[{full}]]"), SpanKind::Full), + ("[[todo]]".to_string(), SpanKind::Plain), + ("[[KQZ]]".to_string(), SpanKind::Plain), + ("[[]]".to_string(), SpanKind::Plain), + ("[[12a]]".to_string(), SpanKind::Plain), + ] + ); + } + + #[test] + fn scan_ignores_unclosed_and_newline_spans() { + assert!(scan("see [[12").is_empty()); + assert!(scan("title [[\n12]] body").is_empty()); + // The later `[[` starts the span. + assert_eq!( + kinds("[[ [[12]]"), + vec![("[[12]]".to_string(), SpanKind::Numeric)] + ); + } + + #[test] + fn scan_handles_multibyte_text() { + let text = "café [[12]] — ünïcode [[kq]]"; + let found = kinds(text); + assert_eq!(found.len(), 2); + assert_eq!(found[0].0, "[[12]]"); + } + + /// Covers AE1. + #[test] + fn numeric_reference_rewrites_and_unknowns_reject() { + let a = ts_with_prefix("kq", 0); + let b = ts_with_prefix("kq", a + 1); + let items = [task(12, 1_000), task(3, a), note(4, b)]; + let r = Resolver::from_items(items.iter()); + + let ok = rewrite_on_save("", "see [[12]]", &r).unwrap(); + let full = stable_id::from_timestamp(1_000); + assert_eq!(ok.text, format!("see [[{full}]]")); + assert_eq!(ok.linked, vec![("[[12]]".to_string(), full)]); + + let err = rewrite_on_save("", "see [[99]]", &r).unwrap_err(); + assert_eq!(err[0].to_string(), "[[99]] not found"); + + let err = rewrite_on_save("", "see [[kq]]", &r).unwrap_err(); + assert_eq!(err[0].to_string(), "[[kq]] matches 2 items"); + } + + #[test] + fn unique_prefix_rewrites() { + let ts = ts_with_prefix("mz", 0); + let items = [task(1, ts)]; + let r = Resolver::from_items(items.iter()); + let ok = rewrite_on_save("", "re [[mz]]", &r).unwrap(); + assert_eq!(ok.text, format!("re [[{}]]", stable_id::from_timestamp(ts))); + } + + /// Covers AE3. + #[test] + fn pre_existing_numeric_text_stays_plain() { + let items = [task(12, 1_000)]; + let r = Resolver::from_items(items.iter()); + let ok = rewrite_on_save("see [[12]]", "see [[12]] typo-fixed", &r).unwrap(); + assert_eq!(ok.text, "see [[12]] typo-fixed"); + assert!(ok.linked.is_empty()); + } + + /// Covers AE2, plus R10 for newly added tokens. + #[test] + fn full_tokens_are_kept_whatever_their_target() { + let r = Resolver::from_items(std::iter::empty()); + let missing = stable_id::from_timestamp(42); + let existing = format!("x [[{missing}]]"); + assert_eq!( + rewrite_on_save(&existing, &existing, &r).unwrap().text, + existing + ); + + let added = format!("y [[{missing}]]"); + assert_eq!(rewrite_on_save("y", &added, &r).unwrap().text, added); + } + + #[test] + fn only_occurrences_beyond_the_baseline_count_are_added() { + let items = [task(12, 1_000)]; + let r = Resolver::from_items(items.iter()); + let full = stable_id::from_timestamp(1_000); + let ok = rewrite_on_save("[[12]]", "[[12]] and [[12]]", &r).unwrap(); + assert_eq!(ok.text, format!("[[12]] and [[{full}]]")); + } + + #[test] + fn moving_a_span_between_title_and_body_is_not_an_addition() { + let r = Resolver::from_items(std::iter::empty()); + let ok = rewrite_on_save("title\nbody [[99]]", "title [[99]]\nbody", &r).unwrap(); + assert_eq!(ok.text, "title [[99]]\nbody"); + } + + #[test] + fn plain_spans_are_never_rejected() { + let r = Resolver::from_items(std::iter::empty()); + for text in [ + "[[todo]]", "[[bin]]", "[[12a]]", "[[ ]]", "[[KQZ]]", "[[Work]]", + ] { + assert_eq!(rewrite_on_save("", text, &r).unwrap().text, text); + } + } + + #[test] + fn divergent_targets_reject() { + let ts = ts_with_prefix("pq", 0); + let items = [task(1, ts), note(2, ts)]; + let r = Resolver::from_items(items.iter()); + let id = stable_id::from_timestamp(ts); + assert!(r.is_divergent(&id)); + + let err = rewrite_on_save("", "[[pq]]", &r).unwrap_err(); + assert_eq!(err[0].to_string(), "[[pq]] is divergent"); + let err = rewrite_on_save("", "[[1]]", &r).unwrap_err(); + assert_eq!(err[0].reason, RejectReason::Divergent); + } + + #[test] + fn unclosed_reference_is_left_as_text() { + let r = Resolver::from_items(std::iter::empty()); + assert_eq!( + rewrite_on_save("", "see [[12", &r).unwrap().text, + "see [[12" + ); + } + + #[test] + fn retry_with_the_same_baseline_rejects_again() { + let r = Resolver::from_items(std::iter::empty()); + assert!(rewrite_on_save("", "a [[99]]", &r).is_err()); + assert!(rewrite_on_save("", "a [[99]] edited", &r).is_err()); + } + + #[test] + fn note_fields_round_trip_through_a_rewrite() { + let items = [task(12, 1_000)]; + let r = Resolver::from_items(items.iter()); + let baseline = join_note("Title", Some("body [[12]]")); + let draft = join_note("Title [[12]]", Some("body")); + let (title, body) = split_note(&rewrite_on_save(&baseline, &draft, &r).unwrap().text); + assert_eq!(title, "Title [[12]]"); + assert_eq!(body.as_deref(), Some("body")); + + let draft = join_note("New", None); + assert_eq!(split_note(&draft), ("New".to_string(), None)); + } + + #[test] + fn rewrite_never_panics_on_odd_input() { + let r = Resolver::from_items(std::iter::empty()); + for text in [ + "[[", + "]]", + "[[]", + "[[[[]]]]", + "é[[é]]é", + "[[\u{1e}12]]", + "[[1]]]]", + "]][[", + ] { + let _ = rewrite_on_save(text, text, &r); + let _ = rewrite_on_save("", text, &r); + } + } + + #[test] + fn every_rejection_is_reported() { + let r = Resolver::from_items(std::iter::empty()); + let err = rewrite_on_save("", "[[98]] [[99]] [[mm]]", &r).unwrap_err(); + assert_eq!(err.len(), 3); + } + // ----- link index ----- + + fn titled_task(id: u64, ts: i64, desc: String) -> StorageItem { + let mut t = Task::new(id, desc, vec!["My Board".to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn token(ts: i64) -> String { + format!("[[{}]]", stable_id::from_timestamp(ts)) + } + + #[test] + fn outgoing_and_backlinks_dedupe_and_exclude_self() { + let (a, b) = (1_000, 2_000); + let items = [ + titled_task(1, a, format!("A {} {} {}", token(b), token(b), token(a))), + titled_task(2, b, "B".to_string()), + ]; + let idx = LinkIndex::build(items.iter()); + let (ida, idb) = (stable_id::from_timestamp(a), stable_id::from_timestamp(b)); + + assert_eq!(item_link_targets(&items[0]), [idb.clone(), ida.clone()]); + assert_eq!(idx.backlinks(&idb), [1]); + assert!(idx.backlinks(&ida).is_empty()); + } + + #[test] + fn link_to_missing_id_is_broken_but_still_findable_by_id() { + let missing = stable_id::from_timestamp(9_999); + let items = [titled_task(1, 1_000, format!("A [[{missing}]]"))]; + let idx = LinkIndex::build(items.iter()); + + assert_eq!(idx.target(&missing), Target::Missing); + assert_eq!(idx.backlinks(&missing), [1]); + assert_eq!( + idx.render(&format!("A [[{missing}]]")), + vec![ + Segment::Text("A ".to_string()), + Segment::Broken { id: missing } + ] + ); + } + + /// Covers AE5 (index half). + #[test] + fn archiving_and_restoring_a_target_toggles_its_links() { + let t = 5_000; + let linking = |id| titled_task(id, 1_000 + id as i64, format!("see {}", token(t))); + let target = titled_task(7, t, "Target".to_string()); + let tid = stable_id::from_timestamp(t); + + let without = [linking(1), linking(2), linking(3)]; + let idx = LinkIndex::build(without.iter()); + assert_eq!(idx.target(&tid), Target::Missing); + assert_eq!(idx.backlinks(&tid), [1, 2, 3]); + + let with: Vec = without.iter().cloned().chain([target]).collect(); + let idx = LinkIndex::build(with.iter()); + assert_eq!(idx.target(&tid), Target::Active { numeric: 7 }); + assert_eq!(idx.backlinks(&tid), [1, 2, 3]); + } + + #[test] + fn shared_ids_are_divergent() { + let ts = 3_000; + let items = [titled_task(1, ts, "x".into()), note(2, ts)]; + let idx = LinkIndex::build(items.iter()); + let id = stable_id::from_timestamp(ts); + assert_eq!(idx.target(&id), Target::Divergent); + assert_eq!( + idx.render(&format!("[[{id}]]")), + vec![Segment::Divergent { id }] + ); + } + + #[test] + fn rendering_substitutes_one_level_only() { + let (a, b) = (1_000, 2_000); + let items = [ + titled_task(1, a, format!("see {}", token(b))), + titled_task(2, b, format!("re {}", token(a))), + ]; + let idx = LinkIndex::build(items.iter()); + let ida = stable_id::from_timestamp(a); + let idb = stable_id::from_timestamp(b); + + let rendered = idx.render(&format!("x {}", token(a))); + assert_eq!( + rendered, + vec![ + Segment::Text("x ".to_string()), + Segment::Link { + id: ida, + title: format!("see {}", idx.short_id(&idb)), + }, + ] + ); + } + + #[test] + fn prefix_lengths_cover_active_and_outside_ids() { + let a = ts_with_prefix("kq", 0); + let b = ts_with_prefix("kq", a + 1); + let c = ts_with_prefix("z", 0); + let items = [task(1, a), task(2, b), task(3, c)]; + let idx = LinkIndex::build(items.iter()); + let ida = stable_id::from_timestamp(a); + + assert!(idx.prefix_len(&ida) >= 3); + assert_eq!(idx.prefix_len(&stable_id::from_timestamp(c)), 1); + // An archived ID sharing "kq" needs at least 3 letters too. + let outside = "kqkkkkkkkkkkkkkk"; + assert!(idx.prefix_len(outside) >= 3); + assert!(idx.short_id(outside).starts_with("kq")); + } + + #[test] + fn link_targets_in_order_of_first_appearance() { + let text = format!("{} {} {} [[12]]", token(2), token(1), token(2)); + assert_eq!( + link_targets(&text), + vec![stable_id::from_timestamp(2), stable_id::from_timestamp(1)] + ); + } + + #[test] + fn note_bodies_contribute_links() { + let mut n = Note::new_with_body( + 1, + "N".into(), + Some(format!("body {}", token(2_000))), + vec![], + ); + n.timestamp = 1_000; + let items = [StorageItem::Note(n), titled_task(2, 2_000, "T".into())]; + let idx = LinkIndex::build(items.iter()); + assert_eq!(idx.backlinks(&stable_id::from_timestamp(2_000)), [1]); + } + + #[test] + fn index_build_scales_to_thousands_of_items() { + let items: Vec = (0..5_000) + .map(|i| { + titled_task( + i + 1, + 1_000 + i as i64, + format!("item {} {}", i, token(1_000 + (i as i64 + 1) % 5_000)), + ) + }) + .collect(); + let start = std::time::Instant::now(); + let idx = LinkIndex::build(items.iter()); + assert_eq!(idx.backlinks(&stable_id::from_timestamp(1_000)).len(), 1); + assert!(start.elapsed() < std::time::Duration::from_secs(2)); + } +} diff --git a/crates/taskbook-common/src/models/item.rs b/crates/taskbook-common/src/models/item.rs index 64dae16..4cfaeb7 100644 --- a/crates/taskbook-common/src/models/item.rs +++ b/crates/taskbook-common/src/models/item.rs @@ -8,4 +8,10 @@ pub trait Item { fn boards(&self) -> &[String]; fn tags(&self) -> &[String]; fn is_task(&self) -> bool; + + /// The item's stable ID, derived from its creation timestamp. + /// See [`crate::stable_id`]. + fn stable_id(&self) -> String { + crate::stable_id::from_timestamp(self.timestamp()) + } } diff --git a/crates/taskbook-common/src/models/mod.rs b/crates/taskbook-common/src/models/mod.rs index 3bf7d35..e772f3f 100644 --- a/crates/taskbook-common/src/models/mod.rs +++ b/crates/taskbook-common/src/models/mod.rs @@ -148,6 +148,12 @@ impl StorageItem { matches!(self, StorageItem::Task(_)) } + /// The item's stable ID, derived from its creation timestamp. + /// See [`crate::stable_id`]. + pub fn stable_id(&self) -> String { + crate::stable_id::from_timestamp(self.timestamp()) + } + pub fn set_description(&mut self, desc: String) { match self { StorageItem::Task(t) => t.description = desc, @@ -238,3 +244,62 @@ impl StorageItem { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stable_id; + + fn task_at(ts: i64) -> StorageItem { + let mut t = Task::new(3, "Write report".to_string(), vec!["Work".to_string()], 1); + t.timestamp = ts; + StorageItem::Task(t) + } + + fn note_at(ts: i64) -> StorageItem { + let mut n = Note::new(4, "Meeting notes".to_string(), vec!["Work".to_string()]); + n.timestamp = ts; + StorageItem::Note(n) + } + + #[test] + fn stable_id_is_derived_from_timestamp_only() { + let ts = 1_790_000_000_000; + assert_eq!(task_at(ts).stable_id(), stable_id::from_timestamp(ts)); + assert_eq!(task_at(ts).stable_id(), note_at(ts).stable_id()); + + let via_trait: &dyn Item = &task_at(ts); + assert_eq!(via_trait.stable_id(), stable_id::from_timestamp(ts)); + } + + #[test] + fn stable_id_survives_every_mutation_but_timestamp() { + let mut item = task_at(1_790_000_000_000); + let before = item.stable_id(); + + item.set_description("Rewritten".to_string()); + item.set_starred(true); + item.set_boards(vec!["Home".to_string()]); + item.set_tags(vec!["urgent".to_string()]); + if let Some(t) = item.as_task_mut() { + t.is_complete = true; + t.in_progress = true; + t.id = 99; + } + + assert_eq!(item.stable_id(), before); + } + + #[test] + fn stable_id_round_trips_without_a_stored_field() { + let item = note_at(1_790_000_000_123); + let json = serde_json::to_string(&item).unwrap(); + assert!( + !json.contains(&item.stable_id()), + "stable id must not be serialized: {json}" + ); + + let back: StorageItem = serde_json::from_str(&json).unwrap(); + assert_eq!(back.stable_id(), item.stable_id()); + } +} diff --git a/crates/taskbook-common/src/stable_id.rs b/crates/taskbook-common/src/stable_id.rs new file mode 100644 index 0000000..0796089 --- /dev/null +++ b/crates/taskbook-common/src/stable_id.rs @@ -0,0 +1,180 @@ +//! Stable item identity derived from the creation timestamp. +//! +//! An item's numeric `_id` is positional and changes on archive and restore, +//! so links cannot use it. The stable ID plays the role of a jj change ID: it +//! is a pure function of `_timestamp` (which never changes after creation), +//! computed whenever an item is read and never stored. +//! +//! The timestamp is passed through an invertible 64-bit mixer, so distinct +//! timestamps never collide and nearby timestamps get unrelated prefixes, then +//! written as 16 letters from jj's "reverse hex" alphabet (`k`..=`z`). The +//! letters-only alphabet keeps stable IDs lexically disjoint from numeric IDs. +//! +//! The mapping is part of the data contract: every `[[id]]` token written by a +//! user depends on it. Never change the mixer constants or the alphabet. + +use std::collections::HashMap; + +/// The 16-letter alphabet; index `n` encodes nibble value `n`. +pub const ALPHABET: &[u8; 16] = b"klmnopqrstuvwxyz"; + +/// Length of a full stable ID. +pub const LEN: usize = 16; + +/// Derive the stable ID for an item created at `timestamp` (epoch millis). +pub fn from_timestamp(timestamp: i64) -> String { + let mixed = mix(timestamp as u64); + (0..LEN) + .map(|i| { + let nibble = (mixed >> (4 * (LEN - 1 - i))) & 0xf; + ALPHABET[nibble as usize] as char + }) + .collect() +} + +/// The splitmix64 finalizer: a bijection on `u64` (xorshifts and odd +/// multipliers are each invertible), with good avalanche between nearby inputs. +fn mix(mut z: u64) -> u64 { + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// Shortest prefix of `id` shared with neither sorted neighbour. +pub(crate) fn unique_prefix_len(prev: Option<&str>, next: Option<&str>, id: &str) -> usize { + let common = |other: &str| { + other + .bytes() + .zip(id.bytes()) + .take_while(|(a, b)| a == b) + .count() + }; + let shared = prev.map_or(0, common).max(next.map_or(0, common)); + (shared + 1).min(id.len()) +} + +fn is_alphabet_run(s: &str) -> bool { + s.bytes().all(|b| ALPHABET.contains(&b)) +} + +/// True when `s` is a full stable ID: exactly 16 lowercase alphabet letters. +pub fn is_full_id(s: &str) -> bool { + s.len() == LEN && is_alphabet_run(s) +} + +/// True when `s` is a non-empty, shorter-than-full run of lowercase alphabet +/// letters, i.e. something that can only be a stable-ID prefix. +pub fn is_prefix(s: &str) -> bool { + !s.is_empty() && s.len() < LEN && is_alphabet_run(s) +} + +/// For each distinct ID, the length of its shortest prefix that no other ID in +/// the set shares. IDs that occur more than once get the full length. +pub fn shortest_unique_prefix_lengths<'a, I>(ids: I) -> HashMap +where + I: IntoIterator, +{ + let mut sorted: Vec<&str> = ids.into_iter().collect(); + sorted.sort_unstable(); + + let mut lengths = HashMap::with_capacity(sorted.len()); + for (i, id) in sorted.iter().enumerate() { + let prev = i.checked_sub(1).map(|j| sorted[j]); + let next = sorted.get(i + 1).copied(); + let len = if prev == Some(*id) || next == Some(*id) { + LEN + } else { + unique_prefix_len(prev, next, id) + }; + lengths.insert((*id).to_string(), len); + } + lengths +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Golden vectors, computed independently of this module. They freeze the + /// derivation: if any of these change, every stored link breaks. + #[test] + fn golden_vectors_never_change() { + let cases: [(i64, &str); 6] = [ + (0, "kkkkkkkkkkkkkkkk"), + (-1, "voxkppzwzmwvvxrv"), + (i64::MIN, "mpwmqyuprtwyutsu"), + (i64::MAX, "puqsmuzyrtqpxyvx"), + (1_790_000_000_000, "nuyzmslzwrsnzpqn"), + (1_790_000_000_001, "tqqzmlsvvrtzwzqz"), + ]; + for (ts, expected) in cases { + assert_eq!(from_timestamp(ts), expected, "timestamp {ts}"); + } + } + + #[test] + fn output_is_sixteen_alphabet_letters() { + for ts in [0, 1, 42, -7, 1_700_000_000_000, i64::MAX, i64::MIN] { + let id = from_timestamp(ts); + assert_eq!(id.len(), LEN); + assert!(id.bytes().all(|b| ALPHABET.contains(&b)), "{id}"); + assert!(is_full_id(&id)); + } + } + + #[test] + fn distinct_timestamps_never_collide() { + let base = 1_790_000_000_000_i64; + let mut seen = std::collections::HashSet::new(); + for offset in 0..10_000 { + assert!(seen.insert(from_timestamp(base + offset))); + } + } + + #[test] + fn adjacent_timestamps_spread_their_first_letter() { + let base = 1_790_000_000_000_i64; + let differing = (0..1_000) + .filter(|&i| { + from_timestamp(base + i).as_bytes()[0] != from_timestamp(base + i + 1).as_bytes()[0] + }) + .count(); + // A uniform spread differs 15 times in 16; demand most of that. + assert!(differing > 850, "only {differing} of 1000 differ"); + } + + #[test] + fn full_id_and_prefix_recognition() { + assert!(is_full_id("kqzxmvtnplsrkqzx")); + assert!(!is_full_id("kqzxmvtnplsrkqz")); + assert!(!is_full_id("kqzxmvtnplsrkqzxk")); + assert!(!is_full_id("KQZXMVTNPLSRKQZX")); + assert!(!is_full_id("aqzxmvtnplsrkqzx")); + + assert!(is_prefix("k")); + assert!(is_prefix("kqzxmvtnplsrkqz")); + assert!(!is_prefix("")); + assert!(!is_prefix("kqzxmvtnplsrkqzx")); + assert!(!is_prefix("Kq")); + assert!(!is_prefix("todo")); + assert!(!is_prefix("12")); + } + + #[test] + fn shortest_unique_prefixes() { + let ids = ["kqzaaaaaaaaaaaaa", "kqyaaaaaaaaaaaaa", "maaaaaaaaaaaaaaa"]; + let lens = shortest_unique_prefix_lengths(ids.iter().copied()); + assert_eq!(lens["kqzaaaaaaaaaaaaa"], 3); + assert_eq!(lens["kqyaaaaaaaaaaaaa"], 3); + assert_eq!(lens["maaaaaaaaaaaaaaa"], 1); + } + + #[test] + fn lone_id_gets_length_one_and_duplicates_get_full_length() { + let lens = shortest_unique_prefix_lengths(["kqzxmvtnplsrkqzx"]); + assert_eq!(lens["kqzxmvtnplsrkqzx"], 1); + + let dup = shortest_unique_prefix_lengths(["kqzxmvtnplsrkqzx", "kqzxmvtnplsrkqzx", "mkkk"]); + assert_eq!(dup["kqzxmvtnplsrkqzx"], LEN); + } +} diff --git a/docs/README.md b/docs/README.md index ea11408..02eec8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ tb --begin 2 - **Tasks & Notes**: Create tasks with priorities and notes with rich body content - **Boards**: Organize items into custom boards - **Interactive TUI**: Full-featured terminal UI with keyboard navigation +- **Links & Backlinks**: Link items inline with `[[…]]` in the TUI; a side pane shows what links where - **External Editor**: Compose and edit notes in your preferred editor (`$EDITOR`) - **Timeline View**: See items chronologically - **Search & Filter**: Find items by text or attributes diff --git a/docs/plans/2026-10-21-2154-feat-backlinks-plan.md b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md new file mode 100644 index 0000000..be1362f --- /dev/null +++ b/docs/plans/2026-10-21-2154-feat-backlinks-plan.md @@ -0,0 +1,642 @@ +--- +title: Backlinks - Plan +type: feat +date: 2026-10-21 +topic: backlinks +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +execution: code +--- + +# Backlinks - Plan + +## Goal Capsule + +- **Objective:** In the TUI, a user can link any task or note to another with an inline `[[…]]` reference, see from any item what it links to and what links to it, and jump between them, with links that survive edits, moves, archive, restore, and sync; a link to an archived item shows as broken until that item is restored. +- **Means:** a jj-style stable ID computed from each item's creation timestamp, and `[[stable-id]]` tokens in item text resolved by a client-side link index (KTD1, KTD5). +- **Authority:** the Product Contract wins on behavior; Key Technical Decisions win on mechanism within their cited requirements; units override neither. +- **Product Contract preservation:** changed: R3 (ID derived from `_timestamp` alone, computed on read, never stored), R8 (which `[[…]]` spans count as references), R10 (newly added full tokens are kept too), Key Decision "Deterministic, creation-time IDs" reworded; added R19, R20, AE7; Dependencies bullets about older binaries dropping a stored field and a first-load full write removed; planning questions resolved into KTDs. All confirmed by the user in plan scoping. +- **Stop conditions:** stop and ask if implementing R3 would require storing a new field, if the derivation function would need to change after any link has been written, or if a unit cannot meet R9 without changing behavior outside the TUI. +- **Execution profile:** Rust workspace; client and common crates only; the server is untouched. +- **Open blockers:** none. + +--- + +## Product Contract + +### Summary + +Items gain a permanent, letters-only stable ID alongside the existing numeric ID, modeled on jj's change ID vs commit ID split and computed from the item's creation time. Links are `[[stable-id]]` tokens in item text, which is the only place they are stored. The TUI resolves them into titles, shows outgoing links and backlinks in a side pane, and lets the user jump between linked items. + +### Problem Frame + +Taskbook has no durable way to say "this note is the context for that task" or "this task depends on that one." The only handle on an item is its numeric ID, and that ID is positional: it is `max + 1` at creation, it is reassigned when an item is archived, reassigned again when it is restored, and reused when the highest-numbered item is deleted. A reference written as "see #12" today can silently point at a different item tomorrow. + +This is the same problem jj solves by separating the change ID (permanent, what users see and type) from the commit ID (content-derived, rewritten constantly, secondary). Taskbook's numeric ID plays the commit-ID role and has no change-ID counterpart. + +Because sync stores items as opaque client-encrypted blobs and replaces the whole set on each `PUT /items`, any linking scheme has to work entirely on the client and tolerate last-write-wins across devices. + +### Key Decisions + +- **Inline links, text as the single source of truth, plus a non-text way to add one.** Links live in the item text; a TUI keybind appends a token rather than maintaining a separate relation list. Governs R7, R11. (session-settled: user-directed — chosen over explicit-only structured relations and over inline-only: inline matches the Workflowy model, the keybind covers linking without editing text.) +- **Tokens carry only the stable ID.** Raw text shows `[[kqzxmvtn…]]`; views render the title. Governs R7, R13. (session-settled: user-directed — chosen over `[[Title|id]]` and `[[Title]]`: no stale titles in text, no rename or duplicate-title breakage.) +- **A new stable ID beside the numeric ID, shown jj-style in the TUI.** Numeric IDs keep working unchanged; the stable ID is displayed as a shortest unique prefix. Governs R1, R4, R6. (session-settled: user-directed — chosen over making numeric IDs permanent, over a hidden-only stable ID, and over replacing numeric IDs everywhere: it follows jj's model without breaking CLI muscle memory or pulling the CLI into scope.) +- **Letters-only alphabet, full ID in tokens, shortest prefix displayed.** Lexical disjointness makes `[[12]]` and `[[kqz]]` unambiguous without lookup; prefixes never go into stored text because a unique prefix can become ambiguous as items are added. Governs R2, R6, R7, R8. (session-settled: user-approved — proposed from jj's reverse-hex change IDs with the prefix-ambiguity tradeoff shown.) +- **Deterministic, creation-time IDs.** Every client derives the same ID for the same item, so sync and mixed devices agree without coordination. Governs R3. (session-settled: user-approved — chosen over random backfill: random IDs plus last-write-wins `PUT /items` would silently break links made on the losing client.) +- **Creation time alone is the identity, HUID-style.** The ID depends only on `_timestamp`, is never stored, and uniqueness is enforced when an item is created; items that already share a timestamp are accepted as divergent. Governs R3, R5, R19, R20. (session-settled: user-directed — chosen over deriving from `_timestamp` plus `_isTask` into a stored field: no data-format change and nothing for older binaries to drop, at a scale where same-millisecond creation is negligible; precedent: the HUID scheme in alycda/dotfiles `tasks/README.md`.) +- **TUI only for v1.** Governs R18. (session-settled: user-directed — chosen over also covering CLI and MCP.) +- **Outside the TUI, tokens stay raw.** Governs R18. (session-settled: user-directed — chosen over read-only title rendering in CLI/MCP: zero work outside the TUI and lossless MCP round-tripping.) +- **Archived targets are broken links.** Governs R16. (session-settled: user-directed — chosen over resolving archived targets with an "archived" marker and over full archive parity; the jj hidden-commit analogy was raised afterward and the choice was kept.) +- **Side pane for links and backlinks.** Governs R14, R15, R17. (session-settled: user-directed — chosen over a filter-view swap and a popup: links stay visible while browsing.) + +### Requirements + +**Stable identity** + +- R1. Every task and note has a stable ID that is assigned once and never changes across edit, move, star, state change, archive, restore, or sync. +- R2. Stable IDs use a letters-only alphabet, so no stable ID or prefix of one can be read as a numeric ID. +- R3. Every stable ID is a deterministic function of the item's creation timestamp alone, computed whenever the item is read and never stored, so every client and every version that keeps `_timestamp` agrees on it. +- R19. Creating an item gives it a creation timestamp that no other active or archived item in this store has, so items created on this client never share a stable ID; ties created on other devices are divergent per R20. +- R20. Items that already share a creation timestamp are divergent: the TUI marks them, they are not jump targets, and a save that adds a reference to one is rejected per R9. +- R4. Numeric IDs keep their current allocation and behavior for every existing CLI, TUI, and MCP operation. +- R5. Older taskbook-rs versions can still read item data written by this version. +- R6. The TUI shows each item's stable ID as its shortest unique prefix, visually distinguished, next to the numeric ID. + +**Writing links** + +- R7. A link is a `[[]]` token in a task description, note title, or note body; only well-formed full stable-ID tokens are links, and any other `[[…]]` in stored text is plain text. +- R8. When text is saved from TUI input or from the TUI-launched `$EDITOR`, each `[[N]]` (digits only) and `[[]]` (stable-ID alphabet letters only) added in this edit is rewritten to the full stable-ID token of the matching active item; such spans already present in the text before the edit, and any `[[…]]` containing other characters, stay verbatim as plain text. +- R9. A save that adds a numeric or prefix reference matching no active item, or more than one, is rejected with a message naming the reference, nothing is written, and the user's draft is kept: TUI input stays open with its text, and `$EDITOR` reopens on the edited content. +- R10. Full stable-ID tokens, whether already in the text or newly added, are preserved verbatim on save, whatever the state of their target. +- R11. A TUI keybind on the selected item lets the user pick a target item and appends a link token to the selected item's description without manual text editing. +- R12. Typing `[[` in TUI text input opens item autocomplete, and accepting a suggestion inserts the full stable-ID token. + +**Viewing and following links** + +- R13. TUI item rows render each link token as the target's current title, visually marked as a link, instead of the raw ID. +- R14. A side pane shows the selected item's outgoing links and its backlinks (active items whose text links to it), each as title plus short stable ID, and updates as the selection moves. +- R15. From the side pane the user can jump to any listed item that is active and not divergent, and the TUI switches board or view as needed so that item is selected and visible. +- R16. A link whose target is archived or absent renders as a broken-link marker, archived items contribute no backlinks, and restoring an item makes its backlinks and outgoing links resolve again with no other action. +- R17. The side pane can be toggled with a key and is hidden automatically when the terminal is narrower than a minimum width. + +**Outside the TUI** + +- R18. CLI output and input, MCP tools, and the server are unchanged, and link tokens appear there verbatim. + +```mermaid +flowchart TB + T["Item text: description, note title, note body (sole store of links)"] --> P[Parse full stable-ID tokens] + P --> I[In-memory link index built at load] + I --> R["TUI rows: token rendered as title (R13)"] + I --> S["Side pane: outgoing + backlinks (R14)"] + I --> J["Jump to item (R15)"] + T -. "verbatim, no resolution" .-> X["CLI / MCP / server (R18)"] +``` + +```mermaid +flowchart LR + subgraph TUI + direction LR + M["Main list: board, timeline, journal; rows show numeric ID and short stable ID"] --- SP["Side pane (toggle, hides when narrow): Links to, Linked from"] + end +``` + +### Key Flows + +- F1. Link while writing + - **Trigger:** User creates or edits a task in the TUI. + - **Steps:** Types `[[`; autocomplete lists items; user picks a note; the full token is inserted; user saves. + - **Outcome:** The task row shows the note's title as a link; selecting the note shows the task under its backlinks. + - **Covered by:** R7, R12, R13, R14 +- F2. Link without editing text + - **Trigger:** User presses the link keybind on a selected item. + - **Steps:** Picks a target; token is appended to the selected item's description. + - **Outcome:** Same as F1. + - **Covered by:** R11, R14 +- F3. Follow a backlink + - **Trigger:** User selects an item with backlinks. + - **Steps:** Side pane lists backlinks; user moves to an entry and confirms. + - **Outcome:** The backlinking item is selected and visible, on its own board or view. + - **Covered by:** R14, R15 +- F4. Archive and restore a linked item + - **Trigger:** User deletes an item other items link to, then later restores it. + - **Steps:** Delete moves it to the archive; items linking to it show broken markers. Restore brings it back with a new numeric ID and the same stable ID. + - **Outcome:** Links resolve again and the backlinks reappear. + - **Covered by:** R1, R16 + +### Acceptance Examples + +- AE1. **Covers R8, R9.** + - **Given** active item 12 exists, no item 99 exists, and two active items have stable IDs starting `kq`. + - **When** the user saves `see [[12]]`, `see [[99]]`, and `see [[kq]]` in turn. + - **Then** the first is stored as the full token for item 12; the second and third are rejected with a message naming `[[99]]` and `[[kq]]`, and nothing is written. +- AE2. **Covers R10, R16.** + - **Given** a note body containing a full-ID link to an archived task. + - **When** the user edits an unrelated line of the note in `$EDITOR` and saves. + - **Then** the save succeeds, the token is unchanged, and the link still renders as broken. +- AE3. **Covers R7, R8, R9, R18.** + - **Given** a task created via the CLI as `tb --task "see [[12]]"`. + - **When** it is viewed in the TUI, and later edited there (for example, a typo fix elsewhere in the text) and saved. + - **Then** `[[12]]` stays plain text, not a link, both times; the edit saves normally and no backlink appears on item 12. +- AE4. **Covers R3.** + - **Given** two synced clients holding the same pre-upgrade items. + - **When** both upgrade and load before either syncs. + - **Then** each item has the same stable ID on both clients, and links made on either client resolve on both after sync. +- AE5. **Covers R1, R16.** + - **Given** task 7 is linked from three items. + - **When** task 7 is deleted, then restored and assigned numeric ID 15. + - **Then** while archived, all three linking items show a broken marker and task 7 appears in no backlink list; after restore, all three links resolve to task 15 and its side pane lists the three backlinks. +- AE6. **Covers R17.** + - **Given** a terminal narrower than the minimum width. + - **When** the TUI starts or is resized below it. + - **Then** the side pane is hidden and the main list uses the full width; widening the terminal restores the pane if it was toggled on. +- AE7. **Covers R19, R20.** + - **Given** an MCP agent creates three tasks within the same millisecond, and the store already holds two legacy notes that share one `_timestamp`. + - **When** the TUI loads. + - **Then** the three new tasks have three distinct stable IDs; the two legacy notes show a divergent marker, are not offered by `[[` autocomplete, and saving `[[]]` is rejected. + +### Scope Boundaries + +**Deferred for later** + +- CLI rendering of link tokens, `tb --link`, `tb --backlinks`, and accepting stable IDs as CLI targets (the CLI `@` sigil already means boards). +- MCP link fields on `list_items`, a link tool, and protecting tokens from being dropped by `edit_item`. +- A warning when deleting an item that has backlinks. +- Resolving archived targets with an "archived" marker instead of a broken one. + +**Outside this work's identity** + +- Workflowy-style mirrors or transclusion (one item shown live in two places). +- Links to boards or tags, and hierarchical parent/child items. +- Any server-side link index; the server never sees plaintext. + +### Dependencies / Assumptions + +- Nothing in taskbook-rs changes `_timestamp` after creation (only `Task::new`, `Note::new`, `Note::new_with_body` set it), and older taskbook-rs versions keep it on every write, so their writes cannot change a stable ID. +- The original Node.js taskbook is assumed to keep `_timestamp` unchanged when it rewrites `storage.json`; it has not been checked. If it rewrote timestamps, links to affected items would break. +- Items created by older binaries, Node taskbook, or two devices in the same millisecond can share a timestamp; R20 governs how they appear. +- MCP `edit_item` replaces the whole description (`crates/taskbook-client/src/mcp/tools.rs:387`), so an agent that rewrites a title without copying a token removes that link, and nothing catches it. +- Sync stays last-write-wins on the whole item set (`crates/taskbook-server/src/handlers/items.rs:144`); this plan does not change it. + +### Sources / Research + +- ID allocation and renumbering: `crates/taskbook-client/src/taskbook.rs:71` (`generate_id`, `max + 1`), `:323` (`save_item_to_archive`), `:337` (`save_item_to_storage`). +- Item models and serde compatibility: `crates/taskbook-common/src/models/task.rs`, `crates/taskbook-common/src/models/note.rs`. +- Existing sigils: `+tag` and `@board` parsing in `crates/taskbook-common/src/board.rs:90`; `@` targets in `crates/taskbook-client/src/taskbook.rs:730`; `[[` is unused in the client. +- Sync storage: opaque per-item blobs keyed by `item_key` in `crates/taskbook-server/src/migrations/001_initial.sql`; full-set replace in `crates/taskbook-server/src/handlers/items.rs`. +- TUI layout: a single vertical split in `crates/taskbook-client/src/tui/ui.rs:20`; `PopupState` has only `Help` (`crates/taskbook-client/src/tui/app.rs:106`); no width-based layout today. +- TUI autocomplete: `@` suggests boards, item suggestions come from bare tokens after `/` commands (`crates/taskbook-client/src/tui/autocomplete.rs:42`, `:67`, `:160`). +- Note bodies: edited in `$EDITOR` (`crates/taskbook-client/src/editor.rs`); the TUI shows only a `[...]` marker or the journal's first line (`crates/taskbook-client/src/tui/widgets/item_row.rs:80`, `crates/taskbook-client/src/tui/widgets/journal_view.rs:150`). +- jj change ID vs commit ID, reverse-hex alphabet, shortest-prefix display, divergent changes: https://jj-vcs.github.io/jj/latest/glossary/#change-id +- HUID (creation time as identity, collision handled at creation): alycda/dotfiles, branch `effver`, `tasks/README.md` and `CONCEPTS.md`. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Stable ID = invertible 64-bit mix of `_timestamp`, written as 16 letters `k`–`z`.** The timestamp's bits (as `u64`, two's complement, so 0 and negative values are covered) go through a fixed invertible mixer (an xorshift-multiply finalizer with odd multipliers, in the style of splitmix64's finalizer), and each 4-bit nibble maps to one letter of `klmnopqrstuvwxyz` (jj's reverse-hex alphabet). Invertibility means distinct timestamps never collide; mixing means items created minutes apart get unrelated prefixes, keeping displayed prefixes short. Hand-written, no new dependency; never `std`'s `DefaultHasher`, whose output is not stable across Rust releases. Lives in a new pure module `crates/taskbook-common/src/stable_id.rs`, frozen by golden test vectors. Implements R2, R3. (session-settled: user-directed — chosen over hashing `_timestamp` plus `_isTask` into a stored field: no data-format change, HUID precedent.) +- KTD2. **Computed on read, never serialized.** `stable_id()` is an accessor on the `Item` trait and the mirrored inherent methods of `StorageItem` that computes from `timestamp`; no serde field is added, so `storage.json`, the sync payload, and older binaries are unaffected (R3, R5). The link index caches computed IDs per refresh. +- KTD3. **Uniqueness at creation by bumping `_timestamp`.** Every `Taskbook` create path takes the candidate timestamp and, if any active or archived item already has it, increments by 1 ms until free; this covers CLI, TUI, MCP, and editor-created notes because they all go through `Taskbook` (R19). The archive is read on create to make the check complete; in remote mode that is one extra `GET` per create, acceptable at taskbook's scale. A bump of a few milliseconds has no visible effect: `timestamp` only drives ordering and age display. +- KTD4. **Divergence is detected, not repaired.** The link index groups active items by stable ID; any ID held by more than one active item is divergent (R20). An archived copy sharing an ID with an active item (a half-finished delete) is ignored, because archived items contribute nothing to the index. Automatic repair is deferred. +- KTD5. **Link logic is a pure module, `crates/taskbook-common/src/links.rs`.** It owns three things: the span scanner, the save-time rewrite, and the link index. It takes plain item data, has no TUI or storage dependency, and carries the bulk of the tests. Follows the shape of `crates/taskbook-common/src/due.rs`. +- KTD6. **Span classification.** The scanner finds closed `[[…]]` spans; the inner text classifies as Full (exactly 16 alphabet letters), Numeric (digits only), Prefix (1–15 alphabet letters), or Plain (anything else, including mixed or other letters). Only lowercase inner text can be Full or Prefix; any uppercase letter makes the span Plain, so capitalized wiki-style text such as `[[Work]]` never becomes a link. An unclosed `[[` is not a span. +- KTD7. **"Added in this edit" is a multiset diff against the stored baseline.** The baseline is the item's stored text captured once when the edit session starts (when `e` or `l` prefills the command line, when a typed `/edit` is first submitted, or when the editor opens; empty for a create) and reused unchanged for every retry, so a rejected `[[99]]` cannot become "pre-existing" on the second try. For notes, title and body are diffed together so moving a span between them is not an addition. For each span text, the first k occurrences (k = baseline count) stay verbatim and later occurrences are added (R8, R10). +- KTD8. **Rewrite outcome is a value, not an error.** `rewrite_on_save(baseline, draft, index)` returns either the rewritten text or a list of rejected references with reasons (not found, ambiguous prefix, divergent target). Full tokens are never rejected (R10). Callers in the TUI show the message as a status and keep the draft; on success, the status names each reference that was rewritten with its target's title (for example `Linked [[now]] → Morning review`), so an accidental match is visible at once. +- KTD9. **TUI keeps an active-items snapshot and a `LinkIndex` independent of the view.** `App` loads active items in every view (Archive view currently replaces `app.items`), rebuilding the index in `refresh_items`, `set_view`, the SSE handler for both active and archive changes, and after restore. Prefix uniqueness is computed over active items; an archived row's prefix is computed over active items plus itself. +- KTD10. **A rejected submit re-opens the command line with the draft.** Submit keeps today's order (push history, deactivate, execute), because `deactivate_command_line` resets `pending_confirm` and moving it after execution would cancel the `/delete` and `/clear` confirmations. When the link rewrite rejects, the action re-activates the command line with the submitted input and cursor and sets the error status (R9). Other storage errors keep today's behavior. The rejection status stays until the next key press instead of expiring after 3 seconds; each reference reads as `[[99]] not found`, `[[kq]] matches 2 items`, or `[[kq]] is divergent`, and when the line would clip, later references collapse into a `+N more` suffix so the first is always fully visible. +- KTD11. **Note editor saves validate title and body together before either write.** `edit_note_external` keeps its existing `edit_description_silent` plus `edit_note_body_silent` pair, but both run only after the combined rewrite succeeds, so a rejection never reaches storage. On rejection the editor reopens on the edited title and body, with the rejection message as a leading `//` comment line (already stripped by `parse_note_content`). The `/note` create-then-editor flow uses the same validation with an empty baseline. +- KTD12. **The link keybind reuses the command line.** `l` on a selected active item opens the command line as `/edit @ [[` with item suggestions already showing; accepting one and pressing Enter saves through the normal rewrite path (R11). For notes the token goes on the title (the `/edit` target). No new picker widget. +- KTD13. **`[[` autocomplete.** `update_suggestions` gains a branch, checked before the `@` branch, for an unclosed `[[` before the cursor in the description position of `/task`, `/note`, and `/edit`. Candidates: active, non-divergent items, excluding the item being edited, shown as rendered title, short stable ID, and board, matched by substring on rendered title, numeric ID, or stable-ID prefix. Accepting inserts `[[]]`. While a `[[` dropdown is open, Enter accepts instead of submitting. An empty `[[` query lists active, non-divergent items newest first, current board first, so the `l` flow opens on useful candidates (today an empty query suggests nothing). The first row is pre-selected whenever a `[[` dropdown opens. Each row shows the rendered title truncated to fit the dropdown, then the short stable ID, with the board as the description. Enter is decided by context, not by whether rows are showing: while an unclosed `[[` sits before the cursor in a description position, Enter accepts the selected row if there is one and otherwise does not submit, setting a hint to pick an item, close the brackets, or press Esc. Esc inside an open `[[` dropdown closes only the dropdown, which stays closed until the cursor leaves that `[[`; a second Esc cancels the command line as today. +- KTD14. **Keys:** `l` link, `v` toggle the side pane, `o` move focus into the pane (`j`/`k` move, Enter jumps, `o` or Esc returns to the list). Tab and `h` are already bound. Pane visibility is session-local in v1 and starts visible. Below the pane's minimum width, `v` still flips the stored toggle and sets an info status `Link pane needs 100 columns (toggle on)`; `o` while the pane is hidden or empty does nothing but set `Link pane hidden` or `No links for this item`. +- KTD15. **Jump does not touch saved settings.** Jumping clears the search term and board filter, switches Archive view to Board view through a non-persisting path, and reveals a completed target with a transient override instead of flipping the persisted hide-completed setting; a status line names what changed. Broken and divergent entries are listed but not jumpable (R15, R16, R20). +- KTD16. **Rendering substitutes one level.** A token renders as the target's current title; tokens inside that title render as short stable IDs, so cycles cannot recurse. Broken tokens render as the short ID with a broken marker; divergent ones with a `??` suffix, as jj does. The same render function feeds rows, the pane, suggestions, and the journal preview (which also moves from byte to character truncation). Encodings use a glyph as well as color so they survive monochrome and `NO_COLOR` terminals: a link is the target title underlined in the info style; a broken link is `✗` plus the short stable ID in the error style; a divergent ID is the short ID plus `??` in the warning style. The stable-ID field after the numeric ID is 6 characters: the unique prefix highlighted, the rest dimmed. +- KTD17. **Pane content rules.** Each target appears once per list however many times it is linked; a self-link is allowed and listed under outgoing links but never as a backlink of itself. Broken outgoing links are listed. When an archived item is selected in Archive view, its backlinks list shows the active items still linking to it. The pane has two fixed headings, `Links to` and `Linked from`, each showing a dim `(none)` when empty. Outgoing entries follow first appearance in the text; backlinks follow the main list's current order. Entries are one line: title truncated by characters with an ellipsis, short stable ID always visible. A focused pane draws its border in the info style and changes the key hint to `j/k move, Enter jump, Esc back`; after a jump, focus returns to the list. A selected divergent item lists no backlinks and shows a `?? divergent` notice instead, because backlinks cannot be attributed to one of the twins. +- KTD18. **Pane layout.** When visible and the terminal is at least 100 columns wide, the content area splits horizontally with the pane on the right (about 30% width, minimum 32 columns); below 100 columns it is hidden and the toggle state is kept (R17). The autocomplete overlay is drawn after the pane so it is never covered. + +### High-Level Technical Design + +Components and data flow. Items load through `Taskbook`; the TUI builds one `LinkIndex` from the active set; every surface reads from it. + +```mermaid +flowchart TB + subgraph common["taskbook-common"] + SID["stable_id.rs: timestamp -> 16-letter ID, prefix helpers"] + LNK["links.rs: scan spans, rewrite_on_save, LinkIndex"] + MOD["models: Item::stable_id() computed from timestamp"] + end + subgraph client["taskbook-client"] + TB["taskbook.rs: unique creation timestamp"] + APP["tui/app.rs: active snapshot + LinkIndex, rebuilt on refresh"] + ACT["tui/actions.rs: save path, keys l / v / o, jump"] + AC["tui/autocomplete.rs: [[ suggestions"] + W["widgets: item rows, journal, link pane"] + end + SID --> MOD + MOD --> LNK + MOD --> TB + TB --> APP + LNK --> APP + APP --> ACT + APP --> AC + APP --> W + ACT -- "rewrite_on_save" --> LNK + ACT -- "edit / create" --> TB +``` + +Save path for any TUI text save (command line or `$EDITOR`): + +```mermaid +flowchart TB + B["Capture stored text once at edit-session start (baseline)"] --> S["User submits draft"] + S --> D["Multiset diff: spans added vs baseline"] + D --> C{"Each added span"} + C -- "Full token" --> K["Keep verbatim"] + C -- "Plain" --> K + C -- "Numeric / Prefix" --> R{"Resolves to exactly one non-divergent active item?"} + R -- yes --> W["Rewrite to full token"] + R -- "no / ambiguous / divergent" --> X["Collect rejection"] + K --> E{"Any rejections?"} + W --> E + X --> E + E -- yes --> KEEP["Show message; keep draft (input stays open / editor reopens)"] + KEEP --> S + E -- no --> SAVE["Single write through Taskbook; refresh index"] +``` + +Stable-ID derivation (directional, not a specification): `id = encode_k_to_z(mix(timestamp as u64))`, where `mix` is a bijection on `u64` and `encode` emits 16 nibbles most-significant first. The exact constants are fixed once in U1 and never changed afterwards. + +### Alternatives Considered + +- **Stored field plus deterministic backfill** from `_timestamp` and `_isTask`. Rejected: older binaries drop unknown fields on write, so the stored value buys nothing that recomputation does not, and it adds a data-format change and a first-load full write that races last-write-wins sync. +- **Truncated hash instead of a bijection.** Rejected: introduces birthday collisions between distinct timestamps for no benefit. +- **Direct base-16 encoding of the timestamp without mixing.** Rejected: items created the same day share long prefixes, defeating short display IDs. +- **A dedicated picker popup for the link keybind.** Rejected for v1: the command line with `[[` autocomplete already provides search, selection, and cancel. + +### Assumptions + +- At taskbook's scale (hundreds to low thousands of items), rebuilding the link index on every refresh is cheap enough without incremental updates. +- Users do not routinely write literal lowercase `[[…]]` text made only of `k`–`z` letters; when they do, a match is named in the success status (KTD8) and no match is rejected per R9. + +--- + +## Implementation Units + +### U1. Stable ID derivation + +**Goal:** a frozen, pure function from creation timestamp to stable ID, plus prefix helpers, exposed on every item. + +**Requirements:** R1, R2, R3, R5; AE4. Implements KTD1, KTD2. + +**Dependencies:** none. + +**Files:** +- `crates/taskbook-common/src/stable_id.rs` (new) +- `crates/taskbook-common/src/lib.rs` +- `crates/taskbook-common/src/models/item.rs` +- `crates/taskbook-common/src/models/mod.rs` +- Tests: inline `#[cfg(test)] mod tests` in `stable_id.rs`, `models/task.rs`, `models/note.rs` + +**Approach:** +1. Implement the mixer, its inverse (test-only is fine), and the `k`–`z` encoder and decoder. +2. Add helpers: `is_valid_full_id`, `is_valid_prefix`, and `shortest_unique_prefix_lengths` over a set of IDs. +3. Add `stable_id()` to the `Item` trait and the mirrored `StorageItem` inherent methods; no serde change. + +**Patterns to follow:** `crates/taskbook-common/src/due.rs` (pure module, exhaustive unit tests); the accessor pairs in `crates/taskbook-common/src/models/mod.rs`. + +**Test scenarios:** +- Golden vectors: at least five fixed timestamps (including 0, -1, `i64::MIN`, `i64::MAX`, and a 2026 timestamp) map to fixed 16-letter IDs; these values are written once and must never change. +- Round trip: decoding then inverting the mix returns the original timestamp for a spread of values. +- Every output is exactly 16 characters from `klmnopqrstuvwxyz`. +- Two timestamps 1 ms apart produce IDs whose first letters differ for most sampled pairs (prefix dispersion). +- `stable_id()` is identical for a task and a note with the same timestamp, and unchanged after changing description, boards, tags, star, completion, and numeric ID. +- Covers AE4. Serializing an item and deserializing it on another `StorageItem` yields the same stable ID, and serialized JSON contains no new field. +- Shortest-unique-prefix: with IDs sharing a two-letter prefix the result is length 3; a lone ID gets length 1. + +**Verification:** stable IDs appear through the accessor; `storage.json` written by this version is byte-identical in shape to before (no new keys). + +### U2. Unique creation timestamps + +**Goal:** no two items created through taskbook-rs share a timestamp. + +**Requirements:** R19, R1; AE7 (creation half). Implements KTD3. + +**Dependencies:** U1. + +**Files:** +- `crates/taskbook-client/src/taskbook.rs` +- Tests: new `#[cfg(test)] mod tests` in `crates/taskbook-client/src/taskbook.rs` + +**Approach:** +1. Add one private helper that, given the candidate timestamp, reads active and archive data and returns the smallest unused timestamp at or after it. +2. Apply it in every create path: `create_task`, `create_task_direct_with_tags`, `create_note`, `create_note_direct_with_tags`, `create_note_with_body_direct`, `create_note_with_editor` (the constructors keep calling `Local::now()`; the helper overwrites `timestamp` before insert). + +**Patterns to follow:** the `Taskbook::with_storage(Box::new(LocalStorage::new(temp_dir().join(..))))` fixture in `crates/taskbook-client/src/mcp/mod.rs` tests. + +**Test scenarios:** +- Covers AE7. Creating 20 tasks and notes back to back yields 20 distinct timestamps and 20 distinct stable IDs. +- An archived item holding timestamp T forces a new item created at T to T+1. +- Active items at T and T+1 force a new item created at T to T+2. +- Archive and restore leave the timestamp and stable ID unchanged while the numeric ID changes. + +**Verification:** every create path goes through the helper; existing CLI and MCP tests still pass unchanged (R4). + +### U3. Span scanner and save-time rewrite + +**Goal:** a pure function that turns a draft into the text to save, or a list of rejected references. + +**Requirements:** R7, R8, R9, R10, R20; AE1, AE2, AE3. Implements KTD6, KTD7, KTD8. + +**Dependencies:** U1. + +**Files:** +- `crates/taskbook-common/src/links.rs` (new) +- `crates/taskbook-common/src/lib.rs` +- Tests: inline in `links.rs` + +**Approach:** +1. Scanner returns closed spans with byte ranges and classification. +2. `rewrite_on_save` takes the baseline text, the draft text, and a resolver view of the active set (numeric ID to stable ID, prefix lookup, divergent set) and applies the multiset rule. +3. Note callers pass title and body joined with a separator that cannot occur inside a span, and split the result back. + +**Test scenarios:** +- Covers AE1. `see [[12]]` with item 12 active rewrites to its full token; `[[99]]` is rejected as not found; `[[kq]]` with two matches is rejected as ambiguous. +- Covers AE3. Baseline `see [[12]]`, draft `see [[12]] typo-fixed` returns the draft unchanged. +- Covers AE2. A pre-existing full token to a missing target is kept and the save succeeds. +- A newly added full token to a missing target is kept (R10). +- Baseline with one `[[12]]`, draft with two: the first stays plain, the second is rewritten. +- A span moved from note body to title is not treated as added. +- `[[todo]]`, `[[bin]]`, `[[12a]]`, and `[[ ]]` are plain text and never rejected. +- Uppercase or mixed-case `[[KQZ]]` and `[[Work]]` stay plain text and are never rejected. +- A successful save that rewrote `[[now]]` reports the reference and its target title. +- A reference resolving to a divergent ID is rejected with a divergent reason (R20). +- An unclosed `[[12` is left as text. +- Retry: after a rejection, calling again with the same baseline and a draft that still contains `[[99]]` rejects again. + +**Verification:** the function is total (never panics) on arbitrary UTF-8 input, including multibyte text adjacent to brackets. + +### U4. Link index and rendering + +**Goal:** one structure that answers every display question: outgoing links, backlinks, prefix lengths, divergence, and rendered text. + +**Requirements:** R6, R13, R14, R16, R20. Implements KTD4, KTD16, KTD17. + +**Dependencies:** U1, U3. + +**Files:** +- `crates/taskbook-common/src/links.rs` +- Tests: inline in `links.rs` + +**Approach:** +1. `LinkIndex::build(active items)` computes ID→items, divergent set, prefix lengths, and per-item outgoing targets from description, title, and body. +2. Backlinks are a reverse map keyed by target stable ID, including IDs with no active item, deduplicated and excluding self; active items look up their own ID, and an archived item selected in Archive view looks up its ID the same way (KTD17). +3. A render function turns text into segments (plain, link with resolved title, broken, divergent) with one level of substitution; callers style the segments. + +**Test scenarios:** +- Item A links B twice and itself once: A's outgoing lists B and A once each; B's backlinks list A; A's backlinks do not list A. +- A links to an ID not in the active set: outgoing shows it as broken, no active item gains a backlink, and looking up that ID's inbound links returns A. +- Covers AE5 (index half). Building with B removed (archived) makes A's link broken; rebuilding with B present again restores the backlink. +- Two active items share an ID: the ID is in the divergent set and renders with the divergent marker. +- A titled "see [[B]]" and B titled "re [[A]]": rendering A shows B's title with A as a short ID, without recursion. +- Prefix lengths are the shortest unique over active items; an archived item's prefix computed against active plus itself. + +**Verification:** index build over 5,000 synthetic items completes without noticeable delay in a debug test run. + +### U5. TUI link state and refresh + +**Goal:** the TUI always holds a current index of active items, in every view, including right after restore. + +**Requirements:** R14, R16; AE5. Implements KTD9. + +**Dependencies:** U4. + +**Files:** +- `crates/taskbook-client/src/tui/app.rs` +- `crates/taskbook-client/src/tui/mod.rs` +- `crates/taskbook-client/src/tui/actions.rs` +- Tests: new `#[cfg(test)] mod tests` in `crates/taskbook-client/src/tui/app.rs` + +**Approach:** +1. Add an `App::with_taskbook(Taskbook, Config, config_path)` test constructor, and route every `self.config.save()` in `App` (`set_view`, `toggle_hide_completed`, `cycle_sort_method`) through a helper that writes to that path, so tests never touch `~/.config/taskbook/taskbook.json`. +2. Add the active snapshot and `LinkIndex` to `App`; rebuild in `refresh_items`, `set_view`, after restore, and in the SSE handler for both data and archive changes. +3. Make restore reload the archive list and the index explicitly (`set_view(Archive)` is a no-op while already in Archive view), so the restored row disappears at once. + +**Patterns to follow:** existing `refresh_items` and SSE `DataChanged` handling. + +**Test scenarios:** +- Covers AE5. Create A, B, C linking to T; delete T: index shows three broken links, T appears in no active backlink list, and inbound links for T's ID still return A, B, C; restore T from the archive view: index shows three backlinks on T without any other action. +- In Archive view, the index still reflects active items, not archived ones. + +**Verification:** switching views never empties the side pane's source data. + +### U6. TUI save path with rewrite and reject + +**Goal:** every TUI text save goes through the rewrite, and rejection keeps the draft. + +**Requirements:** R8, R9, R10; AE1, AE2, AE3. Implements KTD10, KTD11. + +**Dependencies:** U3, U5. + +**Files:** +- `crates/taskbook-client/src/tui/actions.rs` +- `crates/taskbook-client/src/editor.rs` +- Tests: `crates/taskbook-client/src/tui/app.rs` tests module; `crates/taskbook-client/src/editor.rs` tests + +**Approach:** +1. Route `/task`, `/note`, and `/edit` descriptions through `rewrite_on_save` before calling `Taskbook`; baseline is empty for creates and, for `/edit`, the stored description captured at session start per KTD7 and kept on `App` across retries. +2. On rejection, re-activate the command line with the submitted input and cursor and set the error status (KTD10); leave the submit order unchanged. +3. In `edit_note_external`, run the combined title-and-body rewrite before calling the existing two write methods (KTD11). +4. Capture the stored title and body once when the editor session starts; on rejection reopen the editor on the edited content with a leading `//` message line, looping until the save succeeds or the user cancels. + +**Patterns to follow:** status handling for parse errors in `crates/taskbook-client/src/tui/actions.rs`; `edit_existing_note_in_editor` for re-serializing note content. + +**Test scenarios:** +- Covers AE1. Submitting `/task see [[99]]` leaves the command line active with the same text and an error status naming `[[99]]`; no item is created. +- A draft with three rejected references shows the first reference's text in full and a `+2 more` suffix, and the status remains after 3 seconds. +- Submitting `/edit @3 see [[12]]` stores the full token for item 12. +- `/delete 3` still opens its confirmation prompt, and confirming deletes item 3. +- Covers AE3. `/edit` of a CLI-created `see [[12]]` with an unrelated change saves with `[[12]]` still plain. +- A note edit whose body adds `[[99]]` leaves both the stored title and body unchanged. +- Covers AE2. Editing a note whose body links an archived task saves successfully. +- The editor retry loop: a rejected save followed by a corrected one writes once; the baseline stays the original stored text across passes. + +**Verification:** no path from a link rejection reaches `run_app` as an error. + +### U7. Row and preview rendering + +**Goal:** rows show titles for links and the short stable ID beside the numeric ID. + +**Requirements:** R6, R13, R16, R20. Implements KTD16. + +**Dependencies:** U5. + +**Files:** +- `crates/taskbook-client/src/tui/widgets/item_row.rs` +- `crates/taskbook-client/src/tui/widgets/journal_view.rs` +- `crates/taskbook-client/src/tui/autocomplete.rs` (existing item suggestions show rendered text) +- `crates/taskbook-client/src/tui/theme.rs` (link, broken, divergent, and ID-prefix styles) +- Tests: `crates/taskbook-client/src/tui/app.rs` tests module, or a small pure helper test in `item_row.rs` + +**Approach:** +1. Replace the single description span with the rendered segments, styled per segment kind. +2. Show the stable-ID prefix highlighted (unique part) next to the numeric ID, dimming the remainder up to a fixed display width, as jj does. +3. Change the journal preview to render first and truncate by characters. + +**Test scenarios:** +- A row whose description holds a link shows the target's title and not the raw ID. +- A broken link shows the broken marker; a divergent one shows `??`. +- Rendered segments carry the exact glyphs and modifiers from KTD16 (underline for links, `✗` for broken, `??` for divergent), asserted on the segment list. +- A journal preview of a note whose first line holds multibyte text longer than 50 bytes renders without panicking. + +**Verification:** manual check in a narrow and a wide terminal that rows stay on one line. + +### U8. `[[` autocomplete and the link keybind + +**Goal:** linking without typing IDs, through suggestions or one key. + +**Requirements:** R11, R12; F1, F2. Implements KTD12, KTD13, KTD14. + +**Dependencies:** U6, U7. + +**Files:** +- `crates/taskbook-client/src/tui/autocomplete.rs` +- `crates/taskbook-client/src/tui/actions.rs` +- `crates/taskbook-client/src/tui/app.rs` +- `crates/taskbook-client/src/tui/widgets/command_line.rs` +- `crates/taskbook-client/src/tui/widgets/help_popup.rs` +- Tests: `crates/taskbook-client/src/tui/autocomplete.rs` (new tests module) + +**Approach:** +1. Add the `[[` branch to `update_suggestions` before the `@` branch, with a link suggestion kind and icon. +2. Implement the KTD13 Enter and Esc rules for an unclosed `[[`, and the empty-query ordering. +3. Bind `l` (not in Archive view) to open `/edit @ [[` with suggestions active. +4. Add `l`, `v`, and `o` to the help popup. + +**Test scenarios:** +- Covers F1. Typing `/task buy [[mil` suggests an item titled "Milk run"; accepting inserts its full token and closes the brackets. +- Suggestions exclude the item being edited and divergent items. +- Matching by numeric ID and by stable-ID prefix both work. +- Enter with the `[[` dropdown open accepts the suggestion instead of submitting. +- Pressing `l`, typing a query that matches nothing, then pressing Enter leaves the stored description unchanged and shows the hint. +- An empty `[[` query lists recent items with the first row pre-selected. +- Esc with a `[[` dropdown open closes only the dropdown; a second Esc cancels the command line. +- Covers F2. Pressing `l` on item 4 opens `/edit @4 [[` with suggestions visible. + +**Verification:** existing `@board` and item autocomplete behaves as before. + +### U9. Side pane and jump + +**Goal:** see outgoing links and backlinks for the selection and jump to them. + +**Requirements:** R14, R15, R16, R17; F3, AE6. Implements KTD14, KTD15, KTD17, KTD18. + +**Dependencies:** U5, U7. + +**Files:** +- `crates/taskbook-client/src/tui/widgets/link_pane.rs` (new) +- `crates/taskbook-client/src/tui/widgets/mod.rs` +- `crates/taskbook-client/src/tui/ui.rs` +- `crates/taskbook-client/src/tui/app.rs` +- `crates/taskbook-client/src/tui/actions.rs` +- Tests: `crates/taskbook-client/src/tui/app.rs` tests module + +**Approach:** +1. Add pane state to `App`: visible flag, focus flag, pane cursor. +2. Split the content area in `render_content` when visible and wide enough; draw the autocomplete overlay after the pane. +3. Add `v` and `o` handling, plus `j`/`k`/Enter/Esc while the pane is focused; focus returns to the list if the pane auto-hides. +4. Implement jump per KTD15: select the target's index in `display_order` after adjusting view and filters. + +**Test scenarios:** +- Covers F3. With A linking B, selecting B and jumping from its backlinks selects A. +- Jumping to a completed item while hide-completed is on reveals it without changing the saved config. +- Jumping from Archive view switches to Board view and leaves `default_view` unchanged in the test's temp config file. +- Jumping clears an active search term and board filter and sets a status naming that. +- Broken and divergent entries are not jumpable. +- Covers AE6. At 80 columns the pane is not rendered even when toggled on; at 120 columns it is. +- At 80 columns, `v` sets the needs-100-columns status and `o` sets `Link pane hidden`; neither changes focus. +- Pane cursor clamps when a refresh shortens its list. +- Selecting archived T in Archive view lists A, B, C under backlinks. +- An item with no links shows `(none)` under both headings. +- A long title is truncated with an ellipsis and its short stable ID stays visible at 32 columns. +- Selecting a divergent item shows the `?? divergent` notice and no backlinks. +- After Enter jumps, focus is back on the list. + +**Verification:** manual walkthrough of F3 in a real terminal at both widths. + +### U10. Documentation + +**Goal:** users and contributors can find the feature and the new modules. + +**Requirements:** R6, R11, R12, R14, R17. + +**Dependencies:** U8, U9. + +**Files:** +- `README.md` (TUI section: links, keys) +- `docs/README.md` +- `CLAUDE.md` (architecture tree: `stable_id.rs`, `links.rs`, `link_pane.rs`) + +**Approach:** document `[[` linking, the `l`/`v`/`o` keys, divergent and broken markers, and that CLI and MCP show tokens raw. + +**Test expectation:** none -- documentation only. + +**Verification:** the help popup and README list the same keys. + +--- + +## Verification Contract + +| Gate | Command (inside `devenv shell`) | Applies to | +|---|---|---| +| Format | `cargo fmt --all -- --check` | every unit | +| Lint | `cargo clippy --workspace -- -D warnings` | every unit | +| Tests | `cargo test --workspace` | every unit | +| MSRV | build stays within Rust 1.75 (avoid newer std APIs such as `Option::is_none_or`) | every unit | +| Manual TUI | `cargo run --package taskbook-client -- --taskbook-dir `: create two items, link with `[[`, link with `l`, follow from the pane, delete and restore the target | U6–U9 | +| Data format | write a store with this build, inspect `storage/storage.json`: no new keys | U1, U2 | + +These mirror CI in `.github/workflows/ci.yml`. + +--- + +## Definition of Done + +- Every unit's test scenarios exist and pass; all gates in the Verification Contract pass. +- The U1 golden vectors are committed and unchanged by later units. +- No new keys appear in stored item JSON; the server crate has no diff. +- CLI and MCP output are unchanged for existing commands (R4, R18). +- Each acceptance example (AE1–AE7) is covered by at least one named test. +- Abandoned or experimental code from approaches that did not pan out is removed from the diff. + +--- + +## Scope Boundaries (planning) + +### Deferred to Follow-Up Work + +- Automatic repair of divergent items (bumping one twin's timestamp). +- Handling a non-zero `$EDITOR` exit as a cancel instead of an error that closes the TUI (pre-existing behavior). +- A "jump back" key after following a link. +- Making `r` on a stale archived row set a status instead of an error (pre-existing; the explicit reload in U5 removes the stale row). +- Matching `/search` against rendered titles. +- Saving text that already holds an unclosed `[[` (written from the CLI or MCP) after `e`: under KTD13, Enter in that position only shows the hint, and Esc with no dropdown cancels the edit, so the hint's "press Esc" discards the draft. Candidate fix: Esc always dismisses an unclosed `[[`, Enter on a dismissed `[[` submits, and the `e` prefill pre-dismisses one from stored text. + +### Considered and not built + +- A single-write `edit_note_silent`: validation already happens before either write, so a rejection cannot half-save a note; a storage failure between the two writes is pre-existing and out of scope. +- Writing derived IDs back on load: nothing is stored, so there is nothing to write; it would only race last-write-wins sync. +- A token-to-title legend in `$EDITOR` comments: useful, but R9 only requires the message; revisit if raw IDs in the editor prove confusing. +- Persisting pane visibility in the config: session-local is enough for v1; revisit if users toggle it every session. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Changing the derivation function later silently breaks every link | U1 golden vectors; Stop condition in the Goal Capsule | +| Node taskbook or a hand edit rewrites `_timestamp` | Documented assumption; affected links render broken, not wrong | +| Same-millisecond items from older binaries or two devices | Shown as divergent (R20), never silently merged | +| Enter-accepts-suggestion changes muscle memory | Only while a `[[` dropdown is open; other dropdowns unchanged | +| Extra archive read on create in remote mode | One `GET` per create; acceptable at taskbook's scale | diff --git a/scripts/bench-envelope.py b/scripts/bench-envelope.py new file mode 100644 index 0000000..7d186eb --- /dev/null +++ b/scripts/bench-envelope.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Time the tb CLI against generated stores around the supported size. + +Usage: + cargo build --release -p taskbook-client + python3 scripts/bench-envelope.py target/release/tb [ACTIVE/ARCHIVED ...] + +Prints a markdown table of median board-view and create times. Stores are +synthetic: 80% tasks, notes with 200-1500 character bodies, about 385 bytes +per item. Each create is timed against a fresh copy of the store. +""" +import json +import os +import random +import shutil +import statistics +import subprocess +import sys +import tempfile +import time + +DEFAULT_SIZES = ["256/4096", "512/4096", "1024/4096", "2048/8192"] +RUNS = 7 +WORDS = ( + "alpha beta gamma review deploy invoice call email draft fix bug plan " + "meeting doc report budget tax rust ffi nix ledger sync server client " + "release notes" +).split() +BOARDS = ["my board", "work", "home", "finance", "rust"] + + +def text(rng, lo, hi): + target = rng.randint(lo, hi) + out = [] + while len(" ".join(out)) < target: + out.append(rng.choice(WORDS)) + return " ".join(out) + + +def item(rng, i, ts): + is_task = rng.random() < 0.8 + entry = { + "_id": i, + "_date": "Wed Oct 21 2026", + "_timestamp": ts, + "_isTask": is_task, + "description": text(rng, 60, 120), + "isStarred": rng.random() < 0.05, + "boards": [rng.choice(BOARDS)], + } + if is_task: + entry.update( + isComplete=rng.random() < 0.5, + inProgress=False, + priority=rng.choice([1, 1, 1, 2, 3]), + ) + elif rng.random() < 0.7: + entry["body"] = text(rng, 200, 1500) + return entry + + +def write_store(path, count, start_ts, rng): + os.makedirs(os.path.dirname(path), exist_ok=True) + data = {str(i): item(rng, i, start_ts + i * 1000) for i in range(1, count + 1)} + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def median_ms(cmd, env, prepare=None): + times = [] + for _ in range(RUNS): + if prepare: + prepare() + started = time.perf_counter() + subprocess.run( + cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True + ) + times.append(time.perf_counter() - started) + return statistics.median(times) * 1000 + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + tb = os.path.abspath(sys.argv[1]) + sizes = sys.argv[2:] or DEFAULT_SIZES + + print("| Active / archived | Files | Board view | Create |") + print("|---|---|---|---|") + with tempfile.TemporaryDirectory(prefix="tb-bench-") as root: + for spec in sizes: + active, archived = (int(x) for x in spec.split("/")) + rng = random.Random(42) + golden = os.path.join(root, f"golden-{spec.replace('/', '-')}") + write_store(f"{golden}/.taskbook/storage/storage.json", active, 1_700_000_000_000, rng) + write_store(f"{golden}/.taskbook/archive/archive.json", archived, 1_600_000_000_000, rng) + size = sum( + os.path.getsize(f"{golden}/.taskbook/{p}") + for p in ("storage/storage.json", "archive/archive.json") + ) + + work = os.path.join(root, "work") + + def fresh_copy(): + shutil.rmtree(work, ignore_errors=True) + shutil.copytree(golden, work) + + env = dict(os.environ, TASKBOOK_DIR=work) + fresh_copy() + show = median_ms([tb, "--cli"], env) + create = median_ms([tb, "--cli", "--task", "bench task"], env, fresh_copy) + print(f"| {active} / {archived} | {size / 1e6:.1f} MB | {show:.0f} ms | {create:.0f} ms |") + + +if __name__ == "__main__": + main()