use crate::config::ClinConfig;
use std::path::PathBuf;
pub use crate::cli::CliCommand;
use crate::constants::*;
pub use crate::editor::*;
use crate::events::get_title_text;
use crate::events::make_title_editor;
pub use crate::list_view::*;
use crate::markdown::MarkdownRenderer;
pub use crate::popups::*;
use crate::ui::text_area_from_content;
use crate::ui::{now_unix_secs, open_in_file_manager};
use ratatui::style::{Color, Style};
use ratatui::text::Text;
use std::borrow::Cow;
use std::time::Duration;
use std::time::Instant;
use crate::keybinds::Keybinds;
use crate::storage::{Note, NoteSummary, Storage};
use crate::templates::Template;
use anyhow::{Context, Result};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui_textarea::TextArea;
use std::collections::{HashMap, HashSet};
pub const VIRTUAL_PINNED_PATH: &str = "__clin_virtual__/pinned";
pub const VIRTUAL_PINNED_LABEL: &str = "Pinned";
#[derive(Debug, Clone, Default)]
pub struct SearchQuery {
pub text: String,
pub folder_filter: Option<String>,
pub pinned_only: bool,
pub tag_filter: Option<Vec<String>>,
pub grep_mode: bool,
pub grep_text: String,
}
fn find_filter_tokens(s: &str) -> Vec<(usize, &'static str)> {
let spaced = [" f:", " g:", " p:", " t:"];
let bare = ["f:", "g:", "p:", "t:"];
let mut tokens: Vec<(usize, &'static str)> = Vec::new();
let is_escaped = |s: &str, pos: usize, _prefix_len: usize| -> bool {
if pos < 3 {
return false;
}
&s[pos - 3..pos] == "\\e\\"
};
for &prefix in &spaced {
let mut start = 0;
while let Some(pos) = s[start..].find(prefix) {
let abs_pos = start + pos;
if !is_escaped(s, abs_pos, prefix.len()) {
tokens.push((abs_pos, prefix));
}
start = abs_pos + prefix.len();
}
}
for &prefix in &bare {
if s.starts_with(prefix)
&& !tokens.iter().any(|&(p, _)| p == 0)
&& !is_escaped(s, 0, prefix.len())
{
tokens.push((0, prefix));
}
}
tokens.sort_by_key(|&(pos, _)| pos);
tokens
}
fn strip_escape_filter(s: &str) -> String {
if !s.contains("\\e\\") {
return s.to_string();
}
let filter_chars = ['f', 'g', 'p', 't'];
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().collect::<Vec<_>>().into_iter().peekable();
while let Some(c) = chars.next() {
if c == '\\' && chars.peek() == Some(&'e') {
chars.next();
if chars.peek() == Some(&'\\') {
chars.next();
let next = chars.peek().copied();
let after = {
let mut it = chars.clone();
it.next();
it.next()
};
let is_filter = next
.zip(after)
.is_some_and(|(ch, colon)| filter_chars.contains(&ch) && colon == ':');
if is_filter {
continue;
}
out.push('\\');
out.push('e');
out.push('\\');
} else {
out.push('\\');
out.push('e');
}
} else {
out.push(c);
}
}
out
}
pub fn parse_search_query(query: &str) -> SearchQuery {
let text = query.to_string();
let mut folder_filter = None;
let mut pinned_only = false;
let mut grep_mode = false;
let mut grep_text = String::new();
let mut tag_filter = None;
let tokens = find_filter_tokens(&text);
if tokens.is_empty() {
return SearchQuery {
text,
folder_filter,
pinned_only,
grep_mode,
grep_text,
tag_filter,
};
}
let mut ranges: Vec<(usize, usize)> = Vec::with_capacity(tokens.len());
for i in 0..tokens.len() {
let (pos, prefix) = tokens[i];
let val_start = pos + prefix.len();
let val_end = tokens.get(i + 1).map_or(text.len(), |&(next, _)| next);
let value = text[val_start..val_end].trim().to_string();
ranges.push((pos, val_end));
match prefix {
" f:" | "f:" => {
folder_filter = Some(if value.is_empty() {
String::new()
} else {
strip_escape_filter(&value)
});
}
" p:" | "p:" => {
pinned_only = true;
}
" g:" | "g:" => {
grep_mode = true;
grep_text = strip_escape_filter(&value);
}
" t:" | "t:" => {
let stripped = strip_escape_filter(&value);
let tags: Vec<String> = stripped
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
tag_filter = Some(tags);
}
_ => {}
}
}
let mut clean = text.clone();
for (start, end) in ranges.into_iter().rev() {
clean.replace_range(start..end, "");
}
clean = strip_escape_filter(&clean);
clean = clean.trim().to_string();
SearchQuery {
text: clean,
folder_filter,
pinned_only,
grep_mode,
grep_text,
tag_filter,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
List,
Edit,
Help,
Graph,
Draw,
Canvas,
Backup,
ContentTree,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HelpTab {
Notes,
Editor,
Graph,
Draw,
Canvas,
Backup,
Templates,
ContentTree,
About,
}
impl HelpTab {
pub fn prev(self) -> Self {
match self {
HelpTab::Notes => HelpTab::About,
HelpTab::Editor => HelpTab::Notes,
HelpTab::Graph => HelpTab::Editor,
HelpTab::Draw => HelpTab::Graph,
HelpTab::Canvas => HelpTab::Draw,
HelpTab::Backup => HelpTab::Canvas,
HelpTab::Templates => HelpTab::Backup,
HelpTab::ContentTree => HelpTab::Templates,
HelpTab::About => HelpTab::ContentTree,
}
}
pub fn next(self) -> Self {
match self {
HelpTab::Notes => HelpTab::Editor,
HelpTab::Editor => HelpTab::Graph,
HelpTab::Graph => HelpTab::Draw,
HelpTab::Draw => HelpTab::Canvas,
HelpTab::Canvas => HelpTab::Backup,
HelpTab::Backup => HelpTab::Templates,
HelpTab::Templates => HelpTab::ContentTree,
HelpTab::ContentTree => HelpTab::About,
HelpTab::About => HelpTab::Notes,
}
}
pub fn label(self) -> &'static str {
match self {
HelpTab::Notes => "Notes",
HelpTab::Editor => "Editor",
HelpTab::Graph => "Graph",
HelpTab::Draw => "Draw",
HelpTab::Canvas => "Pinstar",
HelpTab::Backup => "Backup",
HelpTab::Templates => "Templates",
HelpTab::ContentTree => "Content Tree",
HelpTab::About => "About",
}
}
pub fn from_index(i: usize) -> Self {
match i {
0 => HelpTab::Notes,
1 => HelpTab::Editor,
2 => HelpTab::Graph,
3 => HelpTab::Draw,
4 => HelpTab::Canvas,
5 => HelpTab::Backup,
6 => HelpTab::Templates,
7 => HelpTab::ContentTree,
_ => HelpTab::About,
}
}
pub fn index(self) -> usize {
match self {
HelpTab::Notes => 0,
HelpTab::Editor => 1,
HelpTab::Graph => 2,
HelpTab::Draw => 3,
HelpTab::Canvas => 4,
HelpTab::Backup => 5,
HelpTab::Templates => 6,
HelpTab::ContentTree => 7,
HelpTab::About => 8,
}
}
pub fn count() -> usize {
9
}
}
pub struct App {
pub popups: crate::popups::PopupManager,
pub storage: Storage,
pub keybinds: Keybinds,
pub notes: Vec<NoteSummary>,
pub editor: NoteEditor,
pub list: ListView,
pub mode: ViewMode,
pub status: Cow<'static, str>,
pub status_until: Option<Instant>,
pub help_scroll: u16,
pub help_tab: HelpTab,
pub help_tab_scroll: HashMap<HelpTab, u16>,
pub command_palette: Option<crate::palette::CommandPalette>,
pub needs_full_redraw: bool,
pub confirm_on_delete: bool,
pub confirm_on_quit: bool,
pub should_quit: bool,
pub preview_encryption: bool,
pub preview_position: crate::config::PreviewPosition,
pub pinned_on_top: bool,
pub default_folder: Option<String>,
pub last_g_press: Option<Instant>,
pub last_esc_press: Option<Instant>,
pub return_mode: Option<ViewMode>,
pub app_theme: crate::app_theme::AppThemeColors,
pub canvas_state: Option<crate::pinstar::state::PinstarState>,
}
impl App {
pub fn new(storage: Storage) -> Result<Self> {
let keybinds = storage.load_keybinds();
let bootstrap_config = crate::config::ClinConfig::load().unwrap_or_default();
let app_theme = crate::app_theme::AppThemeColors::from_config(&bootstrap_config.theme);
let mut editor = NoteEditor::new();
editor.external_editor_enabled = bootstrap_config.external_editor_enabled;
editor.external_editor = bootstrap_config.external_editor;
editor.editor_preview_enabled = bootstrap_config.editor_preview_enabled;
editor.show_line_numbers = bootstrap_config.show_line_numbers;
editor.title_editor = make_title_editor("", Color::Black, Color::Cyan);
let mut list = ListView::new();
list.sort_field = bootstrap_config
.default_sort_field
.unwrap_or(SortField::Title);
list.sort_order = bootstrap_config
.default_sort_order
.unwrap_or(SortOrder::Ascending);
list.preview_enabled = bootstrap_config.preview_enabled;
list.page_size = 10;
list.notes_layout = bootstrap_config.visual.notes_layout.clone();
let mut app = Self {
storage,
keybinds,
notes: Vec::new(),
editor,
list,
mode: ViewMode::List,
status: Cow::Borrowed(LIST_HELP_HINTS),
status_until: None,
help_scroll: 0,
help_tab: HelpTab::Notes,
help_tab_scroll: HashMap::new(),
command_palette: None,
popups: crate::popups::PopupManager::default(),
needs_full_redraw: false,
confirm_on_delete: bootstrap_config.confirm_on_delete,
confirm_on_quit: bootstrap_config.confirm_on_quit,
should_quit: false,
preview_encryption: bootstrap_config.preview_encryption,
preview_position: bootstrap_config.preview_position,
pinned_on_top: bootstrap_config.pinned_on_top,
default_folder: bootstrap_config.default_folder.clone(),
last_g_press: None,
last_esc_press: None,
return_mode: None,
app_theme,
canvas_state: None,
};
app.list.folder_expanded.insert(String::new());
app.refresh_notes()?;
Ok(app)
}
fn is_virtual_pinned_path(path: &str) -> bool {
path == VIRTUAL_PINNED_PATH
}
pub fn refresh_notes(&mut self) -> Result<()> {
let mut summaries = Vec::new();
for id in self.storage.list_note_ids()? {
if let Ok(summary) = self.storage.load_note_summary(&id) {
summaries.push(summary);
}
}
summaries.sort_by(|a, b| {
if self.pinned_on_top {
let pin_cmp = b.pinned.cmp(&a.pinned);
if pin_cmp != std::cmp::Ordering::Equal {
return pin_cmp;
}
}
let a_clin = a.id.ends_with(".clin");
let b_clin = b.id.ends_with(".clin");
let clin_cmp = b_clin.cmp(&a_clin);
if clin_cmp != std::cmp::Ordering::Equal {
return clin_cmp;
}
match self.list.sort_field {
SortField::Modified => match self.list.sort_order {
SortOrder::Descending => b.updated_at.cmp(&a.updated_at),
SortOrder::Ascending => a.updated_at.cmp(&b.updated_at),
},
SortField::Title => match self.list.sort_order {
SortOrder::Ascending => a.title.to_lowercase().cmp(&b.title.to_lowercase()),
SortOrder::Descending => b.title.to_lowercase().cmp(&a.title.to_lowercase()),
},
}
});
self.notes = summaries;
self.refresh_visual_list();
Ok(())
}
pub fn refresh_visual_list(&mut self) {
let mut visual = Vec::new();
let mut by_folder: HashMap<&str, Vec<(usize, &NoteSummary)>> = HashMap::new();
let mut pinned_notes: Vec<(usize, &NoteSummary)> = Vec::new();
for (i, note) in self.notes.iter().enumerate() {
by_folder
.entry(note.folder.as_str())
.or_default()
.push((i, note));
if note.pinned {
pinned_notes.push((i, note));
}
}
visual.push(VisualItem::Folder {
path: VIRTUAL_PINNED_PATH.to_string(),
name: VIRTUAL_PINNED_LABEL.to_string(),
depth: 0,
is_expanded: self.list.folder_expanded.contains(VIRTUAL_PINNED_PATH),
note_count: pinned_notes.len(),
});
if self.list.folder_expanded.contains(VIRTUAL_PINNED_PATH) {
for (idx, note) in &pinned_notes {
visual.push(VisualItem::Note {
summary_idx: *idx,
depth: 1,
is_clin: note.id.ends_with(".clin"),
is_draw: note.id.ends_with(".draw"),
is_canvas: note.id.ends_with(".canvas"),
in_virtual_pinned_folder: true,
});
}
}
visual.push(VisualItem::Folder {
path: String::new(),
name: String::from("Vault"),
depth: 0,
is_expanded: self.list.folder_expanded.contains(""),
note_count: by_folder.get("").map_or(0, |v| v.len()),
});
if self.list.folder_expanded.contains("") {
if let Some(notes) = by_folder.get("") {
for (idx, note) in notes {
visual.push(VisualItem::Note {
summary_idx: *idx,
depth: 1,
is_clin: note.id.ends_with(".clin"),
is_draw: note.id.ends_with(".draw"),
is_canvas: note.id.ends_with(".canvas"),
in_virtual_pinned_folder: false,
});
}
}
visual.push(VisualItem::CreateNew {
path: String::new(),
depth: 1,
});
}
let all_folders = if let Some(ref cache) = self.list.folder_cache {
cache
} else {
let folders = self.storage.list_folders().unwrap_or_default();
self.list.folder_cache = Some(folders);
self.list.folder_cache.as_ref().unwrap()
};
if self.list.notes_layout == crate::config::NotesLayout::Grid {
visual.clear();
let gf = &self.list.grid_folder;
if gf == VIRTUAL_PINNED_PATH {
for (idx, note) in &pinned_notes {
visual.push(VisualItem::Note {
summary_idx: *idx,
depth: 0,
is_clin: note.id.ends_with(".clin"),
is_draw: note.id.ends_with(".draw"),
is_canvas: note.id.ends_with(".canvas"),
in_virtual_pinned_folder: true,
});
}
} else {
if !gf.is_empty() {
let parent_path = if let Some(slash) = gf.rfind('/') {
&gf[..slash]
} else {
""
};
visual.push(VisualItem::Folder {
path: parent_path.to_string(),
name: "..".to_string(),
depth: 0,
is_expanded: false,
note_count: 0,
});
}
for folder in all_folders {
let parent_path = if let Some(slash) = folder.rfind('/') {
&folder[..slash]
} else {
""
};
if parent_path == gf {
let parts: Vec<&str> = folder.split('/').collect();
let name = parts.last().unwrap_or(&"").to_string();
visual.push(VisualItem::Folder {
path: folder.clone(),
name,
depth: 0,
is_expanded: false,
note_count: by_folder.get(folder.as_str()).map_or(0, |v| v.len()),
});
}
}
if let Some(notes) = by_folder.get(gf.as_str()) {
for (idx, note) in notes {
visual.push(VisualItem::Note {
summary_idx: *idx,
depth: 0,
is_clin: note.id.ends_with(".clin"),
is_draw: note.id.ends_with(".draw"),
is_canvas: note.id.ends_with(".canvas"),
in_virtual_pinned_folder: false,
});
}
}
}
self.list.visual_list = visual;
self.request_preview_update_immediate();
return;
}
for folder in all_folders {
let parts: Vec<&str> = folder.split('/').collect();
let depth = parts.len();
let name = parts.last().unwrap_or(&"").to_string();
let parent_path = if let Some(slash) = folder.rfind('/') {
&folder[..slash]
} else {
""
};
let mut is_visible = true;
let mut current_parent = parent_path;
while !current_parent.is_empty() {
if !self.list.folder_expanded.contains(current_parent) {
is_visible = false;
break;
}
if let Some(slash) = current_parent.rfind('/') {
current_parent = ¤t_parent[..slash];
} else {
current_parent = "";
}
}
if !self.list.folder_expanded.contains("") {
is_visible = false;
}
if is_visible {
let is_expanded = self.list.folder_expanded.contains(folder.as_str());
visual.push(VisualItem::Folder {
path: folder.clone(),
name,
depth,
is_expanded,
note_count: by_folder.get(folder.as_str()).map_or(0, |v| v.len()),
});
if is_expanded {
if let Some(notes) = by_folder.get(folder.as_str()) {
for (idx, note) in notes {
visual.push(VisualItem::Note {
summary_idx: *idx,
depth: depth + 1,
is_clin: note.id.ends_with(".clin"),
is_draw: note.id.ends_with(".draw"),
is_canvas: note.id.ends_with(".canvas"),
in_virtual_pinned_folder: false,
});
}
}
visual.push(VisualItem::CreateNew {
path: folder.clone(),
depth: depth + 1,
});
}
}
}
self.list.visual_list = visual;
self.request_preview_update_immediate();
}
pub fn get_current_folder_context(&self) -> String {
let current = match self.list.visual_list.get(self.list.visual_index) {
Some(VisualItem::Folder { path, .. }) => path.clone(),
Some(VisualItem::Note { summary_idx, .. }) => self
.notes
.get(*summary_idx)
.map(|n| n.folder.clone())
.unwrap_or_default(),
Some(VisualItem::CreateNew { path, .. }) => path.clone(),
None => String::new(),
};
if current.is_empty() {
self.default_folder.clone().unwrap_or_default()
} else {
current
}
}
pub fn open_note_at_line(&mut self, note_id: &str, line_number: Option<usize>) {
if note_id.ends_with(".draw") {
self.open_draw_view();
return;
}
if note_id.ends_with(".canvas") {
self.open_canvas_view();
return;
}
if note_id.ends_with(".clin") {
self.status =
Cow::Borrowed("Note is encrypted. Use command palette (Ctrl+P) to decrypt.");
return;
}
if self.editor.external_editor_enabled {
self.open_note_in_external_editor(note_id, line_number);
} else {
self.load_and_open_note(note_id, line_number);
}
}
pub fn open_selected(&mut self) {
if self.list.visual_list.is_empty() {
return;
}
if self.list.visual_index >= self.list.visual_list.len() {
self.list.visual_index = self.list.visual_list.len().saturating_sub(1);
}
match &self.list.visual_list[self.list.visual_index] {
VisualItem::CreateNew { path, .. } => {
self.begin_create_note_in_folder(path.clone());
}
VisualItem::Folder { path, .. } => {
let p = path.clone();
if self.list.notes_layout == crate::config::NotesLayout::Grid {
self.list.grid_folder = p;
self.list.visual_index = 0;
} else if self.list.folder_expanded.contains(&p) {
self.list.folder_expanded.remove(&p);
} else {
self.list.folder_expanded.insert(p);
}
self.refresh_visual_list();
}
VisualItem::Note { summary_idx, .. } => {
let note_id = self.notes.get(*summary_idx).map(|s| s.id.clone());
if let Some(id) = note_id {
self.open_note_at_line(&id, None);
}
}
}
}
pub fn cycle_grid_tab(&mut self) {
if self.list.notes_layout != crate::config::NotesLayout::Grid {
return;
}
self.list.grid_folder = if self.list.grid_folder == VIRTUAL_PINNED_PATH {
String::new()
} else {
VIRTUAL_PINNED_PATH.to_string()
};
self.list.visual_index = 0;
self.refresh_visual_list();
}
pub fn load_and_open_note(&mut self, note_id: &str, line_number: Option<usize>) {
if let Ok(note) = self.storage.load_note(note_id) {
self.editor.editing_id = Some(note_id.to_string());
self.editor.title_editor = make_title_editor(
¬e.title,
self.app_theme.highlight_fg,
self.app_theme.highlight_bg,
);
let mut editor = text_area_from_content(¬e.content);
if let Some(l) = line_number {
editor.move_cursor(ratatui_textarea::CursorMove::Jump(
l.saturating_sub(1) as u16,
0,
));
}
self.editor.editor = editor;
self.mode = ViewMode::Edit;
if self.editor.editor_preview_enabled {
self.update_editor_markdown_preview();
} else {
self.editor.md_preview_renderer = None;
}
self.status = Cow::Borrowed(EDIT_HELP_HINTS);
} else {
self.status = Cow::Borrowed("Failed to load note!");
}
}
fn run_in_external_editor(
&mut self,
extra_args: &[String],
) -> (std::io::Result<std::process::ExitStatus>, String) {
if let Err(e) = disable_raw_mode() {
eprintln!("Failed to disable raw mode: {e}");
}
if let Err(e) = crossterm::execute!(
std::io::stdout(),
LeaveAlternateScreen,
crossterm::event::DisableMouseCapture,
crossterm::event::DisableBracketedPaste
) {
eprintln!("Failed to reset terminal: {e}");
}
let editor_prog = self
.editor
.external_editor
.clone()
.or_else(|| std::env::var("VISUAL").ok())
.or_else(|| std::env::var("EDITOR").ok())
.unwrap_or_else(|| "vi".to_string());
let parts: Vec<&str> = editor_prog.split_whitespace().collect();
let (program, editor_args) = parts
.split_first()
.map(|(p, a)| (*p, a.to_vec()))
.unwrap_or(("vi", vec![]));
let mut command = std::process::Command::new(program);
for arg in editor_args {
command.arg(arg);
}
for arg in extra_args {
command.arg(arg);
}
let result = command.status();
if let Err(e) = enable_raw_mode() {
eprintln!("Failed to enable raw mode: {e}");
}
if let Err(e) = crossterm::execute!(
std::io::stdout(),
EnterAlternateScreen,
crossterm::event::EnableMouseCapture,
crossterm::event::EnableBracketedPaste,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
) {
eprintln!("Failed to restore terminal: {e}");
}
self.needs_full_redraw = true;
(result, editor_prog)
}
pub fn open_note_in_external_editor(&mut self, note_id: &str, line_number: Option<usize>) {
if let Ok(note) = self.storage.load_note(note_id) {
let temp_dir = std::env::temp_dir();
let temp_id = uuid::Uuid::new_v4().to_string();
let temp_file_path = temp_dir.join(format!("clin_{temp_id}.md"));
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp_file_path);
match file {
Ok(mut f) => {
use std::io::Write;
if let Err(e) = f.write_all(note.content.as_bytes()) {
self.set_temporary_status(&format!("Failed to write temp file: {e}"));
return;
}
}
Err(e) => {
self.set_temporary_status(&format!("Failed to create temp file: {e}"));
return;
}
}
}
#[cfg(not(unix))]
{
use std::io::Write;
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_file_path)
{
Ok(mut f) => {
if let Err(e) = f.write_all(note.content.as_bytes()) {
self.set_temporary_status(&format!("Failed to write temp file: {}", e));
return;
}
}
Err(e) => {
self.set_temporary_status(&format!("Failed to create temp file: {}", e));
return;
}
}
}
let mut args: Vec<String> = Vec::new();
if let Some(l) = line_number {
args.push(format!("+{l}"));
}
args.push(temp_file_path.to_string_lossy().into_owned());
let (result, editor_prog) = self.run_in_external_editor(&args);
match result {
Ok(status) if status.success() => {
if let Ok(new_content) = std::fs::read_to_string(&temp_file_path) {
if new_content != note.content {
let updated_note = Note {
title: note.title,
content: new_content,
updated_at: now_unix_secs(),
tags: note.tags,
};
if let Err(e) = self.storage.save_note(note_id, &updated_note) {
self.set_temporary_status(&format!("Failed to save note: {e}"));
} else {
if let Err(e) = self.try_auto_backup(&updated_note.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
self.set_temporary_status_static("Note saved");
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
}
} else {
self.set_temporary_status_static("No changes made in external editor.");
}
} else {
self.set_temporary_status_static("Failed to read from temp file.");
}
}
Ok(status) => {
self.set_temporary_status(&format!(
"Editor '{editor_prog}' exited with status: {status}"
));
}
Err(e) => {
self.set_temporary_status(&format!(
"Failed to launch editor '{editor_prog}': {e}"
));
}
}
if let Ok(len) = std::fs::metadata(&temp_file_path).map(|m| m.len())
&& let Err(e) = std::fs::write(&temp_file_path, vec![0u8; len as usize])
{
self.set_temporary_status(&format!("Failed to zero temp file: {e}"));
}
if let Err(e) = std::fs::remove_file(&temp_file_path) {
self.set_temporary_status(&format!("Failed to remove temp file: {e}"));
}
} else {
self.set_temporary_status_static("Failed to load note for external editor!");
}
}
pub fn collapse_selected_folder(&mut self) {
if self.list.visual_list.is_empty() {
return;
}
if self.list.visual_index >= self.list.visual_list.len() {
self.list.visual_index = self.list.visual_list.len().saturating_sub(1);
}
match &self.list.visual_list[self.list.visual_index] {
VisualItem::Folder {
path, is_expanded, ..
} => {
if *is_expanded {
self.list.folder_expanded.remove(path);
self.refresh_visual_list();
} else if !path.is_empty() {
let parent_path = if let Some(slash) = path.rfind('/') {
&path[..slash]
} else {
""
};
if let Some(idx) = self.list.visual_list.iter().position(|v| {
if let VisualItem::Folder { path: p, .. } = v {
p == parent_path
} else {
false
}
}) {
self.list.visual_index = idx;
}
}
}
VisualItem::Note { .. } | VisualItem::CreateNew { .. } => {
let item_path = match &self.list.visual_list[self.list.visual_index] {
VisualItem::Note { summary_idx, .. } => &self.notes[*summary_idx].folder,
VisualItem::CreateNew { path, .. } => path,
_ => unreachable!(),
};
if let Some(idx) = self.list.visual_list.iter().position(|v| {
if let VisualItem::Folder { path: p, .. } = v {
p == item_path
} else {
false
}
}) {
self.list.visual_index = idx;
}
}
}
}
pub fn expand_selected_folder(&mut self) {
if self.list.visual_list.is_empty() {
return;
}
if self.list.visual_index >= self.list.visual_list.len() {
self.list.visual_index = self.list.visual_list.len().saturating_sub(1);
}
match &self.list.visual_list[self.list.visual_index] {
VisualItem::Folder {
path, is_expanded, ..
} => {
if !is_expanded {
self.list.folder_expanded.insert(path.clone());
self.refresh_visual_list();
} else if self.list.visual_index + 1 < self.list.visual_list.len() {
self.list.visual_index += 1;
}
}
VisualItem::Note { .. } | VisualItem::CreateNew { .. } => {
self.open_selected();
}
}
}
pub fn open_note_by_title(&mut self, title: &str) -> bool {
let query = title.trim();
if query.is_empty() {
return false;
}
if let Some(index) = self
.notes
.iter()
.position(|note| note.title.eq_ignore_ascii_case(query))
&& let Some(v_idx) = self.list.visual_list.iter().position(|v| match v {
VisualItem::Note { summary_idx, .. } => *summary_idx == index,
_ => false,
})
{
self.list.visual_index = v_idx;
self.open_selected();
return true;
}
false
}
pub fn start_new_note(&mut self, folder: String) {
let template_manager = self.storage.template_manager();
if let Some(default_template) = template_manager.load_default() {
self.start_note_from_template(&default_template, folder);
} else {
self.start_blank_note(folder);
}
}
pub fn start_new_note_with_title(&mut self, folder: String, title: String) {
let template_manager = self.storage.template_manager();
if let Some(default_template) = template_manager.load_default() {
self.start_note_from_template_with_title(&default_template, folder, title);
} else {
self.start_blank_note_with_title(folder, title);
}
}
pub fn start_blank_note(&mut self, folder: String) {
let mut new_id = self.storage.new_note_id();
if !folder.is_empty() {
new_id = format!("{folder}/{new_id}");
}
if self.editor.external_editor_enabled {
let new_note = Note {
title: String::from("Untitled note"),
content: String::new(),
updated_at: now_unix_secs(),
tags: Vec::new(),
};
if self.storage.save_note(&new_id, &new_note).is_ok() {
if let Err(e) = self.try_auto_backup(&new_note.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.open_note_in_external_editor(&new_id, None);
}
return;
}
self.mode = ViewMode::Edit;
self.editor.editing_id = Some(new_id);
self.editor.title_editor =
make_title_editor("", self.app_theme.highlight_fg, self.app_theme.highlight_bg);
self.editor.editor = TextArea::default();
self.editor.editor.set_cursor_style(
Style::default()
.fg(self.app_theme.highlight_fg)
.bg(self.app_theme.highlight_bg),
);
self.editor.editor.set_cursor_line_style(Style::default());
self.set_default_status();
}
pub fn start_blank_note_with_title(&mut self, folder: String, title: String) {
let mut new_id = self.storage.new_note_id();
if !folder.is_empty() {
new_id = format!("{folder}/{new_id}");
}
if self.editor.external_editor_enabled {
let new_note = Note {
title,
content: String::new(),
updated_at: now_unix_secs(),
tags: Vec::new(),
};
if self.storage.save_note(&new_id, &new_note).is_ok() {
if let Err(e) = self.try_auto_backup(&new_note.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.open_note_in_external_editor(&new_id, None);
}
return;
}
self.mode = ViewMode::Edit;
self.editor.editing_id = Some(new_id);
self.editor.title_editor = make_title_editor(
&title,
self.app_theme.highlight_fg,
self.app_theme.highlight_bg,
);
self.editor.editor = TextArea::default();
self.editor.editor.set_cursor_style(
Style::default()
.fg(self.app_theme.highlight_fg)
.bg(self.app_theme.highlight_bg),
);
self.editor.editor.set_cursor_line_style(Style::default());
self.set_default_status();
}
pub fn start_note_from_template(&mut self, template: &Template, folder: String) {
let rendered = template.render();
let mut new_id = self.storage.new_note_id();
if !folder.is_empty() {
new_id = format!("{folder}/{new_id}");
}
if self.editor.external_editor_enabled {
let new_note = Note {
title: rendered
.title
.clone()
.unwrap_or_else(|| String::from("Untitled note")),
content: rendered.content.clone(),
updated_at: now_unix_secs(),
tags: Vec::new(),
};
if self.storage.save_note(&new_id, &new_note).is_ok() {
if let Err(e) = self.try_auto_backup(&new_note.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.open_note_in_external_editor(&new_id, None);
}
return;
}
self.mode = ViewMode::Edit;
self.editor.editing_id = Some(new_id);
self.editor.title_editor = make_title_editor(
rendered.title.as_deref().unwrap_or(""),
self.app_theme.highlight_fg,
self.app_theme.highlight_bg,
);
self.editor.editor = text_area_from_content(&rendered.content);
self.editor.editor.set_cursor_style(
Style::default()
.fg(self.app_theme.highlight_fg)
.bg(self.app_theme.highlight_bg),
);
self.editor.editor.set_cursor_line_style(Style::default());
self.set_default_status();
}
pub fn start_note_from_template_with_title(
&mut self,
template: &Template,
folder: String,
title: String,
) {
let rendered = template.render();
let mut new_id = self.storage.new_note_id();
if !folder.is_empty() {
new_id = format!("{folder}/{new_id}");
}
if self.editor.external_editor_enabled {
let new_note = Note {
title,
content: rendered.content.clone(),
updated_at: now_unix_secs(),
tags: Vec::new(),
};
if self.storage.save_note(&new_id, &new_note).is_ok() {
if let Err(e) = self.try_auto_backup(&new_note.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.open_note_in_external_editor(&new_id, None);
}
return;
}
self.mode = ViewMode::Edit;
self.editor.editing_id = Some(new_id);
self.editor.title_editor = make_title_editor(
&title,
self.app_theme.highlight_fg,
self.app_theme.highlight_bg,
);
self.editor.editor = text_area_from_content(&rendered.content);
self.editor.editor.set_cursor_style(
Style::default()
.fg(self.app_theme.highlight_fg)
.bg(self.app_theme.highlight_bg),
);
self.editor.editor.set_cursor_line_style(Style::default());
self.set_default_status();
}
pub fn open_template_popup(&mut self) {
let template_manager = self.storage.template_manager();
match template_manager.list() {
Ok(templates) => {
let mut input = TextArea::default();
input.set_style(self.app_theme.bg_style());
input.set_cursor_line_style(Style::default());
input.set_placeholder_text("Search templates...");
self.popups.template = Some(TemplatePopup {
all_templates: templates.clone(),
filtered_templates: templates,
input,
selected: 0,
focus: crate::popups::TemplatePopupFocus::Search,
});
}
Err(_) => {
self.set_temporary_status_static("Failed to load templates");
}
}
}
pub fn close_template_popup(&mut self) {
self.popups.template = None;
}
pub fn select_template(&mut self) {
let folder = if self.list.notes_layout == crate::config::NotesLayout::Grid {
self.list.grid_folder.clone()
} else {
self.get_current_folder_context()
};
if let Some(popup) = self.popups.template.take()
&& let Some(summary) = popup.filtered_templates.get(popup.selected)
{
let template_manager = self.storage.template_manager();
if let Ok(template) = template_manager.load(&summary.filename) {
self.start_note_from_template(&template, folder);
} else {
self.set_temporary_status_static("Failed to load selected template");
}
}
}
pub fn edit_selected_template_from_popup(&mut self) {
let path = if let Some(popup) = self.popups.template.as_ref()
&& let Some(summary) = popup.filtered_templates.get(popup.selected)
{
self.storage
.template_manager()
.template_path(&summary.filename)
} else {
self.set_temporary_status_static("No template selected");
return;
};
self.popups.template = None;
self.open_template_path_in_editor(&path);
}
fn open_template_path_in_editor(&mut self, path: &std::path::Path) {
if self.editor.external_editor_enabled {
self.open_path_in_external_editor(path);
return;
}
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
self.set_temporary_status(&format!("Failed to load template: {e}"));
return;
}
};
self.mode = ViewMode::Edit;
self.editor.editing_id = None;
self.editor.template_edit_path = Some(path.to_path_buf());
self.editor.title_editor = make_title_editor(
&format!(
"Template: {}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("template")
),
self.app_theme.highlight_fg,
self.app_theme.highlight_bg,
);
self.editor.editor = text_area_from_content(&content);
self.editor.editor.set_cursor_style(
Style::default()
.fg(self.app_theme.highlight_fg)
.bg(self.app_theme.highlight_bg),
);
self.editor.editor.set_cursor_line_style(Style::default());
self.set_temporary_status_static("Editing template (Esc to save and return)");
}
pub fn update_template_popup_filter(&mut self) {
if let Some(popup) = &mut self.popups.template {
let query = popup.input.lines()[0].trim().to_lowercase();
if query.is_empty() {
popup.filtered_templates = popup.all_templates.clone();
} else {
popup.filtered_templates = popup
.all_templates
.iter()
.filter(|t| {
t.name.to_lowercase().contains(&query)
|| t.filename.to_lowercase().contains(&query)
})
.cloned()
.collect();
}
if popup.selected >= popup.filtered_templates.len() {
popup.selected = popup.filtered_templates.len().saturating_sub(1);
}
}
}
pub fn begin_delete_selected_template_from_popup(&mut self) {
let (filename, name) = if let Some(popup) = self.popups.template.as_ref()
&& let Some(summary) = popup.filtered_templates.get(popup.selected)
{
(summary.filename.clone(), summary.name.clone())
} else {
self.set_temporary_status_static("No template selected");
return;
};
self.show_confirm(ConfirmAction::DeleteTemplate { filename, name });
}
pub fn confirm_delete_template(&mut self, filename: String) {
let template_manager = self.storage.template_manager();
match template_manager.delete(&filename) {
Ok(()) => {
if let Some(popup) = &mut self.popups.template {
let selected = popup.selected;
let focus = popup.focus;
match template_manager.list() {
Ok(all_templates) => {
popup.all_templates = all_templates;
popup.focus = focus;
self.update_template_popup_filter();
if let Some(popup) = &mut self.popups.template {
if popup.filtered_templates.is_empty() {
popup.selected = 0;
} else {
popup.selected =
selected.min(popup.filtered_templates.len() - 1);
}
}
self.set_temporary_status_static("Template deleted");
}
Err(e) => {
self.set_temporary_status(&format!(
"Template deleted but refresh failed: {e}"
));
}
}
}
}
Err(e) => {
self.set_temporary_status(&format!("Failed to delete template: {e}"));
}
}
}
pub fn create_template_from_popup(&mut self) {
let template_manager = self.storage.template_manager();
if let Err(e) = template_manager.ensure_dir() {
self.set_temporary_status(&format!("Failed to prepare templates dir: {e}"));
return;
}
let mut idx = 1usize;
let filename = loop {
let candidate = if idx == 1 {
"new_template".to_string()
} else {
format!("new_template_{idx}")
};
let path = template_manager.template_path(&candidate);
if !path.exists() {
break candidate;
}
idx += 1;
};
let path = template_manager.template_path(&filename);
let skeleton = r#"name = "New Template"
[title]
template = "Note - {date}"
[content]
template = """
"""
"#;
if let Err(e) = std::fs::write(&path, skeleton) {
self.set_temporary_status(&format!("Failed to create template: {e}"));
return;
}
self.popups.template = None;
self.open_template_path_in_editor(&path);
}
fn open_path_in_external_editor(&mut self, path: &std::path::Path) {
let (result, editor_prog) =
self.run_in_external_editor(&[path.to_string_lossy().into_owned()]);
match result {
Ok(status) if status.success() => {
self.set_temporary_status_static("External editor closed");
}
Ok(status) => {
self.set_temporary_status(&format!(
"Editor '{editor_prog}' exited with status: {status}"
));
}
Err(e) => {
self.set_temporary_status(&format!("Failed to launch editor '{editor_prog}': {e}"));
}
}
}
pub fn try_auto_backup(&self, note_title: &str) -> Result<()> {
let config = ClinConfig::load()?;
if config.backup.enabled && config.backup.backup_on_save {
let vault_path = config
.effective_storage_path()
.unwrap_or_else(|_| PathBuf::from("."));
let git_ops = crate::backup::git_ops::GitOps::init(&vault_path)?;
if git_ops.has_changes().unwrap_or(false) {
let msg = format!("auto: {note_title}");
git_ops.add_all().and_then(|_| git_ops.commit(&msg))?;
if config.backup.auto_push
&& let Some(remote) = &config.backup.remote_name
{
git_ops.push(remote)?;
}
}
}
Ok(())
}
pub fn try_auto_backup_on_quit(&self) -> Result<()> {
let config = ClinConfig::load()?;
if config.backup.enabled && config.backup.backup_on_quit {
let vault_path = config
.effective_storage_path()
.unwrap_or_else(|_| PathBuf::from("."));
let git_ops = crate::backup::git_ops::GitOps::init(&vault_path)?;
if git_ops.has_changes().unwrap_or(false) {
let msg = "auto: backup on quit";
git_ops.add_all().and_then(|_| git_ops.commit(msg))?;
if config.backup.auto_push
&& let Some(remote) = &config.backup.remote_name
{
git_ops.push(remote)?;
}
}
}
Ok(())
}
pub fn autosave(&mut self) {
let content = self.editor.editor.lines().join("\n");
if let Some(path) = &self.editor.template_edit_path
&& self.editor.editing_id.is_none()
{
if let Err(e) = std::fs::write(path, content) {
self.set_temporary_status(&format!("Template save failed: {e}"));
}
return;
}
let mut title = get_title_text(&self.editor.title_editor).trim().to_string();
if title.is_empty() {
title = String::from("Untitled note");
}
let id = match &self.editor.editing_id {
Some(id) => id.clone(),
None => return,
};
if id.ends_with(".clin") {
return;
}
let (updated_at, tags) = self
.storage
.load_note(&id)
.map(|n| (n.updated_at, n.tags))
.unwrap_or_else(|_| (now_unix_secs(), Vec::new()));
let note = Note {
title,
content,
updated_at,
tags,
};
if let Ok(saved_id) = self.storage.save_note(&id, ¬e) {
self.editor.editing_id = Some(saved_id);
if let Err(e) = self.try_auto_backup(¬e.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
}
}
pub fn back_to_list(&mut self) {
if let Some(return_to) = self.return_mode.take() {
self.editor.editing_id = None;
self.editor.template_edit_path = None;
self.editor.title_editor =
make_title_editor("", self.app_theme.highlight_fg, self.app_theme.highlight_bg);
self.editor.editor = TextArea::default();
self.popups.confirm = None;
self.editor.md_preview_renderer = None;
self.mode = return_to;
self.set_default_status();
return;
}
self.mode = ViewMode::List;
self.editor.editing_id = None;
self.editor.template_edit_path = None;
self.editor.title_editor =
make_title_editor("", self.app_theme.highlight_fg, self.app_theme.highlight_bg);
self.editor.editor = TextArea::default();
self.popups.confirm = None;
self.editor.md_preview_renderer = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_default_status();
}
pub fn handle_menu_action(&mut self, action: usize, focus: &mut EditFocus) {
match action {
0 => match focus {
EditFocus::Title => {
self.editor.title_editor.copy();
}
EditFocus::Body => {
self.editor.editor.copy();
}
},
1 => match focus {
EditFocus::Title => {
self.editor.title_editor.cut();
}
EditFocus::Body => {
self.editor.editor.cut();
}
},
2 => match focus {
EditFocus::Title => {
self.editor.title_editor.paste();
}
EditFocus::Body => {
self.editor.editor.paste();
}
},
3 => match focus {
EditFocus::Title => {
self.editor.title_editor.select_all();
}
EditFocus::Body => {
self.editor.editor.select_all();
}
},
_ => {}
}
}
pub fn begin_delete_selected(&mut self) {
if !self.list.selected_indices.is_empty() {
let mut note_ids = Vec::new();
for &idx in &self.list.selected_indices {
if let Some(VisualItem::Note { summary_idx, .. }) = self.list.visual_list.get(idx) {
note_ids.push(self.notes[*summary_idx].id.clone());
}
}
if !note_ids.is_empty() {
if self.confirm_on_delete {
self.show_confirm(ConfirmAction::BulkDeleteNotes { note_ids });
} else {
self.confirm_bulk_delete(note_ids);
}
return;
}
}
if self.list.visual_index >= self.list.visual_list.len() {
self.set_temporary_status_static("No item selected to delete");
return;
}
match &self.list.visual_list[self.list.visual_index] {
VisualItem::Note { summary_idx, .. } => {
if let Some(note) = self.notes.get(*summary_idx) {
let note_id = note.id.clone();
let title = note.title.clone();
if self.confirm_on_delete {
self.show_confirm(ConfirmAction::DeleteNote { note_id, title });
} else {
self.confirm_delete_selected(note_id);
}
}
}
VisualItem::Folder { path, .. } => {
if path.is_empty() {
self.set_temporary_status_static("Cannot delete Vault root");
return;
}
if Self::is_virtual_pinned_path(path) {
self.set_temporary_status_static("Cannot delete virtual Pinned folder");
return;
}
let path = path.clone();
if self.confirm_on_delete {
self.show_confirm(ConfirmAction::DeleteFolder { path });
} else {
self.confirm_delete_folder(path);
}
}
_ => {
self.set_temporary_status_static("Cannot delete this item");
}
}
}
pub fn show_confirm(&mut self, action: ConfirmAction) {
let (title, message, detail, confirm_label, is_destructive) = match &action {
ConfirmAction::DeleteNote { title, .. } => (
"Move to Trash".into(),
format!("Move \"{title}\" to trash?"),
Some("Use Shift+T to view/restore trashed notes.".into()),
"Trash".into(),
false,
),
ConfirmAction::DeleteFolder { path } => (
"Move Folder to Trash".into(),
format!("Move folder \"{path}\" and all contents to trash?"),
Some("Use Shift+T to view/restore trashed notes.".into()),
"Trash".into(),
false,
),
ConfirmAction::DeleteTag { tag } => (
"Confirm Delete Tag".into(),
format!("Delete tag \"{tag}\"?"),
Some("This will remove the tag from all notes.".into()),
"Delete".into(),
true,
),
ConfirmAction::DeleteTemplate { name, .. } => (
"Delete Template".into(),
format!("Delete template \"{name}\"?"),
Some("This removes template file permanently.".into()),
"Delete".into(),
true,
),
ConfirmAction::DeleteFromTrash { item } => (
"Confirm Permanent Delete".into(),
format!("Permanently delete \"{}\"?", item.name.to_string_lossy()),
Some("This action cannot be undone.".into()),
"Delete Forever".into(),
true,
),
ConfirmAction::EmptyTrash { items } => (
"Confirm Empty Trash".into(),
format!("Permanently delete {} note(s)?", items.len()),
Some("This action cannot be undone.".into()),
"Empty Trash".into(),
true,
),
ConfirmAction::BulkDeleteNotes { note_ids } => (
"Move to Trash".into(),
format!("Move {} selected note(s) to trash?", note_ids.len()),
Some("Use Shift+T to view/restore trashed notes.".into()),
"Trash".into(),
false,
),
ConfirmAction::QuitApp => (
"Exit Application".into(),
"Are you sure you want to quit?".into(),
None,
"Quit".into(),
false,
),
};
self.popups.confirm = Some(ConfirmPopup {
action,
title,
message,
detail,
confirm_label,
is_destructive,
selected_button: 1,
});
}
pub fn confirm_action(&mut self) {
if let Some(popup) = self.popups.confirm.take() {
match popup.action {
ConfirmAction::DeleteNote { note_id, .. } => {
self.confirm_delete_selected(note_id);
}
ConfirmAction::DeleteFolder { path } => {
self.confirm_delete_folder(path);
}
ConfirmAction::DeleteTag { tag } => {
self.confirm_delete_tag(tag);
}
ConfirmAction::DeleteTemplate { filename, .. } => {
self.confirm_delete_template(filename);
}
ConfirmAction::DeleteFromTrash { item } => {
self.confirm_delete_from_trash(item);
}
ConfirmAction::EmptyTrash { items } => {
self.confirm_empty_trash(items);
}
ConfirmAction::BulkDeleteNotes { note_ids } => {
self.confirm_bulk_delete(note_ids);
}
ConfirmAction::QuitApp => {
self.should_quit = true;
}
}
}
}
pub fn cancel_confirm(&mut self) {
self.popups.confirm = None;
}
pub fn confirm_popup_select_confirm(&mut self) {
if let Some(popup) = &mut self.popups.confirm {
popup.selected_button = 0;
}
}
pub fn confirm_popup_select_cancel(&mut self) {
if let Some(popup) = &mut self.popups.confirm {
popup.selected_button = 1;
}
}
pub fn confirm_popup_toggle_button(&mut self) {
if let Some(popup) = &mut self.popups.confirm {
popup.selected_button = (popup.selected_button + 1) % 2;
}
}
pub fn confirm_popup_activate(&mut self) {
let is_confirm = self
.popups
.confirm
.as_ref()
.map(|p| p.selected_button == 0)
.unwrap_or(false);
if is_confirm {
self.confirm_action();
} else {
self.cancel_confirm();
}
}
pub fn confirm_delete_selected(&mut self, id: String) {
match self.storage.trash_note(&id) {
Ok(()) => {
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
if self.list.visual_index >= self.list.visual_list.len()
&& !self.list.visual_list.is_empty()
{
self.list.visual_index = self.list.visual_list.len() - 1;
}
self.set_temporary_status_static("Note moved to trash");
}
Err(err) => {
self.set_temporary_status(&format!("Move to trash failed: {err:#}"));
}
}
}
pub fn confirm_delete_folder(&mut self, path: String) {
match self.storage.trash_folder(&path) {
Ok(()) => {
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
if self.list.visual_index >= self.list.visual_list.len()
&& !self.list.visual_list.is_empty()
{
self.list.visual_index = self.list.visual_list.len() - 1;
}
self.set_temporary_status_static("Folder moved to trash");
}
Err(e) => {
self.set_temporary_status(&format!("Failed to trash folder: {e}"));
}
}
}
pub fn begin_create_folder(&mut self) {
let parent_path = if self.list.notes_layout == crate::config::NotesLayout::Grid {
self.list.grid_folder.clone()
} else {
self.get_current_folder_context()
};
if Self::is_virtual_pinned_path(&parent_path) {
self.set_temporary_status_static("Cannot create folder inside virtual Pinned");
return;
}
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
let title = if parent_path.is_empty() {
"Create Folder - Esc to cancel, Enter to save".to_string()
} else {
format!("Create Folder in '{parent_path}' - Esc to cancel, Enter to save")
};
input.set_style(self.app_theme.bg_style());
input.set_block(
ratatui::widgets::Block::default()
.style(self.app_theme.bg_style())
.borders(ratatui::widgets::Borders::ALL)
.title(title),
);
self.popups.folder = Some(FolderPopup {
mode: FolderPopupMode::Create { parent_path },
input,
});
}
pub fn begin_rename_folder(&mut self) {
if let Some(VisualItem::Folder { path, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
if path.is_empty() {
self.set_temporary_status_static("Cannot rename Vault root");
return;
}
if Self::is_virtual_pinned_path(path) {
self.set_temporary_status_static("Cannot rename virtual Pinned folder");
return;
}
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.insert_str(path);
input.set_style(self.app_theme.bg_style());
input.set_block(
ratatui::widgets::Block::default()
.style(self.app_theme.bg_style())
.borders(ratatui::widgets::Borders::ALL)
.title("Rename Folder - Esc to cancel, Enter to save"),
);
self.popups.folder = Some(FolderPopup {
mode: FolderPopupMode::Rename {
old_path: path.clone(),
},
input,
});
} else {
self.set_temporary_status_static("Select a folder to rename");
}
}
pub fn confirm_folder_popup(&mut self) {
if let Some(popup) = self.popups.folder.take() {
let text = popup.input.lines().join("");
let text = text.trim();
if text.is_empty() {
self.set_temporary_status_static("Folder name cannot be empty");
return;
}
match &popup.mode {
FolderPopupMode::Create { parent_path } => {
if Self::is_virtual_pinned_path(parent_path) {
self.set_temporary_status_static(
"Cannot create folder inside virtual Pinned",
);
return;
}
let full_path = if parent_path.is_empty() {
text.to_string()
} else {
format!("{parent_path}/{text}")
};
if let Err(e) = self.storage.create_folder(&full_path) {
self.set_temporary_status(&format!("Failed to create folder: {e}"));
} else {
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_temporary_status_static("Folder created");
}
}
FolderPopupMode::Rename { old_path } => {
if Self::is_virtual_pinned_path(old_path) {
self.set_temporary_status_static("Cannot rename virtual Pinned folder");
return;
}
if let Err(e) = self.storage.rename_folder(old_path, text) {
self.set_temporary_status(&format!("Failed to rename folder: {e}"));
} else {
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_temporary_status_static("Folder renamed");
}
}
}
}
}
pub fn confirm_bulk_delete(&mut self, note_ids: Vec<String>) {
let mut failed = 0;
for id in note_ids {
if self.storage.trash_note(&id).is_err() {
failed += 1;
}
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.list.selected_indices.clear();
self.list.list_mode = ListMode::Normal;
if failed > 0 {
self.set_temporary_status(&format!("Failed to trash {failed} note(s)"));
} else {
self.set_temporary_status_static("Selected notes moved to trash");
}
}
pub fn begin_move_note(&mut self) {
if let Some(VisualItem::Note { summary_idx, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
let note = &self.notes[*summary_idx];
if let Ok(folders) = self.storage.list_folders() {
let mut all_folders = vec!["".to_string()];
all_folders.extend(folders);
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_placeholder_text("Search folders...");
self.popups.folder_picker = Some(FolderPicker {
mode: FolderPickerMode::MoveNote {
note_id: note.id.clone(),
},
filtered_folders: all_folders.clone(),
all_folders,
selected: 0,
input,
focus: FolderPickerFocus::Search,
});
} else {
self.set_temporary_status_static("Failed to list folders");
}
} else {
self.set_temporary_status_static("Select a note to move");
}
}
pub fn begin_move_folder(&mut self) {
if let Some(VisualItem::Folder { path, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
if Self::is_virtual_pinned_path(path) {
self.set_temporary_status_static("Cannot move virtual Pinned folder");
return;
}
let folder_path = path.clone();
if let Ok(folders) = self.storage.list_folders() {
let mut all_folders = vec!["".to_string()];
all_folders.extend(
folders.into_iter().filter(|f| {
f != &folder_path && !f.starts_with(&format!("{folder_path}/"))
}),
);
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_placeholder_text("Search folders...");
self.popups.folder_picker = Some(FolderPicker {
mode: FolderPickerMode::MoveFolder { folder_path },
filtered_folders: all_folders.clone(),
all_folders,
selected: 0,
input,
focus: FolderPickerFocus::Search,
});
} else {
self.set_temporary_status_static("Failed to list folders");
}
} else {
self.set_temporary_status_static("Select a folder to move");
}
}
pub fn begin_move(&mut self) {
if !self.list.selected_indices.is_empty() {
let mut note_ids = Vec::new();
for &idx in &self.list.selected_indices {
if let Some(VisualItem::Note { summary_idx, .. }) = self.list.visual_list.get(idx) {
note_ids.push(self.notes[*summary_idx].id.clone());
}
}
if !note_ids.is_empty()
&& let Ok(folders) = self.storage.list_folders()
{
let mut all_folders = vec!["".to_string()];
all_folders.extend(folders);
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_placeholder_text("Search folders...");
self.popups.folder_picker = Some(FolderPicker {
mode: FolderPickerMode::BulkMoveNotes { note_ids },
filtered_folders: all_folders.clone(),
all_folders,
selected: 0,
input,
focus: FolderPickerFocus::Search,
});
return;
}
}
match self.list.visual_list.get(self.list.visual_index) {
Some(VisualItem::Note { .. }) => self.begin_move_note(),
Some(VisualItem::Folder { .. }) => self.begin_move_folder(),
_ => self.set_temporary_status_static("Nothing selected"),
}
}
pub fn confirm_move(&mut self) {
if let Some(picker) = self.popups.folder_picker.take()
&& let Some(target_folder) = picker.filtered_folders.get(picker.selected)
{
match picker.mode {
FolderPickerMode::MoveNote { note_id } => {
if let Err(e) = self.storage.move_note(¬e_id, target_folder) {
self.set_temporary_status(&format!("Failed to move note: {e}"));
} else {
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_temporary_status_static("Note moved");
}
}
FolderPickerMode::CopyNote { note_id } => {
if let Err(e) = self.storage.duplicate_note(¬e_id, target_folder) {
self.set_temporary_status(&format!("Failed to copy note: {e}"));
} else {
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_temporary_status_static("Note copied");
}
}
FolderPickerMode::MoveFolder { folder_path } => {
let folder_name = folder_path.rsplit('/').next().unwrap_or(&folder_path);
let new_path = if target_folder.is_empty() {
folder_name.to_string()
} else {
format!("{target_folder}/{folder_name}")
};
if folder_path == new_path {
self.set_temporary_status_static("Folder is already in this location");
return;
}
if let Err(e) = self.storage.rename_folder(&folder_path, &new_path) {
self.set_temporary_status(&format!("Failed to move folder: {e}"));
} else {
if self.list.folder_expanded.remove(&folder_path) {
self.list.folder_expanded.insert(new_path);
}
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_temporary_status_static("Folder moved");
}
}
FolderPickerMode::BulkMoveNotes { note_ids } => {
let mut failed = 0;
for id in note_ids {
if self.storage.move_note(&id, target_folder).is_err() {
failed += 1;
}
}
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.list.selected_indices.clear();
self.list.list_mode = ListMode::Normal;
if failed > 0 {
self.set_temporary_status(&format!("Failed to move {failed} note(s)"));
} else {
self.set_temporary_status_static("Selected notes moved");
}
}
}
}
}
pub fn update_folder_picker_filter(&mut self) {
if let Some(picker) = &mut self.popups.folder_picker {
let query = picker.input.lines().join("").trim().to_lowercase();
if query.is_empty() {
picker.filtered_folders = picker.all_folders.clone();
} else {
picker.filtered_folders = picker
.all_folders
.iter()
.filter(|folder| folder.to_lowercase().contains(&query))
.cloned()
.collect();
}
if picker.selected >= picker.filtered_folders.len() {
picker.selected = picker.filtered_folders.len().saturating_sub(1);
}
}
}
pub fn collect_live_tags(&self) -> Vec<String> {
let mut tags: HashSet<String> = HashSet::new();
for note in &self.notes {
for tag in ¬e.tags {
tags.insert(tag.clone());
}
}
let mut result: Vec<String> = tags.into_iter().collect();
result.sort();
result
}
pub fn begin_manage_tags(&mut self) {
if let Some(VisualItem::Note { summary_idx, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
let note = &self.notes[*summary_idx];
let current_tags = note.tags.clone();
let all_tags = self.collect_live_tags();
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_style(self.app_theme.bg_style());
input.set_placeholder_text("Add tags...");
input.insert_str(current_tags.join(", "));
self.popups.tag = Some(TagPopup {
note_id: note.id.clone(),
input,
all_tags,
suggestions: Vec::new(),
suggestion_index: 0,
focus: crate::popups::TagPopupFocus::Input,
all_tags_selected: 0,
});
self.update_tag_suggestions();
} else {
self.set_temporary_status_static("Select a note to manage tags");
}
}
pub fn confirm_manage_tags(&mut self) {
if let Some(popup) = self.popups.tag.take() {
let text = popup.input.lines().join("");
let tags: Vec<String> = text
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if let Ok(mut note) = self.storage.load_note(&popup.note_id) {
note.tags = tags;
if let Err(e) = self.storage.save_note(&popup.note_id, ¬e) {
self.set_temporary_status(&format!("Failed to save tags: {e}"));
} else {
if let Err(e) = self.try_auto_backup(¬e.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_temporary_status_static("Tags updated");
}
} else {
self.set_temporary_status_static("Failed to load note to update tags");
}
}
}
fn get_current_tag_word(input: &str) -> &str {
input.rsplit(',').next().map(|s| s.trim()).unwrap_or("")
}
pub fn update_tag_suggestions(&mut self) {
if let Some(popup) = &mut self.popups.tag {
let text = popup.input.lines().join("");
let current_word = Self::get_current_tag_word(&text).to_lowercase();
let entered_tags: Vec<String> = text
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if current_word.is_empty() {
popup.suggestions.clear();
} else {
popup.suggestions = popup
.all_tags
.iter()
.filter(|tag| {
let tag_lower = tag.to_lowercase();
tag_lower.starts_with(¤t_word) && !entered_tags.contains(&tag_lower)
})
.cloned()
.collect();
}
popup.suggestion_index = 0;
}
}
pub fn cycle_tag_suggestion(&mut self) {
if let Some(popup) = &mut self.popups.tag
&& !popup.suggestions.is_empty()
{
popup.suggestion_index = (popup.suggestion_index + 1) % popup.suggestions.len();
}
}
pub fn accept_tag_suggestion(&mut self) {
if let Some(popup) = &mut self.popups.tag
&& let Some(suggestion) = popup.suggestions.get(popup.suggestion_index).cloned()
{
let text = popup.input.lines().join("");
if let Some(last_comma) = text.rfind(',') {
let prefix = &text[..=last_comma];
let new_text = format!("{prefix} {suggestion}, ");
popup.input.select_all();
popup.input.cut();
popup.input.insert_str(&new_text);
} else {
popup.input.select_all();
popup.input.cut();
popup.input.insert_str(format!("{suggestion}, "));
}
popup.suggestions.clear();
popup.suggestion_index = 0;
}
}
pub fn begin_delete_tag_with_name(&mut self, tag: String) {
let count = self
.storage
.list_note_ids()
.ok()
.map(|ids| {
ids.iter()
.filter(|id| {
self.storage
.load_note(id)
.ok()
.is_some_and(|n| n.tags.contains(&tag))
})
.count()
})
.unwrap_or(0);
let detail = if count == 1 {
"Will remove tag from 1 note.".to_string()
} else {
format!("Will remove tag from {count} notes.")
};
self.popups.confirm = Some(ConfirmPopup {
action: ConfirmAction::DeleteTag { tag: tag.clone() },
title: "Confirm Delete Tag".into(),
message: format!("Delete tag \"{tag}\"?"),
detail: Some(detail),
confirm_label: "Delete".into(),
is_destructive: true,
selected_button: 1,
});
}
pub fn confirm_delete_tag(&mut self, tag: String) {
let mut count = 0;
if let Ok(note_ids) = self.storage.list_note_ids() {
for note_id in note_ids {
if let Ok(mut note) = self.storage.load_note(¬e_id)
&& note.tags.contains(&tag)
{
note.tags.retain(|t| t != &tag);
if self.storage.save_note(¬e_id, ¬e).is_ok()
&& let Err(e) = self.try_auto_backup(¬e.title)
{
self.set_temporary_status(&format!("Backup failed: {e}"));
}
count += 1;
}
}
}
self.set_temporary_status(&format!("Deleted '{tag}' from {count} note(s)"));
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
let live_tags = self.collect_live_tags();
if let Some(popup) = &mut self.popups.tag {
popup.all_tags = live_tags;
let text = popup.input.lines().join("");
let entered_tags: Vec<String> = text
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && s != &tag)
.collect();
let new_text = if entered_tags.is_empty() {
String::new()
} else {
format!("{}, ", entered_tags.join(", "))
};
popup.input.select_all();
popup.input.cut();
popup.input.insert_str(&new_text);
}
self.update_tag_suggestions();
}
pub fn apply_tag_to_selected(&mut self, tag: String) {
let mut count = 0;
let indices: Vec<usize> = self.list.selected_indices.iter().copied().collect();
for &idx in &indices {
if let crate::list_view::VisualItem::Note { summary_idx, .. } = self
.list
.visual_list
.get(idx)
.unwrap_or(&crate::list_view::VisualItem::CreateNew {
path: String::new(),
depth: 0,
})
{
let note = &self.notes[*summary_idx];
let note_id = note.id.clone();
if let Ok(mut loaded) = self.storage.load_note(¬e_id) {
if !loaded.tags.contains(&tag) {
loaded.tags.push(tag.clone());
}
if self.storage.save_note(¬e_id, &loaded).is_ok() {
if let Err(e) = self.try_auto_backup(&loaded.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
count += 1;
}
}
}
}
self.list.selected_indices.clear();
self.list.list_mode = crate::list_view::ListMode::Normal;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
return;
}
self.set_temporary_status(&format!("Tag '{tag}' applied to {count} note(s)"));
}
pub fn open_selected_note_location(&mut self) {
if self.list.visual_index >= self.list.visual_list.len() {
self.set_temporary_status_static("No note selected for location");
return;
}
let summary_idx = match &self.list.visual_list[self.list.visual_index] {
VisualItem::Note { summary_idx, .. } => *summary_idx,
_ => {
self.set_temporary_status_static("Selected item is not a note");
return;
}
};
let Some(note) = self.notes.get(summary_idx) else {
self.set_temporary_status_static("No note selected for location");
return;
};
let note_path = self.storage.note_path(¬e.id);
let Some(parent) = note_path.parent() else {
self.set_temporary_status_static("Could not determine note directory");
return;
};
match open_in_file_manager(parent) {
Ok(()) => self.set_temporary_status_static("Opened note file location"),
Err(err) => self.set_temporary_status(&format!("Open location failed: {err:#}")),
}
}
pub fn toggle_external_editor_mode(&mut self) {
self.editor.external_editor_enabled = !self.editor.external_editor_enabled;
let msg = if self.editor.external_editor_enabled {
"External editor mode enabled"
} else {
"External editor mode disabled"
};
self.set_temporary_status(msg);
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.external_editor_enabled = self.editor.external_editor_enabled;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn open_help_page(&mut self) {
self.open_help_page_with_tab(HelpTab::Notes);
}
pub fn open_help_page_with_tab(&mut self, tab: HelpTab) {
self.mode = ViewMode::Help;
self.help_tab = tab;
self.help_scroll = 0;
self.help_tab_scroll.insert(tab, 0);
self.status = Cow::Borrowed(HELP_PAGE_HINTS);
self.status_until = None;
self.list.help_text_cache = None;
}
pub fn close_help_page(&mut self) {
self.mode = self.return_mode.take().unwrap_or(ViewMode::List);
self.help_scroll = 0;
self.help_tab = HelpTab::Notes;
self.help_tab_scroll.clear();
self.list.help_text_cache = None;
self.set_default_status();
}
pub fn open_graph_view(&mut self) {
self.return_mode = Some(self.mode);
self.mode = ViewMode::Graph;
}
pub fn open_content_tree_view(&mut self) {
self.return_mode = Some(self.mode);
self.mode = ViewMode::ContentTree;
}
pub fn open_backup_view(&mut self) {
self.return_mode = Some(self.mode);
self.mode = ViewMode::Backup;
}
pub fn open_draw_view(&mut self) {
self.return_mode = Some(self.mode);
self.mode = ViewMode::Draw;
}
pub fn close_draw_view(&mut self) {
self.editor.editing_id = None;
self.mode = self.return_mode.take().unwrap_or(ViewMode::List);
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_default_status();
}
pub fn open_canvas_view(&mut self) {
if let Some(VisualItem::Note { summary_idx, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
let path = self.storage.note_path(&self.notes[*summary_idx].id);
if let Ok(state) = crate::pinstar::state::PinstarState::load(&path) {
self.canvas_state = Some(state);
self.return_mode = Some(self.mode);
self.mode = ViewMode::Canvas;
self.editor.editing_id = Some(self.notes[*summary_idx].id.clone());
self.set_default_status();
} else {
self.set_temporary_status_static("Failed to load .canvas file!");
}
}
}
pub fn close_canvas_view(&mut self) {
self.editor.editing_id = None;
self.canvas_state = None;
self.mode = self.return_mode.take().unwrap_or(ViewMode::List);
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_default_status();
}
pub fn get_selected_note_id(&self) -> Option<String> {
if let Some(id) = &self.editor.editing_id {
return Some(id.clone());
}
if let Some(VisualItem::Note { summary_idx, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
Some(self.notes[*summary_idx].id.clone())
} else {
None
}
}
pub fn open_note_from_graph(&mut self, note_id: &str) {
if note_id.ends_with(".clin") {
self.set_temporary_status_static("Cannot open encrypted notes. Decrypt first.");
return;
}
self.return_mode = Some(ViewMode::Graph);
if self.editor.external_editor_enabled {
self.open_note_in_external_editor(note_id, None);
self.mode = self.return_mode.take().unwrap_or(ViewMode::List);
} else {
self.load_and_open_note(note_id, None);
}
}
pub fn default_status_text(&self) -> Cow<'static, str> {
match self.mode {
ViewMode::List => Cow::Borrowed(LIST_HELP_HINTS),
ViewMode::Edit => Cow::Borrowed(EDIT_HELP_HINTS),
ViewMode::Help => Cow::Borrowed(HELP_PAGE_HINTS),
ViewMode::Graph => Cow::Borrowed(GRAPH_HELP_HINTS),
ViewMode::Draw => Cow::Borrowed(DRAW_HELP_HINTS),
ViewMode::Canvas => Cow::Borrowed(CANVAS_HELP_HINTS),
ViewMode::Backup => Cow::Borrowed(BACKUP_HELP_HINTS),
ViewMode::ContentTree => Cow::Borrowed(crate::constants::CONTENT_TREE_HELP_HINTS),
}
}
pub fn set_default_status(&mut self) {
self.status = self.default_status_text();
self.status_until = None;
}
pub fn set_temporary_status(&mut self, message: &str) {
self.status = Cow::Owned(message.to_string());
self.status_until = Some(Instant::now() + Duration::from_secs(2));
}
pub fn set_temporary_status_static(&mut self, message: &'static str) {
self.status = Cow::Borrowed(message);
self.status_until = Some(Instant::now() + Duration::from_secs(2));
}
pub fn tick_status(&mut self) {
if let Some(until) = self.status_until
&& Instant::now() >= until
{
self.set_default_status();
}
}
pub fn switch_help_tab(&mut self, tab: HelpTab) {
if tab == self.help_tab {
return;
}
let current_scroll = self.help_scroll;
self.help_tab_scroll.insert(self.help_tab, current_scroll);
self.help_tab = tab;
self.help_scroll = self.help_tab_scroll.get(&tab).copied().unwrap_or(0);
self.list.help_text_cache = None;
}
pub fn get_help_text(&mut self) -> &Text<'static> {
let tab = self.help_tab;
if self.list.help_text_cache.is_none() {
self.list.help_text_cache = Some(crate::ui::help_text_for_tab(
tab,
&self.keybinds,
&self.app_theme,
));
}
self.list.help_text_cache.as_ref().unwrap()
}
pub fn begin_create_note(&mut self) {
let folder = if self.list.notes_layout == crate::config::NotesLayout::Grid {
self.list.grid_folder.clone()
} else {
self.get_current_folder_context()
};
self.begin_create_note_in_folder(folder);
}
pub fn begin_create_note_in_folder(&mut self, folder: String) {
if Self::is_virtual_pinned_path(&folder) {
self.set_temporary_status_static("Cannot create note inside virtual Pinned");
return;
}
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_style(self.app_theme.bg_style());
input.set_block(
ratatui::widgets::Block::default()
.style(self.app_theme.bg_style())
.borders(ratatui::widgets::Borders::ALL)
.title("New Note Name - Esc to cancel, Enter to create"),
);
self.popups.note_create = Some(NoteCreatePopup { folder, input });
}
pub fn confirm_create_note(&mut self) {
if let Some(popup) = self.popups.note_create.take() {
let mut title = popup.input.lines().join("");
title = title.trim().to_string();
if title.is_empty() {
title = String::from("Untitled note");
}
self.start_new_note_with_title(popup.folder, title);
}
}
pub fn begin_import(
&mut self,
source: ImportSource,
target: ImportTarget,
folder: String,
note_id: Option<String>,
) {
if target == ImportTarget::NewNote && Self::is_virtual_pinned_path(&folder) {
self.set_temporary_status_static("Cannot create note inside virtual Pinned");
return;
}
let prompt = match source {
ImportSource::File => "File path - Esc cancel, Enter import",
ImportSource::Csv => "CSV/TSV file path - Esc cancel, Enter import",
ImportSource::Json => "JSON file path - Esc cancel, Enter import",
ImportSource::Url => "URL - Esc cancel, Enter import",
ImportSource::Clipboard => "Clipboard - Esc cancel, Enter import",
};
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_style(self.app_theme.bg_style());
input.set_block(
ratatui::widgets::Block::default()
.style(self.app_theme.bg_style())
.borders(ratatui::widgets::Borders::ALL)
.title(prompt),
);
self.popups.import = Some(ImportPopup {
source,
target,
folder,
note_id,
input,
});
}
pub fn confirm_import(&mut self) {
let Some(popup) = self.popups.import.take() else {
return;
};
let input = popup.input.lines().join("").trim().to_string();
if input.is_empty() {
self.set_temporary_status_static("No path/URL entered");
return;
}
use crate::actions::import::*;
let result = match popup.source {
ImportSource::File => convert_file(&input),
ImportSource::Csv => convert_csv(&input),
ImportSource::Json => convert_json(&input),
ImportSource::Url => convert_url(&input),
ImportSource::Clipboard => unreachable!(),
};
match result {
Ok((title, md)) => {
if let Err(e) =
self.insert_content(popup.target, popup.note_id.as_deref(), title, md)
{
self.set_temporary_status(&format!("Import failed: {e:#}"));
}
}
Err(e) => {
self.set_temporary_status(&format!("Import failed: {e:#}"));
}
}
}
pub fn insert_content(
&mut self,
target: ImportTarget,
note_id: Option<&str>,
title: String,
content: String,
) -> Result<()> {
match target {
ImportTarget::NewNote => {
let folder = self.get_current_folder_context();
self.start_note_with_content(folder, title, content);
}
ImportTarget::AppendCurrent => {
let id = note_id.context("No note selected")?;
let mut note = self.storage.load_note(id)?;
note.content.push_str("\n\n");
note.content.push_str(&content);
note.updated_at = now_unix_secs();
self.storage.save_note(id, ¬e)?;
self.try_auto_backup(¬e.title)?;
self.refresh_notes()?;
self.set_temporary_status_static("Content appended");
}
}
Ok(())
}
pub fn start_note_with_content(&mut self, folder: String, title: String, content: String) {
if Self::is_virtual_pinned_path(&folder) {
self.set_temporary_status_static("Cannot create note inside virtual Pinned");
return;
}
let mut new_id = self.storage.new_note_id();
if !folder.is_empty() {
new_id = format!("{folder}/{new_id}");
}
if self.editor.external_editor_enabled {
let note = Note {
title,
content,
updated_at: now_unix_secs(),
tags: vec![],
};
if self.storage.save_note(&new_id, ¬e).is_ok() {
if let Err(e) = self.try_auto_backup(¬e.title) {
self.set_temporary_status(&format!("Backup failed: {e}"));
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.open_note_in_external_editor(&new_id, None);
}
return;
}
self.mode = ViewMode::Edit;
self.editor.editing_id = Some(new_id);
self.editor.title_editor = make_title_editor(
&title,
self.app_theme.highlight_fg,
self.app_theme.highlight_bg,
);
self.editor.editor = text_area_from_content(&content);
self.editor.editor.set_cursor_style(
Style::default()
.fg(self.app_theme.highlight_fg)
.bg(self.app_theme.highlight_bg),
);
self.editor.editor.set_cursor_line_style(Style::default());
self.set_default_status();
}
pub fn begin_create_draw(&mut self) {
let folder = if self.list.notes_layout == crate::config::NotesLayout::Grid {
self.list.grid_folder.clone()
} else {
self.get_current_folder_context()
};
if Self::is_virtual_pinned_path(&folder) {
self.set_temporary_status_static("Cannot create drawing inside virtual Pinned");
return;
}
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_style(self.app_theme.bg_style());
input.set_block(
ratatui::widgets::Block::default()
.style(self.app_theme.bg_style())
.borders(ratatui::widgets::Borders::ALL)
.title("New Drawing Name - Esc to cancel, Enter to create"),
);
self.popups.draw_create = Some(NoteCreatePopup { folder, input });
}
pub fn confirm_create_draw(&mut self) {
if let Some(popup) = self.popups.draw_create.take() {
let mut title = popup.input.lines().join("");
title = title.trim().to_string();
if title.is_empty() {
title = String::from("Untitled drawing");
}
let canvas_id = if popup.folder.is_empty() {
format!("{title}.draw")
} else {
format!("{}/{}.draw", popup.folder, title)
};
self.return_mode = Some(self.mode);
self.mode = ViewMode::Draw;
self.editor.editing_id = Some(canvas_id);
}
}
pub fn begin_create_canvas(&mut self) {
let folder = if self.list.notes_layout == crate::config::NotesLayout::Grid {
self.list.grid_folder.clone()
} else {
self.get_current_folder_context()
};
if Self::is_virtual_pinned_path(&folder) {
self.set_temporary_status_static("Cannot create canvas inside virtual Pinned");
return;
}
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_style(self.app_theme.bg_style());
input.set_block(
ratatui::widgets::Block::default()
.style(self.app_theme.bg_style())
.borders(ratatui::widgets::Borders::ALL)
.title("New Canvas Name - Esc to cancel, Enter to create"),
);
self.popups.canvas_create = Some(NoteCreatePopup { folder, input });
}
pub fn confirm_create_canvas(&mut self) {
if let Some(popup) = self.popups.canvas_create.take() {
let mut title = popup.input.lines().join("");
title = title.trim().to_string();
if title.is_empty() {
title = String::from("Untitled canvas");
}
let canvas_id = if popup.folder.is_empty() {
format!("{title}.canvas")
} else {
format!("{}/{}.canvas", popup.folder, title)
};
let path = self.storage.note_path(&canvas_id);
if !path.exists() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let data = crate::pinstar::data::CanvasData {
nodes: vec![],
edges: vec![],
};
if let Ok(content) = serde_json::to_string_pretty(&data)
&& let Err(e) = std::fs::write(&path, content)
{
self.set_temporary_status(&format!("Failed to write canvas file: {e}"));
return;
}
}
self.return_mode = Some(self.mode);
self.mode = ViewMode::Canvas;
self.editor.editing_id = Some(canvas_id);
if let Ok(state) = crate::pinstar::state::PinstarState::load(&path) {
self.canvas_state = Some(state);
}
self.set_default_status();
}
}
pub fn begin_rename_note(&mut self) {
if let Some(VisualItem::Note { summary_idx, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
let summary_idx = *summary_idx;
let id = self.notes[summary_idx].id.clone();
let note = &self.notes[summary_idx];
let mut input = TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.insert_str(¬e.title);
input.set_style(self.app_theme.bg_style());
input.set_block(
ratatui::widgets::Block::default()
.style(self.app_theme.bg_style())
.borders(ratatui::widgets::Borders::ALL)
.title("Rename Note - Esc to cancel, Enter to save"),
);
self.popups.note_rename = Some(NoteRenamePopup { note_id: id, input });
} else {
self.set_temporary_status_static("Select a note to rename");
}
}
pub fn confirm_rename_note(&mut self) {
if let Some(popup) = self.popups.note_rename.take() {
let new_title = popup.input.lines().join("");
let new_title = new_title.trim();
if new_title.is_empty() {
self.set_temporary_status_static("Title cannot be empty");
return;
}
match self.storage.rename_note(&popup.note_id, new_title) {
Ok(_) => {
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
self.set_temporary_status_static("Note renamed");
}
Err(e) => {
self.set_temporary_status(&format!("Failed to rename: {e}"));
}
}
}
}
pub fn duplicate_note(&mut self) {
if let Some(VisualItem::Note { summary_idx, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
let id = self.notes[*summary_idx].id.clone();
if let Ok(folders) = self.storage.list_folders() {
let mut all_folders = vec!["".to_string()];
all_folders.extend(folders);
let mut input = ratatui_textarea::TextArea::default();
input.set_cursor_line_style(ratatui::style::Style::default());
input.set_placeholder_text("Search folders...");
self.popups.folder_picker = Some(crate::popups::FolderPicker {
mode: crate::popups::FolderPickerMode::CopyNote { note_id: id },
filtered_folders: all_folders.clone(),
all_folders,
selected: 0,
input,
focus: crate::popups::FolderPickerFocus::Search,
});
} else {
self.set_temporary_status_static("Failed to list folders");
}
} else {
self.set_temporary_status_static("Select a note to duplicate");
}
}
pub fn toggle_pin(&mut self) {
if let Some(VisualItem::Note { summary_idx, .. }) =
self.list.visual_list.get(self.list.visual_index)
{
let id = self.notes[*summary_idx].id.clone();
match self.storage.toggle_pin(&id) {
Ok(pinned) => {
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
if pinned {
self.set_temporary_status_static("Note pinned");
} else {
self.set_temporary_status_static("Note unpinned");
}
}
Err(e) => {
self.set_temporary_status(&format!("Failed to toggle pin: {e}"));
}
}
} else {
self.set_temporary_status_static("Select a note to pin/unpin");
}
}
pub fn cycle_sort(&mut self) {
match (self.list.sort_field, self.list.sort_order) {
(SortField::Modified, SortOrder::Descending) => {
self.list.sort_field = SortField::Modified;
self.list.sort_order = SortOrder::Ascending;
}
(SortField::Modified, SortOrder::Ascending) => {
self.list.sort_field = SortField::Title;
self.list.sort_order = SortOrder::Ascending;
}
(SortField::Title, SortOrder::Ascending) => {
self.list.sort_field = SortField::Title;
self.list.sort_order = SortOrder::Descending;
}
(SortField::Title, SortOrder::Descending) => {
self.list.sort_field = SortField::Modified;
self.list.sort_order = SortOrder::Descending;
}
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
let sort_desc = match (self.list.sort_field, self.list.sort_order) {
(SortField::Modified, SortOrder::Descending) => "Sort: Modified (newest)",
(SortField::Modified, SortOrder::Ascending) => "Sort: Modified (oldest)",
(SortField::Title, SortOrder::Ascending) => "Sort: Title (A-Z)",
(SortField::Title, SortOrder::Descending) => "Sort: Title (Z-A)",
};
self.set_temporary_status_static(sort_desc);
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.default_sort_field = Some(self.list.sort_field);
config.default_sort_order = Some(self.list.sort_order);
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn begin_search(&mut self) {
let mut input = TextArea::default();
input.set_style(self.app_theme.bg_style());
input.set_cursor_line_style(Style::default());
input.set_placeholder_text("Search notes...");
self.popups.search = Some(SearchPopup {
input,
focus: crate::popups::SearchFocus::Input,
title_results: Vec::new(),
title_result_indices: Vec::new(),
title_selected: 0,
grep_results: Vec::new(),
grep_result_indices: Vec::new(),
grep_is_header: Vec::new(),
grep_expanded: std::collections::HashSet::new(),
grep_selected: 0,
original_index: self.list.visual_index,
original_folder_expanded: self.list.folder_expanded.clone(),
});
}
fn jump_to_note_index(&mut self, note_idx: usize) {
if let Some(note) = self.notes.get(note_idx)
&& !note.folder.is_empty()
{
let mut path = String::new();
for part in note.folder.split('/') {
if !path.is_empty() {
path.push('/');
}
path.push_str(part);
self.list.folder_expanded.insert(path.clone());
}
}
self.refresh_visual_list();
for (idx, item) in self.list.visual_list.iter().enumerate() {
if let VisualItem::Note { summary_idx, .. } = item
&& *summary_idx == note_idx
{
self.list.visual_index = idx;
self.request_preview_update();
return;
}
}
}
pub fn update_search(&mut self) {
let Some(popup) = self.popups.search.as_ref() else {
return;
};
let query_text = popup.input.lines().join("");
let parsed = parse_search_query(&query_text);
let title_query = parsed.text.trim().to_lowercase();
let grep_query = parsed.grep_text.trim().to_lowercase();
let no_filters = title_query.is_empty()
&& grep_query.is_empty()
&& parsed.folder_filter.is_none()
&& !parsed.pinned_only
&& parsed.tag_filter.is_none();
if no_filters {
if let Some(popup) = &mut self.popups.search {
popup.title_results.clear();
popup.title_result_indices.clear();
popup.title_selected = 0;
popup.grep_results.clear();
popup.grep_result_indices.clear();
popup.grep_is_header.clear();
popup.grep_expanded.clear();
popup.grep_selected = 0;
}
return;
}
let mut title_results = Vec::new();
let mut title_result_indices = Vec::new();
let mut grep_results = Vec::new();
let mut grep_result_indices = Vec::new();
let mut grep_is_header = Vec::new();
for (note_idx, note) in self.notes.iter().enumerate() {
if parsed.pinned_only && !note.pinned {
continue;
}
if let Some(ref folder) = parsed.folder_filter {
let matches_folder = if folder.is_empty() {
note.folder.is_empty()
} else {
note.folder == *folder || note.folder.starts_with(&format!("{folder}/"))
};
if !matches_folder {
continue;
}
}
if let Some(ref tags) = parsed.tag_filter
&& !tags.is_empty()
{
let note_tags: Vec<String> = note.tags.iter().map(|t| t.to_lowercase()).collect();
let matches_tag = tags.iter().any(|t| note_tags.contains(t));
if !matches_tag {
continue;
}
}
let matched_title = title_query.is_empty()
|| note.title.to_lowercase().contains(&title_query)
|| note.id.to_lowercase().contains(&title_query);
let content_opt = if !grep_query.is_empty() {
self.storage.load_note(¬e.id).ok()
} else {
None
};
let matched_grep = grep_query.is_empty()
|| content_opt
.as_ref()
.is_some_and(|n| n.content.to_lowercase().contains(&grep_query));
let label = if note.folder.is_empty() {
note.title.clone()
} else {
format!("{}/{}", note.folder, note.title)
};
let lock_prefix = if note.id.ends_with(".clin") {
"\u{f023} "
} else {
""
};
let tags_str = if note.tags.is_empty() {
String::new()
} else {
format!(
" [{}]",
note.tags
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(" ")
)
};
if !title_query.is_empty() && matched_title {
title_results.push(format!("{lock_prefix}{label}{tags_str}"));
title_result_indices.push(note_idx);
}
if !grep_query.is_empty()
&& matched_grep
&& matched_title
&& let Some(note_data) =
content_opt.filter(|n| n.content.to_lowercase().contains(&grep_query))
{
let match_count = note_data
.content
.lines()
.filter(|l| l.to_lowercase().contains(&grep_query))
.count();
grep_results.push(format!(" {lock_prefix}{label}{tags_str} ({match_count})"));
grep_result_indices.push(note_idx);
grep_is_header.push(true);
for (line_no, line) in note_data
.content
.lines()
.enumerate()
.filter(|(_, line)| line.to_lowercase().contains(&grep_query))
{
let trimmed = line.trim();
let snippet: String = if trimmed.chars().count() > 56 {
trimmed.chars().take(56).collect::<String>() + "…"
} else {
trimmed.to_string()
};
grep_results.push(format!(" L{}: {}", line_no + 1, snippet));
grep_result_indices.push(note_idx);
grep_is_header.push(false);
}
}
if title_query.is_empty()
&& grep_query.is_empty()
&& (parsed.folder_filter.is_some()
|| parsed.pinned_only
|| parsed.tag_filter.is_some())
{
let tags_str = if note.tags.is_empty() {
String::new()
} else {
format!(
" [{}]",
note.tags
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(" ")
)
};
title_results.push(format!("{lock_prefix}{label}{tags_str}"));
title_result_indices.push(note_idx);
}
}
if let Some(popup) = &mut self.popups.search {
popup.title_results = title_results;
popup.title_result_indices = title_result_indices;
if popup.title_selected >= popup.title_results.len() {
popup.title_selected = popup.title_results.len().saturating_sub(1);
}
popup.grep_results = grep_results;
popup.grep_result_indices = grep_result_indices;
popup.grep_is_header = grep_is_header;
if popup.grep_selected >= popup.grep_results.len() {
popup.grep_selected = popup.grep_results.len().saturating_sub(1);
}
}
}
pub fn confirm_search(&mut self) {
self.popups.search = None;
}
pub fn jump_to_selected_result(&mut self) {
if let Some(popup) = &self.popups.search {
let mut target_line = None;
let note_idx = match popup.focus {
crate::popups::SearchFocus::Results => {
let has_grep = !popup.grep_results.is_empty();
if has_grep {
let is_header = popup
.grep_is_header
.get(popup.grep_selected)
.copied()
.unwrap_or(false);
if !is_header
&& let Some(line_str) = popup.grep_results.get(popup.grep_selected)
&& let Some(l_pos) = line_str.as_str().find('L')
&& let Some(colon_pos) = line_str.as_str().find(':')
&& colon_pos > l_pos + 1
&& let Ok(num) = line_str[l_pos + 1..colon_pos].trim().parse::<usize>()
{
target_line = Some(num);
}
popup.grep_result_indices.get(popup.grep_selected).copied()
} else {
popup
.title_result_indices
.get(popup.title_selected)
.copied()
}
}
crate::popups::SearchFocus::Input => None,
};
if let Some(idx) = note_idx {
self.jump_to_note_index(idx);
if let Some(note) = self.notes.get(idx) {
let id = note.id.clone();
self.open_note_at_line(&id, target_line);
}
}
}
}
pub fn cancel_search(&mut self) {
if let Some(popup) = self.popups.search.take() {
self.list.visual_index = popup.original_index;
self.list.folder_expanded = popup.original_folder_expanded;
self.refresh_visual_list();
if !self.list.visual_list.is_empty() {
self.list.visual_index = self
.list
.visual_index
.min(self.list.visual_list.len().saturating_sub(1));
}
self.request_preview_update();
}
}
pub fn jump_to_top(&mut self) {
self.list.visual_index = 0;
self.request_preview_update();
}
pub fn jump_to_bottom(&mut self) {
self.list.visual_index = self.list.visual_list.len().saturating_sub(1);
self.request_preview_update();
}
pub fn page_up(&mut self) {
self.list.visual_index = self.list.visual_index.saturating_sub(self.list.page_size);
self.request_preview_update();
}
pub fn page_down(&mut self) {
let max_index = self.list.visual_list.len().saturating_sub(1);
self.list.visual_index = (self.list.visual_index + self.list.page_size).min(max_index);
self.request_preview_update();
}
pub fn handle_g_press(&mut self) -> bool {
let now = Instant::now();
if let Some(last) = self.last_g_press
&& now.duration_since(last) < Duration::from_millis(500)
{
self.last_g_press = None;
self.jump_to_top();
return true;
}
self.last_g_press = Some(now);
false
}
pub fn initiate_quit(&mut self) {
if self.confirm_on_quit {
self.show_confirm(ConfirmAction::QuitApp);
} else {
self.should_quit = true;
}
}
pub fn handle_esc_press(&mut self) {
let now = Instant::now();
if let Some(last) = self.last_esc_press
&& now.duration_since(last) < Duration::from_millis(500)
{
self.last_esc_press = None;
self.collapse_all_folders();
return;
}
self.last_esc_press = Some(now);
}
pub fn collapse_all_folders(&mut self) {
self.list.folder_expanded.clear();
self.list.folder_expanded.insert(String::new());
self.refresh_visual_list();
self.request_preview_update();
}
pub fn open_trash_view(&mut self) {
match self.storage.list_trash() {
Ok(items) => {
if items.is_empty() {
self.set_temporary_status_static("Trash is empty");
return;
}
self.popups.trash_view = Some(TrashView { items, selected: 0 });
}
Err(e) => {
self.set_temporary_status(&format!("Failed to open trash: {e}"));
}
}
}
pub fn close_trash_view(&mut self) {
self.popups.trash_view = None;
}
pub fn restore_from_trash(&mut self) {
let item = self
.popups
.trash_view
.as_ref()
.and_then(|t| t.items.get(t.selected).cloned());
let Some(item) = item else { return };
match self.storage.restore_trash_items(vec![item]) {
Ok(_) => {
if let Ok(items) = self.storage.list_trash() {
if items.is_empty() {
self.popups.trash_view = None;
self.set_temporary_status_static("Note restored, trash is now empty");
} else if let Some(ref mut trash) = self.popups.trash_view {
trash.items = items;
trash.selected = trash.selected.min(trash.items.len().saturating_sub(1));
self.set_temporary_status_static("Note restored");
}
}
self.list.folder_cache = None;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
}
Err(e) => {
self.set_temporary_status(&format!("Failed to restore: {e}"));
}
}
}
pub fn begin_delete_from_trash(&mut self) {
if let Some(trash) = &self.popups.trash_view
&& let Some(item) = trash.items.get(trash.selected).cloned()
{
self.show_confirm(ConfirmAction::DeleteFromTrash { item });
}
}
pub fn confirm_delete_from_trash(&mut self, item: trash::TrashItem) {
match self.storage.purge_trash_items(vec![item]) {
Ok(()) => {
if let Some(ref mut trash) = self.popups.trash_view
&& let Ok(items) = self.storage.list_trash()
{
if items.is_empty() {
self.popups.trash_view = None;
self.set_temporary_status_static("Note deleted, trash is now empty");
} else {
trash.items = items;
trash.selected = trash.selected.min(trash.items.len().saturating_sub(1));
self.set_temporary_status_static("Note permanently deleted");
}
}
}
Err(e) => {
self.set_temporary_status(&format!("Failed to delete: {e}"));
}
}
}
pub fn begin_empty_trash(&mut self) {
if let Some(trash) = &self.popups.trash_view {
if trash.items.is_empty() {
self.set_temporary_status_static("Trash is already empty");
} else {
self.show_confirm(ConfirmAction::EmptyTrash {
items: trash.items.clone(),
});
}
}
}
pub fn confirm_empty_trash(&mut self, items: Vec<trash::TrashItem>) {
let count = items.len();
match self.storage.purge_trash_items(items) {
Ok(()) => {
self.popups.trash_view = None;
self.set_temporary_status(&format!("Deleted {count} notes from trash"));
}
Err(e) => {
self.set_temporary_status(&format!("Failed to empty trash: {e}"));
}
}
}
pub fn toggle_preview(&mut self) {
self.list.preview_enabled = !self.list.preview_enabled;
if self.list.preview_enabled {
self.update_preview();
self.set_temporary_status_static("Preview enabled");
} else {
self.list.preview_content = None;
self.set_temporary_status_static("Preview disabled");
}
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.preview_enabled = self.list.preview_enabled;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn poll_renderers(&mut self) -> bool {
let mut updated = false;
if let Some(last) = self.list.last_selection_change
&& last.elapsed() > Duration::from_millis(150)
&& self.list.pending_preview_update
{
self.update_preview();
self.list.pending_preview_update = false;
self.list.last_selection_change = None;
updated = true;
}
if let Some(last) = self.editor.last_editor_change
&& last.elapsed() > Duration::from_millis(150)
&& self.editor.pending_editor_preview_update
{
self.update_editor_markdown_preview();
self.editor.pending_editor_preview_update = false;
self.editor.last_editor_change = None;
updated = true;
}
if let Some(PreviewContent::Markdown(renderer)) = &mut self.list.preview_content
&& renderer.poll()
{
if !renderer.pages_built() {
let visible = 34u16;
renderer.build_pages(visible, self.app_theme.preview_bg());
}
updated = true;
}
if let Some(renderer) = &mut self.editor.md_preview_renderer
&& renderer.poll()
{
if !renderer.pages_built() {
let visible = 36u16;
renderer.build_pages(visible, self.app_theme.preview_bg());
}
updated = true;
}
updated
}
pub fn request_preview_update(&mut self) {
if !self.list.preview_enabled {
return;
}
self.list.preview_content_index = None;
self.list.last_selection_change = Some(Instant::now());
self.list.pending_preview_update = true;
}
pub fn request_preview_update_immediate(&mut self) {
if !self.list.preview_enabled {
return;
}
self.update_preview();
self.list.pending_preview_update = false;
self.list.last_selection_change = None;
}
pub fn request_editor_preview_update(&mut self) {
if !self.editor.editor_preview_enabled {
return;
}
self.editor.last_editor_change = Some(Instant::now());
self.editor.pending_editor_preview_update = true;
}
pub fn update_preview(&mut self) {
if !self.list.preview_enabled {
return;
}
let item = self.list.visual_list.get(self.list.visual_index);
match item {
Some(VisualItem::Note {
summary_idx,
is_draw,
is_canvas,
..
}) => {
let summary_idx = *summary_idx;
let is_draw = *is_draw;
let is_canvas = *is_canvas;
let id = &self.notes[summary_idx].id;
let is_clin = id.ends_with(".clin");
if self.preview_encryption && is_clin {
self.list.preview_content = None;
self.list.preview_content_index = Some(self.list.visual_index);
return;
}
if is_draw {
let path = self.storage.note_path(id);
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<crate::draw::state::DrawData>(&content) {
Ok(data) => {
let grid = crate::snapshot::render_draw_snapshot(
&data,
&self.app_theme,
);
self.list.preview_content =
Some(PreviewContent::DrawGrid(grid));
}
Err(e) => {
self.list.preview_content = None;
self.status = Cow::Owned(format!("Failed to parse draw: {e}"));
}
}
}
Err(_) => {
self.list.preview_content = None;
}
}
self.list.preview_content_index = Some(self.list.visual_index);
return;
}
if is_canvas {
let path = self.storage.note_path(id);
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<crate::pinstar::data::CanvasData>(&content)
{
Ok(data) => {
let grid = crate::snapshot::render_canvas_snapshot(
&data,
&self.app_theme,
);
self.list.preview_content =
Some(PreviewContent::CanvasGrid(grid));
}
Err(e) => {
self.list.preview_content = None;
self.status =
Cow::Owned(format!("Failed to parse canvas: {e}"));
}
}
}
Err(_) => {
self.list.preview_content = None;
}
}
self.list.preview_content_index = Some(self.list.visual_index);
return;
}
if let Ok(note) = self.storage.load_note(id) {
let width = 80u16.saturating_sub(2).max(40);
let mut renderer = MarkdownRenderer::new(width);
renderer.render(¬e.content, width);
self.list.preview_content = Some(PreviewContent::Markdown(Box::new(renderer)));
} else {
self.list.preview_content = None;
}
self.list.preview_content_index = Some(self.list.visual_index);
}
Some(VisualItem::Folder { path, name, .. }) => {
let folder_path = path.clone();
let is_pinned = folder_path == crate::app::VIRTUAL_PINNED_PATH;
let all_folders = if let Some(ref cache) = self.list.folder_cache {
cache.clone()
} else {
let folders = self.storage.list_folders().unwrap_or_default();
self.list.folder_cache = Some(folders.clone());
folders
};
let mut subfolders = Vec::new();
if !is_pinned {
for f in &all_folders {
let parent_path = if let Some(slash) = f.rfind('/') {
&f[..slash]
} else {
""
};
if parent_path == folder_path {
let name = f.split('/').next_back().unwrap_or("").to_string();
subfolders.push(name);
}
}
subfolders.sort();
}
let mut notes = Vec::new();
for note in &self.notes {
let matches = if is_pinned {
note.pinned
} else {
note.folder == folder_path
};
if matches {
notes.push(note.title.clone());
}
}
notes.sort();
let display_title = if is_pinned {
"Pinned Notes".to_string()
} else if name == ".." {
format!(
"Parent: {}",
if folder_path.is_empty() {
"Vault"
} else {
&folder_path
}
)
} else if folder_path.is_empty() {
"Vault (Root)".to_string()
} else {
name.clone()
};
let mut md = format!("# {display_title}\n\n");
if !subfolders.is_empty() {
md.push_str("## Folders\n");
for sub in &subfolders {
md.push_str(&format!("- \u{f07b} {sub}\n"));
}
md.push('\n');
}
if !notes.is_empty() {
md.push_str("## Notes\n");
for note in ¬es {
md.push_str(&format!("- \u{f15c} {note}\n"));
}
} else if subfolders.is_empty() {
md.push_str("*This folder is empty.*\n");
}
let width = 80u16.saturating_sub(2).max(40);
let mut renderer = MarkdownRenderer::new(width);
renderer.render(&md, width);
self.list.preview_content = Some(PreviewContent::Markdown(Box::new(renderer)));
self.list.preview_content_index = Some(self.list.visual_index);
}
_ => {
self.list.preview_content = None;
self.list.preview_content_index = None;
}
}
}
pub fn update_editor_markdown_preview(&mut self) {
if !self.editor.editor_preview_enabled {
return;
}
let content = self.editor.editor.lines().join("\n");
let width = 80u16.saturating_sub(2).max(40);
let mut renderer = MarkdownRenderer::new(width);
renderer.render(&content, width);
self.editor.md_preview_renderer = Some(renderer);
}
pub fn toggle_markdown_preview(&mut self) {
self.editor.editor_preview_enabled = !self.editor.editor_preview_enabled;
if self.editor.editor_preview_enabled {
self.update_editor_markdown_preview();
self.set_temporary_status_static("Markdown preview enabled");
} else {
self.editor.md_preview_renderer = None;
self.set_temporary_status_static("Markdown preview disabled");
}
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.editor_preview_enabled = self.editor.editor_preview_enabled;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn toggle_show_line_numbers(&mut self) {
self.editor.show_line_numbers = !self.editor.show_line_numbers;
let msg: &'static str = if self.editor.show_line_numbers {
"Line numbers enabled"
} else {
"Line numbers disabled"
};
self.set_temporary_status_static(msg);
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.show_line_numbers = self.editor.show_line_numbers;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn toggle_confirm_on_delete(&mut self) {
self.confirm_on_delete = !self.confirm_on_delete;
let msg: &'static str = if self.confirm_on_delete {
"Delete confirmation enabled"
} else {
"Delete confirmation disabled"
};
self.set_temporary_status_static(msg);
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.confirm_on_delete = self.confirm_on_delete;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn toggle_confirm_on_quit(&mut self) {
self.confirm_on_quit = !self.confirm_on_quit;
let msg: &'static str = if self.confirm_on_quit {
"Quit confirmation enabled"
} else {
"Quit confirmation disabled"
};
self.set_temporary_status_static(msg);
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.confirm_on_quit = self.confirm_on_quit;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn toggle_preview_encryption(&mut self) {
self.preview_encryption = !self.preview_encryption;
let msg: &'static str = if self.preview_encryption {
"Encrypted note previews enabled"
} else {
"Encrypted note previews hidden"
};
self.set_temporary_status_static(msg);
if self.list.preview_enabled {
self.update_preview();
}
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.preview_encryption = self.preview_encryption;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn toggle_pinned_on_top(&mut self) {
self.pinned_on_top = !self.pinned_on_top;
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
let msg: &'static str = if self.pinned_on_top {
"Pinned notes shown on top"
} else {
"Pinned notes in natural order"
};
self.set_temporary_status_static(msg);
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.pinned_on_top = self.pinned_on_top;
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
pub fn reload_theme(&mut self) {
let config = crate::config::ClinConfig::load().unwrap_or_default();
self.app_theme = crate::app_theme::AppThemeColors::from_config(&config.theme);
if self.mode == ViewMode::Help {
self.list.help_text_cache = None;
}
}
pub fn begin_theme_selection(&mut self) {
let themes = vec![
"default".to_string(),
"tokyo_night".to_string(),
"catppuccin_mocha".to_string(),
"onedark".to_string(),
"gruvbox".to_string(),
"dracula".to_string(),
"nord".to_string(),
"rose_pine".to_string(),
"everforest".to_string(),
"kanagawa".to_string(),
"solarized".to_string(),
];
let config = crate::config::ClinConfig::load().unwrap_or_default();
let current = config.theme.theme.to_string();
let selected = themes.iter().position(|t| t == ¤t).unwrap_or(0);
let general_is_solid = matches!(config.theme.background, crate::config::Background::Solid);
let graph_is_solid = matches!(
config.visual.graph_background,
crate::config::Background::Solid
);
self.popups.theme = Some(ThemePopup {
themes,
selected,
focus: ThemePopupFocus::ThemeList,
general_is_solid,
graph_is_solid,
});
}
pub fn begin_sort_selection(&mut self) {
use crate::list_view::{SortField, SortOrder};
let current_idx = match (self.list.sort_field, self.list.sort_order) {
(SortField::Title, SortOrder::Ascending) => 0,
(SortField::Title, SortOrder::Descending) => 1,
(SortField::Modified, SortOrder::Descending) => 2,
(SortField::Modified, SortOrder::Ascending) => 3,
};
self.popups.sort = Some(crate::popups::SortPopup {
selected: current_idx,
});
}
pub fn select_sort(&mut self) {
if let Some(popup) = self.popups.sort.take() {
use crate::list_view::{SortField, SortOrder};
match popup.selected {
0 => {
self.list.sort_field = SortField::Title;
self.list.sort_order = SortOrder::Ascending;
}
1 => {
self.list.sort_field = SortField::Title;
self.list.sort_order = SortOrder::Descending;
}
2 => {
self.list.sort_field = SortField::Modified;
self.list.sort_order = SortOrder::Descending;
}
3 => {
self.list.sort_field = SortField::Modified;
self.list.sort_order = SortOrder::Ascending;
}
_ => {}
}
if let Err(e) = self.refresh_notes() {
self.set_temporary_status(&format!("Refresh failed: {e}"));
}
if let Ok(mut config) = crate::config::ClinConfig::load() {
config.default_sort_field = Some(self.list.sort_field);
config.default_sort_order = Some(self.list.sort_order);
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save config: {e}"));
}
}
}
}
pub fn close_sort_popup(&mut self) {
self.popups.sort = None;
}
pub fn select_theme(&mut self) {
if let Some(mut popup) = self.popups.theme.take() {
match popup.focus {
ThemePopupFocus::ThemeList => {
let next_theme = popup.themes[popup.selected].clone();
let mut config = crate::config::ClinConfig::load().unwrap_or_default();
config.theme.theme = next_theme.parse().unwrap_or_default();
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save theme: {e}"));
return;
}
self.reload_theme();
self.set_temporary_status(&format!("Theme set to: {next_theme}"));
self.popups.theme = Some(popup);
}
ThemePopupFocus::GeneralBg => {
popup.general_is_solid = !popup.general_is_solid;
let mut config = crate::config::ClinConfig::load().unwrap_or_default();
config.theme.background = if popup.general_is_solid {
crate::config::Background::Solid
} else {
crate::config::Background::Transparent
};
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save bg: {e}"));
}
self.reload_theme();
self.popups.theme = Some(popup);
}
ThemePopupFocus::GraphBg => {
popup.graph_is_solid = !popup.graph_is_solid;
let mut config = crate::config::ClinConfig::load().unwrap_or_default();
config.visual.graph_background = if popup.graph_is_solid {
crate::config::Background::Solid
} else {
crate::config::Background::Transparent
};
if let Err(e) = config.save() {
self.set_temporary_status(&format!("Failed to save graph bg: {e}"));
}
self.popups.theme = Some(popup);
}
}
}
}
pub fn close_theme_popup(&mut self) {
self.popups.theme = None;
}
pub fn app_theme_name(&self) -> String {
let config = crate::config::ClinConfig::load().unwrap_or_default();
config.theme.theme.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::Storage;
use tempfile::tempdir;
#[test]
fn test_refresh_visual_list_requests_preview_update() {
let temp_dir = tempdir().unwrap();
let data_dir = temp_dir.path().join("data");
let config_dir = temp_dir.path().join("config");
let notes_dir = temp_dir.path().join("notes");
let templates_dir = temp_dir.path().join("templates");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::create_dir_all(¬es_dir).unwrap();
std::fs::create_dir_all(&templates_dir).unwrap();
let storage = Storage {
data_dir,
config_dir,
notes_dir,
templates_dir,
key: [0u8; 32],
};
let mut app = App::new(storage).unwrap();
app.list.preview_enabled = true;
app.list.notes_layout = crate::config::NotesLayout::Grid;
app.list.preview_content_index = Some(999);
app.refresh_visual_list();
assert!(!app.list.pending_preview_update);
assert_eq!(app.list.preview_content_index, None);
app.list.notes_layout = crate::config::NotesLayout::Tree;
app.list.preview_content_index = Some(999);
app.refresh_visual_list();
assert!(!app.list.pending_preview_update);
assert_eq!(app.list.preview_content_index, Some(0));
}
}