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, MouseEvent};
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, FileOp, OverlayData};
16use crate::components::hint_row::HintRow;
17use crate::components::panel::{ModalSpec, modal_chrome};
18use crate::components::single_line_input::{InputOutcome, SingleLineInput};
19use crate::settings::themes::Theme;
20
21pub struct RenameDialog {
32 pub path: VaultPath,
34 pub vault: Arc<NoteVault>,
36 pub path_display: String,
38 pub input: SingleLineInput,
40 pub validation_state: ValidationState,
42 pub validation_task: Option<JoinHandle<()>>,
44 pub error: Option<String>,
46 hints: HintRow,
47}
48
49impl RenameDialog {
50 pub fn new(path: VaultPath, vault: Arc<NoteVault>) -> Self {
54 let (_, filename) = path.get_parent_path();
55 let path_display = format!(" {}", path);
56 Self {
57 path,
58 vault,
59 path_display,
60 input: SingleLineInput::with_value(filename),
61 validation_state: ValidationState::Idle,
62 validation_task: None,
63 error: None,
64 hints: super::confirm_hints("Rename"),
65 }
66 }
67
68 fn spawn_validation(&mut self, tx: &AppTx) {
77 if let Some(handle) = self.validation_task.take() {
79 handle.abort();
80 }
81
82 let vault = Arc::clone(&self.vault);
83 let input = self.input.value().to_string();
84 let path = self.path.clone();
85 let tx_clone = tx.clone();
86
87 let handle = tokio::spawn(async move {
88 let parent = path.get_parent_path().0;
89 let candidate = if path.is_note() {
90 parent.append(&VaultPath::note_path_from(&input))
91 } else {
92 parent.append(&VaultPath::new(&input))
93 };
94 let exists = vault.exists(&candidate).await;
95 tx_clone
97 .send(AppEvent::OverlayData(OverlayData::RenameValidation {
98 available: !exists,
99 }))
100 .ok();
101 });
102
103 self.validation_task = Some(handle);
104 self.validation_state = ValidationState::Pending;
105 }
106
107 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
114 if let Some(key) = self.hints.hit(m) {
115 self.handle_key(key, tx);
116 }
117 EventState::Consumed
118 }
119
120 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
123 match self.input.handle_key(&key) {
124 InputOutcome::Submit => {
125 if self.validation_state == ValidationState::Available {
126 let from = self.path.clone();
127 let parent = from.get_parent_path().0;
128 let new_name = self.input.value();
129 let new_path = if from.is_note() {
130 parent.append(&VaultPath::note_path_from(new_name))
131 } else {
132 parent.append(&VaultPath::new(new_name))
133 };
134 let vault = Arc::clone(&self.vault);
135 let tx2 = tx.clone();
136 tokio::spawn(async move {
137 let result = vault.rename_entry(&from, &new_path).await;
140 match result {
141 Ok(()) => {
142 tx2.send(AppEvent::FileOp(FileOp::Renamed { from, to: new_path }))
143 .ok();
144 }
145 Err(e) => {
146 tx2.send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
147 .ok();
148 }
149 }
150 });
151 }
152 EventState::Consumed
153 }
154 InputOutcome::Cancel => {
155 tx.send(AppEvent::CloseOverlay).ok();
156 EventState::Consumed
157 }
158 InputOutcome::Changed => {
159 self.spawn_validation(tx);
160 EventState::Consumed
161 }
162 InputOutcome::Consumed => EventState::Consumed,
163 InputOutcome::NotConsumed => EventState::NotConsumed,
164 }
165 }
166}
167
168impl Component for RenameDialog {
173 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
174 let height = if self.error.is_some() { 13 } else { 12 };
178 let popup_area = super::fixed_centered_rect(50, height, rect);
179
180 let inner = modal_chrome(
181 f,
182 popup_area,
183 theme,
184 ModalSpec {
185 title: Some(" Rename "),
186 border: Some(Style::default().fg(theme.fg.to_ratatui())),
187 ..Default::default()
188 },
189 );
190
191 let rows = Layout::default()
204 .direction(Direction::Vertical)
205 .constraints([
206 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), ])
216 .split(inner);
217
218 let bg = theme.bg_panel.to_ratatui();
219 let fg = theme.fg.to_ratatui();
220 let gray = theme.gray.to_ratatui();
221
222 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
224
225 super::render_separator(f, rows[2], gray, bg);
227
228 f.render_widget(
230 Paragraph::new(" NEW NAME").style(Style::default().fg(gray).bg(bg)),
231 rows[3],
232 );
233
234 let input_chunks = Layout::default()
238 .direction(Direction::Horizontal)
239 .constraints([
240 Constraint::Min(1), Constraint::Length(3), ])
243 .split(rows[4]);
244
245 let input_block = Block::default()
246 .borders(Borders::ALL)
247 .border_style(Style::default().fg(gray))
248 .style(Style::default().bg(bg));
249 let input_inner = input_block.inner(input_chunks[0]);
250 f.render_widget(input_block, input_chunks[0]);
251 self.input
252 .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
253
254 let (indicator_text, indicator_style) = match self.validation_state {
256 ValidationState::Idle => (" ", Style::default()),
257 ValidationState::Pending => (" \u{231b} ", Style::default().fg(gray)),
258 ValidationState::Available => {
259 (" \u{2713} ", Style::default().fg(theme.green.to_ratatui()))
260 }
261 ValidationState::Taken => (" \u{2717} ", Style::default().fg(theme.red.to_ratatui())),
262 };
263 let indicator_rows = Layout::default()
264 .direction(Direction::Vertical)
265 .constraints([
266 Constraint::Length(1),
267 Constraint::Length(1),
268 Constraint::Length(1),
269 ])
270 .split(input_chunks[1]);
271 f.render_widget(
272 Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
273 indicator_rows[1],
274 );
275
276 let (status_text, status_style) = match self.validation_state {
278 ValidationState::Idle => ("", Style::default()),
279 ValidationState::Pending => (" Checking...", Style::default().fg(gray).bg(bg)),
280 ValidationState::Available => (
281 " Available",
282 Style::default().fg(theme.green.to_ratatui()).bg(bg),
283 ),
284 ValidationState::Taken => (
285 " Already exists",
286 Style::default().fg(theme.red.to_ratatui()).bg(bg),
287 ),
288 };
289 f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
290
291 super::render_confirm_hints(
293 f,
294 rows[7],
295 &mut self.hints,
296 self.validation_state == ValidationState::Available,
297 self.validation_state == ValidationState::Available,
299 fg,
300 theme,
301 );
302
303 if let Some(msg) = &self.error {
305 super::render_error_row(f, rows[8], msg, theme);
306 }
307 }
308}
309
310#[cfg(test)]
315mod tests {
316 use super::*;
317 use kimun_core::VaultConfig;
318 use tokio::sync::mpsc;
319
320 #[test]
323 fn validation_state_variants_compile() {
324 let states = [
325 ValidationState::Idle,
326 ValidationState::Pending,
327 ValidationState::Available,
328 ValidationState::Taken,
329 ];
330 for state in states {
331 let _label = match state {
332 ValidationState::Idle => "idle",
333 ValidationState::Pending => "pending",
334 ValidationState::Available => "available",
335 ValidationState::Taken => "taken",
336 };
337 }
338 }
339
340 #[tokio::test]
353 #[ignore = "requires a real vault directory with kimun.sqlite"]
354 async fn new_prefills_input() {
355 use std::path::PathBuf;
356
357 let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
358 std::fs::create_dir_all(&tmp).unwrap();
359
360 let vault = Arc::new(
361 NoteVault::new(VaultConfig::new(crate::test_support::sys(PathBuf::from(
362 &tmp,
363 ))))
364 .await
365 .expect("vault creation failed"),
366 );
367
368 let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
369 let path = VaultPath::new("notes/projects/kimun.md");
370 let (_, expected_filename) = path.get_parent_path();
371
372 let dialog = RenameDialog::new(path, vault);
373 assert_eq!(dialog.input.value(), expected_filename);
374 }
375
376 #[test]
379 fn esc_sends_close_dialog() {
380 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
381
382 let rt = tokio::runtime::Runtime::new().unwrap();
383 rt.block_on(async {
384 let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
385 std::fs::create_dir_all(&tmp).unwrap();
386
387 let vault_result =
388 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
389 let Ok(vault) = vault_result else {
390 return;
392 };
393
394 let vault = Arc::new(vault);
395 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
396 let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
397
398 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
399 let state = dialog.handle_key(key, &tx);
400
401 assert_eq!(state, EventState::Consumed);
402 let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
403 assert!(matches!(event, AppEvent::CloseOverlay));
404 });
405 }
406}