Skip to content

Commit efffb9b

Browse files
committed
Refactor PathInfo, harden cancel logic, unify key dispatch, add cargo-deny
- PathInfo now stores path: PathBuf and display_name: String, supporting non-UTF8 filenames (lossy display) instead of silently dropping them - Replace unreachable!() in cancel_most_recent_task with proper pop-then-match - Unify hardcoded key dispatch: handler.rs now uses hardcoded_action() from keybindings.rs as single source of truth - Eliminate intermediate Vec allocations in sort() via sort_by closures - Add macOS ARM64 to CI build matrix (aarch64-apple-darwin) - Quote clipboard paths with shell_words::quote for newline safety - Switch CI from deprecated cargo-audit to EmbarkStudios/cargo-deny-action - Convert dir_total_size from recursive to iterative (no stack overflow)
1 parent c1fe848 commit efffb9b

18 files changed

Lines changed: 179 additions & 169 deletions

File tree

.github/workflows/release.yml

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,24 @@ jobs:
3939
- name: Run tests
4040
run: cargo test
4141

42-
- name: Audit dependencies
43-
run: cargo install cargo-audit && cargo audit
42+
- name: Check dependencies
43+
uses: EmbarkStudios/cargo-deny-action@v2
4444

4545
build:
4646
needs: check
4747
runs-on: ${{ matrix.os }}
4848
strategy:
4949
matrix:
50-
os: [ubuntu-latest, macos-latest]
50+
include:
51+
- os: ubuntu-latest
52+
target: x86_64-unknown-linux-gnu
53+
artifact_name: filectrl-linux
54+
- os: macos-latest
55+
target: x86_64-apple-darwin
56+
artifact_name: filectrl-macos
57+
- os: macos-latest
58+
target: aarch64-apple-darwin
59+
artifact_name: filectrl-macos-arm64
5160

5261
steps:
5362
- name: Checkout code
@@ -60,14 +69,17 @@ jobs:
6069
if: matrix.os == 'ubuntu-latest'
6170
run: sudo apt-get update && sudo apt-get install -y libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
6271

72+
- name: Add Rust target
73+
run: rustup target add ${{ matrix.target }}
74+
6375
- name: Build
64-
run: cargo build --release
76+
run: cargo build --release --target ${{ matrix.target }}
6577

6678
- name: Upload build artifact
6779
uses: actions/upload-artifact@v7
6880
with:
69-
name: filectrl-${{ matrix.os }}
70-
path: target/release/filectrl
81+
name: ${{ matrix.artifact_name }}
82+
path: target/${{ matrix.target }}/release/filectrl
7183

7284
release:
7385
needs: build
@@ -94,8 +106,9 @@ jobs:
94106
- name: Prepare release assets
95107
run: |
96108
mkdir release-assets
97-
cp artifacts/filectrl-ubuntu-latest/filectrl release-assets/filectrl-linux
98-
cp artifacts/filectrl-macos-latest/filectrl release-assets/filectrl-macos
109+
cp artifacts/filectrl-linux/filectrl release-assets/filectrl-linux
110+
cp artifacts/filectrl-macos/filectrl release-assets/filectrl-macos
111+
cp artifacts/filectrl-macos-arm64/filectrl release-assets/filectrl-macos-arm64
99112
100113
- name: Create release
101114
uses: softprops/action-gh-release@v3
@@ -109,3 +122,4 @@ jobs:
109122
files: |
110123
release-assets/filectrl-linux
111124
release-assets/filectrl-macos
125+
release-assets/filectrl-macos-arm64

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# FileCTRL
22

3-
FileCTRL is a light, opinionated, responsive, theme-able, and simple Text User Interface (TUI) file manager for Linux and macOS
3+
FileCTRL is a lightweight, opinionated, responsive, theme-able, and simple Text User Interface (TUI) file manager for Linux and macOS
44

55
[![42KM theme](./screenshots/42KM.png)](./screenshots/42KM.png)
66

@@ -44,7 +44,7 @@ Run `filectrl --help` to view the available command line arguments and options:
4444
```text
4545
Usage: filectrl [-c <config>] [-i <include...>] [--write-default-config] [--write-default-themes] [--colors-256] [--keybindings] [--] [<directory>]
4646
47-
FileCTRL is a light, opinionated, responsive, theme-able, and simple Text User Interface (TUI) file manager for Linux and macOS
47+
FileCTRL is a lightweight, opinionated, responsive, theme-able, and simple Text User Interface (TUI) file manager for Linux and macOS
4848
4949
Positional Arguments:
5050
directory path to a directory to navigate to
@@ -174,7 +174,7 @@ Run `filectrl --write-default-config` to write the [default configuration](./src
174174
You can also override only the properties you want to change:
175175
176176
```bash
177-
$ cat ~/.config/filectrl/config.toml
177+
cat ~/.config/filectrl/config.toml
178178
[openers.linux]
179179
open_current_directory = "alacritty --working-directory %s"
180180
open_new_window = "alacritty --command filectrl %s"

deny.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# cargo-deny configuration
2+
# https://embarkstudios.github.io/cargo-deny/
3+
4+
[advisories]
5+
ignore = []
6+
7+
[licenses]
8+
allow = [
9+
"Apache-2.0",
10+
"BSD-2-Clause",
11+
"BSD-3-Clause",
12+
"ISC",
13+
"MIT",
14+
"OpenSSL",
15+
"Unicode-3.0",
16+
"Zlib",
17+
]
18+
19+
[bans]
20+
multiple-versions = "warn"
21+
22+
[sources]
23+
unknown-registry = "deny"
24+
unknown-git = "deny"

src/app/clipboard.rs

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::{
44
};
55

66
use crate::file_system::path_info::PathInfo;
7-
use anyhow::{Error, anyhow};
7+
use anyhow::{Error, Result, anyhow};
88
use arboard::Clipboard as ArboardClipboard;
99
use log::warn;
1010

@@ -96,23 +96,18 @@ impl ClipboardEntry {
9696
}
9797
}
9898

99-
/// Serialized as `"cp /path\n/path2..."` or `"mv /path\n/path2..."` in the system clipboard.
100-
/// Paths are stored unquoted because they are never passed to a shell -- they are parsed back
101-
/// into `PathInfo` values and used directly with Rust filesystem APIs (`fs::copy`, `fs::rename`,
102-
/// etc.), so there is no shell injection risk.
99+
/// Serialized as `"cp '/path/one' '/path/two'"` in the system clipboard.
100+
/// Paths are quoted with `shell_words::quote` so filenames containing spaces,
101+
/// newlines, or other shell metacharacters round-trip correctly.
103102
impl Display for ClipboardEntry {
104103
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
105104
let name = match self {
106105
Self::Copy(_) => "cp",
107106
Self::Move(_) => "mv",
108107
};
109108
write!(f, "{name}")?;
110-
// First path is space-separated from the command; the rest are
111-
// newline-separated. An empty path list writes just the command
112-
// (which TryFrom rejects as invalid) rather than panicking.
113-
for (i, path) in self.paths().iter().enumerate() {
114-
let separator = if i == 0 { ' ' } else { '\n' };
115-
write!(f, "{separator}{path}")?;
109+
for path in self.paths() {
110+
write!(f, " {}", shell_words::quote(&path.path.to_string_lossy()))?;
116111
}
117112
Ok(())
118113
}
@@ -122,26 +117,27 @@ impl TryFrom<&str> for ClipboardEntry {
122117
type Error = Error;
123118

124119
fn try_from(value: &str) -> Result<Self, Self::Error> {
125-
let mut lines = value.lines();
120+
let parts =
121+
shell_words::split(value).map_err(|e| anyhow!("Invalid clipboard format: {e}"))?;
126122

127-
let first_line = lines.next().ok_or_else(|| anyhow!("Empty clipboard"))?;
128-
let mut parts = first_line.splitn(2, ' ');
129-
130-
let command_str = parts.next().ok_or_else(|| anyhow!("Missing command"))?;
131-
let path_str = parts.next().ok_or_else(|| anyhow!("Missing path"))?;
132-
133-
let mut paths = vec![PathInfo::try_from(path_str)?];
134-
for line in lines {
135-
if !line.is_empty() {
136-
paths.push(PathInfo::try_from(line)?);
137-
}
123+
if parts.len() < 2 {
124+
return Err(anyhow!("Missing command or path in clipboard"));
138125
}
139126

140-
match command_str {
141-
"cp" => Ok(Self::Copy(paths)),
142-
"mv" => Ok(Self::Move(paths)),
143-
_ => Err(anyhow!("Invalid ClipboardEntry: {command_str}")),
144-
}
127+
parse_clipboard_parts(&parts)
128+
}
129+
}
130+
131+
fn parse_clipboard_parts(parts: &[String]) -> Result<ClipboardEntry> {
132+
let command_str = &parts[0];
133+
let paths: Vec<_> = parts[1..]
134+
.iter()
135+
.map(|p| PathInfo::try_from(p.as_str()))
136+
.collect::<Result<Vec<_>, _>>()?;
137+
match command_str.as_str() {
138+
"cp" => Ok(ClipboardEntry::Copy(paths)),
139+
"mv" => Ok(ClipboardEntry::Move(paths)),
140+
_ => Err(anyhow!("Invalid ClipboardEntry: {command_str}")),
145141
}
146142
}
147143

src/app/config/keybindings.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,18 @@ fn hardcoded_keys(action: Action) -> &'static [KeyCombo] {
347347
.map_or(&[], |(_, keys)| *keys)
348348
}
349349

350+
/// Look up an action from a key press using only hardcoded bindings.
351+
/// Returns `None` if the key combo is not hardcoded.
352+
pub fn hardcoded_action(code: &KeyCode, modifiers: &KeyModifiers) -> Option<Action> {
353+
let combo = KeyCombo::new(*code, *modifiers);
354+
for (action, keys) in HARDCODED {
355+
if keys.contains(&combo) {
356+
return Some(*action);
357+
}
358+
}
359+
None
360+
}
361+
350362
/// Build the key→action HashMap, detecting duplicate key mappings.
351363
/// Hardcoded keys are inserted first for actions present in this mode's
352364
/// binding list, then user bindings override them.

src/file_system.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ impl FileSystem {
128128
self.previous_directory = Some(current.clone());
129129
}
130130
self.directory = Some(directory.clone());
131-
let path_buf = PathBuf::from(&directory.path);
131+
let path_buf = directory.path.clone();
132132
if let Some(watcher) = &mut self.watcher
133133
&& let Err(e) = watcher.watch_directory(path_buf.clone())
134134
{
@@ -165,21 +165,16 @@ impl FileSystem {
165165

166166
fn cancel_most_recent_task(&mut self) -> CommandResult {
167167
// LIFO across file operations and search: cancel whichever was started most recently.
168-
while let Some(cancellable) = self.cancellables.last() {
168+
while let Some(cancellable) = self.cancellables.pop() {
169169
match cancellable {
170-
Cancellable::Task((_, token, _)) => {
170+
Cancellable::Task((_, token, kind)) => {
171171
if !token.is_cancelled() {
172172
token.cancel();
173-
let Some(Cancellable::Task((_, _, kind))) = self.cancellables.pop() else {
174-
unreachable!()
175-
};
176173
return Command::AlertInfo(format!("Cancelled: {}", kind.message())).into();
177174
}
178-
self.cancellables.pop();
179175
}
180176
Cancellable::Search(token) => {
181177
token.cancel();
182-
self.cancellables.pop();
183178
// Non-destructive: keep streamed results and the notice;
184179
// NoticesView relabels it to "Cancelled: [Searching] <query>".
185180
return Command::CancelSearch.into();

src/file_system/operations.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub(super) fn open_in(path: &PathInfo, template: &str, command_tx: Sender<Comman
4242
if template.is_empty() {
4343
return Ok(());
4444
}
45-
let command = template.replace("%s", &shell_words::quote(&path.path));
45+
let command = template.replace("%s", &shell_words::quote(&path.path.to_string_lossy()));
4646
let mut child = spawn_detached("sh", ["-c", &command])
4747
.map_err(|error| anyhow!("Failed to run command \"{command}\": {error}"))?;
4848

0 commit comments

Comments
 (0)