diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 77498a4103..91a1d1c9c9 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -206,6 +206,7 @@ pub struct ExportUi { pub rendered: u32, pub total_frames: u32, pub output_path: Option, + copied_file: Option, pub error: Option, pub cancel: Arc, pub export_task: Option>, @@ -296,6 +297,7 @@ impl ExportUi { rendered: 0, total_frames: 0, output_path: None, + copied_file: None, error: None, cancel: Arc::new(AtomicBool::new(false)), export_task: None, @@ -835,6 +837,7 @@ impl EditorWindow { ui.rendered = 0; ui.total_frames = 0; ui.output_path = None; + ui.copied_file = None; cx.notify(); ui.export_task = Some(cx.spawn_in(window, async move |this, cx| { @@ -1023,6 +1026,44 @@ impl EditorWindow { })); } + /// Runs in the `Copying` phase so closing the panel, starting another + /// export and installing an update all wait for the copy to finish. + fn duplicate_output(&mut self, window: &mut Window, cx: &mut Context) { + let Some(ui) = self + .export + .as_mut() + .filter(|ui| ui.phase == ExportPhase::Done) + else { + return; + }; + let Some(source) = ui.output_path.clone() else { + return; + }; + ui.phase = ExportPhase::Copying; + ui.copied_file = None; + ui.error = None; + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { library::duplicate_file(&source) }) + .await; + this.update_in(cx, |this, window, cx| { + if let Some(ui) = this.export.as_mut() { + ui.phase = ExportPhase::Done; + match result { + Ok(path) => ui.copied_file = Some(path), + Err(error) => ui.error = Some(error), + } + } + cx.notify(); + this.finish_requested_export_close(window, cx); + }) + .ok(); + }) + .detach(); + } + fn retry_clipboard_copy(&mut self, window: &mut Window, cx: &mut Context) { let Some(ui) = self.export.as_mut() else { return; @@ -2549,7 +2590,20 @@ impl EditorWindow { .child(error), ) }) - .when(ui.phase.is_busy(), |this| { + .when_some( + ui.copied_file.as_ref().and_then(|path| path.file_name()), + |this, name| { + this.child( + div() + .max_w(px(360.)) + .text_size(px(12.)) + .text_color(Hsla::from(editor.text_2)) + .text_center() + .child(format!("Saved a copy as {}", name.to_string_lossy())), + ) + }, + ) + .when(ui.phase.is_busy() && ui.phase != ExportPhase::Copying, |this| { this.child( ui::Button::plain( &theme, @@ -2647,6 +2701,21 @@ impl EditorWindow { } }), ) + .when(ui.phase == ExportPhase::Done, |this| { + this.child( + ui::Button::plain( + &theme, + "export-copy-file", + ui::ButtonVariant::Gray, + ui::ButtonSize::Md, + ) + .icon("icons/copy.svg") + .label("Copy File") + .on_click(cx.listener(|this, _, window, cx| { + this.duplicate_output(window, cx); + })), + ) + }) }, ) .when( diff --git a/apps/desktop-gpui/src/library.rs b/apps/desktop-gpui/src/library.rs index f45cef1cb7..ea04167c84 100644 --- a/apps/desktop-gpui/src/library.rs +++ b/apps/desktop-gpui/src/library.rs @@ -16,6 +16,9 @@ //! all it does is fan jobs out to that executor. Nothing here touches gpui //! state. +mod file_copy; +pub use file_copy::duplicate_file; + use std::{ collections::HashSet, path::{Path, PathBuf}, diff --git a/apps/desktop-gpui/src/library/file_copy.rs b/apps/desktop-gpui/src/library/file_copy.rs new file mode 100644 index 0000000000..ee4ac4ed9b --- /dev/null +++ b/apps/desktop-gpui/src/library/file_copy.rs @@ -0,0 +1,122 @@ +use std::{ + fs::{File, OpenOptions}, + io::{self, ErrorKind}, + path::{Path, PathBuf}, +}; + +fn copy_path(source: &Path, number: u64) -> PathBuf { + let mut name = source.file_stem().unwrap_or_default().to_os_string(); + name.push(" copy"); + if number > 1 { + name.push(format!(" {number}")); + } + if let Some(extension) = source.extension() { + name.push("."); + name.push(extension); + } + source.with_file_name(name) +} + +pub fn duplicate_file(source: &Path) -> Result { + duplicate(source).map_err(|error| format!("Failed to copy file: {error}")) +} + +fn duplicate(source: &Path) -> io::Result { + let mut input = File::open(source)?; + if !input.metadata()?.is_file() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "only files can be copied", + )); + } + let mut number = 1; + loop { + let destination = copy_path(source, number); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination) + { + Ok(mut output) => { + if let Err(error) = io::copy(&mut input, &mut output) { + drop(output); + let _ = std::fs::remove_file(&destination); + return Err(error); + } + return Ok(destination); + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => number += 1, + Err(error) => return Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn copy_names_preserve_extensions() { + for (source, expected) in [ + ("video.mp4", "video copy.mp4"), + ("video.final.mp4", "video.final copy.mp4"), + ("recording", "recording copy"), + (".hidden", ".hidden copy"), + ("日本語.png", "日本語 copy.png"), + ("video copy.mp4", "video copy copy.mp4"), + ] { + assert_eq!(copy_path(Path::new(source), 1), Path::new(expected)); + } + assert_eq!( + copy_path(Path::new("/output/video.mp4"), 2), + Path::new("/output/video copy 2.mp4") + ); + } + + #[test] + fn existing_copies_are_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("video.final.mp4"); + std::fs::write(&source, b"original").unwrap(); + std::fs::write(dir.path().join("video.final copy.mp4"), b"first").unwrap(); + std::fs::create_dir(dir.path().join("video.final copy 2.mp4")).unwrap(); + let copy = duplicate_file(&source).unwrap(); + assert_eq!(copy, dir.path().join("video.final copy 3.mp4")); + assert_eq!(std::fs::read(copy).unwrap(), b"original"); + assert_eq!( + std::fs::read(dir.path().join("video.final copy.mp4")).unwrap(), + b"first" + ); + assert_eq!(std::fs::read(source).unwrap(), b"original"); + } + + #[test] + fn concurrent_copies_get_distinct_names() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("screenshot.png"); + std::fs::write(&source, b"pixels").unwrap(); + let copies = std::thread::scope(|scope| { + let first = scope.spawn(|| duplicate_file(&source).unwrap()); + let second = scope.spawn(|| duplicate_file(&source).unwrap()); + [first.join().unwrap(), second.join().unwrap()] + }); + assert_ne!(copies[0], copies[1]); + for copy in copies { + assert_eq!(std::fs::read(copy).unwrap(), b"pixels"); + } + } + + #[test] + fn missing_files_and_project_directories_fail_without_a_copy() { + let dir = tempfile::tempdir().unwrap(); + assert!( + duplicate_file(&dir.path().join("missing.mp4")) + .unwrap_err() + .starts_with("Failed to copy file:") + ); + let project = dir.path().join("recording.cap"); + std::fs::create_dir(&project).unwrap(); + assert!(duplicate_file(&project).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } +}