diff --git a/CLAUDE.md b/CLAUDE.md index 2f740f9..0cd4a3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,8 +161,8 @@ The **one typed API**. The CLI and the desktop host are its only clients; every module is called directly only by the integration tests. Surface: lifecycle + indexing (`open` / `open_with_embedder` / `reindex` / `reindex_with_progress` / `plan_reindex` / `project` / `embed`), reads (`read` / `list_notes` / `neighbors` / `explain` / `search` / `similar`), writes (`add_note` / -`move_note` / `link` / `write`). **Add operations when a command needs them; do not pre-build a broad -surface.** The embedder is injected here: `open` defaults to the fake, `open_with_embedder` is how the +`create_note` / `move_note` / `link` / `write`). **Add operations when a command needs them; do not +pre-build a broad surface.** The embedder is injected here: `open` defaults to the fake, `open_with_embedder` is how the adapters wire the real model. ### Data flows diff --git a/crates/b2-core/src/add.rs b/crates/b2-core/src/add.rs index 0a250d4..d6be2bc 100644 --- a/crates/b2-core/src/add.rs +++ b/crates/b2-core/src/add.rs @@ -58,27 +58,64 @@ pub fn add_note( content: Option<&str>, created: &str, ) -> Result { + let rel = write_new_note(vault_root, path_input, title, content, created)?; + + // 2. Project from that Markdown: stamp the `b2id`, chunk + embed the body, and + // derive any edges its content authors. + let ingested = ingest::ingest_file(conn, vault_root, &rel, idgen, embedder)?; + Ok(AddReport { + b2id: ingested.b2id, + path: rel, + }) +} + +/// The **model-free** sibling of [`add_note`] — the desktop's New-note action +/// (`Vault::create_note`): same file write, but the projection is +/// [`ingest::project_file`] (chunks + FTS + edges, **no embedder**), the same pass +/// `Vault::write` runs after a save. The new note's chunks join the DB-derived +/// missing-vector set for any later embed/reindex to fill +/// (projection-embedding-split.md §7.2) — and a body-less note has nothing to +/// embed anyway. Same validation and refusals as [`add_note`]. +pub fn create_note( + conn: &Connection, + idgen: &dyn IdGen, + vault_root: &Path, + path_input: &str, + title: Option<&str>, + content: Option<&str>, + created: &str, +) -> Result { + let rel = write_new_note(vault_root, path_input, title, content, created)?; + let projected = ingest::project_file(conn, vault_root, &rel, idgen)?; + Ok(AddReport { + b2id: projected.b2id, + path: rel, + }) +} + +/// The shared create step: validate `path_input`, refuse to clobber, render the +/// minimal frontmatter + body, and write the new file (creating missing parent +/// dirs). Markdown first (step 1 of both entry points) — the `b2id` is deliberately +/// left off; ingest/projection stamps it on first sight (§1). Returns the +/// vault-relative `.md` path. +fn write_new_note( + vault_root: &Path, + path_input: &str, + title: Option<&str>, + content: Option<&str>, + created: &str, +) -> Result { let rel = crate::pathspec::normalize_rel_md(path_input).map_err(Error::AddDestination)?; let abs = vault_root.join(&rel); if abs.exists() { return Err(Error::AddTargetExists(rel)); } - - // 1. Markdown first: write the new file (creating any missing parent dirs). The - // `b2id` is deliberately left off — ingest stamps it on first sight (§1). let doc = render_note(title, content, created); if let Some(parent) = abs.parent() { fs::create_dir_all(parent)?; } fs::write(&abs, doc)?; - - // 2. Project from that Markdown: stamp the `b2id`, chunk + embed the body, and - // derive any edges its content authors. - let ingested = ingest::ingest_file(conn, vault_root, &rel, idgen, embedder)?; - Ok(AddReport { - b2id: ingested.b2id, - path: rel, - }) + Ok(rel) } /// Render a new note's text: a minimal valid frontmatter block followed by the body. diff --git a/crates/b2-core/src/vault.rs b/crates/b2-core/src/vault.rs index 3a077ef..71268f5 100644 --- a/crates/b2-core/src/vault.rs +++ b/crates/b2-core/src/vault.rs @@ -4,8 +4,8 @@ //! (and future adapters) are the sole clients of. It owns the open connection, the //! embedder, and the id generator, and exposes *only what the shipped commands need* //! — `open` / `reindex` / `project` / `embed` / `read` / `write` / `neighbors` / -//! `explain` / `search` / `similar` / `link` / `add` / `mv`. Add operations when a -//! command needs them; do not pre-build a sprawling surface. +//! `explain` / `search` / `similar` / `link` / `add` / `create` / `mv`. Add +//! operations when a command needs them; do not pre-build a sprawling surface. //! //! A vault is one portable folder: the index lives under `/.b2/` (there is no //! durable state outside the Markdown — data-model.md §4), so pointing B2 at a folder @@ -1042,6 +1042,31 @@ impl Vault { ) } + /// Create a new, empty note **model-free** — the desktop's New-note action + /// (its tree affordance / ⌘N), the create sibling of [`write`](Self::write): + /// write `path` with the same minimal frontmatter as [`add_note`](Self::add_note) + /// (no title — a note's display title is its filename, data-model.md §1) and + /// project it via [`ingest::project_file`] with **no embedder touched**, so + /// creation works with no model provisioned and a fake-opened vault can never + /// write foreign vectors into a real-model embedding space. The note's chunks + /// join the DB-derived missing-vector set, healed by any later + /// [`embed`](Self::embed)/reindex (projection-embedding-split.md §7.2) — and an + /// empty body has nothing to embed anyway. Same refusals as `add_note`: + /// [`Error::AddDestination`] / [`Error::AddTargetExists`]. + pub fn create_note(&self, path: &str) -> Result { + let _op = tracing::debug_span!(target: "b2::vault", "create", path).entered(); + let created = self.today()?; + add::create_note( + &self.conn, + &self.idgen, + &self.root, + path, + None, + None, + &created, + ) + } + /// Today's date (`YYYY-MM-DD`) from **SQLite** — the same clock that stamps /// `indexed_at`, so `b2-core` needs no wall-clock crate and the façade is the /// determinism boundary (as it is for `idgen`). The vault convention for a note's diff --git a/crates/b2-core/tests/add.rs b/crates/b2-core/tests/add.rs index 9170065..4f3fac9 100644 --- a/crates/b2-core/tests/add.rs +++ b/crates/b2-core/tests/add.rs @@ -150,6 +150,61 @@ fn add_refuses_to_clobber_an_existing_file() { ); } +#[test] +fn create_note_writes_a_stamped_minimal_note_model_free() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, root) = reindexed(tmp.path()); + let before = vault.embed_status().unwrap(); + + let report = vault.create_note("inbox/idea").unwrap(); + assert_eq!(report.path, "inbox/idea.md"); + assert!(!report.b2id.is_empty()); + + // On disk: the minimal frontmatter (no title — the display title is the + // filename, data-model.md §1), stamped, body-less, in a freshly-created dir. + let text = fs::read_to_string(root.join("inbox/idea.md")).unwrap(); + assert!(text.contains(&format!("b2id: {}", report.b2id)), "{text}"); + assert!(text.contains("type: note"), "{text}"); + assert!(text.contains("created:"), "{text}"); + assert!(!text.contains("title:"), "{text}"); + + // Projected: it resolves by path and b2id, and the tree lists it. + assert!(vault.explain("inbox/idea").is_ok()); + assert!(vault.explain(&report.b2id).is_ok()); + assert!(vault + .list_notes() + .unwrap() + .iter() + .any(|n| n.path == "inbox/idea.md")); + + // Model-free: the embedding space is untouched — coverage gains no embedded + // note (an empty body has no chunks; a later embed/reindex owns any vectors). + let after = vault.embed_status().unwrap(); + assert_eq!( + after.embedded, before.embedded, + "create_note must never embed" + ); + assert_eq!(after.total, before.total + 1); +} + +#[test] +fn create_note_refuses_clobber_and_invalid_paths() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, _root) = reindexed(tmp.path()); + + let err = vault.create_note("concepts/memory").unwrap_err(); + assert!(matches!(err, Error::AddTargetExists(p) if p == "concepts/memory.md")); + for bad in ["../escape", "/abs/path", " "] { + assert!( + matches!( + vault.create_note(bad).unwrap_err(), + Error::AddDestination(_) + ), + "path {bad:?} must be rejected" + ); + } +} + #[test] fn add_rejects_an_invalid_path() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/b2-core/tests/explain.rs b/crates/b2-core/tests/explain.rs index 61160bc..299d1dd 100644 --- a/crates/b2-core/tests/explain.rs +++ b/crates/b2-core/tests/explain.rs @@ -98,7 +98,11 @@ fn explain_surfaces_outbound_resource_links() { assert_eq!(r.caption.as_deref(), Some("a tiny diagram")); assert!(r.embed, "an image embed reads as embed=true"); // A note with no file links reports an empty list, never an error. - assert!(vault.explain("concepts/memory").unwrap().resources.is_empty()); + assert!(vault + .explain("concepts/memory") + .unwrap() + .resources + .is_empty()); } #[test] diff --git a/crates/b2-desktop/CLAUDE.md b/crates/b2-desktop/CLAUDE.md index fb06e1c..463b15e 100644 --- a/crates/b2-desktop/CLAUDE.md +++ b/crates/b2-desktop/CLAUDE.md @@ -60,12 +60,14 @@ add a UI concern to `b2-core`, that's the signal you're putting logic in the wro ([`Vault::open`](../b2-core/src/vault.rs)); anything that embeds a query or writes vectors (`search`, `link`'s re-projection, `embed`) opens the real model ([`Vault::open_with_embedder`](../b2-core/src/vault.rs)) and fails fast with the "run `b2 init`" - message if it's absent. Two write-side ops are deliberately **model-free** and open the fake: + message if it's absent. Three write-side ops are deliberately **model-free** and open the fake: `project` — the model-free half of a reindex ([specs/completed/projection-embedding-split.md](../../planning/specs/completed/projection-embedding-split.md) §6), - so the first tree paint never waits on a model load — and `write_note` — the save path + so the first tree paint never waits on a model load — `write_note` — the save path ([specs/completed/desktop-editing.md](../../planning/specs/completed/desktop-editing.md) §3), so editing works with no - model provisioned and saved chunks are healed by the trailing background embed. + model provisioned and saved chunks are healed by the trailing background embed — and + `create_note` — the tree's New-note action, the same posture as the save path (the new + note is projected immediately; its vectors fill on the next embed pass). - **Errors stay generic to the webview.** Map façade errors to user-facing, actionable messages exactly as the CLI funnels through `user_message` in [`b2-cli/src/main.rs`](../b2-cli/src/main.rs) — **never** leak sqlite/io/serde internals into the UI. Use a `thiserror` enum for this crate's errors (matched → mapped), diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index 47fd90c..1e2d073 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -18,6 +18,7 @@ use crate::error::CmdError; use crate::watch::VaultWatcher; use crate::{open_vault, AppState}; +use b2_core::add::AddReport; use b2_core::ingest::ReindexProgress; use b2_core::vault::{ EmbedReport, ExplainView, LinkReport, NeighborView, NoteSummary, NoteView, ProjectReport, @@ -162,6 +163,20 @@ pub fn write_note( write_note_impl(state.inner(), ¬e, &body, &base_revision) } +/// Create a new, empty note — the file tree's New-note action (and ⌘N). **Model-free** +/// like `write_note`/`project`: `Vault::create_note` writes the file and projects it +/// without touching vectors, so creating works with no model provisioned, and it runs +/// outside the single-in-flight embed slot (short, and a fake-opened vault must never +/// write into a real-model embedding space). The note's chunks join the DB-derived +/// pending set for the next embed pass — and an empty body has nothing to embed anyway. +/// Folders need no command: they exist only as file paths (the tree is index-derived), +/// so a staged folder materializes when its first note is created inside it +/// (`create_note` creates missing parent dirs, mirroring `b2 add`). +#[tauri::command(async)] +pub fn create_note(state: State<'_, AppState>, path: String) -> Result { + create_note_impl(state.inner(), &path) +} + #[tauri::command(async)] pub fn similar( state: State<'_, AppState>, @@ -448,6 +463,11 @@ fn write_note_impl( Ok(vault.write(note, body, base_revision)?) } +fn create_note_impl(state: &AppState, path: &str) -> Result { + let (vault, _) = open_vault(state, false)?; + Ok(vault.create_note(path)?) +} + /// The testable core of `set_model`. `EmbedConfig::set_model` validates the id against /// the registry *before* any filesystem write, so the unknown-model path is hermetic (no /// file touched); the real-config write itself is exercised by `b2-embed`'s `write_model` @@ -731,6 +751,57 @@ mod tests { write_note_impl(&state, "concepts/memory", "Again.\n", &report.revision).unwrap(); } + #[test] + fn create_note_projects_and_lists_model_free() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().join("vault"); + golden_indexed(&root); + let state = AppState::new(Some(root.clone())); + + // Creating into a not-yet-existing folder materializes it (the staged-folder + // flow: the tree's "new folder" is UI state until its first note lands). + let report = create_note_impl(&state, "inbox/idea").unwrap(); + assert_eq!(report.path, "inbox/idea.md"); + assert!(root.join("inbox/idea.md").is_file()); + + // Immediately in the tree and readable (projected, no model touched). + let notes = list_notes_impl(&state).unwrap(); + assert!(notes.iter().any(|n| n.path == "inbox/idea.md")); + let note = read_note_impl(&state, "inbox/idea").unwrap(); + assert_eq!(note.body, ""); + assert_eq!(note.b2id, report.b2id); + } + + #[test] + fn create_note_refusals_stay_generic_and_actionable() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().join("vault"); + golden_indexed(&root); + let state = AppState::new(Some(root)); + + // Clobber refusal names the path and the way out. + let err = create_note_impl(&state, "concepts/memory").unwrap_err(); + assert!(matches!( + err, + CmdError::Core(b2_core::Error::AddTargetExists(_)) + )); + assert_eq!( + user_message(&err), + "A note already exists at 'concepts/memory.md'. Choose a different name, or open that note." + ); + + // An invalid destination is refused with actionable phrasing, no internals. + let err = create_note_impl(&state, "../escape").unwrap_err(); + assert!(matches!( + err, + CmdError::Core(b2_core::Error::AddDestination(_)) + )); + assert_eq!( + user_message(&err), + "That note name isn't valid. Give a vault-relative name like `notes/new-idea`." + ); + } + #[test] fn write_conflict_is_generic_and_recognizable() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/b2-desktop/src/error.rs b/crates/b2-desktop/src/error.rs index 3a85025..441b497 100644 --- a/crates/b2-desktop/src/error.rs +++ b/crates/b2-desktop/src/error.rs @@ -65,6 +65,13 @@ pub fn user_message(err: &CmdError) -> String { CmdError::Core(b2_core::Error::InvalidRelation(v)) => format!( "'{v}' isn't a known relation type. Use one of: references, relates, elaborates, supports, refutes, contradicts, example-of, part-of, supersedes, derived-from." ), + CmdError::Core(b2_core::Error::AddTargetExists(p)) => format!( + "A note already exists at '{p}'. Choose a different name, or open that note." + ), + CmdError::Core(b2_core::Error::AddDestination(_)) => { + "That note name isn't valid. Give a vault-relative name like `notes/new-idea`." + .to_string() + } CmdError::Core(b2_core::Error::WriteConflict(_)) => { "This note changed on disk since it was opened. Reload the note, then reapply your edit." .to_string() diff --git a/crates/b2-desktop/src/main.rs b/crates/b2-desktop/src/main.rs index 3a1f5b6..b05085d 100644 --- a/crates/b2-desktop/src/main.rs +++ b/crates/b2-desktop/src/main.rs @@ -315,6 +315,7 @@ fn main() { commands::explain_resource, commands::open_resource, commands::write_note, + commands::create_note, commands::similar, commands::search, commands::neighbors, diff --git a/ui/package.json b/ui/package.json index 54a012e..61c0a85 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "test": "node --experimental-strip-types src/panes.test.ts && node --experimental-strip-types src/graph.test.ts" + "test": "node --experimental-strip-types src/panes.test.ts && node --experimental-strip-types src/graph.test.ts && node --experimental-strip-types src/newentry.test.ts" }, "dependencies": { "@codemirror/commands": "^6.0.0", diff --git a/ui/src/api.ts b/ui/src/api.ts index 19193da..51c6ba2 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -7,6 +7,7 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { + AddReport, EmbedReport, EmbedStat, ExplainView, @@ -106,6 +107,14 @@ export const api = { writeNote: (note: string, body: string, baseRevision: string): Promise => invoke("write_note", { note, body, baseRevision }), + /** + * Create a new, empty note at a vault-relative path (`.md` optional; missing + * parent folders are created — how a staged folder becomes real). Model-free + * like `writeNote`: the note is projected immediately (tree/search/graph) and + * its vectors fill on the next embed pass. + */ + createNote: (path: string): Promise => invoke("create_note", { path }), + /** A note's typed neighbors (both directions). */ neighbors: (note: string): Promise => invoke("neighbors", { note }), diff --git a/ui/src/main.ts b/ui/src/main.ts index 1891ff0..abfe60c 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -12,6 +12,7 @@ import { Compartment, type Extension } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import { api, errText, isWriteConflict } from "./api"; import { state, type SideSection, type ThemePref } from "./state"; +import { dirChain, joinPath, normalizeName, parentDir } from "./newentry"; import { livePreview, wikilink } from "./livepreview"; import { BOUNDS, initPanes } from "./panes"; import { @@ -34,9 +35,36 @@ function el(id: string): HTMLElement { // The note pane's last-written HTML, for the render memo below. Cleared whenever the // pane is owned imperatively (edit mode writes its own DOM) so exiting always repaints. let lastNotePaneHtml: string | null = null; +// The tree pane's memo — same idea, different reason: while an inline create input +// is open (`state.treeCreate`), its typed name lives only in the DOM, so an +// unrelated repaint (a toast timer, streamed progress) must not rebuild the pane +// under the user's cursor. Identical HTML skips the swap entirely; a real tree +// change swaps and then restores the input's value, caret, and focus below. +let lastTreePaneHtml: string | null = null; + +/** Repaint the tree pane (memoized), carrying an open create input across the swap. */ +function paintTree(): void { + const html = treePaneHtml(state); + if (html === lastTreePaneHtml) return; + const prev = document.getElementById("tree-create-input") as HTMLInputElement | null; + const saved = + prev && state.treeCreate + ? { value: prev.value, start: prev.selectionStart, end: prev.selectionEnd } + : null; + el("tree-pane").innerHTML = html; + lastTreePaneHtml = html; + const input = document.getElementById("tree-create-input") as HTMLInputElement | null; + if (input) { + if (saved) { + input.value = saved.value; + input.setSelectionRange(saved.start ?? saved.value.length, saved.end ?? saved.value.length); + } + input.focus(); + } +} function render(): void { - el("tree-pane").innerHTML = treePaneHtml(state); + paintTree(); // The carve-out (desktop-editing.md §6): while editing, the note pane belongs to // the live EditorView — rebuilding it here (e.g. from a toast timer) would destroy // the editor mid-keystroke. Everything else keeps rendering. @@ -181,6 +209,7 @@ async function loadNote(ref: string, commit: (path: string) => void): Promise void): Promi state.current = null; commit(resource.path); expandAncestors(resource.path); + state.selectedDir = parentDir(resource.path); // the create context follows the selection state.searchQuery = ""; state.searchResults = []; state.similar = []; @@ -368,6 +398,7 @@ async function navGo(delta: -1 | 1): Promise { function toggleDir(path: string): void { if (state.expandedDirs.has(path)) state.expandedDirs.delete(path); else state.expandedDirs.add(path); + state.selectedDir = path; // clicking a folder also makes it the create context render(); } @@ -392,18 +423,31 @@ function toggleCard(key: string): void { render(); } -// --- discovery-card context menu -------------------------------------------------- +// --- context menus (discovery cards + the file tree) ------------------------------ // -// Right-click a Similar card → a small floating menu (Open note / Link…), replacing the -// inline "Link…" button. Anchored at the cursor, but clamped so it never spills past the +// Right-click a Similar card → Open note / Link… (replacing the inline "Link…" +// button); right-click the file tree → New note / New folder in the folder under +// the cursor. Anchored at the cursor, but clamped so a menu never spills past the // viewport edge (a menu that opens off-screen is unusable). const CTX_MENU_W = 168; -const CTX_MENU_H = 76; +const CARD_MENU_H = 76; +const TREE_MENU_H = 100; // the context line + two items -function openContextMenu(clientX: number, clientY: number, path: string, title: string): void { +function clampMenu(clientX: number, clientY: number, height: number): { x: number; y: number } { const x = Math.min(clientX, window.innerWidth - CTX_MENU_W - 8); - const y = Math.min(clientY, window.innerHeight - CTX_MENU_H - 8); - state.contextMenu = { x: Math.max(8, x), y: Math.max(8, y), path, title: title || null }; + const y = Math.min(clientY, window.innerHeight - height - 8); + return { x: Math.max(8, x), y: Math.max(8, y) }; +} + +function openCardMenu(clientX: number, clientY: number, path: string, title: string): void { + const { x, y } = clampMenu(clientX, clientY, CARD_MENU_H); + state.contextMenu = { kind: "card", x, y, path, title: title || null }; + render(); +} + +function openTreeMenu(clientX: number, clientY: number, dir: string): void { + const { x, y } = clampMenu(clientX, clientY, TREE_MENU_H); + state.contextMenu = { kind: "tree", x, y, dir }; render(); } @@ -413,6 +457,81 @@ function closeContextMenu(): void { render(); } +// --- tree creation: new note / new folder (left nav) ------------------------------ +// +// The create affordances: the tree-head icons, ⌘N / ⇧⌘N, and the tree's right-click +// menu — all contextual, landing the entry in `state.selectedDir` (which follows +// the selection: the open document's folder, or the last folder clicked). The name +// is typed into an inline input row in the tree (Enter commits, Escape cancels, +// blur commits a non-empty name). +// +// A new *note* is real — and auto-indexed — immediately: the model-free +// `create_note` writes the file and projects it (tree, keyword search, graph), and +// its vectors fill through the normal editing pipeline (the note opens in edit +// mode; autosave's trailing embed covers whatever gets typed — an empty body has +// nothing to embed). A new *folder* is staged UI state (`pendingDirs`): the +// index-derived tree can't list an empty dir and nothing durable lives outside the +// Markdown, so B2 writes no empty folder — it materializes on disk when its first +// note is created inside it (`create_note` creates missing parent dirs). + +function startTreeCreate(kind: "note" | "folder", dir: string): void { + if (state.vaultRoot === null) return; + state.contextMenu = null; + state.treeCreate = { kind, dir }; + for (const d of dirChain(dir)) state.expandedDirs.add(d); // reveal the target folder + render(); // paintTree focuses the fresh input +} + +function cancelTreeCreate(): void { + if (!state.treeCreate) return; + state.treeCreate = null; + render(); +} + +/** + * Commit the inline input's name. `open` distinguishes the two commit gestures: + * Enter means "create and start writing" (the note opens in edit mode); a blur + * commit (the user clicked into something else) creates quietly and leaves their + * click's navigation alone. + */ +async function commitTreeCreate(raw: string, open: boolean): Promise { + const create = state.treeCreate; + if (!create) return; + const name = normalizeName(raw); + if (name === null) { + cancelTreeCreate(); // an empty (or traversal) name is a back-out, not an error + return; + } + const path = joinPath(create.dir, name); + state.treeCreate = null; + if (create.kind === "folder") { + for (const d of dirChain(path)) { + state.pendingDirs.add(d); + state.expandedDirs.add(d); + } + state.selectedDir = path; // the natural next step is a note inside it + render(); + return; + } + try { + const report = await api.createNote(path); + await loadNotes(); // the tree lists it now — create_note already projected it + void refreshEmbedStatus(state.vaultRoot); // the N/M denominator grew (#26) + if (open) { + await openNote(report.path); // sets selectedDir to the new note's folder + enterEdit(); // a fresh, empty note wants a cursor, not a reading view + } else { + flash(`Created ${report.path}.`); + } + } catch (e) { + // Refused (e.g. the name already exists): keep the input open — with the typed + // name intact, since the unchanged tree HTML skips the repaint — so the user + // adjusts rather than retypes; the toast explains. + state.treeCreate = create; + flash(errText(e)); + } +} + // --- the anchored ghost graph (GH #22) -------------------------------------------- // // The center pane's third mode. Both toggles below are pure state flips — the scene @@ -740,6 +859,9 @@ async function switchVault(): Promise { state.searchQuery = ""; state.searchResults = []; state.expandedDirs = new Set(); + state.selectedDir = ""; // the create context belongs to the vault we left… + state.pendingDirs = new Set(); // …as do any staged, still-empty folders + state.treeCreate = null; navClear(); // history is per-vault: the old stack's paths mean nothing here const input = document.getElementById("search-input") as HTMLInputElement | null; if (input) input.value = ""; @@ -1403,17 +1525,30 @@ function wireEvents(): void { document.addEventListener("click", (e) => { const target = e.target as HTMLElement; - // The discovery-card right-click menu owns the next click: its own items act, any - // other click merely dismisses it (a menu-dismissing click isn't also a card click). + // An open right-click menu owns the next click: its own items act, any other + // click merely dismisses it (a menu-dismissing click isn't also a card click). if (state.contextMenu) { + const menu = state.contextMenu; + if (menu.kind === "tree") { + if (target.closest("[data-ctx-new-note]")) { + startTreeCreate("note", menu.dir); // clears the menu itself + return; + } + if (target.closest("[data-ctx-new-folder]")) { + startTreeCreate("folder", menu.dir); + return; + } + closeContextMenu(); + return; + } if (target.closest("[data-ctx-open]")) { - const p = state.contextMenu.path; + const p = menu.path; closeContextMenu(); void openNote(p); return; } if (target.closest("[data-ctx-link]")) { - const { path, title } = state.contextMenu; + const { path, title } = menu; closeContextMenu(); openLinkModal(path, title ?? ""); return; @@ -1422,6 +1557,16 @@ function wireEvents(): void { return; } + // The tree-head create icons — contextual on the selection's folder. + if (target.closest("[data-new-note]")) { + startTreeCreate("note", state.selectedDir); + return; + } + if (target.closest("[data-new-folder]")) { + startTreeCreate("folder", state.selectedDir); + return; + } + if (target.closest("#open-settings")) { void openSettings(); return; @@ -1580,19 +1725,31 @@ function wireEvents(): void { } }); - // Right-click a Similar card — or a ghost node in the graph (same latent - // candidate, same menu: Open note / Link…). Only these intercept the default - // menu; everywhere else the webview's stays untouched. + // Right-click surfaces. The file tree's default menu is taken over wholesale: + // New note / New folder, contextual on the row under the cursor — a folder row + // targets itself, a file row its parent folder, the pane's empty space the vault + // root — and, like a click, the right-click also moves the selection context. + // Similar cards — and ghost nodes in the graph (same latent candidate) — keep + // their menu (Open note / Link…). Everywhere else the webview's stays untouched. document.addEventListener("contextmenu", (e) => { - const card = (e.target as HTMLElement).closest(".card.candidate, .gnode.is-ghost"); + const target = e.target as HTMLElement; + if (target.closest("#tree-pane") && state.vaultRoot !== null) { + e.preventDefault(); + const dirRow = target.closest("[data-dir]"); + const fileRow = target.closest("[data-open], [data-open-resource]"); + const dir = dirRow + ? (dirRow.dataset.dir ?? "") + : fileRow + ? parentDir(fileRow.dataset.open ?? fileRow.dataset.openResource ?? "") + : ""; + state.selectedDir = dir; + openTreeMenu(e.clientX, e.clientY, dir); + return; + } + const card = target.closest(".card.candidate, .gnode.is-ghost"); if (!card) return; e.preventDefault(); - openContextMenu( - e.clientX, - e.clientY, - card.dataset.cardPath ?? "", - card.dataset.cardTitle ?? "", - ); + openCardMenu(e.clientX, e.clientY, card.dataset.cardPath ?? "", card.dataset.cardTitle ?? ""); }); // The floating menu is positioned at fixed viewport coords, so any scroll or resize @@ -1623,10 +1780,40 @@ function wireEvents(): void { } }); + // The inline create input commits on blur (a non-empty name — clicking away is a + // "yes, make it", VS Code-style; empty backs out). `isConnected` distinguishes a + // real blur from the input being torn down by a tree repaint or its own commit — + // a removed node must never re-commit. + document.addEventListener("focusout", (e) => { + const t = e.target as HTMLElement; + if (t.id === "tree-create-input" && t.isConnected && state.treeCreate) { + void commitTreeCreate((t as HTMLInputElement).value, false); + } + }); + // ⌘, toggles Settings (the macOS Preferences reflex); Escape closes whichever modal is // up; Cmd/Ctrl+S forces an immediate flush while editing (autosave means it's never - // *required* — this is for the reflex). + // *required* — this is for the reflex); ⌘N / ⇧⌘N create a note / folder in the + // selection's folder (the tree-head icons' shortcuts). document.addEventListener("keydown", (e) => { + // The tree's inline create input owns its keys first: Enter commits, Escape + // cancels, and nothing else typed there leaks into the global chords below. + if (state.treeCreate && (e.target as HTMLElement).id === "tree-create-input") { + if (e.key === "Enter") { + e.preventDefault(); + void commitTreeCreate((e.target as HTMLInputElement).value, true); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelTreeCreate(); + } + return; + } + if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "n") { + if (state.settingsOpen || state.linkTarget) return; // a modal owns the keyboard + e.preventDefault(); + startTreeCreate(e.shiftKey ? "folder" : "note", state.selectedDir); + return; + } if ((e.metaKey || e.ctrlKey) && e.key === ",") { e.preventDefault(); if (state.settingsOpen) closeSettings(); diff --git a/ui/src/newentry.test.ts b/ui/src/newentry.test.ts new file mode 100644 index 0000000..f8d849e --- /dev/null +++ b/ui/src/newentry.test.ts @@ -0,0 +1,76 @@ +// The tree-creation path rules (newentry.ts), pinned. Pure string logic — no DOM — +// so node runs it straight off the source via its native type-stripping: `npm test`. +// Dependency-free like panes.test.ts (hand-rolled assert; no @types/node). +import { dirChain, joinPath, normalizeName, parentDir } from "./newentry.ts"; + +let passed = 0; + +function assert(cond: boolean, msg: string): void { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} +function equal(actual: string | null, expected: string | null, msg: string): void { + assert(actual === expected, `${msg} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} +function check(name: string, fn: () => void): void { + fn(); + passed++; + console.log(` ok ${name}`); +} + +// --- parentDir: the selection's folder context -------------------------------------- + +check("a nested path's parent is its folder", () => { + equal(parentDir("concepts/memory.md"), "concepts", "one level"); + equal(parentDir("a/b/c.md"), "a/b", "two levels"); +}); + +check("a root-level path's parent is the root", () => { + equal(parentDir("solo.md"), "", "root file"); + equal(parentDir(""), "", "empty stays root"); +}); + +// --- normalizeName: forgiving shape, refused traversal ------------------------------ + +check("a plain name passes through trimmed", () => { + equal(normalizeName(" my idea "), "my idea", "trimmed"); + equal(normalizeName("note.md"), "note.md", "an explicit .md is kept"); +}); + +check("nesting is allowed and separators are normalized", () => { + equal(normalizeName("projects/2026"), "projects/2026", "nested"); + equal(normalizeName("a\\b"), "a/b", "backslash counts as a slash"); + equal(normalizeName("/a//b/"), "a/b", "stray + doubled slashes drop"); + equal(normalizeName(" a / b "), "a/b", "per-segment trim"); +}); + +check("an empty input is a cancel (null), never an error", () => { + equal(normalizeName(""), null, "empty"); + equal(normalizeName(" "), null, "whitespace"); + equal(normalizeName("//"), null, "only slashes"); +}); + +check("traversal segments are refused", () => { + equal(normalizeName(".."), null, "plain .."); + equal(normalizeName("a/../b"), null, "embedded .."); + equal(normalizeName("./a"), null, ". segment"); +}); + +// --- joinPath: context + name -------------------------------------------------------- + +check("joins against a folder context, or stands alone at the root", () => { + equal(joinPath("projects", "idea"), "projects/idea", "in a folder"); + equal(joinPath("", "idea"), "idea", "at the root"); +}); + +// --- dirChain: staging + reveal ------------------------------------------------------ + +check("every prefix of a nested folder, shallowest first", () => { + const chain = dirChain("a/b/c"); + equal(chain.join("|"), "a|a/b|a/b/c", "the full chain"); +}); + +check("the root yields no chain", () => { + equal(dirChain("").length === 0 ? "empty" : "not", "empty", "no prefixes"); +}); + +console.log(`newentry: ${passed} checks passed`); diff --git a/ui/src/newentry.ts b/ui/src/newentry.ts new file mode 100644 index 0000000..e9fc759 --- /dev/null +++ b/ui/src/newentry.ts @@ -0,0 +1,52 @@ +// Pure path logic for the tree's create affordances (new note / new folder) — no +// DOM, no IPC — so node runs its test straight off the source (`npm test`), like +// panes.ts/graph.ts. The host re-validates every path (`create_note` refuses +// absolute/escaping/occupied destinations); these helpers just resolve the +// creation *context* and keep honest input from round-tripping through a generic +// error. + +/** The folder containing `path` ("" for a root-level entry). */ +export function parentDir(path: string): string { + const i = path.lastIndexOf("/"); + return i < 0 ? "" : path.slice(0, i); +} + +/** + * Normalize a typed entry name into a clean vault-relative fragment, or null when + * nothing valid was typed (a null is a *cancel*, not an error — an empty input is + * how you back out). Forgiving on shape — trims, treats `\` as `/`, drops empty + * segments (so `a//b`, `/a`, `a/` all resolve) and allows nesting + * (`projects/2026`) — but refuses traversal (`.`/`..` segments). + */ +export function normalizeName(input: string): string | null { + const segs = input + .replace(/\\/g, "/") + .split("/") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (segs.length === 0) return null; + if (segs.some((s) => s === "." || s === "..")) return null; + return segs.join("/"); +} + +/** Join a context folder and a normalized name into a vault-relative path. */ +export function joinPath(dir: string, name: string): string { + return dir ? `${dir}/${name}` : name; +} + +/** + * Every folder prefix of `path`, shallowest first: `a/b/c` → `["a","a/b","a/b/c"]`. + * Empty for "" — the root needs no expansion or staging. Feeds both the staged + * `pendingDirs` set (each level renders as a folder) and `expandedDirs` (reveal + * the whole chain down to a new entry). + */ +export function dirChain(path: string): string[] { + if (!path) return []; + const out: string[] = []; + let acc = ""; + for (const seg of path.split("/")) { + acc = acc ? `${acc}/${seg}` : seg; + out.push(acc); + } + return out; +} diff --git a/ui/src/render.ts b/ui/src/render.ts index 1dd0bf5..bc11366 100644 --- a/ui/src/render.ts +++ b/ui/src/render.ts @@ -101,13 +101,20 @@ const CLASS_GLYPHS: Record = { binary: "◆", }; -/** Fold the flat, path-ordered note + resource lists into one nested folder tree. */ -function buildTree(notes: NoteSummary[], resources: ResourceSummary[]): TreeDir { +/** Fold the flat, path-ordered note + resource lists into one nested folder tree. + * `staged` adds folders that hold no file yet — the UI's pending "new folder"s + * (and the folder an inline create input is open in), which the index-derived + * lists can't know about; an already-real folder merges harmlessly. */ +function buildTree( + notes: NoteSummary[], + resources: ResourceSummary[], + staged: Iterable, +): TreeDir { const root: TreeDir = { name: "", path: "", dirs: new Map(), files: [] }; - const insert = (file: TreeFile) => { - const parts = file.path.split("/"); + const descend = (dirPath: string): TreeDir => { let dir = root; - for (const seg of parts.slice(0, -1)) { + if (!dirPath) return dir; + for (const seg of dirPath.split("/")) { const full = dir.path ? `${dir.path}/${seg}` : seg; let child = dir.dirs.get(seg); if (!child) { @@ -116,7 +123,11 @@ function buildTree(notes: NoteSummary[], resources: ResourceSummary[]): TreeDir } dir = child; } - dir.files.push(file); + return dir; + }; + const insert = (file: TreeFile) => { + const parts = file.path.split("/"); + descend(parts.slice(0, -1).join("/")).files.push(file); }; for (const note of notes) { insert({ kind: "note", path: note.path, label: fileLabel(note), glyph: "" }); @@ -129,6 +140,9 @@ function buildTree(notes: NoteSummary[], resources: ResourceSummary[]): TreeDir glyph: CLASS_GLYPHS[r.class] ?? CLASS_GLYPHS.binary, }); } + for (const dir of staged) { + descend(dir); + } return root; } @@ -139,6 +153,20 @@ function fileLabel(note: NoteSummary): string { return base.replace(/\.md$/i, ""); } +/** The inline name input for a pending create (new note / new folder), rendered at + * the top of its target folder's children. The typed value lives only in the DOM — + * main.ts commits on Enter/blur, cancels on Escape, and carries it across an + * unrelated tree repaint. */ +function treeCreateRowHtml(kind: "note" | "folder", pad: string): string { + return `
+ ${kind === "folder" ? "▶" : ""} + +
`; +} + /** Render one folder's children (its sub-folders, then its files), recursively. */ function treeChildrenHtml(dir: TreeDir, state: AppState, depth: number): string { const subdirs = [...dir.dirs.values()].sort((a, b) => a.name.localeCompare(b.name)); @@ -147,10 +175,18 @@ function treeChildrenHtml(dir: TreeDir, state: AppState, depth: number): string // so files sit one notch deeper than the folder header above them. const pad = (d: number) => `padding-left:${8 + d * 14}px`; + // An open create input renders first in its target folder (startTreeCreate + // expanded the chain down to here, so a match is always visible). + const create = + state.treeCreate && state.treeCreate.dir === dir.path + ? treeCreateRowHtml(state.treeCreate.kind, pad(depth)) + : ""; + const dirHtml = subdirs .map((sub) => { const open = state.expandedDirs.has(sub.path); - const header = ` + + `; } export function treePaneHtml(state: AppState): string { const total = state.notes.length + state.resources.length; const head = `

Files

- ${total || ""} + + ${total || ""} + ${state.vaultRoot === null ? "" : treeActionsHtml(state)} +
`; if (state.vaultRoot === null) return head + `

No vault open.

`; - if (total === 0) + // Staged folders (pending "new folder"s) and the folder an inline create input + // is open in join the index-derived lists, so both render even before any file + // exists under them — including on a completely empty vault. + const staged = state.treeCreate + ? [...state.pendingDirs, state.treeCreate.dir] + : [...state.pendingDirs]; + const body = treeChildrenHtml(buildTree(state.notes, state.resources, staged), state, 0); + if (!body) return head + `

No files indexed yet — Reindex to populate.

`; - return ( - head + - `
${treeChildrenHtml(buildTree(state.notes, state.resources), state, 0)}
` - ); + return head + `
${body}
`; } // --- pane builders -------------------------------------------------------------- @@ -957,18 +1021,24 @@ function settingsModalHtml(state: AppState): string { `; } -// The discovery card right-click menu (replaces the inline "Link…" button on Similar -// cards). Anchored at the cursor via inline left/top — the coords are set + clamped -// on-screen in main.ts, and are plain numbers, so no escaping is needed. Rendered into -// its own overlay root so it floats above the panes; an outside click / Escape / scroll -// dismisses it (main.ts). +// The right-click menu — one overlay, two surfaces (state.ts `ContextMenuState`): +// a discovery card (Open note / Link…, replacing the old inline "Link…" button) or +// the file tree (New note / New folder in the folder under the cursor, named in a +// muted context line). Anchored at the cursor via inline left/top — the coords are +// set + clamped on-screen in main.ts, and are plain numbers, so no escaping is +// needed. Rendered into its own overlay root so it floats above the panes; an +// outside click / Escape / scroll dismisses it (main.ts). export function contextMenuHtml(state: AppState): string { const m = state.contextMenu; if (!m) return ""; - return ``; + const items = + m.kind === "tree" + ? `
${escapeHtml(m.dir ? `${m.dir}/` : "vault root")}
+ + ` + : ` + `; + return ``; } export function modalHtml(state: AppState): string { diff --git a/ui/src/state.ts b/ui/src/state.ts index 9d375ae..ef5649e 100644 --- a/ui/src/state.ts +++ b/ui/src/state.ts @@ -47,18 +47,15 @@ export interface LinkTarget { } /** - * An open right-click menu on a discovery card. Anchored at the cursor (viewport - * coords, already clamped on-screen when opened) and carrying the card's note so its - * actions (Open / Link…) know their target. Null when no menu is up. Replaces the - * inline "Link…" button on Similar cards — the whole card is the target, right-click - * is the affordance. + * An open right-click menu, anchored at the cursor (viewport coords, already + * clamped on-screen when opened). Null when no menu is up. Two surfaces share the + * one overlay: a discovery **card** (Open / Link… — the whole card is the target, + * replacing the old inline "Link…" button) and the file **tree** (New note / New + * folder, targeting the folder under the cursor). */ -export interface ContextMenuState { - x: number; - y: number; - path: string; - title: string | null; -} +export type ContextMenuState = + | { kind: "card"; x: number; y: number; path: string; title: string | null } + | { kind: "tree"; x: number; y: number; dir: string }; /** * Appearance preference. `"system"` (the default) defers to the OS via @@ -83,6 +80,22 @@ export interface AppState { resources: ResourceSummary[]; /** Folder paths (vault-relative, no trailing slash) the tree shows expanded. */ expandedDirs: Set; + /** + * The tree's creation context — the folder a new note/folder lands in (⌘N, the + * tree-head icons). Follows the selection: the open document's folder, or the + * last folder row clicked/right-clicked. "" is the vault root (the default). + */ + selectedDir: string; + /** + * Staged folders (session-scoped, cleared on vault switch): created in the UI + * but still empty, so the index-derived tree can't list them and B2 writes no + * empty dir to disk (nothing durable outside the Markdown). Each materializes + * for real when its first note is created inside it — `create_note` creates + * missing parent dirs, exactly like `b2 add`. + */ + pendingDirs: Set; + /** An inline name input open in the tree (new note / new folder in `dir`), or null. */ + treeCreate: { kind: "note" | "folder"; dir: string } | null; /** The open note (left pane), or null before one is opened. */ current: NoteView | null; /** @@ -194,6 +207,9 @@ export const state: AppState = { notes: [], resources: [], expandedDirs: new Set(), + selectedDir: "", + pendingDirs: new Set(), + treeCreate: null, current: null, currentResource: null, frontmatterOpen: false, diff --git a/ui/src/types.ts b/ui/src/types.ts index 57030d3..f029d2d 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -201,6 +201,15 @@ export interface WriteReport { revision: string; } +/** + * `Vault::create_note` — the created note's identity: the `b2id` projection + * stamped, and the vault-relative path (`.md`-normalized) to open it by. + */ +export interface AddReport { + b2id: string; + path: string; +} + /** `Vault::link` — the committed edge (idempotent: `created=false` if it existed). */ export interface LinkReport { src_path: string; diff --git a/ui/style.css b/ui/style.css index dfeafe3..ed21854 100644 --- a/ui/style.css +++ b/ui/style.css @@ -370,6 +370,38 @@ body.is-resizing { font-size: 11px; } +/* The head's right cluster: the count plus the two create icons (new note / new + folder). The icons are quiet like the topbar's icon-btns, sized down to the + tree-head's scale. */ +.tree-head-right { + display: flex; + align-items: center; + gap: 2px; +} +.tree-head-right .tree-count { + margin-right: 4px; +} +.tree-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: none; + border: none; + border-radius: 5px; + color: var(--muted); + cursor: pointer; +} +.tree-action:hover { + background: var(--surface-2); + color: var(--accent); +} +.tree-action svg { + display: block; +} + .tree-empty { color: var(--muted); font-size: 13px; @@ -425,6 +457,38 @@ body.is-resizing { font-weight: 600; } +/* The selection context (state.selectedDir): the folder ⌘N / the head icons create + into. Quieter than a file's is-active — context, not the open document. */ +.tree-dir.is-selected { + background: var(--surface-2); +} +.tree-dir.is-selected .tree-label { + color: var(--accent); +} + +/* The inline create row (new note / new folder): a name input in place, at the + target folder's indent. The row itself isn't a button, so no hover wash. */ +.tree-row.tree-create, +.tree-row.tree-create:hover { + background: none; + cursor: default; +} +.tree-create-input { + flex: 1; + min-width: 0; + font: inherit; + font-size: 13px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--accent); + border-radius: 5px; + padding: 2px 6px; + outline: none; +} +.tree-create-input::placeholder { + color: var(--muted); +} + /* --- the note (reading surface) ----------------------------------------------- */ .note { @@ -1159,6 +1223,16 @@ body.is-loading { background: var(--accent); color: #fff; } +/* The tree menu's context line: which folder the create lands in ("projects/"). */ +.context-label { + padding: 4px 10px 2px; + font-size: 11px; + color: var(--muted); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} /* --- link modal --------------------------------------------------------------- */