diff --git a/src/main.rs b/src/main.rs index 0049dcd..384570d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,16 @@ use std::{process, sync::Arc}; -use crate::{cli::Cli, gui::FilViz, tui::TuiApp}; +use crate::{ + cli::Cli, + config::Config, + gui::FilViz, + tui::TuiApp, + types::{Kasten, KastenHandle, Workspace}, +}; use clap::Parser; +use tokio::sync::RwLock; +use tracing::{debug, info}; mod cli; mod gui; @@ -30,6 +38,15 @@ fn main() -> color_eyre::Result<()> { return rt.block_on(async { command.process().await }); } + // create the kasten handle + let kh: KastenHandle = rt.block_on(async { + let cfg = Config::parse()?; + let ws = Workspace::instansiate(cfg.app_config.workspace).await?; + Ok::(Arc::new(RwLock::new(Kasten::index(ws).await?))) + })?; + + debug!("{kh:#?}"); + // then we spawn the tui on its own thread let tui_handle = std::thread::spawn({ // arc stuff @@ -39,7 +56,7 @@ fn main() -> color_eyre::Result<()> { move || -> color_eyre::Result<()> { // block the tui on the same runtime as above tui_rt.block_on(async { - let mut tui = TuiApp::new(args.tick_rate, args.frame_rate)?; + let mut tui = TuiApp::new(args.tick_rate, args.frame_rate, kh)?; tui.run().await?; // just close everything as soon as the tui is done running process::exit(0); diff --git a/src/tui/app.rs b/src/tui/app.rs index 08423ce..f07258f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -11,6 +11,7 @@ use tracing::debug; use crate::{ config::Config, tui::{Event, Tui, components::Zk}, + types::KastenHandle, }; use super::{components::Component, signal::Signal}; @@ -25,6 +26,7 @@ pub struct App { #[allow(dead_code)] region: Region, last_tick_key_events: Vec, + kh: KastenHandle, signal_tx: UnboundedSender, signal_rx: UnboundedReceiver, } @@ -43,18 +45,19 @@ pub enum Region { impl App { /// Construct a new `App` instance. - pub fn new(tick_rate: f64, frame_rate: f64) -> Result { + pub fn new(tick_rate: f64, frame_rate: f64, kh: KastenHandle) -> Result { let (signal_tx, signal_rx) = mpsc::unbounded_channel(); Ok(Self { tick_rate, frame_rate, - components: vec![Box::new(Zk::new())], + components: vec![Box::new(Zk::new(kh.clone()))], should_quit: false, should_suspend: false, config: Config::parse()?, region: Region::default(), last_tick_key_events: Vec::new(), + kh, signal_tx, signal_rx, }) diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index efbad1b..2699733 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -1,19 +1,53 @@ use async_trait::async_trait; -use color_eyre::eyre::Result; -use ratatui::widgets::Paragraph; +use color_eyre::{eyre::Result, owo_colors::colors::Red}; +use ratatui::{prelude::*, widgets::Block}; use tokio::sync::mpsc::UnboundedSender; -use crate::tui::{Signal, components::Component}; +use crate::{ + tui::{Signal, components::Component}, + types::KastenHandle, +}; mod zettel; pub struct Zk { signal_tx: Option>, + kh: KastenHandle, + layouts: Layouts, +} + +struct Layouts { + left_right: Layout, + search_zl: Layout, + z_preview: Layout, +} + +impl Default for Layouts { + fn default() -> Self { + Self { + left_right: Layout::horizontal(vec![ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]), + search_zl: Layout::vertical(vec![ + Constraint::Percentage(15), + Constraint::Percentage(85), + ]), + z_preview: Layout::vertical(vec![ + Constraint::Percentage(30), + Constraint::Percentage(70), + ]), + } + } } impl Zk { - pub const fn new() -> Self { - Self { signal_tx: None } + pub fn new(kh: KastenHandle) -> Self { + Self { + signal_tx: None, + kh, + layouts: Layouts::default(), + } } } @@ -45,8 +79,23 @@ impl Component for Zk { frame: &mut ratatui::Frame, area: ratatui::prelude::Rect, ) -> color_eyre::Result<()> { - let hello = Paragraph::new("Hello Surendra"); - frame.render_widget(hello, area); + let (search_layout, zettel_list_layout, zettel_layout, preview_layout) = { + let rects = self.layouts.left_right.split(area); + + let (left, right) = (rects[0], rects[1]); + + let l_rects = self.layouts.search_zl.split(left); + + let r_rects = self.layouts.z_preview.split(right); + + (l_rects[0], l_rects[1], r_rects[0], r_rects[1]) + }; + + frame.render_widget(Block::new().bg(Color::Red), search_layout); + frame.render_widget(Block::new().bg(Color::Blue), zettel_list_layout); + frame.render_widget(Block::new().bg(Color::Green), zettel_layout); + frame.render_widget(Block::new().bg(Color::Yellow), preview_layout); + Ok(()) } } diff --git a/src/types/kasten.rs b/src/types/kasten.rs index 25aea03..8f317b8 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -6,7 +6,8 @@ use egui_graphs::{ petgraph::{Directed, graph::NodeIndex, prelude::StableGraph}, }; use rayon::iter::{ParallelBridge as _, ParallelIterator as _}; -use std::{cmp::max, collections::HashMap}; +use std::{cmp::max, collections::HashMap, sync::Arc}; +use tokio::sync::RwLock; use crate::types::Workspace; @@ -35,6 +36,8 @@ const GRAPH_MIN_NODES: usize = 128; /// Arbitrarily chosen minimum number of edges const GRAPH_MIN_EDGES: usize = GRAPH_MIN_NODES * 3; +pub type KastenHandle = Arc>; + impl Kasten { /// Indexes the `Workspace` and constructs a `Kasten` #[expect(dead_code)] diff --git a/src/types/mod.rs b/src/types/mod.rs index ca93402..13b2415 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -25,8 +25,8 @@ mod link; pub use link::Link; mod kasten; -#[expect(unused_imports)] pub use kasten::Kasten; +pub use kasten::KastenHandle; mod frontmatter; pub use frontmatter::FrontMatter; -- 2.54.0 From eb5aa10fbd10d70b8f7f428e25ab7780d534aa48 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Tue, 31 Mar 2026 22:49:23 -0700 Subject: [PATCH 02/12] feat: zettel_list + preview! --- .config/config.kdl | 2 + flake.lock | 22 ++-- src/main.rs | 4 +- src/tui/app.rs | 8 +- src/tui/components/zk/mod.rs | 179 ++++++++++++++++++++++----- src/tui/components/zk/preview.rs | 23 ++++ src/tui/components/zk/zettel.rs | 0 src/tui/components/zk/zettel_view.rs | 89 +++++++++++++ src/tui/signal.rs | 5 + src/types/frontmatter.rs | 2 +- src/types/kasten.rs | 12 +- src/types/zettel.rs | 17 ++- 12 files changed, 311 insertions(+), 52 deletions(-) create mode 100644 src/tui/components/zk/preview.rs delete mode 100644 src/tui/components/zk/zettel.rs create mode 100644 src/tui/components/zk/zettel_view.rs diff --git a/.config/config.kdl b/.config/config.kdl index 7e3bb51..ab9a878 100644 --- a/.config/config.kdl +++ b/.config/config.kdl @@ -8,5 +8,7 @@ keymap { Quit // Another way to quit Suspend // Suspend the application h Helix + j MoveDown + k MoveUp } } diff --git a/flake.lock b/flake.lock index f7ee214..d650120 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1774682177, - "narHash": "sha256-OVbuJnJLlbHE28eRMudjtA6NXz/ifuXSho79gvh6GHY=", + "lastModified": 1774887814, + "narHash": "sha256-8bsvr2SgW9t70nFxUdMVQg4RSU+E/y9+Je4urivtOYE=", "owner": "nix-community", "repo": "fenix", - "rev": "e0f515387df77b9fdbaaf81e7f866f0365474c18", + "rev": "9b6c1cb49eff1346e1f5d2430994a0ba0fa02910", "type": "github" }, "original": { @@ -23,12 +23,12 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774386573, - "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", - "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", - "revCount": 969196, + "lastModified": 1774709303, + "narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=", + "rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685", + "revCount": 971119, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.969196%2Brev-46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9/019d279e-af65-79ce-92be-5dee7b1e36d4/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.971119%2Brev-8110df5ad7abf5d4c0f6fb0f8f978390e77f9685/019d3c72-3e5d-7d8e-a4fc-0fe67ed1554b/source.tar.gz" }, "original": { "type": "tarball", @@ -44,11 +44,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1774569884, - "narHash": "sha256-E8iWEPzg7OnE0XXXjo75CX7xFauqzJuGZ5wSO9KS8Ek=", + "lastModified": 1774787924, + "narHash": "sha256-Cbpmf0+1pqi/zbpub2vkp5lTPx3QdVtDkkagDwQzHHg=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "443ddcddd0c73b07b799d052f5ef3b448c2f3508", + "rev": "f1297b21119565c626320c1ffc248965fffb2527", "type": "github" }, "original": { diff --git a/src/main.rs b/src/main.rs index 384570d..ac1f232 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use crate::{ }; use clap::Parser; use tokio::sync::RwLock; -use tracing::{debug, info}; +use tracing::debug; mod cli; mod gui; @@ -56,7 +56,7 @@ fn main() -> color_eyre::Result<()> { move || -> color_eyre::Result<()> { // block the tui on the same runtime as above tui_rt.block_on(async { - let mut tui = TuiApp::new(args.tick_rate, args.frame_rate, kh)?; + let mut tui = TuiApp::new(args.tick_rate, args.frame_rate, kh).await?; tui.run().await?; // just close everything as soon as the tui is done running process::exit(0); diff --git a/src/tui/app.rs b/src/tui/app.rs index f07258f..0a86d4b 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -26,7 +26,7 @@ pub struct App { #[allow(dead_code)] region: Region, last_tick_key_events: Vec, - kh: KastenHandle, + _kh: KastenHandle, signal_tx: UnboundedSender, signal_rx: UnboundedReceiver, } @@ -45,19 +45,19 @@ pub enum Region { impl App { /// Construct a new `App` instance. - pub fn new(tick_rate: f64, frame_rate: f64, kh: KastenHandle) -> Result { + pub async fn new(tick_rate: f64, frame_rate: f64, kh: KastenHandle) -> Result { let (signal_tx, signal_rx) = mpsc::unbounded_channel(); Ok(Self { tick_rate, frame_rate, - components: vec![Box::new(Zk::new(kh.clone()))], + components: vec![Box::new(Zk::new(kh.clone()).await?)], should_quit: false, should_suspend: false, config: Config::parse()?, region: Region::default(), last_tick_key_events: Vec::new(), - kh, + _kh: kh, signal_tx, signal_rx, }) diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index 2699733..cad0358 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -1,19 +1,29 @@ use async_trait::async_trait; -use color_eyre::{eyre::Result, owo_colors::colors::Red}; -use ratatui::{prelude::*, widgets::Block}; +use color_eyre::eyre::Result; +use ratatui::{ + prelude::*, + widgets::{Block, List, ListState}, +}; use tokio::sync::mpsc::UnboundedSender; use crate::{ tui::{Signal, components::Component}, - types::KastenHandle, + types::{KastenHandle, ZettelId}, }; -mod zettel; +mod preview; +mod zettel_view; + +use preview::Preview; +use zettel_view::ZettelView; -pub struct Zk { +pub struct Zk<'text> { signal_tx: Option>, kh: KastenHandle, layouts: Layouts, + zettel_list: ZettelList<'text>, + zettel_view: ZettelView<'text>, + preview: Preview<'text>, } struct Layouts { @@ -22,6 +32,12 @@ struct Layouts { z_preview: Layout, } +struct ZettelList<'text> { + render_list: List<'text>, + id_list: Vec, + state: ListState, +} + impl Default for Layouts { fn default() -> Self { Self { @@ -30,47 +46,144 @@ impl Default for Layouts { Constraint::Percentage(50), ]), search_zl: Layout::vertical(vec![ - Constraint::Percentage(15), - Constraint::Percentage(85), + Constraint::Percentage(10), + Constraint::Percentage(90), ]), z_preview: Layout::vertical(vec![ - Constraint::Percentage(30), - Constraint::Percentage(70), + Constraint::Percentage(20), + Constraint::Percentage(80), ]), } } } -impl Zk { - pub fn new(kh: KastenHandle) -> Self { - Self { +impl Zk<'_> { + pub async fn new(kh: KastenHandle) -> Result { + let kt = kh.read().await; + + let nodes = kt.graph.nodes_iter().collect::>(); + + let zettel_list = { + let render_list = List::new(nodes.iter().map(|(_, n)| { + let z = n.payload(); + let title = z.title.clone(); + let _tags = z.tags.clone(); + // let _last_modified = z.modified_at; + Text::from(title) + })) + .style(Color::White) + .highlight_style(Modifier::REVERSED) + .highlight_symbol("> "); + + let id_list = nodes + .iter() + .map(|(_, n)| n.payload().id.clone()) + .collect::>(); + + let mut state = ListState::default(); + state.select_first(); + + ZettelList { + render_list, + id_list, + state, + } + }; + + let selected_zettel = zettel_list + .id_list + .get( + zettel_list + .state + .selected() + .expect("TODO: must handle the case where there isnt one..."), + ) + .expect("must exist"); + + let zettel = kt + .get_by_zettel_id(selected_zettel) + .expect("must exist, handle case where it doesnt later...") + .payload(); + + let preview = Preview::from( + zettel + .content(&kt.ws) + .await + .expect("This thing cannot be parsed properly..."), + ); + + // okay now that we have the zettel we need to construct the zettel out of this id + let zettel_view: ZettelView = kt + .get_by_zettel_id(selected_zettel) + .expect("must exist, handle case where it doesnt later...") + .payload() + .into(); + + drop(kt); + + Ok(Self { signal_tx: None, kh, layouts: Layouts::default(), - } + zettel_list, + zettel_view, + preview, + }) + } + + async fn update_views_from_zettel_list_selection(&mut self) -> Result<()> { + let selection_idx = self + .zettel_list + .state + .selected() + .expect("i have no idea what to do if this doesnt exist"); + + // sometimes the selection we get is over the length of the thing, so its + // actually fine if this is none, just means we reached the end of the list + let Some(z_id) = self.zettel_list.id_list.get(selection_idx) else { + return Ok(()); + }; + + let kh = self.kh.read().await; + + self.zettel_view = kh + .get_by_zettel_id(z_id) + .expect("this should be valid unless the kasten changed out underneath us") + .payload() + .into(); + + self.preview = kh + .get_by_zettel_id(z_id) + .expect("this should be valid unless the kasten changed out underneath us") + .payload() + .content(&kh.ws) + .await? + .into(); + drop(kh); + + Ok(()) } } #[async_trait] -impl Component for Zk { +impl Component for Zk<'_> { fn register_signal_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.signal_tx = Some(tx); Ok(()) } - async fn update(&mut self, _signal: Signal) -> Result> { - // match signal { - // Signal::Tick => todo!(), - // Signal::Render => todo!(), - // Signal::Resize(_, _) => todo!(), - // Signal::Suspend => todo!(), - // Signal::Resume => todo!(), - // Signal::Quit => todo!(), - // Signal::ClearScreen => todo!(), - // Signal::Error(_) => todo!(), - // Signal::Help => todo!(), - // Signal::Helix => todo!(), - // } + async fn update(&mut self, signal: Signal) -> Result> { + match signal { + Signal::MoveDown => { + self.zettel_list.state.select_next(); + self.update_views_from_zettel_list_selection().await?; + } + Signal::MoveUp => { + self.zettel_list.state.select_previous(); + self.update_views_from_zettel_list_selection().await?; + } + _ => {} + } Ok(None) } @@ -92,9 +205,15 @@ impl Component for Zk { }; frame.render_widget(Block::new().bg(Color::Red), search_layout); - frame.render_widget(Block::new().bg(Color::Blue), zettel_list_layout); - frame.render_widget(Block::new().bg(Color::Green), zettel_layout); - frame.render_widget(Block::new().bg(Color::Yellow), preview_layout); + + frame.render_stateful_widget( + &self.zettel_list.render_list, + zettel_list_layout, + &mut self.zettel_list.state, + ); + + frame.render_widget(self.zettel_view.clone(), zettel_layout); + frame.render_widget(self.preview.clone(), preview_layout); Ok(()) } diff --git a/src/tui/components/zk/preview.rs b/src/tui/components/zk/preview.rs new file mode 100644 index 0000000..9724e07 --- /dev/null +++ b/src/tui/components/zk/preview.rs @@ -0,0 +1,23 @@ +use ratatui::{text::Text, widgets::Widget}; + +#[derive(Debug, Clone)] +pub struct Preview<'text> { + content: Text<'text>, +} + +impl From for Preview<'_> { + fn from(value: String) -> Self { + Self { + content: Text::from(value), + } + } +} + +impl Widget for Preview<'_> { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + self.content.render(area, buf); + } +} diff --git a/src/tui/components/zk/zettel.rs b/src/tui/components/zk/zettel.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/tui/components/zk/zettel_view.rs b/src/tui/components/zk/zettel_view.rs new file mode 100644 index 0000000..bc49065 --- /dev/null +++ b/src/tui/components/zk/zettel_view.rs @@ -0,0 +1,89 @@ +use ratatui::{ + layout::{Constraint, Layout}, + text::Text, + widgets::Widget, +}; + +use crate::types::Zettel; + +/// A `Widget` that represents a `Zettel` +#[derive(Debug, Clone)] +pub struct ZettelView<'text> { + title: Text<'text>, + tags: Text<'text>, + created_at: Text<'text>, + modified_at: Text<'text>, + id: Text<'text>, + layouts: Layouts, +} + +impl From<&Zettel> for ZettelView<'_> { + fn from(value: &Zettel) -> Self { + Self { + title: Text::from(value.title.clone()), + tags: Text::from( + value + .tags + .iter() + .fold(" ".to_owned(), |acc, t| format!("{acc} {}", t.name)), + ), + created_at: Text::from(value.created_at()), + modified_at: Text::from(value.modified_at()), + id: Text::from(value.id.to_string()), + layouts: Layouts::default(), + } + } +} + +#[derive(Debug, Clone)] +struct Layouts { + left_right: Layout, + title_tags: Layout, + cr_mod_id: Layout, +} + +impl Default for Layouts { + fn default() -> Self { + Self { + left_right: Layout::horizontal(vec![ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]), + + title_tags: Layout::vertical(vec![ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]), + cr_mod_id: Layout::vertical(vec![ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]), + } + } +} + +impl Widget for ZettelView<'_> { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + let (title_rect, tags_rect, created_rect, modified_rect, id_rect) = { + let rects = self.layouts.left_right.split(area); + + let (left, right) = (rects[0], rects[1]); + + let l_rects = self.layouts.title_tags.split(left); + + let r_rects = self.layouts.cr_mod_id.split(right); + + (l_rects[0], l_rects[1], r_rects[0], r_rects[1], r_rects[2]) + }; + + self.title.render(title_rect, buf); + self.tags.render(tags_rect, buf); + self.created_at.render(created_rect, buf); + self.modified_at.render(modified_rect, buf); + self.id.render(id_rect, buf); + } +} diff --git a/src/tui/signal.rs b/src/tui/signal.rs index 5e0e3b3..cbd3d8d 100644 --- a/src/tui/signal.rs +++ b/src/tui/signal.rs @@ -17,6 +17,9 @@ pub enum Signal { ClearScreen, Error(String), Help, + MoveDown, + MoveUp, + /// this is fucking temporary Helix, } @@ -30,6 +33,8 @@ impl FromStr for Signal { "resume" => Self::Resume, "quit" => Self::Quit, "helix" => Self::Helix, + "movedown" => Self::MoveDown, + "moveup" => Self::MoveUp, _ => { return Err(eyre!(format!( "Attempt to construct a non-user Signal from str: {s}" diff --git a/src/types/frontmatter.rs b/src/types/frontmatter.rs index 41a3a16..89b7b3b 100644 --- a/src/types/frontmatter.rs +++ b/src/types/frontmatter.rs @@ -10,7 +10,7 @@ use dto::DateTime; use crate::types::{Link, Zettel}; -const DATE_FMT_STR: &str = "%Y-%m-%d %I:%M:%S %p"; +pub(super) const DATE_FMT_STR: &str = "%Y-%m-%d %I:%M:%S %p"; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)] pub struct FrontMatter { diff --git a/src/types/kasten.rs b/src/types/kasten.rs index 8f317b8..b1cfbde 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -2,7 +2,7 @@ use crate::types::{Link, Zettel, ZettelId}; use color_eyre::eyre::Result; use eframe::emath; use egui_graphs::{ - Graph, + Graph, Node, petgraph::{Directed, graph::NodeIndex, prelude::StableGraph}, }; use rayon::iter::{ParallelBridge as _, ParallelIterator as _}; @@ -40,7 +40,6 @@ pub type KastenHandle = Arc>; impl Kasten { /// Indexes the `Workspace` and constructs a `Kasten` - #[expect(dead_code)] pub async fn index(ws: Workspace) -> Result { let paths = std::fs::read_dir(&ws.root)? .par_bridge() @@ -105,4 +104,13 @@ impl Kasten { most_recently_edited: None, }) } + pub fn get_by_zettel_id(&self, id: &ZettelId) -> Option<&Node> { + let idx = self.zid_to_gid.get(id)?; + + let node = self.graph.node(*idx).expect( + "invariant broken if internal hashmap is not uptodate with + the state of the graph...", + ); + Some(node) + } } diff --git a/src/types/zettel.rs b/src/types/zettel.rs index 2286fd9..3dca505 100644 --- a/src/types/zettel.rs +++ b/src/types/zettel.rs @@ -15,12 +15,11 @@ use color_eyre::eyre::{Error, Result, eyre}; use dto::NanoId; use tokio::{fs::File, io::AsyncWriteExt}; -use crate::types::{FrontMatter, Link, Tag, Workspace}; +use crate::types::{FrontMatter, Link, Tag, Workspace, frontmatter}; /// A `Zettel` is a note about a single idea. /// It can have many `Tag`s, just meaning it can fall under many /// categories. -#[expect(dead_code)] #[derive(Debug, Clone)] pub struct Zettel { /// Should only be constructed from models. @@ -30,6 +29,7 @@ pub struct Zettel { /// a workspace-local file path, needs to be canonicalized before usage pub file_path: PathBuf, pub created_at: DateTime, + pub modified_at: DateTime, pub tags: Vec, } @@ -113,6 +113,18 @@ impl Zettel { Self::from_path(path, ws).await } + pub fn created_at(&self) -> String { + self.created_at + .format(frontmatter::DATE_FMT_STR) + .to_string() + } + + pub fn modified_at(&self) -> String { + self.modified_at + .format(frontmatter::DATE_FMT_STR) + .to_string() + } + pub async fn from_path(path: impl Into, ws: &Workspace) -> Result { let path: PathBuf = path.into(); @@ -273,6 +285,7 @@ impl From for Zettel { title: value.title, file_path: value.file_path.into(), created_at: value.created_at, + modified_at: value.modified_at, tags: value.tags.into_iter().map(Into::into).collect(), } } -- 2.54.0 From 2140a9a1ffd30d1f1443e9288abeb26500f7e693 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Wed, 1 Apr 2026 14:04:42 -0700 Subject: [PATCH 03/12] feat: Update Zettel after editing file --- .config/config.kdl | 2 +- src/tui/app.rs | 37 +++++++++++++++--------- src/tui/components/zk/mod.rs | 54 +++++++++++++++++++++++++++++++++--- src/tui/signal.rs | 16 +++++++++-- src/types/kasten.rs | 32 ++++++++++++++++++++- src/types/zettel.rs | 20 ++++++++++--- 6 files changed, 135 insertions(+), 26 deletions(-) diff --git a/.config/config.kdl b/.config/config.kdl index ab9a878..f4b5c98 100644 --- a/.config/config.kdl +++ b/.config/config.kdl @@ -7,8 +7,8 @@ keymap { q Quit // Quit the application Quit // Another way to quit Suspend // Suspend the application - h Helix j MoveDown k MoveUp + o OpenZettel } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 0a86d4b..b60e3a7 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -11,7 +11,7 @@ use tracing::debug; use crate::{ config::Config, tui::{Event, Tui, components::Zk}, - types::KastenHandle, + types::{KastenHandle, ZettelId}, }; use super::{components::Component, signal::Signal}; @@ -26,7 +26,7 @@ pub struct App { #[allow(dead_code)] region: Region, last_tick_key_events: Vec, - _kh: KastenHandle, + kh: KastenHandle, signal_tx: UnboundedSender, signal_rx: UnboundedReceiver, } @@ -57,7 +57,7 @@ impl App { config: Config::parse()?, region: Region::default(), last_tick_key_events: Vec::new(), - _kh: kh, + kh, signal_tx, signal_rx, }) @@ -160,27 +160,38 @@ impl App { debug!("handling signal: {signal:?}"); } - match signal { + match signal.clone() { Signal::Tick => { self.last_tick_key_events.drain(..); } Signal::Quit => self.should_quit = true, - Signal::Helix => { + Signal::Helix { path } => { tui.exit()?; - let hx = spawn(move || -> Result<()> { - Command::new("hx") - .stdin(std::process::Stdio::inherit()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .status()?; - - Ok(()) + let hx = spawn({ + let path = path.clone(); + move || -> Result<()> { + Command::new("hx") + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .arg(path) + .status()?; + + Ok(()) + } }); hx.join().unwrap().unwrap(); + // once we get out of the edit, we need to update the zettel for this + // path and then update the db and the kasten for this stuff + let zid = ZettelId::try_from(path)?; + + self.kh.write().await.process_zid(&zid).await?; + + self.signal_tx.send(Signal::ClosedZettel)?; tui.terminal.clear()?; tui.enter()?; diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index cad0358..ca1db6d 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -101,7 +101,7 @@ impl Zk<'_> { .expect("must exist"); let zettel = kt - .get_by_zettel_id(selected_zettel) + .get_node_by_zettel_id(selected_zettel) .expect("must exist, handle case where it doesnt later...") .payload(); @@ -114,7 +114,7 @@ impl Zk<'_> { // okay now that we have the zettel we need to construct the zettel out of this id let zettel_view: ZettelView = kt - .get_by_zettel_id(selected_zettel) + .get_node_by_zettel_id(selected_zettel) .expect("must exist, handle case where it doesnt later...") .payload() .into(); @@ -147,13 +147,13 @@ impl Zk<'_> { let kh = self.kh.read().await; self.zettel_view = kh - .get_by_zettel_id(z_id) + .get_node_by_zettel_id(z_id) .expect("this should be valid unless the kasten changed out underneath us") .payload() .into(); self.preview = kh - .get_by_zettel_id(z_id) + .get_node_by_zettel_id(z_id) .expect("this should be valid unless the kasten changed out underneath us") .payload() .content(&kh.ws) @@ -182,6 +182,52 @@ impl Component for Zk<'_> { self.zettel_list.state.select_previous(); self.update_views_from_zettel_list_selection().await?; } + + Signal::OpenZettel => { + let Some(selcted) = self.zettel_list.state.selected() else { + return Ok(None); + }; + + let Some(zid) = self.zettel_list.id_list.get(selcted) else { + return Ok(None); + }; + + let kh = self.kh.read().await; + let path = kh + .get_node_by_zettel_id(zid) + .expect( + "This should not have + change dout underneath us", + ) + .payload() + .absolute_path(&kh.ws); + + drop(kh); + + return Ok(Some(Signal::Helix { path })); + } + + Signal::ClosedZettel => { + let selected = self.zettel_list.state.selected().expect( + "still have to + figure out what to do if this doesnt exist", + ); + + let Some(id) = self.zettel_list.id_list.get(selected) else { + return Ok(None); + }; + + let kh = self.kh.read().await; + + let node = kh + .get_node_by_zettel_id(id) + .expect("Invariant broken, this must exist."); + + self.zettel_view = ZettelView::from(node.payload()); + self.preview = Preview::from(node.payload().content(&kh.ws).await?); + drop(kh); + } + _ => {} } Ok(None) diff --git a/src/tui/signal.rs b/src/tui/signal.rs index cbd3d8d..600f3f5 100644 --- a/src/tui/signal.rs +++ b/src/tui/signal.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::{path::PathBuf, str::FromStr}; use color_eyre::eyre::eyre; use strum::Display; @@ -17,11 +17,21 @@ pub enum Signal { ClearScreen, Error(String), Help, + + // movement MoveDown, MoveUp, + /// User asks to open a `Zettel` + OpenZettel, + + /// The user is done editing a `Zettel` + ClosedZettel, + /// this is fucking temporary - Helix, + Helix { + path: PathBuf, + }, } impl FromStr for Signal { @@ -32,9 +42,9 @@ impl FromStr for Signal { "suspend" => Self::Suspend, "resume" => Self::Resume, "quit" => Self::Quit, - "helix" => Self::Helix, "movedown" => Self::MoveDown, "moveup" => Self::MoveUp, + "openzettel" => Self::OpenZettel, _ => { return Err(eyre!(format!( "Attempt to construct a non-user Signal from str: {s}" diff --git a/src/types/kasten.rs b/src/types/kasten.rs index b1cfbde..5eee944 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -104,7 +104,26 @@ impl Kasten { most_recently_edited: None, }) } - pub fn get_by_zettel_id(&self, id: &ZettelId) -> Option<&Node> { + + /// processes the `Zettel` for the provided `ZettelId`, + /// meaning it updates the internal state of the `Kasten` + /// with the changes in `Zettel`. + pub async fn process_zid(&mut self, zid: &ZettelId) -> Result<()> { + //NOTE: need to clone to get around borrowing rules but + // ideally we dont have to do this, kind of cringe imo. + let ws = self.ws.clone(); + + let zettel = self + .get_node_by_zettel_id_mut(zid) + .expect("this should not happen ever") + .payload_mut(); + + zettel.sync_with_file(&ws).await?; + + Ok(()) + } + + pub fn get_node_by_zettel_id(&self, id: &ZettelId) -> Option<&Node> { let idx = self.zid_to_gid.get(id)?; let node = self.graph.node(*idx).expect( @@ -113,4 +132,15 @@ impl Kasten { ); Some(node) } + + pub fn get_node_by_zettel_id_mut(&mut self, id: &ZettelId) -> Option<&mut Node> { + let idx = self.zid_to_gid.get(id)?; + + let node = self.graph.node_mut(*idx).expect( + "invariant broken if internal hashmap is not uptodate with the + state of the graph...", + ); + + Some(node) + } } diff --git a/src/types/zettel.rs b/src/types/zettel.rs index 3dca505..f7ed760 100644 --- a/src/types/zettel.rs +++ b/src/types/zettel.rs @@ -1,7 +1,7 @@ use dto::{ ActiveModelTrait, ActiveValue, ColumnTrait, DateTime, EntityTrait as _, IntoActiveModel, - QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModelEx, - ZettelTagActiveModel, ZettelTagColumns, ZettelTagEntity, + QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelActiveModelEx, ZettelEntity, + ZettelModelEx, ZettelTagActiveModel, ZettelTagColumns, ZettelTagEntity, }; use pulldown_cmark::{Event, Parser, Tag as MkTag}; use serde::{Deserialize, Serialize}; @@ -15,7 +15,7 @@ use color_eyre::eyre::{Error, Result, eyre}; use dto::NanoId; use tokio::{fs::File, io::AsyncWriteExt}; -use crate::types::{FrontMatter, Link, Tag, Workspace, frontmatter}; +use crate::types::{FrontMatter, Kasten, Link, Tag, Workspace, frontmatter}; /// A `Zettel` is a note about a single idea. /// It can have many `Tag`s, just meaning it can fall under many @@ -79,6 +79,18 @@ impl Zettel { Ok(zettel.into()) } + pub async fn sync_with_file(&mut self, ws: &Workspace) -> Result<()> { + let (fm, content) = FrontMatter::extract_from_file(self.absolute_path(ws)).await?; + + self.title = fm.title; + + // it could have new tags and stuff... + + // todo!(); + + Ok(()) + } + /// Returns the most up-to-date `FrontMatter` for this /// `Zettel` pub async fn front_matter(&self, ws: &Workspace) -> Result { @@ -101,7 +113,7 @@ impl Zettel { Ok(File::open(path).await?) } - fn absolute_path(&self, ws: &Workspace) -> PathBuf { + pub fn absolute_path(&self, ws: &Workspace) -> PathBuf { ws.root.clone().join(&self.file_path) } -- 2.54.0 From 5edeaa45b5221ecd09c034910bdd5171c5fa83f6 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Wed, 1 Apr 2026 16:12:16 -0700 Subject: [PATCH 04/12] feat: show Color field --- Cargo.lock | 1 + crates/dto/migration/Cargo.toml | 2 + crates/dto/migration/src/types/color.rs | 12 ++++ crates/dto/src/entity/tag.rs | 7 +- src/tui/app.rs | 2 +- src/tui/components/mod.rs | 2 +- src/tui/components/zk/mod.rs | 75 +++++++++++--------- src/tui/components/zk/zettel_list.rs | 92 ++++++++++++++++++++++++ src/types/color.rs | 12 +++- src/types/tag.rs | 1 - src/types/zettel.rs | 93 +++++++++++++++++++++++-- 11 files changed, 252 insertions(+), 47 deletions(-) create mode 100644 src/tui/components/zk/zettel_list.rs diff --git a/Cargo.lock b/Cargo.lock index 43d79a6..0644cee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4392,6 +4392,7 @@ version = "0.1.0" dependencies = [ "async-std", "nanoid", + "rand 0.10.0", "rgb", "sea-orm", "sea-orm-migration", diff --git a/crates/dto/migration/Cargo.toml b/crates/dto/migration/Cargo.toml index 181c851..342dd31 100644 --- a/crates/dto/migration/Cargo.toml +++ b/crates/dto/migration/Cargo.toml @@ -13,7 +13,9 @@ async-std = { version = "1", features = ["attributes", "tokio1"] } nanoid = "0.4.0" sea-orm = "2.0.0-rc" rgb = "0.8.53" + serde = {workspace = true} +rand = "0.10.0" [dependencies.sea-orm-migration] version = "2.0.0-rc" diff --git a/crates/dto/migration/src/types/color.rs b/crates/dto/migration/src/types/color.rs index e24bc80..42eeb76 100644 --- a/crates/dto/migration/src/types/color.rs +++ b/crates/dto/migration/src/types/color.rs @@ -1,3 +1,4 @@ +use rand::RngExt; use rgb::RGB8; use sea_orm::DeriveValueType; use std::fmt::{Debug, Display}; @@ -49,3 +50,14 @@ impl From for RGB8 { c.to_rgb8() } } + +impl Default for Color { + fn default() -> Self { + let mut rng = rand::rng(); + let r = rng.random_range(0..=255); + let g = rng.random_range(0..=255); + let b = rng.random_range(0..=255); + + Self::new(r, g, b) + } +} diff --git a/crates/dto/src/entity/tag.rs b/crates/dto/src/entity/tag.rs index 8603272..b217a41 100644 --- a/crates/dto/src/entity/tag.rs +++ b/crates/dto/src/entity/tag.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use migration::types::*; -use sea_orm::entity::prelude::*; use sea_orm::ActiveValue::Set; +use sea_orm::entity::prelude::*; use std::{future::ready, pin::Pin}; #[sea_orm::model] @@ -33,6 +33,11 @@ impl ActiveModelBehavior for ActiveModel { if insert && self.nano_id.is_not_set() { self.nano_id = Set(NanoId::default()); } + + if insert && self.color.is_not_set() { + self.color = Set(Color::default()) + } + Box::pin(ready(Ok(self))) } } diff --git a/src/tui/app.rs b/src/tui/app.rs index b60e3a7..fa7c741 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -77,7 +77,7 @@ impl App { } for component in &mut self.components { - component.init(tui.size()?)?; + component.init(tui.size()?).await?; } let signal_tx = self.signal_tx.clone(); diff --git a/src/tui/components/mod.rs b/src/tui/components/mod.rs index 8cf5f7a..1776d41 100644 --- a/src/tui/components/mod.rs +++ b/src/tui/components/mod.rs @@ -58,7 +58,7 @@ pub trait Component: Send + Sync { /// # Returns /// /// * [`color_eyre::Result<()>`] - An Ok result or an error. - fn init(&mut self, area: Size) -> color_eyre::Result<()> { + async fn init(&mut self, area: Size) -> color_eyre::Result<()> { let _ = area; // to appease clippy Ok(()) } diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index ca1db6d..fadcac0 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -2,21 +2,26 @@ use async_trait::async_trait; use color_eyre::eyre::Result; use ratatui::{ prelude::*, - widgets::{Block, List, ListState}, + widgets::{Block, ListState}, }; use tokio::sync::mpsc::UnboundedSender; use crate::{ tui::{Signal, components::Component}, - types::{KastenHandle, ZettelId}, + types::KastenHandle, }; mod preview; +mod zettel_list; mod zettel_view; use preview::Preview; +use zettel_list::ZettelList; use zettel_view::ZettelView; +/// in theory we could do some fancy `type_state` encoding stuff +/// to make this work cleanly (so we know when the widgets are properly +/// initialized) pub struct Zk<'text> { signal_tx: Option>, kh: KastenHandle, @@ -32,12 +37,6 @@ struct Layouts { z_preview: Layout, } -struct ZettelList<'text> { - render_list: List<'text>, - id_list: Vec, - state: ListState, -} - impl Default for Layouts { fn default() -> Self { Self { @@ -63,32 +62,11 @@ impl Zk<'_> { let nodes = kt.graph.nodes_iter().collect::>(); - let zettel_list = { - let render_list = List::new(nodes.iter().map(|(_, n)| { - let z = n.payload(); - let title = z.title.clone(); - let _tags = z.tags.clone(); - // let _last_modified = z.modified_at; - Text::from(title) - })) - .style(Color::White) - .highlight_style(Modifier::REVERSED) - .highlight_symbol("> "); - - let id_list = nodes - .iter() - .map(|(_, n)| n.payload().id.clone()) - .collect::>(); - - let mut state = ListState::default(); - state.select_first(); - - ZettelList { - render_list, - id_list, - state, - } - }; + // in theory this is wasted compute, we should be initializing all our + // stuff inside the init function + let mut l_state = ListState::default(); + l_state.select_first(); + let zettel_list = ZettelList::new(nodes.as_slice(), l_state, 0); let selected_zettel = zettel_list .id_list @@ -167,6 +145,26 @@ impl Zk<'_> { #[async_trait] impl Component for Zk<'_> { + /// this tells us how big the space we have for this is + async fn init(&mut self, area: Size) -> color_eyre::Result<()> { + let total_width = area.width; + + let kt = self.kh.read().await; + + let nodes = kt.graph.nodes_iter().collect::>(); + + let mut l_state = ListState::default(); + l_state.select_first(); + + let zettel_list = ZettelList::new(nodes.as_slice(), l_state, total_width / 2); + + self.zettel_list = zettel_list; + + drop(kt); + + Ok(()) + } + fn register_signal_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.signal_tx = Some(tx); Ok(()) @@ -223,6 +221,15 @@ impl Component for Zk<'_> { .get_node_by_zettel_id(id) .expect("Invariant broken, this must exist."); + //TODO: we would actually want to create the new zettel_list_item + // and insert it into the list where the selected id is there, that + // is what makes the most sense imo + self.zettel_list = ZettelList::new( + // ideally we dont want to do this right? + kh.graph.nodes_iter().collect::>().as_slice(), + self.zettel_list.state, + self.zettel_list.width, + ); self.zettel_view = ZettelView::from(node.payload()); self.preview = Preview::from(node.payload().content(&kh.ws).await?); drop(kh); diff --git a/src/tui/components/zk/zettel_list.rs b/src/tui/components/zk/zettel_list.rs new file mode 100644 index 0000000..c3da488 --- /dev/null +++ b/src/tui/components/zk/zettel_list.rs @@ -0,0 +1,92 @@ +use egui_graphs::{Node, petgraph::graph::NodeIndex}; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{List, ListState}, +}; + +use crate::types::{Link, Zettel, ZettelId}; + +pub struct ZettelList<'text> { + pub render_list: ratatui::widgets::List<'text>, + pub id_list: Vec, + pub state: ListState, + pub width: u16, +} + +pub struct ZettelListItem<'text> { + title: Span<'text>, + tags: Vec>, + date: Span<'text>, + width: u16, +} + +impl From<&Zettel> for ZettelListItem<'_> { + fn from(value: &Zettel) -> Self { + Self { + title: Span::from(value.title.clone()), + tags: value + .tags + .iter() + .map(|t| { + Span::from(format!("{} ", t.name)) + .style(Style::new().fg(t.color.into()).italic()) + }) + .collect(), + date: Span::from(value.created_at()), + width: 0, + } + } +} + +impl<'item, 'text> From> for Text<'text> +where + 'item: 'text, +{ + fn from(value: ZettelListItem<'item>) -> Self { + let title_width: usize = value.title.width(); + let tags_width: usize = value.tags.iter().map(Span::width).sum(); + let date_width: usize = value.date.width(); + + // tags start 2 tabs after the title + let gap_after_title = 2; + let used = title_width + gap_after_title + tags_width + date_width; + let padding = (value.width as usize).saturating_sub(used); + + let line = std::iter::once(value.title) + .chain(std::iter::once(Span::raw(" "))) + .chain(value.tags) + .chain(std::iter::once(Span::raw(" ".repeat(padding)))) + .chain(std::iter::once(value.date)) + .collect::(); + + line.into() + } +} + +impl ZettelList<'_> { + pub fn new(nodes: &[(NodeIndex, &Node)], state: ListState, width: u16) -> Self { + let render_list = List::new(nodes.iter().map(|(_, n)| { + let z = n.payload(); + let mut zli: ZettelListItem<'_> = z.into(); + zli.width = width; + + Text::from(zli) + })) + .style(Color::White) + .highlight_style(Modifier::REVERSED) + .highlight_symbol("> "); + + let id_list = nodes + .iter() + .map(|(_, n)| n.payload().id.clone()) + .collect::>(); + + ZettelList { + render_list, + id_list, + state, + width, + } + } +} diff --git a/src/types/color.rs b/src/types/color.rs index e90cb47..3a426ec 100644 --- a/src/types/color.rs +++ b/src/types/color.rs @@ -1,9 +1,9 @@ use dto::ColorDTO; +use ratatui::style::Color as RatColor; /// Agnostic Color type, /// internally represented as rgb -#[expect(dead_code)] -#[derive(Debug, Clone)] +#[derive(Debug, Copy, Clone, Default)] pub struct Color(ColorDTO); impl From for Color { @@ -11,3 +11,11 @@ impl From for Color { Self(value) } } + +impl From for RatColor { + fn from(value: Color) -> Self { + let rgb = value.0.to_rgb8(); + + Self::Rgb(rgb.r, rgb.g, rgb.b) + } +} diff --git a/src/types/tag.rs b/src/types/tag.rs index 7c37575..6ea22db 100644 --- a/src/types/tag.rs +++ b/src/types/tag.rs @@ -4,7 +4,6 @@ use crate::types::Color; /// Represents a `Tag` in a `ZettelKasten` note taking method. /// Easy way to link multiple notes under one simple word. -#[expect(dead_code)] #[derive(Debug, Clone)] pub struct Tag { /// Should only be constructed from models. diff --git a/src/types/zettel.rs b/src/types/zettel.rs index f7ed760..c3ccace 100644 --- a/src/types/zettel.rs +++ b/src/types/zettel.rs @@ -1,6 +1,6 @@ use dto::{ ActiveModelTrait, ActiveValue, ColumnTrait, DateTime, EntityTrait as _, IntoActiveModel, - QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelActiveModelEx, ZettelEntity, + QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModel, ZettelModelEx, ZettelTagActiveModel, ZettelTagColumns, ZettelTagEntity, }; use pulldown_cmark::{Event, Parser, Tag as MkTag}; @@ -15,7 +15,7 @@ use color_eyre::eyre::{Error, Result, eyre}; use dto::NanoId; use tokio::{fs::File, io::AsyncWriteExt}; -use crate::types::{FrontMatter, Kasten, Link, Tag, Workspace, frontmatter}; +use crate::types::{FrontMatter, Link, Tag, Workspace, frontmatter}; /// A `Zettel` is a note about a single idea. /// It can have many `Tag`s, just meaning it can fall under many @@ -80,13 +80,93 @@ impl Zettel { } pub async fn sync_with_file(&mut self, ws: &Workspace) -> Result<()> { - let (fm, content) = FrontMatter::extract_from_file(self.absolute_path(ws)).await?; + let (fm, _) = FrontMatter::extract_from_file(self.absolute_path(ws)).await?; - self.title = fm.title; + let mut model = ZettelEntity::find_by_nano_id(self.id.clone()) + .one(&ws.db) + .await? + .expect("this must exist") + .into_active_model(); + + model.title = ActiveValue::Set(fm.title); + + let updated: ZettelModel = model.update(&ws.db).await?; + + self.title = updated.title; + self.modified_at = updated.modified_at; + self.created_at = updated.created_at; + + self.sync_tags(ws).await?; + + Ok(()) + } + + /// Sync's `Tag`'s that are present in the frontmatter of this + /// `Zettel` to the database, and then updates the `Tag`s on the + /// `Zettel` to reflect the changes. + pub async fn sync_tags(&mut self, ws: &Workspace) -> Result<()> { + let mut fm = self.front_matter(ws).await?; + fm.tag_strings.sort(); + + let mut tag_strings = fm.tag_strings; + + let Some(db_zettel): Option = ZettelEntity::load() + .with(TagEntity) + .filter_by_nano_id(self.id.clone()) + .one(&ws.db) + .await? + else { + panic!("how the fuck was this deleted"); + }; + + for db_tag in db_zettel.tags { + if let Ok(idx) = tag_strings.binary_search(&db_tag.name) { + // we remove tags we have already processed + tag_strings.remove(idx); + } else { + // the db says the file has tag `x`, but that tag is missing from the + // front matter, we can assume its gone, lets delete that link + let to_remove = ZettelTagEntity::find() + .filter(ZettelTagColumns::ZettelNanoId.eq(self.id.0.clone())) + .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) + .one(&ws.db) + .await? + .expect("this link must exist"); - // it could have new tags and stuff... + to_remove.into_active_model().delete(&ws.db).await?; + } + } - // todo!(); + // now any tags that are left inside zettel_tag_strings, + // we have to put them inside the db + for new_tag in tag_strings { + // create a new tag + let tag = TagActiveModel { + name: ActiveValue::Set(new_tag), + ..Default::default() + } + .insert(&ws.db) + .await?; + + // this zettel has this tag now + let _ = ZettelTagActiveModel { + zettel_nano_id: ActiveValue::Set(self.id.to_string()), + tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), + } + .insert(&ws.db) + .await?; + } + + let entity = ZettelEntity::load() + .with(TagEntity) + .filter_by_nano_id(self.id.clone()) + .one(&ws.db) + .await? + .expect("this exists"); + + let temp_zettel: Self = entity.into(); + + self.tags = temp_zettel.tags; Ok(()) } @@ -118,7 +198,6 @@ impl Zettel { } /// uses the id and root to parse out of the root directory - #[expect(dead_code)] pub async fn from_id(id: &ZettelId, ws: &Workspace) -> Result { let mut path = ws.root.clone(); path.push(id.0.to_string()); -- 2.54.0 From 9760bce0f84c6cd0ea9662767362c6f1ad6c6bcb Mon Sep 17 00:00:00 2001 From: suri-codes Date: Wed, 1 Apr 2026 17:00:45 -0700 Subject: [PATCH 05/12] feat: unique tag names + parsing --- .../src/m20260327_175853_tag_table.rs | 2 +- crates/dto/src/entity/prelude.rs | 2 +- crates/dto/src/entity/tag.rs | 7 +- src/types/zettel.rs | 78 +++++-------------- 4 files changed, 26 insertions(+), 63 deletions(-) diff --git a/crates/dto/migration/src/m20260327_175853_tag_table.rs b/crates/dto/migration/src/m20260327_175853_tag_table.rs index 4275e42..45a255b 100644 --- a/crates/dto/migration/src/m20260327_175853_tag_table.rs +++ b/crates/dto/migration/src/m20260327_175853_tag_table.rs @@ -20,7 +20,7 @@ impl MigrationTrait for Migration { .unique_key() .not_null(), ) - .col(string(Tag::Name).not_null()) + .col(string(Tag::Name).unique_key().not_null()) .col(integer(Tag::Color).not_null()) .to_owned(), ) diff --git a/crates/dto/src/entity/prelude.rs b/crates/dto/src/entity/prelude.rs index de2c434..ef214b3 100644 --- a/crates/dto/src/entity/prelude.rs +++ b/crates/dto/src/entity/prelude.rs @@ -1,6 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - #![expect(unused_imports)] + pub use super::group::Entity as Group; pub use super::tag::Entity as Tag; pub use super::task::Entity as Task; diff --git a/crates/dto/src/entity/tag.rs b/crates/dto/src/entity/tag.rs index b217a41..3773a5d 100644 --- a/crates/dto/src/entity/tag.rs +++ b/crates/dto/src/entity/tag.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use migration::types::*; -use sea_orm::ActiveValue::Set; use sea_orm::entity::prelude::*; +use sea_orm::ActiveValue::Set; use std::{future::ready, pin::Pin}; #[sea_orm::model] @@ -13,6 +13,7 @@ pub struct Model { pub id: i64, #[sea_orm(unique)] pub nano_id: NanoId, + #[sea_orm(unique)] pub name: String, pub color: Color, #[sea_orm(has_many, via = "zettel_tag")] @@ -33,11 +34,9 @@ impl ActiveModelBehavior for ActiveModel { if insert && self.nano_id.is_not_set() { self.nano_id = Set(NanoId::default()); } - if insert && self.color.is_not_set() { - self.color = Set(Color::default()) + self.color = Set(Color::default()); } - Box::pin(ready(Ok(self))) } } diff --git a/src/types/zettel.rs b/src/types/zettel.rs index c3ccace..a2c2356 100644 --- a/src/types/zettel.rs +++ b/src/types/zettel.rs @@ -138,15 +138,23 @@ impl Zettel { } // now any tags that are left inside zettel_tag_strings, - // we have to put them inside the db - for new_tag in tag_strings { - // create a new tag - let tag = TagActiveModel { - name: ActiveValue::Set(new_tag), - ..Default::default() - } - .insert(&ws.db) - .await?; + // we have to look up the tags in the db and then reset them? + for tag_str in tag_strings { + // this is the tag that either already exists with this name, or we just created this new one + let tag = if let Some(existing) = TagEntity::load() + .filter_by_name(&tag_str) + .one(&ws.db) + .await? + { + existing + } else { + let am = TagActiveModel { + name: ActiveValue::Set(tag_str), + ..Default::default() + }; + + am.insert(&ws.db).await?.into() + }; // this zettel has this tag now let _ = ZettelTagActiveModel { @@ -223,10 +231,6 @@ impl Zettel { let (front_matter, _) = FrontMatter::extract_from_file(&ws.root.clone().join(path)).await?; - let mut zettel_tag_strings = front_matter.tag_strings.clone(); - - zettel_tag_strings.sort(); - // get the zettel from the db let db_zettel: ZettelModelEx = if let Some(existing_zettel) = ZettelEntity::load() .with(TagEntity) @@ -254,52 +258,12 @@ impl Zettel { .expect("we just inserted the zettel") }; - // get the tags for it - for db_tag in db_zettel.tags { - if let Ok(idx) = zettel_tag_strings.binary_search(&db_tag.name) { - // we remove tags we have already processed - zettel_tag_strings.remove(idx); - } else { - // the db says the file has tag `x`, but that tag is missing from the - // front matter, we can assume its gone, lets delete that link - let to_remove = ZettelTagEntity::find() - .filter(ZettelTagColumns::ZettelNanoId.eq(id.0.clone())) - .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) - .one(&ws.db) - .await? - .expect("this link must exist"); - - to_remove.into_active_model().delete(&ws.db).await?; - } - } - - // now any tags that are left inside zettel_tag_strings, - // we have to put them inside the db - for new_tag in zettel_tag_strings { - // create a new tag - let tag = TagActiveModel { - name: ActiveValue::Set(new_tag), - ..Default::default() - } - .insert(&ws.db) - .await?; - - // this zettel has this tag now - let _ = ZettelTagActiveModel { - zettel_nano_id: ActiveValue::Set(id.to_string()), - tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), - } - .insert(&ws.db) - .await?; - } + let mut temp_zettel: Self = db_zettel.clone().into(); + temp_zettel.sync_tags(ws).await?; if front_matter.title != db_zettel.title { - let am = ZettelActiveModel { - id: ActiveValue::Unchanged(db_zettel.id), - title: ActiveValue::Set(front_matter.title.clone()), - ..Default::default() - }; - + let mut am = db_zettel.into_active_model(); + am.title = ActiveValue::Set(front_matter.title.clone()); am.update(&ws.db).await?; } -- 2.54.0 From a5f1a7950e56cf1b9db969157c4f56258694a5f9 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Wed, 1 Apr 2026 17:18:12 -0700 Subject: [PATCH 06/12] feat: coloring for ZettelList --- src/tui/components/zk/zettel_list.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/tui/components/zk/zettel_list.rs b/src/tui/components/zk/zettel_list.rs index c3da488..dc1749e 100644 --- a/src/tui/components/zk/zettel_list.rs +++ b/src/tui/components/zk/zettel_list.rs @@ -1,6 +1,6 @@ use egui_graphs::{Node, petgraph::graph::NodeIndex}; use ratatui::{ - style::{Color, Modifier, Style}, + style::{Color, Modifier, Style, Stylize}, text::{Line, Span, Text}, widgets::{List, ListState}, }; @@ -24,16 +24,19 @@ pub struct ZettelListItem<'text> { impl From<&Zettel> for ZettelListItem<'_> { fn from(value: &Zettel) -> Self { Self { - title: Span::from(value.title.clone()), + title: Span::from(value.title.clone()) + .style(Style::new().cyan()) + .add_modifier(Modifier::BOLD), tags: value .tags .iter() .map(|t| { Span::from(format!("{} ", t.name)) .style(Style::new().fg(t.color.into()).italic()) + .add_modifier(Modifier::DIM) }) .collect(), - date: Span::from(value.created_at()), + date: Span::from(value.created_at()).style(Style::new().add_modifier(Modifier::DIM)), width: 0, } } @@ -46,20 +49,24 @@ where fn from(value: ZettelListItem<'item>) -> Self { let title_width: usize = value.title.width(); let tags_width: usize = value.tags.iter().map(Span::width).sum(); - let date_width: usize = value.date.width(); + let date_width: usize = value.date.width() + 2; // tags start 2 tabs after the title let gap_after_title = 2; let used = title_width + gap_after_title + tags_width + date_width; let padding = (value.width as usize).saturating_sub(used); - let line = std::iter::once(value.title) + let mut spans: Vec = std::iter::once(value.title) .chain(std::iter::once(Span::raw(" "))) .chain(value.tags) .chain(std::iter::once(Span::raw(" ".repeat(padding)))) - .chain(std::iter::once(value.date)) - .collect::(); + .collect(); + if tags_width + title_width + date_width <= value.width as usize { + spans.push(value.date); + } + + let line = Line::from(spans); line.into() } } -- 2.54.0 From 9e2cadb83f167b8a6f2943d3f8137360a83fa9ab Mon Sep 17 00:00:00 2001 From: suri-codes Date: Wed, 1 Apr 2026 19:55:31 -0700 Subject: [PATCH 07/12] feat: zettel view impl --- flake.lock | 22 +++---- src/tui/components/zk/mod.rs | 14 ++--- src/tui/components/zk/preview.rs | 4 +- src/tui/components/zk/zettel_view.rs | 91 +++++++++++++++++----------- 4 files changed, 74 insertions(+), 57 deletions(-) diff --git a/flake.lock b/flake.lock index d650120..c8ebebd 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1774887814, - "narHash": "sha256-8bsvr2SgW9t70nFxUdMVQg4RSU+E/y9+Je4urivtOYE=", + "lastModified": 1775115015, + "narHash": "sha256-XO7jmyFupI82Sr1M2tLfsSxslIJwUOjzhFqeffaWyNw=", "owner": "nix-community", "repo": "fenix", - "rev": "9b6c1cb49eff1346e1f5d2430994a0ba0fa02910", + "rev": "45f82ed61800d52e27390b70823426045d982c84", "type": "github" }, "original": { @@ -23,12 +23,12 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774709303, - "narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=", - "rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685", - "revCount": 971119, + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", + "revCount": 972949, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.971119%2Brev-8110df5ad7abf5d4c0f6fb0f8f978390e77f9685/019d3c72-3e5d-7d8e-a4fc-0fe67ed1554b/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.972949%2Brev-6201e203d09599479a3b3450ed24fa81537ebc4e/019d4b63-29c4-7e95-9524-45503c792ef3/source.tar.gz" }, "original": { "type": "tarball", @@ -44,11 +44,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1774787924, - "narHash": "sha256-Cbpmf0+1pqi/zbpub2vkp5lTPx3QdVtDkkagDwQzHHg=", + "lastModified": 1775045117, + "narHash": "sha256-PLZYhcg3HUZ+lUMUV+JbXs9ExOAYpZC0PAtOVHCgYss=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "f1297b21119565c626320c1ffc248965fffb2527", + "rev": "e599ad4fc8861e0401906e4d730f74bfcc530e07", "type": "github" }, "original": { diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index fadcac0..7022c32 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -48,10 +48,7 @@ impl Default for Layouts { Constraint::Percentage(10), Constraint::Percentage(90), ]), - z_preview: Layout::vertical(vec![ - Constraint::Percentage(20), - Constraint::Percentage(80), - ]), + z_preview: Layout::vertical(vec![Constraint::Max(6), Constraint::Percentage(80)]), } } } @@ -221,15 +218,15 @@ impl Component for Zk<'_> { .get_node_by_zettel_id(id) .expect("Invariant broken, this must exist."); - //TODO: we would actually want to create the new zettel_list_item - // and insert it into the list where the selected id is there, that - // is what makes the most sense imo + // actually this is the only way to do it with the list thing, + // the ratatui api doesnt expose a swap function to the inner render + // list. self.zettel_list = ZettelList::new( - // ideally we dont want to do this right? kh.graph.nodes_iter().collect::>().as_slice(), self.zettel_list.state, self.zettel_list.width, ); + self.zettel_view = ZettelView::from(node.payload()); self.preview = Preview::from(node.payload().content(&kh.ws).await?); drop(kh); @@ -267,6 +264,7 @@ impl Component for Zk<'_> { frame.render_widget(self.zettel_view.clone(), zettel_layout); frame.render_widget(self.preview.clone(), preview_layout); + // frame.render_widget(Block::new().bg(Color::Red), preview_layout); Ok(()) } diff --git a/src/tui/components/zk/preview.rs b/src/tui/components/zk/preview.rs index 9724e07..82dfc35 100644 --- a/src/tui/components/zk/preview.rs +++ b/src/tui/components/zk/preview.rs @@ -1,4 +1,4 @@ -use ratatui::{text::Text, widgets::Widget}; +use ratatui::{style::Style, text::Text, widgets::Widget}; #[derive(Debug, Clone)] pub struct Preview<'text> { @@ -18,6 +18,6 @@ impl Widget for Preview<'_> { where Self: Sized, { - self.content.render(area, buf); + self.content.style(Style::new()).render(area, buf); } } diff --git a/src/tui/components/zk/zettel_view.rs b/src/tui/components/zk/zettel_view.rs index bc49065..9114331 100644 --- a/src/tui/components/zk/zettel_view.rs +++ b/src/tui/components/zk/zettel_view.rs @@ -1,7 +1,8 @@ use ratatui::{ layout::{Constraint, Layout}, - text::Text, - widgets::Widget, + style::Style, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Paragraph, Widget}, }; use crate::types::Zettel; @@ -9,27 +10,55 @@ use crate::types::Zettel; /// A `Widget` that represents a `Zettel` #[derive(Debug, Clone)] pub struct ZettelView<'text> { - title: Text<'text>, - tags: Text<'text>, - created_at: Text<'text>, - modified_at: Text<'text>, - id: Text<'text>, + // title: Text<'text>, + title: Paragraph<'text>, + tags: Paragraph<'text>, + created_at: Paragraph<'text>, + zettel_id: Paragraph<'text>, layouts: Layouts, } impl From<&Zettel> for ZettelView<'_> { fn from(value: &Zettel) -> Self { Self { - title: Text::from(value.title.clone()), - tags: Text::from( + title: Paragraph::new(value.title.clone()).block( + Block::default() + .title("Title") + .borders(Borders::all()) + .border_style(Style::new()) + .border_type(BorderType::Plain), + ), + tags: Paragraph::new( value .tags .iter() - .fold(" ".to_owned(), |acc, t| format!("{acc} {}", t.name)), + .map(|t| { + Span::from(format!("{} ", t.name)).style(Style::new().fg(t.color.into())) + }) + .collect::(), + ) + .block( + Block::default() + .title("Tags") + .borders(Borders::all()) + .border_style(Style::new()) + .border_type(BorderType::Plain), + ), + created_at: Paragraph::new(value.created_at()).block( + Block::default() + .title("Created At") + .borders(Borders::all()) + .border_style(Style::new()) + .border_type(BorderType::Plain), + ), + zettel_id: Paragraph::new(value.id.to_string()).block( + Block::default() + .title("Id") + .borders(Borders::all()) + .border_style(Style::new()) + .border_type(BorderType::Plain), ), - created_at: Text::from(value.created_at()), - modified_at: Text::from(value.modified_at()), - id: Text::from(value.id.to_string()), + layouts: Layouts::default(), } } @@ -37,28 +66,19 @@ impl From<&Zettel> for ZettelView<'_> { #[derive(Debug, Clone)] struct Layouts { - left_right: Layout, - title_tags: Layout, - cr_mod_id: Layout, + top_bottom: Layout, + title_created: Layout, + tags_id: Layout, } impl Default for Layouts { fn default() -> Self { Self { - left_right: Layout::horizontal(vec![ - Constraint::Percentage(70), - Constraint::Percentage(30), - ]), + top_bottom: Layout::vertical(vec![Constraint::Max(3), Constraint::Max(3)]), + + title_created: Layout::horizontal(vec![Constraint::Fill(80), Constraint::Min(24)]), - title_tags: Layout::vertical(vec![ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]), - cr_mod_id: Layout::vertical(vec![ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(33), - ]), + tags_id: Layout::horizontal(vec![Constraint::Fill(95), Constraint::Min(8)]), } } } @@ -68,22 +88,21 @@ impl Widget for ZettelView<'_> { where Self: Sized, { - let (title_rect, tags_rect, created_rect, modified_rect, id_rect) = { - let rects = self.layouts.left_right.split(area); + let (title_rect, created_rect, tags_rect, modified_rect) = { + let rects = self.layouts.top_bottom.split(area); let (left, right) = (rects[0], rects[1]); - let l_rects = self.layouts.title_tags.split(left); + let t_rects = self.layouts.title_created.split(left); - let r_rects = self.layouts.cr_mod_id.split(right); + let b_rects = self.layouts.tags_id.split(right); - (l_rects[0], l_rects[1], r_rects[0], r_rects[1], r_rects[2]) + (t_rects[0], t_rects[1], b_rects[0], b_rects[1]) }; self.title.render(title_rect, buf); self.tags.render(tags_rect, buf); self.created_at.render(created_rect, buf); - self.modified_at.render(modified_rect, buf); - self.id.render(id_rect, buf); + self.zettel_id.render(modified_rect, buf); } } -- 2.54.0 From ade170d96f2e93bfa39ef45904e594b3249446e7 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Thu, 2 Apr 2026 19:01:21 -0700 Subject: [PATCH 08/12] feat: new zettel --- .config/config.kdl | 1 + src/tui/app.rs | 15 ++++++++-- src/tui/signal.rs | 4 ++- src/types/kasten.rs | 72 ++++++++++++++++++++++++++++++++++++++++----- src/types/zettel.rs | 2 ++ 5 files changed, 82 insertions(+), 12 deletions(-) diff --git a/.config/config.kdl b/.config/config.kdl index f4b5c98..8de17c6 100644 --- a/.config/config.kdl +++ b/.config/config.kdl @@ -10,5 +10,6 @@ keymap { j MoveDown k MoveUp o OpenZettel + n NewZettel } } diff --git a/src/tui/app.rs b/src/tui/app.rs index fa7c741..e9495b1 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -11,7 +11,7 @@ use tracing::debug; use crate::{ config::Config, tui::{Event, Tui, components::Zk}, - types::{KastenHandle, ZettelId}, + types::{KastenHandle, Zettel}, }; use super::{components::Component, signal::Signal}; @@ -167,6 +167,16 @@ impl App { Signal::Quit => self.should_quit = true, + Signal::NewZettel => { + // what the fuck am i going to do in here + + let ws = &self.kh.read().await.ws; + let z = Zettel::new("", ws).await?; + let path = z.absolute_path(ws); + + self.signal_tx.send(Signal::Helix { path })?; + } + Signal::Helix { path } => { tui.exit()?; @@ -187,9 +197,8 @@ impl App { hx.join().unwrap().unwrap(); // once we get out of the edit, we need to update the zettel for this // path and then update the db and the kasten for this stuff - let zid = ZettelId::try_from(path)?; - self.kh.write().await.process_zid(&zid).await?; + self.kh.write().await.process_path(&path).await?; self.signal_tx.send(Signal::ClosedZettel)?; diff --git a/src/tui/signal.rs b/src/tui/signal.rs index 600f3f5..9aa26c8 100644 --- a/src/tui/signal.rs +++ b/src/tui/signal.rs @@ -22,9 +22,10 @@ pub enum Signal { MoveDown, MoveUp, + /// New `Zettel` + NewZettel, /// User asks to open a `Zettel` OpenZettel, - /// The user is done editing a `Zettel` ClosedZettel, @@ -45,6 +46,7 @@ impl FromStr for Signal { "movedown" => Self::MoveDown, "moveup" => Self::MoveUp, "openzettel" => Self::OpenZettel, + "newzettel" => Self::NewZettel, _ => { return Err(eyre!(format!( "Attempt to construct a non-user Signal from str: {s}" diff --git a/src/types/kasten.rs b/src/types/kasten.rs index 5eee944..b2216c7 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -1,12 +1,13 @@ use crate::types::{Link, Zettel, ZettelId}; use color_eyre::eyre::Result; +use dto::{TagEntity, ZettelEntity}; use eframe::emath; use egui_graphs::{ Graph, Node, - petgraph::{Directed, graph::NodeIndex, prelude::StableGraph}, + petgraph::{Directed, Direction, graph::NodeIndex, prelude::StableGraph, visit::EdgeRef}, }; use rayon::iter::{ParallelBridge as _, ParallelIterator as _}; -use std::{cmp::max, collections::HashMap, sync::Arc}; +use std::{cmp::max, collections::HashMap, path::Path, sync::Arc}; use tokio::sync::RwLock; use crate::types::Workspace; @@ -108,18 +109,73 @@ impl Kasten { /// processes the `Zettel` for the provided `ZettelId`, /// meaning it updates the internal state of the `Kasten` /// with the changes in `Zettel`. - pub async fn process_zid(&mut self, zid: &ZettelId) -> Result<()> { + pub async fn process_path(&mut self, path: &Path) -> Result<()> { //NOTE: need to clone to get around borrowing rules but // ideally we dont have to do this, kind of cringe imo. let ws = self.ws.clone(); - let zettel = self - .get_node_by_zettel_id_mut(zid) - .expect("this should not happen ever") - .payload_mut(); - + let zid = ZettelId::try_from(path)?; + + let mut gid = self.zid_to_gid.get(&zid).copied(); + // sometimes this zid is new, so it wont be in the kasten + let zettel = if let Some(existing) = self.get_node_by_zettel_id_mut(&zid) { + existing.payload_mut() + } else { + // this should aleady be in the database though so lets get it from there first + let zettel: Zettel = ZettelEntity::load() + .filter_by_nano_id(zid) + .with(TagEntity) + .one(&ws.db) + .await? + .expect("This should be in the database already") + .into(); + + let zid = zettel.id.clone(); + let idx = self.graph.add_node(zettel); + + gid = Some( + self.zid_to_gid + .insert(zid.clone(), idx) + .expect("this cannot have existed already"), + ); + + self.get_node_by_zettel_id_mut(&zid) + .expect("we just inserted it") + .payload_mut() + }; + + // to get past borrowchecker rules + let mut zettel = zettel.clone(); + + // gid must be set + let gid = gid.unwrap(); + + // and then we sync with the file zettel.sync_with_file(&ws).await?; + // and now we manage the links going out of the file + + // remove all the old shit + self.graph + .edges_directed(gid, Direction::Outgoing) + .map(|e| e.id()) + .collect::>() + .into_iter() + .for_each(|e| { + let _ = self.graph.remove_edge(e); + }); + + // add the links that actually exist + zettel.links(&ws).await?.into_iter().for_each(|link| { + // this is an option because a user c + let dest = self + .zid_to_gid + .get(&link.dest) + .expect("Links should be valid"); + + self.graph.add_edge(gid, *dest, link); + }); + Ok(()) } diff --git a/src/types/zettel.rs b/src/types/zettel.rs index a2c2356..d92b64d 100644 --- a/src/types/zettel.rs +++ b/src/types/zettel.rs @@ -314,6 +314,8 @@ impl Zettel { continue; }; + // TODO: check that the thing actually exists inside the ws.db + // instead of just seeing if we can turn it into a ZettelId let dst_id = ZettelId::try_from(canon_url)?; let link = Link::new(self.id.clone(), dst_id); -- 2.54.0 From 97ad8485bfbf3c10d8541840fa7a7c4088d1ccb1 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Thu, 2 Apr 2026 20:25:51 -0700 Subject: [PATCH 09/12] feat: zettel_list uses db as source of truth --- crates/dto/src/lib.rs | 1 + crates/dto/tests/zettel.rs | 4 ++- src/tui/components/zk/mod.rs | 54 ++++++++++++++++++++-------- src/tui/components/zk/zettel_list.rs | 14 +++----- src/types/kasten.rs | 16 ++++----- 5 files changed, 54 insertions(+), 35 deletions(-) diff --git a/crates/dto/src/lib.rs b/crates/dto/src/lib.rs index 0341e60..dc6cb99 100644 --- a/crates/dto/src/lib.rs +++ b/crates/dto/src/lib.rs @@ -12,6 +12,7 @@ pub use sea_orm::ColumnTrait; pub use sea_orm::EntityTrait; pub use sea_orm::IntoActiveModel; pub use sea_orm::QueryFilter; +pub use sea_orm::QueryOrder; /// Exporting this as a generic NanoId. pub use migration::types::NanoId; diff --git a/crates/dto/tests/zettel.rs b/crates/dto/tests/zettel.rs index fa7f7f4..ad1ffad 100644 --- a/crates/dto/tests/zettel.rs +++ b/crates/dto/tests/zettel.rs @@ -2,7 +2,7 @@ use dto::{ ActiveModelTrait, ActiveValue::Set, ColorDTO, TagActiveModel, TagEntity, TagModel, ZettelActiveModel, ZettelEntity, ZettelModel, }; -use sea_orm::IntoActiveModel; +use sea_orm::{IntoActiveModel, QueryOrder}; mod common; @@ -56,7 +56,9 @@ async fn test_zettel_tag_insert() { let zettels_for_tag = TagEntity::load() .filter_by_nano_id(tag.nano_id.clone()) .with(ZettelEntity) + .all(&db) + .await .unwrap(); diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index 7022c32..32330a7 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use color_eyre::eyre::Result; +use dto::{QueryOrder, TagEntity, ZettelColumns, ZettelEntity}; use ratatui::{ prelude::*, widgets::{Block, ListState}, @@ -8,7 +9,7 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{ tui::{Signal, components::Component}, - types::KastenHandle, + types::{KastenHandle, Zettel}, }; mod preview; @@ -24,6 +25,8 @@ use zettel_view::ZettelView; /// initialized) pub struct Zk<'text> { signal_tx: Option>, + // TODO: really think whether or not this actually needs a kasten + // handle or is a workspace clone enough? kh: KastenHandle, layouts: Layouts, zettel_list: ZettelList<'text>, @@ -57,13 +60,20 @@ impl Zk<'_> { pub async fn new(kh: KastenHandle) -> Result { let kt = kh.read().await; - let nodes = kt.graph.nodes_iter().collect::>(); + let zettels: Vec = ZettelEntity::load() + .with(TagEntity) + .order_by_desc(ZettelColumns::ModifiedAt) + .all(&kt.ws.db) + .await? + .into_iter() + .map(Into::into) + .collect(); // in theory this is wasted compute, we should be initializing all our // stuff inside the init function let mut l_state = ListState::default(); l_state.select_first(); - let zettel_list = ZettelList::new(nodes.as_slice(), l_state, 0); + let zettel_list = ZettelList::new(zettels, l_state, 0); let selected_zettel = zettel_list .id_list @@ -138,6 +148,21 @@ impl Zk<'_> { Ok(()) } + + pub async fn get_zettels_by_current_query(&self) -> Result> { + let kt = self.kh.read().await; + let models = ZettelEntity::load() + .with(TagEntity) + .order_by_desc(ZettelColumns::ModifiedAt) + .all(&kt.ws.db) + .await?; + + // im being a good boy and dropping this as soon as im done with the db + drop(kt); + + let zettels: Vec = models.into_iter().map(Into::into).collect(); + Ok(zettels) + } } #[async_trait] @@ -146,19 +171,18 @@ impl Component for Zk<'_> { async fn init(&mut self, area: Size) -> color_eyre::Result<()> { let total_width = area.width; - let kt = self.kh.read().await; - - let nodes = kt.graph.nodes_iter().collect::>(); - + // in theory this is wasted compute, we should be initializing all our let mut l_state = ListState::default(); l_state.select_first(); - let zettel_list = ZettelList::new(nodes.as_slice(), l_state, total_width / 2); + let zettel_list = ZettelList::new( + self.get_zettels_by_current_query().await?, + l_state, + total_width / 2, + ); self.zettel_list = zettel_list; - drop(kt); - Ok(()) } @@ -212,9 +236,9 @@ impl Component for Zk<'_> { return Ok(None); }; - let kh = self.kh.read().await; + let kt = self.kh.read().await; - let node = kh + let node = kt .get_node_by_zettel_id(id) .expect("Invariant broken, this must exist."); @@ -222,14 +246,14 @@ impl Component for Zk<'_> { // the ratatui api doesnt expose a swap function to the inner render // list. self.zettel_list = ZettelList::new( - kh.graph.nodes_iter().collect::>().as_slice(), + self.get_zettels_by_current_query().await?, self.zettel_list.state, self.zettel_list.width, ); self.zettel_view = ZettelView::from(node.payload()); - self.preview = Preview::from(node.payload().content(&kh.ws).await?); - drop(kh); + self.preview = Preview::from(node.payload().content(&kt.ws).await?); + drop(kt); } _ => {} diff --git a/src/tui/components/zk/zettel_list.rs b/src/tui/components/zk/zettel_list.rs index dc1749e..52a8021 100644 --- a/src/tui/components/zk/zettel_list.rs +++ b/src/tui/components/zk/zettel_list.rs @@ -1,11 +1,10 @@ -use egui_graphs::{Node, petgraph::graph::NodeIndex}; use ratatui::{ style::{Color, Modifier, Style, Stylize}, text::{Line, Span, Text}, widgets::{List, ListState}, }; -use crate::types::{Link, Zettel, ZettelId}; +use crate::types::{Zettel, ZettelId}; pub struct ZettelList<'text> { pub render_list: ratatui::widgets::List<'text>, @@ -72,22 +71,17 @@ where } impl ZettelList<'_> { - pub fn new(nodes: &[(NodeIndex, &Node)], state: ListState, width: u16) -> Self { - let render_list = List::new(nodes.iter().map(|(_, n)| { - let z = n.payload(); + pub fn new(zettels: Vec, state: ListState, width: u16) -> Self { + let render_list = List::new(zettels.iter().map(|z| { let mut zli: ZettelListItem<'_> = z.into(); zli.width = width; - Text::from(zli) })) .style(Color::White) .highlight_style(Modifier::REVERSED) .highlight_symbol("> "); - let id_list = nodes - .iter() - .map(|(_, n)| n.payload().id.clone()) - .collect::>(); + let id_list = zettels.into_iter().map(|z| z.id).collect::>(); ZettelList { render_list, diff --git a/src/types/kasten.rs b/src/types/kasten.rs index b2216c7..6018944 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -133,26 +133,24 @@ impl Kasten { let zid = zettel.id.clone(); let idx = self.graph.add_node(zettel); - gid = Some( - self.zid_to_gid - .insert(zid.clone(), idx) - .expect("this cannot have existed already"), - ); + self.zid_to_gid.insert(zid.clone(), idx); + + gid = Some(idx); self.get_node_by_zettel_id_mut(&zid) .expect("we just inserted it") .payload_mut() }; + // and then we sync with the file + zettel.sync_with_file(&ws).await?; + // to get past borrowchecker rules - let mut zettel = zettel.clone(); + let zettel = zettel.clone(); // gid must be set let gid = gid.unwrap(); - // and then we sync with the file - zettel.sync_with_file(&ws).await?; - // and now we manage the links going out of the file // remove all the old shit -- 2.54.0 From 2293f37274139d4274e56a759d3cc0154380708c Mon Sep 17 00:00:00 2001 From: suri-codes Date: Fri, 3 Apr 2026 12:02:02 -0700 Subject: [PATCH 10/12] feat: widgets for title + tag search --- Cargo.lock | 13 ++++++ Cargo.toml | 1 + src/tui/components/zk/mod.rs | 34 +++++++++++----- src/tui/components/zk/search.rs | 71 +++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 src/tui/components/zk/search.rs diff --git a/Cargo.lock b/Cargo.lock index 0644cee..612e87e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2306,6 +2306,7 @@ dependencies = [ "pulldown-cmark", "rand 0.10.0", "ratatui", + "ratatui-textarea", "rayon", "serde", "signal-hook 0.4.3", @@ -5805,6 +5806,18 @@ dependencies = [ "termwiz", ] +[[package]] +name = "ratatui-textarea" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de236b7cc74b3f7dea227b3fbad97bf459cddf552b6503d888fb9a106eda59ab" +dependencies = [ + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "unicode-width 0.2.2", +] + [[package]] name = "ratatui-widgets" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index c6c31b8..a7b2825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ egui_graphs = "0.30.0" rayon = "1.11.0" rand = "0.10.0" pulldown-cmark = { version = "0.13.3", features = ["simd"] } +ratatui-textarea = "0.8.0" [build-dependencies] anyhow = "1.0.102" diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index 32330a7..82dff92 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -1,10 +1,8 @@ use async_trait::async_trait; use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; use dto::{QueryOrder, TagEntity, ZettelColumns, ZettelEntity}; -use ratatui::{ - prelude::*, - widgets::{Block, ListState}, -}; +use ratatui::{prelude::*, widgets::ListState}; use tokio::sync::mpsc::UnboundedSender; use crate::{ @@ -13,10 +11,12 @@ use crate::{ }; mod preview; +mod search; mod zettel_list; mod zettel_view; use preview::Preview; +use search::Search; use zettel_list::ZettelList; use zettel_view::ZettelView; @@ -29,6 +29,7 @@ pub struct Zk<'text> { // handle or is a workspace clone enough? kh: KastenHandle, layouts: Layouts, + search: Search<'text>, zettel_list: ZettelList<'text>, zettel_view: ZettelView<'text>, preview: Preview<'text>, @@ -47,11 +48,8 @@ impl Default for Layouts { Constraint::Percentage(50), Constraint::Percentage(50), ]), - search_zl: Layout::vertical(vec![ - Constraint::Percentage(10), - Constraint::Percentage(90), - ]), - z_preview: Layout::vertical(vec![Constraint::Max(6), Constraint::Percentage(80)]), + search_zl: Layout::vertical(vec![Constraint::Min(6), Constraint::Fill(95)]), + z_preview: Layout::vertical(vec![Constraint::Min(6), Constraint::Fill(95)]), } } } @@ -113,6 +111,7 @@ impl Zk<'_> { zettel_list, zettel_view, preview, + search: Search::default(), }) } @@ -160,6 +159,8 @@ impl Zk<'_> { // im being a good boy and dropping this as soon as im done with the db drop(kt); + // for now we are going to just read that shit every time... + let zettels: Vec = models.into_iter().map(Into::into).collect(); Ok(zettels) } @@ -251,6 +252,11 @@ impl Component for Zk<'_> { self.zettel_list.width, ); + // we have to select the first one because when + // the zettels from the db get fetched, they are ordered + // by most recently modified + self.zettel_list.state.select_first(); + self.zettel_view = ZettelView::from(node.payload()); self.preview = Preview::from(node.payload().content(&kt.ws).await?); drop(kt); @@ -261,6 +267,13 @@ impl Component for Zk<'_> { Ok(None) } + fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result> { + // ok so we get the text here too + // self.search.title.input(key); + + Ok(None) + } + fn draw( &mut self, frame: &mut ratatui::Frame, @@ -278,7 +291,7 @@ impl Component for Zk<'_> { (l_rects[0], l_rects[1], r_rects[0], r_rects[1]) }; - frame.render_widget(Block::new().bg(Color::Red), search_layout); + frame.render_widget(self.search.clone(), search_layout); frame.render_stateful_widget( &self.zettel_list.render_list, @@ -288,7 +301,6 @@ impl Component for Zk<'_> { frame.render_widget(self.zettel_view.clone(), zettel_layout); frame.render_widget(self.preview.clone(), preview_layout); - // frame.render_widget(Block::new().bg(Color::Red), preview_layout); Ok(()) } diff --git a/src/tui/components/zk/search.rs b/src/tui/components/zk/search.rs new file mode 100644 index 0000000..7c4a778 --- /dev/null +++ b/src/tui/components/zk/search.rs @@ -0,0 +1,71 @@ +use ratatui::{ + layout::{Constraint, Layout}, + style::Style, + widgets::{Block, BorderType, Borders, Widget}, +}; +use ratatui_textarea::TextArea; + +#[derive(Clone)] +pub struct Search<'text> { + pub title: TextArea<'text>, + pub tag: TextArea<'text>, + layouts: Layouts, +} + +impl Default for Search<'_> { + fn default() -> Self { + let mut title = TextArea::default(); + + title.set_style(Style::default()); + title.set_block( + Block::new() + .border_type(BorderType::Plain) + .borders(Borders::all()) + .title("Search Titles"), + ); + + let mut tag = TextArea::default(); + tag.set_style(Style::default()); + tag.set_block( + Block::new() + .border_type(BorderType::Plain) + .borders(Borders::all()) + .title("Search Tags"), + ); + + Self { + title, + tag, + layouts: Layouts::default(), + } + } +} + +#[derive(Clone)] +struct Layouts { + title_tag: Layout, +} + +impl Default for Layouts { + fn default() -> Self { + Self { + title_tag: Layout::vertical(vec![Constraint::Min(3), Constraint::Min(3)]), + } + } +} + +impl Widget for Search<'_> { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + let (title_search_rect, tag_search_rect) = { + let rects = self.layouts.title_tag.split(area); + + (rects[0], rects[1]) + }; + + self.title.render(title_search_rect, buf); + self.tag.render(tag_search_rect, buf); + } +} -- 2.54.0 From 4a3cd7f260d44fd5d9c9c38c33c50f4ae1502331 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Fri, 3 Apr 2026 12:26:51 -0700 Subject: [PATCH 11/12] fix: binds are now non-interfering with typing --- .config/config.kdl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.config/config.kdl b/.config/config.kdl index 8de17c6..2000139 100644 --- a/.config/config.kdl +++ b/.config/config.kdl @@ -7,9 +7,9 @@ keymap { q Quit // Quit the application Quit // Another way to quit Suspend // Suspend the application - j MoveDown - k MoveUp - o OpenZettel - n NewZettel + up MoveUp + down MoveDown + enter OpenZettel + NewZettel } } -- 2.54.0 From 908d86bb281b841b58cd36b666fb59f2e13d3d61 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Fri, 3 Apr 2026 19:56:17 -0700 Subject: [PATCH 12/12] feat: search functionality! --- .config/config.kdl | 1 - Cargo.lock | 11 +++ Cargo.toml | 1 + src/tui/app.rs | 14 +--- src/tui/components/mod.rs | 6 +- src/tui/components/zk/mod.rs | 111 +++++++++++++++++-------- src/tui/components/zk/search.rs | 118 +++++++++++++++++++++------ src/tui/components/zk/zettel_list.rs | 2 +- src/tui/components/zk/zettel_view.rs | 2 +- 9 files changed, 189 insertions(+), 77 deletions(-) diff --git a/.config/config.kdl b/.config/config.kdl index 2000139..effe102 100644 --- a/.config/config.kdl +++ b/.config/config.kdl @@ -4,7 +4,6 @@ filaments_dir "{INSERT_HERE}" keymap { Home { - q Quit // Quit the application Quit // Another way to quit Suspend // Suspend the application up MoveUp diff --git a/Cargo.lock b/Cargo.lock index 612e87e..7517eb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2303,6 +2303,7 @@ dependencies = [ "futures", "human-panic", "kdl", + "nucleo-matcher", "pulldown-cmark", "rand 0.10.0", "ratatui", @@ -4550,6 +4551,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index a7b2825..0e45c7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ rayon = "1.11.0" rand = "0.10.0" pulldown-cmark = { version = "0.13.3", features = ["simd"] } ratatui-textarea = "0.8.0" +nucleo-matcher = "0.3.1" [build-dependencies] anyhow = "1.0.102" diff --git a/src/tui/app.rs b/src/tui/app.rs index e9495b1..f88751c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -11,7 +11,7 @@ use tracing::debug; use crate::{ config::Config, tui::{Event, Tui, components::Zk}, - types::{KastenHandle, Zettel}, + types::KastenHandle, }; use super::{components::Component, signal::Signal}; @@ -124,7 +124,7 @@ impl App { } for component in &mut self.components { - if let Some(signal) = component.handle_events(Some(event.clone()))? { + if let Some(signal) = component.handle_events(Some(event.clone())).await? { signal_tx.send(signal)?; } } @@ -167,16 +167,6 @@ impl App { Signal::Quit => self.should_quit = true, - Signal::NewZettel => { - // what the fuck am i going to do in here - - let ws = &self.kh.read().await.ws; - let z = Zettel::new("", ws).await?; - let path = z.absolute_path(ws); - - self.signal_tx.send(Signal::Helix { path })?; - } - Signal::Helix { path } => { tui.exit()?; diff --git a/src/tui/components/mod.rs b/src/tui/components/mod.rs index 1776d41..e88be26 100644 --- a/src/tui/components/mod.rs +++ b/src/tui/components/mod.rs @@ -71,9 +71,9 @@ pub trait Component: Send + Sync { /// # Returns /// /// * [`color_eyre::Result>`] - A signal to be processed or none. - fn handle_events(&mut self, event: Option) -> color_eyre::Result> { + async fn handle_events(&mut self, event: Option) -> color_eyre::Result> { let signal = match event { - Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, + Some(Event::Key(key_event)) => self.handle_key_event(key_event).await?, Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, _ => None, }; @@ -88,7 +88,7 @@ pub trait Component: Send + Sync { /// # Returns /// /// * [`color_eyre::Result>`] - A signal to be processed or none. - fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result> { + async fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result> { let _ = key; // to appease clippy Ok(None) } diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index 82dff92..19df416 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -4,6 +4,7 @@ use crossterm::event::KeyEvent; use dto::{QueryOrder, TagEntity, ZettelColumns, ZettelEntity}; use ratatui::{prelude::*, widgets::ListState}; use tokio::sync::mpsc::UnboundedSender; +use tracing::info; use crate::{ tui::{Signal, components::Component}, @@ -23,10 +24,10 @@ use zettel_view::ZettelView; /// in theory we could do some fancy `type_state` encoding stuff /// to make this work cleanly (so we know when the widgets are properly /// initialized) +/// The tui interface for interacting with a `ZettelKasten`. +/// Has `Search` functionality and `Preview` to view each `Zettel`. pub struct Zk<'text> { signal_tx: Option>, - // TODO: really think whether or not this actually needs a kasten - // handle or is a workspace clone enough? kh: KastenHandle, layouts: Layouts, search: Search<'text>, @@ -45,10 +46,11 @@ impl Default for Layouts { fn default() -> Self { Self { left_right: Layout::horizontal(vec![ - Constraint::Percentage(50), - Constraint::Percentage(50), + Constraint::Fill(51), + Constraint::Min(1), + Constraint::Fill(50), ]), - search_zl: Layout::vertical(vec![Constraint::Min(6), Constraint::Fill(95)]), + search_zl: Layout::vertical(vec![Constraint::Min(3), Constraint::Fill(95)]), z_preview: Layout::vertical(vec![Constraint::Min(6), Constraint::Fill(95)]), } } @@ -58,14 +60,24 @@ impl Zk<'_> { pub async fn new(kh: KastenHandle) -> Result { let kt = kh.read().await; - let zettels: Vec = ZettelEntity::load() - .with(TagEntity) - .order_by_desc(ZettelColumns::ModifiedAt) - .all(&kt.ws.db) - .await? - .into_iter() - .map(Into::into) - .collect(); + let fetch_all = async || -> Result> { + Ok(ZettelEntity::load() + .with(TagEntity) + .order_by_desc(ZettelColumns::ModifiedAt) + .all(&kt.ws.db) + .await? + .into_iter() + .map(Into::into) + .collect()) + }; + + let mut zettels: Vec = fetch_all().await?; + + if zettels.is_empty() { + // we should have a welcome zettel + Zettel::new("Welcome!", &kt.ws).await?; + zettels = fetch_all().await?; + } // in theory this is wasted compute, we should be initializing all our // stuff inside the init function @@ -79,13 +91,14 @@ impl Zk<'_> { zettel_list .state .selected() - .expect("TODO: must handle the case where there isnt one..."), + .expect("We explicitly select the first item"), ) - .expect("must exist"); + // so technically this might not exist + .expect("There must always be one atleast one zettel"); let zettel = kt .get_node_by_zettel_id(selected_zettel) - .expect("must exist, handle case where it doesnt later...") + .expect("") .payload(); let preview = Preview::from( @@ -102,6 +115,8 @@ impl Zk<'_> { .payload() .into(); + let ws = kt.ws.clone(); + drop(kt); Ok(Self { @@ -111,7 +126,7 @@ impl Zk<'_> { zettel_list, zettel_view, preview, - search: Search::default(), + search: Search::new(ws), }) } @@ -162,8 +177,23 @@ impl Zk<'_> { // for now we are going to just read that shit every time... let zettels: Vec = models.into_iter().map(Into::into).collect(); + Ok(zettels) } + + pub async fn update_with_respect_to_query(&mut self) -> Result<()> { + let zettels = self + .search + .rank(self.get_zettels_by_current_query().await?) + .await; + + self.zettel_list = ZettelList::new(zettels, self.zettel_list.state, self.zettel_list.width); + info!("we are moving selection to first"); + self.zettel_list.state.select_first(); + self.update_views_from_zettel_list_selection().await?; + + Ok(()) + } } #[async_trait] @@ -227,11 +257,25 @@ impl Component for Zk<'_> { return Ok(Some(Signal::Helix { path })); } + Signal::NewZettel => { + // what the fuck am i going to do in here + + let ws = &self.kh.read().await.ws; + + // we create the zettel with the query as the + let z = Zettel::new(self.search.query(), ws).await?; + + let path = z.absolute_path(ws); + + return Ok(Some(Signal::Helix { path })); + } + Signal::ClosedZettel => { - let selected = self.zettel_list.state.selected().expect( - "still have to - figure out what to do if this doesnt exist", - ); + let selected = self + .zettel_list + .state + .selected() + .expect("This must be the zettel we just edited"); let Some(id) = self.zettel_list.id_list.get(selected) else { return Ok(None); @@ -243,20 +287,17 @@ impl Component for Zk<'_> { .get_node_by_zettel_id(id) .expect("Invariant broken, this must exist."); - // actually this is the only way to do it with the list thing, - // the ratatui api doesnt expose a swap function to the inner render - // list. + // reset the state of the component + self.search.clear_query(); + self.zettel_list.state.select_first(); + + // regenerate a fresh zettel list self.zettel_list = ZettelList::new( self.get_zettels_by_current_query().await?, self.zettel_list.state, self.zettel_list.width, ); - // we have to select the first one because when - // the zettels from the db get fetched, they are ordered - // by most recently modified - self.zettel_list.state.select_first(); - self.zettel_view = ZettelView::from(node.payload()); self.preview = Preview::from(node.payload().content(&kt.ws).await?); drop(kt); @@ -267,9 +308,13 @@ impl Component for Zk<'_> { Ok(None) } - fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result> { - // ok so we get the text here too - // self.search.title.input(key); + async fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result> { + // NOTE: this is hardcoded for now, but I honestly think people should not + // be able to change these binds, opinionated software or something... + if !(key.code.is_up() || key.code.is_down() || key.code.is_enter()) { + self.search.query.input(key); + self.update_with_respect_to_query().await?; + } Ok(None) } @@ -282,7 +327,7 @@ impl Component for Zk<'_> { let (search_layout, zettel_list_layout, zettel_layout, preview_layout) = { let rects = self.layouts.left_right.split(area); - let (left, right) = (rects[0], rects[1]); + let (left, right) = (rects[0], rects[2]); let l_rects = self.layouts.search_zl.split(left); diff --git a/src/tui/components/zk/search.rs b/src/tui/components/zk/search.rs index 7c4a778..a4127c7 100644 --- a/src/tui/components/zk/search.rs +++ b/src/tui/components/zk/search.rs @@ -1,3 +1,8 @@ +use color_eyre::eyre::Error; +use nucleo_matcher::{ + Matcher, Utf32Str, + pattern::{CaseMatching, Normalization, Pattern}, +}; use ratatui::{ layout::{Constraint, Layout}, style::Style, @@ -5,51 +10,117 @@ use ratatui::{ }; use ratatui_textarea::TextArea; +use crate::types::{Workspace, Zettel}; + #[derive(Clone)] pub struct Search<'text> { - pub title: TextArea<'text>, - pub tag: TextArea<'text>, + pub query: TextArea<'text>, layouts: Layouts, + matcher: Matcher, + ws: Workspace, } -impl Default for Search<'_> { - fn default() -> Self { - let mut title = TextArea::default(); - - title.set_style(Style::default()); - title.set_block( - Block::new() - .border_type(BorderType::Plain) - .borders(Borders::all()) - .title("Search Titles"), - ); - +impl Search<'_> { + pub fn new(ws: Workspace) -> Self { let mut tag = TextArea::default(); tag.set_style(Style::default()); tag.set_block( Block::new() .border_type(BorderType::Plain) .borders(Borders::all()) - .title("Search Tags"), + .title("Filter by Tag"), ); Self { - title, - tag, + matcher: Matcher::default(), + query: Self::new_query(), + ws, layouts: Layouts::default(), } } + + fn new_query<'a>() -> TextArea<'a> { + let mut query = TextArea::default(); + query.set_style(Style::default()); + query.set_block( + Block::new() + .border_type(BorderType::Plain) + .borders(Borders::all()) + .title("Search"), + ); + + query.set_max_histories(0); + query + } + + /// Clears the query + pub fn clear_query(&mut self) { + self.query = Self::new_query(); + } + + /// Getter method for the current `Query` + pub fn query(&self) -> &str { + self.query.lines()[0].as_str() + } + + /// Sorts the vector of `Zettels` by their relation to the + /// search query. + //TODO: this should really take in some sort of file cache + // so we arent reloading it every single time... + pub async fn rank(&mut self, zettels: Vec) -> Vec { + // if no query, we dont do any ranking + if self.query().is_empty() { + return zettels; + } + + let read_tasks = zettels + .into_iter() + .map(|z| { + let ws = self.ws.clone(); + tokio::spawn(async move { + let content = z.content(&ws).await?; + let front_matter = z.front_matter(&ws).await?; + Ok::<(Zettel, String), Error>((z, format!("{content}\n{front_matter}"))) + }) + }) + .collect::>(); + + // await all of them + let documents = futures::future::join_all(read_tasks) + .await + .into_iter() + .filter_map(|result| result.ok()?.ok()) + .collect::>(); + + let pattern = Pattern::parse(self.query(), CaseMatching::Ignore, Normalization::Smart); + + let mut results: Vec<(Zettel, u32)> = documents + .into_iter() + .filter_map(|(z, doc)| { + let mut buf = Vec::new(); + let score = pattern + .score(Utf32Str::new(doc.as_str(), &mut buf), &mut self.matcher) + .unwrap_or_default(); + + if score > 0 { Some((z, score)) } else { None } + }) + .collect(); + + results.sort_by(|a, b| b.1.cmp(&a.1)); + + results.into_iter().map(|(i, _)| i).collect() + } } #[derive(Clone)] struct Layouts { - title_tag: Layout, + title: Layout, } impl Default for Layouts { fn default() -> Self { Self { - title_tag: Layout::vertical(vec![Constraint::Min(3), Constraint::Min(3)]), + title: Layout::vertical(vec![Constraint::Min(3)]), } } } @@ -59,13 +130,8 @@ impl Widget for Search<'_> { where Self: Sized, { - let (title_search_rect, tag_search_rect) = { - let rects = self.layouts.title_tag.split(area); - - (rects[0], rects[1]) - }; + let rect = { self.layouts.title.split(area)[0] }; - self.title.render(title_search_rect, buf); - self.tag.render(tag_search_rect, buf); + self.query.render(rect, buf); } } diff --git a/src/tui/components/zk/zettel_list.rs b/src/tui/components/zk/zettel_list.rs index 52a8021..9508ac9 100644 --- a/src/tui/components/zk/zettel_list.rs +++ b/src/tui/components/zk/zettel_list.rs @@ -35,7 +35,7 @@ impl From<&Zettel> for ZettelListItem<'_> { .add_modifier(Modifier::DIM) }) .collect(), - date: Span::from(value.created_at()).style(Style::new().add_modifier(Modifier::DIM)), + date: Span::from(value.modified_at()).style(Style::new().add_modifier(Modifier::DIM)), width: 0, } } diff --git a/src/tui/components/zk/zettel_view.rs b/src/tui/components/zk/zettel_view.rs index 9114331..01efce2 100644 --- a/src/tui/components/zk/zettel_view.rs +++ b/src/tui/components/zk/zettel_view.rs @@ -74,7 +74,7 @@ struct Layouts { impl Default for Layouts { fn default() -> Self { Self { - top_bottom: Layout::vertical(vec![Constraint::Max(3), Constraint::Max(3)]), + top_bottom: Layout::vertical(vec![Constraint::Min(3), Constraint::Min(3)]), title_created: Layout::horizontal(vec![Constraint::Fill(80), Constraint::Min(24)]),