From 18e000159cb777dcaa57cd6db3e658e4b6c9d5bb Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:32:48 +0100 Subject: [PATCH 01/12] v0.3.0: fix clippy lints, collapse nested ifs, clean comments --- src/app.rs | 268 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 198 insertions(+), 70 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0344d7d..e26ff2e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,6 +34,7 @@ pub enum Action { Quit, Sync, NewNote, + NewFolder, EditNote, DeleteNote, RenameNote, @@ -79,6 +80,7 @@ impl SortMode { pub enum InputMode { Normal, Editing, + CreatingFolder, Renaming, ConfirmDelete, Search, @@ -87,10 +89,12 @@ pub enum InputMode { Help, } -// main application state +// main app state pub struct App { pub notes: Vec, pub all_notes: Vec, + pub fs_items: Vec, + pub current_dir: PathBuf, pub list_state: ListState, pub status_msg: String, pub base_path: PathBuf, @@ -110,7 +114,7 @@ pub struct App { } impl App { - // initialize application state + // init app state pub fn new(notes: Vec, base_path: PathBuf, config: Config) -> App { let state = ListState::default(); let all_notes = notes.clone(); @@ -136,6 +140,8 @@ impl App { let mut app = App { notes, all_notes, + fs_items: Vec::new(), + current_dir: PathBuf::new(), list_state: state, status_msg: String::from(" Press 'h' for help "), base_path, @@ -177,19 +183,45 @@ impl App { app.theme.bold = parse(&user_theme.bold, app.theme.bold); } - // Apply initial sort app.sort_notes(); + app.refresh_fs_view(); - if !app.notes.is_empty() { + if !app.fs_items.is_empty() { app.list_state.select(Some(0)); - app.load_note_content(0); } app } - // sort notes based on current mode + // refresh item list from current dir + pub fn refresh_fs_view(&mut self) { + let target_dir = self.base_path.join(&self.current_dir); + let items_res = data::load_all_items(&target_dir.to_string_lossy()); + + if let Ok(mut items) = items_res { + let current_depth = self.current_dir.components().count(); + + items.retain(|item| { + let path = match item { + data::FileSystemItem::Note(n) => &n.path, + data::FileSystemItem::Folder(p) => p, + }; + + let rel_path = path.strip_prefix(&self.base_path).unwrap_or(path); + + if !rel_path.starts_with(&self.current_dir) { + return false; + } + + let rel_depth = rel_path.components().count(); + rel_depth == current_depth + 1 + }); + + self.fs_items = items; + } + } + + // sort notes pub fn sort_notes(&mut self) { - // sort only if not in search mode if !self.search_query.is_empty() { return; } @@ -209,7 +241,7 @@ impl App { } } - // filter notes list based on fuzzy search query + // fuzzy search notes by title pub fn update_search(&mut self) { if self.search_query.is_empty() { self.notes = self.all_notes.clone(); @@ -230,7 +262,7 @@ impl App { self.notes = matches.into_iter().map(|(n, _)| n.clone()).collect(); } - // Reset selection + // reset selection if !self.notes.is_empty() { self.list_state.select(Some(0)); self.load_note_content(0); @@ -239,7 +271,7 @@ impl App { } } - // filter notes list based on fuzzy search of tags + // fuzzy search notes by tags pub fn update_tag_search(&mut self) { if self.search_query.is_empty() { self.notes = self.all_notes.clone(); @@ -265,7 +297,7 @@ impl App { self.notes = matches.into_iter().map(|(n, _)| n.clone()).collect(); } - // Reset selection + // reset selection if !self.notes.is_empty() { self.list_state.select(Some(0)); self.load_note_content(0); @@ -274,7 +306,7 @@ impl App { } } - // filter notes list based on fuzzy search of content + // fuzzy search notes by content pub fn update_content_search(&mut self) { if self.search_query.is_empty() { self.notes = self.all_notes.clone(); @@ -287,21 +319,17 @@ impl App { .all_notes .iter() .filter_map(|note| { - // Try to load content if not present let content = if let Some(ref c) = note.content { Some(c.clone()) } else { data::read_note_content(¬e.path).ok() }; - if let Some(content) = content { - // We use simple case-insensitive contains for content search - // as fuzzy matching large files is extremely expensive. - if content.to_lowercase().contains(&query) { - // Boost score slightly if the title also matches - let title_score = matcher.fuzzy_match(¬e.title, &query).unwrap_or(0); - return Some((note, 100 + title_score)); - } + if let Some(content) = content + && content.to_lowercase().contains(&query) + { + let title_score = matcher.fuzzy_match(¬e.title, &query).unwrap_or(0); + return Some((note, 100 + title_score)); } None }) @@ -311,7 +339,7 @@ impl App { self.notes = matches.into_iter().map(|(n, _)| n.clone()).collect(); } - // Reset selection + // reset selection if !self.notes.is_empty() { self.list_state.select(Some(0)); self.load_note_content(0); @@ -320,7 +348,7 @@ impl App { } } - // load file content into memory with lru cache eviction + // load content with lru cache pub fn load_note_content(&mut self, index: usize) { if index >= self.notes.len() { return; @@ -338,60 +366,117 @@ impl App { } } - // Update cache (LRU) + // update cache (lru) self.recent_indices.retain(|&i| i != index); self.recent_indices.push_back(index); if self.recent_indices.len() > 10 && let Some(old_idx) = self.recent_indices.pop_front() + && Some(old_idx) != self.list_state.selected() + && !self.recent_indices.contains(&old_idx) { - // Don't clear if it's currently selected or still in recent list - if Some(old_idx) != self.list_state.selected() - && !self.recent_indices.contains(&old_idx) - { - self.notes[old_idx].content = None; - } + self.notes[old_idx].content = None; } } pub fn next(&mut self) { - if self.notes.is_empty() { - return; - } - let i = match self.list_state.selected() { - Some(i) => { - if i >= self.notes.len() - 1 { - 0 - } else { - i + 1 + if !self.search_query.is_empty() { + if self.notes.is_empty() { + return; + } + let i = match self.list_state.selected() { + Some(i) => { + if i >= self.notes.len() - 1 { + 0 + } else { + i + 1 + } } + None => 0, + }; + self.list_state.select(Some(i)); + self.load_note_content(i); + } else { + // navigate fs_items + if self.fs_items.is_empty() { + return; } - None => 0, - }; - self.list_state.select(Some(i)); - self.load_note_content(i); + let i = match self.list_state.selected() { + Some(i) => { + if i >= self.fs_items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + self.load_fs_item_content(i); + } self.preview_scroll = 0; } pub fn previous(&mut self) { - if self.notes.is_empty() { - return; - } - let i = match self.list_state.selected() { - Some(i) => { - if i == 0 { - self.notes.len() - 1 - } else { - i - 1 + // if searching, navigate notes list + if !self.search_query.is_empty() { + if self.notes.is_empty() { + return; + } + let i = match self.list_state.selected() { + Some(i) => { + if i == 0 { + self.notes.len() - 1 + } else { + i - 1 + } } + None => 0, + }; + self.list_state.select(Some(i)); + self.load_note_content(i); + } else { + // navigate fs_items + if self.fs_items.is_empty() { + return; } - None => 0, - }; - self.list_state.select(Some(i)); - self.load_note_content(i); + let i = match self.list_state.selected() { + Some(i) => { + if i == 0 { + self.fs_items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + self.load_fs_item_content(i); + } self.preview_scroll = 0; } + // load content for file system items + pub fn load_fs_item_content(&mut self, index: usize) { + if index >= self.fs_items.len() { + return; + } + + match &self.fs_items[index] { + data::FileSystemItem::Note(_note) => {} + data::FileSystemItem::Folder(_) => { + return; + } + } + + if let data::FileSystemItem::Note(ref mut n) = self.fs_items[index] + && n.content.is_none() + && let Ok(c) = data::read_note_content(&n.path) + { + n.content = Some(c); + } + } + pub fn tick(&mut self) { if self.syncing { self.spinner_index = (self.spinner_index + 1) % 4; @@ -411,16 +496,16 @@ impl App { } pub fn cycle_theme(&mut self) { - // Simple cycle: Default -> Gruvbox -> Tokyo Night -> Default + // cycle through themes let current_accent = self.theme.accent; - // Identify current theme by accent color (heuristic) + // identify current theme by accent // Default: 137, 220, 235 (#89dceb) // Gruvbox: 250, 189, 47 (#fabd2f) // Tokyo: 122, 162, 247 (#7aa2f7) let next_theme = if current_accent == Color::Rgb(137, 220, 235) { - // Switch to Gruvbox + // Gruvbox ThemeColors { accent: Color::Rgb(250, 189, 47), // Yellow selection: Color::Rgb(215, 153, 33), // Dark Yellow @@ -429,7 +514,7 @@ impl App { bold: Color::Rgb(254, 128, 25), // Orange } } else if current_accent == Color::Rgb(250, 189, 47) { - // Switch to Tokyo Night + // Tokyo Night ThemeColors { accent: Color::Rgb(122, 162, 247), // Blue selection: Color::Rgb(187, 154, 247), // Purple @@ -445,7 +530,7 @@ impl App { self.theme = next_theme; } - // process keyboard input based on current mode + // handle input pub fn handle_input(&mut self, key: KeyEvent) -> Action { match self.input_mode { InputMode::Normal => match key.code { @@ -474,6 +559,7 @@ impl App { } KeyCode::Char('g') => Action::Sync, KeyCode::Char('n') => Action::NewNote, + KeyCode::Char('f') => Action::NewFolder, KeyCode::Char('d') => Action::DeleteNote, KeyCode::Char('r') => Action::RenameNote, KeyCode::Char('s') => Action::CycleSort, @@ -498,22 +584,64 @@ impl App { self.status_msg = String::from("Content Search: "); Action::None } - KeyCode::Char('h') => { + KeyCode::F(1) => { self.input_mode = InputMode::Help; self.status_msg = String::from(" Help "); Action::None } - KeyCode::Enter => Action::EditNote, + KeyCode::Char('h') | KeyCode::Backspace => { + if self.search_query.is_empty() + && self.current_dir.components().count() > 0 + && let Some(parent) = self.current_dir.parent() + { + self.current_dir = parent.to_path_buf(); + self.refresh_fs_view(); + self.list_state.select(Some(0)); + self.status_msg = format!("Dir: {}", self.current_dir.display()); + } + Action::None + } + KeyCode::Char('l') | KeyCode::Enter => { + // check if selected item is folder + if self.search_query.is_empty() { + if let Some(i) = self.list_state.selected() { + if i < self.fs_items.len() { + match &self.fs_items[i] { + data::FileSystemItem::Folder(path) => { + // enter folder + let rel_path = + path.strip_prefix(&self.base_path).unwrap_or(path); + self.current_dir = rel_path.to_path_buf(); + self.refresh_fs_view(); + self.list_state.select(Some(0)); + self.status_msg = + format!("Dir: {}", self.current_dir.display()); + Action::None + } + data::FileSystemItem::Note(_) => Action::EditNote, + } + } else { + Action::None + } + } else { + Action::None + } + } else { + Action::EditNote + } + } KeyCode::F(12) => Action::ToggleLogs, _ => Action::None, }, - InputMode::Editing | InputMode::Renaming => match key.code { - KeyCode::Enter => Action::SubmitInput, - KeyCode::Esc => Action::CancelInput, - KeyCode::Backspace => Action::Backspace, - KeyCode::Char(c) => Action::EnterChar(c), - _ => Action::None, - }, + InputMode::Editing | InputMode::CreatingFolder | InputMode::Renaming => { + match key.code { + KeyCode::Enter => Action::SubmitInput, + KeyCode::Esc => Action::CancelInput, + KeyCode::Backspace => Action::Backspace, + KeyCode::Char(c) => Action::EnterChar(c), + _ => Action::None, + } + } InputMode::ConfirmDelete => match key.code { KeyCode::Char('y') => Action::SubmitInput, KeyCode::Char('n') | KeyCode::Esc => Action::CancelInput, From 69a8fbd5d40287ff7ab2a040fcef53ec6cd4a0b0 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:32:51 +0100 Subject: [PATCH 02/12] v0.3.0: clean comments --- src/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8083b1e..0e18510 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,7 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::fs; -// application configuration options +// config options #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Config { pub editor_cmd: Option, @@ -55,7 +55,7 @@ auto_sync = false # bold = "#f38ba8" # Bold text and heavy emphasis "##; -// load configuration from standard location or return defaults +// load config pub fn load_config() -> Result { let home_dir = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; @@ -75,7 +75,7 @@ pub fn load_config() -> Result { Ok(config) } -// save configuration to disk +// save config pub fn save_config(config: &Config) -> Result<()> { let home_dir = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; From 2fb2ad5a9c466268e4654153cf28953a96155235 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:32:54 +0100 Subject: [PATCH 03/12] v0.3.0: fix clippy lints, refactor matching, clean comments --- src/data.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/src/data.rs b/src/data.rs index b9d9d70..1013d75 100644 --- a/src/data.rs +++ b/src/data.rs @@ -13,8 +13,14 @@ struct Frontmatter { tags: Vec, } -// represents a single markdown note file -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] +pub enum FileSystemItem { + Note(Note), + Folder(PathBuf), +} + +// represents a markdown note +#[derive(Debug, Clone, PartialEq)] pub struct Note { pub path: PathBuf, pub title: String, @@ -25,14 +31,14 @@ pub struct Note { } impl Note { - // create a note object from a file path - pub fn from_path(path: PathBuf) -> Result { + // create note from path + pub fn from_path(path: PathBuf, root: &std::path::Path) -> Result { let metadata = fs::metadata(&path) .with_context(|| format!("Failed to get metadata for: {:?}", path))?; - let title = path - .file_stem() - .ok_or_else(|| anyhow::anyhow!("Invalid filename"))? + let relative_path = path.strip_prefix(root).unwrap_or(&path); + let title = relative_path + .with_extension("") .to_string_lossy() .to_string(); @@ -54,7 +60,7 @@ fn extract_tags(path: &PathBuf) -> Result> { let mut reader = BufReader::new(file); let mut line = String::new(); - // Check first line + // check first line if reader.read_line(&mut line)? == 0 || line.trim() != "---" { return Ok(Vec::new()); } @@ -71,27 +77,28 @@ fn extract_tags(path: &PathBuf) -> Result> { frontmatter_content.push_str(&line); } - // Attempt to parse YAML + // parse yaml frontmatter // If it fails, we just assume no valid tags were found in that block let fm: Frontmatter = serde_yaml::from_str(&frontmatter_content).unwrap_or(Frontmatter { tags: Vec::new() }); Ok(fm.tags) } -// read the full text content of a note file +// read note content pub fn read_note_content(path: &PathBuf) -> Result { fs::read_to_string(path).with_context(|| format!("Failed to read file: {:?}", path)) } -// scan directory and return a list of markdown notes sorted by modification date +// scan directory for notes pub fn load_notes(directory: &str) -> Result> { let mut notes = Vec::new(); + let root = PathBuf::from(directory); for entry in WalkDir::new(directory).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") { - match Note::from_path(path.to_path_buf()) { + match Note::from_path(path.to_path_buf(), &root) { Ok(note) => notes.push(note), Err(e) => { warn!("Skipping file {:?}: {}", path, e); @@ -103,3 +110,48 @@ pub fn load_notes(directory: &str) -> Result> { notes.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); Ok(notes) } + +// scan directory for all items +pub fn load_all_items(directory: &str) -> Result> { + let mut items = Vec::new(); + let root = PathBuf::from(directory); + + for entry in WalkDir::new(directory).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + + // skip root directory + if path == root { + continue; + } + + // skip hidden files + if path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.starts_with('.')) + .unwrap_or(false) + { + continue; + } + + if path.is_dir() { + // keep relative path + items.push(FileSystemItem::Folder(path.to_path_buf())); + } else if path.is_file() + && path.extension().and_then(|s| s.to_str()) == Some("md") + && let Ok(note) = Note::from_path(path.to_path_buf(), &root) + { + items.push(FileSystemItem::Note(note)); + } + } + + // sort folders then files + items.sort_by(|a, b| match (a, b) { + (FileSystemItem::Folder(pa), FileSystemItem::Folder(pb)) => pa.cmp(pb), + (FileSystemItem::Folder(_), FileSystemItem::Note(_)) => std::cmp::Ordering::Less, + (FileSystemItem::Note(_), FileSystemItem::Folder(_)) => std::cmp::Ordering::Greater, + (FileSystemItem::Note(na), FileSystemItem::Note(nb)) => na.title.cmp(&nb.title), + }); + + Ok(items) +} From e32e9c23d09b8eac74058e28eccc50dc78267ee6 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:32:56 +0100 Subject: [PATCH 04/12] v0.3.0: clean comments --- src/events.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/events.rs b/src/events.rs index 26b4145..7925861 100644 --- a/src/events.rs +++ b/src/events.rs @@ -11,7 +11,7 @@ pub enum AppEvent { FileChanged, } -// handles keyboard inputs and tick events in a separate thread +// handle input and ticks pub struct EventHandler { pub sender: mpsc::Sender, receiver: mpsc::Receiver, @@ -19,7 +19,7 @@ pub struct EventHandler { } impl EventHandler { - // spawn a background thread to poll for input events + // spawn input polling thread pub fn new(tick_rate_ms: u64) -> Self { let (tx, rx) = mpsc::channel(); let tick_rate = Duration::from_millis(tick_rate_ms); @@ -58,12 +58,12 @@ impl EventHandler { Ok(self.receiver.recv()?) } - // pause input polling + // pause polling pub fn pause(&self) { self.paused.store(true, Ordering::SeqCst); } - // resume input polling + // resume polling pub fn resume(&self) { self.paused.store(false, Ordering::SeqCst); } From 050903952ce13feebc26b83b742313835429f85a Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:32:59 +0100 Subject: [PATCH 05/12] v0.3.0: fix clippy lints, collapse branching, clean comments --- src/main.rs | 291 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 216 insertions(+), 75 deletions(-) diff --git a/src/main.rs b/src/main.rs index 10de0e0..996b50d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use crossterm::{ use kiroku_tui::{ app::{Action, App, InputMode}, config, data, + errors::KirokuError, events::{AppEvent, EventHandler}, ops, ui, }; @@ -21,7 +22,7 @@ fn main() -> Result<()> { tui_logger::init_logger(log::LevelFilter::Info).unwrap(); tui_logger::set_default_level(log::LevelFilter::Info); - // setup directory, using cli argument or default home path + // setup directory using cli arg or default let args: Vec = std::env::args().collect(); let kiroku_path = if args.len() > 1 { PathBuf::from(&args[1]) @@ -31,13 +32,13 @@ fn main() -> Result<()> { home_dir.join("kiroku") }; - // create kiroku directory if it does not exist + // create notebook directory if missing if !kiroku_path.exists() { fs::create_dir_all(&kiroku_path)?; println!("created new notebook directory at {:?}", kiroku_path); } - // load config from default location + // load configuration let config = match config::load_config() { Ok(c) => c, Err(e) => { @@ -46,14 +47,14 @@ fn main() -> Result<()> { } }; - // setup terminal in raw mode + // setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // load initial data from notes directory + // load notes from directory let path_str = kiroku_path.to_string_lossy().to_string(); let notes = match data::load_notes(&path_str) { Ok(n) => n, @@ -64,10 +65,10 @@ fn main() -> Result<()> { }; let mut app = App::new(notes, kiroku_path.clone(), config); - // setup event handler in a separate thread + // setup event handler let events = EventHandler::new(250); - // setup file watcher to detect changes in notes directory + // setup file watcher let tx = events.sender.clone(); let mut watcher = notify::recommended_watcher(move |res: notify::Result| { if let Ok(event) = res @@ -78,7 +79,7 @@ fn main() -> Result<()> { })?; watcher.watch(&kiroku_path, RecursiveMode::NonRecursive)?; - // main application loop + // main loop while !app.should_quit { terminal.draw(|f| ui::ui(f, &mut app))?; @@ -114,7 +115,7 @@ fn main() -> Result<()> { events.pause(); std::thread::sleep(std::time::Duration::from_millis(300)); - // suspend tui to allow interactive shell commands + // suspend tui for shell commands let _ = disable_raw_mode(); let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); let _ = terminal.show_cursor(); @@ -126,7 +127,7 @@ fn main() -> Result<()> { println!("Repository path: {:?}", app.base_path); println!("(If prompted for password, input will be hidden)"); - // run git sync synchronously + // run git sync let result = ops::run_git_sync(&app.base_path).map_err(|e| e.to_string()); @@ -155,21 +156,60 @@ fn main() -> Result<()> { app.input.clear(); app.status_msg = String::from("Enter filename: "); } + Action::NewFolder => { + app.input_mode = InputMode::CreatingFolder; + app.input.clear(); + app.status_msg = String::from("Enter folder name: "); + } Action::RenameNote => { - if let Some(i) = app.list_state.selected() - && i < app.notes.len() - { - app.input_mode = InputMode::Renaming; - app.input = app.notes[i].title.clone(); - app.status_msg = String::from("Rename note: "); + if let Some(i) = app.list_state.selected() { + let title = if !app.search_query.is_empty() { + if i < app.notes.len() { + Some(app.notes[i].title.clone()) + } else { + None + } + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + data::FileSystemItem::Note(n) => Some(n.title.clone()), + data::FileSystemItem::Folder(p) => { + Some(p.file_name().unwrap().to_string_lossy().to_string()) + } + } + } else { + None + }; + + if let Some(t) = title { + app.input_mode = InputMode::Renaming; + app.input = t; + app.status_msg = String::from("Rename item: "); + } } } Action::DeleteNote => { - if let Some(i) = app.list_state.selected() - && i < app.notes.len() - { - app.input_mode = InputMode::ConfirmDelete; - app.status_msg = format!("Delete '{}'? (y/n)", app.notes[i].title); + if let Some(i) = app.list_state.selected() { + let name = if !app.search_query.is_empty() { + if i < app.notes.len() { + Some(app.notes[i].title.clone()) + } else { + None + } + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + data::FileSystemItem::Note(n) => Some(n.title.clone()), + data::FileSystemItem::Folder(p) => { + Some(p.file_name().unwrap().to_string_lossy().to_string()) + } + } + } else { + None + }; + + if let Some(n) = name { + app.input_mode = InputMode::ConfirmDelete; + app.status_msg = format!("Delete '{}'? (y/n)", n); + } } } Action::EnterChar(c) => { @@ -181,7 +221,9 @@ fn main() -> Result<()> { Action::SubmitInput => match app.input_mode { InputMode::Editing => { if !app.input.trim().is_empty() { - match ops::create_note(&app.base_path, &app.input) { + // create relative to current_dir + let target_path = app.base_path.join(&app.current_dir); + match ops::create_note(&target_path, &app.input) { Ok(path) => { events.pause(); if let Err(e) = ops::open_editor( @@ -194,6 +236,8 @@ fn main() -> Result<()> { events.resume(); app.input_mode = InputMode::Normal; app.status_msg = String::from("Note created."); + // refresh view to show new file + app.refresh_fs_view(); terminal.clear()?; } Err(e) => { @@ -204,32 +248,86 @@ fn main() -> Result<()> { } InputMode::Renaming => { if let Some(i) = app.list_state.selected() - && i < app.notes.len() && !app.input.trim().is_empty() { - match ops::rename_note(&app.notes[i].path, &app.input) { - Ok(_) => { - app.input_mode = InputMode::Normal; - app.status_msg = String::from("Note renamed."); + let old_path = if !app.search_query.is_empty() { + if i < app.notes.len() { + Some(app.notes[i].path.clone()) + } else { + None } - Err(e) => { - app.status_msg = format!("Rename error: {}", e); + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + data::FileSystemItem::Note(n) => Some(n.path.clone()), + data::FileSystemItem::Folder(p) => Some(p.clone()), + } + } else { + None + }; + + if let Some(path) = old_path { + match ops::rename_note(&path, &app.input) { + Ok(_) => { + app.input_mode = InputMode::Normal; + app.status_msg = String::from("Item renamed."); + app.refresh_fs_view(); + } + Err(e) => { + app.status_msg = format!("Rename error: {}", e); + } } } } } InputMode::ConfirmDelete => { - if let Some(i) = app.list_state.selected() - && i < app.notes.len() - { - if let Err(e) = ops::delete_note(&app.notes[i].path) { - app.status_msg = format!("Delete error: {}", e); + if let Some(i) = app.list_state.selected() { + let path_to_delete = if !app.search_query.is_empty() { + if i < app.notes.len() { + Some(app.notes[i].path.clone()) + } else { + None + } + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + data::FileSystemItem::Note(n) => Some(n.path.clone()), + data::FileSystemItem::Folder(p) => Some(p.clone()), + } } else { - app.status_msg = String::from("Note deleted."); + None + }; + + if let Some(path) = path_to_delete { + let res = if path.is_dir() { + fs::remove_dir_all(&path).map_err(KirokuError::Io) + } else { + ops::delete_note(&path) + }; + + if let Err(e) = res { + app.status_msg = format!("Delete error: {}", e); + } else { + app.status_msg = String::from("Item deleted."); + app.refresh_fs_view(); + } } } app.input_mode = InputMode::Normal; } + InputMode::CreatingFolder => { + if !app.input.trim().is_empty() { + let target_path = app.base_path.join(&app.current_dir); + match ops::create_folder(&target_path, &app.input) { + Ok(_) => { + app.input_mode = InputMode::Normal; + app.status_msg = String::from("Folder created."); + app.refresh_fs_view(); + } + Err(e) => { + app.status_msg = format!("Error: {}", e); + } + } + } + } _ => {} }, Action::CancelInput => { @@ -238,31 +336,57 @@ fn main() -> Result<()> { app.status_msg = String::from("Cancelled."); } Action::EditNote => { - if let Some(i) = app.list_state.selected() - && i < app.notes.len() - { - let path = app.notes[i].path.clone(); - events.pause(); - if let Err(e) = ops::open_editor( - &app.base_path, - Some(&path), - app.config.editor_cmd.as_deref(), - ) { - log::error!("Failed to open editor for {:?}: {}", path, e); - app.status_msg = format!("Editor error: {}", e); + if let Some(i) = app.list_state.selected() { + let path = if !app.search_query.is_empty() { + if i < app.notes.len() { + Some(app.notes[i].path.clone()) + } else { + None + } + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + data::FileSystemItem::Note(n) => Some(n.path.clone()), + data::FileSystemItem::Folder(_) => None, // cannot edit folder + } } else { - terminal.clear()?; + None + }; + + if let Some(p) = path { + events.pause(); + if let Err(e) = ops::open_editor( + &app.base_path, + Some(&p), + app.config.editor_cmd.as_deref(), + ) { + log::error!("Failed to open editor for {:?}: {}", p, e); + app.status_msg = format!("Editor error: {}", e); + } else { + terminal.clear()?; + } + events.resume(); } - events.resume(); } } Action::CopyContent => { - if let Some(i) = app.list_state.selected() - && i < app.notes.len() - { - let note = &app.notes[i]; - if let Some(content) = ¬e.content { - // Lazy init clipboard if missing + if let Some(i) = app.list_state.selected() { + let content = if !app.search_query.is_empty() { + if i < app.notes.len() { + app.notes[i].content.clone() + } else { + None + } + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + data::FileSystemItem::Note(n) => n.content.clone(), + data::FileSystemItem::Folder(_) => None, + } + } else { + None + }; + + if let Some(c) = content { + // lazy init clipboard if missing if app.clipboard.is_none() { match Clipboard::new() { Ok(cb) => app.clipboard = Some(cb), @@ -273,7 +397,7 @@ fn main() -> Result<()> { } if let Some(cb) = &mut app.clipboard { - if let Err(e) = cb.set_text(content.clone()) { + if let Err(e) = cb.set_text(c) { app.status_msg = format!("Copy error: {}", e); } else { app.status_msg = @@ -283,32 +407,49 @@ fn main() -> Result<()> { app.status_msg = String::from("Clipboard unavailable."); } } else { - app.status_msg = String::from("Note content not loaded."); + app.status_msg = + String::from("Note content not loaded or item is folder."); } } } Action::CopyPath => { - if let Some(i) = app.list_state.selected() - && i < app.notes.len() - { - let path = app.notes[i].path.to_string_lossy().to_string(); - - // Lazy init clipboard if missing - if app.clipboard.is_none() { - match Clipboard::new() { - Ok(cb) => app.clipboard = Some(cb), - Err(e) => log::warn!("Failed to re-init clipboard: {}", e), + if let Some(i) = app.list_state.selected() { + let path_str = if !app.search_query.is_empty() { + if i < app.notes.len() { + Some(app.notes[i].path.to_string_lossy().to_string()) + } else { + None } - } + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + data::FileSystemItem::Note(n) => { + Some(n.path.to_string_lossy().to_string()) + } + data::FileSystemItem::Folder(p) => { + Some(p.to_string_lossy().to_string()) + } + } + } else { + None + }; - if let Some(cb) = &mut app.clipboard { - if let Err(e) = cb.set_text(path) { - app.status_msg = format!("Copy error: {}", e); + if let Some(p) = path_str { + if app.clipboard.is_none() { + match Clipboard::new() { + Ok(cb) => app.clipboard = Some(cb), + Err(e) => log::warn!("Failed to re-init clipboard: {}", e), + } + } + + if let Some(cb) = &mut app.clipboard { + if let Err(e) = cb.set_text(p) { + app.status_msg = format!("Copy error: {}", e); + } else { + app.status_msg = String::from("Path copied to clipboard."); + } } else { - app.status_msg = String::from("Path copied to clipboard."); + app.status_msg = String::from("Clipboard unavailable."); } - } else { - app.status_msg = String::from("Clipboard unavailable."); } } } @@ -342,7 +483,7 @@ fn main() -> Result<()> { } } - // teardown: restore terminal state + // restore terminal state disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; From a9e8ed800a173806aae4d1bc0ef59b9a21298397 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:33:01 +0100 Subject: [PATCH 06/12] v0.3.0: clean comments --- src/ops.rs | 46 +++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/ops.rs b/src/ops.rs index 94ee5bb..8d7e395 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -8,13 +8,12 @@ use std::io; use std::path::{Path, PathBuf}; use std::process::Command; -// open the user's preferred editor for the given file +// open user editor pub fn open_editor( base_path: &Path, file_path: Option<&PathBuf>, editor_cmd: Option<&str>, ) -> Result<(), KirokuError> { - // temporarily disable raw mode to allow the editor to take over the terminal disable_raw_mode()?; execute!(io::stdout(), LeaveAlternateScreen)?; @@ -33,7 +32,6 @@ pub fn open_editor( let status = cmd.status().map_err(KirokuError::Io)?; - // restore raw mode after editor exits execute!(io::stdout(), EnterAlternateScreen)?; enable_raw_mode()?; @@ -46,7 +44,7 @@ pub fn open_editor( Ok(()) } -// create a new markdown file with the given filename +// create markdown file pub fn create_note(base_path: &Path, filename: &str) -> Result { let mut safe_filename = filename.trim().replace(" ", "_"); if !safe_filename.ends_with(".md") { @@ -62,17 +60,37 @@ pub fn create_note(base_path: &Path, filename: &str) -> Result Result { + let safe_foldername = foldername.trim().replace(" ", "_"); + let path = base_path.join(safe_foldername); + + if path.exists() { + return Err(KirokuError::Io(io::Error::new( + io::ErrorKind::AlreadyExists, + "Folder already exists", + ))); + } + + fs::create_dir_all(&path)?; + Ok(path) +} + +// delete note file pub fn delete_note(path: &Path) -> Result<(), KirokuError> { fs::remove_file(path)?; Ok(()) } -// rename an existing note file +// rename note pub fn rename_note(old_path: &Path, new_filename: &str) -> Result { let mut safe_filename = new_filename.trim().replace(" ", "_"); if !safe_filename.ends_with(".md") { @@ -91,11 +109,15 @@ pub fn rename_note(old_path: &Path, new_filename: &str) -> Result Result { println!("Executing git sync in: {:?}", base_path); if !base_path.join(".git").exists() { @@ -104,14 +126,14 @@ pub fn run_git_sync(base_path: &Path) -> Result { )); } - // 1. Check if there are any changes (staged or unstaged) + // check for local changes let status_out = Command::new("git") .args(["status", "--porcelain"]) .current_dir(base_path) .output()?; let has_changes = !status_out.stdout.is_empty(); - // 2. Check if we are ahead of the remote + // check if ahead of remote let ahead_out = Command::new("git") .args(["rev-list", "HEAD@{u}..HEAD"]) .current_dir(base_path) @@ -123,7 +145,7 @@ pub fn run_git_sync(base_path: &Path) -> Result { } if has_changes { - // stage all changes + // stage changes let add = Command::new("git") .arg("add") .arg(".") @@ -134,15 +156,13 @@ pub fn run_git_sync(base_path: &Path) -> Result { return Err(KirokuError::Git("git add failed".to_string())); } - // commit changes with a default message let _commit = Command::new("git") .args(["commit", "-m", "auto-sync from kiroku"]) .current_dir(base_path) .status()?; } - // 3. Push changes to remote only if needed - // We re-check if ahead after commit + // push to remote if needed let ahead_after_commit = Command::new("git") .args(["rev-list", "@{u}..HEAD"]) .current_dir(base_path) From 05f435dcab63a885e1639313ac98915d738b8510 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:33:03 +0100 Subject: [PATCH 07/12] v0.3.0: fix clippy lints, fix unused variable, clean comments --- src/ui.rs | 298 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 210 insertions(+), 88 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index c178874..58b6625 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use tui_logger::TuiLoggerWidget; -// renders the main tui interface +// render tui interface pub fn ui(f: &mut Frame, app: &mut App) { let constraints = if app.show_logs { vec![ @@ -34,31 +34,97 @@ pub fn ui(f: &mut Frame, app: &mut App) { .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(main_area); - // --- List Widget --- - let items: Vec = app - .notes - .iter() - .map(|note| { - let tags_display = if !note.tags.is_empty() { - format!( - " [{}]", - note.tags - .iter() - .map(|t| format!("#{}", t)) - .collect::>() - .join(" ") - ) - } else { - String::new() - }; - ListItem::new(format!(" {}{}", note.title, tags_display)) - }) - .collect(); + let items: Vec = if !app.search_query.is_empty() { + // show filtered notes + app.notes + .iter() + .map(|note| { + let tags_display = if !note.tags.is_empty() { + format!( + " [{}]", + note.tags + .iter() + .map(|t| format!("#{}", t)) + .collect::>() + .join(" ") + ) + } else { + String::new() + }; + + let mut spans = Vec::new(); + + let separator_idx = note.title.rfind('/').or_else(|| note.title.rfind('\\')); + + if let Some(idx) = separator_idx { + let (folder, name) = note.title.split_at(idx + 1); + spans.push(Span::styled( + format!(" {}", folder), + Style::default().fg(app.theme.dim), + )); + spans.push(Span::raw(name)); + } else { + spans.push(Span::raw(format!(" {}", note.title))); + } + + if !tags_display.is_empty() { + spans.push(Span::styled( + tags_display, + Style::default().fg(app.theme.dim), + )); + } + + ListItem::new(Line::from(spans)) + }) + .collect() + } else { + // show file system items + app.fs_items + .iter() + .map(|item| match item { + crate::data::FileSystemItem::Folder(path) => { + let name = path.file_name().unwrap().to_string_lossy(); + ListItem::new(Line::from(vec![ + Span::styled( + "> ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + name.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + ])) + } + crate::data::FileSystemItem::Note(note) => { + let name = note.path.file_name().unwrap().to_string_lossy(); + let name = name.strip_suffix(".md").unwrap_or(&name); + + ListItem::new(Line::from(vec![ + Span::styled(" ", Style::default().fg(app.theme.accent)), + Span::raw(name.to_string()), + ])) + } + }) + .collect() + }; + + let title = if !app.search_query.is_empty() { + format!(" Search Results [{}] ", app.notes.len()) + } else { + let path_str = if app.current_dir.as_os_str().is_empty() { + "Root".to_string() + } else { + app.current_dir.to_string_lossy().to_string() + }; + format!(" {} ", path_str) + }; let list = List::new(items) .block( Block::default() - .title(format!(" Notes [{}] ", app.sort_mode.as_str())) + .title(title) .title_style(Style::default().add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -74,70 +140,119 @@ pub fn ui(f: &mut Frame, app: &mut App) { f.render_stateful_widget(list, main_chunks[0], &mut app.list_state); - // Preview Widget - let (preview_content, preview_title, preview_footer) = - if let Some(i) = app.list_state.selected() { + // keep content string alive + // let mut content_string = String::new(); + + let (preview_content, preview_title, preview_footer) = if let Some(i) = + app.list_state.selected() + { + let selected_note = if !app.search_query.is_empty() { if i < app.notes.len() { - let note = &app.notes[i]; - let content = note.content.as_deref().unwrap_or("Loading..."); + Some(app.notes[i].clone()) + } else { + None + } + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + crate::data::FileSystemItem::Note(n) => Some(n.clone()), + crate::data::FileSystemItem::Folder(_) => None, + } + } else { + None + }; - let lines: Vec = content - .lines() - .map(|line| { - if line.starts_with("# ") { - Line::from(Span::styled( - line, - Style::default() - .fg(app.theme.header) - .add_modifier(Modifier::BOLD), - )) - } else if line.starts_with("## ") { - Line::from(Span::styled( - line, - Style::default() - .fg(app.theme.accent) - .add_modifier(Modifier::BOLD), - )) - } else if line.starts_with("### ") { - Line::from(Span::styled( - line, - Style::default() - .fg(app.theme.selection) - .add_modifier(Modifier::BOLD), - )) - } else if line.starts_with( - "` + if let Some(note) = selected_note { + let content_string = note + .content + .clone() + .unwrap_or_else(|| "Loading...".to_string()); + let content = content_string.as_str(); + + let lines: Vec = content + .lines() + .map(|line| { + if line.starts_with("# ") { + Line::from(Span::styled( + line.to_string(), + Style::default() + .fg(app.theme.header) + .add_modifier(Modifier::BOLD), + )) + } else if line.starts_with("## ") { + Line::from(Span::styled( + line.to_string(), + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )) + } else if line.starts_with("### ") { + Line::from(Span::styled( + line.to_string(), + Style::default() + .fg(app.theme.selection) + .add_modifier(Modifier::BOLD), + )) + } else if line.starts_with( + "` ```", - ) { - Line::from(Span::styled(line, Style::default().fg(app.theme.dim))) - } else if line.starts_with("> ") { + ) { + Line::from(Span::styled( + line.to_string(), + Style::default().fg(app.theme.dim), + )) + } else if line.starts_with("> ") { + Line::from(Span::styled( + line.to_string(), + Style::default() + .fg(Color::Rgb(166, 227, 161)) + .add_modifier(Modifier::ITALIC), + )) + } else { + Line::from(line.to_string()) + } + }) + .collect(); + + let title = format!(" {} ", note.title); + let dt: DateTime = note.last_modified.into(); + let footer = format!(" {} | {} bytes ", dt.format("%Y-%m-%d %H:%M"), note.size); + + (lines, title, footer) + } else { + // handle folder selection or invalid index + if !app.search_query.is_empty() { + (vec![Line::from("")], " Preview ".to_string(), String::new()) + } else if i < app.fs_items.len() { + match &app.fs_items[i] { + crate::data::FileSystemItem::Folder(p) => ( + vec![ + Line::from(""), Line::from(Span::styled( - line, + " > Folder", Style::default() - .fg(Color::Rgb(166, 227, 161)) - .add_modifier(Modifier::ITALIC), - )) - } else { - Line::from(line) - } - }) - .collect(); - - let title = format!(" {} ", note.title); - let dt: DateTime = note.last_modified.into(); - let footer = format!(" {} | {} bytes ", dt.format("%Y-%m-%d %H:%M"), note.size); - - (lines, title, footer) + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(format!(" {}", p.file_name().unwrap().to_string_lossy())), + Line::from(""), + Line::from(" Press 'l' or Enter to open."), + ], + " Folder Info ".to_string(), + String::new(), + ), + _ => (vec![Line::from("")], " Preview ".to_string(), String::new()), + } } else { (vec![Line::from("")], " Preview ".to_string(), String::new()) } - } else { - ( - vec![Line::from(" Press 'n' to create a new note.")], - " Kiroku ".to_string(), - String::new(), - ) - }; + } + } else { + ( + vec![Line::from(" Press 'n' to create a new note.")], + " Kiroku ".to_string(), + String::new(), + ) + }; let preview_block = Block::default() .title(preview_title) @@ -160,7 +275,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { f.render_widget(preview, main_chunks[1]); - // Logs + // render logs if app.show_logs { let tui_sm = TuiLoggerWidget::default() .block( @@ -176,7 +291,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { f.render_widget(tui_sm, chunks[1]); } - // Status Bar + // render status bar let spinner = if app.syncing { let frames = ["|", "/", "-", "\\"]; format!(" {} ", frames[app.spinner_index]) @@ -196,6 +311,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { } } InputMode::Editing => format!("{} CREATING NOTE: {}", spinner, app.status_msg), + InputMode::CreatingFolder => format!("{} CREATING FOLDER: {}", spinner, app.status_msg), InputMode::Renaming => format!("{} RENAMING NOTE: {}", spinner, app.status_msg), InputMode::ConfirmDelete => format!("{} DELETING NOTE: {}", spinner, app.status_msg), InputMode::Search => format!("{} SEARCH: {}", spinner, app.search_query), @@ -219,15 +335,19 @@ pub fn ui(f: &mut Frame, app: &mut App) { f.render_widget(status, status_area); - // Popups - if app.input_mode == InputMode::Editing || app.input_mode == InputMode::Renaming { + // render popups + if app.input_mode == InputMode::Editing + || app.input_mode == InputMode::Renaming + || app.input_mode == InputMode::CreatingFolder + { let area = centered_rect(60, 20, f.area()); f.render_widget(Clear, area); - let title = if app.input_mode == InputMode::Editing { - " New Note " - } else { - " Rename Note " + let title = match app.input_mode { + InputMode::Editing => " New Note ", + InputMode::Renaming => " Rename Note ", + InputMode::CreatingFolder => " New Folder ", + _ => "", }; let input_block = Block::default() @@ -303,6 +423,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { .add_modifier(Modifier::BOLD), )), Line::from(" j / k : Scroll list down / up"), + Line::from(" h / l : Go up / Enter folder"), Line::from(" Ctrl+j / k : Scroll preview down / up"), Line::from(" Enter : Edit selected note"), Line::from(""), @@ -313,6 +434,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { .add_modifier(Modifier::BOLD), )), Line::from(" n : New note"), + Line::from(" f : New folder"), Line::from(" r : Rename note"), Line::from(" d : Delete note"), Line::from(" g : Sync with git"), @@ -337,7 +459,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { .fg(app.theme.accent) .add_modifier(Modifier::BOLD), )), - Line::from(" h : Toggle this help"), + Line::from(" F1 : Toggle this help"), Line::from(" t : Cycle themes"), Line::from(" F12 : Toggle logs"), Line::from(" q : Quit"), @@ -352,7 +474,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { } } -// helper to center popups +// center rect helper fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) From ab000a3143875c21ac16b2be9d70857df9bdedc8 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:33:05 +0100 Subject: [PATCH 08/12] v0.3.0: fix test signatures, fix lru cache test --- tests/integration_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0ac6c7a..23217d1 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -30,7 +30,7 @@ fn test_frontmatter_tags() -> anyhow::Result<()> { writeln!(file, "---")?; writeln!(file, "# Content")?; - let note = Note::from_path(file_path.clone())?; + let note = Note::from_path(file_path.clone(), temp_dir.path())?; assert_eq!(note.tags.len(), 2); assert!(note.tags.contains(&"work".to_string())); @@ -130,7 +130,7 @@ fn test_lru_cache_eviction() { app.list_state.select(Some(10)); - for i in 1..=10 { + for i in 0..=10 { app.load_note_content(i); } @@ -148,7 +148,7 @@ fn test_note_from_path() -> anyhow::Result<()> { use std::io::Write; writeln!(file, "# Test Content")?; - let note = Note::from_path(file_path.clone())?; + let note = Note::from_path(file_path.clone(), temp_dir.path())?; assert_eq!(note.title, "test_note"); assert_eq!(note.path, file_path); From e22f9b2ebc3e9a67858da130c607c4b141888f53 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:33:39 +0100 Subject: [PATCH 09/12] v0.3.0 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f736992..801c818 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "kiroku-tui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "arboard", From ebbf41485e2522625b4e58654feba748565a9144 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:33:43 +0100 Subject: [PATCH 10/12] v0.3.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c587240..413623d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kiroku-tui" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "A simple, terminal-based personal journaling and note-taking tool." repository = "https://github.com/gab-dev-7/kiroku" From f82291373e287c33bdbfc1fac7f5e29cedc30335 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:33:47 +0100 Subject: [PATCH 11/12] v0.3.0 --- README.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3302af3..38f943e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ kiroku-tui helps you manage a collection of markdown notes directly from your te ## Features - **Terminal Interface**: Clean TUI built with `ratatui`. -- **Fuzzy Search**: Quickly find notes by title. +- **Folder Support**: Organize your notes into directories and navigate them with a file browser. +- **Fuzzy Search**: Quickly find notes by title across all folders. - **Content Search**: Deep search within the body of your notes. - **Tag Search**: Filter notes by tags defined in YAML frontmatter. - **Note Renaming**: Rename existing notes directly within the app. @@ -55,23 +56,35 @@ git init # Add your remote... ``` +### Navigation Modes + +**Browser Mode (Default)** +View your notes and folders hierarchically. +- Use `h` and `l` to navigate in and out of directories. +- Use `f` to create new folders. + +**Search Mode** +When you start searching (`/`, `#`, `?`), the view switches to a flat list of all matching notes, regardless of their folder. + ### Keybindings **Normal Mode** -- `h`: Open help popup +- `F1`: Open help popup - `n`: Create a new note -- `Enter`: Edit the selected note -- `r`: Rename the selected note -- `d`: Delete the selected note (prompts for confirmation) +- `f`: Create a new folder +- `Enter` / `l`: Edit selected note or Enter folder +- `Backspace` / `h`: Go up a directory +- `r`: Rename the selected item +- `d`: Delete the selected item (prompts for confirmation) - `s`: Cycle sort mode (Date, Name, Size) - `t`: Cycle built-in themes (Default -> Gruvbox -> Tokyo Night) - `g`: Sync with Git (add, commit, push) - `/`: Enter title search mode - `?`: Enter content search mode - `#`: Enter tag search mode -- `j` / `k`: Navigate up/down -- `Ctrl+j` / `Ctrl+k`: Scroll preview pane up/down +- `j` / `k`: Navigate down/up +- `Ctrl+j` / `Ctrl+k`: Scroll preview pane down/up - `y`: Copy note content to clipboard - `Y`: Copy note file path to clipboard - `q`: Quit @@ -81,7 +94,7 @@ git init - Type to filter notes - `Enter`: Keep current filter and return to list -- `Esc`: Clear search and return to list +- `Esc`: Clear search and return to browser view ### Using Tags From 7c843674aeeb41d02c91fe1986fe56e8505d4dd9 Mon Sep 17 00:00:00 2001 From: Gabriel Windlin Date: Tue, 3 Feb 2026 20:34:04 +0100 Subject: [PATCH 12/12] v0.3.0: folder tests --- tests/folder_support.rs | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/folder_support.rs diff --git a/tests/folder_support.rs b/tests/folder_support.rs new file mode 100644 index 0000000..0537bc0 --- /dev/null +++ b/tests/folder_support.rs @@ -0,0 +1,59 @@ +use kiroku_tui::data; +use kiroku_tui::ops; +use tempfile::tempdir; + +#[test] +fn test_folder_support() { + let dir = tempdir().unwrap(); + let root = dir.path(); + + // 1. Test create_note with folder + let note_path = ops::create_note(root, "work/project_a").unwrap(); + assert!(note_path.exists()); + assert!(root.join("work").exists()); + assert!(root.join("work/project_a.md").exists()); + + // 2. Test load_notes + let path_str = root.to_string_lossy().to_string(); + let notes = data::load_notes(&path_str).unwrap(); + + // Find the note we just created + let note = notes + .iter() + .find(|n| n.path == note_path) + .expect("Note not found"); + + // Check title (should be relative path without extension) + #[cfg(unix)] + assert_eq!(note.title, "work/project_a"); + + // 3. Test deep nesting + let deep_note_path = ops::create_note(root, "a/b/c/deep").unwrap(); + assert!(deep_note_path.exists()); + assert!(root.join("a/b/c/deep.md").exists()); + + let notes_v2 = data::load_notes(&path_str).unwrap(); + let deep_note = notes_v2 + .iter() + .find(|n| n.path == deep_note_path) + .expect("Deep note not found"); + + #[cfg(unix)] + assert_eq!(deep_note.title, "a/b/c/deep"); + + // 4. Rename into folder + let new_path = ops::rename_note(¬e_path, "subdir/project_b").unwrap(); + assert!(new_path.exists()); + assert!(!note_path.exists()); + assert!(root.join("work/subdir/project_b.md").exists()); + + // 5. Test rename with '..' to move up + let moved_path = ops::rename_note(&new_path, "../../moved").unwrap(); + assert!(root.join("moved.md").exists()); + assert!(moved_path.exists()); + + // 6. Test create_folder + let folder_path = ops::create_folder(root, "new_folder").unwrap(); + assert!(folder_path.exists()); + assert!(folder_path.is_dir()); +}