From 2810f13043674c06b0b1cd4d53687c6762854eee Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:57:49 +0100 Subject: [PATCH] refactor: semantically port to AffineScript --- ui/src/Dispatcher.affine | 13 +- ui/src/GraphLayout.affine | 49 ++- ui/src/Main.affine | 134 +++++- ui/src/Model.affine | 99 ++++- ui/src/Msg.affine | 71 +++- ui/src/Navigation.affine | 79 +++- ui/src/Types.affine | 90 +++- ui/src/Update.affine | 375 ++++++++++++++++- ui/src/View.affine | 634 ++++++++++++++++++++++++++++- ui/src/bindings/DomBindings.affine | 54 ++- ui/src/store/Exchange.affine | 42 +- ui/src/store/Persist.affine | 54 ++- ui/src/store/WasmStore.affine | 224 +++++++++- ui/tests/GraphLayoutTests.affine | 55 ++- ui/tests/NavigationTests.affine | 67 ++- ui/tests/UpdateTests.affine | 115 +++++- ui/tests/WasmStoreTests.affine | 60 ++- 17 files changed, 2164 insertions(+), 51 deletions(-) diff --git a/ui/src/Dispatcher.affine b/ui/src/Dispatcher.affine index 7a02471..d49464f 100644 --- a/ui/src/Dispatcher.affine +++ b/ui/src/Dispatcher.affine @@ -1,7 +1,14 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Dispatcher; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// Late-bound dispatch for effects (e.g. the file picker) that complete +/// after update() has returned. Main.res registers the live dispatcher on +/// mount. + +fn dispatchRef: ref unit> = ref(_ => ()) + +fn dispatch = (msg: Msg.msg) => dispatchRef.contents(msg) + diff --git a/ui/src/GraphLayout.affine b/ui/src/GraphLayout.affine index 81ae1c5..15aec32 100644 --- a/ui/src/GraphLayout.affine +++ b/ui/src/GraphLayout.affine @@ -1,7 +1,50 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module GraphLayout; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// Deterministic layout for the graph view. A circular arrangement keeps the +/// result static (no animation — friendly to prefers-reduced-motion) and +/// pure, so it is unit-testable without a DOM. Coordinates are always finite +/// and inside the given box. + +open Types + +struct nodePos { { + id: noteId, + title: string, + x: float, + y: float, +} + +/// Place notes evenly on a circle inscribed in width×height. +fn circular = (~notes: array, ~width: float, ~height: float): array => { + fn n = Array.length(notes) + if n == 0 { + [] + } else { + fn cx = width /. 2.0 + fn cy = height /. 2.0 + fn radius = Js.Math.min_float(width, height) *. 0.38 + notes->Array.mapWithIndex((note, i) => { + // Start at the top (−90°) and go clockwise. + fn angle = + -.Js.Math._PI /. 2.0 +. 2.0 *. Js.Math._PI *. Int.toFloat(i) /. Int.toFloat(n) + { + id: note.id, + title: note.title == "" ? "Untitled" : note.title, + x: cx +. radius *. Js.Math.cos(angle), + y: cy +. radius *. Js.Math.sin(angle), + } + }) + } +} + +/// Index positions by note id for edge lookup. +fn byId = (positions: array): Dict.t => { + fn dict = Dict.make() + positions->Array.forEach(p => Dict.set(dict, p.id, p)) + dict +} + diff --git a/ui/src/Main.affine b/ui/src/Main.affine index d410d4c..0559764 100644 --- a/ui/src/Main.affine +++ b/ui/src/Main.affine @@ -1,7 +1,135 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Main; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// Main entry point for Nexia-List: loads the WASM core, restores the autosaved +/// notebook from IndexedDB, then mounts the app. + +module App = { + @react.component + fn make = (~initialNotebook: Types.notebook) => { + fn (model, setModel) = React.useState(() => { + ...Model.initial(), + notebook: initialNotebook, + }) + + fn dispatch = (msg: Msg.msg) => { + setModel(currentModel => Update.update(currentModel, msg)) + } + + React.useEffect0(() => { + Dispatcher.dispatchRef := dispatch + // Load any agents restored from the autosaved notebook. + dispatch(Msg.RefreshAgents) + None + }) + + // Any notebook change (edits, load, new) refreshes the IndexedDB copy + // after a debounce, so the working set survives reloads. + React.useEffect1(() => { + Persist.scheduleAutosave() + None + }, [model.notebook]) + + // Keyboard shortcuts. The handler is registered once; guards that need + // current state (e.g. "don't delete while editing") live in Update.update, + // which always sees the latest model. + React.useEffect0(() => { + fn handleKeyDown = (e: DomBindings.keyboardEvent) => { + fn key = DomBindings.key(e) + fn modKey = DomBindings.ctrlKey(e) || DomBindings.metaKey(e) + fn typing = DomBindings.isTyping(e) + + // Arrow keys drive keyboard-only canvas navigation. Alt+arrow nudges + // the selected note; a plain arrow moves the selection. Suppressed + // while typing so text fields keep their caret movement. + fn arrow = switch key { + | "ArrowUp" => Some(Types.Up) + | "ArrowDown" => Some(Types.Down) + | "ArrowLeft" => Some(Types.Left) + | "ArrowRight" => Some(Types.Right) + | _ => None + } + + switch (modKey, key, arrow, typing) { + | (true, "n", _, _) => { + DomBindings.preventDefault(e) + dispatch(Msg.CreateNote) + } + | (true, "s", _, _) => { + DomBindings.preventDefault(e) + dispatch(Msg.SaveNotebook) + } + | (_, _, Some(direction), false) => { + DomBindings.preventDefault(e) + if DomBindings.altKey(e) { + dispatch(Msg.NudgeSelectedNote(direction)) + } else { + dispatch(Msg.NavigateCanvas(direction)) + } + } + | (false, "Escape", _, _) => { + dispatch(Msg.ClearSelection) + dispatch(Msg.StopEditingNote) + } + | (false, "Delete", _, false) | (false, "Backspace", _, false) => + dispatch(Msg.DeleteSelectedNotes) + | _ => () + } + } + + DomBindings.addKeydownListener(handleKeyDown) + Some(() => DomBindings.removeKeydownListener(handleKeyDown)) + }) + + + } +} + +// Register the offline service worker (best-effort; never blocks startup). +%%raw(` +if ("serviceWorker" in navigator) { + globalThis.addEventListener("load", () => { + navigator.serviceWorker.register("./service-worker.js").catch(() => {}); + }); +} +`) + +fn start = async () => { + try { + await WasmStore.init("./wasm/nexia_core_bg.wasm") + + fn initialNotebook = switch await Persist.loadAutosave() { + | Some(json) => + switch WasmStore.loadFromJson(json) { + | Ok(snapshot) => snapshot + | Error(_) => WasmStore.snapshot() // unreadable autosave: start fresh + } + | None => WasmStore.snapshot() + } + + switch ReactDOM.querySelector("#root") { + | Some(root) => + ReactDOM.Client.createRoot(root)->ReactDOM.Client.Root.render() + | None => Js.Console.error("Could not find #root element") + } + } catch { + | e => { + Js.Console.error2("Nexia-List failed to start", e->Exn.anyToExnInternal) + switch ReactDOM.querySelector("#root") { + | Some(root) => + ReactDOM.Client.createRoot(root)->ReactDOM.Client.Root.render( +
+ {React.string("Nexia-List failed to start. Check the browser console for details.")} +
, + ) + | None => () + } + } + } +} + +start()->ignore + diff --git a/ui/src/Model.affine b/ui/src/Model.affine index cd11603..dea839b 100644 --- a/ui/src/Model.affine +++ b/ui/src/Model.affine @@ -1,7 +1,100 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Model; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// Application model - single source of truth + +open Types + +/// The complete application state +struct model { { + /// All notes in the notebook + notebook: notebook, + /// Current view mode + viewMode: viewMode, + /// Currently selected notes + selection: selection, + /// Canvas viewport state + viewport: viewport, + /// Search query + searchQuery: string, + /// Search results (note IDs) + searchResults: array, + /// Currently editing note (for inline edit) + editingNote: option, + /// Sidebar visibility + sidebarOpen: bool, + /// File path of current notebook + filePath: option, + /// Unsaved changes flag + dirty: bool, + /// Error message to display + error: option, + /// Persistent saved queries + agents: array, + /// The agent whose results are currently shown (if any) + activeAgent: option, + /// Note IDs collected by the active agent + agentResults: array, + /// Source and most recent result for the progressively disclosed L1 λδ formula panel + formulaSource: string, + formulaResult: option, +} + +/// Create an empty notebook +fn emptyNotebook = (): notebook => { + fn now = Js.Date.toISOString(Js.Date.make()) + { + notes: Js.Dict.empty(), + backlinks: Js.Dict.empty(), + name: "Untitled Notebook", + createdAt: now, + modifiedAt: now, + } +} + +/// Initial application state +fn initial = (): model => { + notebook: emptyNotebook(), + viewMode: ListView, + selection: NoSelection, + viewport: Viewport.initial(), + searchQuery: "", + searchResults: [], + editingNote: None, + sidebarOpen: true, + filePath: None, + dirty: false, + error: None, + agents: [], + activeAgent: None, + agentResults: [], + formulaSource: "(count (words (content self)))", + formulaResult: None, +} + +/// Get a note by ID from the model +fn getNote = (model: model, id: noteId): option => { + Js.Dict.get(model.notebook.notes, id) +} + +/// Get all notes as an array +fn allNotes = (model: model): array => { + Js.Dict.values(model.notebook.notes) +} + +/// Get backlinks for a note +fn getBacklinks = (model: model, id: noteId): array => { + switch Js.Dict.get(model.notebook.backlinks, id) { + | Some(links) => links + | None => [] + } +} + +/// Count total notes +fn noteCount = (model: model): int => { + Js.Dict.keys(model.notebook.notes)->Array.length +} + diff --git a/ui/src/Msg.affine b/ui/src/Msg.affine index b1a59b8..db3d763 100644 --- a/ui/src/Msg.affine +++ b/ui/src/Msg.affine @@ -1,7 +1,72 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Msg; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// All application messages + +open Types + +/// Messages that can be sent to update the model +struct msg { + // Note CRUD + | CreateNote + | CreateNoteAt(point2D) + | DeleteNote(noteId) + | DeleteSelectedNotes + // Note editing + | UpdateNoteTitle(noteId, string) + | UpdateNoteContent(noteId, string) + | StartEditingNote(noteId) + | StopEditingNote + // L1 LambdaDelta formula field (read-only; `self` is the selected note) + | SetFormulaSource(string) + | EvaluateFormula(noteId) + // Note positioning + | MoveNote(noteId, point2D) + | ResizeNote(noteId, float, float) + // Links + | LinkNotes(noteId, noteId) + | UnlinkNotes(noteId, noteId) + // Selection + | SelectNote(noteId) + | AddToSelection(noteId) + | ClearSelection + | SelectAll + // View + | SetViewMode(viewMode) + | ToggleSidebar + // Canvas + | PanCanvas(float, float) + | ZoomCanvas(float) + | ResetViewport + // Keyboard canvas navigation + | NavigateCanvas(direction) + | NudgeSelectedNote(direction) + // Search + | SetSearchQuery(string) + | ClearSearch + // File operations + | NewNotebook + | SaveNotebook + | SaveNotebookAs(string) + | LoadNotebook(string) + | NotebookLoaded(notebook) + | NotebookSaved + // Import / export + | ExportMarkdown + | ExportOpml + | ImportVault + // Agents (persistent saved queries) + | RefreshAgents + | CreateAgent(string, string) + | DeleteAgent(agentId) + | RunAgent(agentId) + | ClearAgent + // Errors + | SetError(string) + | ClearError + // No-op + | NoOp + diff --git a/ui/src/Navigation.affine b/ui/src/Navigation.affine index 918f836..d4f0f3e 100644 --- a/ui/src/Navigation.affine +++ b/ui/src/Navigation.affine @@ -1,7 +1,80 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Navigation; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// Pure geometry for keyboard navigation on the spatial canvas: given a +/// focused note and a direction, find the nearest note that lies (mostly) in +/// that direction. Kept side-effect-free so it is unit-testable under +/// `bun test` without a DOM. + +open Types + +fn positionOf = (note: note): option => note.position + +/// Candidate must lie in the half-plane of `direction` from `origin`, then we +/// score by along-axis distance plus a penalty for lateral drift so a note +/// straight ahead beats one far off to the side. +fn score = (~origin: point2D, ~target: point2D, ~direction: direction): option => { + fn dx = target.x -. origin.x + fn dy = target.y -. origin.y + fn (along, lateral) = switch direction { + | Up => (-.dy, dx) + | Down => (dy, dx) + | Left => (-.dx, dy) + | Right => (dx, dy) + } + if along <= 0.0 { + None + } else { + Some(along +. Js.Math.abs_float(lateral) *. 2.0) + } +} + +/// The nearest positioned note to `fromId` in `direction`, if any. +fn nearestInDirection = ( + ~notes: array, + ~fromId: noteId, + ~direction: direction, +): option => { + switch notes->Array.find(n => n.id == fromId) { + | None => None + | Some(current) => + switch positionOf(current) { + | None => None + | Some(origin) => + notes->Array.reduce(None, (best, candidate) => { + if candidate.id == fromId { + best + } else { + switch positionOf(candidate) { + | None => best + | Some(target) => + switch score(~origin, ~target, ~direction) { + | None => best + | Some(s) => + switch best { + | Some((_, bestScore)) if bestScore <= s => best + | _ => Some((candidate.id, s)) + } + } + } + } + })->Option.map(((id, _)) => id) + } + } +} + +/// The step, in canvas units, that a modifier+arrow nudge moves a note. +fn nudgeStep = 20.0 + +/// Apply a nudge to a position in the given direction. +fn nudge = (~position: point2D, ~direction: direction): point2D => + switch direction { + | Up => {...position, y: position.y -. nudgeStep} + | Down => {...position, y: position.y +. nudgeStep} + | Left => {...position, x: position.x -. nudgeStep} + | Right => {...position, x: position.x +. nudgeStep} + } + diff --git a/ui/src/Types.affine b/ui/src/Types.affine index 4006671..28decba 100644 --- a/ui/src/Types.affine +++ b/ui/src/Types.affine @@ -1,7 +1,91 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Types; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// Core structs for Nexia-List UI - mirrors Rust core structs + +/// Unique identifier for a note +struct noteId { string + +/// 2D position on the spatial canvas +struct point2D { { + x: float, + y: float, +} + +/// A single note in the knowledge graph +struct note { { + id: noteId, + title: string, + content: string, + position: option, + size: option<(float, float)>, + createdAt: string, // ISO 8601 datetime + modifiedAt: string, + links: array, + protostruct: option, + attributes: Js.Dict.t, +} + +/// Notebook containing all notes +struct notebook { { + notes: Js.Dict.t, + backlinks: Js.Dict.t>, + name: string, + createdAt: string, + modifiedAt: string, +} + +/// View mode for the application +struct viewMode { + | ListView + | CanvasView + | GraphView + +/// Selection state +struct selection { + | NoSelection + | SingleNote(noteId) + | MultipleNotes(array) + +/// A cardinal direction for keyboard navigation on the canvas. +struct direction { + | Up + | Down + | Left + | Right + +/// A persistent saved query (agent). +struct agentId { string +struct agent { { + id: agentId, + name: string, + query: string, +} + +/// Canvas viewport state +struct viewport { { + offsetX: float, + offsetY: float, + zoom: float, +} + +// Note construction lives in the Rust core (WasmStore.createNote) — the UI +// never fabricates ids or timestamps, which is what kept these structs from +// drifting apart before. + +module Point2D = { + fn make = (x: float, y: float): point2D => {x, y} + fn origin = (): point2D => {x: 0.0, y: 0.0} +} + +module Viewport = { + fn initial = (): viewport => { + offsetX: 0.0, + offsetY: 0.0, + zoom: 1.0, + } +} + diff --git a/ui/src/Update.affine b/ui/src/Update.affine index 0ebdce0..89be228 100644 --- a/ui/src/Update.affine +++ b/ui/src/Update.affine @@ -1,7 +1,376 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Update; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// Update function — handles all state transitions. +/// +/// All notebook mutations delegate to the Rust core (WasmStore); the +/// model's notebook is a read model patched with what the core returns. +/// The dicts are mutated in place — a new notebook record is produced per +/// transition so React re-renders, but older model values must not be +/// treated as immutable history. + +open Types +open Model +open Msg + +%%private( + fn deleteKey: (Js.Dict.t<'a>, string) => unit = %raw(`(dict, key) => { delete dict[key] }`) +) + +/// Upsert a single note view returned by the core. +fn setNote = (notebook: notebook, note: note): notebook => { + Js.Dict.set(notebook.notes, note.id, note) + {...notebook, modifiedAt: note.modifiedAt} +} + +/// Apply a topology delta returned by the core. +fn applyDelta = (notebook: notebook, delta: WasmStore.delta): notebook => { + delta.changed->Array.forEach(note => Js.Dict.set(notebook.notes, note.id, note)) + delta.removed->Array.forEach(id => { + deleteKey(notebook.notes, id) + deleteKey(notebook.backlinks, id) + }) + Js.Dict.entries(delta.backlinks)->Array.forEach(((id, sources)) => + Js.Dict.set(notebook.backlinks, id, sources) + ) + // Spread with a no-op override: a fresh record identity so React re-renders. + {...notebook, name: notebook.name} +} + +fn patchNote = (model: model, result: result): model => + switch result { + | Ok(note) => {...model, notebook: setNote(model.notebook, note), dirty: true} + | Error(message) => {...model, error: Some(message)} + } + +/// The main update function. Every transition passes through `update`, which +/// keeps an active agent's collection live by re-running it afterwards. +fn rec update = (model: model, msg: msg): model => { + fn next = step(model, msg) + switch next.activeAgent { + | Some(id) => {...next, agentResults: WasmStore.runAgent(id)} + | None => next + } +} +and step = (model: model, msg: msg): model => { + switch msg { + // Note CRUD + | CreateNote => + switch WasmStore.createNote("New Note") { + | Ok(note) => { + ...model, + notebook: setNote(model.notebook, note), + selection: SingleNote(note.id), + editingNote: Some(note.id), + dirty: true, + } + | Error(message) => {...model, error: Some(message)} + } + + | CreateNoteAt(position) => + switch WasmStore.createNoteAt("New Note", position.x, position.y) { + | Ok(note) => { + ...model, + notebook: setNote(model.notebook, note), + selection: SingleNote(note.id), + editingNote: Some(note.id), + dirty: true, + } + | Error(message) => {...model, error: Some(message)} + } + + | DeleteNote(id) => + switch WasmStore.deleteNote(id) { + | Ok(delta) => { + ...model, + notebook: applyDelta(model.notebook, delta), + selection: switch model.selection { + | SingleNote(selectedId) if selectedId == id => NoSelection + | MultipleNotes(ids) => { + fn remaining = ids->Array.filter(i => i != id) + switch remaining { + | [] => NoSelection + | [single] => SingleNote(single) + | multiple => MultipleNotes(multiple) + } + } + | other => other + }, + editingNote: switch model.editingNote { + | Some(editId) if editId == id => None + | other => other + }, + dirty: true, + } + | Error(message) => {...model, error: Some(message)} + } + + | DeleteSelectedNotes => + // Guarded here rather than in the keyboard handler so the check always + // sees current state (the handler is registered once and would capture a + // stale model). + switch model.editingNote { + | Some(_) => model + | None => + switch model.selection { + | NoSelection => model + | SingleNote(id) => update(model, DeleteNote(id)) + | MultipleNotes(ids) => ids->Array.reduce(model, (m, id) => update(m, DeleteNote(id))) + } + } + + // Note editing + | UpdateNoteTitle(id, title) => patchNote(model, WasmStore.updateTitle(id, title)) + + | UpdateNoteContent(id, content) => + switch WasmStore.updateContent(id, content) { + | Ok(delta) => {...model, notebook: applyDelta(model.notebook, delta), dirty: true} + | Error(message) => {...model, error: Some(message)} + } + + | StartEditingNote(id) => {...model, editingNote: Some(id)} + + | StopEditingNote => {...model, editingNote: None} + + | SetFormulaSource(source) => {...model, formulaSource: source, formulaResult: None} + + | EvaluateFormula(id) => + switch WasmStore.evalFormula(id, model.formulaSource) { + | Ok(value) => {...model, formulaResult: Some(value), error: None} + | Error(message) => {...model, formulaResult: None, error: Some(message)} + } + + // Note positioning + | MoveNote(id, position) => patchNote(model, WasmStore.moveNote(id, position.x, position.y)) + + | ResizeNote(id, width, height) => patchNote(model, WasmStore.resizeNote(id, width, height)) + + // Links + | LinkNotes(fromId, toId) => + if fromId == toId { + model + } else { + switch WasmStore.link(fromId, toId) { + | Ok(delta) => {...model, notebook: applyDelta(model.notebook, delta), dirty: true} + | Error(message) => {...model, error: Some(message)} + } + } + + | UnlinkNotes(fromId, toId) => + switch WasmStore.unlink(fromId, toId) { + | Ok(delta) => {...model, notebook: applyDelta(model.notebook, delta), dirty: true} + | Error(message) => {...model, error: Some(message)} + } + + // Selection + | SelectNote(id) => {...model, selection: SingleNote(id), formulaResult: None} + + | AddToSelection(id) => + switch model.selection { + | NoSelection => {...model, selection: SingleNote(id)} + | SingleNote(existing) => + if existing == id { + model + } else { + {...model, selection: MultipleNotes([existing, id])} + } + | MultipleNotes(ids) => + if Array.includes(ids, id) { + model + } else { + {...model, selection: MultipleNotes(Array.concat(ids, [id]))} + } + } + + | ClearSelection => {...model, selection: NoSelection} + + | SelectAll => { + fn allIds = Js.Dict.keys(model.notebook.notes) + { + ...model, + selection: switch allIds { + | [] => NoSelection + | [single] => SingleNote(single) + | multiple => MultipleNotes(multiple) + }, + } + } + + // View + | SetViewMode(mode) => {...model, viewMode: mode} + + | ToggleSidebar => {...model, sidebarOpen: !model.sidebarOpen} + + // Canvas + | PanCanvas(dx, dy) => { + ...model, + viewport: { + ...model.viewport, + offsetX: model.viewport.offsetX +. dx, + offsetY: model.viewport.offsetY +. dy, + }, + } + + | ZoomCanvas(factor) => { + fn newZoom = model.viewport.zoom *. factor + fn clampedZoom = Js.Math.max_float(0.1, Js.Math.min_float(5.0, newZoom)) + {...model, viewport: {...model.viewport, zoom: clampedZoom}} + } + + | ResetViewport => {...model, viewport: Viewport.initial()} + + // Move the selection to the nearest note in a direction (keyboard nav). + | NavigateCanvas(direction) => + switch model.selection { + | SingleNote(id) => + switch Navigation.nearestInDirection(~notes=allNotes(model), ~fromId=id, ~direction) { + | Some(next) => {...model, selection: SingleNote(next)} + | None => model + } + | _ => + // Nothing focused yet: select the first positioned note, if any. + switch allNotes(model)->Array.find(n => n.position->Option.isSome) { + | Some(note) => {...model, selection: SingleNote(note.id)} + | None => model + } + } + + // Nudge the selected note (modifier+arrow); the mouse equivalent is PR-D. + | NudgeSelectedNote(direction) => + switch model.selection { + | SingleNote(id) => + switch Model.getNote(model, id) { + | Some(note) => + fn base = switch note.position { + | Some(p) => p + | None => {x: 0.0, y: 0.0} + } + fn next = Navigation.nudge(~position=base, ~direction) + patchNote(model, WasmStore.moveNote(id, next.x, next.y)) + | None => model + } + | _ => model + } + + // Search + | SetSearchQuery(query) => { + ...model, + searchQuery: query, + searchResults: query == "" ? [] : WasmStore.search(query), + } + + | ClearSearch => {...model, searchQuery: "", searchResults: []} + + // File operations + | NewNotebook => { + ...initial(), + notebook: WasmStore.reset("Untitled Notebook"), + viewMode: model.viewMode, + sidebarOpen: model.sidebarOpen, + agents: WasmStore.agents(), + } + + | SaveNotebook => + switch WasmStore.toJson() { + | Ok(json) => { + Persist.saveToFile(model.notebook.name, json) + {...model, dirty: false} + } + | Error(message) => {...model, error: Some(message)} + } + + | SaveNotebookAs(name) => + switch WasmStore.toJson() { + | Ok(json) => { + Persist.saveToFile(name, json) + {...model, dirty: false} + } + | Error(message) => {...model, error: Some(message)} + } + + | LoadNotebook(_path) => { + // Async: the picker resolves after update() returns; the result comes + // back through Dispatcher as NotebookLoaded / SetError. + Persist.openFile() + ->Promise.thenResolve(content => + switch content { + | Some(json) => + switch WasmStore.loadFromJson(json) { + | Ok(snapshot) => Dispatcher.dispatch(NotebookLoaded(snapshot)) + | Error(message) => Dispatcher.dispatch(SetError(message)) + } + | None => () + } + ) + ->ignore + model + } + + | NotebookLoaded(notebook) => { + ...model, + notebook, + dirty: false, + selection: NoSelection, + editingNote: None, + searchQuery: "", + searchResults: [], + error: None, + agents: WasmStore.agents(), + activeAgent: None, + agentResults: [], + } + + | NotebookSaved => {...model, dirty: false} + + // Import / export (effects resolve after update via Dispatcher) + | ExportMarkdown => { + Exchange.exportMarkdown() + model + } + + | ExportOpml => { + Exchange.exportOpml(model.notebook.name) + model + } + + | ImportVault => { + Exchange.importVault() + model + } + + // Agents + | RefreshAgents => {...model, agents: WasmStore.agents()} + + | CreateAgent(name, query) => + switch WasmStore.addAgent(name, query) { + | Ok(_) => {...model, agents: WasmStore.agents(), dirty: true} + | Error(message) => {...model, error: Some(message)} + } + + | DeleteAgent(id) => { + WasmStore.removeAgent(id)->ignore + { + ...model, + agents: WasmStore.agents(), + dirty: true, + activeAgent: model.activeAgent == Some(id) ? None : model.activeAgent, + agentResults: model.activeAgent == Some(id) ? [] : model.agentResults, + } + } + + | RunAgent(id) => {...model, activeAgent: Some(id), agentResults: WasmStore.runAgent(id)} + + | ClearAgent => {...model, activeAgent: None, agentResults: []} + + // Errors + | SetError(error) => {...model, error: Some(error)} + + | ClearError => {...model, error: None} + + | NoOp => model + } +} + diff --git a/ui/src/View.affine b/ui/src/View.affine index b69df0b..f3fb10b 100644 --- a/ui/src/View.affine +++ b/ui/src/View.affine @@ -1,7 +1,635 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module View; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +/// View functions - render the UI + +open Types +open Model +open Msg + +// Enter / Space activate an element exposed as role="button". +fn onActivateKey = (handler: unit => unit, e: ReactEvent.Keyboard.t) => + switch ReactEvent.Keyboard.key(e) { + | "Enter" | " " => + ReactEvent.Keyboard.preventDefault(e) + handler() + | _ => () + } + +module AgentsPanel = { + @react.component + fn make = (~model: model, ~dispatch: msg => unit) => { + fn (name, setName) = React.useState(() => "") + fn (query, setQuery) = React.useState(() => "") + + fn submit = _ => { + fn n = String.trim(name) + fn q = String.trim(query) + if n != "" && q != "" { + dispatch(CreateAgent(n, q)) + setName(_ => "") + setQuery(_ => "") + } + } + +
+

{React.string("Agents")}

+
    + {model.agents + ->Array.map(agent => { + fn isActive = model.activeAgent == Some(agent.id) +
  • + + +
  • + }) + ->React.array} +
+
+ setName(_ => ReactEvent.Form.target(e)["value"])} + /> + setQuery(_ => ReactEvent.Form.target(e)["value"])} + /> + +
+
+ } +} + +module Sidebar = { + @react.component + fn make = (~model: model, ~dispatch: msg => unit) => { + fn notes = allNotes(model)->Array.toSorted((a, b) => + String.localeCompare(a.title, b.title) + ) + // What the list shows: search results, else an active agent's collection, + // else every note. + fn listedIds = if model.searchQuery != "" { + model.searchResults + } else if model.activeAgent->Option.isSome { + model.agentResults + } else { + notes->Array.map(n => n.id) + } + + + } +} + +module NoteEditor = { + // A note's display title, resolved from the model (falls back gracefully). + fn titleOf = (model: model, id: noteId): string => + switch getNote(model, id) { + | Some(n) => n.title != "" ? n.title : "Untitled" + | None => "(unknown)" + } + + @react.component + fn make = (~model: model, ~note: note, ~dispatch: msg => unit) => { + fn (linkQuery, setLinkQuery) = React.useState(() => "") + fn backlinks = getBacklinks(model, note.id) + + // Candidate targets for the "add link" picker: other notes not already + // linked, filtered by the picker query. + fn candidates = + allNotes(model) + ->Array.filter(n => { + n.id != note.id && + !Array.includes(note.links, n.id) && + (linkQuery == "" || + String.includes(String.toLowerCase(n.title), String.toLowerCase(linkQuery))) + }) + ->Array.toSorted((a, b) => String.localeCompare(a.title, b.title)) + +
+ + dispatch(UpdateNoteTitle(note.id, ReactEvent.Form.target(e)["value"]))} + onBlur={_ => dispatch(StopEditingNote)} + /> +