kimun_notes/components/dialogs/
rename_dialog.rs1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::crossterm::event::KeyEvent;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::Style;
9use ratatui::widgets::{Block, Borders, Paragraph};
10use tokio::task::JoinHandle;
11
12use crate::components::Component;
13use crate::components::dialogs::ValidationState;
14use crate::components::event_state::EventState;
15use crate::components::events::{AppEvent, AppTx};
16use crate::components::panel::{ModalSpec, modal_chrome};
17use crate::components::single_line_input::{InputOutcome, SingleLineInput};
18use crate::settings::themes::Theme;
19
20pub struct RenameDialog {
31 pub path: VaultPath,
33 pub vault: Arc<NoteVault>,
35 pub path_display: String,
37 pub input: SingleLineInput,
39 pub validation_state: ValidationState,
41 pub validation_task: Option<JoinHandle<()>>,
43 pub error: Option<String>,
45}
46
47impl RenameDialog {
48 pub fn new(path: VaultPath, vault: Arc<NoteVault>) -> Self {
52 let (_, filename) = path.get_parent_path();
53 let path_display = format!(" {}", path);
54 Self {
55 path,
56 vault,
57 path_display,
58 input: SingleLineInput::with_value(filename),
59 validation_state: ValidationState::Idle,
60 validation_task: None,
61 error: None,
62 }
63 }
64
65 fn spawn_validation(&mut self, tx: &AppTx) {
74 if let Some(handle) = self.validation_task.take() {
76 handle.abort();
77 }
78
79 let vault = Arc::clone(&self.vault);
80 let input = self.input.value().to_string();
81 let path = self.path.clone();
82 let tx_clone = tx.clone();
83
84 let handle = tokio::spawn(async move {
85 let parent = path.get_parent_path().0;
86 let candidate = if path.is_note() {
87 parent.append(&VaultPath::note_path_from(&input))
88 } else {
89 parent.append(&VaultPath::new(&input))
90 };
91 let exists = vault.exists(&candidate).await;
92 tx_clone
94 .send(AppEvent::RenameValidation { available: !exists })
95 .ok();
96 });
97
98 self.validation_task = Some(handle);
99 self.validation_state = ValidationState::Pending;
100 }
101
102 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
109 match self.input.handle_key(&key) {
110 InputOutcome::Submit => {
111 if self.validation_state == ValidationState::Available {
112 let from = self.path.clone();
113 let parent = from.get_parent_path().0;
114 let new_name = self.input.value();
115 let new_path = if from.is_note() {
116 parent.append(&VaultPath::note_path_from(new_name))
117 } else {
118 parent.append(&VaultPath::new(new_name))
119 };
120 let vault = Arc::clone(&self.vault);
121 let tx2 = tx.clone();
122 tokio::spawn(async move {
123 let result = vault.rename_entry(&from, &new_path).await;
126 match result {
127 Ok(()) => {
128 tx2.send(AppEvent::EntryRenamed { from, to: new_path }).ok();
129 }
130 Err(e) => {
131 tx2.send(AppEvent::DialogError(e.to_string())).ok();
132 }
133 }
134 });
135 }
136 EventState::Consumed
137 }
138 InputOutcome::Cancel => {
139 tx.send(AppEvent::CloseOverlay).ok();
140 EventState::Consumed
141 }
142 InputOutcome::Changed => {
143 self.spawn_validation(tx);
144 EventState::Consumed
145 }
146 InputOutcome::Consumed => EventState::Consumed,
147 InputOutcome::NotConsumed => EventState::NotConsumed,
148 }
149 }
150}
151
152impl Component for RenameDialog {
157 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
158 let height = if self.error.is_some() { 13 } else { 12 };
162 let popup_area = super::fixed_centered_rect(50, height, rect);
163
164 let inner = modal_chrome(
165 f,
166 popup_area,
167 theme,
168 ModalSpec {
169 title: Some(" Rename "),
170 border: Some(Style::default().fg(theme.fg.to_ratatui())),
171 ..Default::default()
172 },
173 );
174
175 let rows = Layout::default()
188 .direction(Direction::Vertical)
189 .constraints([
190 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), ])
200 .split(inner);
201
202 let bg = theme.bg_panel.to_ratatui();
203 let fg = theme.fg.to_ratatui();
204 let gray = theme.gray.to_ratatui();
205
206 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
208
209 super::render_separator(f, rows[2], gray, bg);
211
212 f.render_widget(
214 Paragraph::new(" NEW NAME").style(Style::default().fg(gray).bg(bg)),
215 rows[3],
216 );
217
218 let input_chunks = Layout::default()
222 .direction(Direction::Horizontal)
223 .constraints([
224 Constraint::Min(1), Constraint::Length(3), ])
227 .split(rows[4]);
228
229 let input_block = Block::default()
230 .borders(Borders::ALL)
231 .border_style(Style::default().fg(gray))
232 .style(Style::default().bg(bg));
233 let input_inner = input_block.inner(input_chunks[0]);
234 f.render_widget(input_block, input_chunks[0]);
235 self.input
236 .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
237
238 let (indicator_text, indicator_style) = match self.validation_state {
240 ValidationState::Idle => (" ", Style::default()),
241 ValidationState::Pending => (" \u{231b} ", Style::default().fg(gray)),
242 ValidationState::Available => {
243 (" \u{2713} ", Style::default().fg(theme.green.to_ratatui()))
244 }
245 ValidationState::Taken => (" \u{2717} ", Style::default().fg(theme.red.to_ratatui())),
246 };
247 let indicator_rows = Layout::default()
248 .direction(Direction::Vertical)
249 .constraints([
250 Constraint::Length(1),
251 Constraint::Length(1),
252 Constraint::Length(1),
253 ])
254 .split(input_chunks[1]);
255 f.render_widget(
256 Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
257 indicator_rows[1],
258 );
259
260 let (status_text, status_style) = match self.validation_state {
262 ValidationState::Idle => ("", Style::default()),
263 ValidationState::Pending => (" Checking...", Style::default().fg(gray).bg(bg)),
264 ValidationState::Available => (
265 " Available",
266 Style::default().fg(theme.green.to_ratatui()).bg(bg),
267 ),
268 ValidationState::Taken => (
269 " Already exists",
270 Style::default().fg(theme.red.to_ratatui()).bg(bg),
271 ),
272 };
273 f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
274
275 super::render_confirm_hint(
277 f,
278 rows[7],
279 " [Enter] Rename",
280 self.validation_state == ValidationState::Available,
281 fg,
282 gray,
283 bg,
284 );
285
286 if let Some(msg) = &self.error {
288 super::render_error_row(f, rows[8], msg, theme);
289 }
290 }
291}
292
293#[cfg(test)]
298mod tests {
299 use super::*;
300 use kimun_core::VaultConfig;
301 use tokio::sync::mpsc;
302
303 #[test]
306 fn validation_state_variants_compile() {
307 let states = [
308 ValidationState::Idle,
309 ValidationState::Pending,
310 ValidationState::Available,
311 ValidationState::Taken,
312 ];
313 for state in states {
314 let _label = match state {
315 ValidationState::Idle => "idle",
316 ValidationState::Pending => "pending",
317 ValidationState::Available => "available",
318 ValidationState::Taken => "taken",
319 };
320 }
321 }
322
323 #[tokio::test]
336 #[ignore = "requires a real vault directory with kimun.sqlite"]
337 async fn new_prefills_input() {
338 use std::path::PathBuf;
339
340 let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
341 std::fs::create_dir_all(&tmp).unwrap();
342
343 let vault = Arc::new(
344 NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
345 .await
346 .expect("vault creation failed"),
347 );
348
349 let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
350 let path = VaultPath::new("notes/projects/kimun.md");
351 let (_, expected_filename) = path.get_parent_path();
352
353 let dialog = RenameDialog::new(path, vault);
354 assert_eq!(dialog.input.value(), expected_filename);
355 }
356
357 #[test]
360 fn esc_sends_close_dialog() {
361 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
362
363 let rt = tokio::runtime::Runtime::new().unwrap();
364 rt.block_on(async {
365 let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
366 std::fs::create_dir_all(&tmp).unwrap();
367
368 let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
369 let Ok(vault) = vault_result else {
370 return;
372 };
373
374 let vault = Arc::new(vault);
375 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
376 let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
377
378 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
379 let state = dialog.handle_key(key, &tx);
380
381 assert_eq!(state, EventState::Consumed);
382 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
383 assert!(matches!(event, AppEvent::CloseOverlay));
384 });
385 }
386}