mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Issue #947: compare a cross-volume move's copy in 1 MiB chunks; Move to Folder… uses the shared folder picker
move_across_volumes read both whole files into memory to compare them; it now compares lengths first, then streams 1 MiB chunks. The grid's Move to Folder… asks through pick::ask (background dialog where the host has one) like the other folder pickers. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
This commit is contained in:
2 files changed
+73
-8
No files matched your search
@@ -397,11 +397,7 @@ impl MoveFs for RealFs {
|
||||
/// byte for byte, and only then remove the original. A copy that doesn't match is removed.
|
||||
fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
fs.copy_no_replace(a, b)?;
|
||||
let same = match (std::fs::read(a), std::fs::read(b)) {
|
||||
(Ok(x), Ok(y)) => x == y,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
if !same_contents(a, b).unwrap_or(false) {
|
||||
let _ = std::fs::remove_file(b);
|
||||
return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display())));
|
||||
}
|
||||
@@ -411,6 +407,43 @@ fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so
|
||||
/// a large raw or video is never read into memory whole.
|
||||
fn same_contents(a: &Path, b: &Path) -> std::io::Result<bool> {
|
||||
use std::io::Read;
|
||||
const CHUNK: usize = 1 << 20;
|
||||
let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?);
|
||||
if fa.metadata()?.len() != fb.metadata()?.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]);
|
||||
loop {
|
||||
let n = read_full(&mut fa, &mut ba)?;
|
||||
let m = read_full(&mut fb, &mut bb)?;
|
||||
if n != m || ba.get(..n) != bb.get(..m) {
|
||||
return Ok(false);
|
||||
}
|
||||
if n == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer
|
||||
/// only at the end of the file).
|
||||
fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut filled = 0;
|
||||
while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) {
|
||||
match r.read(rest) {
|
||||
Ok(0) => break,
|
||||
Ok(k) => filled = filled.saturating_add(k),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
|
||||
/// volume): the rename must go through a temporary name.
|
||||
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
|
||||
@@ -784,6 +817,31 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// The cross-volume move's check compares files chunk by chunk: equal files across several
|
||||
/// chunks, a difference in the last chunk, different lengths, a missing file.
|
||||
#[test]
|
||||
fn same_contents_compares_in_chunks() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let big: Vec<u8> = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect();
|
||||
let (a, b) = (dir.join("a"), dir.join("b"));
|
||||
std::fs::write(&a, &big).unwrap();
|
||||
std::fs::write(&b, &big).unwrap();
|
||||
assert!(same_contents(&a, &b).unwrap());
|
||||
let mut other = big.clone();
|
||||
*other.last_mut().unwrap() ^= 1;
|
||||
std::fs::write(&b, &other).unwrap();
|
||||
assert!(!same_contents(&a, &b).unwrap());
|
||||
std::fs::write(&b, &big[..big.len() - 1]).unwrap();
|
||||
assert!(!same_contents(&a, &b).unwrap());
|
||||
std::fs::write(&a, b"").unwrap();
|
||||
std::fs::write(&b, b"").unwrap();
|
||||
assert!(same_contents(&a, &b).unwrap());
|
||||
assert!(same_contents(&a, &dir.join("missing")).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or
|
||||
/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail.
|
||||
pub(crate) struct FakeFs {
|
||||
|
||||
@@ -984,9 +984,16 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
if ui.button(crate::i18n::tr("Rename…")).clicked() {
|
||||
let _ = app.run("dialog.rename", json!({}));
|
||||
}
|
||||
if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move to Folder…")).clicked() {
|
||||
let into = app.services.pick_folder.as_mut().and_then(|f| f());
|
||||
if let Some(into) = into {
|
||||
let can_pick = app.services.pick_folder.is_some() || app.services.picker.is_some();
|
||||
if can_pick && ui.button(crate::i18n::tr("Move to Folder…")).clicked() {
|
||||
// the folder dialog goes through the shared picker (off the UI thread where the host has
|
||||
// one: then photo.moveToFolder runs when it closes)
|
||||
let req = crate::pick::PickRequest::folder(crate::i18n::tr("Move to Folder…"));
|
||||
let picked =
|
||||
crate::pick::ask(app, "photo.moveToFolder", &json!({}), "folder", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]));
|
||||
if let crate::pick::Picked::Now(v) = picked
|
||||
&& let Some(into) = v.into_iter().next()
|
||||
{
|
||||
match app.run("photo.moveToFolder", json!({"folder": into})) {
|
||||
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
|
||||
Reference in new issue
Block a user