Added saving, save-as

This commit is contained in:
Joshua Barretto 2026-01-07 17:16:23 +00:00
parent 1456571671
commit 2b4464debd
8 changed files with 210 additions and 121 deletions

View file

@ -10,6 +10,8 @@
- [x] Multiple panes
- [x] Pane creation/deletion
- [x] Opener
- [x] Save
- [x] Save as
- [x] Find
- [x] Search in buffer switcher
- [x] File saving
@ -38,6 +40,7 @@ Buffers represent open files and are tracked independently of view panes.
```
Ctrl + o = Open buffer
Ctrl + s = Save buffer
Ctrl + Shift + s = Save buffer as
Ctrl + b = Switch buffer
```

View file

@ -49,6 +49,7 @@ pub enum Action {
OpenSwitcher,
// Open the file opener
OpenOpener(PathBuf),
OpenSaver(PathBuf),
// Open the finder, with the given default query
OpenFinder(Option<String>),
// Switch the current pane to the given buffer
@ -57,6 +58,8 @@ pub enum Action {
OpenFile(PathBuf, Option<usize>),
// Create a new file and switch the current pane to it
CreateFile(PathBuf),
// Save the file in the current pane to the possibly-new path
SaveFileAs(PathBuf),
// Start a new command
CommandStart(&'static str),
// Go to the specified file line
@ -73,6 +76,8 @@ pub enum Action {
Save,
// Save the current buffer, forcefully
Overwrite,
// Save the current buffer as a new path, forcefully
OverwriteFileAs(PathBuf),
// Reload the current file from disk, losing unsaved changes
Reload,
// (action, pos, is_ctrl, drag_id)
@ -329,7 +334,7 @@ impl RawEvent {
}
}
pub fn to_open_opener(&self, path: &PathBuf) -> Option<Action> {
pub fn to_open_browser(&self, path: &PathBuf) -> Option<Action> {
if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
@ -340,6 +345,16 @@ impl RawEvent {
})
) {
Some(Action::OpenOpener(path.clone()))
} else if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Char('s'),
modifiers,
kind: KeyEventKind::Press,
..
}) if *modifiers == KeyModifiers::CONTROL | KeyModifiers::SHIFT
) {
Some(Action::OpenSaver(path.clone()))
} else {
None
}

View file

@ -229,22 +229,31 @@ impl Buffer {
Ok(Self::file(unsaved, chars, path))
}
pub fn save(&mut self) -> Result<(), Error> {
pub fn save_as(&mut self, path: PathBuf) -> Result<(), Error> {
// Ensure trailing newline exists
if self.text.chars.last().map_or(false, |c| *c != '\n') {
self.insert(self.text.chars.len(), ['\n']);
}
let path = self.path.as_ref().expect("buffer must have path to save");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, self.text.to_string())?;
std::fs::write(&path, self.text.to_string())?;
self.path = Some(path);
self.diverged = false;
self.opened_at = Some(SystemTime::now());
self.unsaved = false;
Ok(())
}
pub fn save(&mut self) -> Result<(), Error> {
if let Some(path) = self.path.take() {
self.save_as(path)
} else {
// TODO: Not okay!
Ok(())
}
}
pub fn name(&self) -> Option<String> {
Some(
match self.path.as_ref()?.file_name().and_then(|n| n.to_str()) {

View file

@ -76,15 +76,15 @@ impl Element for Doc {
match event.to_action(|e| {
e.to_open_switcher()
.or_else(|| e.to_open_opener(&open_path))
.or_else(|| e.to_open_browser(&open_path))
.or_else(|| e.to_open_finder(None))
.or_else(|| e.to_move())
.or_else(|| e.to_save())
.or_else(|| e.to_path_search())
}) {
action @ Some(Action::OpenSwitcher) | action @ Some(Action::OpenOpener(_)) => {
Ok(Resp::handled(action.map(Into::into)))
}
action @ Some(Action::OpenSwitcher)
| action @ Some(Action::OpenOpener(_))
| action @ Some(Action::OpenSaver(_)) => Ok(Resp::handled(action.map(Into::into))),
ref action @ Some(Action::OpenFinder(ref query)) => {
self.finder = Some(Finder::new(
buffer.cursors[*cursor_id],
@ -134,13 +134,6 @@ impl Element for Doc {
Action::Show(Some(format!("Could not create file")), format!("{err}")).into(),
))),
},
Some(Action::Overwrite) => Ok(Resp::handled(buffer.save().err().map(|err| {
Action::Show(Some("Could not save file".to_string()), err.to_string()).into()
}))),
Some(Action::Reload) => {
buffer.reload();
Ok(Resp::handled(None))
}
Some(Action::Save) => Ok(Resp::handled(if buffer.diverged {
Some(
Action::Confirm(
@ -150,18 +143,37 @@ impl Element for Doc {
.into(),
)
} else if buffer.path.is_none() {
Some(
Action::Show(
None,
"Error: buffer does not have a path (TODO: implement save_as)".to_string(),
)
.into(),
)
Some(Action::OpenSaver(std::env::current_dir().expect("no cwd")).into())
} else {
buffer.save().err().map(|err| {
Action::Show(Some("Could not save file".to_string()), err.to_string()).into()
})
})),
Some(Action::Overwrite) => Ok(Resp::handled(buffer.save().err().map(|err| {
Action::Show(Some("Could not save file".to_string()), err.to_string()).into()
}))),
Some(Action::SaveFileAs(path)) => Ok(Resp::handled(if path.exists() {
Some(
Action::Confirm(
format!("File already exists on disk. Are you sure you wish to overwrite it (y/n)?"),
Box::new(Action::OverwriteFileAs(path)),
)
.into(),
)
} else {
buffer.save_as(path).err().map(|err| {
Action::Show(Some("Could not save file".to_string()), err.to_string()).into()
})
})),
Some(Action::OverwriteFileAs(path)) => {
Ok(Resp::handled(buffer.save_as(path).err().map(|err| {
Action::Show(Some("Could not save file".to_string()), err.to_string()).into()
})))
}
Some(Action::Reload) => {
buffer.reload();
Ok(Resp::handled(None))
}
_ => {
let Some(buffer) = state.buffers.get_mut(self.buffer) else {
return Err(event);

View file

@ -10,7 +10,7 @@ pub use self::{
doc::{Doc, Finder},
input::Input,
panes::Panes,
prompt::{Confirm, Opener, Prompt, Show, Switcher},
prompt::{Confirm, FileBrowser, FileBrowserMode, Prompt, Show, Switcher},
root::Root,
search::Searcher,
status::Status,

View file

@ -7,7 +7,7 @@ pub enum PaneKind {
}
enum PaneTask {
Opener(Opener),
FileBrowser(FileBrowser),
Switcher(Switcher),
}
@ -21,7 +21,17 @@ impl Element for Pane {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp, Event> {
match event.to_action(|_| None) {
Some(Action::OpenOpener(path)) => {
self.task = Some(PaneTask::Opener(Opener::new(path)));
self.task = Some(PaneTask::FileBrowser(FileBrowser::new(
path,
FileBrowserMode::Opener,
)));
Ok(Resp::handled(None))
}
Some(Action::OpenSaver(path)) => {
self.task = Some(PaneTask::FileBrowser(FileBrowser::new(
path,
FileBrowserMode::Save,
)));
Ok(Resp::handled(None))
}
Some(Action::OpenSwitcher) => {
@ -36,7 +46,7 @@ impl Element for Pane {
_ => {
let event = if let Some(task) = &mut self.task {
let resp = match task {
PaneTask::Opener(opener) => opener.handle(state, event),
PaneTask::FileBrowser(browser) => browser.handle(state, event),
PaneTask::Switcher(switcher) => switcher.handle(state, event),
};
match resp {
@ -64,8 +74,8 @@ impl Element for Pane {
impl Visual for Pane {
fn render(&mut self, state: &State, frame: &mut Rect) {
let remaining_space = match &mut self.task {
Some(PaneTask::Opener(opener)) => {
opener.render(state, frame);
Some(PaneTask::FileBrowser(browser)) => {
browser.render(state, frame);
None
}
Some(PaneTask::Switcher(switcher)) => {
@ -225,7 +235,10 @@ impl Panes {
if path.is_dir() {
(
state.new_anonymous(),
Some(PaneTask::Opener(Opener::new(path.clone()))),
Some(PaneTask::FileBrowser(FileBrowser::new(
path.clone(),
FileBrowserMode::Opener,
))),
)
} else {
(state.create(path.clone()).ok()?, None)

View file

@ -301,17 +301,23 @@ impl Visual for BufferId {
}
}
pub struct Opener {
pub enum FileBrowserMode {
Opener,
Save,
}
pub struct FileBrowser {
pub options: Options<FileOption>,
// Filter
pub buffer: Buffer,
pub cursor_id: CursorId,
pub input: Input,
preview: Option<(Buffer, CursorId, Input)>,
mode: FileBrowserMode,
}
impl Opener {
pub fn new(path: PathBuf) -> Self {
impl FileBrowser {
pub fn new(path: PathBuf, mode: FileBrowserMode) -> Self {
let mut buffer = Buffer::default();
let cursor_id = buffer.start_session();
match path.display().to_string().as_str() {
@ -327,6 +333,7 @@ impl Opener {
buffer,
input: Input::filter(),
preview: None,
mode,
};
this.update_completions();
this
@ -420,7 +427,7 @@ impl Opener {
}
}
impl Element<()> for Opener {
impl Element<()> for FileBrowser {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<()>, Event> {
let path_str = self.buffer.text.to_string();
let res = match event.to_action(|e| e.to_cancel().or_else(|| e.to_char().map(Action::Char))) {
@ -448,8 +455,14 @@ impl Element<()> for Opener {
self.set_string(&format!("{}/", file.path.display()));
Ok(Resp::handled(None))
},
FileKind::File => Ok(Resp::end(Some(Action::OpenFile(file.path, None).into()))),
FileKind::New => Ok(Resp::end(Some(Action::CreateFile(file.path).into()))),
FileKind::File => match &self.mode {
FileBrowserMode::Opener => Ok(Resp::end(Some(Action::OpenFile(file.path, None).into()))),
FileBrowserMode::Save => Ok(Resp::end(Some(Action::SaveFileAs(file.path).into()))),
},
FileKind::New => match &self.mode {
FileBrowserMode::Opener => Ok(Resp::end(Some(Action::CreateFile(file.path).into()))),
FileBrowserMode::Save => Ok(Resp::end(Some(Action::SaveFileAs(file.path).into()))),
},
FileKind::Unknown => Ok(Resp::handled(None)),
}
Ok(None) => Ok(Resp::handled(None)),
@ -521,7 +534,7 @@ impl Visual for FileOption {
}
}
impl Visual for Opener {
impl Visual for FileBrowser {
fn render(&mut self, state: &State, frame: &mut Rect) {
self.preview = self.options.selected().and_then(|f| {
self.preview
@ -557,8 +570,12 @@ impl Visual for Opener {
[frame.size()[0], path_input_sz],
)
.with(|f| {
let title = match &self.mode {
FileBrowserMode::Opener => "Open file",
FileBrowserMode::Save => "Save file",
};
self.input
.render(state, None, &self.buffer, self.cursor_id, None, f)
.render(state, Some(title), &self.buffer, self.cursor_id, None, f)
});
}
}

View file

@ -45,101 +45,121 @@ impl Element<()> for Root {
.map(Event::Action)
.unwrap_or(event);
// Pass the event down through the list of tasks until we meet one that can handle it
let mut task_idx = self.tasks.len();
let event = loop {
task_idx = match task_idx.checked_sub(1) {
Some(task_idx) => task_idx,
None => {
break match self.panes.handle(state, event) {
Ok(resp) => resp.event,
Err(event) => Some(event),
};
}
};
let res = match &mut self.tasks[task_idx] {
Task::Prompt(p) => p.handle(state, event),
Task::Show(s) => s.handle(state, event),
Task::Confirm(c) => c.handle(state, event),
Task::Searcher(s) => s.handle(state, event),
};
match res {
Ok(resp) => {
// If the task has requested that it should end, kill it and all of its children
if resp.is_end() {
self.tasks.truncate(task_idx);
loop {
// Pass the event down through the list of tasks until we meet one that can handle it
let mut task_idx = self.tasks.len();
event = loop {
task_idx = match task_idx.checked_sub(1) {
Some(task_idx) => task_idx,
None => {
break match self.panes.handle(state, event) {
Ok(resp) => match resp.event {
Some(new_event) => new_event,
None => return Ok(Resp::handled(None)),
},
Err(event) => event,
};
}
event = if let Some(event) = resp.event {
event
} else {
break None;
};
}
Err(e) => event = e,
}
};
};
// Handle 'top-level' actions
if let Some(action) = event.as_ref().and_then(|e| {
e.to_action(|e| {
let res = match &mut self.tasks[task_idx] {
Task::Prompt(p) => p.handle(state, event),
Task::Show(s) => s.handle(state, event),
Task::Confirm(c) => c.handle(state, event),
Task::Searcher(s) => s.handle(state, event),
};
match res {
Ok(resp) => {
// If the task has requested that it should end, kill it and all of its children
if resp.is_end() {
self.tasks.truncate(task_idx);
}
event = if let Some(event) = resp.event {
event
} else {
return Ok(Resp::handled(None));
};
}
Err(e) => event = e,
}
};
// Handle 'top-level' actions
event = if let Some(action) = event.to_action(|e| {
e.to_open_prompt()
.or_else(|| e.to_cancel())
.or_else(|| e.to_command_start())
})
}) {
match action {
Action::OpenPrompt => {
self.tasks.clear(); // Prompt overrides all
self.tasks.push(Task::Prompt(Prompt::new("")));
}
Action::OpenSearcher(path, needle) => {
self.tasks.clear(); // Overrides all
self.tasks.push(Task::Searcher(Searcher::new(path, needle)));
}
Action::CommandStart(cmd) => {
self.tasks.clear(); // Prompt overrides all
self.tasks
.push(Task::Prompt(Prompt::new(&format!("{cmd} "))));
}
Action::Cancel => {
let unsaved = state.buffers.values().filter(|b| b.unsaved).count();
if state.buffers.is_empty() {
return Ok(Resp::end(None));
} else {
self.tasks.push(Task::Confirm(Confirm {
label: Label(if unsaved == 0 {
format!("Are you sure you wish to quit? (y/n). You have multiple documents open!")
} else {
format!("Are you sure you wish to quit? (y/n). Note that {} files are unsaved!", unsaved)
}),
action: Action::Quit,
}));
}) {
match action {
Action::OpenPrompt => {
self.tasks.clear(); // Prompt overrides all
self.tasks.push(Task::Prompt(Prompt::new("")));
break Ok(Resp::handled(None));
}
}
Action::Confirm(q, action) => self.tasks.push(Task::Confirm(Confirm {
label: Label(q),
action: *action,
})),
Action::Show(title, text) => self.tasks.push(Task::Show(Show {
title,
label: Label(text),
})),
Action::Quit => return Ok(Resp::end(None)),
action => {
return self
Action::OpenSearcher(path, needle) => {
self.tasks.clear(); // Overrides all
self.tasks.push(Task::Searcher(Searcher::new(path, needle)));
break Ok(Resp::handled(None));
}
Action::CommandStart(cmd) => {
self.tasks.clear(); // Prompt overrides all
self.tasks
.push(Task::Prompt(Prompt::new(&format!("{cmd} "))));
break Ok(Resp::handled(None));
}
Action::Cancel => {
let unsaved = state.buffers.values().filter(|b| b.unsaved).count();
if state.buffers.is_empty() {
break Ok(Resp::end(None));
} else {
self.tasks.push(Task::Confirm(Confirm {
label: Label(if unsaved == 0 {
format!("Are you sure you wish to quit? (y/n). You have multiple documents open!")
} else {
format!("Are you sure you wish to quit? (y/n). Note that {} files are unsaved!", unsaved)
}),
action: Action::Quit,
}));
break Ok(Resp::handled(None));
}
}
Action::Confirm(q, action) => {
self.tasks.push(Task::Confirm(Confirm {
label: Label(q),
action: *action,
}));
break Ok(Resp::handled(None));
}
Action::Show(title, text) => {
self.tasks.push(Task::Show(Show {
title,
label: Label(text),
}));
break Ok(Resp::handled(None));
}
Action::Quit => break Ok(Resp::end(None)),
action => match self
.panes
.handle(state, Event::Action(action))
.map(|r| r.into_can_end());
.map(|r| r.into_can_end::<()>())
{
Ok(resp) if resp.is_end() => return Ok(resp),
Ok(resp) => {
if let Some(new_event) = resp.event {
new_event
} else {
// Nothing to do
break Ok(Resp::handled(None));
}
}
Err(event) => break Err(event),
},
}
} else {
break Err(event);
}
} else if let Some(event) = event {
return Err(event);
}
// Root element swallows all other events
Ok(Resp::handled(None))
}
}