use std::sync::Arc;
use kimun_core::NoteVault;
use kimun_core::nfs::VaultPath;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use tokio::task::JoinHandle;
use crate::components::Component;
use crate::components::dialogs::ValidationState;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx};
use crate::settings::themes::Theme;
pub struct RenameDialog {
pub path: VaultPath,
pub vault: Arc<NoteVault>,
pub path_display: String,
pub input: String,
pub validation_state: ValidationState,
pub validation_task: Option<JoinHandle<()>>,
pub error: Option<String>,
}
impl RenameDialog {
pub fn new(path: VaultPath, vault: Arc<NoteVault>) -> Self {
let (_, filename) = path.get_parent_path();
let path_display = format!(" {}", path);
Self {
path,
vault,
path_display,
input: filename,
validation_state: ValidationState::Idle,
validation_task: None,
error: None,
}
}
fn spawn_validation(&mut self, tx: &AppTx) {
if let Some(handle) = self.validation_task.take() {
handle.abort();
}
let vault = Arc::clone(&self.vault);
let input = self.input.clone();
let path = self.path.clone();
let tx_clone = tx.clone();
let handle = tokio::spawn(async move {
let parent = path.get_parent_path().0;
let candidate = if path.is_note() {
parent.append(&VaultPath::note_path_from(&input))
} else {
parent.append(&VaultPath::new(&input))
};
let exists = vault.exists(&candidate).await.is_some();
tx_clone.send(AppEvent::RenameValidation { available: !exists }).ok();
});
self.validation_task = Some(handle);
self.validation_state = ValidationState::Pending;
}
pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
match key.code {
KeyCode::Char(c) => {
self.input.push(c);
self.spawn_validation(tx);
EventState::Consumed
}
KeyCode::Backspace => {
self.input.pop();
self.spawn_validation(tx);
EventState::Consumed
}
KeyCode::Enter => {
if self.validation_state == ValidationState::Available {
let from = self.path.clone();
let parent = from.get_parent_path().0;
let new_path = if from.is_note() {
parent.append(&VaultPath::note_path_from(&self.input))
} else {
parent.append(&VaultPath::new(&self.input))
};
let vault = Arc::clone(&self.vault);
let tx2 = tx.clone();
tokio::spawn(async move {
let result = if from.is_note() {
vault.rename_note(&from, &new_path).await
} else {
vault.rename_directory(&from, &new_path).await
};
match result {
Ok(()) => {
tx2.send(AppEvent::EntryRenamed {
from,
to: new_path,
})
.ok();
}
Err(e) => {
tx2.send(AppEvent::DialogError(e.to_string())).ok();
}
}
});
}
EventState::Consumed
}
KeyCode::Esc => {
tx.send(AppEvent::CloseDialog).ok();
EventState::Consumed
}
_ => EventState::NotConsumed,
}
}
}
impl Component for RenameDialog {
fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
let height = if self.error.is_some() { 13 } else { 12 };
let popup_area = super::fixed_centered_rect(50, height, rect);
f.render_widget(Clear, popup_area);
let outer_block = Block::default()
.title(" Rename ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.fg.to_ratatui()))
.style(theme.panel_style());
let inner = outer_block.inner(popup_area);
f.render_widget(outer_block, popup_area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
let bg = theme.bg_panel.to_ratatui();
let fg = theme.fg.to_ratatui();
let fg_muted = theme.fg_muted.to_ratatui();
super::render_path_row(f, rows[1], &self.path_display, fg, bg);
super::render_separator(f, rows[2], fg_muted, bg);
f.render_widget(
Paragraph::new(" NEW NAME")
.style(Style::default().fg(fg_muted).bg(bg)),
rows[3],
);
let input_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(1), Constraint::Length(3), ])
.split(rows[4]);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(fg_muted))
.style(Style::default().bg(bg));
let input_inner = input_block.inner(input_chunks[0]);
f.render_widget(input_block, input_chunks[0]);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(self.input.as_str()),
Span::raw("_"),
]))
.style(Style::default().fg(fg).bg(bg)),
input_inner,
);
let (indicator_text, indicator_style) = match self.validation_state {
ValidationState::Idle => (" ", Style::default()),
ValidationState::Pending => (" \u{231b} ", Style::default().fg(fg_muted)),
ValidationState::Available => (" \u{2713} ", Style::default().fg(Color::Green)),
ValidationState::Taken => (" \u{2717} ", Style::default().fg(Color::Red)),
};
let indicator_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(input_chunks[1]);
f.render_widget(
Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
indicator_rows[1],
);
let (status_text, status_style) = match self.validation_state {
ValidationState::Idle => ("", Style::default()),
ValidationState::Pending => (" Checking...", Style::default().fg(fg_muted).bg(bg)),
ValidationState::Available => (" Available", Style::default().fg(Color::Green).bg(bg)),
ValidationState::Taken => (" Already exists", Style::default().fg(Color::Red).bg(bg)),
};
f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
super::render_confirm_hint(
f, rows[7], " [Enter] Rename",
self.validation_state == ValidationState::Available,
fg, fg_muted, bg,
);
if let Some(msg) = &self.error {
super::render_error_row(f, rows[8], msg, bg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc;
#[test]
fn validation_state_variants_compile() {
let states = [
ValidationState::Idle,
ValidationState::Pending,
ValidationState::Available,
ValidationState::Taken,
];
for state in states {
let _label = match state {
ValidationState::Idle => "idle",
ValidationState::Pending => "pending",
ValidationState::Available => "available",
ValidationState::Taken => "taken",
};
}
}
#[tokio::test]
#[ignore = "requires a real vault directory with kimun.sqlite"]
async fn new_prefills_input() {
use std::path::PathBuf;
let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
std::fs::create_dir_all(&tmp).unwrap();
let vault = Arc::new(
NoteVault::new(PathBuf::from(&tmp))
.await
.expect("vault creation failed"),
);
let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
let path = VaultPath::new("notes/projects/kimun.md");
let (_, expected_filename) = path.get_parent_path();
let dialog = RenameDialog::new(path, vault);
assert_eq!(dialog.input, expected_filename);
}
#[test]
fn esc_sends_close_dialog() {
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
std::fs::create_dir_all(&tmp).unwrap();
let vault_result = NoteVault::new(tmp).await;
let Ok(vault) = vault_result else {
return;
};
let vault = Arc::new(vault);
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
let mut dialog =
RenameDialog::new(VaultPath::new("notes/test.md"), vault);
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let state = dialog.handle_key(key, &tx);
assert_eq!(state, EventState::Consumed);
let event = rx.try_recv().expect("expected AppEvent::CloseDialog");
assert!(matches!(event, AppEvent::CloseDialog));
});
}
}