Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion apps/desktop-gpui/src/editor_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ pub struct ExportUi {
pub rendered: u32,
pub total_frames: u32,
pub output_path: Option<PathBuf>,
copied_file: Option<PathBuf>,
pub error: Option<String>,
pub cancel: Arc<AtomicBool>,
pub export_task: Option<gpui::Task<()>>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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<Self>) {
let Some(ui) = self
.export
.as_mut()
.filter(|ui| ui.phase == ExportPhase::Done)
Comment thread
yashranaway marked this conversation as resolved.
else {
return;
};
let Some(source) = ui.output_path.clone() else {
return;
};
ui.phase = ExportPhase::Copying;
Comment thread
yashranaway marked this conversation as resolved.
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();
Comment thread
yashranaway marked this conversation as resolved.
}

fn retry_clipboard_copy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(ui) = self.export.as_mut() else {
return;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-gpui/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
122 changes: 122 additions & 0 deletions apps/desktop-gpui/src/library/file_copy.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf, String> {
duplicate(source).map_err(|error| format!("Failed to copy file: {error}"))
}

fn duplicate(source: &Path) -> io::Result<PathBuf> {
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);
}
}
Loading