From 782f88b965970224917c0f76148f484b0af0d773 Mon Sep 17 00:00:00 2001 From: Collide <44722470+TD-Sky@users.noreply.github.com> Date: Mon, 25 Sep 2023 01:48:37 +0800 Subject: [PATCH] feat: new `link` command, which creates symlinks to the yanked files (#167) --- app/src/executor.rs | 14 +++++- config/docs/keymap.md | 1 - config/preset/keymap.toml | 4 +- core/src/files/file.rs | 5 +- core/src/manager/tab.rs | 4 +- core/src/manager/watcher.rs | 2 +- core/src/tasks/scheduler.rs | 25 ++++++++-- core/src/tasks/tasks.rs | 18 +++++-- core/src/tasks/workers/file.rs | 89 ++++++++++++++++++++++++---------- shared/src/fns.rs | 84 +++++++++++++++++++++++++++++++- 10 files changed, 200 insertions(+), 46 deletions(-) diff --git a/app/src/executor.rs b/app/src/executor.rs index 51e928db..a406782c 100644 --- a/app/src/executor.rs +++ b/app/src/executor.rs @@ -98,16 +98,26 @@ impl Executor { "open" => cx.manager.open(exec.named.contains_key("interactive")), "yank" => cx.manager.yank(exec.named.contains_key("cut")), "paste" => { - let dest = cx.manager.cwd().to_owned(); + let dest = cx.manager.cwd(); let (cut, src) = cx.manager.yanked(); let force = exec.named.contains_key("force"); if *cut { cx.tasks.file_cut(src, dest, force) } else { - cx.tasks.file_copy(src, dest, force, exec.named.contains_key("follow")) + cx.tasks.file_copy(src, dest, force) } } + "link" => { + let (cut, src) = cx.manager.yanked(); + !cut + && cx.tasks.file_link( + src, + cx.manager.cwd(), + exec.named.contains_key("relative"), + exec.named.contains_key("force"), + ) + } "remove" => { let targets = cx.manager.selected().into_iter().map(|f| f.url_owned()).collect(); let force = exec.named.contains_key("force"); diff --git a/config/docs/keymap.md b/config/docs/keymap.md index c6ebb0f7..81e7025f 100644 --- a/config/docs/keymap.md +++ b/config/docs/keymap.md @@ -56,7 +56,6 @@ - paste: Paste the files. - `--force`: Overwrite the destination file if it exists. - - `--follow`: Copy the file pointed to by a symbolic link, rather than the link itself. Only valid during copying. - remove: Move the files to the trash/recycle bin. diff --git a/config/preset/keymap.toml b/config/preset/keymap.toml index d21b91cc..71e0282e 100644 --- a/config/preset/keymap.toml +++ b/config/preset/keymap.toml @@ -51,8 +51,8 @@ keymap = [ { on = [ "x" ], exec = "yank --cut", desc = "Cut the selected files" }, { on = [ "p" ], exec = "paste", desc = "Paste the files" }, { on = [ "P" ], exec = "paste --force", desc = "Paste the files (overwrite if the destination exists)" }, - { on = [ "k" ], exec = "paste --follow", desc = "Paste the files (follow the symlinks)" }, - { on = [ "K" ], exec = "paste --follow --force", desc = "Paste the files (overwrite + follow)" }, + { on = [ "n" ], exec = "link", desc = "Symlink the absolute path of files" }, + { on = [ "N" ], exec = "link --relative", desc = "Symlink the relative path of files" }, { on = [ "d" ], exec = "remove", desc = "Move the files to the trash" }, { on = [ "D" ], exec = "remove --permanently", desc = "Permanently delete the files" }, { on = [ "a" ], exec = "create", desc = "Create a file or directory (ends with / for directories)" }, diff --git a/core/src/files/file.rs b/core/src/files/file.rs index 3e01c076..b1b1d756 100644 --- a/core/src/files/file.rs +++ b/core/src/files/file.rs @@ -42,14 +42,11 @@ impl File { pub fn url(&self) -> &Url { &self.url } #[inline] - pub fn set_url(&mut self, url: Url) { self.url = url; } + pub fn url_mut(&mut self) -> &mut Url { &mut self.url } #[inline] pub fn url_owned(&self) -> Url { self.url.clone() } - #[inline] - pub fn url_os_str(&self) -> &OsStr { self.url.as_os_str() } - #[inline] pub fn name(&self) -> Option<&OsStr> { self.url.file_name() } diff --git a/core/src/manager/tab.rs b/core/src/manager/tab.rs index 5f141b7c..295e8c65 100644 --- a/core/src/manager/tab.rs +++ b/core/src/manager/tab.rs @@ -222,7 +222,7 @@ impl Tab { let mut it = self.selected().into_iter().peekable(); while let Some(f) = it.next() { s.push(match type_ { - "path" => f.url_os_str(), + "path" => f.url().as_os_str(), "dirname" => f.url().parent().map_or(OsStr::new(""), |p| p.as_os_str()), "filename" => f.name().unwrap_or(OsStr::new("")), "name_without_ext" => f.stem().unwrap_or(OsStr::new("")), @@ -356,7 +356,7 @@ impl Tab { let selected: Vec<_> = self .selected() .into_iter() - .map(|f| (f.url_os_str().to_owned(), Default::default())) + .map(|f| (f.url().as_os_str().to_owned(), Default::default())) .collect(); let mut exec = exec.to_owned(); diff --git a/core/src/manager/watcher.rs b/core/src/manager/watcher.rs index f4bf91b1..d7538095 100644 --- a/core/src/manager/watcher.rs +++ b/core/src/manager/watcher.rs @@ -197,7 +197,7 @@ impl Watcher { let mut new = Vec::with_capacity(files.len()); for file in files { let mut file = file.clone(); - file.set_url(ori.join(file.url().strip_prefix(url).unwrap())); + *file.url_mut() = ori.join(file.url().strip_prefix(url).unwrap()); new.push(file); } new diff --git a/core/src/tasks/scheduler.rs b/core/src/tasks/scheduler.rs index d48f1002..3c4a0259 100644 --- a/core/src/tasks/scheduler.rs +++ b/core/src/tasks/scheduler.rs @@ -8,7 +8,7 @@ use shared::{unique_path, Throttle, Url}; use tokio::{fs, select, sync::{mpsc::{self, UnboundedReceiver}, oneshot}, time::sleep}; use tracing::{info, trace}; -use super::{workers::{File, FileOpDelete, FileOpPaste, FileOpTrash, Precache, PrecacheOpMime, PrecacheOpSize, Process, ProcessOpOpen}, Running, TaskOp, TaskStage}; +use super::{workers::{File, FileOpDelete, FileOpLink, FileOpPaste, FileOpTrash, Precache, PrecacheOpMime, PrecacheOpSize, Process, ProcessOpOpen}, Running, TaskOp, TaskStage}; use crate::emit; pub struct Scheduler { @@ -218,7 +218,7 @@ impl Scheduler { }); } - pub(super) fn file_copy(&self, from: Url, mut to: Url, force: bool, follow: bool) { + pub(super) fn file_copy(&self, from: Url, mut to: Url, force: bool) { let name = format!("Copy {:?} to {:?}", from, to); let id = self.running.write().add(name); @@ -228,7 +228,26 @@ impl Scheduler { if !force { to = unique_path(to).await; } - file.paste(FileOpPaste { id, from, to, cut: false, follow, retry: 0 }).await.ok(); + file.paste(FileOpPaste { id, from, to, cut: false, follow: true, retry: 0 }).await.ok(); + } + .boxed() + }); + } + + pub(super) fn file_link(&self, from: Url, mut to: Url, relative: bool, force: bool) { + let name = format!("Link {from:?} to {to:?}"); + let id = self.running.write().add(name); + + let _ = self.todo.send_blocking({ + let file = self.file.clone(); + async move { + if !force { + to = unique_path(to).await; + } + file + .link(FileOpLink { id, from, to, meta: None, resolve: false, relative, delete: false }) + .await + .ok(); } .boxed() }); diff --git a/core/src/tasks/tasks.rs b/core/src/tasks/tasks.rs index 8c94a547..158fd9b1 100644 --- a/core/src/tasks/tasks.rs +++ b/core/src/tasks/tasks.rs @@ -155,7 +155,7 @@ impl Tasks { false } - pub fn file_cut(&self, src: &HashSet, dest: Url, force: bool) -> bool { + pub fn file_cut(&self, src: &HashSet, dest: &Url, force: bool) -> bool { for u in src { let to = dest.join(u.file_name().unwrap()); if force && u == &to { @@ -167,13 +167,25 @@ impl Tasks { false } - pub fn file_copy(&self, src: &HashSet, dest: Url, force: bool, follow: bool) -> bool { + pub fn file_copy(&self, src: &HashSet, dest: &Url, force: bool) -> bool { for u in src { let to = dest.join(u.file_name().unwrap()); if force && u == &to { trace!("file_copy: same file, skipping {:?}", to); } else { - self.scheduler.file_copy(u.clone(), to, force, follow); + self.scheduler.file_copy(u.clone(), to, force); + } + } + false + } + + pub fn file_link(&self, src: &HashSet, dest: &Url, relative: bool, force: bool) -> bool { + for u in src { + let to = dest.join(u.file_name().unwrap()); + if force && *u == to { + trace!("file_link: same file, skipping {:?}", to); + } else { + self.scheduler.file_link(u.clone(), to, relative, force); } } false diff --git a/core/src/tasks/workers/file.rs b/core/src/tasks/workers/file.rs index 52d8f77d..7060b43d 100644 --- a/core/src/tasks/workers/file.rs +++ b/core/src/tasks/workers/file.rs @@ -1,9 +1,9 @@ -use std::{collections::VecDeque, fs::Metadata, path::{Path, PathBuf}}; +use std::{borrow::Cow, collections::VecDeque, fs::Metadata, path::{Path, PathBuf}}; use anyhow::Result; use config::TASKS; use futures::{future::BoxFuture, FutureExt}; -use shared::{calculate_size, copy_with_progress, Url}; +use shared::{calculate_size, copy_with_progress, path_relative_to, Url}; use tokio::{fs, io::{self, ErrorKind::{AlreadyExists, NotFound}}, sync::mpsc}; use tracing::trace; @@ -36,11 +36,13 @@ pub(crate) struct FileOpPaste { #[derive(Clone, Debug)] pub(crate) struct FileOpLink { - pub id: usize, - pub from: Url, - pub to: Url, - pub cut: bool, - pub length: u64, + pub id: usize, + pub from: Url, + pub to: Url, + pub meta: Option, + pub resolve: bool, + pub relative: bool, + pub delete: bool, } #[derive(Clone, Debug)] @@ -114,33 +116,49 @@ impl File { self.sch.send(TaskOp::Adv(task.id, 1, 0))?; } FileOp::Link(task) => { - let src = match fs::read_link(&task.from).await { - Ok(src) => src, - Err(e) if e.kind() == NotFound => { - self.log(task.id, format!("Link task partially done: {:?}", task))?; - return Ok(self.sch.send(TaskOp::Adv(task.id, 1, task.length))?); + let meta = task.meta.as_ref().unwrap(); + + let src = if task.resolve { + match fs::read_link(&task.from).await { + Ok(p) => Cow::Owned(p), + Err(e) if e.kind() == NotFound => { + self.log(task.id, format!("Link task partially done: {:?}", task))?; + return Ok(self.sch.send(TaskOp::Adv(task.id, 1, meta.len()))?); + } + Err(e) => Err(e)?, } - Err(e) => Err(e)?, + } else { + Cow::Borrowed(task.from.as_path()) + }; + + let src = if task.relative { + path_relative_to(&src, &fs::canonicalize(task.to.parent().unwrap()).await?) + } else { + src }; match fs::remove_file(&task.to).await { Err(e) if e.kind() != NotFound => Err(e)?, _ => { - #[cfg(target_os = "windows")] - { - fs::symlink_file(src, &task.to).await? - } - #[cfg(not(target_os = "windows"))] + #[cfg(unix)] { fs::symlink(src, &task.to).await? } + #[cfg(windows)] + { + if meta.is_dir() { + fs::symlink_dir(src, &task.to).await? + } else { + fs::symlink_file(src, &task.to).await? + } + } } } - if task.cut { + if task.delete { fs::remove_file(&task.from).await.ok(); } - self.sch.send(TaskOp::Adv(task.id, 1, task.length))?; + self.sch.send(TaskOp::Adv(task.id, 1, meta.len()))?; } FileOp::Delete(task) => { if let Err(e) = fs::remove_file(&task.target).await { @@ -192,7 +210,7 @@ impl File { if meta.is_file() { self.tx.send(FileOp::Paste(task)).await?; } else if meta.is_symlink() { - self.tx.send(FileOp::Link(task.to_link(meta.len()))).await?; + self.tx.send(FileOp::Link(task.to_link(meta))).await?; } return self.done(id); } @@ -205,7 +223,7 @@ impl File { let dest = root.join(src.components().skip(skip).collect::()); match fs::create_dir(&dest).await { Err(e) if e.kind() != AlreadyExists => { - self.log(task.id, format!("Create dir failed: {:?}, {e}", dest))?; + self.log(task.id, format!("Create dir failed: {dest:?}, {e}"))?; continue; } _ => {} @@ -214,7 +232,7 @@ impl File { let mut it = match fs::read_dir(&src).await { Ok(it) => it, Err(e) => { - self.log(task.id, format!("Read dir failed: {:?}, {e}", src))?; + self.log(task.id, format!("Read dir failed: {src:?}, {e}"))?; continue; } }; @@ -237,13 +255,24 @@ impl File { if meta.is_file() { self.tx.send(FileOp::Paste(task.clone())).await?; } else if meta.is_symlink() { - self.tx.send(FileOp::Link(task.to_link(meta.len()))).await?; + self.tx.send(FileOp::Link(task.to_link(meta))).await?; } } } self.done(task.id) } + pub(crate) async fn link(&self, mut task: FileOpLink) -> Result<()> { + let id = task.id; + if task.meta.is_none() { + task.meta = Some(fs::symlink_metadata(&task.from).await?); + } + + self.sch.send(TaskOp::New(id, task.meta.as_ref().unwrap().len()))?; + self.tx.send(FileOp::Link(task)).await?; + self.done(id) + } + pub(crate) async fn delete(&self, mut task: FileOpDelete) -> Result<()> { let meta = fs::symlink_metadata(&task.target).await?; if !meta.is_dir() { @@ -321,7 +350,15 @@ impl File { } impl FileOpPaste { - fn to_link(&self, length: u64) -> FileOpLink { - FileOpLink { id: self.id, from: self.from.clone(), to: self.to.clone(), cut: self.cut, length } + fn to_link(&self, meta: Metadata) -> FileOpLink { + FileOpLink { + id: self.id, + from: self.from.clone(), + to: self.to.clone(), + meta: Some(meta), + resolve: true, + relative: false, + delete: self.cut, + } } } diff --git a/shared/src/fns.rs b/shared/src/fns.rs index 5b915a98..be1daa28 100644 --- a/shared/src/fns.rs +++ b/shared/src/fns.rs @@ -1,4 +1,4 @@ -use std::{env, path::{Path, PathBuf}}; +use std::{borrow::Cow, env, path::{Component, Path, PathBuf}}; use tokio::fs; @@ -51,7 +51,7 @@ pub fn readable_size(size: u64) -> String { } pub async fn unique_path(mut p: Url) -> Url { - let Some(name) = p.file_name().map(|n| n.to_os_string()) else { + let Some(name) = p.file_name().map(|n| n.to_owned()) else { return p; }; @@ -65,6 +65,51 @@ pub async fn unique_path(mut p: Url) -> Url { p } +// Parmaters +// * `path`: The absolute path(contains no `/./`) to get relative path. +// * `root`: The absolute path(contains no `/./`) to be compared. +// +// Return +// * Unix: The relative format to `root` of `path`. +// * Windows: The relative format to `root` of `path`; or `path` itself when +// `path` and `root` are both under different disk drives. +pub fn path_relative_to<'a>(path: &'a Path, root: &Path) -> Cow<'a, Path> { + assert!(path.is_absolute()); + assert!(root.is_absolute()); + let mut p_comps = path.components(); + let mut r_comps = root.components(); + + // 1. Ensure that the two paths have the same prefix. + // 2. Strips any common prefix the two paths do have. + // + // NOTE: + // Prefixes are platform dependent, + // but different prefixes would for example indicate paths for different drives + // on Windows. + let (p_head, r_head) = loop { + use std::path::Component::*; + match (p_comps.next(), r_comps.next()) { + (Some(RootDir), Some(RootDir)) => (), + (Some(Prefix(a)), Some(Prefix(b))) if a == b => (), + (Some(Prefix(_) | RootDir), _) | (_, Some(Prefix(_) | RootDir)) => { + return Cow::from(path); + } + (None, None) => break (None, None), + (a, b) if a != b => break (a, b), + _ => (), + } + }; + + let p_comps = p_head.into_iter().chain(p_comps); + let walk_up = r_head.into_iter().chain(r_comps).map(|_| Component::ParentDir); + + let mut buf = PathBuf::new(); + buf.extend(walk_up); + buf.extend(p_comps); + + Cow::from(buf) +} + #[inline] pub fn optional_bool(s: &str) -> Option { if s == "true" { @@ -75,3 +120,38 @@ pub fn optional_bool(s: &str) -> Option { None } } + +#[cfg(test)] +mod tests { + use std::{borrow::Cow, path::Path}; + + use super::path_relative_to; + + #[cfg(unix)] + #[test] + fn test_path_relative_to() { + fn assert(path: &str, root: &str, res: &str) { + assert_eq!(path_relative_to(Path::new(path), Path::new(root)), Cow::Borrowed(Path::new(res))); + } + + assert("/a/b", "/a/b/c", "../"); + assert("/a/b/c", "/a/b", "c"); + assert("/a/b/c", "/a/b/d", "../c"); + assert("/a", "/a/b/c", "../../"); + assert("/a/a/b", "/a/b/b", "../../a/b"); + } + + #[cfg(windows)] + #[test] + fn test_path_relative_to() { + fn assert(path: &str, root: &str, res: &str) { + assert_eq!(path_relative_to(Path::new(path), Path::new(root)), Cow::Borrowed(Path::new(res))); + } + + assert("C:\\a\\b", "C:\\a\\b\\c", "..\\"); + assert("C:\\a\\b\\c", "C:\\a\\b", "c"); + assert("C:\\a\\b\\c", "C:\\a\\b\\d", "..\\c"); + assert("C:\\a", "C:\\a\\b\\c", "..\\..\\"); + assert("C:\\a\\a\\b", "C:\\a\\b\\b", "..\\..\\a\\b"); + } +}