diff options
| author | main <main@swarm.moe> | 2026-03-19 17:41:40 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-19 17:41:40 -0400 |
| commit | 352fb5f089e74bf47b60c6221594b9c22defe251 (patch) | |
| tree | 2ad1620fcf9e0f138ae950888c925b9f53a19997 /crates/fidget-spinner-cli/src | |
| parent | 958c7bf261a404a7df99e394997ab10e724cfca7 (diff) | |
| download | fidget_spinner-352fb5f089e74bf47b60c6221594b9c22defe251.zip | |
Prepare fidget spinner for public sharing
Diffstat (limited to 'crates/fidget-spinner-cli/src')
| -rw-r--r-- | crates/fidget-spinner-cli/src/main.rs | 273 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/ui.rs | 600 |
2 files changed, 866 insertions, 7 deletions
diff --git a/crates/fidget-spinner-cli/src/main.rs b/crates/fidget-spinner-cli/src/main.rs index 9b2b8ae..fe4cb5f 100644 --- a/crates/fidget-spinner-cli/src/main.rs +++ b/crates/fidget-spinner-cli/src/main.rs @@ -1,8 +1,10 @@ mod bundled_skill; mod mcp; +mod ui; use std::collections::{BTreeMap, BTreeSet}; use std::fs; +use std::net::SocketAddr; use std::path::{Path, PathBuf}; use camino::{Utf8Path, Utf8PathBuf}; @@ -10,7 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use fidget_spinner_core::{ AnnotationVisibility, CodeSnapshotRef, CommandRecipe, ExecutionBackend, FrontierContract, FrontierNote, FrontierVerdict, GitCommitHash, MetricObservation, MetricSpec, MetricUnit, - NodeAnnotation, NodeClass, NodePayload, NonEmptyText, OptimizationObjective, + NodeAnnotation, NodeClass, NodePayload, NonEmptyText, OptimizationObjective, TagName, }; use fidget_spinner_store_sqlite::{ CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, EdgeAttachment, @@ -21,7 +23,11 @@ use serde_json::{Map, Value, json}; use uuid::Uuid; #[derive(Parser)] -#[command(author, version, about = "Fidget Spinner local project CLI")] +#[command( + author, + version, + about = "Fidget Spinner CLI, MCP server, and local navigator" +)] struct Cli { #[command(subcommand)] command: Command, @@ -29,29 +35,48 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Initialize a project-local `.fidget_spinner/` store. Init(InitArgs), + /// Read the local project payload schema. Schema { #[command(subcommand)] command: SchemaCommand, }, + /// Create and inspect frontiers. Frontier { #[command(subcommand)] command: FrontierCommand, }, + /// Create, inspect, and mutate DAG nodes. Node { #[command(subcommand)] command: NodeCommand, }, + /// Record terse off-path notes. Note(NoteCommand), + /// Manage the repo-local tag registry. + Tag { + #[command(subcommand)] + command: TagCommand, + }, + /// Record off-path research and enabling work. Research(ResearchCommand), + /// Close a core-path experiment atomically. Experiment { #[command(subcommand)] command: ExperimentCommand, }, + /// Serve the hardened stdio MCP endpoint. Mcp { #[command(subcommand)] command: McpCommand, }, + /// Serve the minimal local web navigator. + Ui { + #[command(subcommand)] + command: UiCommand, + }, + /// Inspect or install bundled Codex skills. Skill { #[command(subcommand)] command: SkillCommand, @@ -60,22 +85,28 @@ enum Command { #[derive(Args)] struct InitArgs { + /// Project root to initialize. #[arg(long, default_value = ".")] project: PathBuf, + /// Human-facing project name. Defaults to the directory name. #[arg(long)] name: Option<String>, + /// Payload schema namespace written into `.fidget_spinner/schema.json`. #[arg(long, default_value = "local.project")] namespace: String, } #[derive(Subcommand)] enum SchemaCommand { + /// Show the current project schema as JSON. Show(ProjectArg), } #[derive(Subcommand)] enum FrontierCommand { + /// Create a frontier and root contract node. Init(FrontierInitArgs), + /// Show one frontier projection or list frontiers when omitted. Status(FrontierStatusArgs), } @@ -115,10 +146,15 @@ struct FrontierStatusArgs { #[derive(Subcommand)] enum NodeCommand { + /// Create a generic DAG node. Add(NodeAddArgs), + /// List recent nodes. List(NodeListArgs), + /// Show one node in full. Show(NodeShowArgs), + /// Attach an annotation to a node. Annotate(NodeAnnotateArgs), + /// Archive a node without deleting it. Archive(NodeArchiveArgs), } @@ -138,6 +174,8 @@ struct NodeAddArgs { payload_json: Option<String>, #[arg(long = "payload-file")] payload_file: Option<PathBuf>, + #[command(flatten)] + tag_selection: ExplicitTagSelectionArgs, #[arg(long = "field")] fields: Vec<String>, #[arg(long = "annotation")] @@ -154,12 +192,22 @@ struct NodeListArgs { frontier: Option<String>, #[arg(long, value_enum)] class: Option<CliNodeClass>, + #[arg(long = "tag")] + tags: Vec<String>, #[arg(long)] include_archived: bool, #[arg(long, default_value_t = 20)] limit: u32, } +#[derive(Args, Default)] +struct ExplicitTagSelectionArgs { + #[arg(long = "tag")] + tags: Vec<String>, + #[arg(long, conflicts_with = "tags")] + no_tags: bool, +} + #[derive(Args)] struct NodeShowArgs { #[command(flatten)] @@ -198,9 +246,18 @@ struct NoteCommand { #[derive(Subcommand)] enum NoteSubcommand { + /// Record a quick off-path note. Quick(QuickNoteArgs), } +#[derive(Subcommand)] +enum TagCommand { + /// Register a new repo-local tag. + Add(TagAddArgs), + /// List registered repo-local tags. + List(ProjectArg), +} + #[derive(Args)] struct ResearchCommand { #[command(subcommand)] @@ -209,6 +266,7 @@ struct ResearchCommand { #[derive(Subcommand)] enum ResearchSubcommand { + /// Record off-path research or enabling work. Add(QuickResearchArgs), } @@ -222,11 +280,23 @@ struct QuickNoteArgs { title: String, #[arg(long)] body: String, + #[command(flatten)] + tag_selection: ExplicitTagSelectionArgs, #[arg(long = "parent")] parents: Vec<String>, } #[derive(Args)] +struct TagAddArgs { + #[command(flatten)] + project: ProjectArg, + #[arg(long)] + name: String, + #[arg(long)] + description: String, +} + +#[derive(Args)] struct QuickResearchArgs { #[command(flatten)] project: ProjectArg, @@ -244,16 +314,24 @@ struct QuickResearchArgs { #[derive(Subcommand)] enum ExperimentCommand { + /// Close a core-path experiment with checkpoint, run, note, and verdict. Close(ExperimentCloseArgs), } #[derive(Subcommand)] enum McpCommand { + /// Serve the public stdio MCP host. If `--project` is omitted, the host starts unbound. Serve(McpServeArgs), #[command(hide = true)] Worker(McpWorkerArgs), } +#[derive(Subcommand)] +enum UiCommand { + /// Serve the local read-only navigator. + Serve(UiServeArgs), +} + #[derive(Args)] struct ExperimentCloseArgs { #[command(flatten)] @@ -304,33 +382,41 @@ struct ExperimentCloseArgs { #[derive(Subcommand)] enum SkillCommand { + /// List bundled skills. List, + /// Install bundled skills into a Codex skill directory. Install(SkillInstallArgs), + /// Print one bundled skill body. Show(SkillShowArgs), } #[derive(Args)] struct SkillInstallArgs { + /// Bundled skill name. Defaults to all bundled skills. #[arg(long)] name: Option<String>, + /// Destination root. Defaults to `~/.codex/skills`. #[arg(long)] destination: Option<PathBuf>, } #[derive(Args)] struct SkillShowArgs { + /// Bundled skill name. Defaults to `fidget-spinner`. #[arg(long)] name: Option<String>, } #[derive(Args)] struct ProjectArg { + /// Project root or any nested path inside a project containing `.fidget_spinner/`. #[arg(long, default_value = ".")] project: PathBuf, } #[derive(Args)] struct McpServeArgs { + /// Optional initial project binding. When omitted, the MCP starts unbound. #[arg(long)] project: Option<PathBuf>, } @@ -341,6 +427,18 @@ struct McpWorkerArgs { project: PathBuf, } +#[derive(Args)] +struct UiServeArgs { + #[command(flatten)] + project: ProjectArg, + /// Bind address for the local navigator. + #[arg(long, default_value = "127.0.0.1:8913")] + bind: SocketAddr, + /// Maximum rows rendered in list views. + #[arg(long, default_value_t = 200)] + limit: u32, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum CliNodeClass { Contract, @@ -416,6 +514,10 @@ fn run() -> Result<(), StoreError> { Command::Note(command) => match command.command { NoteSubcommand::Quick(args) => run_quick_note(args), }, + Command::Tag { command } => match command { + TagCommand::Add(args) => run_tag_add(args), + TagCommand::List(project) => run_tag_list(project), + }, Command::Research(command) => match command.command { ResearchSubcommand::Add(args) => run_quick_research(args), }, @@ -426,6 +528,9 @@ fn run() -> Result<(), StoreError> { McpCommand::Serve(args) => mcp::serve(args.project), McpCommand::Worker(args) => mcp::serve_worker(args.project), }, + Command::Ui { command } => match command { + UiCommand::Serve(args) => run_ui_serve(args), + }, Command::Skill { command } => match command { SkillCommand::List => print_json(&bundled_skill::bundled_skill_summaries()), SkillCommand::Install(args) => run_skill_install(args), @@ -439,16 +544,17 @@ fn run() -> Result<(), StoreError> { fn run_init(args: InitArgs) -> Result<(), StoreError> { let project_root = utf8_path(args.project); - let display_name = NonEmptyText::new(args.name.unwrap_or_else(|| { - project_root - .file_name() - .map_or_else(|| "fidget-spinner-project".to_owned(), ToOwned::to_owned) - }))?; + let display_name = args + .name + .map(NonEmptyText::new) + .transpose()? + .unwrap_or(default_display_name_for_root(&project_root)?); let namespace = NonEmptyText::new(args.namespace)?; let store = ProjectStore::init(&project_root, display_name, namespace)?; println!("initialized {}", store.state_root()); println!("project: {}", store.config().display_name); println!("schema: {}", store.state_root().join("schema.json")); + maybe_print_gitignore_hint(&project_root)?; Ok(()) } @@ -498,6 +604,7 @@ fn run_node_add(args: NodeAddArgs) -> Result<(), StoreError> { .as_deref() .map(parse_frontier_id) .transpose()?; + let tags = optional_cli_tags(args.tag_selection, args.class == CliNodeClass::Note)?; let payload = load_payload( store.schema().schema_ref(), args.payload_json, @@ -514,6 +621,7 @@ fn run_node_add(args: NodeAddArgs) -> Result<(), StoreError> { frontier_id, title: NonEmptyText::new(args.title)?, summary: args.summary.map(NonEmptyText::new).transpose()?, + tags, payload, annotations, attachments: lineage_attachments(args.parents)?, @@ -530,6 +638,7 @@ fn run_node_list(args: NodeListArgs) -> Result<(), StoreError> { .map(parse_frontier_id) .transpose()?, class: args.class.map(Into::into), + tags: parse_tag_set(args.tags)?, include_archived: args.include_archived, limit: args.limit, })?; @@ -585,6 +694,7 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> { .transpose()?, title: NonEmptyText::new(args.title)?, summary: None, + tags: Some(explicit_cli_tags(args.tag_selection)?), payload, annotations: Vec::new(), attachments: lineage_attachments(args.parents)?, @@ -592,6 +702,20 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> { print_json(&node) } +fn run_tag_add(args: TagAddArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let tag = store.add_tag( + TagName::new(args.name)?, + NonEmptyText::new(args.description)?, + )?; + print_json(&tag) +} + +fn run_tag_list(args: ProjectArg) -> Result<(), StoreError> { + let store = open_store(&args.project)?; + print_json(&store.list_tags()?) +} + fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> { let mut store = open_store(&args.project.project)?; let payload = NodePayload::with_schema( @@ -607,6 +731,7 @@ fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> { .transpose()?, title: NonEmptyText::new(args.title)?, summary: args.summary.map(NonEmptyText::new).transpose()?, + tags: None, payload, annotations: Vec::new(), attachments: lineage_attachments(args.parents)?, @@ -684,6 +809,10 @@ fn run_skill_install(args: SkillInstallArgs) -> Result<(), StoreError> { Ok(()) } +fn run_ui_serve(args: UiServeArgs) -> Result<(), StoreError> { + ui::serve(utf8_path(args.project.project), args.bind, args.limit) +} + fn resolve_bundled_skill( requested_name: Option<&str>, ) -> Result<bundled_skill::BundledSkill, StoreError> { @@ -712,10 +841,88 @@ fn open_store(path: &Path) -> Result<ProjectStore, StoreError> { ProjectStore::open(utf8_path(path.to_path_buf())) } +fn open_or_init_store_for_binding(path: &Path) -> Result<ProjectStore, StoreError> { + let requested_root = utf8_path(path.to_path_buf()); + match ProjectStore::open(requested_root.clone()) { + Ok(store) => Ok(store), + Err(StoreError::MissingProjectStore(_)) => { + let project_root = binding_bootstrap_root(&requested_root)?; + if !is_empty_directory(&project_root)? { + return Err(StoreError::MissingProjectStore(requested_root)); + } + ProjectStore::init( + &project_root, + default_display_name_for_root(&project_root)?, + default_namespace_for_root(&project_root)?, + ) + } + Err(error) => Err(error), + } +} + fn utf8_path(path: impl Into<PathBuf>) -> Utf8PathBuf { Utf8PathBuf::from(path.into().to_string_lossy().into_owned()) } +fn binding_bootstrap_root(path: &Utf8Path) -> Result<Utf8PathBuf, StoreError> { + match fs::metadata(path.as_std_path()) { + Ok(metadata) if metadata.is_file() => Ok(path + .parent() + .map_or_else(|| path.to_path_buf(), Utf8Path::to_path_buf)), + Ok(_) => Ok(path.to_path_buf()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(error) => Err(StoreError::from(error)), + } +} + +fn is_empty_directory(path: &Utf8Path) -> Result<bool, StoreError> { + match fs::metadata(path.as_std_path()) { + Ok(metadata) if metadata.is_dir() => { + let mut entries = fs::read_dir(path.as_std_path())?; + Ok(entries.next().transpose()?.is_none()) + } + Ok(_) => Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(StoreError::from(error)), + } +} + +fn default_display_name_for_root(project_root: &Utf8Path) -> Result<NonEmptyText, StoreError> { + NonEmptyText::new( + project_root + .file_name() + .map_or_else(|| "fidget-spinner-project".to_owned(), ToOwned::to_owned), + ) + .map_err(StoreError::from) +} + +fn default_namespace_for_root(project_root: &Utf8Path) -> Result<NonEmptyText, StoreError> { + let slug = slugify_namespace_component(project_root.file_name().unwrap_or("project")); + NonEmptyText::new(format!("local.{slug}")).map_err(StoreError::from) +} + +fn slugify_namespace_component(raw: &str) -> String { + let mut slug = String::new(); + let mut previous_was_separator = false; + for character in raw.chars().flat_map(char::to_lowercase) { + if character.is_ascii_alphanumeric() { + slug.push(character); + previous_was_separator = false; + continue; + } + if !previous_was_separator { + slug.push('_'); + previous_was_separator = true; + } + } + let slug = slug.trim_matches('_').to_owned(); + if slug.is_empty() { + "project".to_owned() + } else { + slug + } +} + fn to_text_vec(values: Vec<String>) -> Result<Vec<NonEmptyText>, StoreError> { values .into_iter() @@ -728,6 +935,35 @@ fn to_text_set(values: Vec<String>) -> Result<BTreeSet<NonEmptyText>, StoreError to_text_vec(values).map(BTreeSet::from_iter) } +fn parse_tag_set(values: Vec<String>) -> Result<BTreeSet<TagName>, StoreError> { + values + .into_iter() + .map(TagName::new) + .collect::<Result<BTreeSet<_>, _>>() + .map_err(StoreError::from) +} + +fn explicit_cli_tags(selection: ExplicitTagSelectionArgs) -> Result<BTreeSet<TagName>, StoreError> { + optional_cli_tags(selection, true)?.ok_or(StoreError::NoteTagsRequired) +} + +fn optional_cli_tags( + selection: ExplicitTagSelectionArgs, + required: bool, +) -> Result<Option<BTreeSet<TagName>>, StoreError> { + if selection.no_tags { + return Ok(Some(BTreeSet::new())); + } + if selection.tags.is_empty() { + return if required { + Err(StoreError::NoteTagsRequired) + } else { + Ok(None) + }; + } + Ok(Some(parse_tag_set(selection.tags)?)) +} + fn parse_env(values: Vec<String>) -> BTreeMap<String, String> { values .into_iter() @@ -825,6 +1061,29 @@ fn run_git(project_root: &Utf8Path, args: &[&str]) -> Result<Option<String>, Sto Ok(Some(text)) } +fn maybe_print_gitignore_hint(project_root: &Utf8Path) -> Result<(), StoreError> { + if run_git(project_root, &["rev-parse", "--show-toplevel"])?.is_none() { + return Ok(()); + } + + let status = std::process::Command::new("git") + .arg("-C") + .arg(project_root.as_str()) + .args(["check-ignore", "-q", ".fidget_spinner"]) + .status()?; + + match status.code() { + Some(0) => Ok(()), + Some(1) => { + println!( + "note: add `.fidget_spinner/` to `.gitignore` or `.git/info/exclude` if you do not want local state in `git status`" + ); + Ok(()) + } + _ => Ok(()), + } +} + fn parse_metric_observation(raw: String) -> Result<MetricObservation, StoreError> { let parts = raw.split(':').collect::<Vec<_>>(); if parts.len() != 4 { diff --git a/crates/fidget-spinner-cli/src/ui.rs b/crates/fidget-spinner-cli/src/ui.rs new file mode 100644 index 0000000..0cb9c05 --- /dev/null +++ b/crates/fidget-spinner-cli/src/ui.rs @@ -0,0 +1,600 @@ +use std::collections::BTreeMap; +use std::io; +use std::net::SocketAddr; + +use axum::Router; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use camino::Utf8PathBuf; +use fidget_spinner_core::{DagNode, FieldValueType, NodeClass, ProjectSchema, TagName}; +use linkify::{LinkFinder, LinkKind}; +use maud::{DOCTYPE, Markup, PreEscaped, html}; +use serde::Deserialize; +use serde_json::Value; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::{open_store, to_pretty_json}; + +#[derive(Clone)] +struct NavigatorState { + project_root: Utf8PathBuf, + limit: u32, +} + +#[derive(Debug, Default, Deserialize)] +struct NavigatorQuery { + tag: Option<String>, +} + +struct NavigatorEntry { + node: DagNode, + frontier_label: Option<String>, +} + +struct TagFacet { + name: TagName, + description: String, + count: usize, +} + +pub(crate) fn serve( + project_root: Utf8PathBuf, + bind: SocketAddr, + limit: u32, +) -> Result<(), fidget_spinner_store_sqlite::StoreError> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_io() + .build() + .map_err(fidget_spinner_store_sqlite::StoreError::from)?; + runtime.block_on(async move { + let state = NavigatorState { + project_root, + limit, + }; + let app = Router::new() + .route("/", get(navigator)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind(bind) + .await + .map_err(fidget_spinner_store_sqlite::StoreError::from)?; + println!("navigator: http://{bind}/"); + axum::serve(listener, app).await.map_err(|error| { + fidget_spinner_store_sqlite::StoreError::Io(io::Error::other(error.to_string())) + }) + }) +} + +async fn navigator( + State(state): State<NavigatorState>, + Query(query): Query<NavigatorQuery>, +) -> Response { + match render_navigator(state, query) { + Ok(markup) => Html(markup.into_string()).into_response(), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("navigator render failed: {error}"), + ) + .into_response(), + } +} + +fn render_navigator( + state: NavigatorState, + query: NavigatorQuery, +) -> Result<Markup, fidget_spinner_store_sqlite::StoreError> { + let store = open_store(state.project_root.as_std_path())?; + let selected_tag = query.tag.map(TagName::new).transpose()?; + let schema = store.schema().clone(); + let frontiers = store + .list_frontiers()? + .into_iter() + .map(|frontier| (frontier.id, frontier.label.to_string())) + .collect::<BTreeMap<_, _>>(); + + let recent_nodes = load_recent_nodes(&store, None, state.limit)?; + let visible_nodes = load_recent_nodes(&store, selected_tag.clone(), state.limit)?; + let tag_facets = store + .list_tags()? + .into_iter() + .map(|tag| TagFacet { + count: recent_nodes + .iter() + .filter(|node| node.tags.contains(&tag.name)) + .count(), + description: tag.description.to_string(), + name: tag.name, + }) + .collect::<Vec<_>>(); + let entries = visible_nodes + .into_iter() + .map(|node| NavigatorEntry { + frontier_label: node + .frontier_id + .and_then(|frontier_id| frontiers.get(&frontier_id).cloned()), + node, + }) + .collect::<Vec<_>>(); + + let title = selected_tag.as_ref().map_or_else( + || "all recent nodes".to_owned(), + |tag| format!("tag: {tag}"), + ); + let project_name = store.config().display_name.to_string(); + + Ok(html! { + (DOCTYPE) + html { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { "Fidget Spinner Navigator" } + style { (PreEscaped(stylesheet().to_owned())) } + } + body { + main class="shell" { + aside class="rail" { + h1 { "Navigator" } + p class="project" { (project_name) } + nav class="tag-list" { + a + href="/" + class={ "tag-link " (if selected_tag.is_none() { "selected" } else { "" }) } { + span class="tag-name" { "all" } + span class="tag-count" { (recent_nodes.len()) } + } + @for facet in &tag_facets { + a + href={ "/?tag=" (facet.name.as_str()) } + class={ "tag-link " (if selected_tag.as_ref() == Some(&facet.name) { "selected" } else { "" }) } { + span class="tag-name" { (facet.name.as_str()) } + span class="tag-count" { (facet.count) } + span class="tag-description" { (facet.description.as_str()) } + } + } + } + } + section class="feed" { + header class="feed-header" { + h2 { (title) } + p class="feed-meta" { + (entries.len()) " shown" + " · " + (recent_nodes.len()) " recent" + " · " + (state.limit) " max" + } + } + @if entries.is_empty() { + article class="empty-state" { + h3 { "No matching nodes" } + p { "Try clearing the tag filter or recording new notes." } + } + } @else { + @for entry in &entries { + (render_entry(entry, &schema)) + } + } + } + } + } + } + }) +} + +fn load_recent_nodes( + store: &fidget_spinner_store_sqlite::ProjectStore, + tag: Option<TagName>, + limit: u32, +) -> Result<Vec<DagNode>, fidget_spinner_store_sqlite::StoreError> { + let summaries = store.list_nodes(fidget_spinner_store_sqlite::ListNodesQuery { + tags: tag.into_iter().collect(), + limit, + ..fidget_spinner_store_sqlite::ListNodesQuery::default() + })?; + summaries + .into_iter() + .map(|summary| { + store.get_node(summary.id)?.ok_or( + fidget_spinner_store_sqlite::StoreError::NodeNotFound(summary.id), + ) + }) + .collect() +} + +fn render_entry(entry: &NavigatorEntry, schema: &ProjectSchema) -> Markup { + let body = entry.node.payload.field("body").and_then(Value::as_str); + let mut keys = entry + .node + .payload + .fields + .keys() + .filter(|name| name.as_str() != "body") + .cloned() + .collect::<Vec<_>>(); + keys.sort_unstable(); + + html! { + article class="entry" id={ "node-" (entry.node.id) } { + header class="entry-header" { + div class="entry-title-row" { + span class={ "class-badge class-" (entry.node.class.as_str()) } { + (entry.node.class.as_str()) + } + h3 class="entry-title" { + a href={ "#node-" (entry.node.id) } { (entry.node.title.as_str()) } + } + } + div class="entry-meta" { + span { (render_timestamp(entry.node.updated_at)) } + @if let Some(label) = &entry.frontier_label { + span { "frontier: " (label.as_str()) } + } + @if !entry.node.tags.is_empty() { + span class="tag-strip" { + @for tag in &entry.node.tags { + a class="entry-tag" href={ "/?tag=" (tag.as_str()) } { (tag.as_str()) } + } + } + } + } + } + @if let Some(summary) = &entry.node.summary { + p class="entry-summary" { (summary.as_str()) } + } + @if let Some(body) = body { + section class="entry-body" { + (render_string_value(body)) + } + } + @if !keys.is_empty() { + dl class="field-list" { + @for key in &keys { + @if let Some(value) = entry.node.payload.field(key) { + (render_field(entry.node.class, schema, key, value)) + } + } + } + } + @if !entry.node.diagnostics.items.is_empty() { + section class="diagnostics" { + h4 { "diagnostics" } + ul { + @for item in &entry.node.diagnostics.items { + li { + span class="diag-severity" { (format!("{:?}", item.severity).to_ascii_lowercase()) } + " " + (item.message.as_str()) + } + } + } + } + } + } + } +} + +fn render_field(class: NodeClass, schema: &ProjectSchema, key: &str, value: &Value) -> Markup { + let value_type = schema + .field_spec(class, key) + .and_then(|field| field.value_type); + let is_plottable = schema + .field_spec(class, key) + .is_some_and(|field| field.is_plottable()); + html! { + dt { + (key) + @if let Some(value_type) = value_type { + span class="field-type" { (value_type.as_str()) } + } + @if is_plottable { + span class="field-type plottable" { "plot" } + } + } + dd { + @match value_type { + Some(FieldValueType::String) => { + @if let Some(text) = value.as_str() { + (render_string_value(text)) + } @else { + (render_json_value(value)) + } + } + Some(FieldValueType::Numeric) => { + @if let Some(number) = value.as_f64() { + code class="numeric" { (number) } + } @else { + (render_json_value(value)) + } + } + Some(FieldValueType::Boolean) => { + @if let Some(boolean) = value.as_bool() { + span class={ "boolean " (if boolean { "true" } else { "false" }) } { + (if boolean { "true" } else { "false" }) + } + } @else { + (render_json_value(value)) + } + } + Some(FieldValueType::Timestamp) => { + @if let Some(raw) = value.as_str() { + time datetime=(raw) { (render_timestamp_value(raw)) } + } @else { + (render_json_value(value)) + } + } + None => (render_json_value(value)), + } + } + } +} + +fn render_string_value(text: &str) -> Markup { + let finder = LinkFinder::new(); + html! { + div class="rich-text" { + @for line in text.lines() { + p { + @for span in finder.spans(line) { + @match span.kind() { + Some(LinkKind::Url) => a href=(span.as_str()) { (span.as_str()) }, + _ => (span.as_str()), + } + } + } + } + } + } +} + +fn render_json_value(value: &Value) -> Markup { + let text = to_pretty_json(value).unwrap_or_else(|_| value.to_string()); + html! { + pre class="json-value" { (text) } + } +} + +fn render_timestamp(timestamp: OffsetDateTime) -> String { + timestamp + .format(&Rfc3339) + .unwrap_or_else(|_| timestamp.to_string()) +} + +fn render_timestamp_value(raw: &str) -> String { + OffsetDateTime::parse(raw, &Rfc3339) + .map(render_timestamp) + .unwrap_or_else(|_| raw.to_owned()) +} + +fn stylesheet() -> &'static str { + r#" + :root { + color-scheme: light; + --bg: #f6f3ec; + --panel: #fffdf8; + --line: #d8d1c4; + --text: #22201a; + --muted: #746e62; + --accent: #2d5c4d; + --accent-soft: #dbe8e2; + --tag: #ece5d8; + --warn: #8b5b24; + } + + * { box-sizing: border-box; } + + body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 "Iosevka Web", "IBM Plex Mono", "SFMono-Regular", monospace; + } + + a { + color: var(--accent); + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + .shell { + display: grid; + grid-template-columns: 18rem minmax(0, 1fr); + min-height: 100vh; + } + + .rail { + border-right: 1px solid var(--line); + padding: 1.25rem 1rem; + position: sticky; + top: 0; + align-self: start; + height: 100vh; + overflow: auto; + background: rgba(255, 253, 248, 0.85); + backdrop-filter: blur(6px); + } + + .project, .feed-meta, .entry-meta, .entry-summary, .tag-description { + color: var(--muted); + } + + .tag-list { + display: grid; + gap: 0.5rem; + } + + .tag-link { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.2rem 0.75rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--line); + background: var(--panel); + } + + .tag-link.selected { + border-color: var(--accent); + background: var(--accent-soft); + } + + .tag-name { + font-weight: 700; + overflow-wrap: anywhere; + } + + .tag-count { + color: var(--muted); + } + + .tag-description { + grid-column: 1 / -1; + font-size: 0.9rem; + } + + .feed { + padding: 1.5rem; + display: grid; + gap: 1rem; + } + + .feed-header { + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--line); + } + + .entry, .empty-state { + background: var(--panel); + border: 1px solid var(--line); + padding: 1rem 1.1rem; + } + + .entry-header { + display: grid; + gap: 0.35rem; + margin-bottom: 0.75rem; + } + + .entry-title-row { + display: flex; + gap: 0.75rem; + align-items: baseline; + } + + .entry-title { + margin: 0; + font-size: 1.05rem; + } + + .entry-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.9rem; + } + + .class-badge, .field-type, .entry-tag { + display: inline-block; + padding: 0.08rem 0.4rem; + border: 1px solid var(--line); + background: var(--tag); + font-size: 0.82rem; + } + + .field-type.plottable { + background: var(--accent-soft); + border-color: var(--accent); + } + + .tag-strip { + display: inline-flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .entry-body { + margin-bottom: 0.9rem; + } + + .rich-text p { + margin: 0 0 0.55rem; + } + + .rich-text p:last-child { + margin-bottom: 0; + } + + .field-list { + display: grid; + grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr); + gap: 0.55rem 1rem; + margin: 0; + } + + .field-list dt { + font-weight: 700; + display: flex; + gap: 0.4rem; + align-items: center; + overflow-wrap: anywhere; + } + + .field-list dd { + margin: 0; + } + + .json-value { + margin: 0; + padding: 0.6rem 0.7rem; + background: #f3eee4; + overflow: auto; + } + + .boolean.true { color: var(--accent); } + .boolean.false { color: #8a2f2f; } + .numeric { font-size: 1rem; } + + .diagnostics { + margin-top: 1rem; + padding-top: 0.8rem; + border-top: 1px dashed var(--line); + } + + .diagnostics h4 { + margin: 0 0 0.4rem; + font-size: 0.9rem; + text-transform: lowercase; + } + + .diagnostics ul { + margin: 0; + padding-left: 1.1rem; + } + + .diag-severity { + color: var(--warn); + font-weight: 700; + } + + @media (max-width: 900px) { + .shell { + grid-template-columns: 1fr; + } + + .rail { + position: static; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .field-list { + grid-template-columns: 1fr; + } + } + "# +} |