From b724edee4d6a453d12ed729a07d509a6774fc598 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:04:43 +0000 Subject: [PATCH 1/3] feat(desktop-gpui): add a copy file action to finished exports --- apps/desktop-gpui/src/editor_export.rs | 73 ++++++++++++ apps/desktop-gpui/src/library.rs | 3 + apps/desktop-gpui/src/library/file_copy.rs | 122 +++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 apps/desktop-gpui/src/library/file_copy.rs diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 77498a4103d..7b3e423075e 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -206,6 +206,8 @@ pub struct ExportUi { pub rendered: u32, pub total_frames: u32, pub output_path: Option, + copying_file: bool, + copied_file: Option, pub error: Option, pub cancel: Arc, pub export_task: Option>, @@ -296,6 +298,8 @@ impl ExportUi { rendered: 0, total_frames: 0, output_path: None, + copying_file: false, + copied_file: None, error: None, cancel: Arc::new(AtomicBool::new(false)), export_task: None, @@ -835,6 +839,8 @@ impl EditorWindow { ui.rendered = 0; ui.total_frames = 0; ui.output_path = None; + ui.copied_file = None; + ui.copying_file = false; cx.notify(); ui.export_task = Some(cx.spawn_in(window, async move |this, cx| { @@ -1023,6 +1029,46 @@ impl EditorWindow { })); } + fn duplicate_output(&mut self, cx: &mut Context) { + let Some(ui) = self.export.as_mut() else { + return; + }; + if ui.copying_file { + return; + } + let Some(source) = ui.output_path.clone() else { + return; + }; + ui.copying_file = true; + ui.copied_file = None; + ui.error = None; + cx.notify(); + cx.spawn(async move |this, cx| { + let path = source.clone(); + let result = cx + .background_executor() + .spawn(async move { library::duplicate_file(&path) }) + .await; + this.update(cx, |this, cx| { + let Some(ui) = this + .export + .as_mut() + .filter(|ui| ui.output_path.as_ref() == Some(&source)) + else { + return; + }; + ui.copying_file = false; + match result { + Ok(path) => ui.copied_file = Some(path), + Err(error) => ui.error = Some(error), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + fn retry_clipboard_copy(&mut self, window: &mut Window, cx: &mut Context) { let Some(ui) = self.export.as_mut() else { return; @@ -2549,6 +2595,19 @@ impl EditorWindow { .child(error), ) }) + .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(), |this| { this.child( ui::Button::plain( @@ -2647,6 +2706,20 @@ impl EditorWindow { } }), ) + .child( + ui::Button::plain( + &theme, + "export-copy-file", + ui::ButtonVariant::Gray, + ui::ButtonSize::Md, + ) + .icon("icons/copy.svg") + .label("Copy File") + .disabled(ui.copying_file) + .on_click(cx.listener(|this, _, _, cx| { + this.duplicate_output(cx); + })), + ) }, ) .when( diff --git a/apps/desktop-gpui/src/library.rs b/apps/desktop-gpui/src/library.rs index f45cef1cb7d..ea04167c84e 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 00000000000..ee4ac4ed9b4 --- /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); + } +} From 436ff3378b3b263847669d6d0b55cab8989252c2 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:43:59 +0000 Subject: [PATCH 2/3] fix(desktop-gpui): hold the export panel busy while a copy runs --- apps/desktop-gpui/src/editor_export.rs | 48 +++++++++++--------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 7b3e423075e..04d750868c1 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -206,7 +206,6 @@ pub struct ExportUi { pub rendered: u32, pub total_frames: u32, pub output_path: Option, - copying_file: bool, copied_file: Option, pub error: Option, pub cancel: Arc, @@ -298,7 +297,6 @@ impl ExportUi { rendered: 0, total_frames: 0, output_path: None, - copying_file: false, copied_file: None, error: None, cancel: Arc::new(AtomicBool::new(false)), @@ -840,7 +838,6 @@ impl EditorWindow { ui.total_frames = 0; ui.output_path = None; ui.copied_file = None; - ui.copying_file = false; cx.notify(); ui.export_task = Some(cx.spawn_in(window, async move |this, cx| { @@ -1029,40 +1026,38 @@ impl EditorWindow { })); } - fn duplicate_output(&mut self, cx: &mut Context) { - let Some(ui) = self.export.as_mut() else { + /// 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; }; - if ui.copying_file { - return; - } let Some(source) = ui.output_path.clone() else { return; }; - ui.copying_file = true; + ui.phase = ExportPhase::Copying; ui.copied_file = None; ui.error = None; cx.notify(); - cx.spawn(async move |this, cx| { - let path = source.clone(); + cx.spawn_in(window, async move |this, cx| { let result = cx .background_executor() - .spawn(async move { library::duplicate_file(&path) }) + .spawn(async move { library::duplicate_file(&source) }) .await; - this.update(cx, |this, cx| { - let Some(ui) = this - .export - .as_mut() - .filter(|ui| ui.output_path.as_ref() == Some(&source)) - else { - return; - }; - ui.copying_file = false; - match result { - Ok(path) => ui.copied_file = Some(path), - Err(error) => ui.error = Some(error), + 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(); }) @@ -2715,9 +2710,8 @@ impl EditorWindow { ) .icon("icons/copy.svg") .label("Copy File") - .disabled(ui.copying_file) - .on_click(cx.listener(|this, _, _, cx| { - this.duplicate_output(cx); + .on_click(cx.listener(|this, _, window, cx| { + this.duplicate_output(window, cx); })), ) }, From 84fd6c1421a00f35d89c162cd56ad9b53360197b Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:04:16 +0000 Subject: [PATCH 3/3] fix(desktop-gpui): only offer actions that work while copying an export --- apps/desktop-gpui/src/editor_export.rs | 28 ++++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 04d750868c1..91a1d1c9c90 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -2603,7 +2603,7 @@ impl EditorWindow { ) }, ) - .when(ui.phase.is_busy(), |this| { + .when(ui.phase.is_busy() && ui.phase != ExportPhase::Copying, |this| { this.child( ui::Button::plain( &theme, @@ -2701,19 +2701,21 @@ impl EditorWindow { } }), ) - .child( - ui::Button::plain( - &theme, - "export-copy-file", - ui::ButtonVariant::Gray, - ui::ButtonSize::Md, + .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); + })), ) - .icon("icons/copy.svg") - .label("Copy File") - .on_click(cx.listener(|this, _, window, cx| { - this.duplicate_output(window, cx); - })), - ) + }) }, ) .when(