` | 在文本编辑器中编辑所选文件 |
+| `` | 打开日志面板 |
+| `` | 退出 termscp |
+| `` | 重命名所选文件 |
+| `` | 将所选文件另存为新名称 |
+| `` | 将所选路径上的更改同步到远程 |
+| `` | 进入上级目录 |
+| `` | 使用该文件类型的默认程序打开所选文件 |
+| `` | 使用你指定的程序打开所选文件 |
+| `` | 执行命令 |
+| `` | 切换同步浏览 |
+| `` | 更改文件模式 |
+| `>` | 过滤文件(同时支持正则表达式和通配符匹配) |
+| `` | 选择所有文件 |
+| `` | 取消选择所有文件 |
+| `` | 中止文件传输过程 |
+| `` | 获取所选路径的总大小 |
+| `` | 显示所有已同步的路径 |
diff --git a/dprint.json b/dprint.json
new file mode 100644
index 0000000..892c8d2
--- /dev/null
+++ b/dprint.json
@@ -0,0 +1,39 @@
+{
+ "$schema": "https://dprint.dev/schemas/v0.json",
+ "indentWidth": 2,
+ "lineWidth": 80,
+ "newLineKind": "lf",
+ "markdown": {
+ "textWrap": "maintain"
+ },
+ "toml": {},
+ "yaml": {},
+ "exec": {
+ "cwd": "${configDir}",
+ "commands": [
+ {
+ "command": "rustup run nightly rustfmt --edition 2024",
+ "exts": ["rs"],
+ "cacheKeyFiles": ["rustfmt.toml", "rust-toolchain.toml"]
+ }
+ ]
+ },
+ "excludes": [
+ "**/target",
+ "**/node_modules",
+ "**/*-lock.json",
+ "Cargo.lock",
+ "docs/book",
+ "docs/zh-CN/cli/cli.md",
+ "docs/zh-CN/configuration/explorer-format.md",
+ "docs/zh-CN/configuration/themes.md",
+ "docs/zh-CN/usage/keyboard-shortcuts.md",
+ "**/tests/fixtures"
+ ],
+ "plugins": [
+ "https://plugins.dprint.dev/markdown-0.22.1.wasm@4906fbb038977732aae0e216e4b0b957e2722f8d79282660ccb36d27dd051f17",
+ "https://plugins.dprint.dev/toml-0.7.0.wasm@0126c8112691542d30b52a639076ecc83e07bace877638cee7c6915fd36b8629",
+ "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm@40a2fdda7040317eb1b23520f3a00769a5571eedb049c4ca9175c1b9eeba01ae",
+ "https://plugins.dprint.dev/dprint/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463"
+ ]
+}
diff --git a/just/build.just b/just/build.just
new file mode 100644
index 0000000..4983419
--- /dev/null
+++ b/just/build.just
@@ -0,0 +1,34 @@
+# Build everything
+[group('build')]
+build_all: build_crates
+
+# Build the Rust crate
+[group('build')]
+build_crates args="":
+ cargo build --workspace {{ args }}
+
+# Build all Rust crates in release mode
+[group('build')]
+build_crates_release:
+ just build_crates "--release"
+
+# Build a release binary for a target triple
+[group('build')]
+build_release target features="":
+ cargo build --locked --release --target {{ target }} {{ features }}
+
+# Package an already-built Linux release as a Debian package
+[group('build')]
+package_deb target:
+ cargo deb --no-build --target {{ target }} --features smb-vendored
+
+# Update Cargo.lock; pass cargo update arguments to scope the update.
+[group('build')]
+update_lock args="":
+ cargo update {{ args }}
+
+# Clean build artifacts
+[group('build')]
+[confirm("Are you sure you want to clean the build artifacts?")]
+clean:
+ cargo clean
diff --git a/just/changelog.just b/just/changelog.just
new file mode 100644
index 0000000..1159e6f
--- /dev/null
+++ b/just/changelog.just
@@ -0,0 +1,44 @@
+set positional-arguments
+
+# Print the unreleased changelog section to stdout (preview only), e.g. `just changelog_preview 8.1.0`
+[group('changelog')]
+changelog_preview version:
+ git-cliff --config cliff.toml --unreleased --tag "v$1" --strip all
+
+# Add a new entry to CHANGELOG.md from the unreleased conventional commits, e.g. `just changelog 8.1.0`
+[group('changelog')]
+changelog version:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ version="$1"
+ if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
+ echo "invalid release version: $version (expected MAJOR.MINOR.PATCH)" >&2
+ exit 2
+ fi
+ tag="v$version"
+ section="$(git-cliff --config cliff.toml --unreleased --tag "$tag" --strip all)"
+ if [ -z "$section" ]; then
+ echo "No unreleased conventional commits found; nothing to add." >&2
+ exit 0
+ fi
+ anchor="$(printf '%s' "$version" | tr -d '.')"
+ secfile="$(mktemp)"
+ tmp="$(mktemp)"
+ printf '%s\n' "$section" > "$secfile"
+ # Insert a TOC entry before the first existing version entry and the rendered
+ # section before the first existing version heading, keeping the title + TOC.
+ awk -v secfile="$secfile" -v ver="$version" -v anc="$anchor" '
+ !toc_done && /^[[:space:]]*- \[[0-9]/ {
+ print " - [" ver "](#" anc ")"
+ toc_done = 1
+ }
+ !body_done && /^## [0-9]/ {
+ while ((getline line < secfile) > 0) print line
+ print ""
+ body_done = 1
+ }
+ { print }
+ ' CHANGELOG.md > "$tmp"
+ rm -f "$secfile"
+ mv "$tmp" CHANGELOG.md
+ echo "CHANGELOG.md updated for $tag"
diff --git a/just/code_check.just b/just/code_check.just
new file mode 100644
index 0000000..5dd4cdb
--- /dev/null
+++ b/just/code_check.just
@@ -0,0 +1,57 @@
+alias lint := clippy
+
+# Format all sources (Markdown, TOML, YAML and Rust via nightly rustfmt) with dprint
+[group('code_check')]
+fmt args="":
+ dprint fmt {{ args }}
+
+# Check formatting of all sources with dprint (no writes)
+[group('code_check')]
+fmt_check args="":
+ dprint check {{ args }}
+
+# Run clippy on all targets
+[group('code_check')]
+clippy args="":
+ cargo clippy --workspace --all-targets {{ args }}
+
+# Build the crate documentation, denying warnings
+[group('code_check')]
+doc args="":
+ RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps {{ args }}
+
+# Check dependencies for advisories, licenses, bans and sources (cargo-deny)
+[group('code_check')]
+deny args="":
+ cargo deny check {{ args }}
+
+# Scan for secrets with trufflehog (defaults to the whole working tree)
+[group('code_check')]
+scan_secrets *args=".":
+ trufflehog filesystem {{ args }} --results=verified,unknown --fail --no-update
+
+# Point git at the tracked .githooks directory (installs the pre-commit hook)
+[group('code_check')]
+setup_githooks:
+ git config core.hooksPath .githooks
+ @echo "git hooks installed: core.hooksPath = .githooks"
+
+# Lint the install scripts (shellcheck; PowerShell parse when pwsh is available)
+[group('code_check')]
+check_install_scripts:
+ sh -n install.sh
+ shellcheck install.sh
+ @if command -v pwsh >/dev/null 2>&1; then \
+ pwsh -NoProfile -Command '$t = $null; $e = $null; $null = [System.Management.Automation.Language.Parser]::ParseFile("install.ps1", [ref]$t, [ref]$e); if ($e) { $e; exit 1 }'; \
+ else \
+ echo "pwsh not found: skipping install.ps1 parse check"; \
+ fi
+
+# Run all code checks. Fails if any check fails
+[group('code_check')]
+check_code:
+ just fmt_check
+ just clippy "-- -D warnings"
+ just doc
+ just deny
+ just check_install_scripts
diff --git a/just/publish.just b/just/publish.just
new file mode 100644
index 0000000..8558634
--- /dev/null
+++ b/just/publish.just
@@ -0,0 +1,4 @@
+# Publish the termscp crate
+[group('publish')]
+publish_crate args="":
+ cargo publish --locked --features smb-vendored {{ args }}
diff --git a/just/site.just b/just/site.just
new file mode 100644
index 0000000..36c3e75
--- /dev/null
+++ b/just/site.just
@@ -0,0 +1,28 @@
+# Install website dependencies from the lockfile
+[group('site')]
+site_install:
+ cd site && npm ci
+
+# Check website formatting
+[group('site')]
+site_fmt_check:
+ cd site && npm run format:check
+
+# Run Astro and TypeScript checks
+[group('site')]
+site_check:
+ cd site && npm run check
+
+# Run website tests when the package defines them
+[group('site')]
+site_test:
+ cd site && npm test --if-present
+
+# Build the website
+[group('site')]
+site_build:
+ cd site && npm run build
+
+# Run every website validation step
+[group('site')]
+site_ci: site_fmt_check site_check site_test site_build
diff --git a/just/test.just b/just/test.just
new file mode 100644
index 0000000..b62145d
--- /dev/null
+++ b/just/test.just
@@ -0,0 +1,13 @@
+# Run all tests
+[group('test')]
+test_all: test
+
+# Run the Rust test suite
+[group('test')]
+test args="":
+ cargo test --workspace {{ args }}
+
+# Generate an LCOV code coverage report for the Rust workspace (requires cargo-llvm-cov)
+[group('test')]
+coverage output="lcov.info":
+ cargo llvm-cov --workspace --lcov --output-path {{ output }}
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..a866dcb
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel = "1.98.0"
+components = ["clippy", "rustfmt"]
diff --git a/src/config/serialization.rs b/src/config/serialization.rs
index d945e8c..7bab606 100644
--- a/src/config/serialization.rs
+++ b/src/config/serialization.rs
@@ -200,7 +200,7 @@ mod tests {
.unwrap(),
PathBuf::from("/home/omar/.ssh/beaglebone.key")
);
- assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
+ assert!(!cfg.remote.ssh_keys.contains_key("1.1.1.1"));
}
#[test]
@@ -240,7 +240,7 @@ mod tests {
.unwrap(),
PathBuf::from("/home/omar/.ssh/beaglebone.key")
);
- assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
+ assert!(!cfg.remote.ssh_keys.contains_key("1.1.1.1"));
}
#[test]
diff --git a/src/explorer.rs b/src/explorer.rs
index b4285b5..862f817 100644
--- a/src/explorer.rs
+++ b/src/explorer.rs
@@ -401,7 +401,7 @@ mod tests {
assert_eq!(explorer.dirstack.len(), 2);
assert_eq!(*explorer.dirstack.get(1).unwrap(), PathBuf::from("/dev"));
assert_eq!(
- *explorer.dirstack.get(0).unwrap(),
+ *explorer.dirstack.front().unwrap(),
PathBuf::from("/home/omar")
);
}
@@ -425,7 +425,7 @@ mod tests {
assert!(explorer.get(100).is_none());
//assert_eq!(explorer.count(), 6);
// Verify (files are sorted by name)
- assert_eq!(explorer.files.get(0).unwrap().name(), ".git");
+ assert_eq!(explorer.files.first().unwrap().name(), ".git");
// Iter files (all)
assert_eq!(explorer.iter_files_all().count(), 6);
// Iter files (hidden excluded) (.git, .gitignore are hidden)
@@ -453,7 +453,7 @@ mod tests {
]);
explorer.sort_by(FileSorting::Name);
// First entry should be "Cargo.lock"
- assert_eq!(explorer.files.get(0).unwrap().name(), "Cargo.lock");
+ assert_eq!(explorer.files.first().unwrap().name(), "Cargo.lock");
// Last should be "src"
assert_eq!(explorer.files.get(8).unwrap().name(), "src");
}
@@ -469,7 +469,7 @@ mod tests {
explorer.set_files(vec![entry1, entry2]);
explorer.sort_by(FileSorting::ModifyTime);
// First entry should be "CODE_OF_CONDUCT.md"
- assert_eq!(explorer.files.get(0).unwrap().name(), "CODE_OF_CONDUCT.md");
+ assert_eq!(explorer.files.first().unwrap().name(), "CODE_OF_CONDUCT.md");
// Last should be "src"
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
}
@@ -485,7 +485,7 @@ mod tests {
explorer.set_files(vec![entry1, entry2]);
explorer.sort_by(FileSorting::CreationTime);
// First entry should be "CODE_OF_CONDUCT.md"
- assert_eq!(explorer.files.get(0).unwrap().name(), "CODE_OF_CONDUCT.md");
+ assert_eq!(explorer.files.first().unwrap().name(), "CODE_OF_CONDUCT.md");
// Last should be "src"
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
}
@@ -501,7 +501,7 @@ mod tests {
]);
explorer.sort_by(FileSorting::Size);
// Directory has size 4096
- assert_eq!(explorer.files.get(0).unwrap().name(), "src");
+ assert_eq!(explorer.files.first().unwrap().name(), "src");
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
assert_eq!(explorer.files.get(2).unwrap().name(), "CONTRIBUTING.md");
}
@@ -525,7 +525,7 @@ mod tests {
explorer.sort_by(FileSorting::Name);
explorer.group_dirs_by(Some(GroupDirs::First));
// First entry should be "docs"
- assert_eq!(explorer.files.get(0).unwrap().name(), "docs");
+ assert_eq!(explorer.files.first().unwrap().name(), "docs");
assert_eq!(explorer.files.get(1).unwrap().name(), "src");
// 3rd is file first for alphabetical order
assert_eq!(explorer.files.get(2).unwrap().name(), "Cargo.lock");
@@ -555,7 +555,7 @@ mod tests {
assert_eq!(explorer.files.get(8).unwrap().name(), "docs");
assert_eq!(explorer.files.get(9).unwrap().name(), "src");
// first is file for alphabetical order
- assert_eq!(explorer.files.get(0).unwrap().name(), "Cargo.lock");
+ assert_eq!(explorer.files.first().unwrap().name(), "Cargo.lock");
// Last in files should be "README.md" (last file for alphabetical ordening)
assert_eq!(explorer.files.get(7).unwrap().name(), "README.md");
}
diff --git a/src/host/localhost.rs b/src/host/localhost.rs
index ae06fae..63e0ef3 100644
--- a/src/host/localhost.rs
+++ b/src/host/localhost.rs
@@ -602,9 +602,11 @@ mod tests {
use pretty_assertions::assert_eq;
use super::*;
+ use crate::utils::test_helpers::create_sample_file;
+ #[cfg(posix)]
+ use crate::utils::test_helpers::make_file_at;
#[cfg(posix)]
use crate::utils::test_helpers::make_fsentry;
- use crate::utils::test_helpers::{create_sample_file, make_file_at};
#[test]
fn test_host_error_new() {
@@ -632,13 +634,13 @@ mod tests {
#[test]
#[cfg(win)]
fn test_host_localhost_new() {
- let mut host: Localhost = Localhost::new(PathBuf::from("C:\\users")).ok().unwrap();
+ let host: Localhost = Localhost::new(PathBuf::from("C:\\users")).ok().unwrap();
assert_eq!(host.wrkdir, PathBuf::from("C:\\users"));
// Scan dir
let entries = std::fs::read_dir(PathBuf::from("C:\\users").as_path()).unwrap();
let mut counter: usize = 0;
for _ in entries {
- counter = counter + 1;
+ counter += 1;
}
assert_eq!(host.files.len(), counter);
}
@@ -769,7 +771,7 @@ mod tests {
let host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
let files: Vec = host.files.clone();
// Verify files
- let file_0: &File = files.get(0).unwrap();
+ let file_0: &File = files.first().unwrap();
if file_0.name() == *"foo.txt" {
assert!(file_0.metadata.symlink.is_none());
} else {
@@ -827,7 +829,7 @@ mod tests {
let files: Vec = host.files.clone();
assert_eq!(files.len(), 1); // There should be 1 file now
// Remove file
- assert!(host.remove(files.get(0).unwrap()).is_ok());
+ assert!(host.remove(files.first().unwrap()).is_ok());
// There should be 0 files now
let files: Vec = host.files.clone();
assert_eq!(files.len(), 0); // There should be 0 files now
@@ -836,7 +838,7 @@ mod tests {
// Delete directory
let files: Vec = host.files.clone();
assert_eq!(files.len(), 1); // There should be 1 file now
- assert!(host.remove(files.get(0).unwrap()).is_ok());
+ assert!(host.remove(files.first().unwrap()).is_ok());
// Remove unexisting directory
assert!(
host.remove(&make_fsentry(PathBuf::from("/a/b/c/d"), true))
@@ -859,22 +861,22 @@ mod tests {
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
let files: Vec = host.files.clone();
assert_eq!(files.len(), 1); // There should be 1 file now
- assert_eq!(files.get(0).unwrap().name(), "foo.txt");
+ assert_eq!(files.first().unwrap().name(), "foo.txt");
// Rename file
let dst_path: PathBuf =
PathBuf::from(format!("{}/bar.txt", tmpdir.path().display()).as_str());
assert!(
- host.rename(files.get(0).unwrap(), dst_path.as_path())
+ host.rename(files.first().unwrap(), dst_path.as_path())
.is_ok()
);
// There should be still 1 file now, but named bar.txt
let files: Vec = host.files.clone();
assert_eq!(files.len(), 1); // There should be 0 files now
- assert_eq!(files.get(0).unwrap().name(), "bar.txt");
+ assert_eq!(files.first().unwrap().name(), "bar.txt");
// Fail
let bad_path: PathBuf = PathBuf::from("/asdailsjoidoewojdijow/ashdiuahu");
assert!(
- host.rename(files.get(0).unwrap(), bad_path.as_path())
+ host.rename(files.first().unwrap(), bad_path.as_path())
.is_err()
);
}
@@ -939,7 +941,7 @@ mod tests {
file2_path.push("bar.txt");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
- let file1_entry: File = host.files.get(0).unwrap().clone();
+ let file1_entry: File = host.files.first().unwrap().clone();
assert_eq!(file1_entry.name(), String::from("foo.txt"));
// Copy
assert!(host.copy(&file1_entry, file2_path.as_path()).is_ok());
@@ -969,7 +971,7 @@ mod tests {
let file2_path: PathBuf = PathBuf::from("bar.txt");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
- let file1_entry: File = host.files.get(0).unwrap().clone();
+ let file1_entry: File = host.files.first().unwrap().clone();
assert_eq!(file1_entry.name(), String::from("foo.txt"));
// Copy
assert!(host.copy(&file1_entry, file2_path.as_path()).is_ok());
@@ -989,7 +991,7 @@ mod tests {
assert!(file1.write_all(b"Hello world!\n").is_ok());
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
- let file1_entry: File = host.files.get(0).unwrap().clone();
+ let file1_entry: File = host.files.first().unwrap().clone();
assert_eq!(file1_entry.name(), String::from("foo.txt"));
// Copy with empty destination -> must fail and leave file untouched
assert!(
@@ -1022,7 +1024,7 @@ mod tests {
dir_dest.push("test_dest_dir/");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
- let dir_src_entry: File = host.files.get(0).unwrap().clone();
+ let dir_src_entry: File = host.files.first().unwrap().clone();
assert_eq!(dir_src_entry.name(), String::from("test_dir"));
// Copy
assert!(host.copy(&dir_src_entry, dir_dest.as_path()).is_ok());
@@ -1052,7 +1054,7 @@ mod tests {
let dir_dest: PathBuf = PathBuf::from("test_dest_dir/");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
- let dir_src_entry: File = host.files.get(0).unwrap().clone();
+ let dir_src_entry: File = host.files.first().unwrap().clone();
assert_eq!(dir_src_entry.name(), String::from("test_dir"));
// Copy
assert!(host.copy(&dir_src_entry, dir_dest.as_path()).is_ok());
diff --git a/src/main.rs b/src/main.rs
index 2d0e5ea..af93a51 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -88,13 +88,7 @@ fn parse_args(args: Args) -> Result {
// Match ticks
run_opts.ticks = Duration::from_millis(args.ticks);
// Remote argument
- match RemoteArgs::try_from(&args) {
- Err(err) => return Err(err),
- Ok(remote) => {
- // Set params
- run_opts.remote = remote;
- }
- }
+ run_opts.remote = RemoteArgs::try_from(&args)?;
// set activity based on remote state
run_opts.task = if run_opts.remote.remote.is_none() {
diff --git a/src/system/auto_update.rs b/src/system/auto_update.rs
index 150218d..5b567b6 100644
--- a/src/system/auto_update.rs
+++ b/src/system/auto_update.rs
@@ -68,7 +68,8 @@ impl Update {
}
/// Returns whether a new version of termscp is available
- /// In case of success returns Ok(Option), where the Option is Some(new_version);
+ /// In case of success returns `Ok(Option)`, where the option is
+ /// `Some(new_version)`;
/// otherwise if no version is available, return None
/// In case of error returns Error with the error description
pub fn is_new_version_available() -> Result