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
6 changes: 3 additions & 3 deletions apps/tauri/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fileterm/tauri",
"version": "2.2.27",
"version": "2.2.28",
"private": true,
"type": "module",
"description": "Modern remote workspace for SSH, SFTP, and FTP.",
Expand Down Expand Up @@ -30,8 +30,8 @@
"release:linux:arm64": "node ./scripts/run-tauri.mjs build --target aarch64-unknown-linux-gnu --config src-tauri/tauri.release.linux.conf.json"
},
"dependencies": {
"@fileterm/core": "2.2.27",
"@fileterm/shared": "2.2.27",
"@fileterm/core": "2.2.28",
"@fileterm/shared": "2.2.28",
"@monaco-editor/react": "4.7.0",
"@tanstack/react-virtual": "^3.14.3",
"@xterm/addon-fit": "^0.11.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/tauri/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/tauri/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fileterm"
version = "2.2.27"
version = "2.2.28"
description = "FileTerm desktop workspace"
authors = ["FileTerm"]
edition = "2021"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
<content_attribute id="money-gambling">none</content_attribute>
</content_rating>
<releases>
<release version="2.2.27" date="2026-10-08">
<release version="2.2.28" date="2026-10-09">
<description>
<p>Official release with multi-tab remote terminal and file management workspace.</p>
</description>
Expand Down
116 changes: 116 additions & 0 deletions apps/tauri/src-tauri/src/sessions/ssh/shell/command_hook_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
use super::SHELL_CWD_SETUP;
use std::io::Write;
use std::process::{Command, Stdio};

/// Exercise the real injected script; skip shells absent on the CI host.
fn interactive_output(shell: &str, args: &[&str], script: &str) -> Option<String> {
let mut child = match Command::new(shell)
.args(args)
.env("TERM", "dumb")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => child,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => panic!("could not start {shell}: {error}"),
};
child
.stdin
.take()
.unwrap()
.write_all(script.as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{shell} failed: {:?}",
output.status
);
Some(String::from_utf8(output.stdout).unwrap())
}

#[test]
fn zsh_preexec_preserves_user_hooks_and_skips_blank_input_after_reinjection() {
let script = format!(
"PS1=; preexec() {{ printf USER_HOOK; }}\n{SHELL_CWD_SETUP}\n{SHELL_CWD_SETUP}\n\n\nprintf COMMAND_DONE\n\nexit\n"
);
let Some(output) = interactive_output("zsh", &["-dfi"], &script) else {
return;
};
// Reinstallation itself, the command and exit. Blank lines emit no marker;
// installing twice must not duplicate our preexec hook.
assert_eq!(output.matches("\x1b]7777;FileTermCommand\x07").count(), 3);
assert_eq!(output.matches("USER_HOOK").count(), 4);
assert!(output.contains("COMMAND_DONE"));
}

#[test]
fn bash_ps0_preserves_user_content_and_works_with_history_disabled() {
let script = format!(
"PS1=; PS0=USER_PS0; PROMPT_COMMAND=\"printf USER_PROMPT\"; set +o history\n{SHELL_CWD_SETUP}\n{SHELL_CWD_SETUP}\n\n\nprintf COMMAND_DONE\n\ncase \"$PS0\" in *USER_PS0*) printf PS0_PRESERVED ;; esac\nexit\n"
);
let Some(output) = interactive_output("bash", &["--noprofile", "--norc", "-i"], &script) else {
return;
};
assert!(output.contains("PS0_PRESERVED"));
assert!(output.contains("USER_PROMPT"));
assert!(output.contains("COMMAND_DONE"));
if output.contains("\x1b]7777;FileTermCommandTracking=1\x07") {
// PS0 output is written to stderr; test its value explicitly below.
let script = format!(
"PS0=USER_PS0\n{SHELL_CWD_SETUP}\n{SHELL_CWD_SETUP}\nprintf \"%s\" \"$PS0\"\nexit\n"
);
let output = interactive_output("bash", &["--noprofile", "--norc", "-i"], &script).unwrap();
assert_eq!(output.matches("\x1b]7777;FileTermCommand\x07").count(), 1);
} else {
assert!(output.contains("\x1b]7777;FileTermCommandTracking=0\x07"));
}
}

#[test]
fn missing_root_home_preserves_real_cwd_and_reports_root_identity() {
let directory =
std::env::temp_dir().join(format!("fileterm-missing-home-{}", uuid::Uuid::new_v4()));
std::fs::create_dir(&directory).unwrap();
let login_home = directory.join("stoffel");
std::fs::create_dir(&login_home).unwrap();
let missing_home = directory.join("root");
let script = format!(
"cd {}; HOME={}; id() {{ printf root; }}; {}; {}",
super::shell_quote(login_home.to_str().unwrap()),
super::shell_quote(missing_home.to_str().unwrap()),
super::SHELL_HOME_DIRECTORY_RECOVERY,
SHELL_CWD_SETUP,
);
let result = Command::new("bash")
.args(["--noprofile", "--norc", "-c", &script])
.output();
let expected = login_home
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned();
let missing = !missing_home.exists();
std::fs::remove_dir_all(&directory).unwrap();
let output = match result {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
Err(error) => panic!("bash fixture failed: {error}"),
};
assert!(missing);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let (cwd, user) =
super::track_cwd_and_user(&String::from_utf8_lossy(&output.stdout), &mut String::new());
assert_eq!(cwd.as_deref(), Some(expected.as_str()));
assert_eq!(user.as_deref(), Some("root"));
assert_eq!(
super::resolve_shell_file_access("stoffel", user.as_deref().unwrap()),
("root", Some("root".into()))
);
}
82 changes: 63 additions & 19 deletions apps/tauri/src-tauri/src/sessions/ssh/shell/cwd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ async fn follow_shell_cwd(
root_file_access_method: RootFileAccessMethod,
sudo_user: Option<String>,
sudo_password: Option<String>,
) {
silent: bool,
) -> Option<String> {
crate::services::logging::ssh_debug(
&app,
&tab_id,
Expand All @@ -291,29 +292,38 @@ async fn follow_shell_cwd(
sudo_password.is_some(),
),
);
let previous_remote_path = {
let (previous_remote_path, previous_files) = {
let state = app.state::<crate::services::workspace::WorkspaceState>();
let mut sessions = state.sessions.write().await;
let Some(session) = sessions.get_mut(&tab_id) else {
return;
};
let session = sessions.get_mut(&tab_id)?;
if session.shell_cwd.as_deref() != Some(cwd.as_str())
|| !session.follow_shell_cwd
|| session.file_access_mode != file_access_mode
|| (file_access_mode == "root" && session.sudo_user != sudo_user)
|| (silent && session.remote_files_loading)
{
return;
return None;
}
if !silent {
session.remote_files_loading = true;
}
session.remote_files_loading = true;
session.remote_path.clone()
(
session.remote_path.clone(),
silent.then(|| session.remote_files.clone()),
)
};
if let Ok(snapshot) = crate::commands::get_workspace_snapshot(app.clone()).await {
let _ = app.emit("workspace:snapshot", snapshot);
if !silent {
if let Ok(snapshot) = crate::commands::get_workspace_snapshot(app.clone()).await {
let _ = app.emit("workspace:snapshot", snapshot);
}
}
// A completed command re-aligns a manually browsed pane even when the
// shell CWD did not change. Resolve the actual SFTP namespace in both
// modes; silence only suppresses loading and unchanged snapshots.

// The SFTP session belongs to the login user. Once `sudo -i` has started
// a root shell, following CWD through that channel silently remains in
// the old user's view. Electron switches to its sudo shell path here.
// the old user's view. Use the authenticated privileged shell instead.
let listing = match timeout(operation_timeout, async {
if file_access_mode == "root" {
exec_list_dir_via_shell(
Expand Down Expand Up @@ -343,19 +353,29 @@ async fn follow_shell_cwd(
let follow_error = listing.as_ref().err().cloned();
let state = app.state::<crate::services::workspace::WorkspaceState>();
let mut sessions = state.sessions.write().await;
let Some(session) = sessions.get_mut(&tab_id) else {
return;
};
session.remote_files_loading = false;
let session = sessions.get_mut(&tab_id)?;
if !silent {
session.remote_files_loading = false;
}
let mut publish = !silent;
let mut applied_path = None;
if session.shell_cwd.as_deref() == Some(cwd.as_str())
&& session.follow_shell_cwd
&& session.remote_path == previous_remote_path
&& session.file_access_mode == file_access_mode
&& (file_access_mode != "root" || session.sudo_user == sudo_user)
&& (!silent
|| (!session.remote_files_loading
&& previous_files.as_ref() == Some(&session.remote_files)))
{
if let Ok((files, resolved_path)) = &listing {
session.remote_path = resolved_path.clone();
session.remote_files = files.clone();
applied_path = Some(resolved_path.clone());
publish |= apply_followed_remote_listing(
&mut session.remote_path,
&mut session.remote_files,
resolved_path,
files,
);
}
}
drop(sessions);
Expand All @@ -382,9 +402,33 @@ async fn follow_shell_cwd(
}
}

if let Ok(snapshot) = crate::commands::get_workspace_snapshot(app.clone()).await {
let _ = app.emit("workspace:snapshot", snapshot);
if publish {
if let Ok(snapshot) = crate::commands::get_workspace_snapshot(app.clone()).await {
let _ = app.emit("workspace:snapshot", snapshot);
}
}
applied_path
}

fn apply_followed_remote_listing(
current_path: &mut String,
current_files: &mut Vec<Value>,
resolved_path: &str,
files: &[Value],
) -> bool {
let path_changed = current_path != resolved_path;
if path_changed {
*current_path = resolved_path.to_owned();
}
replace_changed_remote_files(current_files, files) || path_changed
}

fn replace_changed_remote_files(current: &mut Vec<Value>, files: &[Value]) -> bool {
if current == files {
return false;
}
*current = files.to_vec();
true
}

/// Flush the batch buffer to the terminal output pump channel.
Expand Down
Loading
Loading