From 9e686a8d60e04695a93bbe8e40c57cfd2ce5c393 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Sun, 2 Aug 2026 17:14:58 -0700 Subject: [PATCH] feat(properties): colocated property grid with form-style authoring (openspec property-authoring) --- crates/trawler-core/src/outline.rs | 71 + crates/trawler/src/devtools.rs | 1307 ++++++------- crates/trawler/src/editor.rs | 25 +- crates/trawler/src/main.rs | 1624 +++++++++++++++-- crates/trawler/src/ui_tests.rs | 473 ++++- openspec/changes/add-supertags/design.md | 8 + openspec/changes/property-authoring/design.md | 283 +-- .../specs/property-authoring/spec.md | 230 +-- openspec/changes/property-authoring/tasks.md | 100 +- 9 files changed, 3092 insertions(+), 1029 deletions(-) diff --git a/crates/trawler-core/src/outline.rs b/crates/trawler-core/src/outline.rs index 4ed3e00..1476ce0 100644 --- a/crates/trawler-core/src/outline.rs +++ b/crates/trawler-core/src/outline.rs @@ -219,6 +219,28 @@ impl<'a> Outline<'a> { )) } + /// Set or clear a block's property-grid disclosure flag (spec: + /// block-graph/"Property-grid disclosure is document content"). A + /// plain `properties_folded` meta key beside `folded`, with the same + /// rationale: never a `properties` entry, so it stays invisible to + /// property queries and indexes, and last-write-wins merge is right + /// for a boolean toggle. + pub fn set_properties_folded(&self, id: TreeID, folded: bool) -> LoroResult<()> { + let meta = self.tree().get_meta(id)?; + meta.insert("properties_folded", folded)?; + Ok(()) + } + + /// A block's property-grid disclosure flag. Absent — every block + /// written before the flag existed — reads as expanded. + pub fn properties_folded(&self, id: TreeID) -> LoroResult { + let meta = self.tree().get_meta(id)?; + Ok(matches!( + meta.get("properties_folded"), + Some(loro::ValueOrContainer::Value(loro::LoroValue::Bool(true))) + )) + } + /// Set (or overwrite) a typed property on a block. pub fn set_property( &self, @@ -454,6 +476,55 @@ mod tests { assert!(outline.properties(block).unwrap().is_empty()); } + #[test] + fn properties_fold_flag_round_trips_and_defaults_to_expanded() { + let doc = doc_with_tree(); + let outline = Outline::new(&doc); + let page = outline.create_block(None, Position::Index(0), "").unwrap(); + let a = outline + .create_block(Some(page), Position::Index(0), "collapsed grid") + .unwrap(); + let b = outline + .create_block(Some(page), Position::Index(1), "expanded grid") + .unwrap(); + + // Absent key (every pre-flag block) reads as expanded. + assert!(!outline.properties_folded(a).unwrap()); + + outline.set_properties_folded(a, true).unwrap(); + assert!(outline.properties_folded(a).unwrap()); + + // Survives export/import — the same path storage snapshots take + // (spec scenario: "Disclosure round-trips through storage"). + let bytes = doc.export(loro::ExportMode::Snapshot).unwrap(); + let reopened = LoroDoc::new(); + reopened.import(&bytes).unwrap(); + let ro = Outline::new(&reopened); + assert!(ro.properties_folded(a).unwrap()); + assert!(!ro.properties_folded(b).unwrap()); + + outline.set_properties_folded(a, false).unwrap(); + assert!(!outline.properties_folded(a).unwrap()); + } + + #[test] + fn properties_fold_flag_is_invisible_to_the_property_system() { + let doc = doc_with_tree(); + let outline = Outline::new(&doc); + let page = outline.create_block(None, Position::Index(0), "").unwrap(); + let block = outline + .create_block(Some(page), Position::Index(0), "gridded") + .unwrap(); + + outline.set_properties_folded(block, true).unwrap(); + + // Spec scenario: "Disclosure is invisible to the property system". + assert!(outline.properties(block).unwrap().is_empty()); + + // And it is independent of the block fold flag. + assert!(!outline.folded(block).unwrap()); + } + #[test] fn reorder_sibling_via_after() { let doc = doc_with_tree(); diff --git a/crates/trawler/src/devtools.rs b/crates/trawler/src/devtools.rs index 25d7704..226c26a 100644 --- a/crates/trawler/src/devtools.rs +++ b/crates/trawler/src/devtools.rs @@ -1,620 +1,687 @@ -//! Local dev automation server (openspec change add-dev-automation, -//! capability dev-automation-server): a `127.0.0.1`-only TCP endpoint that -//! lets an agent or contributor drive and observe the running app during -//! development — inject keystrokes/text through GPUI's own dispatch, read -//! a structured dump of the UI state, and capture a window screenshot. -//! -//! Doubly gated: this module only compiles under the `devtools` cargo -//! feature, and even then [`maybe_start`] is a no-op unless -//! `TRAWLER_DEVTOOLS=1` is set at launch. -//! -//! Protocol: one JSON request object per line in, one JSON response object -//! per line out, in request order. Keystroke syntax is gpui's -//! `Keystroke::parse` (e.g. `"ctrl-k"`, `"shift-tab"`). Failures answer -//! `{"ok":false,"error":...}` and never affect the app. -//! -//! Threading: a plain listener thread does blocking line I/O and forwards -//! `(request, reply-sender)` pairs over an async channel to a single -//! foreground task spawned on the GPUI main thread, which executes each -//! command against the real window. Input therefore needs no OS focus and -//! replies are sent only after the dispatched action has run. Screenshots -//! are the exception: capture is OS-level and needs no GPUI state, so it -//! runs on its own thread and replies when the PNG is written, keeping a -//! slow capture from hitching the UI. - -use std::io::{BufRead as _, BufReader, Write as _}; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::sync::mpsc as sync_mpsc; - -use futures::channel::mpsc as async_mpsc; -use futures::StreamExt as _; -use gpui::{App, AsyncApp, Keystroke, Modifiers, WindowHandle}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use trawler_core::outline::Outline; - -use crate::{TrawlerApp, View}; - -/// Version stamped on every `dump` response; bump on breaking schema -/// changes so clients can detect drift. -const DUMP_VERSION: u64 = 1; - -/// Written into the graph directory so clients can discover the port. -const PORT_FILE: &str = "devtools.port"; - -// Unknown fields are tolerated (serde can't deny them on an internally -// tagged enum) — deliberate: newer clients degrade gracefully. -#[derive(Debug, PartialEq, Deserialize)] -#[serde(tag = "cmd", rename_all = "snake_case")] -enum Request { - /// Dispatch whitespace-separated keystrokes through the window keymap. - Keys { keys: String }, - /// Insert literal text through the focused editor's input path. - Type { text: String }, - /// Structured UI state (see [`Dump`]). - Dump, - /// Window position/size/scale only. - Bounds, - /// Capture the app window to a PNG at `path`. - Screenshot { path: PathBuf }, -} - -/// Devtools CLI flags that run instead of the app. Returns `true` if one -/// was handled (successfully or not) and `main` should return immediately. -pub fn handle_cli() -> bool { - let args: Vec = std::env::args().skip(1).collect(); - match args.first().map(String::as_str) { - // Seed the standard fixture graph and exit. - Some("--seed-fixtures") => { - match args.get(1) { - Some(dir) => match trawler_core::fixtures::seed(dir) { - Ok(_) => println!("seeded fixture graph at {dir}"), - Err(err) => eprintln!("failed to seed fixture graph at {dir}: {err}"), - }, - None => eprintln!("usage: trawler --seed-fixtures "), - } - true - } - // Hidden helper mode used by the `screenshot` command: capture the - // window of another trawler process. Runs as a separate process - // because xcap (deliberately, on Windows) refuses to enumerate the - // calling process's own windows; from here the app's window is - // just another process's window, on every platform. - Some("--devtools-capture") => { - let result = match ( - args.get(1).and_then(|pid| pid.parse::().ok()), - args.get(2), - ) { - (Some(pid), Some(path)) => capture_window_of_pid(pid, Path::new(path)), - _ => Err("usage: trawler --devtools-capture ".into()), - }; - match result { - Ok(path) => println!("{path}"), - Err(err) => { - eprintln!("{err}"); - std::process::exit(1); - } - } - true - } - _ => false, - } -} - -/// Start the automation server if `TRAWLER_DEVTOOLS=1`. Failure to start -/// (port bind, port-file write) is logged and otherwise ignored — the app -/// itself must never be affected by devtools. -pub fn maybe_start(graph_dir: PathBuf, window: WindowHandle, cx: &mut App) { - if std::env::var("TRAWLER_DEVTOOLS").as_deref() != Ok("1") { - return; - } - if let Err(err) = start(graph_dir, window, cx) { - eprintln!("trawler devtools failed to start: {err}"); - } -} - -fn start( - graph_dir: PathBuf, - window: WindowHandle, - cx: &mut App, -) -> std::io::Result<()> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let port = listener.local_addr()?.port(); - std::fs::write(graph_dir.join(PORT_FILE), port.to_string())?; - println!("trawler devtools listening on 127.0.0.1:{port}"); - - let (tx, mut rx) = async_mpsc::unbounded::<(Request, Reply)>(); - std::thread::spawn(move || accept_loop(listener, tx)); - - cx.spawn(async move |cx| { - while let Some((request, reply)) = rx.next().await { - handle_request(request, window, cx, reply); - } - }) - .detach(); - Ok(()) -} - -/// Where a command's single response goes. The listener thread blocks on -/// this, which is what serializes one-response-per-request in order. -type Reply = sync_mpsc::Sender; - -fn accept_loop(listener: TcpListener, tx: async_mpsc::UnboundedSender<(Request, Reply)>) { - // Clients are served one at a time: a dev session drives one - // connection, and interleaving two clients' keystrokes would be - // meaningless anyway. - for stream in listener.incoming() { - let Ok(stream) = stream else { continue }; - if !serve_client(stream, &tx) { - return; // app side hung up: stop accepting - } - } -} - -/// Serve one client connection. Returns `false` once the foreground task -/// is gone (app shutting down). -fn serve_client(stream: TcpStream, tx: &async_mpsc::UnboundedSender<(Request, Reply)>) -> bool { - let Ok(read_half) = stream.try_clone() else { - return true; - }; - let mut writer = stream; - for line in BufReader::new(read_half).lines() { - let Ok(line) = line else { break }; - if line.trim().is_empty() { - continue; - } - let response = match serde_json::from_str::(&line) { - Err(err) => error_response(format!("bad request: {err}")), - Ok(request) => { - let (reply_tx, reply_rx) = sync_mpsc::channel(); - if tx.unbounded_send((request, reply_tx)).is_err() { - return false; - } - match reply_rx.recv() { - Ok(value) => value, - Err(_) => return false, - } - } - }; - if writeln!(writer, "{response}").is_err() { - break; - } - } - true -} - -fn error_response(message: impl Into) -> Value { - json!({ "ok": false, "error": message.into() }) -} - -/// Execute one command on the main thread and send its reply. Runs inside -/// the foreground task; must never panic (a client can send anything). -fn handle_request( - request: Request, - window: WindowHandle, - cx: &mut AsyncApp, - reply: Reply, -) { - let response = match request { - Request::Keys { keys } => dispatch_keys(&keys, window, cx), - Request::Type { text } => dispatch_text(&text, window, cx), - Request::Dump => dump(window, cx), - Request::Bounds => bounds(window, cx), - Request::Screenshot { path } => { - // Replies from its own thread once the PNG is written. - screenshot_in_background(path, reply); - return; - } - }; - let _ = reply.send(response); -} - -/// `keys`: parse every keystroke first (so an invalid sequence dispatches -/// nothing), then dispatch each through the window keymap. -/// -/// Dispatch goes through [`gpui::AnyWindowHandle::update`], NOT the typed -/// `WindowHandle::update`: the typed variant holds a lease on -/// the root `TrawlerApp` entity for the duration of the closure, and any -/// dispatched action that updates the app entity (all of them do) would -/// double-lease it and abort the process. -fn dispatch_keys(keys: &str, window: WindowHandle, cx: &mut AsyncApp) -> Value { - let parsed: Result, _> = keys.split_whitespace().map(Keystroke::parse).collect(); - let keystrokes = match parsed { - Ok(keystrokes) if !keystrokes.is_empty() => keystrokes, - Ok(_) => return error_response("no keystrokes given"), - Err(err) => return error_response(format!("bad keystroke: {err}")), - }; - let result = (*window).update(cx, |_, window, cx| { - for keystroke in keystrokes { - window.dispatch_keystroke(keystroke, cx); - } - }); - match result { - Ok(()) => json!({ "ok": true }), - Err(err) => error_response(err.to_string()), - } -} - -/// `type`: each character dispatched as an unmodified keystroke with its -/// `key_char` set, exactly what a real typed key produces — unbound keys -/// fall through `dispatch_keystroke` to the focused editor's -/// `EntityInputHandler` (the IME path), so completion triggers, query -/// re-eval, etc. all fire as if typed (design O1). -fn dispatch_text(text: &str, window: WindowHandle, cx: &mut AsyncApp) -> Value { - // AnyWindowHandle for the same no-view-lease reason as `dispatch_keys`. - let result = (*window).update(cx, |_, window, cx| { - for ch in text.chars() { - let keystroke = Keystroke { - modifiers: Modifiers::default(), - key: ch.to_string(), - key_char: Some(ch.to_string()), - }; - window.dispatch_keystroke(keystroke, cx); - } - }); - match result { - Ok(()) => json!({ "ok": true }), - Err(err) => error_response(err.to_string()), - } -} - -// NOTE: there is deliberately no `click` command. gpui 0.2.2's -// `Window::dispatch_event` returns a `pub(crate)` type, making it -// uncallable from outside the crate — mouse events cannot be injected -// through public API. Drive the UI through keyboard equivalents instead -// (every mouse affordance is required to have one; see the outline-editor -// spec's mouseless-operations requirement). - -fn bounds(window: WindowHandle, cx: &mut AsyncApp) -> Value { - match window.update(cx, |_, window, _| bounds_dump(window)) { - Ok(bounds) => match serde_json::to_value(&bounds) { - Ok(mut value) => { - value["ok"] = json!(true); - value - } - Err(err) => error_response(err.to_string()), - }, - Err(err) => error_response(err.to_string()), - } -} - -fn dump(window: WindowHandle, cx: &mut AsyncApp) -> Value { - let result = window.update(cx, |app, window, cx| build_dump(app, window, cx)); - match result { - Ok(dump) => match serde_json::to_value(&dump) { - Ok(mut value) => { - value["ok"] = json!(true); - value - } - Err(err) => error_response(err.to_string()), - }, - Err(err) => error_response(err.to_string()), - } -} - -// --- dump schema --------------------------------------------------------- -// -// Serialized straight from `TrawlerApp`/`BlockEditor` entity state (never -// from rendered elements — design D5), so a UI refactor that breaks a -// field breaks the build here rather than silently emitting stale shapes. - -#[derive(Serialize)] -struct Dump { - v: u64, - view: ViewDump, - /// The visible outline, in rendered (depth-first, fold-aware) order. - rows: Vec, - focused: Option, - quick_open: Option, - search: Option, - /// First day of the month the calendar picker is showing, if visible. - /// The calendar is a sidebar panel (openspec change ui-polish), so - /// this is populated exactly when the sidebar is open. - calendar_month: Option, - /// The right-hand sidebar's state (openspec change ui-polish, - /// tasks 3.5 — additive field). - sidebar: SidebarDump, - /// The designated home document's canonical node id, if any (openspec - /// change home-document — additive field). `None` = journal. - home: Option, - /// Pinned node ids in sidebar order (openspec change pinned-sidebar — - /// additive field). - pinned: Vec, - /// The open context menu's target node id, if one is open (openspec - /// change pinned-sidebar — additive field). - context_menu: Option, - bounds: BoundsDump, -} - -#[derive(Serialize)] -struct SidebarDump { - open: bool, - width: f32, - /// Panel titles in display order. - panels: Vec, -} - -#[derive(Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum ViewDump { - Journal, - Node { id: String }, - Settings, -} - -#[derive(Serialize)] -struct RowDump { - id: String, - depth: usize, - content: String, - has_children: bool, - folded: bool, - is_query: bool, -} - -#[derive(Serialize)] -struct FocusedDump { - block: String, - /// Live editor buffer (may be ahead of graph storage until commit). - text: String, - /// Byte offset into `text`. - cursor: usize, - /// Byte range; empty = plain cursor. - selection: (usize, usize), - completion: Option, -} - -#[derive(Serialize)] -struct CompletionDump { - trigger: char, - query: String, - candidates: Vec, - selected: usize, -} - -#[derive(Serialize)] -struct QuickOpenDump { - query: String, - items: Vec, - selected: usize, -} - -#[derive(Serialize)] -struct SearchDump { - query: String, - /// Block ids of the hits, in result order. - results: Vec, - selected: usize, -} - -#[derive(Serialize)] -struct BoundsDump { - x: f64, - y: f64, - width: f64, - height: f64, - scale: f32, -} - -fn build_dump(app: &TrawlerApp, window: &gpui::Window, cx: &gpui::Context) -> Dump { - let outline = Outline::new(app.storage.doc()); - - let view = match &app.view { - View::Journal => ViewDump::Journal, - View::Node(id) => ViewDump::Node { id: id.to_string() }, - View::Settings => ViewDump::Settings, - }; - - let rows = app - .rows - .iter() - .map(|row| RowDump { - id: row.id.to_string(), - depth: row.depth, - content: outline.content(row.id).unwrap_or_default(), - has_children: row.has_children, - folded: row.folded, - is_query: row.is_query, - }) - .collect(); - - let focused = app.editor.as_ref().map(|editor| { - let input = editor.input.read(cx); - FocusedDump { - block: editor.block.to_string(), - text: input.value().to_string(), - cursor: input.cursor_offset(), - selection: { - let range = input.selection(); - (range.start, range.end) - }, - completion: input - .completion_state() - .map(|(trigger, query, candidates, selected)| CompletionDump { - trigger, - query: query.to_string(), - candidates: candidates.iter().map(|c| c.display.clone()).collect(), - selected, - }), - } - }); - - let quick_open = app.quick_open.as_ref().map(|state| QuickOpenDump { - query: state.query.clone(), - items: state.results.iter().map(|row| row.label.clone()).collect(), - selected: state.selected, - }); - - let search = app.search_open.as_ref().map(|state| SearchDump { - query: state.query.clone(), - results: state - .results - .iter() - .map(|hit| hit.block.to_string()) - .collect(), - selected: state.selected, - }); - - let calendar_month = app - .sidebar_open - .then(|| app.calendar.month.format("%Y-%m-%d").to_string()); - - let sidebar = SidebarDump { - open: app.sidebar_open, - width: app.sidebar_width, - panels: crate::SIDEBAR_PANELS - .iter() - .map(|p| p.title().to_string()) - .collect(), - }; - - let home = trawler_core::settings::Settings::new(app.storage.doc()) - .home() - .map(|id| id.to_string()); - let pinned = trawler_core::pins::Pins::new(app.storage.doc()) - .load(&outline) - .into_iter() - .map(|id| id.to_string()) - .collect(); - let context_menu = app.context_menu.as_ref().map(|m| m.target.to_string()); - - Dump { - v: DUMP_VERSION, - view, - rows, - focused, - quick_open, - search, - calendar_month, - sidebar, - pinned, - context_menu, - home, - bounds: bounds_dump(window), - } -} - -fn bounds_dump(window: &gpui::Window) -> BoundsDump { - let bounds = window.bounds(); - BoundsDump { - x: f64::from(bounds.origin.x), - y: f64::from(bounds.origin.y), - width: f64::from(bounds.size.width), - height: f64::from(bounds.size.height), - scale: window.scale_factor(), - } -} - -// --- screenshot ---------------------------------------------------------- - -fn screenshot_in_background(path: PathBuf, reply: Reply) { - std::thread::spawn(move || { - let response = match capture_via_helper(&path) { - Ok(path) => json!({ "ok": true, "path": path }), - Err(err) => error_response(err), - }; - let _ = reply.send(response); - }); -} - -/// Capture the app window by re-executing this binary as a short-lived -/// helper (`--devtools-capture `). xcap's Windows backend -/// deliberately filters out the calling process's own windows (a -/// GetWindowText-deadlock precaution), so in-process capture can never see -/// us there — from a child process, the app window is capturable with the -/// same code on every platform. -fn capture_via_helper(path: &Path) -> Result { - let exe = std::env::current_exe().map_err(|err| format!("current_exe: {err}"))?; - let output = std::process::Command::new(exe) - .arg("--devtools-capture") - .arg(std::process::id().to_string()) - .arg(path) - .output() - .map_err(|err| format!("spawn capture helper: {err}"))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } else { - Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) - } -} - -/// Helper-process side: capture the (non-minimized) window owned by `pid` -/// to a PNG via `xcap`. Best-effort per platform (design D6): failures — -/// no capturable window, Wayland compositor limitations, permission not -/// granted on macOS — surface as an error reply and never affect any -/// other command. -fn capture_window_of_pid(pid: u32, path: &Path) -> Result { - let windows = xcap::Window::all().map_err(|err| format!("window enumeration failed: {err}"))?; - let window = windows - .into_iter() - .filter(|w| w.pid().is_ok_and(|p| p == pid)) - .find(|w| !w.is_minimized().unwrap_or(false)) - .ok_or_else(|| format!("no capturable window for pid {pid}"))?; - let image = window - .capture_image() - .map_err(|err| format!("capture failed: {err}"))?; - if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { - std::fs::create_dir_all(parent).map_err(|err| format!("create {parent:?}: {err}"))?; - } - image - .save(path) - .map_err(|err| format!("save {path:?}: {err}"))?; - Ok(path.display().to_string()) -} - -// --- protocol tests (run with: cargo test -p trawler --features devtools) - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_every_command() { - assert_eq!( - serde_json::from_str::(r#"{"cmd":"keys","keys":"ctrl-k"}"#).unwrap(), - Request::Keys { - keys: "ctrl-k".into() - } - ); - assert_eq!( - serde_json::from_str::(r#"{"cmd":"type","text":"hello [["}"#).unwrap(), - Request::Type { - text: "hello [[".into() - } - ); - assert_eq!( - serde_json::from_str::(r#"{"cmd":"dump"}"#).unwrap(), - Request::Dump - ); - assert_eq!( - serde_json::from_str::(r#"{"cmd":"bounds"}"#).unwrap(), - Request::Bounds - ); - assert_eq!( - serde_json::from_str::(r#"{"cmd":"screenshot","path":"shot.png"}"#).unwrap(), - Request::Screenshot { - path: PathBuf::from("shot.png") - } - ); - } - - #[test] - fn rejects_unknown_and_malformed_requests() { - assert!(serde_json::from_str::(r#"{"cmd":"reboot"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"cmd":"keys"}"#).is_err()); - assert!(serde_json::from_str::("not json").is_err()); - // Unknown extra fields are tolerated for forward compatibility. - assert_eq!( - serde_json::from_str::(r#"{"cmd":"dump","x":1}"#).unwrap(), - Request::Dump - ); - } - - #[test] - fn error_responses_are_shaped_as_specified() { - let value = error_response("nope"); - assert_eq!(value["ok"], json!(false)); - assert_eq!(value["error"], json!("nope")); - } -} +//! Local dev automation server (openspec change add-dev-automation, +//! capability dev-automation-server): a `127.0.0.1`-only TCP endpoint that +//! lets an agent or contributor drive and observe the running app during +//! development — inject keystrokes/text through GPUI's own dispatch, read +//! a structured dump of the UI state, and capture a window screenshot. +//! +//! Doubly gated: this module only compiles under the `devtools` cargo +//! feature, and even then [`maybe_start`] is a no-op unless +//! `TRAWLER_DEVTOOLS=1` is set at launch. +//! +//! Protocol: one JSON request object per line in, one JSON response object +//! per line out, in request order. Keystroke syntax is gpui's +//! `Keystroke::parse` (e.g. `"ctrl-k"`, `"shift-tab"`). Failures answer +//! `{"ok":false,"error":...}` and never affect the app. +//! +//! Threading: a plain listener thread does blocking line I/O and forwards +//! `(request, reply-sender)` pairs over an async channel to a single +//! foreground task spawned on the GPUI main thread, which executes each +//! command against the real window. Input therefore needs no OS focus and +//! replies are sent only after the dispatched action has run. Screenshots +//! are the exception: capture is OS-level and needs no GPUI state, so it +//! runs on its own thread and replies when the PNG is written, keeping a +//! slow capture from hitching the UI. + +use std::io::{BufRead as _, BufReader, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc as sync_mpsc; + +use futures::channel::mpsc as async_mpsc; +use futures::StreamExt as _; +use gpui::{App, AsyncApp, Keystroke, Modifiers, WindowHandle}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use trawler_core::outline::Outline; + +use crate::{TrawlerApp, View}; + +/// Version stamped on every `dump` response; bump on breaking schema +/// changes so clients can detect drift. +const DUMP_VERSION: u64 = 1; + +/// Written into the graph directory so clients can discover the port. +const PORT_FILE: &str = "devtools.port"; + +// Unknown fields are tolerated (serde can't deny them on an internally +// tagged enum) — deliberate: newer clients degrade gracefully. +#[derive(Debug, PartialEq, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +enum Request { + /// Dispatch whitespace-separated keystrokes through the window keymap. + Keys { keys: String }, + /// Insert literal text through the focused editor's input path. + Type { text: String }, + /// Structured UI state (see [`Dump`]). + Dump, + /// Window position/size/scale only. + Bounds, + /// Capture the app window to a PNG at `path`. + Screenshot { path: PathBuf }, +} + +/// Devtools CLI flags that run instead of the app. Returns `true` if one +/// was handled (successfully or not) and `main` should return immediately. +pub fn handle_cli() -> bool { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + // Seed the standard fixture graph and exit. + Some("--seed-fixtures") => { + match args.get(1) { + Some(dir) => match trawler_core::fixtures::seed(dir) { + Ok(_) => println!("seeded fixture graph at {dir}"), + Err(err) => eprintln!("failed to seed fixture graph at {dir}: {err}"), + }, + None => eprintln!("usage: trawler --seed-fixtures "), + } + true + } + // Hidden helper mode used by the `screenshot` command: capture the + // window of another trawler process. Runs as a separate process + // because xcap (deliberately, on Windows) refuses to enumerate the + // calling process's own windows; from here the app's window is + // just another process's window, on every platform. + Some("--devtools-capture") => { + let result = match ( + args.get(1).and_then(|pid| pid.parse::().ok()), + args.get(2), + ) { + (Some(pid), Some(path)) => capture_window_of_pid(pid, Path::new(path)), + _ => Err("usage: trawler --devtools-capture ".into()), + }; + match result { + Ok(path) => println!("{path}"), + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + } + true + } + _ => false, + } +} + +/// Start the automation server if `TRAWLER_DEVTOOLS=1`. Failure to start +/// (port bind, port-file write) is logged and otherwise ignored — the app +/// itself must never be affected by devtools. +pub fn maybe_start(graph_dir: PathBuf, window: WindowHandle, cx: &mut App) { + if std::env::var("TRAWLER_DEVTOOLS").as_deref() != Ok("1") { + return; + } + if let Err(err) = start(graph_dir, window, cx) { + eprintln!("trawler devtools failed to start: {err}"); + } +} + +fn start( + graph_dir: PathBuf, + window: WindowHandle, + cx: &mut App, +) -> std::io::Result<()> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + std::fs::write(graph_dir.join(PORT_FILE), port.to_string())?; + println!("trawler devtools listening on 127.0.0.1:{port}"); + + let (tx, mut rx) = async_mpsc::unbounded::<(Request, Reply)>(); + std::thread::spawn(move || accept_loop(listener, tx)); + + cx.spawn(async move |cx| { + while let Some((request, reply)) = rx.next().await { + handle_request(request, window, cx, reply); + } + }) + .detach(); + Ok(()) +} + +/// Where a command's single response goes. The listener thread blocks on +/// this, which is what serializes one-response-per-request in order. +type Reply = sync_mpsc::Sender; + +fn accept_loop(listener: TcpListener, tx: async_mpsc::UnboundedSender<(Request, Reply)>) { + // Clients are served one at a time: a dev session drives one + // connection, and interleaving two clients' keystrokes would be + // meaningless anyway. + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + if !serve_client(stream, &tx) { + return; // app side hung up: stop accepting + } + } +} + +/// Serve one client connection. Returns `false` once the foreground task +/// is gone (app shutting down). +fn serve_client(stream: TcpStream, tx: &async_mpsc::UnboundedSender<(Request, Reply)>) -> bool { + let Ok(read_half) = stream.try_clone() else { + return true; + }; + let mut writer = stream; + for line in BufReader::new(read_half).lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Err(err) => error_response(format!("bad request: {err}")), + Ok(request) => { + let (reply_tx, reply_rx) = sync_mpsc::channel(); + if tx.unbounded_send((request, reply_tx)).is_err() { + return false; + } + match reply_rx.recv() { + Ok(value) => value, + Err(_) => return false, + } + } + }; + if writeln!(writer, "{response}").is_err() { + break; + } + } + true +} + +fn error_response(message: impl Into) -> Value { + json!({ "ok": false, "error": message.into() }) +} + +/// Execute one command on the main thread and send its reply. Runs inside +/// the foreground task; must never panic (a client can send anything). +fn handle_request( + request: Request, + window: WindowHandle, + cx: &mut AsyncApp, + reply: Reply, +) { + let response = match request { + Request::Keys { keys } => dispatch_keys(&keys, window, cx), + Request::Type { text } => dispatch_text(&text, window, cx), + Request::Dump => dump(window, cx), + Request::Bounds => bounds(window, cx), + Request::Screenshot { path } => { + // Replies from its own thread once the PNG is written. + screenshot_in_background(path, reply); + return; + } + }; + let _ = reply.send(response); +} + +/// `keys`: parse every keystroke first (so an invalid sequence dispatches +/// nothing), then dispatch each through the window keymap. +/// +/// Dispatch goes through [`gpui::AnyWindowHandle::update`], NOT the typed +/// `WindowHandle::update`: the typed variant holds a lease on +/// the root `TrawlerApp` entity for the duration of the closure, and any +/// dispatched action that updates the app entity (all of them do) would +/// double-lease it and abort the process. +fn dispatch_keys(keys: &str, window: WindowHandle, cx: &mut AsyncApp) -> Value { + let parsed: Result, _> = keys.split_whitespace().map(Keystroke::parse).collect(); + let keystrokes = match parsed { + Ok(keystrokes) if !keystrokes.is_empty() => keystrokes, + Ok(_) => return error_response("no keystrokes given"), + Err(err) => return error_response(format!("bad keystroke: {err}")), + }; + let result = (*window).update(cx, |_, window, cx| { + for keystroke in keystrokes { + window.dispatch_keystroke(keystroke, cx); + } + }); + match result { + Ok(()) => json!({ "ok": true }), + Err(err) => error_response(err.to_string()), + } +} + +/// `type`: each character dispatched as an unmodified keystroke with its +/// `key_char` set, exactly what a real typed key produces — unbound keys +/// fall through `dispatch_keystroke` to the focused editor's +/// `EntityInputHandler` (the IME path), so completion triggers, query +/// re-eval, etc. all fire as if typed (design O1). +fn dispatch_text(text: &str, window: WindowHandle, cx: &mut AsyncApp) -> Value { + // AnyWindowHandle for the same no-view-lease reason as `dispatch_keys`. + let result = (*window).update(cx, |_, window, cx| { + for ch in text.chars() { + let keystroke = Keystroke { + modifiers: Modifiers::default(), + key: ch.to_string(), + key_char: Some(ch.to_string()), + }; + window.dispatch_keystroke(keystroke, cx); + } + }); + match result { + Ok(()) => json!({ "ok": true }), + Err(err) => error_response(err.to_string()), + } +} + +// NOTE: there is deliberately no `click` command. gpui 0.2.2's +// `Window::dispatch_event` returns a `pub(crate)` type, making it +// uncallable from outside the crate — mouse events cannot be injected +// through public API. Drive the UI through keyboard equivalents instead +// (every mouse affordance is required to have one; see the outline-editor +// spec's mouseless-operations requirement). + +fn bounds(window: WindowHandle, cx: &mut AsyncApp) -> Value { + match window.update(cx, |_, window, _| bounds_dump(window)) { + Ok(bounds) => match serde_json::to_value(&bounds) { + Ok(mut value) => { + value["ok"] = json!(true); + value + } + Err(err) => error_response(err.to_string()), + }, + Err(err) => error_response(err.to_string()), + } +} + +fn dump(window: WindowHandle, cx: &mut AsyncApp) -> Value { + let result = window.update(cx, |app, window, cx| build_dump(app, window, cx)); + match result { + Ok(dump) => match serde_json::to_value(&dump) { + Ok(mut value) => { + value["ok"] = json!(true); + value + } + Err(err) => error_response(err.to_string()), + }, + Err(err) => error_response(err.to_string()), + } +} + +// --- dump schema --------------------------------------------------------- +// +// Serialized straight from `TrawlerApp`/`BlockEditor` entity state (never +// from rendered elements — design D5), so a UI refactor that breaks a +// field breaks the build here rather than silently emitting stale shapes. + +#[derive(Serialize)] +struct Dump { + v: u64, + view: ViewDump, + /// The visible outline, in rendered (depth-first, fold-aware) order. + rows: Vec, + focused: Option, + quick_open: Option, + search: Option, + /// First day of the month the calendar picker is showing, if visible. + /// The calendar is a sidebar panel (openspec change ui-polish), so + /// this is populated exactly when the sidebar is open. + calendar_month: Option, + /// The right-hand sidebar's state (openspec change ui-polish, + /// tasks 3.5 — additive field). + sidebar: SidebarDump, + /// The designated home document's canonical node id, if any (openspec + /// change home-document — additive field). `None` = journal. + home: Option, + /// Pinned node ids in sidebar order (openspec change pinned-sidebar — + /// additive field). + pinned: Vec, + /// The open context menu's target, if one is open (openspec change + /// pinned-sidebar — additive field): a node id, or + /// `property::` for a property-row menu (openspec change + /// property-authoring). + context_menu: Option, + /// The property-grid editing surface, if open (openspec change + /// property-authoring — additive field). + property_grid: Option, + bounds: BoundsDump, +} + +#[derive(Serialize)] +struct SidebarDump { + open: bool, + width: f32, + /// Panel titles in display order. + panels: Vec, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ViewDump { + Journal, + Node { id: String }, + Settings, +} + +#[derive(Serialize)] +struct RowDump { + id: String, + depth: usize, + content: String, + has_children: bool, + folded: bool, + is_query: bool, + /// Visible property rows (machine keys filtered, sorted by key) and + /// the grid's disclosure state (openspec change property-authoring — + /// additive fields). + properties: Vec, + properties_folded: bool, +} + +#[derive(Serialize)] +struct PropRowDump { + key: String, + value: String, + #[serde(rename = "type")] + type_name: String, +} + +/// The property-grid editing surface, when open (openspec change +/// property-authoring — additive field). +#[derive(Serialize)] +struct PropertyGridDump { + block: String, + /// Draft rows as shown, including uncommitted fresh rows. + rows: Vec, + row: usize, + /// `"key"` or `"value"`. + field: String, + /// Live field input's buffer. + text: String, +} + +#[derive(Serialize)] +struct FocusedDump { + block: String, + /// Live editor buffer (may be ahead of graph storage until commit). + text: String, + /// Byte offset into `text`. + cursor: usize, + /// Byte range; empty = plain cursor. + selection: (usize, usize), + completion: Option, +} + +#[derive(Serialize)] +struct CompletionDump { + trigger: char, + query: String, + candidates: Vec, + selected: usize, +} + +#[derive(Serialize)] +struct QuickOpenDump { + query: String, + items: Vec, + selected: usize, +} + +#[derive(Serialize)] +struct SearchDump { + query: String, + /// Block ids of the hits, in result order. + results: Vec, + selected: usize, +} + +#[derive(Serialize)] +struct BoundsDump { + x: f64, + y: f64, + width: f64, + height: f64, + scale: f32, +} + +fn build_dump(app: &TrawlerApp, window: &gpui::Window, cx: &gpui::Context) -> Dump { + let outline = Outline::new(app.storage.doc()); + + let view = match &app.view { + View::Journal => ViewDump::Journal, + View::Node(id) => ViewDump::Node { id: id.to_string() }, + View::Settings => ViewDump::Settings, + }; + + let rows = app + .rows + .iter() + .map(|row| RowDump { + id: row.id.to_string(), + depth: row.depth, + content: outline.content(row.id).unwrap_or_default(), + has_children: row.has_children, + folded: row.folded, + is_query: row.is_query, + properties: row + .properties + .iter() + .map(|p| PropRowDump { + key: p.key.clone(), + value: p.value.clone(), + type_name: p.type_name.to_string(), + }) + .collect(), + properties_folded: row.properties_folded, + }) + .collect(); + + let focused = app.editor.as_ref().map(|editor| { + let input = editor.input.read(cx); + FocusedDump { + block: editor.block.to_string(), + text: input.value().to_string(), + cursor: input.cursor_offset(), + selection: { + let range = input.selection(); + (range.start, range.end) + }, + completion: input + .completion_state() + .map(|(trigger, query, candidates, selected)| CompletionDump { + trigger, + query: query.to_string(), + candidates: candidates.iter().map(|c| c.display.clone()).collect(), + selected, + }), + } + }); + + let quick_open = app.quick_open.as_ref().map(|state| QuickOpenDump { + query: state.query.clone(), + items: state.results.iter().map(|row| row.label.clone()).collect(), + selected: state.selected, + }); + + let search = app.search_open.as_ref().map(|state| SearchDump { + query: state.query.clone(), + results: state + .results + .iter() + .map(|hit| hit.block.to_string()) + .collect(), + selected: state.selected, + }); + + let calendar_month = app + .sidebar_open + .then(|| app.calendar.month.format("%Y-%m-%d").to_string()); + + let sidebar = SidebarDump { + open: app.sidebar_open, + width: app.sidebar_width, + panels: crate::SIDEBAR_PANELS + .iter() + .map(|p| p.title().to_string()) + .collect(), + }; + + let home = trawler_core::settings::Settings::new(app.storage.doc()) + .home() + .map(|id| id.to_string()); + let pinned = trawler_core::pins::Pins::new(app.storage.doc()) + .load(&outline) + .into_iter() + .map(|id| id.to_string()) + .collect(); + let context_menu = app.context_menu.as_ref().map(|m| match &m.target { + crate::MenuTarget::Node(id) => id.to_string(), + crate::MenuTarget::PropertyRow { block, key } => { + format!("property:{block}:{key}") + } + }); + + let property_grid = app.grid_edit.as_ref().map(|grid| PropertyGridDump { + block: grid.block.to_string(), + rows: grid + .rows + .iter() + .map(|d| PropRowDump { + key: d.key.clone(), + value: d.value.clone(), + type_name: d.type_name.to_string(), + }) + .collect(), + row: grid.row_ix, + field: match grid.field { + crate::GridField::Key => "key".to_string(), + crate::GridField::Value => "value".to_string(), + }, + text: grid.input.read(cx).value().to_string(), + }); + + Dump { + v: DUMP_VERSION, + view, + rows, + focused, + quick_open, + search, + calendar_month, + sidebar, + pinned, + context_menu, + property_grid, + home, + bounds: bounds_dump(window), + } +} + +fn bounds_dump(window: &gpui::Window) -> BoundsDump { + let bounds = window.bounds(); + BoundsDump { + x: f64::from(bounds.origin.x), + y: f64::from(bounds.origin.y), + width: f64::from(bounds.size.width), + height: f64::from(bounds.size.height), + scale: window.scale_factor(), + } +} + +// --- screenshot ---------------------------------------------------------- + +fn screenshot_in_background(path: PathBuf, reply: Reply) { + std::thread::spawn(move || { + let response = match capture_via_helper(&path) { + Ok(path) => json!({ "ok": true, "path": path }), + Err(err) => error_response(err), + }; + let _ = reply.send(response); + }); +} + +/// Capture the app window by re-executing this binary as a short-lived +/// helper (`--devtools-capture `). xcap's Windows backend +/// deliberately filters out the calling process's own windows (a +/// GetWindowText-deadlock precaution), so in-process capture can never see +/// us there — from a child process, the app window is capturable with the +/// same code on every platform. +fn capture_via_helper(path: &Path) -> Result { + let exe = std::env::current_exe().map_err(|err| format!("current_exe: {err}"))?; + let output = std::process::Command::new(exe) + .arg("--devtools-capture") + .arg(std::process::id().to_string()) + .arg(path) + .output() + .map_err(|err| format!("spawn capture helper: {err}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// Helper-process side: capture the (non-minimized) window owned by `pid` +/// to a PNG via `xcap`. Best-effort per platform (design D6): failures — +/// no capturable window, Wayland compositor limitations, permission not +/// granted on macOS — surface as an error reply and never affect any +/// other command. +fn capture_window_of_pid(pid: u32, path: &Path) -> Result { + let windows = xcap::Window::all().map_err(|err| format!("window enumeration failed: {err}"))?; + let window = windows + .into_iter() + .filter(|w| w.pid().is_ok_and(|p| p == pid)) + .find(|w| !w.is_minimized().unwrap_or(false)) + .ok_or_else(|| format!("no capturable window for pid {pid}"))?; + let image = window + .capture_image() + .map_err(|err| format!("capture failed: {err}"))?; + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(|err| format!("create {parent:?}: {err}"))?; + } + image + .save(path) + .map_err(|err| format!("save {path:?}: {err}"))?; + Ok(path.display().to_string()) +} + +// --- protocol tests (run with: cargo test -p trawler --features devtools) + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_every_command() { + assert_eq!( + serde_json::from_str::(r#"{"cmd":"keys","keys":"ctrl-k"}"#).unwrap(), + Request::Keys { + keys: "ctrl-k".into() + } + ); + assert_eq!( + serde_json::from_str::(r#"{"cmd":"type","text":"hello [["}"#).unwrap(), + Request::Type { + text: "hello [[".into() + } + ); + assert_eq!( + serde_json::from_str::(r#"{"cmd":"dump"}"#).unwrap(), + Request::Dump + ); + assert_eq!( + serde_json::from_str::(r#"{"cmd":"bounds"}"#).unwrap(), + Request::Bounds + ); + assert_eq!( + serde_json::from_str::(r#"{"cmd":"screenshot","path":"shot.png"}"#).unwrap(), + Request::Screenshot { + path: PathBuf::from("shot.png") + } + ); + } + + #[test] + fn rejects_unknown_and_malformed_requests() { + assert!(serde_json::from_str::(r#"{"cmd":"reboot"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"cmd":"keys"}"#).is_err()); + assert!(serde_json::from_str::("not json").is_err()); + // Unknown extra fields are tolerated for forward compatibility. + assert_eq!( + serde_json::from_str::(r#"{"cmd":"dump","x":1}"#).unwrap(), + Request::Dump + ); + } + + #[test] + fn error_responses_are_shaped_as_specified() { + let value = error_response("nope"); + assert_eq!(value["ok"], json!(false)); + assert_eq!(value["error"], json!("nope")); + } +} diff --git a/crates/trawler/src/editor.rs b/crates/trawler/src/editor.rs index 5cce246..bc914a6 100644 --- a/crates/trawler/src/editor.rs +++ b/crates/trawler/src/editor.rs @@ -219,6 +219,9 @@ pub struct BlockEditor { /// whether a block is a query is graph state `BlockEditor` itself /// doesn't know about. highlight_scheme: bool, + /// Bare-completion mode (see `set_complete_bare`): the whole content + /// is the completion query, no trigger characters involved. + complete_bare: bool, /// The `(cursor, content length)` pair last used to request the /// containing list autoscroll to this editor (openspec change /// ui-polish, design D5 — "the viewport follows focus"). The element's @@ -354,6 +357,7 @@ impl BlockEditor { is_selecting: false, completion: None, highlight_scheme: false, + complete_bare: false, autoscroll_key: None, reveal_key: None, reveal_attempts: 0, @@ -427,6 +431,13 @@ impl BlockEditor { /// Enable/disable Scheme syntax highlighting and matching-paren /// indication for this editor instance (a query block vs. an ordinary /// one) — see the field doc on `highlight_scheme`. + /// Complete against the whole content instead of a `[[`/`#` trigger — + /// the property grid's key field, whose vocabulary is the graph's + /// existing property keys (openspec change property-authoring). + pub fn set_complete_bare(&mut self, enabled: bool) { + self.complete_bare = enabled; + } + pub fn set_highlight_scheme(&mut self, enabled: bool, cx: &mut Context) { self.highlight_scheme = enabled; cx.notify(); @@ -456,9 +467,15 @@ impl BlockEditor { /// Find an active `[[` or `#` completion trigger ending at the cursor, /// on the current line only (spec: "Reference completion"). Returns /// the trigger char and the byte range of the query text typed since - /// it (excluding the trigger itself). + /// it (excluding the trigger itself). In bare-completion mode (the + /// property grid's key field; openspec change property-authoring) the + /// entire content up to the cursor is the query, reported with a `'\0'` + /// pseudo-trigger. fn detect_trigger(&self) -> Option<(char, Range)> { let cursor = self.cursor_offset(); + if self.complete_bare { + return Some(('\0', 0..cursor)); + } let line_start = self.content[..cursor].rfind('\n').map_or(0, |i| i + 1); let line = &self.content[line_start..cursor]; @@ -529,6 +546,12 @@ impl BlockEditor { fn escape(&mut self, _: &EditorEscape, _: &mut Window, cx: &mut Context) { if self.completion.take().is_some() { cx.notify(); + } else { + // Nothing to dismiss here — let escape climb to an enclosing + // surface's binding (the property grid's exit; openspec change + // property-authoring). No-op for ordinary block editing, where + // nothing above binds escape. + cx.propagate(); } } diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index 1972941..20b0d26 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -47,11 +47,11 @@ use std::time::Duration; use chrono::{Datelike, NaiveDate}; use editor::{BlockEditor, BlockEditorEvent, CompletionCandidate}; use gpui::{ - actions, deferred, div, list, point, px, relative, rgb, size, App, AppContext as _, - Application, Bounds, ClickEvent, Context, Entity, FocusHandle, Focusable as _, FontWeight, - InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, ListAlignment, ListState, - MouseButton, ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription, - TitlebarOptions, Window, WindowBounds, WindowControlArea, WindowOptions, + actions, deferred, div, list, point, px, relative, rgb, size, AnimationExt as _, App, + AppContext as _, Application, Bounds, ClickEvent, Context, Entity, FocusHandle, Focusable as _, + FontWeight, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, ListAlignment, + ListState, MouseButton, ParentElement, Render, StatefulInteractiveElement as _, Styled, + Subscription, TitlebarOptions, Window, WindowBounds, WindowControlArea, WindowOptions, }; use loro::TreeID; use markdown::{Block, Inline}; @@ -79,10 +79,19 @@ actions!( ToggleSidebar, GoHome, GoToJournal, - NewPage + NewPage, + EditProperties, + TogglePropertiesFold, + GridExit, + GridDeleteRow ] ); +/// Key context of the property-grid editing surface's wrapper element — +/// the grid's own bindings (escape to exit, shift-delete on a row) resolve +/// here, above the field input's `BlockEditor` context. +const PROPERTY_GRID_CONTEXT: &str = "PropertyGrid"; + const APP_CONTEXT: &str = "Trawler"; /// The app-wide UI font. Bundled into the binary (see `load_bundled_fonts`) @@ -151,6 +160,16 @@ fn init_keymap(cx: &mut App) { KeyBinding::new("ctrl-shift-b", ToggleSidebar, Some(APP_CONTEXT)), KeyBinding::new("ctrl-h", GoHome, Some(APP_CONTEXT)), KeyBinding::new("ctrl-n", NewPage, Some(APP_CONTEXT)), + // Property grid (openspec change property-authoring). App-level + // like ToggleQueryBlock: which block's grid to open is graph/focus + // state, not a text edit. + KeyBinding::new("ctrl-shift-p", EditProperties, Some(APP_CONTEXT)), + KeyBinding::new("ctrl-alt-p", TogglePropertiesFold, Some(APP_CONTEXT)), + // Inside the grid surface: escape exits (the field editor's own + // escape handler propagates when no completion popup is open), + // shift-delete removes the current row's property. + KeyBinding::new("escape", GridExit, Some(PROPERTY_GRID_CONTEXT)), + KeyBinding::new("shift-delete", GridDeleteRow, Some(PROPERTY_GRID_CONTEXT)), ]); } @@ -169,6 +188,29 @@ enum View { Settings, } +/// Property keys owned by the app rather than the user, excluded from the +/// property grid's display and editing while remaining ordinary properties +/// for storage, indexing, and queries (spec: property-authoring/"Machine +/// properties are hidden from the grid"). +const MACHINE_PROPERTY_KEYS: &[&str] = &["query"]; + +/// One display row of a block's property grid (spec: property-authoring/ +/// "Properties render colocated with their block"): the key, the value in +/// its editable string form, and the stored value's type name — not +/// rendered at rest (a value's type is apparent from the value), shown +/// only as the edit-time label and in the devtools dump. +#[derive(Clone, PartialEq)] +struct PropRow { + key: String, + value: String, + type_name: &'static str, + /// The referenced node when the value is a Ref — display-mode value + /// cells and summary spans navigate to it on click, like references + /// everywhere else. `None` while a row is a draft of the editing + /// surface, where clicking cells moves the live field instead. + ref_target: Option, +} + /// A single visible outline row: a block's id, its depth for indentation, /// and its content parsed into renderable Markdown blocks. struct VisibleRow { @@ -196,6 +238,14 @@ struct VisibleRow { /// Whether this row is its parent's first child — where its parent's /// guide line begins, which gets a small top inset. is_first_child: bool, + /// The block's user-visible properties (machine keys filtered, sorted + /// by key), rendered as the property grid below the content (spec: + /// property-authoring/"Properties render colocated with their block"). + properties: Vec, + /// The grid's own disclosure state (spec: property-authoring/ + /// "Independent grid disclosure") — collapsing it never hides + /// descendants, unlike `folded`. + properties_folded: bool, } /// One entry in the backlinks panel: a referencing block, its containing @@ -225,6 +275,65 @@ struct FocusedEditor { _content_observation: Subscription, } +/// Which field of a grid row holds the live input. +#[derive(Clone, Copy, PartialEq, Debug)] +enum GridField { + Key, + Value, +} + +/// One draft row of the grid editing surface: the committed key it +/// corresponds to (`None` for a fresh, never-persisted row), the +/// in-progress key/value strings, and the value string as loaded — an +/// untouched value never re-commits, so inference can't silently re-type +/// a stored value that merely gained and lost focus. +struct GridDraftRow { + original_key: Option, + key: String, + value: String, + original_value: String, + type_name: &'static str, +} + +impl GridDraftRow { + fn fresh() -> Self { + GridDraftRow { + original_key: None, + key: String::new(), + value: String::new(), + original_value: String::new(), + type_name: "", + } + } + + fn from_committed(p: PropRow) -> Self { + GridDraftRow { + original_key: Some(p.key.clone()), + key: p.key, + original_value: p.value.clone(), + value: p.value, + type_name: p.type_name, + } + } +} + +/// The property-grid editing surface (spec: property-authoring/"The grid +/// is a separate editing surface"). At most one exists at a time, and +/// opening it closes the block editor — the one-hot editing model spans +/// both surfaces, so there is still only ever one live text input. The +/// input entity is recreated per field move (the `focus_block` pattern); +/// dropping the previous subscription first keeps the outgoing editor's +/// blur from being interpreted as an exit. +struct GridEditState { + block: TreeID, + rows: Vec, + row_ix: usize, + field: GridField, + input: Entity, + focus_handle: FocusHandle, + _event_subscription: Subscription, +} + /// State for the Ctrl+K fuzzy switcher (spec: "Quick open" — fuzzy switcher /// over page/tag names including journal dates). /// What confirming a quick-open selection does. @@ -261,7 +370,16 @@ struct QuickOpenState { /// spot for future per-node actions. struct ContextMenuState { position: gpui::Point, - target: NodeId, + target: MenuTarget, +} + +/// What a context menu acts on: a node (bullets, headings, pinned rows — +/// pin/unpin plus per-node actions), or one row of a block's property grid +/// (openspec change property-authoring). +#[derive(Clone)] +enum MenuTarget { + Node(NodeId), + PropertyRow { block: TreeID, key: String }, } /// State for the Ctrl+F full-text search overlay (spec: "Search and jump"). @@ -338,6 +456,14 @@ struct TrawlerApp { /// non-root `NodeId::Tree`. breadcrumb: Vec<(TreeID, String)>, editor: Option, + /// The open property-grid editing surface, if any (openspec change + /// property-authoring) — see [`GridEditState`]. + grid_edit: Option, + /// The block whose grid disclosure was last toggled, with a + /// generation that names the toggle: its chevron plays the + /// quarter-turn once under that identity, while every other chevron + /// (and re-created list elements on scroll) renders statically. + chevron_anim: Option<(TreeID, usize)>, /// Blocks whose descendants are hidden (spec: "Fold hides descendants"). folded: HashSet, quick_open: Option, @@ -537,6 +663,8 @@ impl TrawlerApp { backlinks: Vec::new(), breadcrumb: Vec::new(), editor: None, + grid_edit: None, + chevron_anim: None, folded, quick_open: None, quick_open_focus: cx.focus_handle(), @@ -1620,7 +1748,7 @@ impl TrawlerApp { /// any focused editor, exactly like clicking away to another block. fn open_context_menu( &mut self, - target: NodeId, + target: MenuTarget, position: gpui::Point, window: &mut Window, cx: &mut Context, @@ -1692,7 +1820,12 @@ impl TrawlerApp { .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { - this.open_context_menu(menu_target.clone(), event.position, window, cx); + this.open_context_menu( + MenuTarget::Node(menu_target.clone()), + event.position, + window, + cx, + ); }), ) }); @@ -2440,6 +2573,11 @@ impl TrawlerApp { .position(|r| r.id == id) .is_none_or(|ix| ix >= self.list_state.logical_scroll_top().item_ix) }); + // A lingering property-grid surface commits and closes before the + // block editor attaches (openspec change property-authoring) — + // its input's blur would close it anyway, but doing it here keeps + // the ordering deterministic. + self.grid_close(cx); self.commit_editor(cx); let content = Outline::new(self.storage.doc()) @@ -2838,43 +2976,545 @@ impl TrawlerApp { }; let input = editor.input.clone(); let index = GraphIndex::rebuild(self.storage.doc()); - let query_lower = query.to_lowercase(); + let candidates = reference_candidates(&index, trigger, &query.to_lowercase()); + input.update(cx, |editor, cx| { + editor.set_completion_candidates(trigger, &query, candidates, cx); + }); + } + + // --- property grid (openspec change property-authoring) -------------- - let candidates = match trigger { - '[' => { - let mut candidates: Vec = index - .pages_by_name - .keys() - .filter(|name| fuzzy_contains(&name.to_lowercase(), &query_lower)) - .map(|name| CompletionCandidate { - display: name.clone(), - insert_text: name.clone(), - suffix: "]]".to_string(), - }) - .collect(); - candidates.extend(date_candidates(&query_lower)); - candidates.sort_by(|a, b| a.display.cmp(&b.display)); - candidates.truncate(8); - candidates + /// Ctrl+Shift+P: open the focused block's property grid for editing — + /// or, with the grid already open, exit back to the block (spec: + /// property-authoring/"The grid is a separate editing surface"). + fn edit_properties(&mut self, _: &EditProperties, window: &mut Window, cx: &mut Context) { + if self.grid_edit.is_some() { + self.grid_exit(true, window, cx); + return; + } + let Some(block) = self.editor.as_ref().map(|e| e.block) else { + return; + }; + self.open_property_grid_default(block, window, cx); + } + + /// Open the grid at its natural entry field: the first row's value + /// for a block with named properties, the key of the fresh row + /// otherwise — mirroring the tab flow's value-first stops. + fn open_property_grid_default( + &mut self, + block: TreeID, + window: &mut Window, + cx: &mut Context, + ) { + let outline = Outline::new(self.storage.doc()); + let field = match visible_prop_rows(&outline, block).first() { + Some(p) if !p.key.trim().is_empty() => GridField::Value, + _ => GridField::Key, + }; + self.open_property_grid_at(block, 0, field, window, cx); + } + + /// Ctrl+Alt+P: toggle the focused block's grid disclosure (spec: + /// property-authoring/"Independent grid disclosure"). + fn toggle_properties_fold_focused( + &mut self, + _: &TogglePropertiesFold, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(block) = self + .grid_edit + .as_ref() + .map(|g| g.block) + .or_else(|| self.editor.as_ref().map(|e| e.block)) + else { + return; + }; + self.toggle_properties_fold(block, cx); + } + + fn toggle_properties_fold(&mut self, block: TreeID, cx: &mut Context) { + let outline = Outline::new(self.storage.doc()); + // Nothing to disclose on a block with no visible grid. + if visible_prop_rows(&outline, block).is_empty() { + return; + } + let current = outline.properties_folded(block).unwrap_or(false); + outline + .set_properties_folded(block, !current) + .expect("block still exists"); + self.storage + .persist_update() + .expect("persist grid disclosure"); + let generation = self + .chevron_anim + .map(|(_, g)| g.wrapping_add(1)) + .unwrap_or(0); + self.chevron_anim = Some((block, generation)); + self.refresh_view_data(); + cx.notify(); + } + + /// Open the grid editing surface for `block` at the given row/field. + /// Entering a block with no properties presents one fresh empty row + /// (spec scenario: "Adding the first property"). One-hot across + /// surfaces: any open grid commits and closes first, then the block + /// editor commits — there is never more than one live input. + fn open_property_grid_at( + &mut self, + block: TreeID, + row_ix: usize, + field: GridField, + window: &mut Window, + cx: &mut Context, + ) { + let arriving_from = self + .grid_edit + .as_ref() + .and_then(|g| g.input.read(cx).caret_window_bounds()) + .or_else(|| { + self.editor + .as_ref() + .and_then(|e| e.input.read(cx).caret_window_bounds()) + }); + if self.grid_edit.is_some() { + self.grid_close(cx); + } + self.commit_editor(cx); + + let outline = Outline::new(self.storage.doc()); + let mut rows: Vec = visible_prop_rows(&outline, block) + .into_iter() + .map(GridDraftRow::from_committed) + .collect(); + if rows.is_empty() { + rows.push(GridDraftRow::fresh()); + } + let row_ix = row_ix.min(rows.len() - 1); + let text = match field { + GridField::Key => rows[row_ix].key.clone(), + GridField::Value => rows[row_ix].value.clone(), + }; + // Cross-surface caret glide: hand the departing caret (the block + // editor's, or a previous grid field's) to the fresh input, the + // same handoff `focus_block_at` does between blocks. + let (input, focus_handle, subscription) = + self.grid_make_input(text, field == GridField::Key, window, cx); + if let Some(from) = arriving_from { + input.update(cx, |editor, _| editor.seed_caret_glide(from)); + } + self.grid_edit = Some(GridEditState { + block, + rows, + row_ix, + field, + input, + focus_handle: focus_handle.clone(), + _event_subscription: subscription, + }); + window.focus(&focus_handle); + cx.notify(); + } + + /// Build a grid field's input: a fresh `BlockEditor` (the same + /// construct-per-focus pattern as `focus_block`) whose events are + /// reinterpreted for form semantics — tab/shift-tab advance fields, + /// enter commits, blur closes the surface. NOT focused here: callers + /// install it into `grid_edit` first (dropping the previous field's + /// subscription) and only then focus, so the outgoing field's blur + /// can never be mistaken for leaving the surface. + fn grid_make_input( + &mut self, + text: String, + bare_completion: bool, + window: &mut Window, + cx: &mut Context, + ) -> (Entity, FocusHandle, Subscription) { + let input = cx.new(|cx| BlockEditor::new(window, cx, text)); + if bare_completion { + input.update(cx, |editor, _| editor.set_complete_bare(true)); + } + let subscription = cx.subscribe(&input, |this: &mut Self, _input, event, cx| match event { + BlockEditorEvent::Blurred => this.grid_close(cx), + BlockEditorEvent::Indent => this.grid_defer_advance(1, cx), + BlockEditorEvent::Outdent => this.grid_defer_advance(-1, cx), + BlockEditorEvent::SplitRequested => this.grid_defer_enter(cx), + BlockEditorEvent::CompletionQueryChanged { trigger, query } => { + this.grid_update_completions(*trigger, query.clone(), cx); } - '#' => { - let mut candidates: Vec = index - .tags - .keys() - .filter(|name| fuzzy_contains(&name.to_lowercase(), &query_lower)) - .map(|name| CompletionCandidate { - display: format!("#{name}"), - insert_text: name.clone(), - suffix: String::new(), - }) - .collect(); - candidates.sort_by(|a, b| a.display.cmp(&b.display)); - candidates.truncate(8); - candidates + BlockEditorEvent::FollowReference(kind) => { + let kind = kind.clone(); + let entity = cx.entity(); + let handle = this.window_handle; + cx.defer(move |cx| { + let _ = handle.update(cx, move |_, window, cx| { + entity.update(cx, |this, cx| { + this.navigate_to_reference(kind, window, cx); + }); + }); + }); } - _ => Vec::new(), + _ => {} + }); + let focus_handle = input.read(cx).focus_handle(cx); + (input, focus_handle, subscription) + } + + /// Tab/shift-tab need a `Window` to attach the next field's input; + /// editor events don't carry one — same defer-through-the-window-handle + /// pattern as `FollowReference`. + fn grid_defer_advance(&mut self, dir: i32, cx: &mut Context) { + let entity = cx.entity(); + let handle = self.window_handle; + cx.defer(move |cx| { + let _ = handle.update(cx, move |_, window, cx| { + entity.update(cx, |this, cx| this.grid_advance(dir, window, cx)); + }); + }); + } + + fn grid_defer_enter(&mut self, cx: &mut Context) { + let entity = cx.entity(); + let handle = self.window_handle; + cx.defer(move |cx| { + let _ = handle.update(cx, move |_, window, cx| { + entity.update(cx, |this, cx| this.grid_enter(window, cx)); + }); + }); + } + + /// Move the live input one field forward or backward. Values are the + /// tab stops — an existing property's key is skipped (once supertag + /// stubs land, tabbing through names would double the keypresses to + /// fill a block in); a key field is only entered when that row has no + /// name yet (a fresh row). Tab from the last value appends a fresh + /// row (key focused, it needs a name); shift-tab from the first value + /// stops. Named keys are reached by clicking them. + fn grid_advance(&mut self, dir: i32, window: &mut Window, cx: &mut Context) { + self.grid_read_field_into_draft(cx); + let Some(grid) = self.grid_edit.as_mut() else { + return; }; + let (row_ix, field) = (grid.row_ix, grid.field); + let next = if dir > 0 { + match field { + GridField::Key => Some((row_ix, GridField::Value)), + GridField::Value if row_ix + 1 < grid.rows.len() => { + let unnamed = grid.rows[row_ix + 1].key.trim().is_empty(); + Some(( + row_ix + 1, + if unnamed { + GridField::Key + } else { + GridField::Value + }, + )) + } + GridField::Value => { + grid.rows.push(GridDraftRow::fresh()); + Some((row_ix + 1, GridField::Key)) + } + } + } else { + match field { + GridField::Value if grid.rows[row_ix].key.trim().is_empty() => { + Some((row_ix, GridField::Key)) + } + GridField::Value if row_ix > 0 => Some((row_ix - 1, GridField::Value)), + GridField::Value => None, + GridField::Key if row_ix > 0 => Some((row_ix - 1, GridField::Value)), + GridField::Key => None, + } + }; + let Some((new_row, new_field)) = next else { + return; + }; + if new_row != row_ix { + self.grid_persist_row_at(row_ix, cx); + } + self.grid_focus_field(new_row, new_field, window, cx); + } + + /// Enter: advance like tab, except from the last row's value field it + /// commits and returns to the block — the form-submit gesture. + fn grid_enter(&mut self, window: &mut Window, cx: &mut Context) { + let Some(grid) = self.grid_edit.as_ref() else { + return; + }; + let at_last_value = grid.field == GridField::Value && grid.row_ix + 1 == grid.rows.len(); + if at_last_value { + self.grid_exit(true, window, cx); + } else { + self.grid_advance(1, window, cx); + } + } + + /// Re-seed the live input onto (`row_ix`, `field`). The new input is + /// installed (dropping the old field's subscription) *before* focus + /// moves — see `grid_make_input`'s doc. + fn grid_focus_field( + &mut self, + row_ix: usize, + field: GridField, + window: &mut Window, + cx: &mut Context, + ) { + let Some(grid) = self.grid_edit.as_mut() else { + return; + }; + grid.row_ix = row_ix; + grid.field = field; + let text = match field { + GridField::Key => grid.rows[row_ix].key.clone(), + GridField::Value => grid.rows[row_ix].value.clone(), + }; + let departing_caret = grid.input.read(cx).caret_window_bounds(); + let (input, focus_handle, subscription) = + self.grid_make_input(text, field == GridField::Key, window, cx); + if let Some(from) = departing_caret { + input.update(cx, |editor, _| editor.seed_caret_glide(from)); + } + let Some(grid) = self.grid_edit.as_mut() else { + return; + }; + grid.input = input; + grid.focus_handle = focus_handle.clone(); + grid._event_subscription = subscription; + window.focus(&focus_handle); + cx.notify(); + } + + /// Copy the live input's text into the draft row it edits. + fn grid_read_field_into_draft(&mut self, cx: &mut Context) { + let Some(grid) = self.grid_edit.as_ref() else { + return; + }; + let text = grid.input.read(cx).value().to_string(); + let (row_ix, field) = (grid.row_ix, grid.field); + let Some(grid) = self.grid_edit.as_mut() else { + return; + }; + let Some(draft) = grid.rows.get_mut(row_ix) else { + return; + }; + match field { + GridField::Key => draft.key = text, + GridField::Value => draft.value = text, + } + } + + /// Persist one draft row's edits, if any (spec: property-authoring/ + /// "Values are typed by inference at commit"): + /// - a fresh row persists only with a non-empty key *and* value — + /// committing a key with an empty value stores nothing; + /// - an existing row re-persists when renamed (remove old + set new) + /// or when its value text was actually edited — an untouched field + /// never rewrites, so a stored Text("true") isn't silently re-typed + /// as a bool by mere focus traffic; + /// - an emptied key on an existing row reverts (deletion is the + /// explicit gesture, spec: "Deletion is an explicit gesture"); + /// - machine keys can't be written from the grid. + fn grid_persist_row_at(&mut self, row_ix: usize, cx: &mut Context) { + let Some(grid) = self.grid_edit.as_ref() else { + return; + }; + let block = grid.block; + let Some(draft) = grid.rows.get(row_ix) else { + return; + }; + let original_key = draft.original_key.clone(); + let key = draft.key.trim().to_string(); + let value = draft.value.clone(); + let value_edited = value != draft.original_value; + let key_is_machine = MACHINE_PROPERTY_KEYS.contains(&key.as_str()); + + let outline = Outline::new(self.storage.doc()); + let mut committed_key: Option = original_key.clone(); + let mut changed = false; + match &original_key { + None => { + if !key.is_empty() && !value.is_empty() && !key_is_machine { + let inferred = + infer_property_value(&value, &GraphIndex::rebuild(self.storage.doc())); + outline + .set_property(block, &key, &inferred) + .expect("block still exists"); + committed_key = Some(key.clone()); + changed = true; + } + } + Some(old) => { + let renamed = !key.is_empty() && key != *old && !key_is_machine; + if renamed || value_edited { + let inferred = + infer_property_value(&value, &GraphIndex::rebuild(self.storage.doc())); + if renamed { + outline + .remove_property(block, old) + .expect("block still exists"); + committed_key = Some(key.clone()); + } + let write_key = committed_key.as_deref().unwrap_or(old); + outline + .set_property(block, write_key, &inferred) + .expect("block still exists"); + changed = true; + } + } + } + + if changed { + self.storage + .persist_update() + .expect("persist property edit"); + } + // Re-sync the draft's bookkeeping from what's now stored: the + // committed key, the canonical display string (inference may + // normalize, e.g. "007" → "7"), and the type glyph. + let outline = Outline::new(self.storage.doc()); + if let Some(grid) = self.grid_edit.as_mut() { + if let Some(draft) = grid.rows.get_mut(row_ix) { + draft.original_key = committed_key.clone(); + if draft.key.trim().is_empty() { + if let Some(k) = &committed_key { + draft.key = k.clone(); + } + } + if let Some(k) = &committed_key { + if let Some(stored) = + outline.properties(block).ok().and_then(|mut p| p.remove(k)) + { + let (display, type_name) = prop_value_display(&stored, &outline); + draft.value = display; + draft.type_name = type_name; + } + } + draft.original_value = draft.value.clone(); + } + } + if changed { + self.refresh_view_data(); + cx.notify(); + } + } + + /// Tear down the surface: commit the live field and every draft row, + /// then drop the editing state. Untouched fresh rows persist nothing + /// and simply vanish (spec scenario: "Adding the first property" — + /// enter-and-escape creates nothing). Windowless so the blur path can + /// run it; idempotent. + fn grid_close(&mut self, cx: &mut Context) { + if self.grid_edit.is_none() { + return; + } + self.grid_read_field_into_draft(cx); + let count = self.grid_edit.as_ref().map(|g| g.rows.len()).unwrap_or(0); + for row_ix in 0..count { + self.grid_persist_row_at(row_ix, cx); + } + self.grid_edit = None; + cx.notify(); + } + + /// Close the surface; `refocus` returns focus to the block (the + /// escape/enter path — spec: "escape SHALL return focus to the + /// block"), while the blur path leaves focus where the user put it. + fn grid_exit(&mut self, refocus: bool, window: &mut Window, cx: &mut Context) { + let block = self.grid_edit.as_ref().map(|g| g.block); + let departing_caret = self + .grid_edit + .as_ref() + .and_then(|g| g.input.read(cx).caret_window_bounds()); + self.grid_close(cx); + if refocus { + if let Some(block) = block { + self.focus_block(block, window, cx); + // Glide the caret home to the block editor, closing the + // loop the surface's entry glide opened. + if let (Some(from), Some(editor)) = (departing_caret, self.editor.as_ref()) { + editor + .input + .update(cx, |editor, _| editor.seed_caret_glide(from)); + } + } + } + } + /// Shift+Delete on the live row: remove its property (spec: + /// "Deletion is an explicit gesture") and keep editing — the surface + /// stays open on the nearest surviving row, or on a fresh empty row + /// when the last one went. + fn grid_delete_row_action( + &mut self, + _: &GridDeleteRow, + window: &mut Window, + cx: &mut Context, + ) { + let Some(grid) = self.grid_edit.as_mut() else { + return; + }; + let block = grid.block; + let row_ix = grid.row_ix; + if row_ix >= grid.rows.len() { + return; + } + let removed = grid.rows.remove(row_ix); + if grid.rows.is_empty() { + grid.rows.push(GridDraftRow::fresh()); + } + let new_row = row_ix.min(grid.rows.len() - 1); + if let Some(old_key) = removed.original_key { + self.delete_property(block, &old_key, cx); + } + self.grid_focus_field(new_row, GridField::Key, window, cx); + } + + /// Remove `key` from `block` — the context-menu delete and the grid's + /// shift-delete both land here. + fn delete_property(&mut self, block: TreeID, key: &str, cx: &mut Context) { + let outline = Outline::new(self.storage.doc()); + outline + .remove_property(block, key) + .expect("block still exists"); + self.storage + .persist_update() + .expect("persist property delete"); + self.refresh_view_data(); + cx.notify(); + } + + /// Completion inside the grid: `'\0'` is the key field's bare mode, + /// completed against the graph's existing property vocabulary (spec: + /// "Key fields SHALL offer completion..."); `[[`/`#` in the value + /// field reuses the ordinary reference candidates. + fn grid_update_completions(&mut self, trigger: char, query: String, cx: &mut Context) { + let Some(grid) = self.grid_edit.as_ref() else { + return; + }; + let input = grid.input.clone(); + let index = GraphIndex::rebuild(self.storage.doc()); + let query_lower = query.to_lowercase(); + let candidates = if trigger == '\0' { + let mut keys: Vec = index + .properties + .keys() + .filter(|k| !MACHINE_PROPERTY_KEYS.contains(&k.as_str())) + .filter(|k| fuzzy_contains(&k.to_lowercase(), &query_lower)) + .cloned() + .collect(); + keys.sort(); + keys.truncate(8); + keys.into_iter() + .map(|k| CompletionCandidate { + display: k.clone(), + insert_text: k, + suffix: String::new(), + }) + .collect() + } else { + reference_candidates(&index, trigger, &query_lower) + }; input.update(cx, |editor, cx| { editor.set_completion_candidates(trigger, &query, candidates, cx); }); @@ -2887,6 +3527,139 @@ fn fuzzy_contains(haystack_lower: &str, query_lower: &str) -> bool { query_lower.is_empty() || haystack_lower.contains(query_lower) } +/// Completion candidates for a `[[`/`#` reference trigger (spec: +/// outline-editor "Reference completion") — shared between block editing +/// and the property grid's value field. +fn reference_candidates( + index: &GraphIndex, + trigger: char, + query_lower: &str, +) -> Vec { + match trigger { + '[' => { + let mut candidates: Vec = index + .pages_by_name + .keys() + .filter(|name| fuzzy_contains(&name.to_lowercase(), query_lower)) + .map(|name| CompletionCandidate { + display: name.clone(), + insert_text: name.clone(), + suffix: "]]".to_string(), + }) + .collect(); + candidates.extend(date_candidates(query_lower)); + candidates.sort_by(|a, b| a.display.cmp(&b.display)); + candidates.truncate(8); + candidates + } + '#' => { + let mut candidates: Vec = index + .tags + .keys() + .filter(|name| fuzzy_contains(&name.to_lowercase(), query_lower)) + .map(|name| CompletionCandidate { + display: format!("#{name}"), + insert_text: name.clone(), + suffix: String::new(), + }) + .collect(); + candidates.sort_by(|a, b| a.display.cmp(&b.display)); + candidates.truncate(8); + candidates + } + _ => Vec::new(), + } +} + +/// What a value string would be typed as, before any graph resolution — +/// the single classifier behind [`infer_property_value`] (storage) and +/// [`value_type_name`] (the edit-time type label), so the label can never +/// disagree with what commit will store. +enum ValueClass { + Date(NaiveDate), + Bool(bool), + Number(f64), + RefDate(NaiveDate), + RefPage(String), + RefTag(String), + Text, +} + +fn classify_value(raw: &str) -> ValueClass { + let trimmed = raw.trim(); + if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + return ValueClass::Date(date); + } + if trimmed == "true" { + return ValueClass::Bool(true); + } + if trimmed == "false" { + return ValueClass::Bool(false); + } + if let Ok(n) = trimmed.parse::() { + if n.is_finite() { + return ValueClass::Number(n); + } + } + if let Some(inner) = trimmed + .strip_prefix("[[") + .and_then(|s| s.strip_suffix("]]")) + { + if !inner.is_empty() && !inner.contains("[[") && !inner.contains("]]") { + if let Ok(date) = NaiveDate::parse_from_str(inner, "%Y-%m-%d") { + return ValueClass::RefDate(date); + } + return ValueClass::RefPage(inner.to_string()); + } + } + if let Some(name) = trimmed.strip_prefix('#') { + if !name.is_empty() + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { + return ValueClass::RefTag(name.to_string()); + } + } + ValueClass::Text +} + +/// Best-effort typing of a committed value string (spec: property- +/// authoring/"Values are typed by inference at commit", matching +/// add-supertags D4's ad-hoc semantics): ISO date → Date, `true`/`false` +/// → Bool, parseable finite number → Number, a completed `[[…]]`/`#tag` +/// reference → Ref (resolved exactly like an in-content reference), +/// anything else → Text as typed. +fn infer_property_value(raw: &str, index: &GraphIndex) -> PropertyValue { + match classify_value(raw) { + ValueClass::Date(d) => PropertyValue::Date(d), + ValueClass::Bool(b) => PropertyValue::Bool(b), + ValueClass::Number(n) => PropertyValue::Number(n), + ValueClass::RefDate(d) => PropertyValue::Ref(NodeId::date(d)), + ValueClass::RefPage(name) => { + PropertyValue::Ref(index.resolve_reference(ReferenceKind::Page(name))) + } + ValueClass::RefTag(name) => { + PropertyValue::Ref(index.resolve_reference(ReferenceKind::Tag(name))) + } + ValueClass::Text => PropertyValue::Text(raw.to_string()), + } +} + +/// The type name the edit-time label shows for an in-progress value +/// string. The grid shows no per-row type annotations at rest — a +/// value's type is apparent from the value itself; the label appears +/// while the property is being edited. +fn value_type_name(raw: &str) -> &'static str { + match classify_value(raw) { + ValueClass::Date(_) => "date", + ValueClass::Bool(_) => "boolean", + ValueClass::Number(_) => "number", + ValueClass::RefDate(_) | ValueClass::RefPage(_) | ValueClass::RefTag(_) => "reference", + ValueClass::Text => "text", + } +} + /// Journal date shortcuts offered alongside page candidates when typing a /// `[[` reference (spec: "date completion for journal refs"). fn date_candidates(query_lower: &str) -> Vec { @@ -3093,6 +3866,8 @@ fn push_subtree_inner( raw_content: content, guides: continues.clone(), is_first_child, + properties: visible_prop_rows(outline, id), + properties_folded: outline.properties_folded(id).unwrap_or(false), }); if folded.contains(&id) { return; @@ -3110,6 +3885,62 @@ fn push_subtree_inner( } } +/// A block's user-visible property rows: machine keys filtered out (spec: +/// property-authoring/"Machine properties are hidden from the grid"), +/// sorted by key for a deterministic order (the underlying Loro map is +/// unordered), values in their editable string form. +fn visible_prop_rows(outline: &Outline, id: TreeID) -> Vec { + let Ok(props) = outline.properties(id) else { + return Vec::new(); + }; + let mut rows: Vec = props + .into_iter() + .filter(|(key, _)| !MACHINE_PROPERTY_KEYS.contains(&key.as_str())) + .map(|(key, value)| { + let ref_target = match &value { + PropertyValue::Ref(id) => Some(id.clone()), + _ => None, + }; + let (value, type_name) = prop_value_display(&value, outline); + PropRow { + key, + value, + type_name, + ref_target, + } + }) + .collect(); + rows.sort_by(|a, b| a.key.cmp(&b.key)); + rows +} + +/// A property value's editable string form and type glyph. The string is +/// what the grid shows *and* what seeds the value field when the row is +/// edited, so it must survive `infer_property_value` unchanged — display +/// and re-commit round-trip. +fn prop_value_display(value: &PropertyValue, outline: &Outline) -> (String, &'static str) { + match value { + PropertyValue::Text(s) => (s.clone(), "text"), + PropertyValue::Number(n) => (n.to_string(), "number"), + PropertyValue::Date(d) => (d.to_string(), "date"), + PropertyValue::Bool(b) => (b.to_string(), "boolean"), + PropertyValue::Ref(NodeId::Tag(name)) => (format!("#{name}"), "reference"), + PropertyValue::Ref(NodeId::Date(d)) => (format!("[[{d}]]"), "reference"), + PropertyValue::Ref(id @ NodeId::Tree(raw)) => { + // Show the referenced node by name where possible — its + // content's first line — falling back to the raw id for a + // dangling reference. + let name = id + .as_tree_id() + .and_then(|tid| outline.content(tid).ok()) + .map(|c| c.lines().next().unwrap_or_default().to_string()) + .filter(|line| !line.is_empty()) + .unwrap_or_else(|| raw.clone()); + (format!("[[{name}]]"), "reference") + } + } +} + /// Whether `id` has the `query` property set to `true` (spec: steel- /// queries/"Query blocks live in the outline"). fn is_query_block(outline: &Outline, id: TreeID) -> bool { @@ -3355,6 +4186,18 @@ fn apply_scroll_target(list_state: &ListState, target: ScrollTarget) { // blue-tinted lift, so the card doesn't fight the green accent family. const QUERY_BG: u32 = 0x181818; const QUERY_ACCENT_COLOR: u32 = THREAD_COLOR; +/// Fixed width of the property grid's key column (openspec change +/// property-authoring) — keys align across rows; values take the rest. +const PROP_KEY_WIDTH: f32 = 140.0; +/// The property band's ground: a *raised* second neutral (between the app +/// ground and the hover wash), unlike the query RESULT card's recess — +/// recessing a full-width band made the properties pop harder than the +/// block content it belongs to, exactly backwards. Raised-panel neutrals +/// are the product-register treatment for structural chrome. +const PROP_BAND_BG: u32 = 0x232323; +/// Property values render a step below body text so the grid reads as +/// detail under the block, never competing with the content above it. +const PROP_VALUE_COLOR: u32 = 0xd8d8d8; /// Blank space rendered as a synthetic trailing row after the real outline /// rows, so the last block can be scrolled up away from the window's bottom /// edge instead of staying glued to it while editing. Must stay ≥ the @@ -3385,6 +4228,297 @@ fn render_scheme_source(text: &str) -> gpui::AnyElement { .into_any_element() } +/// Whether a row's property grid renders at all: a folded block hides its +/// grid along with its descendants (spec scenario: "Block fold hides the +/// grid"), and an unfolded one shows it when there are visible properties +/// or the editing surface targets it. +fn grid_renders(folded: bool, properties: &[PropRow], editing: bool) -> bool { + !folded && (!properties.is_empty() || editing) +} + +/// Render a block's property grid beneath its content (openspec change +/// property-authoring), as a full-width recessed band — the query source +/// row's treatment (background only, no border, full row width), not a +/// floating card, so containment reads as part of the outline's row +/// system. Expanded: a Tabler chevron-down sits in the bullet gutter with +/// the key/value rows directly beside it, like an indented child. +/// Collapsed: one line summarizes the properties inline. Rows carry no +/// type annotation at rest; while the editing surface is open, the live +/// row shows a type label reflecting what commit will store. Every row +/// floors at `line_height` — the same trick block rows use — so moving +/// the live input between fields never shifts layout. +#[allow(clippy::too_many_arguments)] +fn render_property_grid( + block: TreeID, + properties: &[PropRow], + properties_folded: bool, + grid_edit: Option<&GridEditSnapshot>, + indent_depth: usize, + row_key: usize, + line_height: gpui::Pixels, + chevron_anim: Option, + cx: &mut Context, +) -> gpui::AnyElement { + let editing = grid_edit.is_some(); + let display_rows: Vec = match grid_edit { + Some(g) => g.rows.clone(), + None => properties.to_vec(), + }; + let collapsed = properties_folded && !editing; + + // The disclosure chevron occupies the bullet gutter (same 14px column + // as outline bullets) so the rows align like a child block's content. + // One glyph, rotated by state: right when collapsed, a quarter turn + // down when expanded. The turn animates exactly once per toggle (the + // `chevron_anim` identity), in the register of the app's other + // micro-motions, and renders statically otherwise — including when + // the animation knobs are zeroed for reduced motion or tests. + let chevron = |collapsed: bool, cx: &mut Context| { + let base = crate::assets::icon("icons/chevron-right.svg") + .size(px(12.0)) + .text_color(rgb(MUTED_COLOR)); + let (from, to) = if collapsed { (0.25, 0.0) } else { (0.0, 0.25) }; + let icon: gpui::AnyElement = match chevron_anim.filter(|_| thread_animation_ms() > 0.0) { + Some(generation) => base + .with_animation( + ("props-chevron-turn", row_key * 1024 + generation), + gpui::Animation::new(std::time::Duration::from_millis(140)) + .with_easing(|t| 1.0 - (1.0 - t).powi(3)), + move |icon, delta| { + icon.with_transformation(gpui::Transformation::rotate(gpui::percentage( + from + (to - from) * delta, + ))) + }, + ) + .into_any_element(), + None => base + .with_transformation(gpui::Transformation::rotate(gpui::percentage(to))) + .into_any_element(), + }; + div() + .id(("props-chevron", row_key)) + .w(px(14.0)) + .h(line_height) + .flex() + .flex_none() + .items_center() + .justify_center() + .cursor_pointer() + .child(icon) + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.toggle_properties_fold(block, cx); + })) + }; + + let grid = div() + .key_context(PROPERTY_GRID_CONTEXT) + .on_action(cx.listener(|this, _: &GridExit, window, cx| { + this.grid_exit(true, window, cx); + })) + .on_action(cx.listener(|this, action: &GridDeleteRow, window, cx| { + this.grid_delete_row_action(action, window, cx); + })) + .w_full() + .relative() + .pl(px( + INDENT_PER_LEVEL * (indent_depth as f32 + 1.0) + OUTLINE_LEFT_PAD + )) + .pr_2() + .py_0p5() + .bg(rgb(PROP_BAND_BG)) + .flex() + .flex_row() + .items_start() + .gap(px(BULLET_TEXT_GAP)) + .text_size(px(12.0)); + + if collapsed { + // Inline summary instead of a count: the keys and values on one + // clipped line, punctuation-separated — collapsed still answers + // *what is here*, not just *how much*. + let mut summary: Vec = Vec::new(); + for (i, p) in display_rows.iter().enumerate() { + if i > 0 { + summary.push( + div() + .flex_none() + .text_color(rgb(MUTED_COLOR)) + .child("\u{00b7}") + .into_any_element(), + ); + } + summary.push( + div() + .flex_none() + .text_color(rgb(MUTED_COLOR)) + .child(p.key.clone()) + .into_any_element(), + ); + if !p.value.is_empty() { + let span = div() + .flex_none() + .text_color(rgb(if p.type_name == "reference" { + REFERENCE_COLOR + } else { + PROP_VALUE_COLOR + })) + .child(p.value.clone()); + summary.push(match p.ref_target.clone() { + Some(target) => span + .id(("props-sum-ref", row_key * 1024 + i)) + .cursor_pointer() + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { + cx.stop_propagation(); + this.navigate_to(View::Node(target.clone()), None, window, cx); + })) + .into_any_element(), + None => span.into_any_element(), + }); + } + } + return grid + .child(chevron(true, cx)) + .child( + div() + .id(("props-summary", row_key)) + .flex() + .flex_row() + .items_center() + .gap_1p5() + .flex_1() + .min_w_0() + .min_h(line_height) + .overflow_hidden() + .cursor_pointer() + .children(summary) + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.toggle_properties_fold(block, cx); + })), + ) + .into_any_element(); + } + + let toggle = chevron(false, cx); + let rows: Vec = display_rows + .into_iter() + .enumerate() + .map(|(i, p)| { + let live = |field: GridField| { + grid_edit + .filter(|g| g.row_ix == i && g.field == field) + .map(|g| g.input.clone()) + }; + let key_cell: gpui::AnyElement = match live(GridField::Key) { + Some(input) => div() + .w(px(PROP_KEY_WIDTH)) + .flex_none() + .min_h(line_height) + .flex() + .items_center() + .child(div().w_full().child(input)) + .into_any_element(), + None => div() + .id(("prop-key", row_key * 1024 + i)) + .w(px(PROP_KEY_WIDTH)) + .flex_none() + .text_color(rgb(MUTED_COLOR)) + .cursor_pointer() + .child(p.key.clone()) + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.open_property_grid_at(block, i, GridField::Key, window, cx); + })) + .into_any_element(), + }; + let value_cell: gpui::AnyElement = match live(GridField::Value) { + Some(input) => div() + .flex_1() + .min_w_0() + .min_h(line_height) + .flex() + .items_center() + .child(div().w_full().child(input)) + .into_any_element(), + None => { + let cell = div() + .id(("prop-value", row_key * 1024 + i)) + .flex_1() + .min_w_0() + .text_color(rgb(if p.type_name == "reference" { + REFERENCE_COLOR + } else { + PROP_VALUE_COLOR + })) + .cursor_pointer() + .child(p.value.clone()); + match p.ref_target.clone() { + // A reference navigates, like references anywhere + // else; the row is still editable via its key + // cell or the keyboard. + Some(target) => cell + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.navigate_to(View::Node(target.clone()), None, window, cx); + })) + .into_any_element(), + None => cell + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.open_property_grid_at(block, i, GridField::Value, window, cx); + })) + .into_any_element(), + } + } + }; + // Edit-time type label: only the live row carries one, + // tracking the value text as typed so the user sees what + // commit will store before it happens. + let type_label = grid_edit.filter(|g| g.row_ix == i).map(|g| { + let name = if g.field == GridField::Value { + value_type_name(g.input.read(cx).value()) + } else if !p.type_name.is_empty() { + p.type_name + } else { + value_type_name(&p.value) + }; + div() + .flex_none() + .text_size(px(10.0)) + .text_color(rgb(MUTED_COLOR)) + .font(gpui::font(MONO_FONT)) + .child(name) + }); + let menu_key = p.key.clone(); + div() + .id(("prop-row", row_key * 1024 + i)) + .flex() + .flex_row() + .items_center() + .gap_2() + .min_h(line_height) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + this.open_context_menu( + MenuTarget::PropertyRow { + block, + key: menu_key.clone(), + }, + event.position, + window, + cx, + ); + }), + ) + .child(key_cell) + .child(value_cell) + .children(type_label) + .into_any_element() + }) + .collect(); + + grid.child(toggle) + .child(div().flex().flex_col().flex_1().min_w_0().children(rows)) + .into_any_element() +} + /// Render a query's evaluation outcome beneath its source (spec: "Results /// render in place" / "Projected properties become columns"). `outline` /// reads each result block's content/ancestry for display and navigation; @@ -3961,6 +5095,22 @@ struct RowSnapshot { guides: Vec, is_first_child: bool, accent: RowAccent, + properties: Vec, + properties_folded: bool, + /// The grid editing surface, when it targets this row (spec: + /// property-authoring/"The grid is a separate editing surface"): the + /// draft rows replace `properties` for display, and the focused field + /// renders the live input. + grid_edit: Option, +} + +/// Render-time snapshot of the grid editing surface for its target row. +#[derive(Clone)] +struct GridEditSnapshot { + rows: Vec, + row_ix: usize, + field: GridField, + input: Entity, } /// The focused-path thread's contribution to one row (design D4 as @@ -4302,6 +5452,27 @@ impl Render for TrawlerApp { guides: r.guides.clone(), is_first_child: r.is_first_child, accent: accents.get(&row_ix).cloned().unwrap_or_default(), + properties: r.properties.clone(), + properties_folded: r.properties_folded, + grid_edit: self + .grid_edit + .as_ref() + .filter(|g| g.block == r.id) + .map(|g| GridEditSnapshot { + rows: g + .rows + .iter() + .map(|d| PropRow { + key: d.key.clone(), + value: d.value.clone(), + type_name: d.type_name, + ref_target: None, + }) + .collect(), + row_ix: g.row_ix, + field: g.field, + input: g.input.clone(), + }), } }) .collect(); @@ -4354,8 +5525,77 @@ impl Render for TrawlerApp { // at the right-click point above it. Escape dismisses via the // menu's own key handler; both go through `close_context_menu`. let context_menu_overlay = self.context_menu.as_ref().map(|state| { - let target = state.target.clone(); - let pinned = trawler_core::pins::Pins::new(self.storage.doc()).is_pinned(&target); + // Items derive from the target at render time. Node targets: + // the pin toggle, plus "Edit properties" for tree-backed nodes + // (openspec change property-authoring). Property-row targets: + // exactly "Delete property". + let mut menu_items: Vec = Vec::new(); + match &state.target { + MenuTarget::Node(node) => { + let pinned = trawler_core::pins::Pins::new(self.storage.doc()).is_pinned(node); + let target = node.clone(); + menu_items.push( + div() + .id("context-menu-pin") + .px_2() + .py_1() + .cursor_pointer() + .hover(|d| d.bg(rgb(HOVER_BG))) + .child(if pinned { "Unpin" } else { "Pin to sidebar" }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.toggle_pin(&target, cx); + this.close_context_menu(cx); + }), + ) + .into_any_element(), + ); + if let Some(block) = node.as_tree_id() { + menu_items.push( + div() + .id("context-menu-edit-properties") + .px_2() + .py_1() + .cursor_pointer() + .hover(|d| d.bg(rgb(HOVER_BG))) + .child("Edit properties") + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event, window, cx| { + cx.stop_propagation(); + this.close_context_menu(cx); + this.open_property_grid_default(block, window, cx); + }), + ) + .into_any_element(), + ); + } + } + MenuTarget::PropertyRow { block, key } => { + let block = *block; + let key = key.clone(); + menu_items.push( + div() + .id("context-menu-delete-property") + .px_2() + .py_1() + .cursor_pointer() + .hover(|d| d.bg(rgb(HOVER_BG))) + .child("Delete property") + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.close_context_menu(cx); + this.delete_property(block, &key, cx); + }), + ) + .into_any_element(), + ); + } + } deferred( div() .id("context-menu-backdrop") @@ -4398,23 +5638,7 @@ impl Render for TrawlerApp { .border_color(rgb(0x3a3a3a)) .rounded_md() .shadow_lg() - .child( - div() - .id("context-menu-pin") - .px_2() - .py_1() - .cursor_pointer() - .hover(|d| d.bg(rgb(HOVER_BG))) - .child(if pinned { "Unpin" } else { "Pin to sidebar" }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.toggle_pin(&target, cx); - this.close_context_menu(cx); - }), - ), - ), + .children(menu_items), ), ), ) @@ -4666,6 +5890,8 @@ impl Render for TrawlerApp { .on_action(cx.listener(Self::zoom_in)) .on_action(cx.listener(Self::zoom_out)) .on_action(cx.listener(Self::toggle_fold_focused)) + .on_action(cx.listener(Self::edit_properties)) + .on_action(cx.listener(Self::toggle_properties_fold_focused)) // Sidebar edge-drag: the handle only sets `sidebar_resizing`; // motion and release are consumed here at the root so the drag // tracks the cursor anywhere in the window. @@ -4941,9 +6167,39 @@ impl Render for TrawlerApp { guides, is_first_child, accent, + properties, + properties_folded, + grid_edit, } = &rows[ix]; let id = *id; let is_query = *is_query; + // The property grid (openspec change property- + // authoring): rendered below the block's own + // row for every block that has visible + // properties or an open editing surface, except + // a folded block — fold hides properties along + // with descendants (spec: "Block fold hides the + // grid"). + let grid_element = grid_renders( + *folded, + properties, + grid_edit.is_some(), + ) + .then(|| { + render_property_grid( + id, + properties, + *properties_folded, + grid_edit.as_ref(), + depth.saturating_sub(usize::from(heading_layout)), + ix, + line_height, + this.chevron_anim + .filter(|&(b, _)| b == id) + .map(|(_, g)| g), + cx, + ) + }); // Lazily kick off an evaluation the first time // a query block with no cached result appears // in a rendered row — covers loading a page @@ -4977,7 +6233,7 @@ impl Render for TrawlerApp { .pb(px(PAGE_HEADING_PAD_BOTTOM)) .text_size(px(PAGE_HEADING_TEXT_SIZE)) .font_weight(FontWeight::BOLD); - return match content { + let heading = match content { RowContent::Editor(input) => heading .child(div().min_h(line_height).child(input.clone())), RowContent::Markdown(blocks) => heading @@ -5011,7 +6267,9 @@ impl Render for TrawlerApp { window, cx| { this.open_context_menu( - NodeId::tree(id), + MenuTarget::Node( + NodeId::tree(id), + ), event.position, window, cx, @@ -5033,8 +6291,18 @@ impl Render for TrawlerApp { RowContent::Source(source) => { heading.child(render_scheme_source(source)) } - } - .into_any_element(); + }; + let Some(grid) = grid_element else { + return heading.into_any_element(); + }; + return div() + .flex() + .flex_col() + .w_full() + .items_start() + .child(heading) + .child(grid) + .into_any_element(); } // Descendants of heading roots render one // level shallower — the heading replaced the @@ -5128,9 +6396,17 @@ impl Render for TrawlerApp { // visibly touches it). While a re-thread // animation runs (openspec change // animate-bullet-thread), every piece clips - // against the window in row-fraction units — - // fractions of this row's own height, so no - // pixel measurements are needed. + // against the window in row units where the + // bullet center sits at 0.5. Items taller than + // one row (a property band or query result + // stacked below) would put a plain + // `relative(0.5)` well below the bullet, so + // animated fragments map units to pixels + // piecewise instead: units [0, 0.5] span + // [item top, bullet center] and [0.5, 1] span + // [bullet center, item bottom], each rendered + // as fractions inside an invisible region box + // with the matching pixel bounds. let row_clip = thread_window.map(|(w0, w1)| (w0 - ix as f32, w1 - ix as f32)); let clip_seg = move |s: f32, e: f32| -> Option<(f32, f32)> { @@ -5141,64 +6417,154 @@ impl Render for TrawlerApp { (b - a > 0.001).then_some((a, b)) }; let mut thread_segments: Vec = Vec::new(); + // A vertical fragment spanning window units + // [a, b], split across the two pixel regions. + let piecewise_vertical = |left: f32, a: f32, b: f32| { + let mut out: Vec = Vec::new(); + let (ua, ub) = (a.min(0.5), b.min(0.5)); + if ub - ua > 0.001 { + out.push( + div() + .absolute() + .left(px(left)) + .w(px(THREAD_WIDTH)) + .top_0() + .h(px(bullet_center_y)) + .child( + div() + .absolute() + .left_0() + .w(px(THREAD_WIDTH)) + .top(gpui::relative(ua / 0.5)) + .h(gpui::relative((ub - ua) / 0.5)) + .bg(rgb(THREAD_COLOR)), + ) + .into_any_element(), + ); + } + let (la, lb) = (a.max(0.5), b.max(0.5)); + if lb - la > 0.001 { + out.push( + div() + .absolute() + .left(px(left)) + .w(px(THREAD_WIDTH)) + .top(px(bullet_center_y)) + .bottom_0() + .child( + div() + .absolute() + .left_0() + .w(px(THREAD_WIDTH)) + .top(gpui::relative((la - 0.5) / 0.5)) + .h(gpui::relative((lb - la) / 0.5)) + .bg(rgb(THREAD_COLOR)), + ) + .into_any_element(), + ); + } + out + }; for &col in &accent.verticals { let Some((a, b)) = clip_seg(0.0, 1.0) else { continue; }; - let seg = div() - .absolute() - .left(px(column_x(col) - THREAD_WIDTH / 2.0)) - .w(px(THREAD_WIDTH)) - .bg(rgb(THREAD_COLOR)); - let seg = if b - a >= 0.999 { - seg.top_0().bottom_0() + let left = column_x(col) - THREAD_WIDTH / 2.0; + if b - a >= 0.999 { + thread_segments.push( + div() + .absolute() + .left(px(left)) + .w(px(THREAD_WIDTH)) + .top_0() + .bottom_0() + .bg(rgb(THREAD_COLOR)) + .into_any_element(), + ); } else { - seg.top(gpui::relative(a)).h(gpui::relative(b - a)) - }; - thread_segments.push(seg.into_any_element()); + thread_segments.extend(piecewise_vertical(left, a, b)); + } } // The stub spans the lower half of its row in - // window units (bullet center ≈ mid-row); the + // window units (bullet center = 0.5); the // settled shape keeps the exact pixel top at // the bullet center. if accent.stub { if let Some((a, b)) = clip_seg(0.5, 1.0) { - let seg = div() - .absolute() - .left(px(column_x(*depth) - THREAD_WIDTH / 2.0)) - .w(px(THREAD_WIDTH)) - .bg(rgb(THREAD_COLOR)); - let seg = if a <= 0.501 && b >= 0.999 { - seg.top(px(bullet_center_y)).bottom_0() + let left = column_x(*depth) - THREAD_WIDTH / 2.0; + if a <= 0.501 && b >= 0.999 { + thread_segments.push( + div() + .absolute() + .left(px(left)) + .w(px(THREAD_WIDTH)) + .top(px(bullet_center_y)) + .bottom_0() + .bg(rgb(THREAD_COLOR)) + .into_any_element(), + ); } else { - seg.top(gpui::relative(a)).h(gpui::relative(b - a)) - }; - thread_segments.push(seg.into_any_element()); + thread_segments.extend(piecewise_vertical(left, a, b)); + } } } // A sibling translate's traveling bend: the - // full elbow box rendered at the fractional - // row position the animation says the bend is - // passing through, sweeping the gutter between - // the parent's column and the sibling column. + // full elbow box rendered at the position the + // animation says the bend is passing through, + // sweeping the gutter between the parent's + // column and the sibling column. The bend's + // bottom edge follows the same piecewise + // unit→pixel mapping as the verticals, so it + // meets each row's bullet exactly instead of + // overshooting through an attached band. if let Some((bend, from_col, to_depth, _, _)) = thread_travel { let f = bend - ix as f32; if (0.0..1.0).contains(&f) { let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; let own_x = column_x(to_depth); - thread_segments.push( + let bend_box = div() + .absolute() + .left_0() + .w(px(own_x - from_left)) + .border_l_2() + .border_b_2() + .rounded_bl(px(THREAD_BEND_RADIUS)) + .border_color(rgb(THREAD_COLOR)); + let piece: gpui::AnyElement = if f <= 0.5 { + // Above the bullet line: the + // bend's bottom edge sits at a + // directly computable pixel. div() .absolute() .left(px(from_left)) .top_0() .w(px(own_x - from_left)) - .h(gpui::relative(f.max(0.05))) - .border_l_2() - .border_b_2() - .rounded_bl(px(THREAD_BEND_RADIUS)) - .border_color(rgb(THREAD_COLOR)) - .into_any_element(), - ); + .h(px(((f / 0.5) * bullet_center_y).max(2.0))) + .child(bend_box.top_0().bottom_0()) + .into_any_element() + } else { + // Below the bullet line: anchor a + // region box to [bullet center, + // item bottom] and let the bend + // overhang it up to the item top + // (negative inset), its bottom a + // fraction of the region. + div() + .absolute() + .left(px(from_left)) + .top(px(bullet_center_y)) + .bottom_0() + .w(px(own_x - from_left)) + .child( + bend_box + .top(px(-bullet_center_y)) + .bottom(gpui::relative( + 1.0 - (f - 0.5) / 0.5, + )), + ) + .into_any_element() + }; + thread_segments.push(piece); } } if let Some(from_col) = accent.elbow_from { @@ -5209,7 +6575,10 @@ impl Render for TrawlerApp { // while the window hasn't reached the // bullet center, only the vertical // continuation draws; the bend pops in as - // the tip rounds the corner (design D4). + // the tip rounds the corner (design D4) — + // and with the piecewise mapping the tip + // pixel-reaches the bullet exactly when + // the window reaches 0.5. let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; if let Some((a, b)) = clip_seg(0.0, 0.5) { if a <= 0.001 && b >= 0.499 { @@ -5228,16 +6597,9 @@ impl Render for TrawlerApp { .into_any_element(), ); } else { - thread_segments.push( - div() - .absolute() - .left(px(from_left)) - .top(gpui::relative(a)) - .h(gpui::relative(b - a)) - .w(px(THREAD_WIDTH)) - .bg(rgb(THREAD_COLOR)) - .into_any_element(), - ); + thread_segments.extend(piecewise_vertical( + from_left, a, b, + )); } } } @@ -5257,9 +6619,7 @@ impl Render for TrawlerApp { .flex() .flex_row() .items_start() - .gap(px(BULLET_TEXT_GAP)) - .children(guide_segments) - .children(thread_segments); + .gap(px(BULLET_TEXT_GAP)); if is_query { // A query block otherwise looks exactly // like a plain outline row containing @@ -5329,7 +6689,7 @@ impl Render for TrawlerApp { window, cx| { this.open_context_menu( - NodeId::tree(id), + MenuTarget::Node(NodeId::tree(id)), event.position, window, cx, @@ -5426,9 +6786,37 @@ impl Render for TrawlerApp { .child(render_scheme_source(source)), ), }; + if !is_query && grid_element.is_none() { + return row + .children(guide_segments) + .children(thread_segments) + .into_any_element(); + } let row = row.into_any_element(); + // The property grid and (for a query) the + // result render beneath the block's own row — + // the grid first: properties are part of the + // block's identity, the result is its output + // (spec: property-authoring/"Properties render + // colocated with their block"). Guide and + // thread segments attach to the whole item + // (row + band), painted last so the band's + // fill sits under them — this is what lets the + // animated thread sweep through the band + // instead of clipping at the row's bottom + // edge. if !is_query { - return row; + return div() + .relative() + .flex() + .flex_col() + .w_full() + .items_start() + .child(row) + .children(grid_element) + .children(guide_segments) + .children(thread_segments) + .into_any_element(); } // Result renders beneath the query's own row // (spec: "Results render in place"), indented @@ -5450,6 +6838,7 @@ impl Render for TrawlerApp { .into_any_element(), }; div() + .relative() .flex() .flex_col() // Full width, like every non-query row: @@ -5465,6 +6854,7 @@ impl Render for TrawlerApp { .w_full() .items_start() .child(row) + .children(grid_element) .child( // `ml`, not `pl`: padding is inside // the box, so a background painted on @@ -5494,6 +6884,8 @@ impl Render for TrawlerApp { ) .child(result), ) + .children(guide_segments) + .children(thread_segments) .into_any_element() }), ) diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index b24bfa7..329e798 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -1803,7 +1803,7 @@ async fn outdent_is_bounded_by_the_view(cx: &mut gpui::TestAppContext) { fn open_menu_on(app: &Entity, cx: &mut VisualTestContext, target: NodeId) { app.update_in(cx, |app, window, cx| { app.open_context_menu( - target, + crate::MenuTarget::Node(target), gpui::point(gpui::px(300.0), gpui::px(300.0)), window, cx, @@ -1812,7 +1812,7 @@ fn open_menu_on(app: &Entity, cx: &mut VisualTestContext, target: No cx.run_until_parked(); } -/// Confirm the open menu's single action, as clicking its item would. +/// Confirm the open menu's pin action, as clicking its item would. fn choose_menu_action(app: &Entity, cx: &mut VisualTestContext) { app.update(cx, |app, cx| { let target = app @@ -1821,7 +1821,10 @@ fn choose_menu_action(app: &Entity, cx: &mut VisualTestContext) { .expect("a context menu is open") .target .clone(); - app.toggle_pin(&target, cx); + let crate::MenuTarget::Node(node) = target else { + panic!("expected a node-targeted context menu"); + }; + app.toggle_pin(&node, cx); app.close_context_menu(cx); }); cx.run_until_parked(); @@ -1939,3 +1942,467 @@ async fn pins_survive_relaunch_and_drop_dangling(cx: &mut gpui::TestAppContext) "dangling pin dropped without error" ); } + +// --- property authoring (openspec change property-authoring) ----------------- + +use trawler_core::graph::PropertyValue; + +/// Persisted properties of `block`, read back from graph storage. +fn stored_properties( + app: &Entity, + cx: &mut VisualTestContext, + block: TreeID, +) -> std::collections::HashMap { + app.update(cx, |app, _cx| { + Outline::new(app.storage.doc()) + .properties(block) + .expect("block exists") + }) +} + +/// `(row_ix, field, live input text)` of the open grid surface. +fn grid_position( + app: &Entity, + cx: &mut VisualTestContext, +) -> (usize, crate::GridField, String) { + app.update(cx, |app, cx| { + let grid = app.grid_edit.as_ref().expect("grid surface is open"); + ( + grid.row_ix, + grid.field, + grid.input.read(cx).value().to_string(), + ) + }) +} + +fn grid_is_open(app: &Entity, cx: &mut VisualTestContext) -> bool { + app.update(cx, |app, _cx| app.grid_edit.is_some()) +} + +/// The visible (machine-filtered) property keys of `block`'s outline row. +fn row_property_keys( + app: &Entity, + cx: &mut VisualTestContext, + block: TreeID, +) -> Vec { + app.update(cx, |app, _cx| { + let row = app + .rows + .iter() + .find(|r| r.id == block) + .expect("block has a visible row"); + row.properties.iter().map(|p| p.key.clone()).collect() + }) +} + +#[gpui::test] +fn property_grid_rows_render_for_multiple_blocks(cx: &mut TestAppContext) { + let (app, cx) = open_app("prop-grid-rows", cx); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let ship = block_by_content(&app, cx, "Ship fixture graphs #project"); + let wire = block_by_content(&app, cx, "Wire dev automation #project"); + + // Both blocks' rows carry their property grids simultaneously, keys + // sorted (spec scenario: "Properties visible for multiple blocks at a + // glance"). + assert_eq!(row_property_keys(&app, cx, ship), vec!["due", "priority"]); + assert_eq!( + row_property_keys(&app, cx, wire), + vec!["done", "due", "priority"] + ); + + // The query block's only property is the machine `query` flag — its + // grid renders empty (spec scenario: "Query flag is invisible in the + // grid"). + let query = block_by_content(&app, cx, trawler_core::fixtures::FIXTURE_QUERY_EXPR); + assert_eq!( + row_property_keys(&app, cx, query), + Vec::::new(), + "machine keys never render" + ); + + // Fold hides the grid together with descendants; expanded blocks with + // properties render it (spec scenario: "Block fold hides the grid"). + app.update(cx, |app, _cx| { + let row = app.rows.iter().find(|r| r.id == ship).unwrap(); + assert!(crate::grid_renders(false, &row.properties, false)); + assert!(!crate::grid_renders(true, &row.properties, false)); + assert!(!crate::grid_renders(false, &[], false)); + assert!(crate::grid_renders(false, &[], true)); + }); +} + +#[gpui::test] +fn grid_disclosure_collapses_rows_not_children_and_survives_relaunch(cx: &mut TestAppContext) { + // Manual open_app so the second launch reuses the dir without + // reseeding and without re-registering actions/keymap (the + // fold_state_survives_relaunch pattern). + crate::set_scroll_animation_ms(0); + crate::editor::set_caret_animation_ms(0); + crate::set_thread_animation_ms(0); + let dir = fixture_dir("prop-disclosure"); + cx.update(|cx| { + crate::editor::init(cx); + crate::init_keymap(cx); + }); + // Give a block with children a property, so the disclosure and the + // block fold are observably independent axes. + let tasks = { + let storage = GraphStorage::open(&dir).expect("open graph"); + let outline = Outline::new(storage.doc()); + let mut stack: Vec = outline.children(None); + let mut tasks = None; + while let Some(id) = stack.pop() { + if outline.content(id).unwrap_or_default() == "Tasks" { + tasks = Some(id); + } + stack.extend(outline.children(Some(id))); + } + let tasks = tasks.expect("fixture has a Tasks block"); + outline + .set_property(tasks, "status", &PropertyValue::Text("active".into())) + .unwrap(); + storage.persist_update().expect("persist property"); + tasks + }; + + { + let (app, cx) = cx.add_window_view(|w, c| TrawlerApp::new(dir.clone(), w, c)); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let children_before = children_of(&app, cx, Some(tasks)).len(); + assert!(children_before > 0, "Tasks has children in the fixture"); + + // Collapse the grid: the disclosure flag flips, children stay + // visible (spec scenario: "Collapsed grid keeps children + // visible"). + app.update(cx, |app, cx| app.toggle_properties_fold(tasks, cx)); + cx.run_until_parked(); + app.update(cx, |app, _cx| { + let outline = Outline::new(app.storage.doc()); + assert!(outline.properties_folded(tasks).unwrap()); + let child_rows = app + .rows + .iter() + .filter(|r| Outline::new(app.storage.doc()).parent(r.id) == Some(tasks)) + .count(); + assert_eq!(child_rows, children_before, "children still render"); + }); + + // Quit the first launch, releasing storage and the search-index + // writer lock. + cx.update(|window, _app| window.remove_window()); + cx.run_until_parked(); + } + // Entities release on flush; flush before reopening. + cx.update(|_cx| {}); + + // Relaunch: the disclosure is document content and survives (spec + // scenario: "Disclosure round-trips through storage"). + let (app, cx) = cx.add_window_view(|w, c| TrawlerApp::new(dir, w, c)); + cx.run_until_parked(); + app.update(cx, |app, _cx| { + let outline = Outline::new(app.storage.doc()); + assert!( + outline.properties_folded(tasks).unwrap(), + "grid disclosure survives relaunch" + ); + }); +} + +#[gpui::test] +fn outline_navigation_skips_property_grid(cx: &mut TestAppContext) { + let (app, cx) = open_app("prop-nav-skip", cx); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + // `ship` carries two properties; Down from it must land on the next + // block, never inside the grid (spec scenario: "Outline navigation + // skips property rows"). + let ship = block_by_content(&app, cx, "Ship fixture graphs #project"); + let wire = block_by_content(&app, cx, "Wire dev automation #project"); + focus_block(&app, cx, ship); + cx.simulate_keystrokes("down"); + cx.run_until_parked(); + assert_eq!(focused_block(&app, cx), wire); + assert!(!grid_is_open(&app, cx)); +} + +#[gpui::test] +fn first_property_flow_and_escape_discards_empty(cx: &mut TestAppContext) { + let (app, cx) = open_app("prop-first", cx); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + // A block with no properties: Ctrl+Shift+P presents one fresh empty + // row; author a typed date property through the form (spec scenarios: + // "Adding the first property", "Date round-trips as a date"). + let goals = block_by_content(&app, cx, "Keyboard-first outlining #project"); + focus_block(&app, cx, goals); + cx.simulate_keystrokes("ctrl-shift-p"); + cx.run_until_parked(); + assert_eq!( + grid_position(&app, cx), + (0, crate::GridField::Key, String::new()) + ); + cx.simulate_input("due"); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + assert_eq!(grid_position(&app, cx).1, crate::GridField::Value); + cx.simulate_input("2026-08-05"); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + + assert!(!grid_is_open(&app, cx), "escape closes the surface"); + assert_eq!(focused_block(&app, cx), goals, "escape refocuses the block"); + let props = stored_properties(&app, cx, goals); + assert_eq!( + props.get("due").and_then(PropertyValue::as_date), + chrono::NaiveDate::from_ymd_opt(2026, 8, 5), + "the value is a typed date, not a string" + ); + + // Enter-and-escape on another empty block creates nothing (spec: + // untouched empty rows are discarded on exit). + let steel = block_by_content(&app, cx, "Live Steel queries #project"); + focus_block(&app, cx, steel); + cx.simulate_keystrokes("ctrl-shift-p"); + cx.run_until_parked(); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert!(stored_properties(&app, cx, steel).is_empty()); +} + +#[gpui::test] +fn tab_appends_row_and_shift_tab_stops_at_first_field(cx: &mut TestAppContext) { + let (app, cx) = open_app("prop-tab", cx); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let ship = block_by_content(&app, cx, "Ship fixture graphs #project"); + focus_block(&app, cx, ship); + cx.simulate_keystrokes("ctrl-shift-p"); + cx.run_until_parked(); + // Entry lands on the first VALUE — named keys are not tab stops. + assert_eq!( + grid_position(&app, cx), + (0, crate::GridField::Value, "2026-07-15".to_string()), + "opens on the first value, not the key" + ); + + // Shift-tab at the very first value stops (no wrap, and the named + // key is skipped). + cx.simulate_keystrokes("shift-tab"); + cx.run_until_parked(); + assert_eq!(grid_position(&app, cx).0, 0); + assert_eq!(grid_position(&app, cx).1, crate::GridField::Value); + + // Tab hops value-to-value across rows, skipping the named key. + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + assert_eq!( + grid_position(&app, cx), + (1, crate::GridField::Value, "high".to_string()) + ); + + // Tab from the last value appends a fresh row, key field focused + // (spec scenario: "Tab appends a row"). + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + let (row, field, text) = grid_position(&app, cx); + assert_eq!((row, field, text.as_str()), (2, crate::GridField::Key, "")); + app.update(cx, |app, _cx| { + assert_eq!(app.grid_edit.as_ref().unwrap().rows.len(), 3); + }); + + // Escape: the untouched fresh row vanishes, nothing new persisted. + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert_eq!( + stored_properties(&app, cx, ship).len(), + 2, + "no key-without-value state was stored" + ); +} + +#[gpui::test] +fn inference_types_number_bool_and_reference(cx: &mut TestAppContext) { + let (app, cx) = open_app("prop-infer", cx); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let goals = block_by_content(&app, cx, "Keyboard-first outlining #project"); + focus_block(&app, cx, goals); + cx.simulate_keystrokes("ctrl-shift-p"); + cx.run_until_parked(); + + // rating = 4.5 → Number; then tab-append: done = true → Bool; then + // source = [[reading-list]] → Ref (spec: "Values are typed by + // inference at commit", "Reference value via completion"). + cx.simulate_input("rating"); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + cx.simulate_input("4.5"); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + cx.simulate_input("done"); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + cx.simulate_input("true"); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + cx.simulate_input("source"); + cx.simulate_keystrokes("tab"); + cx.run_until_parked(); + cx.simulate_input("[[reading-list]]"); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + + let props = stored_properties(&app, cx, goals); + assert_eq!( + props.get("rating").and_then(PropertyValue::as_number), + Some(4.5) + ); + assert_eq!( + props.get("done").and_then(PropertyValue::as_bool), + Some(true) + ); + let reading = block_by_content(&app, cx, "reading-list"); + assert_eq!( + props.get("source").and_then(PropertyValue::as_ref), + Some(&NodeId::tree(reading)), + "a completed [[…]] reference resolves like an in-content one" + ); +} + +#[gpui::test] +fn key_field_completes_against_property_vocabulary(cx: &mut TestAppContext) { + let (app, cx) = open_app("prop-key-complete", cx); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let goals = block_by_content(&app, cx, "Keyboard-first outlining #project"); + focus_block(&app, cx, goals); + cx.simulate_keystrokes("ctrl-shift-p"); + cx.run_until_parked(); + cx.simulate_input("pri"); + cx.run_until_parked(); + + // The fixture's existing keys include `priority`; the machine `query` + // key is never offered (spec: "Key fields SHALL offer completion + // against the property keys already present in the graph"). + let candidates = app.update(cx, |app, cx| { + let grid = app.grid_edit.as_ref().expect("grid open"); + let (trigger, _query, candidates, _sel) = grid + .input + .read(cx) + .completion_state() + .expect("key completion active"); + assert_eq!(trigger, '\0'); + candidates + .iter() + .map(|c| c.display.clone()) + .collect::>() + }); + assert_eq!(candidates, vec!["priority"]); + + // First escape dismisses the popup, second exits without persisting. + cx.simulate_keystrokes("escape escape"); + cx.run_until_parked(); + assert!(!grid_is_open(&app, cx)); + assert!(stored_properties(&app, cx, goals).is_empty()); +} + +#[gpui::test] +fn delete_is_explicit_and_emptied_value_persists_as_text(cx: &mut TestAppContext) { + let (app, cx) = open_app("prop-delete", cx); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + let wire = block_by_content(&app, cx, "Wire dev automation #project"); + + // Shift-delete removes the current row's property and keeps editing + // (spec scenario: "Explicit delete removes the property"). + focus_block(&app, cx, wire); + cx.simulate_keystrokes("ctrl-shift-p"); + cx.run_until_parked(); + assert_eq!( + grid_position(&app, cx).2, + "false", + "opens on the first sorted row's value" + ); + cx.simulate_keystrokes("shift-delete"); + cx.run_until_parked(); + assert!(grid_is_open(&app, cx), "surface stays open after delete"); + let props = stored_properties(&app, cx, wire); + assert!(!props.contains_key("done"), "property removed"); + assert_eq!(props.len(), 2); + + // Clear an existing text value and escape: the property survives as + // empty text — never a silent delete (spec scenario: "Emptying a + // value does not delete"). + app.update_in(cx, |app, window, cx| { + let row_ix = app + .grid_edit + .as_ref() + .unwrap() + .rows + .iter() + .position(|r| r.key == "priority") + .unwrap(); + app.grid_focus_field(row_ix, crate::GridField::Value, window, cx); + }); + cx.run_until_parked(); + assert_eq!(grid_position(&app, cx).2, "medium"); + cx.simulate_keystrokes("ctrl-a backspace escape"); + cx.run_until_parked(); + let props = stored_properties(&app, cx, wire); + assert_eq!( + props.get("priority").and_then(PropertyValue::as_text), + Some(""), + "emptied value persists as empty text" + ); +} + +#[test] +fn property_value_inference_rules() { + let doc = loro::LoroDoc::new(); + let index = crate::GraphIndex::rebuild(&doc); + use crate::infer_property_value as infer; + assert_eq!( + infer("2026-08-05", &index), + PropertyValue::Date(chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap()) + ); + assert_eq!(infer("true", &index), PropertyValue::Bool(true)); + assert_eq!(infer("4.5", &index), PropertyValue::Number(4.5)); + assert_eq!(infer("007", &index), PropertyValue::Number(7.0)); + assert_eq!( + infer("#book", &index), + PropertyValue::Ref(NodeId::tag("book")) + ); + assert_eq!( + infer("[[2026-08-05]]", &index), + PropertyValue::Ref(NodeId::date( + chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap() + )) + ); + // Unresolvable page names fall back to the tag node, exactly like + // in-content references. + assert_eq!( + infer("[[nowhere]]", &index), + PropertyValue::Ref(NodeId::tag("nowhere")) + ); + // Not-quite-typed strings stay text, as typed (untrimmed). + assert_eq!( + infer("2026-13-45", &index), + PropertyValue::Text("2026-13-45".into()) + ); + assert_eq!( + infer("truthy", &index), + PropertyValue::Text("truthy".into()) + ); + assert_eq!(infer(" nan ", &index), PropertyValue::Text(" nan ".into())); +} diff --git a/openspec/changes/add-supertags/design.md b/openspec/changes/add-supertags/design.md index 488205a..b1271ec 100644 --- a/openspec/changes/add-supertags/design.md +++ b/openspec/changes/add-supertags/design.md @@ -94,6 +94,14 @@ schema later defines the key's type, existing values are validated retroactively with warnings, not errors. Capture never has prerequisites; structure is always retrofittable. +> **2026-08-02 note (property-authoring):** the ad-hoc authoring surface +> landed as the per-block property grid (colocated rows below the block, +> separate editing surface, inference at commit) — not remoras' inline +> `key:: value` syntax, which was explicitly rejected as messy for +> complicated blocks. The `key:: value` mention above is remoras- +> historical; the upgrade semantics of this decision are unchanged, and +> schema-backed fields will render into the same grid. + ### D5 — Multiple supertags compose by union; conflicts are surfaced, not silently ordered diff --git a/openspec/changes/property-authoring/design.md b/openspec/changes/property-authoring/design.md index af96ead..10d6072 100644 --- a/openspec/changes/property-authoring/design.md +++ b/openspec/changes/property-authoring/design.md @@ -1,132 +1,151 @@ -# Design: property-authoring - -## Context - -Properties exist end-to-end below the UI: typed values (text/number/date/ -bool/ref) encoded into each block's Loro meta `properties` map, a derived -key→block→value index, and `prop`/`prop-eq`/`table` query primitives. The -only UI writer is the ctrl-shift-q query toggle. Query blocks already render -an attached sub-surface (the RESULT table) below block content — the display -slot this change generalizes. The sidebar was considered and rejected for -this: properties must stay colocated with their node so several blocks' -properties are visible at once. Decisions settled in the 2026-08-02 explore -session. - -## Goals / Non-Goals - -**Goals:** - -- Make the existing property system authorable: add, edit, delete on any - block, keyboard-first. -- Establish the display-and-editing surface that supertag schema-backed - fields will later render into (same rows, declared instead of inferred - types). - -**Non-Goals:** - -- Schema definition or validation (add-supertags; schemas stay Scheme per - its D3 — this change is reading (a) from the explore session). -- Inline `key:: value` syntax in block text (explicitly rejected: messy for - complicated blocks). -- Migrating the `query` flag to a meta key (noted for later; it stays a - property, hidden from the grid). -- Bulk/multi-block property editing, property renaming across the graph. - -## Decisions - -### D1: Colocated attached grid, not a sidebar panel, not inline syntax - -Property rows render below the block's content and above its descendants -(and above a query block's RESULT), as owned display in the same structural -slot the RESULT table already occupies — not child blocks, not text. This -keeps properties glanceable across many blocks simultaneously, which a -focus-reactive sidebar panel cannot do, without polluting block content. - -### D2: The grid is a separate focus surface; outline navigation stays block-only - -Up/down walk blocks, never property rows — fast vertical travel is the -outliner invariant. The grid is its own small focus world (calendar -day-selection precedent): `ctrl-shift-p` (or the context-menu item) enters -the grid of the focused block; escape returns to the block. Entering a block -with no properties opens the grid with one fresh empty row ready to fill. - -### D3: Two fold axes; the grid disclosure is document content - -Folding the block hides properties and descendants together (fold is the -density gesture; properties are detail). A separate per-block disclosure -collapses the grid alone — children stay visible — rendered as a -`▸ properties (n)` header row, toggled by `ctrl-alt-p` (and click). The -disclosure persists as a meta key beside `folded`, with identical semantics: -document content (survives restart, merges, follows future sync), not a -`properties` entry, invisible to property queries and indexes, absent reads -as expanded, no format bump. A collapsed grid still answers "does this block -carry data?" at a glance via the header count. - -### D4: Web-form navigation inside the grid - -Tab and shift-tab move key → value → next row's key. Tab from the last -value field appends a fresh empty row (the append gesture — no separate add -binding inside the surface); an untouched empty row is discarded on exit or -blur. Shift-tab from the first field stops (no wrap). Enter commits the -current field; escape exits the surface. - -### D5: Values are typed by inference at commit - -Committed value text is best-effort typed, in order: ISO `YYYY-MM-DD` → -Date; parseable number → Number; `true`/`false` → Bool; a completed `[[…]]` -reference → Ref (reusing the editor's `[[`/`#` completion machinery, dates -included); anything else → Text. Each row shows a type glyph for what -inference produced. This matches add-supertags D4's "best-effort typed" -ad-hoc semantics; when schemas land, schema-backed keys flip to declared -types in the same rows. Persisted rows always carry a value — there is no -key-without-value storage state; a row with an empty value exists only as -transient surface state (and, later, as schema-slot display), so the grid's -row model is "key slot, value maybe empty" from day one. - -### D6: Deletion is explicit, never commit-of-empty - -A per-row delete gesture (shift-delete inside the surface, plus a -context-menu item on the row) removes the property. Committing an emptied -value field does not delete — empty text is a legal value (`t:`), and -silent delete-on-empty eats data during editing. This gives -`Outline::remove_property` its first real consumer beyond the query toggle. - -### D7: Key fields autocomplete against the graph's property vocabulary - -The index's `properties` map keys are offered as completions while typing a -key. Cheap, and it fights key drift (`due` vs `due-date` vs `deadline`) — -which matters doubly later, since add-supertags D4 matches schema fields to -ad-hoc keys by name. - -### D8: Machine properties are filtered from the grid - -A single machine-keys constant (today exactly `["query"]`) is excluded from -grid display and editing. The flag remains a real property — `(prop -"query")` still works, ctrl-shift-q still toggles it — the grid is just a -user surface. The fold flag set the precedent that machine state should be -meta, not properties; `query` predates that and migrating it is deliberately -deferred. - -## Risks / Trade-offs - -- [A second focus surface in gpui — focus transfer, blur commits, overlay - interaction with the virtualized row list] → the calendar's day-selection - and the completion popup establish the patterns; the grid is per-focused- - block only, never multiple grids in edit mode at once. -- [Inference surprises: "2026-13-45" is Text, "007" becomes Number 7] → the - type glyph makes the result visible at commit time; explicit type override - is an open question below, deferred rather than designed around. -- [Grid rows add vertical space for property-heavy blocks] → the per-block - disclosure (D3) is the pressure valve; density-minded users collapse. -- [`ctrl-alt-*` can collide with AltGr layouts on Windows] → bindings are - provisional; resolve against the keymap at implementation. - -## Open Questions - -1. Explicit type override (e.g. cycling the glyph to force Text for a - date-shaped string). Deferred from v1 — inference plus re-edit covers the - common cases; revisit when schema-locked types arrive and the glyph - becomes interactive anyway. -2. Exact visual weight of the grid rows (indent, mono vs proportional, - glyph placement) — resolve in dev-loop screenshot passes, per the - UI-verify workflow. +# Design: property-authoring + +## Context + +Properties exist end-to-end below the UI: typed values (text/number/date/ +bool/ref) encoded into each block's Loro meta `properties` map, a derived +key→block→value index, and `prop`/`prop-eq`/`table` query primitives. The +only UI writer is the ctrl-shift-q query toggle. Query blocks already render +an attached sub-surface (the RESULT table) below block content — the display +slot this change generalizes. The sidebar was considered and rejected for +this: properties must stay colocated with their node so several blocks' +properties are visible at once. Decisions settled in the 2026-08-02 explore +session. + +## Goals / Non-Goals + +**Goals:** + +- Make the existing property system authorable: add, edit, delete on any + block, keyboard-first. +- Establish the display-and-editing surface that supertag schema-backed + fields will later render into (same rows, declared instead of inferred + types). + +**Non-Goals:** + +- Schema definition or validation (add-supertags; schemas stay Scheme per + its D3 — this change is reading (a) from the explore session). +- Inline `key:: value` syntax in block text (explicitly rejected: messy for + complicated blocks). +- Migrating the `query` flag to a meta key (noted for later; it stays a + property, hidden from the grid). +- Bulk/multi-block property editing, property renaming across the graph. + +## Decisions + +### D1: Colocated attached grid, not a sidebar panel, not inline syntax + +Property rows render below the block's content and above its descendants +(and above a query block's RESULT), as owned display in the same structural +slot the RESULT table already occupies — not child blocks, not text. This +keeps properties glanceable across many blocks simultaneously, which a +focus-reactive sidebar panel cannot do, without polluting block content. + +### D2: The grid is a separate focus surface; outline navigation stays block-only + +Up/down walk blocks, never property rows — fast vertical travel is the +outliner invariant. The grid is its own small focus world (calendar +day-selection precedent): `ctrl-shift-p` (or the context-menu item) enters +the grid of the focused block; escape returns to the block. Entering a block +with no properties opens the grid with one fresh empty row ready to fill. + +### D3: Two fold axes; the grid disclosure is document content + +Folding the block hides properties and descendants together (fold is the +density gesture; properties are detail). A separate per-block disclosure +collapses the grid alone — children stay visible — a Tabler chevron +in the bullet gutter, toggled by `ctrl-alt-p` (and click). Expanded, the +rows render directly beside the chevron like an indented child, no header +line; collapsed, one clipped line summarizes keys and values inline +(punctuation-separated; pills were considered and parked as too +Notion-adjacent for this app's quiet register). The +disclosure persists as a meta key beside `folded`, with identical semantics: +document content (survives restart, merges, follows future sync), not a +`properties` entry, invisible to property queries and indexes, absent reads +as expanded, no format bump. A collapsed grid still answers "does this block +carry data?" at a glance via the inline summary. Both states render on a +full-width raised band — the query source row's treatment (background +only, no border, full row width; 2026-08-02 impeccable pass, revised from +a floating card the same day: bands are the outline's row vocabulary, +cards read as foreign). The band is a *raised* second neutral with values +a step below body text — a recessed band made properties pop harder than +the content that owns them — and ancestor guide lines plus the focused +thread pass through it uninterrupted. The containment is what makes the +rows visibly belong to their block. Every grid row floors at the window line height, +the same trick block rows use, so moving the live input between fields +never shifts layout. + +### D4: Web-form navigation inside the grid; values are the tab stops + +Tab and shift-tab move between value fields, skipping named keys — once +supertag stubs land, tabbing through property names would double the +keypresses to fill out a block, and renaming an existing key is the rare +gesture (click the key). A key field is entered only when its row has no +name yet: entering an empty block's grid, or tab-appending a fresh row +from the last value (the append gesture — no separate add binding inside +the surface). An untouched empty row is discarded on exit or blur. +Shift-tab from the first value stops (no wrap). Enter commits the current +field; escape exits the surface. + +### D5: Values are typed by inference at commit + +Committed value text is best-effort typed, in order: ISO `YYYY-MM-DD` → +Date; parseable number → Number; `true`/`false` → Bool; a completed `[[…]]` +reference → Ref (reusing the editor's `[[`/`#` completion machinery, dates +included); anything else → Text. Rows carry no type annotation at rest (the value shows its own +type); while a row is edited, a live type label shows what commit will +store, tracking the value text as typed. This matches add-supertags D4's "best-effort typed" +ad-hoc semantics; when schemas land, schema-backed keys flip to declared +types in the same rows. Persisted rows always carry a value — there is no +key-without-value storage state; a row with an empty value exists only as +transient surface state (and, later, as schema-slot display), so the grid's +row model is "key slot, value maybe empty" from day one. + +### D6: Deletion is explicit, never commit-of-empty + +A per-row delete gesture (shift-delete inside the surface, plus a +context-menu item on the row) removes the property. Committing an emptied +value field does not delete — empty text is a legal value (`t:`), and +silent delete-on-empty eats data during editing. This gives +`Outline::remove_property` its first real consumer beyond the query toggle. + +### D7: Key fields autocomplete against the graph's property vocabulary + +The index's `properties` map keys are offered as completions while typing a +key. Cheap, and it fights key drift (`due` vs `due-date` vs `deadline`) — +which matters doubly later, since add-supertags D4 matches schema fields to +ad-hoc keys by name. + +### D8: Machine properties are filtered from the grid + +A single machine-keys constant (today exactly `["query"]`) is excluded from +grid display and editing. The flag remains a real property — `(prop +"query")` still works, ctrl-shift-q still toggles it — the grid is just a +user surface. The fold flag set the precedent that machine state should be +meta, not properties; `query` predates that and migrating it is deliberately +deferred. + +## Risks / Trade-offs + +- [A second focus surface in gpui — focus transfer, blur commits, overlay + interaction with the virtualized row list] → the calendar's day-selection + and the completion popup establish the patterns; the grid is per-focused- + block only, never multiple grids in edit mode at once. +- [Inference surprises: "2026-13-45" is Text, "007" becomes Number 7] → the + edit-time type label makes the result visible before commit; explicit type + override is an open question below, deferred rather than designed around. +- [Grid rows add vertical space for property-heavy blocks] → the per-block + disclosure (D3) is the pressure valve; density-minded users collapse. +- [`ctrl-alt-*` can collide with AltGr layouts on Windows] → bindings are + provisional; resolve against the keymap at implementation. + +## Open Questions + +1. Explicit type override (e.g. cycling the glyph to force Text for a + date-shaped string). Deferred from v1 — inference plus re-edit covers the + common cases; revisit when schema-locked types arrive and the glyph + becomes interactive anyway. +2. Exact visual weight of the grid rows (indent, mono vs proportional, + glyph placement) — resolve in dev-loop screenshot passes, per the + UI-verify workflow. diff --git a/openspec/changes/property-authoring/specs/property-authoring/spec.md b/openspec/changes/property-authoring/specs/property-authoring/spec.md index e847c11..9a588e8 100644 --- a/openspec/changes/property-authoring/specs/property-authoring/spec.md +++ b/openspec/changes/property-authoring/specs/property-authoring/spec.md @@ -1,107 +1,123 @@ -# property-authoring Specification (delta) - -## ADDED Requirements - -### Requirement: Properties render colocated with their block -Each block's properties SHALL render as structured key/value rows directly -below the block's content and above its descendants — and above a query -block's rendered result — so that the properties of multiple blocks are -visible simultaneously. Property rows are owned display, not child blocks -and not block text. Each row SHALL show the key, the value, and a glyph for -the value's type. Folding a block SHALL hide its property rows along with -its descendants. - -#### Scenario: Properties visible for multiple blocks at a glance -- **WHEN** two sibling blocks each carry a `due` property -- **THEN** both blocks' grids render their `due` rows simultaneously, - each below its block's content and above that block's children - -#### Scenario: Block fold hides the grid -- **WHEN** a block with properties and children is folded -- **THEN** neither its property rows nor its children render, and unfolding - restores both - -### Requirement: Independent grid disclosure -Each block's property grid SHALL be collapsible independently of the block's -fold state via a disclosure header showing the property count; collapsing -the grid MUST NOT hide the block's descendants. The disclosure state SHALL -persist with the document per the block-graph requirement, and an absent -state SHALL read as expanded. - -#### Scenario: Collapsed grid keeps children visible -- **WHEN** the user toggles the grid disclosure on a block with two - properties and a child -- **THEN** the rows collapse to a header line indicating two properties, - the child remains visible, and toggling again restores the rows - -### Requirement: The grid is a separate editing surface -Outline navigation SHALL remain block-only: moving focus up or down SHALL -never land on a property row. A dedicated keybinding and a context-menu item -on the block's bullet SHALL enter the focused block's grid for editing; -entering the grid of a block with no properties SHALL present one empty row -ready to fill. Inside the grid, tab and shift-tab SHALL move through key and -value fields in order; tab from the last value field SHALL append a fresh -empty row; shift-tab from the first field SHALL do nothing; escape SHALL -return focus to the block. An empty row never committed SHALL be discarded -on exit without creating a property. - -#### Scenario: Outline navigation skips property rows -- **WHEN** a block with three properties is focused and the user presses - down -- **THEN** focus moves to the next block in the outline, not into the grid - -#### Scenario: Adding the first property -- **WHEN** the user invokes the properties keybinding on a block with no - properties, types a key, tabs, types a value, and presses escape -- **THEN** the block has exactly that property, and invoking the binding - and escaping immediately on another empty block creates nothing - -#### Scenario: Tab appends a row -- **WHEN** the user is in the last value field of a block's grid and - presses tab -- **THEN** a fresh empty row is appended with its key field focused - -### Requirement: Values are typed by inference at commit -A committed value SHALL be typed best-effort: an ISO `YYYY-MM-DD` string as -a date, a parseable number as a number, `true`/`false` as a boolean, a -completed reference as a node reference (offering the editor's existing -reference completion inside the value field), and anything else as text. -The row's type glyph SHALL reflect the inferred type. Key fields SHALL -offer completion against the property keys already present in the graph. -Properties SHALL only be persisted with a value; committing a key with an -empty value SHALL NOT store a key-without-value state. - -#### Scenario: Date round-trips as a date -- **WHEN** the user authors `due` = `2026-08-05` in the grid -- **THEN** the block's `due` property is a typed date (a property query - retrieves it as a date, not a string), and the row shows the date glyph - -#### Scenario: Reference value via completion -- **WHEN** the user types `[[` in a value field and accepts the completion - for an existing page -- **THEN** the committed property is a node reference to that page - -### Requirement: Deletion is an explicit gesture -Removing a property SHALL require an explicit per-row delete action (a -keybinding within the grid and a context-menu item). Committing an emptied -value field SHALL NOT delete the property; empty text is a legal value. - -#### Scenario: Explicit delete removes the property -- **WHEN** the user deletes the `priority` row via the delete gesture -- **THEN** the block no longer has a `priority` property and the row is - gone from the grid - -#### Scenario: Emptying a value does not delete -- **WHEN** the user clears a text value to empty and commits it -- **THEN** the property still exists with an empty text value and its row - still renders - -### Requirement: Machine properties are hidden from the grid -Properties on a single machine-keys list (initially exactly the query flag) -SHALL be excluded from grid display and editing, while remaining ordinary -properties for storage, indexing, and queries. - -#### Scenario: Query flag is invisible in the grid -- **WHEN** a query block whose only property is the query flag is focused -- **THEN** its grid renders as empty (entering it presents a fresh row), - while `(prop "query")` still returns the block +# property-authoring Specification (delta) + +## ADDED Requirements + +### Requirement: Properties render colocated with their block +Each block's properties SHALL render as structured key/value rows directly +below the block's content and above its descendants — and above a query +block's rendered result — so that the properties of multiple blocks are +visible simultaneously. Property rows are owned display, not child blocks +and not block text. Each row SHALL show the key and the value, with no +type annotation at rest — a value's type is apparent from the value; the +type SHALL instead be surfaced while the property is being edited. +Reference values SHALL render in the app's reference styling and SHALL +navigate to their target on click (in rows and in the collapsed summary); +inside a value field, the editor's follow-reference shortcut navigates +likewise. Folding +a block SHALL hide its property rows along with its descendants. + +#### Scenario: Properties visible for multiple blocks at a glance +- **WHEN** two sibling blocks each carry a `due` property +- **THEN** both blocks' grids render their `due` rows simultaneously, + each below its block's content and above that block's children + +#### Scenario: Block fold hides the grid +- **WHEN** a block with properties and children is folded +- **THEN** neither its property rows nor its children render, and unfolding + restores both + +### Requirement: Independent grid disclosure +Each block's property grid SHALL be collapsible independently of the block's +fold state via a chevron disclosure control. Expanded, the rows render +directly beside the chevron, indented like a child block, with no header +line; collapsed, a single clipped line summarizes the properties inline +beside the chevron. Collapsing the grid MUST NOT hide the block's +descendants. The disclosure state SHALL +persist with the document per the block-graph requirement, and an absent +state SHALL read as expanded. + +#### Scenario: Collapsed grid keeps children visible +- **WHEN** the user toggles the grid disclosure on a block with two + properties and a child +- **THEN** the rows collapse to a single-line inline summary of the + properties, the child remains visible, and toggling again restores the + rows beside the chevron + +### Requirement: The grid is a separate editing surface +Outline navigation SHALL remain block-only: moving focus up or down SHALL +never land on a property row. A dedicated keybinding and a context-menu item +on the block's bullet SHALL enter the focused block's grid for editing; +entering the grid of a block with no properties SHALL present one empty row +ready to fill. Inside the grid, value fields are the tab stops: tab and +shift-tab SHALL move between value fields, entering a key field only when +that row has no name yet (named keys are edited by clicking them, so +schema-stubbed rows cost one keypress per value); entering the grid SHALL +land on the first value field, or the key of an unnamed row. Tab from the +last value field SHALL append a fresh empty row with its key focused; +shift-tab from the first value SHALL do nothing; escape SHALL return focus +to the block. An empty row never committed SHALL be discarded on exit +without creating a property. + +#### Scenario: Outline navigation skips property rows +- **WHEN** a block with three properties is focused and the user presses + down +- **THEN** focus moves to the next block in the outline, not into the grid + +#### Scenario: Adding the first property +- **WHEN** the user invokes the properties keybinding on a block with no + properties, types a key, tabs, types a value, and presses escape +- **THEN** the block has exactly that property, and invoking the binding + and escaping immediately on another empty block creates nothing + +#### Scenario: Tab appends a row +- **WHEN** the user is in the last value field of a block's grid and + presses tab +- **THEN** a fresh empty row is appended with its key field focused (an + unnamed row is the one case tab enters a key field) + +### Requirement: Values are typed by inference at commit +A committed value SHALL be typed best-effort: an ISO `YYYY-MM-DD` string as +a date, a parseable number as a number, `true`/`false` as a boolean, a +completed reference as a node reference (offering the editor's existing +reference completion inside the value field), and anything else as text. +While a row is being edited, a type label SHALL reflect what the +in-progress value will be stored as. Key fields SHALL +offer completion against the property keys already present in the graph. +Properties SHALL only be persisted with a value; committing a key with an +empty value SHALL NOT store a key-without-value state. + +#### Scenario: Date round-trips as a date +- **WHEN** the user authors `due` = `2026-08-05` in the grid +- **THEN** the block's `due` property is a typed date (a property query + retrieves it as a date, not a string), and while the value was being + edited the row's type label read "date" + +#### Scenario: Reference value via completion +- **WHEN** the user types `[[` in a value field and accepts the completion + for an existing page +- **THEN** the committed property is a node reference to that page + +### Requirement: Deletion is an explicit gesture +Removing a property SHALL require an explicit per-row delete action (a +keybinding within the grid and a context-menu item). Committing an emptied +value field SHALL NOT delete the property; empty text is a legal value. + +#### Scenario: Explicit delete removes the property +- **WHEN** the user deletes the `priority` row via the delete gesture +- **THEN** the block no longer has a `priority` property and the row is + gone from the grid + +#### Scenario: Emptying a value does not delete +- **WHEN** the user clears a text value to empty and commits it +- **THEN** the property still exists with an empty text value and its row + still renders + +### Requirement: Machine properties are hidden from the grid +Properties on a single machine-keys list (initially exactly the query flag) +SHALL be excluded from grid display and editing, while remaining ordinary +properties for storage, indexing, and queries. + +#### Scenario: Query flag is invisible in the grid +- **WHEN** a query block whose only property is the query flag is focused +- **THEN** its grid renders as empty (entering it presents a fresh row), + while `(prop "query")` still returns the block diff --git a/openspec/changes/property-authoring/tasks.md b/openspec/changes/property-authoring/tasks.md index e9650c0..005b32d 100644 --- a/openspec/changes/property-authoring/tasks.md +++ b/openspec/changes/property-authoring/tasks.md @@ -1,50 +1,50 @@ -# Tasks: property-authoring - -## 1. Core - -- [ ] 1.1 Add `set_properties_folded`/`properties_folded` to `Outline` - (meta key beside `folded`, same semantics: absent = expanded, not a - `properties` entry). Unit tests: round-trip through storage, absent - reads expanded, invisible to `properties()` and the index. - -## 2. Grid display - -- [ ] 2.1 Render property rows below block content, above descendants and - above a query block's RESULT: key, value, type glyph per row; - machine-keys constant (`["query"]`) filtered out. -- [ ] 2.2 Disclosure header (`▸ properties (n)`) driven by the core flag; - click and `ctrl-alt-p` toggle it; block fold hides the grid entirely. -- [ ] 2.3 UI tests: rows render for multiple blocks at once; block fold - hides grid; disclosure collapses rows but not children and survives - relaunch; query flag never renders. - -## 3. Editing surface - -- [ ] 3.1 Grid focus surface: `ctrl-shift-p` (and context-menu item) enters - the focused block's grid, presenting a fresh empty row when the block - has none; escape returns to the block; outline up/down never enter - the grid. -- [ ] 3.2 Form navigation: tab/shift-tab across key/value fields; tab from - last value appends an empty row; shift-tab at the first field stops; - untouched empty rows discarded on exit. -- [ ] 3.3 Commit semantics: inference (date/number/bool/ref/text) on value - commit via `set_property`; type glyph reflects the result; `[[` - completion inside value fields; key completion from the index's - property vocabulary; no persistence of empty rows. -- [ ] 3.4 Explicit delete: shift-delete on a row and a context-menu item, - calling `remove_property`; commit-of-empty keeps the property as - empty text. -- [ ] 3.5 UI tests: nav skips grid; first-property flow; tab-append; - empty-row discard; date/number/bool/ref inference round-trips typed - through a query; delete removes; emptied value persists as text. - -## 4. Verification - -- [ ] 4.1 Devtools dump: expose per-row grid state (keys, encoded values, - disclosure) and grid focus, for UI-test and dev-loop assertions. -- [ ] 4.2 Full check: `cargo fmt`, clippy `-D warnings`, - `cargo test --workspace`; dev-loop screenshot pass (grid on fixture - blocks, collapsed disclosure, editing surface with completion open) - and user confirmation before commit. -- [ ] 4.3 Annotate add-supertags design.md D4: the ad-hoc authoring surface - is this change's grid, not remoras' inline `key:: value` syntax. +# Tasks: property-authoring + +## 1. Core + +- [x] 1.1 Add `set_properties_folded`/`properties_folded` to `Outline` + (meta key beside `folded`, same semantics: absent = expanded, not a + `properties` entry). Unit tests: round-trip through storage, absent + reads expanded, invisible to `properties()` and the index. + +## 2. Grid display + +- [x] 2.1 Render property rows below block content, above descendants and + above a query block's RESULT: key, value, type glyph per row; + machine-keys constant (`["query"]`) filtered out. +- [x] 2.2 Disclosure header (`▸ properties (n)`) driven by the core flag; + click and `ctrl-alt-p` toggle it; block fold hides the grid entirely. +- [x] 2.3 UI tests: rows render for multiple blocks at once; block fold + hides grid; disclosure collapses rows but not children and survives + relaunch; query flag never renders. + +## 3. Editing surface + +- [x] 3.1 Grid focus surface: `ctrl-shift-p` (and context-menu item) enters + the focused block's grid, presenting a fresh empty row when the block + has none; escape returns to the block; outline up/down never enter + the grid. +- [x] 3.2 Form navigation: tab/shift-tab across key/value fields; tab from + last value appends an empty row; shift-tab at the first field stops; + untouched empty rows discarded on exit. +- [x] 3.3 Commit semantics: inference (date/number/bool/ref/text) on value + commit via `set_property`; type glyph reflects the result; `[[` + completion inside value fields; key completion from the index's + property vocabulary; no persistence of empty rows. +- [x] 3.4 Explicit delete: shift-delete on a row and a context-menu item, + calling `remove_property`; commit-of-empty keeps the property as + empty text. +- [x] 3.5 UI tests: nav skips grid; first-property flow; tab-append; + empty-row discard; date/number/bool/ref inference round-trips typed + through a query; delete removes; emptied value persists as text. + +## 4. Verification + +- [x] 4.1 Devtools dump: expose per-row grid state (keys, encoded values, + disclosure) and grid focus, for UI-test and dev-loop assertions. +- [x] 4.2 Full check: `cargo fmt`, clippy `-D warnings`, + `cargo test --workspace`; dev-loop screenshot pass (grid on fixture + blocks, collapsed disclosure, editing surface with completion open) + and user confirmation before commit. +- [x] 4.3 Annotate add-supertags design.md D4: the ad-hoc authoring surface + is this change's grid, not remoras' inline `key:: value` syntax. -- 2.51.2