Skip to content
Merged
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
45 changes: 42 additions & 3 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ This is built in Rust on the TUI framework derived from
very small and just enough.

Runs on Linux, macOS, and Windows. You can download the latest pre-release
[0.0.2](https://github.com/kromych/ruf4/releases/tag/v0.0.2).
[0.0.8](https://github.com/kromych/ruf4/releases/tag/v0.0.8).

If you are a developer, [here](./ReleaseFlow.md) are the gory details and notes on builds/releases.
## Screenshots
Expand Down Expand Up @@ -115,6 +115,7 @@ If you are a developer, [here](./ReleaseFlow.md) are the gory details and notes
| F2 | Save settings |
| F9 | Focus menubar |
| F10 | Quit (with confirmation) |
| Ctrl+O | Show the user screen (the output of previously run commands); Ctrl+O or Esc returns |
| Any letter | Activate command line |

### macOS alternatives
Expand All @@ -127,7 +128,7 @@ Mission Control, media, volume). These Ctrl shortcuts work without Fn:
| Ctrl+S | Save settings (F2) |
| Ctrl+Q | Toggle quick view (F3) |
| Ctrl+P | Rename (F4) |
| Ctrl+O | Copy (F5) |
| Ctrl+C | Copy (F5) |
| Ctrl+K | Move (F6) |
| Ctrl+N | Make directory (F7) |
| Ctrl+X | Delete (F8) |
Expand All @@ -147,7 +148,45 @@ Commands run in the active panel's directory.
Commands run in the foreground with the terminal handed back to them, so
interactive programs (a shell, `python`, `vim`, `less`) work normally. The
panel display is restored when the command exits; press Enter at the prompt to
return.
return. Press Ctrl+O at any time to peek at that output again.

## SSH remote filesystems

The change-root dialog (Ctrl+G) lists the hosts from `~/.ssh/config` as
`ssh://host` roots next to the local drives. Panels can also be pointed at any
`ssh://[user@]host[:port]/path` with the `cd` command. Remote directories
browse, sort, quick-view, copy, move, rename, and delete like local ones;
copies stream between hosts through ruf4 with byte progress. Enter on a remote
file downloads it to a temporary directory and opens it; the command line runs
commands on the remote host in the panel's directory over `ssh -t`.

Transport is the OpenSSH client: `ruf4` spawns `ssh -s <host> sftp` and speaks
SFTP over it, so keys, agents, jump hosts, and everything else in
`~/.ssh/config` behave exactly like plain `ssh`. On the first use of a host a
connection master is opened with the panels hidden so host-key and password
prompts work; subsequent channels multiplex over its socket. Set
`RUF4_SSH_CONFIG` to point `ssh` at an alternative client configuration file.
On Windows, where OpenSSH lacks multiplexing, authentication must be
non-interactive (keys or an agent).

## SMB shares

`cd smb://[user@]host/share/path` opens an SMB share using the operating
system's native client, so after navigation the panel works on an ordinary
local directory:

- **Windows**: the URL is rewritten to a UNC path (`\\host\share\path`); UNC
paths can also be entered directly.
- **macOS**: the share is mounted with `mount_smbfs` under `~/.ruf4/mnt`,
prompting for the password with the panels hidden. Unmounted shares
remount automatically when revisited through the directory history.
- **Linux**: the share is mounted in user space with `gio mount` (GVFS,
present on desktop distributions) and appears under the session's `gvfs`
directory.

Mounted shares are listed in the change-root dialog (Ctrl+G) next to the
local drives. Shares stay mounted when ruf4 exits; unmount with the usual
system tools (`umount`, `gio mount -u`, Finder).

### Dialogs

Expand Down
10 changes: 9 additions & 1 deletion crates/ruf4/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub enum Action {
ChangeRoot,
DirHistory,
CmdHistory,
ShowUserScreen,
FocusMenu,
Quit,
}
Expand Down Expand Up @@ -94,6 +95,7 @@ pub const ALL_ACTIONS: &[Action] = &[
Action::ChangeRoot,
Action::DirHistory,
Action::CmdHistory,
Action::ShowUserScreen,
Action::FocusMenu,
Action::Quit,
];
Expand Down Expand Up @@ -192,6 +194,10 @@ pub fn default_bindings() -> Vec<Binding> {
key: kbmod::CTRL | vk::E,
action: Action::CmdHistory,
},
Binding {
key: kbmod::CTRL | vk::O,
action: Action::ShowUserScreen,
},
Binding {
key: kbmod::CTRL | vk::R,
action: Action::Refresh,
Expand Down Expand Up @@ -286,7 +292,7 @@ pub fn default_bindings() -> Vec<Binding> {
action: Action::Rename,
},
Binding {
key: kbmod::CTRL | vk::O,
key: kbmod::CTRL | vk::C,
action: Action::Copy,
},
Binding {
Expand Down Expand Up @@ -447,6 +453,7 @@ fn action_meta(action: Action) -> (&'static str, &'static str) {
Action::ChangeRoot => ("change_root", "Change root"),
Action::DirHistory => ("dir_history", "Directory history"),
Action::CmdHistory => ("cmd_history", "Command history"),
Action::ShowUserScreen => ("show_user_screen", "Show user screen"),
Action::FocusMenu => ("focus_menu", "Focus menubar"),
Action::Quit => ("quit", "Quit"),
}
Expand Down Expand Up @@ -537,6 +544,7 @@ pub fn build_help_text(bindings: &[Binding]) -> Vec<(String, &'static str, Actio
Some(Action::ChangeRoot),
Some(Action::DirHistory),
Some(Action::CmdHistory),
Some(Action::ShowUserScreen),
Some(Action::Refresh),
Some(Action::ToggleHidden),
Some(Action::SortBy(SortBy::Name)),
Expand Down
5 changes: 4 additions & 1 deletion crates/ruf4/src/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ fn draw_menubar(ctx: &mut Context, state: &mut State) -> bool {
) {
state.execute_action(Action::ToggleQuickView);
}
if ctx.menubar_menu_button("User screen", 'U', key(Action::ShowUserScreen)) {
state.execute_action(Action::ShowUserScreen);
}

// Sort modes
let sort = state.active_panel().sort_by;
Expand Down Expand Up @@ -559,7 +562,7 @@ fn draw_single_panel(

let (sel_count, sel_size) = panel.selection_info();
let free = panel
.free_space()
.free_space
.map(panel::format_size)
.unwrap_or_else(|| "N/A".to_string());

Expand Down
118 changes: 63 additions & 55 deletions crates/ruf4/src/fileops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf};

use crate::platform;
use crate::state::{Dialog, State};
use crate::vfs;

/// The final path component as a lossy string, or empty if there is none.
pub fn base_name(path: &Path) -> Cow<'_, str> {
Expand All @@ -28,25 +29,13 @@ pub fn same_file_error(name: &str, is_copy: bool) -> String {
}

pub fn ops_mkdir(path: &Path) -> Result<(), String> {
fs::create_dir_all(path).map_err(|e| format!("Cannot create \"{}\": {e}", path.display()))
vfs::create_dir_all(path).map_err(|e| format!("Cannot create \"{}\": {e}", path.display()))
}

pub fn ops_delete(paths: &[PathBuf]) -> Vec<String> {
let mut errors = Vec::new();
for path in paths {
let is_symlink = fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
// Symlinks are always removed as links, never following the target.
// On Windows, directory symlinks need remove_dir; file symlinks need remove_file.
let result = if is_symlink {
platform::remove_symlink(path)
} else if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
if let Err(e) = result {
if let Err(e) = vfs::remove_tree(path) {
errors.push(format!(
"{}: {e}",
path.file_name().unwrap_or_default().to_string_lossy()
Expand All @@ -58,16 +47,15 @@ pub fn ops_delete(paths: &[PathBuf]) -> Vec<String> {

pub fn ops_build_pairs(sources: &[PathBuf], dest: &str) -> Vec<(PathBuf, PathBuf)> {
let dest_path = PathBuf::from(dest);
let dest_is_dir = vfs::is_dir(&dest_path);
let mut pairs = Vec::new();

for src in sources {
let file_name = src.file_name().unwrap_or_default();
let target = if dest_path.is_dir() {
dest_path.join(file_name)
} else if sources.len() == 1 {
dest_path.clone()
let file_name = src.file_name().unwrap_or_default().to_string_lossy();
let target = if dest_is_dir || sources.len() > 1 {
vfs::join(&dest_path, &file_name)
} else {
dest_path.join(file_name)
dest_path.clone()
};
pairs.push((src.clone(), target));
}
Expand Down Expand Up @@ -146,15 +134,12 @@ pub fn ops_execute_all(pairs: &[(PathBuf, PathBuf)], is_copy: bool) -> Vec<Strin
}

pub fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
vfs::same_file(a, b)
}

/// Resolve `path` to an absolute form even if it doesn't exist yet:
/// canonicalize the longest existing prefix, then append the rest.
fn normalize_against_existing(path: &Path) -> std::io::Result<PathBuf> {
pub(crate) fn normalize_against_existing(path: &Path) -> std::io::Result<PathBuf> {
if let Ok(p) = fs::canonicalize(path) {
return Ok(p);
}
Expand Down Expand Up @@ -220,7 +205,7 @@ pub fn do_mkdir(state: &mut State, name: &str) {
state.dialog = Dialog::None;
return;
}
let path = state.active_panel().path.join(name);
let path = vfs::join(&state.active_panel().path, name);
match ops_mkdir(&path) {
Ok(()) => {
state.dialog = Dialog::None;
Expand All @@ -242,23 +227,27 @@ pub fn do_delete(state: &mut State) {
}

pub fn do_copy(state: &mut State, dest: &str) {
let sources = state.active_panel().selected_or_current();
let pairs = ops_build_pairs(&sources, dest);
if pairs.is_empty() {
state.dialog = Dialog::None;
return;
}
state.start_job(crate::job::spawn_copy_move(pairs, true));
do_copy_move(state, dest, true);
}

pub fn do_move(state: &mut State, dest: &str) {
do_copy_move(state, dest, false);
}

fn do_copy_move(state: &mut State, dest: &str, is_copy: bool) {
// Connect a remote destination now: the worker cannot prompt for
// authentication.
if let Err(message) = vfs::ensure_host(Path::new(dest)) {
state.dialog = Dialog::Error { message };
return;
}
let sources = state.active_panel().selected_or_current();
let pairs = ops_build_pairs(&sources, dest);
if pairs.is_empty() {
state.dialog = Dialog::None;
return;
}
state.start_job(crate::job::spawn_copy_move(pairs, false));
state.start_job(crate::job::spawn_copy_move(pairs, is_copy));
}

pub fn do_rename(state: &mut State, new_name: &str) {
Expand All @@ -267,9 +256,9 @@ pub fn do_rename(state: &mut State, new_name: &str) {
return;
}
let panel = state.active_panel();
let old_path = panel.path.join(&panel.entries[panel.cursor].name);
let new_path = panel.path.join(new_name);
match fs::rename(&old_path, &new_path) {
let old_path = vfs::join(&panel.path, &panel.entries[panel.cursor].name);
let new_path = vfs::join(&panel.path, new_name);
match vfs::rename(&old_path, &new_path) {
Ok(()) => {
state.dialog = Dialog::None;
state.active_panel_mut().refresh();
Expand All @@ -289,38 +278,57 @@ pub fn execute_command(state: &mut State) {

// Intercept "cd" to change the active panel's directory.
if let Some(target) = parse_cd_command(&cmd) {
let raw = if target.is_empty() || target == "~" {
platform::home_dir()
} else {
let p = PathBuf::from(&target);
if p.is_absolute() { p } else { cwd.join(p) }
};
let dest = fs::canonicalize(&raw).unwrap_or(raw);
if dest.is_dir() {
state.record_dir_change(&dest);
state.active_panel_mut().navigate_to(dest);
} else {
state.dialog = Dialog::Error {
message: format!("cd: not a directory: {}", dest.display()),
};
}
state.command_line.clear();
let raw = resolve_cd_target(&cwd, &target);
state.navigate_panel(raw);
return;
}

state.command_line.clear();

// External commands run in the foreground with the terminal handed back to
// them, so interactive programs work. The TUI is suspended and restored by
// `run_interactive`; the screen must be fully repainted afterwards.
if let Err(msg) = platform::run_interactive(&cmd, &cwd) {
// them, so interactive programs work. The TUI is suspended and restored for
// the duration; the screen must be fully repainted afterwards. With a
// remote working directory the command runs on that host over ssh.
let result = match vfs::parse_remote(&cwd) {
Some(remote) => vfs::run_remote_interactive(&remote, &cmd),
None => platform::run_interactive(&cmd, &cwd),
};
if let Err(msg) = result {
state.dialog = Dialog::Error { message: msg };
}
state.request_repaint();
state.left.refresh();
state.right.refresh();
}

/// Resolve a `cd` argument against the panel's working directory. An empty
/// target and `~` go to the home directory (the remote login home when the
/// panel is on a remote host); `ssh://` targets switch hosts.
pub fn resolve_cd_target(cwd: &Path, target: &str) -> PathBuf {
if vfs::is_url(target) {
return PathBuf::from(target);
}
match vfs::parse_remote(cwd) {
Some(remote) => {
if target.is_empty() || target == "~" {
// No path component resolves to the remote home directory.
PathBuf::from(remote.host.display())
} else {
vfs::join(cwd, target)
}
}
None => {
if target.is_empty() || target == "~" {
platform::home_dir()
} else {
let p = PathBuf::from(target);
if p.is_absolute() { p } else { cwd.join(p) }
}
}
}
}

fn parse_cd_command(cmd: &str) -> Option<String> {
let trimmed = cmd.trim();
if trimmed == "cd" {
Expand Down
Loading
Loading