1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
//! Path-entry modal for "Save As" — write the buffer to a chosen path
//! and *adopt* it as the buffer's home. Reuses the shared
//! [`SaveCopyState`] + [`SaveCopyView`] widget, but its post-save effect
//! re-points the buffer, the App's `file_path`, and the filesystem
//! watcher at the new location via [`App::save_buffer_as`]. When the
//! typed path would clobber a *different* existing file it hands off to
//! [`super::OverwriteConfirmModal`] (a vim `:w <path>` instead writes a
//! detached copy and leaves the buffer's path untouched).
//!
//! Reached from several places:
//! - [`Action::SaveAs`](crate::config::Action::SaveAs) (command palette),
//! - a `Save` (`Ctrl-S` / vim `:w`) on a path-less buffer, which has no
//! destination yet, and
//! - the file-deleted `[Save as…]` button, where the original file is gone
//! so the buffer must move to a new home.
//!
//! An optional `after_save` continuation runs once the write succeeds —
//! used by the "save then quit" (`:wq`, quit-confirm) and "save then
//! navigate" (dirty-guard) flows so a path-less buffer can complete the
//! deferred action after the user supplies a path.
use std::any::Any;
use std::path::Path;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::Frame;
use super::types::{Modal, ModalOutcome, ModalRenderCtx};
use crate::app::{App, MessageKind};
use crate::ui::{default_save_as_path, SaveCopyResponse, SaveCopyState, SaveCopyView};
/// A side effect to run after a successful save (e.g. quit, navigate).
pub type AfterSave = Box<dyn FnOnce(&mut App)>;
pub struct SaveAsModal {
state: SaveCopyState,
after_save: Option<AfterSave>,
/// True when this modal is the file-deletion recovery flow (opened
/// from [`super::FileDeletedModal`]'s `[Save as…]`). The watcher
/// dedup in `file_changed.rs` suppresses external events only for
/// *this* variant — a voluntary save-as on a live file must not hide
/// an external change that arrives while the prompt is open.
from_deletion: bool,
}
impl SaveAsModal {
/// Open as the file-deletion recovery flow, seeded with the deleted
/// path. Distinguished from a voluntary save-as so the watcher dedup
/// can treat an open prompt as an in-progress deletion flow.
pub fn for_deleted_file(default_path: String) -> Self {
Self {
state: SaveCopyState::new(default_path),
after_save: None,
from_deletion: true,
}
}
/// Seed the path field with the buffer's current path resolved to an
/// absolute path (so the destination directory is visible and the user
/// can retarget it), or `<cwd>/untitled.md` for an unnamed buffer, and
/// optionally attach a continuation to run after a successful save.
pub fn for_buffer_path(buffer_path: Option<&Path>, after_save: Option<AfterSave>) -> Self {
Self {
state: SaveCopyState::new(default_save_as_path(buffer_path)),
after_save,
from_deletion: false,
}
}
/// Whether this is the file-deletion recovery flow (see
/// [`Self::for_deleted_file`]).
pub fn is_deletion_recovery(&self) -> bool {
self.from_deletion
}
}
impl Modal for SaveAsModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let view = SaveCopyView {
theme: ctx.theme,
cursor_visible: ctx.cursor_visible,
title: "Save As",
};
frame.render_stateful_widget(view, area, &mut self.state);
}
fn handle_key(
&mut self,
key: KeyEvent,
app: &mut App,
_doc_height: usize,
_doc_width: usize,
) -> ModalOutcome {
match self.state.handle_key(&key) {
SaveCopyResponse::Continue => ModalOutcome::Continue,
// Cancel abandons the save-as; any deferred continuation
// (quit / navigate) is dropped, leaving the user where they
// were so a mis-press is recoverable.
SaveCopyResponse::Cancelled => ModalOutcome::Close,
SaveCopyResponse::Save(path_str) => {
let path = Path::new(&path_str).to_owned();
// Writing over a *different* existing file: confirm first.
// Hand the destination and any deferred continuation to the
// confirm modal and close this prompt. Declining there
// returns the user to the editor (like Cancel) — they
// re-open Save As to pick another name.
if app.editor.buffer.would_overwrite(&path) {
let after = self.after_save.take();
return ModalOutcome::CloseAnd(Box::new(move |app| {
app.modal_stack
.push(Box::new(super::OverwriteConfirmModal::new(path, after)));
}));
}
match app.save_buffer_as(&path) {
Ok(()) => {
let after = self.after_save.take();
let msg = format!("Saved to {path_str}");
ModalOutcome::CloseAnd(Box::new(move |app| {
app.flash(msg, MessageKind::Success);
if let Some(after) = after {
after(app);
}
}))
}
Err(e) => {
// Stay open so the user can correct the path;
// surface the error inline.
self.state.last_error = Some(format!("{e}"));
ModalOutcome::Continue
}
}
}
}
}
fn handle_paste(&mut self, text: &str) -> ModalOutcome {
self.state.paste(text);
ModalOutcome::Continue
}
fn handle_click(&mut self, col: u16, row: u16, _app: &mut App) -> ModalOutcome {
super::types::close_if_esc_clicked(self.state.esc_button_rect, col, row)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
use crate::app::test_utils::make_app;
use crate::document::Buffer;
#[test]
fn save_writes_buffer_and_repoints_path() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("renamed.md");
let mut app = make_app();
app.editor.buffer = Buffer::for_new_file(&dir.path().join("original.md"));
app.editor.buffer.insert(0, "moved contents");
app.editor.refresh_parsed();
app.editor.dirty = true;
app.modal_stack
.push(Box::new(SaveAsModal::for_buffer_path(Some(&target), None)));
app.dispatch_modal_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), 40, 80);
assert!(!app.modal_stack.contains::<SaveAsModal>());
assert_eq!(
std::fs::read_to_string(&target).expect("file written"),
"moved contents",
);
// The buffer and App now live at the new path, dirty cleared.
assert_eq!(app.file_path.as_deref(), Some(target.as_path()));
assert_eq!(app.editor.buffer.path(), Some(target.as_path()));
assert!(!app.editor.dirty);
}
#[test]
fn after_save_continuation_runs_on_success() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("quit.md");
let mut app = make_app();
app.editor.buffer = Buffer::new();
app.editor.buffer.insert(0, "scratch");
app.editor.refresh_parsed();
app.editor.dirty = true;
// Seed the field with the target path (an absolute temp path that
// doesn't yet exist, so no overwrite confirm) and accept it.
app.modal_stack.push(Box::new(SaveAsModal::for_buffer_path(
Some(&target),
Some(Box::new(|app| app.should_quit = true)),
)));
app.dispatch_modal_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), 40, 80);
assert!(!app.modal_stack.contains::<SaveAsModal>());
assert!(
app.should_quit,
"continuation must run after a successful save"
);
assert_eq!(app.editor.buffer.path(), Some(target.as_path()));
}
}