Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions ui/src/Dispatcher.affine
Original file line number Diff line number Diff line change
@@ -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<Msg.msg => unit> = ref(_ => ())

fn dispatch = (msg: Msg.msg) => dispatchRef.contents(msg)

49 changes: 46 additions & 3 deletions ui/src/GraphLayout.affine
Original file line number Diff line number Diff line change
@@ -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<note>, ~width: float, ~height: float): array<nodePos> => {
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<nodePos>): Dict.t<nodePos> => {
fn dict = Dict.make()
positions->Array.forEach(p => Dict.set(dict, p.id, p))
dict
}

134 changes: 131 additions & 3 deletions ui/src/Main.affine
Original file line number Diff line number Diff line change
@@ -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))
})

<View model dispatch />
}
}

// 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(<App initialNotebook />)
| 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(
<div role="alert" className="error-banner">
{React.string("Nexia-List failed to start. Check the browser console for details.")}
</div>,
)
| None => ()
}
}
}
}

start()->ignore

99 changes: 96 additions & 3 deletions ui/src/Model.affine
Original file line number Diff line number Diff line change
@@ -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<noteId>,
/// Currently editing note (for inline edit)
editingNote: option<noteId>,
/// Sidebar visibility
sidebarOpen: bool,
/// File path of current notebook
filePath: option<string>,
/// Unsaved changes flag
dirty: bool,
/// Error message to display
error: option<string>,
/// Persistent saved queries
agents: array<agent>,
/// The agent whose results are currently shown (if any)
activeAgent: option<agentId>,
/// Note IDs collected by the active agent
agentResults: array<noteId>,
/// Source and most recent result for the progressively disclosed L1 λδ formula panel
formulaSource: string,
formulaResult: option<string>,
}

/// 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<note> => {
Js.Dict.get(model.notebook.notes, id)
}

/// Get all notes as an array
fn allNotes = (model: model): array<note> => {
Js.Dict.values(model.notebook.notes)
}

/// Get backlinks for a note
fn getBacklinks = (model: model, id: noteId): array<noteId> => {
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
}

71 changes: 68 additions & 3 deletions ui/src/Msg.affine
Original file line number Diff line number Diff line change
@@ -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

Loading
Loading