feat(windows): add optional windows explorer context menus (#310)

* add CLI support for opening directories in new tabs

f

* feat(windows): add optional windows explorer context menus

f

* fix(gui): restore missing windows and reject lossy paths

* fix(windows): harden explorer menu registration and native path handling

* fix(cli): preserve native GUI paths on Windows

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
ARNO
2026-08-03 15:23:59 +08:00
committed by GitHub
co-authored by thomas l0ng-ai
parent c2ee9483a9
commit b7e08c7e11
9 changed files with 864 additions and 66 deletions
+19
View File
@@ -176,8 +176,27 @@ begin
RegWriteStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', Rebuilt);
end;
(* Remove the optional Explorer verbs if this user enabled them in Settings.
The application owns only the final `tty7` subkeys. Deleting those trees
removes their command children without touching another application's verb
or a shared `shell` parent. Missing keys are the normal default and make both
calls harmless no-ops. This cleanup is uninstall-only: upgrades keep the
user's explicit registration, and the next app launch reports "Needs update"
if an install path ever changes. *)
procedure RemoveExplorerContextMenu();
begin
RegDeleteKeyIncludingSubkeys(
HKEY_CURRENT_USER, 'Software\Classes\Directory\shell\tty7');
RegDeleteKeyIncludingSubkeys(
HKEY_CURRENT_USER, 'Software\Classes\Directory\Background\shell\tty7');
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usUninstall then
begin
RemoveAppDirFromUserPath();
RemoveExplorerContextMenu();
end;
end;
+2
View File
@@ -138,7 +138,9 @@ libc = "0.2"
# this pins no new code.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
+19 -2
View File
@@ -40,7 +40,7 @@ pub struct Cli {
value_name = "PATH",
help = "Launch or activate the GUI, opening a new tab at PATH if given"
)]
pub path: Option<String>,
pub path: Option<std::path::PathBuf>,
#[command(subcommand)]
pub command: Option<Command>,
@@ -470,7 +470,24 @@ mod tests {
fn a_path_that_is_not_a_verb_still_launches_the_gui() {
let cli = parse(&["tty7", "C:\\Users\\me\\proj"]);
assert!(cli.command.is_none());
assert_eq!(cli.path.as_deref(), Some("C:\\Users\\me\\proj"));
assert_eq!(
cli.path.as_deref(),
Some(std::path::Path::new("C:\\Users\\me\\proj"))
);
}
#[cfg(windows)]
#[test]
fn a_gui_path_preserves_native_windows_arguments() {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt as _;
let native_path =
OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xD800, b'x' as u16]);
let cli = Cli::try_parse_from([OsString::from("tty7"), native_path.clone()])
.expect("PathBuf arguments accept native Windows strings");
assert_eq!(cli.path, Some(std::path::PathBuf::from(native_path)));
}
#[test]
+34 -31
View File
@@ -48,9 +48,11 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
// Treat a word that is not a path as the typo it almost certainly
// is: launching the GUI for `tty7 statu` would hide the typo.
Some(word) if !looks_like_a_path(&word) => bail!(
"unknown subcommand '{word}' — run `tty7 --help` for the list. \
"unknown subcommand '{}' — run `tty7 --help` for the list. \
(A path in this position would open the GUI there, but \
'{word}' does not name one.)"
'{}' does not name one.)",
word.display(),
word.display(),
),
path => launch_gui(path, machine.as_deref(), backend),
},
@@ -130,17 +132,18 @@ fn local_server(
/// Anything with a separator, a leading `.`/`~`, or that actually exists on
/// disk counts. A plain word like `tree` or `statu` does not — it is a
/// mistyped subcommand, and saying so beats offering to open the GUI there.
fn looks_like_a_path(s: &str) -> bool {
s.starts_with('/')
|| s.starts_with('.')
|| s.starts_with('~')
|| s.contains('/')
|| s.contains('\\')
|| std::path::Path::new(s).exists()
fn looks_like_a_path(path: &std::path::Path) -> bool {
path.to_str().is_some_and(|s| {
s.starts_with('/')
|| s.starts_with('.')
|| s.starts_with('~')
|| s.contains('/')
|| s.contains('\\')
}) || path.exists()
}
fn launch_gui(
path: Option<String>,
path: Option<std::path::PathBuf>,
machine: Option<&str>,
backend: &mut dyn Backend,
) -> Result<Outcome> {
@@ -151,16 +154,24 @@ fn launch_gui(
}
let path = path.map(resolve_gui_path).transpose()?;
let wire_path = path.as_deref().map(gui_wire_path).transpose()?;
let wire_path = path.as_deref().and_then(gui_wire_path);
// A live GUI receives the request through the daemon. If the daemon itself
// is absent, the same fallback as "no GUI registered" starts the app, which
// will start its daemon during normal initialization.
let delivered = match backend.control(ControlRequest::GuiOpen {
path: wire_path.clone(),
}) {
Ok(ReplyOk::Bool(delivered)) => delivered,
Ok(other) => bail!("the server answered GuiOpen with {other:?}"),
Err(_) => false,
let request_path = path
.is_none()
.then_some(None)
.or_else(|| wire_path.clone().map(Some));
let delivered = match request_path {
Some(path) => match backend.control(ControlRequest::GuiOpen { path }) {
Ok(ReplyOk::Bool(delivered)) => delivered,
Ok(other) => bail!("the server answered GuiOpen with {other:?}"),
Err(_) => false,
},
// The JSON control protocol cannot preserve a native non-Unicode path.
// Launching the app does: Command passes the Path as an OsStr, and the
// app keeps it locally when it cannot forward it to another process.
None => false,
};
if !delivered {
@@ -176,18 +187,12 @@ fn launch_gui(
)
}
fn gui_wire_path(path: &std::path::Path) -> Result<String> {
let Some(path_text) = path.to_str() else {
bail!(
"cannot open {} through the GUI protocol because the path is not valid UTF-8",
path.display()
);
};
Ok(path_text.to_owned())
fn gui_wire_path(path: &std::path::Path) -> Option<String> {
path.to_str().map(str::to_owned)
}
fn resolve_gui_path(raw: String) -> Result<std::path::PathBuf> {
let expanded = expand_home(&raw).unwrap_or_else(|| std::path::PathBuf::from(&raw));
fn resolve_gui_path(raw: std::path::PathBuf) -> Result<std::path::PathBuf> {
let expanded = raw.to_str().and_then(expand_home).unwrap_or(raw);
// Do not canonicalize here: preserving the caller's junction or symlink
// spelling keeps shell cwd reporting and tab labels consistent.
let path = if expanded.is_absolute() {
@@ -1764,14 +1769,12 @@ mod tests {
#[cfg(unix)]
#[test]
fn a_non_utf8_gui_path_is_rejected_instead_of_changed() {
fn a_non_utf8_gui_path_stays_off_the_string_protocol() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let path = std::env::temp_dir().join(OsString::from_vec(b"tty7-\xff".to_vec()));
let error = gui_wire_path(&path).expect_err("the string protocol cannot preserve bytes");
assert!(error.to_string().contains("not valid UTF-8"), "{error:#}");
assert_eq!(gui_wire_path(&path), None);
}
#[test]
+559
View File
@@ -0,0 +1,559 @@
//! Optional Windows Explorer context-menu integration.
//!
//! tty7 deliberately does not register shell verbs during installation or
//! startup. The registry is user-visible system state, so only the explicit
//! buttons in Settings call [`register`] or [`unregister`]. Both verbs invoke
//! the GUI-subsystem `tty7-app.exe` directly so Explorer never allocates a
//! transient console. The app first offers the path to an already running GUI
//! through `GuiOpen`, then continues normal startup when no GUI receives it.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
const DIRECTORY_KEY: &str = r"Software\Classes\Directory\shell\tty7";
const BACKGROUND_KEY: &str = r"Software\Classes\Directory\Background\shell\tty7";
/// The state shown in Settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// Neither tty7 verb exists for this user.
NotRegistered,
/// Both verbs exactly describe the currently running tty7 installation.
Registered,
/// At least one verb exists, but the pair is incomplete or points elsewhere.
NeedsUpdate,
/// Explorer shell verbs are unavailable on this operating system.
Unsupported,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Location {
Directory,
Background,
}
impl Location {
fn key(self) -> &'static str {
match self {
Self::Directory => DIRECTORY_KEY,
Self::Background => BACKGROUND_KEY,
}
}
fn label(self) -> &'static str {
match self {
Self::Directory => "Open in tty7",
Self::Background => "Open tty7 here",
}
}
fn placeholder(self) -> &'static str {
match self {
// `%1` is the selected directory passed to a normal static verb.
Self::Directory => "%1",
// Explorer expands `%V` to the folder whose background was clicked.
Self::Background => "%V",
}
}
}
#[derive(Debug)]
struct Registration {
location: Location,
icon: OsString,
command: OsString,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RegistryShape {
values: u32,
subkeys: u32,
}
fn registration_tree_is_exact(root: RegistryShape, command: RegistryShape) -> bool {
root == (RegistryShape {
values: 2,
subkeys: 1,
}) && command
== (RegistryShape {
values: 1,
subkeys: 0,
})
}
fn replace_entry_with(
registration: &Registration,
delete: impl FnOnce(&str) -> Result<()>,
write: impl FnOnce(&Registration) -> Result<()>,
) -> Result<()> {
// The verb root belongs exclusively to tty7. Replacing it at the operation
// boundary prevents stale shell values or handler subkeys from surviving
// an update and changing visibility or execution semantics.
delete(registration.location.key())?;
write(registration)
}
impl Registration {
fn new(location: Location, app: &Path) -> Self {
Self {
location,
icon: app.as_os_str().to_os_string(),
command: quoted_open_command(app, location.placeholder()),
}
}
}
/// Return the live per-user registration state.
pub fn status() -> Result<Status> {
platform_status()
}
/// Register both Explorer verbs for the current user.
pub fn register() -> Result<()> {
platform_register()
}
/// Remove only the two Explorer verb trees owned by tty7.
pub fn unregister() -> Result<()> {
platform_unregister()
}
/// Build a command line without converting the executable path through UTF-8.
///
/// Quotes are unconditional: both the executable and the Explorer-substituted
/// folder can contain spaces. Windows file names cannot contain a quote, so the
/// resulting command is unambiguous without an additional escape layer.
fn quoted_open_command(executable: &Path, placeholder: &str) -> OsString {
let mut command = OsString::from("\"");
command.push(executable.as_os_str());
command.push("\" --open-path \"");
command.push(placeholder);
command.push("\"");
command
}
fn application_path() -> Result<PathBuf> {
let app = std::env::current_exe().context("locating the running tty7 application")?;
app.parent()
.context("the running tty7 application has no parent directory")?;
Ok(app)
}
fn registrations(app: &Path) -> [Registration; 2] {
[
Registration::new(Location::Directory, app),
Registration::new(Location::Background, app),
]
}
#[cfg(windows)]
mod windows {
use super::*;
use std::os::windows::ffi::OsStrExt as _;
use windows_sys::Win32::Foundation::{
ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS,
};
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey,
RegCreateKeyExW, RegDeleteTreeW, RegOpenKeyExW, RegQueryInfoKeyW, RegQueryValueExW,
RegSetValueExW,
};
use windows_sys::Win32::UI::Shell::{SHCNE_ASSOCCHANGED, SHCNF_IDLIST, SHChangeNotify};
struct RegistryKey(HKEY);
impl Drop for RegistryKey {
fn drop(&mut self) {
// SAFETY: `RegistryKey` is created only from a successful Win32
// open/create call and owns exactly one handle.
unsafe {
RegCloseKey(self.0);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EntryState {
Missing,
Matching,
Different,
}
fn wide(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(std::iter::once(0)).collect()
}
fn units(value: &OsStr) -> Vec<u16> {
value.encode_wide().collect()
}
fn io_error(action: &str, code: u32) -> anyhow::Error {
anyhow::anyhow!(
"{action}: {}",
std::io::Error::from_raw_os_error(code as i32)
)
}
fn open_key(path: &str) -> Result<Option<RegistryKey>> {
let path = wide(OsStr::new(path));
let mut key: HKEY = std::ptr::null_mut();
// SAFETY: `path` is NUL-terminated and alive for the call; `key` is a
// valid out-parameter and is wrapped only when the call succeeds.
let code =
unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, path.as_ptr(), 0, KEY_READ, &mut key) };
match code {
ERROR_SUCCESS => Ok(Some(RegistryKey(key))),
ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(None),
other => Err(io_error("opening the tty7 Explorer registry key", other)),
}
}
fn create_key(path: &str) -> Result<RegistryKey> {
let path = wide(OsStr::new(path));
let mut key: HKEY = std::ptr::null_mut();
// SAFETY: all input pointers reference live locals; the optional class,
// security and disposition pointers are null as permitted by the API.
let code = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
path.as_ptr(),
0,
std::ptr::null(),
REG_OPTION_NON_VOLATILE,
KEY_READ | KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
if code != ERROR_SUCCESS {
return Err(io_error("creating the tty7 Explorer registry key", code));
}
Ok(RegistryKey(key))
}
fn query_string(key: &RegistryKey, name: Option<&OsStr>) -> Result<Option<Vec<u16>>> {
let name = name.map(wide);
let name_ptr = name.as_ref().map_or(std::ptr::null(), |name| name.as_ptr());
let mut kind = 0u32;
let mut bytes = 0u32;
// SAFETY: the key is live, the optional value-name pointer is either
// null or NUL-terminated, and the size/type out-parameters are valid.
let code = unsafe {
RegQueryValueExW(
key.0,
name_ptr,
std::ptr::null(),
&mut kind,
std::ptr::null_mut(),
&mut bytes,
)
};
if matches!(code, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) {
return Ok(None);
}
if code != ERROR_SUCCESS {
return Err(io_error("reading a tty7 Explorer registry value", code));
}
if kind != REG_SZ || !bytes.is_multiple_of(2) {
return Ok(None);
}
let mut value = vec![0u16; (bytes as usize).div_ceil(2)];
// SAFETY: `value` is sized from the preceding query and remains live;
// Win32 receives its capacity in bytes through `bytes`.
let code = unsafe {
RegQueryValueExW(
key.0,
name_ptr,
std::ptr::null(),
&mut kind,
value.as_mut_ptr().cast(),
&mut bytes,
)
};
if code != ERROR_SUCCESS {
return Err(io_error("reading a tty7 Explorer registry value", code));
}
value.truncate(bytes as usize / 2);
while value.last() == Some(&0) {
value.pop();
}
Ok(Some(value))
}
fn set_string(key: &RegistryKey, name: Option<&OsStr>, value: &OsStr) -> Result<()> {
let name = name.map(wide);
let name_ptr = name.as_ref().map_or(std::ptr::null(), |name| name.as_ptr());
let value = wide(value);
let byte_len = u32::try_from(value.len() * 2)
.context("the tty7 Explorer registry value is too long")?;
// SAFETY: the key is writable, and both optional name and value point
// to live NUL-terminated UTF-16 buffers for the duration of the call.
let code =
unsafe { RegSetValueExW(key.0, name_ptr, 0, REG_SZ, value.as_ptr().cast(), byte_len) };
if code == ERROR_SUCCESS {
Ok(())
} else {
Err(io_error("writing a tty7 Explorer registry value", code))
}
}
fn key_shape(key: &RegistryKey) -> Result<RegistryShape> {
let mut subkeys = 0u32;
let mut values = 0u32;
// SAFETY: `key` is live and the two count pointers reference writable
// locals. Every optional output that is not needed is passed as null,
// which `RegQueryInfoKeyW` explicitly permits.
let code = unsafe {
RegQueryInfoKeyW(
key.0,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null(),
&mut subkeys,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut values,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if code == ERROR_SUCCESS {
Ok(RegistryShape { values, subkeys })
} else {
Err(io_error("inspecting the tty7 Explorer registry key", code))
}
}
fn entry_state(registration: &Registration) -> Result<EntryState> {
let Some(root) = open_key(registration.location.key())? else {
return Ok(EntryState::Missing);
};
let label = query_string(&root, None)?;
let icon = query_string(&root, Some(OsStr::new("Icon")))?;
let command_path = format!(r"{}\command", registration.location.key());
let command_key = match open_key(&command_path)? {
Some(key) => key,
None => return Ok(EntryState::Different),
};
let command = query_string(&command_key, None)?;
let matches = label.as_deref() == Some(&units(OsStr::new(registration.location.label())))
&& icon.as_deref() == Some(&units(&registration.icon))
&& command.as_deref() == Some(&units(&registration.command))
&& registration_tree_is_exact(key_shape(&root)?, key_shape(&command_key)?);
Ok(if matches {
EntryState::Matching
} else {
EntryState::Different
})
}
fn write_entry_contents(registration: &Registration) -> Result<()> {
let root = create_key(registration.location.key())?;
set_string(&root, None, OsStr::new(registration.location.label()))?;
set_string(&root, Some(OsStr::new("Icon")), &registration.icon)?;
let command_path = format!(r"{}\command", registration.location.key());
let command = create_key(&command_path)?;
set_string(&command, None, &registration.command)
}
fn delete_tree(path: &str) -> Result<()> {
let path = wide(OsStr::new(path));
// SAFETY: `path` is a live, NUL-terminated UTF-16 string. Only tty7's
// own verb key is named, never a shared parent such as `shell`.
let code = unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, path.as_ptr()) };
match code {
ERROR_SUCCESS | ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(()),
other => Err(io_error("removing the tty7 Explorer registry key", other)),
}
}
fn notify_explorer() {
// SAFETY: `SHCNE_ASSOCCHANGED` with `SHCNF_IDLIST` carries no item
// pointers. This is a cache invalidation hint after the registry write.
unsafe {
SHChangeNotify(
SHCNE_ASSOCCHANGED as i32,
SHCNF_IDLIST,
std::ptr::null(),
std::ptr::null(),
);
}
}
pub(super) fn status() -> Result<Status> {
let app = application_path()?;
let states = registrations(&app).map(|entry| entry_state(&entry));
let [directory, background] = states;
let (directory, background) = (directory?, background?);
Ok(match (directory, background) {
(EntryState::Missing, EntryState::Missing) => Status::NotRegistered,
(EntryState::Matching, EntryState::Matching) => Status::Registered,
_ => Status::NeedsUpdate,
})
}
pub(super) fn register() -> Result<()> {
let app = application_path()?;
for registration in registrations(&app) {
replace_entry_with(&registration, delete_tree, write_entry_contents)?;
}
notify_explorer();
Ok(())
}
pub(super) fn unregister() -> Result<()> {
// Try both removals even when one fails, so a damaged first key cannot
// strand the independent second menu entry forever.
let directory = delete_tree(DIRECTORY_KEY);
let background = delete_tree(BACKGROUND_KEY);
directory?;
background?;
notify_explorer();
Ok(())
}
}
#[cfg(windows)]
fn platform_status() -> Result<Status> {
windows::status()
}
#[cfg(windows)]
fn platform_register() -> Result<()> {
windows::register()
}
#[cfg(windows)]
fn platform_unregister() -> Result<()> {
windows::unregister()
}
#[cfg(not(windows))]
fn platform_status() -> Result<Status> {
Ok(Status::Unsupported)
}
#[cfg(not(windows))]
fn platform_register() -> Result<()> {
anyhow::bail!("Windows Explorer integration is only available on Windows")
}
#[cfg(not(windows))]
fn platform_unregister() -> Result<()> {
anyhow::bail!("Windows Explorer integration is only available on Windows")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registration_targets_both_directory_surfaces() {
let app = Path::new(r"C:\Program Files\tty7\tty7-app.exe");
let [directory, background] = registrations(app);
assert_eq!(directory.location.key(), DIRECTORY_KEY);
assert_eq!(directory.location.label(), "Open in tty7");
assert_eq!(directory.icon, app.as_os_str());
assert_eq!(
directory.command,
r#""C:\Program Files\tty7\tty7-app.exe" --open-path "%1""#
);
assert_eq!(background.location.key(), BACKGROUND_KEY);
assert_eq!(background.location.label(), "Open tty7 here");
assert_eq!(background.icon, app.as_os_str());
assert_eq!(
background.command,
r#""C:\Program Files\tty7\tty7-app.exe" --open-path "%V""#
);
}
#[test]
fn commands_quote_even_paths_without_spaces() {
assert_eq!(
quoted_open_command(Path::new(r"C:\tty7\tty7-app.exe"), "%1"),
r#""C:\tty7\tty7-app.exe" --open-path "%1""#
);
}
#[test]
fn registration_shape_rejects_every_extra_value_or_subkey() {
assert!(registration_tree_is_exact(
RegistryShape {
values: 2,
subkeys: 1,
},
RegistryShape {
values: 1,
subkeys: 0,
},
));
assert!(!registration_tree_is_exact(
RegistryShape {
values: 3,
subkeys: 1,
},
RegistryShape {
values: 1,
subkeys: 0,
},
));
assert!(!registration_tree_is_exact(
RegistryShape {
values: 2,
subkeys: 1,
},
RegistryShape {
values: 2,
subkeys: 0,
},
));
assert!(!registration_tree_is_exact(
RegistryShape {
values: 2,
subkeys: 2,
},
RegistryShape {
values: 1,
subkeys: 0,
},
));
}
#[test]
fn registration_replaces_the_owned_tree_before_writing() {
let registration = Registration::new(
Location::Directory,
Path::new(r"C:\Program Files\tty7\tty7-app.exe"),
);
let operations = std::cell::RefCell::new(Vec::new());
replace_entry_with(
&registration,
|key| {
operations.borrow_mut().push(format!("delete:{key}"));
Ok(())
},
|_| {
operations.borrow_mut().push("write".to_string());
Ok(())
},
)
.unwrap();
assert_eq!(
operations.into_inner(),
vec![format!("delete:{DIRECTORY_KEY}"), "write".to_string()]
);
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod actions;
pub mod agent_prompt;
pub mod cli_install;
pub mod config;
pub mod explorer_context_menu;
pub mod keychain;
pub mod session;
pub mod ssh_config;
+80 -32
View File
@@ -94,32 +94,42 @@ fn is_theme_file(p: &std::path::Path) -> bool {
})
}
fn apply_config_dir_arg() {
let mut args = std::env::args().skip(1);
fn strip_os_arg_prefix(arg: &std::ffi::OsStr, prefix: &str) -> Option<std::ffi::OsString> {
let suffix = arg.as_encoded_bytes().strip_prefix(prefix.as_bytes())?;
// SAFETY: `prefix` is ASCII and is removed only from the beginning of an
// existing platform-encoded OsStr. ASCII bytes are self-synchronizing in
// Windows WTF-8 and Unix byte strings, so the suffix keeps valid encoding.
Some(unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(suffix.to_vec()) })
}
fn config_dir_from(
mut args: impl Iterator<Item = std::ffi::OsString>,
) -> Option<std::path::PathBuf> {
while let Some(arg) = args.next() {
if let Some(path) = arg.strip_prefix("--config-dir=") {
crate::core::config::set_config_dir(path.into());
return;
if let Some(path) = strip_os_arg_prefix(&arg, "--config-dir=") {
return Some(path.into());
}
if arg == "--config-dir" {
if let Some(path) = args.next() {
crate::core::config::set_config_dir(path.into());
}
return;
if arg == std::ffi::OsStr::new("--config-dir") {
return args.next().map(Into::into);
}
}
None
}
fn apply_config_dir_arg(args: &[std::ffi::OsString]) {
if let Some(path) = config_dir_from(args.iter().cloned()) {
crate::core::config::set_config_dir(path);
}
}
fn open_path_arg() -> Option<std::path::PathBuf> {
open_path_from(std::env::args().skip(1))
}
fn open_path_from(mut args: impl Iterator<Item = String>) -> Option<std::path::PathBuf> {
fn open_path_from(
mut args: impl Iterator<Item = std::ffi::OsString>,
) -> Option<std::path::PathBuf> {
while let Some(arg) = args.next() {
if let Some(path) = arg.strip_prefix("--open-path=") {
if let Some(path) = strip_os_arg_prefix(&arg, "--open-path=") {
return Some(path.into());
}
if arg == "--open-path" {
if arg == std::ffi::OsStr::new("--open-path") {
return args.next().map(Into::into);
}
}
@@ -262,34 +272,41 @@ fn set_dock_icon_for_bare_binary() {
}
fn main() {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
{
let args: Vec<String> = std::env::args().skip(1).take(3).collect();
if args.first().map(String::as_str) == Some("agent-hook") {
if let [_, agent, event] = args.as_slice() {
if args.first().map(std::ffi::OsString::as_os_str)
== Some(std::ffi::OsStr::new("agent-hook"))
{
if let (Some(agent), Some(event)) = (
args.get(1).and_then(|arg| arg.to_str()),
args.get(2).and_then(|arg| arg.to_str()),
) {
crate::core::agent_hooks::run_agent_hook(agent, event);
}
return;
}
}
apply_config_dir_arg();
apply_config_dir_arg(&args);
let role = if std::env::args().any(|a| a == "--daemon") {
"daemon"
} else {
"gui"
};
let daemon = args
.iter()
.any(|arg| arg == std::ffi::OsStr::new("--daemon"));
let role = if daemon { "daemon" } else { "gui" };
crate::core::crash::install(role);
crate::core::logfile::install(role);
if std::env::args().any(|a| a == "--daemon") {
if daemon {
if let Err(e) = crate::daemon::server::run_daemon() {
log::error!("daemon exited with error: {e}");
}
return;
}
if std::env::args().any(|a| a == "--stop-daemon") {
if args
.iter()
.any(|arg| arg == std::ffi::OsStr::new("--stop-daemon"))
{
crate::daemon::spawn::stop();
return;
}
@@ -297,7 +314,7 @@ fn main() {
#[cfg(unix)]
enrich_path_from_login_shell();
let open_path = open_path_arg();
let open_path = open_path_from(args.into_iter());
if forward_open_path(open_path.as_deref()) {
return;
}
@@ -374,7 +391,8 @@ mod tests {
#[cfg(test)]
mod argument_tests {
use super::{forward_open_path_with, open_path_from};
use super::{config_dir_from, forward_open_path_with, open_path_from};
use std::ffi::OsString;
use std::path::PathBuf;
use tty7_core::daemon::control::ReplyOk;
@@ -383,14 +401,44 @@ mod argument_tests {
let separate = open_path_from(
["--config-dir", "/cfg", "--open-path", "/work"]
.into_iter()
.map(str::to_string),
.map(OsString::from),
);
assert_eq!(separate, Some(PathBuf::from("/work")));
let equals = open_path_from(["--open-path=C:\\work".to_string()].into_iter());
let equals = open_path_from([OsString::from("--open-path=C:\\work")].into_iter());
assert_eq!(equals, Some(PathBuf::from("C:\\work")));
}
#[test]
fn config_dir_accepts_native_separate_and_equals_forms() {
let separate = config_dir_from(
[OsString::from("--config-dir"), OsString::from("C:\\cfg")].into_iter(),
);
assert_eq!(separate, Some(PathBuf::from("C:\\cfg")));
let equals = config_dir_from([OsString::from("--config-dir=C:\\cfg")].into_iter());
assert_eq!(equals, Some(PathBuf::from("C:\\cfg")));
}
#[cfg(windows)]
#[test]
fn open_path_preserves_unpaired_utf16_from_windows_arguments() {
use std::os::windows::ffi::OsStringExt as _;
let native_path =
OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xD800, b'x' as u16]);
let parsed =
open_path_from([OsString::from("--open-path"), native_path.clone()].into_iter());
assert_eq!(parsed, Some(PathBuf::from(native_path.clone())));
let mut equals_arg = OsString::from("--open-path=");
equals_arg.push(&native_path);
assert_eq!(
open_path_from([equals_arg].into_iter()),
Some(PathBuf::from(native_path))
);
}
#[test]
fn no_open_path_never_attempts_early_dispatch() {
let delivered = forward_open_path_with(None, |_| {
+38
View File
@@ -1938,6 +1938,41 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.install_cli_on_path = on);
}
pub(crate) fn register_explorer_context_menu(&mut self, cx: &mut Context<Self>) {
self.run_explorer_context_menu_action(true, cx);
}
pub(crate) fn unregister_explorer_context_menu(&mut self, cx: &mut Context<Self>) {
self.run_explorer_context_menu_action(false, cx);
}
fn run_explorer_context_menu_action(&mut self, register: bool, cx: &mut Context<Self>) {
// Registry operations are tiny and synchronous. Keeping the action on
// the UI thread also makes the displayed status correspond to the
// completed write, without a task racing a closed Settings window.
let result = if register {
crate::core::explorer_context_menu::register()
} else {
crate::core::explorer_context_menu::unregister()
};
let note = match result {
Ok(()) if register => {
"Registered. Right-click a folder or folder background in Explorer to open it in tty7."
.to_string()
}
Ok(()) => "Unregistered from Windows Explorer.".to_string(),
Err(error) if register => format!("Could not register: {error}"),
Err(error) => format!("Could not unregister: {error}"),
};
let status =
crate::core::explorer_context_menu::status().map_err(|error| error.to_string());
if let Some(settings) = self.settings.as_mut() {
settings.explorer_context_menu_status = status;
settings.explorer_context_menu_note = Some(note);
}
cx.notify();
}
pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.dim_inactive_panes = on);
}
@@ -3547,6 +3582,9 @@ impl Tty7App {
theme_search,
recording: None,
rebinding_note: None,
explorer_context_menu_status: crate::core::explorer_context_menu::status()
.map_err(|error| error.to_string()),
explorer_context_menu_note: None,
ssh_form: None,
ssh_detail: crate::ui::settings::SshDetail::None,
ssh_filter,
+112 -1
View File
@@ -378,6 +378,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: "Command line tool",
keywords: "cli tty7 path shell command install symlink terminal iterm agent script",
},
SearchEntry {
section: About,
title: "Windows Explorer context menu",
keywords: "windows explorer right click folder directory background shell menu register unregister open here",
},
]
}
@@ -429,6 +434,9 @@ pub(crate) struct SettingsState {
pub(crate) theme_search: Entity<InputState>,
pub(crate) recording: Option<Recording>,
pub(crate) rebinding_note: Option<String>,
pub(crate) explorer_context_menu_status:
Result<crate::core::explorer_context_menu::Status, String>,
pub(crate) explorer_context_menu_note: Option<String>,
pub(crate) ssh_form: Option<SshProfileForm>,
pub(crate) ssh_detail: SshDetail,
pub(crate) ssh_filter: Entity<InputState>,
@@ -4497,7 +4505,12 @@ impl Tty7App {
fn render_settings_about(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
let (foreground, muted_fg, success, warning) = (
theme.foreground,
theme.muted_foreground,
theme.success,
theme.warning,
);
let update_status = cx
.try_global::<crate::core::update::UpdateStatus>()
@@ -4526,6 +4539,40 @@ impl Tty7App {
};
let check_for_updates = cx.global::<Config>().check_for_updates;
let install_cli_on_path = cx.global::<Config>().install_cli_on_path;
let (explorer_status, explorer_note) = self
.active_settings()
.map(|settings| {
(
settings.explorer_context_menu_status.clone(),
settings.explorer_context_menu_note.clone(),
)
})
.unwrap_or((
Ok(crate::core::explorer_context_menu::Status::Unsupported),
None,
));
let (
explorer_status_text,
explorer_status_color,
register_label,
register_disabled,
unregister_disabled,
) = match explorer_status.as_ref() {
Ok(crate::core::explorer_context_menu::Status::NotRegistered) => {
("Not registered", muted_fg, "Register", false, true)
}
Ok(crate::core::explorer_context_menu::Status::Registered) => {
("Registered", success, "Register", true, false)
}
Ok(crate::core::explorer_context_menu::Status::NeedsUpdate) => {
("Needs update", warning, "Update", false, false)
}
Ok(crate::core::explorer_context_menu::Status::Unsupported) => {
("Unavailable", muted_fg, "Register", true, true)
}
Err(_) => ("Status unavailable", warning, "Register", false, false),
};
let explorer_feedback = explorer_note.or_else(|| explorer_status.err());
let logo = Arc::new(Image::from_bytes(
ImageFormat::Png,
@@ -4667,6 +4714,69 @@ impl Tty7App {
),
),
)
.when(cfg!(windows), |page| {
page.child(
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child("Windows Explorer"),
)
.child(div().text_sm().text_color(muted_fg).child(
"Add “Open in tty7” when you right-click a folder and “Open tty7 here” when you right-click a folder background. This is off by default and is registered only for your Windows account.",
))
.child(
h_flex()
.gap_2()
.items_center()
.child(div().size_2().rounded_full().bg(explorer_status_color))
.child(
div()
.text_sm()
.text_color(foreground)
.child(explorer_status_text),
),
)
.child(
h_flex()
.gap_2()
.child(
Button::new("explorer-menu-register")
.label(register_label)
.small()
.disabled(register_disabled)
.on_click(cx.listener(|this, _, _window, cx| {
this.register_explorer_context_menu(cx)
})),
)
.child(
Button::new("explorer-menu-unregister")
.label("Unregister")
.small()
.disabled(unregister_disabled)
.on_click(cx.listener(|this, _, _window, cx| {
this.unregister_explorer_context_menu(cx)
})),
),
)
.when_some(explorer_feedback, |section, message| {
section.child(
div()
.text_xs()
.text_color(muted_fg)
.child(message),
)
})
.child(div().text_xs().text_color(muted_fg).child(
"On Windows 11, classic shell entries may appear under “Show more options”.",
)),
)
})
.child(
v_flex()
.mt_6()
@@ -4795,6 +4905,7 @@ mod tests {
("bell", Terminal),
("known_hosts", Ssh),
("claude", Agents),
("right click", About),
];
for (query, expected) in cases {
assert_eq!(