diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index e416e813f..7c81160dd 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -21,8 +21,9 @@ | `:line-ending` | Set the document's default line ending. Options: crlf, lf. | | `:earlier`, `:ear` | Jump back to an earlier point in edit history. Accepts a number of steps or a time span. | | `:later`, `:lat` | Jump to a later point in edit history. Accepts a number of steps or a time span. | -| `:write-quit`, `:wq`, `:x` | Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt) | -| `:write-quit!`, `:wq!`, `:x!` | Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt) | +| `:write-quit`, `:wq` | Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt) | +| `:write-quit!`, `:wq!` | Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt) | +| `:exit`, `:xit`, `:x`, `:x!` | Save the current view if named and modified (but not externally), then quit. | | `:write-all`, `:wa` | Write changes from all buffers to disk. | | `:write-all!`, `:wa!` | Forcefully write changes from all buffers to disk creating necessary subdirectories. | | `:write-quit-all`, `:wqa`, `:xa` | Write changes from all buffers to disk and close all views. | diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 82cad8386..db7508b71 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -728,6 +728,28 @@ fn force_write_quit( force_quit(cx, Args::default(), event) } +/// exit command: Write only if named and modified (but not externally), then quit. +fn write_exit(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (_, doc) = current!(cx.editor); + + // Current document named and modified? + // Write changes if not externally modified already. + if doc.is_modified() { + if doc.path().is_some() && !doc.externally_overwritten() { + write_impl(cx, None, false)?; + cx.block_try_flush_writes()?; + } else { + doc.reset_modified(); + } + } + + quit(cx, Args::default(), event) +} + /// Results in an error if there are modified buffers remaining and sets editor /// error, otherwise returns `Ok(())`. If the current document is unmodified, /// and there are modified documents, switches focus to one of them. @@ -2906,7 +2928,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ }, TypableCommand { name: "write-quit", - aliases: &["wq", "x"], + aliases: &["wq"], doc: "Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt)", fun: write_quit, completer: CommandCompleter::positional(&[completers::filename]), @@ -2918,7 +2940,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ }, TypableCommand { name: "write-quit!", - aliases: &["wq!", "x!"], + aliases: &["wq!"], doc: "Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt)", fun: force_write_quit, completer: CommandCompleter::positional(&[completers::filename]), @@ -2928,6 +2950,17 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ ..Signature::DEFAULT }, }, + TypableCommand { + name: "exit", + aliases: &["xit", "x", "x!"], + doc: "Save the current view if named and modified (but not externally), then quit.", + fun: write_exit, + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::DEFAULT + }, + }, TypableCommand { name: "write-all", aliases: &["wa"], diff --git a/helix-term/tests/test/commands/write.rs b/helix-term/tests/test/commands/write.rs index 0cf09e1ea..84fb954d2 100644 --- a/helix-term/tests/test/commands/write.rs +++ b/helix-term/tests/test/commands/write.rs @@ -143,8 +143,10 @@ async fn test_overwrite_protection() -> anyhow::Result<()> { file.as_file_mut().flush()?; file.as_file_mut().sync_all()?; + let last_saved_time = file.path().metadata()?.modified()?; - test_key_sequence(&mut app, Some(":x"), None, false).await?; + // Exit empty unmodified view, externally changed file shouldn't be overwritten + test_key_sequence(&mut app, Some(":x"), None, true).await?; reload_file(&mut file).unwrap(); let mut file_content = String::new(); @@ -152,6 +154,46 @@ async fn test_overwrite_protection() -> anyhow::Result<()> { assert_eq!("extremely important content", file_content); + let saved_time = file.path().metadata()?.modified()?; + assert_eq!(saved_time, last_saved_time); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_overwrite_if_not_modified_since() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + file.as_file_mut().write_all("34".as_bytes())?; + file.as_file_mut().flush()?; + file.as_file_mut().sync_all()?; + + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; + + helpers::run_event_loop_until_idle(&mut app).await; + + // Modify and exit allowing saving because file hasn't been changed externally since opening + test_key_sequence(&mut app, Some("i12:x"), None, true).await?; + + reload_file(&mut file).unwrap(); + let mut file_content = String::new(); + file.read_to_string(&mut file_content)?; + + assert_eq!("1234", file_content); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_exit_if_not_named() -> anyhow::Result<()> { + let mut app = helpers::AppBuilder::new().build()?; + + helpers::run_event_loop_until_idle(&mut app).await; + + // Modify and exit without saving because file hasn't been named yet + test_key_sequence(&mut app, Some("i12:x"), None, true).await?; + Ok(()) } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 04b7703c5..2b7ca00cd 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1183,23 +1183,21 @@ impl Document { pub fn pickup_last_saved_time(&mut self) { self.last_saved_time = match self.path() { - Some(path) => match path.metadata() { - Ok(metadata) => match metadata.modified() { - Ok(mtime) => mtime, - Err(err) => { - log::debug!("Could not fetch file system's mtime, falling back to current system time: {}", err); - SystemTime::now() - } - }, - Err(err) => { - log::debug!("Could not fetch file system's mtime, falling back to current system time: {}", err); - SystemTime::now() - } - }, + Some(path) => path.metadata().and_then(|m| m.modified()).unwrap_or_else(|err| { + log::debug!("Could not fetch file system's mtime, falling back to current system time: {}", err); + SystemTime::now() + }), None => SystemTime::now(), }; } + /// Path given and externally modified since `last_saved_time`? + pub fn externally_overwritten(&self) -> bool { + self.path() + .and_then(|p| p.metadata().ok().and_then(|m| m.modified().ok())) + .is_some_and(|mtime| mtime > self.last_saved_time) + } + // Detect if the file is readonly and change the readonly field if necessary (unix only) pub fn detect_readonly(&mut self) { // Allows setting the flag for files the user cannot modify, like root files