mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(®istration.icon))
|
||||
&& command.as_deref() == Some(&units(®istration.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")), ®istration.icon)?;
|
||||
|
||||
let command_path = format!(r"{}\command", registration.location.key());
|
||||
let command = create_key(&command_path)?;
|
||||
set_string(&command, None, ®istration.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(®istration, 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(
|
||||
®istration,
|
||||
|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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, |_| {
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user