Read a timeout in units, share one usage answer, and let a tilde mean home
The composer's settings row is outlined bubbles opening round menus, and the message box is a TextFieldValue so anything put into it without being typed -- a draft, a share, a slash command -- leaves the cursor at the end. A tap that puts a text selection away no longer also collapses the card the text was drawn in: every open and close on the session screen goes through one guard that spends such a press on the selection. The usage bar and the usage dialog were two polls of one measurement and disagreed for up to a minute at a time; they are one feed now, and the countdown rounds up to the minute in the one place both read. A working directory typed as ~/repos/ai-app was four literal characters on the local transport and as an argument on both, so the existence check refused every home-relative path. It is checked by entering the directory now, expanded for a local spawn the way the remote shell expands it, and stored short so the phone draws what somebody would write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2970a6cf9a
commit
32b2a4871a
16 files changed
+414
-99
No files matched your search
+11
-1
@@ -1177,8 +1177,18 @@ async fn set_cwd(
|
||||
setup.name
|
||||
)));
|
||||
}
|
||||
// Stored in the short form, so the one path that is kept is the one
|
||||
// the phone will draw -- rather than storing `/home/bob/…` and
|
||||
// abbreviating it again at each place it is shown, which is two
|
||||
// representations of one directory and a second rule to keep in step.
|
||||
// Only where the setup runs here; see `setups::shorten_home`.
|
||||
let stored = if setup.ssh.is_none() {
|
||||
crate::setups::shorten_home(&cwd)
|
||||
} else {
|
||||
cwd.clone()
|
||||
};
|
||||
manager
|
||||
.set_session_cwd(&id, PathBuf::from(&cwd))
|
||||
.set_session_cwd(&id, PathBuf::from(&stored))
|
||||
.map_err(bad_request)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -441,7 +441,14 @@ pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
||||
if path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None);
|
||||
// Asked by *entering* it rather than by `test -d <path>`, because the
|
||||
// question this is standing in for is "can a session start here" and
|
||||
// because a path is only expanded where it is a working directory --
|
||||
// `~/repos/ai-app` as an argument stays four literal characters on
|
||||
// both transports (`ssh::quote_path`, `ssh::expand_home`), so the old
|
||||
// form answered "no such directory" about every home-relative path
|
||||
// somebody typed.
|
||||
let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path)));
|
||||
transport.capture(&launch).await.is_ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,32 @@ pub fn tidy(value: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The inverse of [`tidy`]'s expansion: an absolute path under this
|
||||
/// machine's home, written back as `~/…`.
|
||||
///
|
||||
/// So that a working directory reads on a phone the way it is written by
|
||||
/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and
|
||||
/// almost all of it is the part nobody is reading.
|
||||
///
|
||||
/// Applied only to paths on **this** machine. `$HOME` here says nothing
|
||||
/// about the home directory of a machine reached over ssh, so a remote
|
||||
/// path is stored exactly as it was typed -- where a `~` somebody wrote
|
||||
/// stays a `~`, and the remote shell is what expands it
|
||||
/// (`ssh::quote_path`).
|
||||
pub fn shorten_home(path: &str) -> String {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
return path.to_string();
|
||||
};
|
||||
let home = home.to_string_lossy();
|
||||
// The separator has to be part of the match, or `/home/bobby` would be
|
||||
// read as a path inside `/home/bob`.
|
||||
match path.strip_prefix(home.as_ref()) {
|
||||
Some("") => "~".to_string(),
|
||||
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a launch to completion and returns its stdout.
|
||||
impl Transport {
|
||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||
@@ -182,3 +208,29 @@ impl Transport {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two halves of a home-relative path, which have to be inverses:
|
||||
/// what is stored is what the phone draws, and what the phone sends
|
||||
/// back is what a process is started in.
|
||||
#[test]
|
||||
fn a_home_path_shortens_and_expands_back() {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
return;
|
||||
};
|
||||
let full = home.join("repos/ai-app-2");
|
||||
let full = full.to_string_lossy();
|
||||
assert_eq!(shorten_home(&full), "~/repos/ai-app-2");
|
||||
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
|
||||
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
|
||||
|
||||
// Not a prefix match on the characters: a sibling directory whose
|
||||
// name merely starts with the home directory's is not inside it.
|
||||
let sibling = format!("{}-backup/notes", home.to_string_lossy());
|
||||
assert_eq!(shorten_home(&sibling), sibling);
|
||||
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
||||
}
|
||||
}
|
||||
+59
-2
@@ -10,7 +10,7 @@
|
||||
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
|
||||
//! only one place to configure connections (PLAN.md, rule 23).
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::SshConfig;
|
||||
@@ -50,7 +50,15 @@ pub fn command(
|
||||
let mut command = Command::new(program);
|
||||
command.args(args);
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
// Expanded here for the same reason `quote_path` expands it on
|
||||
// the far side: a working directory typed as `~/repos/ai-app`
|
||||
// has to mean the same thing whichever machine runs it. There
|
||||
// is no shell in this branch, so nothing else would --
|
||||
// `current_dir` would be handed the literal one-character
|
||||
// directory `~`, and the session would fail to start with an
|
||||
// error naming a path nobody typed. Only the cwd, matching
|
||||
// the remote side, where arguments stay literal.
|
||||
command.current_dir(expand_home(cwd));
|
||||
}
|
||||
return command;
|
||||
};
|
||||
@@ -101,6 +109,30 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
|
||||
script
|
||||
}
|
||||
|
||||
/// A path with a leading `~` replaced by this machine's home directory.
|
||||
///
|
||||
/// The local half of the rule [`quote_path`] states for the remote one, and
|
||||
/// the two are deliberately the same shape: the tilde is expanded, `~user`
|
||||
/// is not (there is no portable expansion for another account's home), and
|
||||
/// nothing else in the path gains a meaning. A machine with no home
|
||||
/// directory at all leaves the path alone, which fails with the operating
|
||||
/// system's own message rather than with a guess.
|
||||
pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
||||
let Some(rest) = path.to_str().and_then(|p| {
|
||||
if p == "~" {
|
||||
Some("")
|
||||
} else {
|
||||
p.strip_prefix("~/")
|
||||
}
|
||||
}) else {
|
||||
return path.to_path_buf();
|
||||
};
|
||||
match std::env::home_dir() {
|
||||
Some(home) => home.join(rest),
|
||||
None => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quotes a path, expanding a leading `~` and nothing else.
|
||||
///
|
||||
/// [`quote`] is right for every other word crossing to the remote side and
|
||||
@@ -247,6 +279,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The same character, on the transport with no shell to expand it.
|
||||
///
|
||||
/// The local branch runs the program directly, so a working directory
|
||||
/// of `~/repos/ai-app` would reach `current_dir` as the literal
|
||||
/// one-character directory `~` -- a session that fails to start,
|
||||
/// naming a path nobody typed. The two transports have to agree about
|
||||
/// what a tilde means or a path is only portable by accident.
|
||||
#[test]
|
||||
fn a_local_cwd_expands_its_tilde_the_same_way() {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
expand_home(Path::new("~/repos/ai-app")),
|
||||
home.join("repos/ai-app")
|
||||
);
|
||||
assert_eq!(expand_home(Path::new("~")), home);
|
||||
// Leading only, and its own segment only -- `quote_path`'s rule.
|
||||
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
|
||||
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
|
||||
|
||||
let local = command(None, "claude", &args(["-p"]), Some(Path::new("~/work")));
|
||||
assert_eq!(local.get_current_dir(), Some(home.join("work").as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_metacharacters_cross_as_data_not_syntax() {
|
||||
// Expanding $HOME must not open a door for anything else: the rest
|
||||
|
||||
Reference in new issue
Block a user