feat(macos): add default terminal integration (#818)

* feat(macos): add default terminal integration

* fix(macos): route external opens through the layout pull

Five holes in the LaunchServices path, all on the way from a URL to a tab.

The `ssh:` arm handed the raw URL back to `parse_quick_connect`, which
reads a bare `user@host:port` typed into Quick Connect. Everything a URL
carries past the authority landed in the wrong field: `ssh://h:2200/`
parsed its port as `2200/` and was dropped on the floor, `ssh://h/srv`
became the host `h/srv`, and the percent escapes `url` was added for were
never decoded. Read the authority off the parsed URL instead.

`x-man-page://3/printf` is Apple's sectioned form, and taking the host as
the page name ran `man 3`, which asks the user what page they wanted.
Section and page are now both carried.

A window that is pulling its layout is one `Adopt::IfEmpty` will not adopt
into, so a tab inserted while the pull is out comes back as the whole
workspace — the failure `then_open` already exists to avoid. Both the
script/man path and the SSH path inserted straight into a freshly restored
window, so `then_open` becomes a list of parked requests and carries a
command or an SSH link as well as a folder. A cold `ssh://` link also went
through `open_at` directly, claiming a fresh workspace and leaving the
restored one detached and unannounced; it takes the shared restore now.

`new_tab_running` wrote the command whether or not a tab opened, so a
failed spawn typed a script path and a newline into whatever pane was
focused before — a shell mid-line, or an agent.

Left alone deliberately: an `ssh://` link still connects without a
confirmation, which is a product call rather than a defect.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
ayamir
2026-09-09 17:05:58 +08:00
committed by GitHub
co-authored by l0ng-ai
parent e632c0f81b
commit 59dbe83913
14 changed files with 781 additions and 58 deletions
+33
View File
@@ -72,6 +72,39 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>CFBundlePackageType</key><string>APPL</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSPrincipalClass</key><string>NSApplication</string>
<!-- LaunchServices has no global default-terminal setting. These declare
the specific document types tty7 can open, and Settings lets users
select tty7 as their handler for them. -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key><string>Folder</string>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
<key>LSItemContentTypes</key><array><string>public.directory</string></array>
</dict>
<dict>
<key>CFBundleTypeName</key><string>Shell Script</string>
<key>CFBundleTypeRole</key><string>Shell</string>
<key>LSItemContentTypes</key><array><string>public.shell-script</string></array>
</dict>
<dict>
<key>CFBundleTypeName</key><string>Unix Executable</string>
<key>CFBundleTypeRole</key><string>Shell</string>
<key>LSItemContentTypes</key><array><string>public.unix-executable</string></array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key><string>SSH</string>
<key>CFBundleURLSchemes</key><array><string>ssh</string></array>
</dict>
<dict>
<key>CFBundleURLName</key><string>Man Page</string>
<key>CFBundleURLSchemes</key><array><string>x-man-page</string></array>
</dict>
</array>
<!-- tty7 is a terminal workbench: panes are forked from the bundled
executable, so macOS attributes a child process's protected-resource
requests to tty7.app. Without these usage strings a program you run in
Generated
+2
View File
@@ -9848,6 +9848,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"percent-encoding",
"plist",
"regex",
"reqwest_client",
@@ -9862,6 +9863,7 @@ dependencies = [
"tty7-core",
"unicode-segmentation",
"unicode-width",
"url",
"uuid",
"windows 0.58.0",
"windows-sys 0.61.2",
+7
View File
@@ -66,6 +66,13 @@ smallvec.workspace = true
serde = { workspace = true }
serde_json.workspace = true
tempfile = "3"
# LaunchServices sends files and custom URL schemes through gpui's unified
# open-URL callback. `url` keeps the decoding and SSH authority parsing here
# standards-compliant rather than treating percent escapes as literal paths,
# and `percent-encoding` — already in the tree under `url` — spells the
# userinfo and man-page names back out.
url = "2"
percent-encoding = "2"
# SSH profile ids in the connection manager UI (`ui::ssh_connect`,
# `ui::settings`, the command palette). The profiles themselves — and the
+277
View File
@@ -0,0 +1,277 @@
//! macOS default-terminal integration.
//!
//! LaunchServices has no system-wide "default terminal" switch. Instead it
//! remembers a handler per document type and URL scheme, which is exactly the
//! narrow promise tty7 can make: folders, runnable local files, SSH links, and
//! man-page links.
use std::path::PathBuf;
#[cfg(target_os = "macos")]
pub const BUNDLE_ID: &str = "com.github.tty7";
#[cfg(target_os = "macos")]
const URL_SCHEMES: &[&str] = &["ssh", "x-man-page"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExternalOpen {
Folder(PathBuf),
Runnable(PathBuf),
Ssh(tty7_core::core::ssh_profile::QuickConnect),
/// `man [section] page`, from `x-man-page://page` or the sectioned
/// `x-man-page://section/page` that Apple's own man-page links use.
ManPage {
section: Option<String>,
page: String,
},
}
/// Parses the strings supplied by gpui's `Application::on_open_urls`. Finder
/// represents document opens as `file:` URLs on macOS, so paths and custom
/// schemes deliberately share this one entry point.
pub fn parse_open_url(raw: &str) -> Result<ExternalOpen, String> {
let url = url::Url::parse(raw).map_err(|error| format!("invalid URL: {error}"))?;
match url.scheme() {
"file" => {
let path = url
.to_file_path()
.map_err(|_| "the file URL does not name a local path".to_string())?;
if path.is_dir() {
Ok(ExternalOpen::Folder(path))
} else {
Ok(ExternalOpen::Runnable(path))
}
}
"ssh" => quick_connect_from(&url).map(ExternalOpen::Ssh),
"x-man-page" => man_page_from(&url),
scheme => Err(format!("unsupported URL scheme: {scheme}")),
}
}
/// Reads the authority `Url` has already validated rather than handing the raw
/// string to [`tty7_core::core::ssh_profile::parse_quick_connect`]. That parser
/// takes a bare `user@host:port` typed into Quick Connect, so everything a URL
/// may carry past the authority lands in the wrong field: `ssh://h:22/` parses
/// its port as `22/` and is dropped, and `ssh://h/srv` becomes the host
/// `h/srv`.
fn quick_connect_from(
url: &url::Url,
) -> Result<tty7_core::core::ssh_profile::QuickConnect, String> {
let host = match url.host() {
// `Host`'s own `Display` brackets an IPv6 address for use in a URL.
// `QuickConnect` holds the bare form and brackets it again when it
// writes one out, so unwrap it here.
Some(url::Host::Ipv6(address)) => address.to_string(),
Some(host) => host.to_string(),
None => return Err("the SSH URL has no host".to_string()),
};
if host.is_empty() {
return Err("the SSH URL has no host".to_string());
}
let user = decode(url.username(), "user name")?;
Ok(tty7_core::core::ssh_profile::QuickConnect {
user: (!user.is_empty()).then_some(user),
host,
// Port 0 is what `Url` gives back for `:0`, and no SSH server listens
// there; the Quick Connect parser rejects it the same way.
port: url.port().filter(|port| *port != 0),
})
}
/// Apple writes these two ways: `x-man-page://ls`, and `x-man-page://3/printf`
/// where the authority is the *section*. Taking the host as the page name
/// turns the second form into `man 3`, which asks the user what page they
/// wanted.
fn man_page_from(url: &url::Url) -> Result<ExternalOpen, String> {
let mut parts = Vec::new();
if let Some(host) = url.host_str() {
parts.push(decode(host, "page name")?);
}
for segment in url.path().split('/') {
parts.push(decode(segment, "page name")?);
}
parts.retain(|part| !part.is_empty());
let mut parts = parts.into_iter();
let first = parts
.next()
.ok_or_else(|| "the man-page URL has no page name".to_string())?;
Ok(match parts.next() {
Some(page) => ExternalOpen::ManPage {
section: Some(first),
page,
},
None => ExternalOpen::ManPage {
section: None,
page: first,
},
})
}
fn decode(raw: &str, what: &str) -> Result<String, String> {
percent_encoding::percent_decode_str(raw)
.decode_utf8()
.map(|decoded| decoded.into_owned())
.map_err(|_| format!("the URL has a {what} that is not UTF-8"))
}
#[cfg(target_os = "macos")]
pub fn set_as_default_terminal() -> Result<(), String> {
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
// LaunchServices is a subframework of CoreServices. Linking the parent is
// portable across both the full Xcode SDK and Command Line Tools SDK; the
// latter has no standalone `LaunchServices.framework` linker path.
#[link(name = "CoreServices", kind = "framework")]
unsafe extern "C" {
fn LSSetDefaultRoleHandlerForContentType(
content_type: core_foundation::string::CFStringRef,
role: u32,
handler: core_foundation::string::CFStringRef,
) -> i32;
fn LSSetDefaultHandlerForURLScheme(
scheme: core_foundation::string::CFStringRef,
handler: core_foundation::string::CFStringRef,
) -> i32;
}
// This is the conventional macOS definition of "default terminal":
// iTerm2 makes the same `public.unix-executable` / `Shell` association. A
// folder is a Viewer/Editor item rather than something a terminal executes,
// so asking LaunchServices to assign its Shell role is invalid (-50).
const ROLE_SHELL: u32 = 0x0000_0008;
let handler = CFString::new(BUNDLE_ID);
let executable = CFString::new("public.unix-executable");
let status = unsafe {
LSSetDefaultRoleHandlerForContentType(
executable.as_concrete_TypeRef(),
ROLE_SHELL,
handler.as_concrete_TypeRef(),
)
};
if status != 0 {
return Err(format!(
"could not set the Unix executable handler (LaunchServices status {status})"
));
}
for scheme in URL_SCHEMES {
let scheme = CFString::new(scheme);
let status = unsafe {
LSSetDefaultHandlerForURLScheme(
scheme.as_concrete_TypeRef(),
handler.as_concrete_TypeRef(),
)
};
if status != 0 {
return Err(format!(
"could not set the {scheme} URL handler (LaunchServices status {status})"
));
}
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn set_as_default_terminal() -> Result<(), String> {
Err("setting a default terminal is only available on macOS".to_string())
}
#[cfg(test)]
mod tests {
use super::{ExternalOpen, parse_open_url};
#[test]
fn parses_finder_file_urls_and_percent_decodes_paths() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("a folder");
std::fs::create_dir(&path).unwrap();
assert_eq!(
parse_open_url(&url::Url::from_file_path(&path).unwrap().to_string()).unwrap(),
ExternalOpen::Folder(path)
);
}
fn ssh(raw: &str) -> tty7_core::core::ssh_profile::QuickConnect {
let ExternalOpen::Ssh(ssh) = parse_open_url(raw).unwrap() else {
panic!("expected SSH request from {raw:?}");
};
ssh
}
#[test]
fn parses_ssh_authority_and_port() {
let parsed = ssh("ssh://me@example.test:2200");
assert_eq!(parsed.user.as_deref(), Some("me"));
assert_eq!(parsed.host, "example.test");
assert_eq!(parsed.port, Some(2200));
}
/// The authority is read off the parsed URL, so what follows it cannot
/// leak into the host or the port the way it does when the raw string is
/// re-parsed as a bare `user@host:port`.
#[test]
fn ssh_paths_and_trailing_slashes_stay_out_of_the_authority() {
let trailing = ssh("ssh://me@example.test:2200/");
assert_eq!(trailing.host, "example.test");
assert_eq!(trailing.port, Some(2200));
let with_path = ssh("ssh://example.test/srv/app");
assert_eq!(with_path.host, "example.test");
assert_eq!(with_path.port, None);
// `Url` lowercases the scheme, so the arm fires whatever case the
// link was written in.
assert_eq!(ssh("SSH://example.test").host, "example.test");
}
#[test]
fn ssh_decodes_the_user_and_unwraps_ipv6() {
assert_eq!(
ssh("ssh://user%40corp@example.test").user.as_deref(),
Some("user@corp")
);
let numeric = ssh("ssh://[fe80::1]:2200");
assert_eq!(numeric.host, "fe80::1");
assert_eq!(numeric.port, Some(2200));
}
#[test]
fn ssh_without_a_host_is_rejected() {
assert!(parse_open_url("ssh://").is_err());
}
#[test]
fn parses_man_page_host_or_path() {
assert_eq!(
parse_open_url("x-man-page://printf").unwrap(),
ExternalOpen::ManPage {
section: None,
page: "printf".into()
}
);
assert_eq!(
parse_open_url("x-man-page:/ls").unwrap(),
ExternalOpen::ManPage {
section: None,
page: "ls".into()
}
);
}
/// Apple's sectioned form puts the section in the authority. Reading the
/// host as the page name ran `man 3` and asked what page was wanted.
#[test]
fn a_sectioned_man_page_keeps_its_page_name() {
assert_eq!(
parse_open_url("x-man-page://3/printf").unwrap(),
ExternalOpen::ManPage {
section: Some("3".into()),
page: "printf".into()
}
);
}
#[test]
fn a_man_page_url_with_no_name_is_rejected() {
assert!(parse_open_url("x-man-page://").is_err());
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod agent_prompt;
pub mod aumid;
pub mod cli_install;
pub mod config;
pub mod default_terminal;
pub mod explorer_context_menu;
pub mod keychain;
pub mod rate_meter;
+89 -33
View File
@@ -441,6 +441,47 @@ fn set_dock_icon_for_bare_binary() {
}
}
/// Delivers Finder document opens and LaunchServices URL opens after gpui has
/// created the application. The native callback queues requests; UI state is
/// then changed on gpui's application loop.
fn handle_external_opens(urls: Vec<String>, cx: &mut App) {
use crate::core::default_terminal::{ExternalOpen, parse_open_url};
for raw in urls {
let request = match parse_open_url(&raw) {
Ok(request) => request,
Err(error) => {
log::warn!("ignored external open request {raw:?}: {error}");
continue;
}
};
match request {
ExternalOpen::Folder(path) => crate::ui::windows::open_from_cli(cx, Some(path)),
ExternalOpen::Runnable(path) => {
let Some(parent) = path.parent().map(std::path::Path::to_path_buf) else {
log::warn!(
"ignored runnable without a parent directory: {}",
path.display()
);
continue;
};
let command =
crate::core::shell_quote::quote_for_shell(&path.to_string_lossy(), None);
crate::ui::windows::run_local_command(cx, parent, command);
}
ExternalOpen::Ssh(ssh) => crate::ui::windows::quick_connect_from_url(cx, ssh),
ExternalOpen::ManPage { section, page } => {
let mut command = String::from("man");
for argument in section.iter().chain(std::iter::once(&page)) {
command.push(' ');
command.push_str(&crate::core::shell_quote::quote_for_shell(argument, None));
}
crate::ui::windows::run_local_command(cx, std::env::temp_dir(), command);
}
}
}
}
fn main() {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
{
@@ -583,46 +624,61 @@ fn main() {
log::error!("failed to ensure daemon is running: {e}");
}
gpui_platform::application()
// `Application`, not the in-loop `App`, owns the native delegate. Register
// before `run` so macOS can deliver Finder and URL events from launch.
let (external_open_tx, external_open_rx) = smol::channel::unbounded();
let application = gpui_platform::application()
.with_assets(Assets)
// The window-close path decides whether this process survives: with
// the tray icon on, closing the last window retires to the tray, and
// without it the close handler quits explicitly. The platform default
// would quit on the last window unconditionally, which is exactly the
// orphaned-daemon trap the tray is meant to prevent.
.with_quit_mode(QuitMode::Explicit)
.run(move |cx| {
gpui_component::init(cx);
register_bundled_fonts(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
crate::ui::i18n::set_locale(&gui_language);
// The load above is reused rather than re-read: reading the same
// file twice at launch would report the same failure twice.
cx.set_global(config);
crate::ui::theme::refresh_system_appearance(cx);
crate::core::session::WorkspaceStore::init(cx);
crate::ui::windows::WindowRegistry::init(cx);
crate::ui::presets::load_registry(cx);
crate::ui::theme::apply_cursor_hide_mode(cx);
spawn_config_watcher(cx);
crate::core::update::spawn_check(cx);
cx.background_executor()
.spawn(async {
crate::core::agent_hooks::refresh_hooks_at_launch();
})
.detach();
keymap::init(cx);
crate::ui::local_link::LocalLink::install(cx);
let reopen = crate::ui::windows::restore_target(cx, open_path.as_deref());
crate::ui::windows::open_at(cx, reopen.map(|(id, _)| id), open_path);
crate::ui::windows::announce_detached_at_launch(cx, reopen);
if config_outcome.failed() {
notify_config_load_failed(cx, config_outcome, true);
.with_quit_mode(QuitMode::Explicit);
application.on_open_urls(move |urls| {
let _ = external_open_tx.try_send(urls);
});
application.run(move |cx| {
// gpui invokes this callback without an `App` context. Bridge it
// back onto the application loop instead of touching UI state on
// AppKit's delegate call stack.
cx.spawn(async move |cx| {
while let Ok(urls) = external_open_rx.recv().await {
let _ = cx.update(|cx| handle_external_opens(urls, cx));
}
});
})
.detach();
gpui_component::init(cx);
register_bundled_fonts(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
crate::ui::i18n::set_locale(&gui_language);
// The load above is reused rather than re-read: reading the same
// file twice at launch would report the same failure twice.
cx.set_global(config);
crate::ui::theme::refresh_system_appearance(cx);
crate::core::session::WorkspaceStore::init(cx);
crate::ui::windows::WindowRegistry::init(cx);
crate::ui::presets::load_registry(cx);
crate::ui::theme::apply_cursor_hide_mode(cx);
spawn_config_watcher(cx);
crate::core::update::spawn_check(cx);
cx.background_executor()
.spawn(async {
crate::core::agent_hooks::refresh_hooks_at_launch();
})
.detach();
keymap::init(cx);
crate::ui::local_link::LocalLink::install(cx);
let reopen = crate::ui::windows::restore_target(cx, open_path.as_deref());
crate::ui::windows::open_at(cx, reopen.map(|(id, _)| id), open_path);
crate::ui::windows::announce_detached_at_launch(cx, reopen);
if config_outcome.failed() {
notify_config_load_failed(cx, config_outcome, true);
}
});
}
/// The watcher tick, from a reloaded file to the keys the app dispatches on.
+35
View File
@@ -3552,6 +3552,41 @@ impl Tty7App {
self.new_tab_with_cwd(Some(cwd), None, window, cx);
}
/// Opens a new local shell in `cwd`, then types a command only after the
/// pane exists. LaunchServices uses this for a script/executable selected
/// in Finder and for `x-man-page:` requests.
///
/// A tab that did not open takes the command with it. `new_tab_with_cwd`
/// returns early when the spawn fails or the workspace cannot host a local
/// shell, and writing anyway would type the command into whatever pane was
/// focused before — a shell the user is mid-line in, or an agent.
pub(crate) fn new_tab_running(
&mut self,
cwd: std::path::PathBuf,
command: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let before = self.tabs.len();
self.new_tab_with_cwd(Some(cwd), None, window, cx);
if self.tabs.len() == before {
log::warn!("no tab opened for {command:?}; not writing it to another pane");
return;
}
if let Some(terminal) = self.focused_leaf(window, cx) {
terminal.read(cx).run_command_line(&command);
}
}
/// Uses the terminal that a newly-created window already opened. This keeps
/// a cold LaunchServices request to one tab rather than creating the
/// window's default shell and then a second shell for the requested item.
pub(crate) fn run_in_active_terminal(&self, command: &str, window: &Window, cx: &App) {
if let Some(terminal) = self.focused_leaf(window, cx) {
terminal.read(cx).run_command_line(command);
}
}
pub(crate) fn new_tab_with_shell(
&mut self,
shell: Option<ShellSpec>,
+11
View File
@@ -635,6 +635,17 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsAboutDesc1 => {
"A terminal workbench: persistent sessions, remote work, agents."
}
L10nKey::SettingsDefaultTerminal => "Default terminal",
L10nKey::SettingsDefaultTerminalDesc => {
"Make tty7 the macOS default terminal for Unix executables, SSH links, and man-page links. tty7 can also open folders and scripts, but does not replace Finder's folder handler. Apps that choose their own terminal may ignore this setting."
}
L10nKey::SettingsDefaultTerminalSet => "Set as Default Terminal",
L10nKey::SettingsDefaultTerminalSetSuccess => {
"tty7 is now the default handler for supported terminal files and links."
}
L10nKey::SettingsDefaultTerminalSetFailed => {
"Could not set tty7 as the default terminal: {error}"
}
L10nKey::SettingsVersion => "Version",
L10nKey::SettingsUpdates => "Updates",
L10nKey::SettingsUpdateAndRelaunch => "Update and relaunch",
+11
View File
@@ -642,6 +642,17 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAboutDesc1 => {
"ターミナルワークベンチ: 常駐セッション、リモート作業、エージェント"
}
L10nKey::SettingsDefaultTerminal => "デフォルトのターミナル",
L10nKey::SettingsDefaultTerminalDesc => {
"tty7 を Unix 実行ファイル、SSH リンク、man ページリンク用の macOS のデフォルトターミナルにします。tty7 はフォルダとスクリプトも開けますが、Finder のフォルダハンドラは置き換えません。独自のターミナルを指定するアプリはこの設定を無視することがあります。"
}
L10nKey::SettingsDefaultTerminalSet => "デフォルトのターミナルに設定",
L10nKey::SettingsDefaultTerminalSetSuccess => {
"tty7 を対応するターミナルファイルとリンクのデフォルトハンドラに設定しました。"
}
L10nKey::SettingsDefaultTerminalSetFailed => {
"tty7 をデフォルトのターミナルに設定できませんでした: {error}"
}
L10nKey::SettingsVersion => "バージョン",
L10nKey::SettingsUpdates => "アップデート",
L10nKey::SettingsUpdateAndRelaunch => "更新して再起動",
+5
View File
@@ -508,6 +508,11 @@ l10n_keys! {
KeybindForkSessionDown,
KeybindForkSessionUp,
SettingsAboutDesc1,
SettingsDefaultTerminal,
SettingsDefaultTerminalDesc,
SettingsDefaultTerminalSet,
SettingsDefaultTerminalSetSuccess,
SettingsDefaultTerminalSetFailed,
SettingsVersion,
SettingsUpdates,
SettingsUpdateAndRelaunch,
+9
View File
@@ -563,6 +563,15 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::KeybindForkSessionDown => "向下 Fork 会话",
L10nKey::KeybindForkSessionUp => "向上 Fork 会话",
L10nKey::SettingsAboutDesc1 => "终端工作台:常驻会话、远程工作、agent。",
L10nKey::SettingsDefaultTerminal => "默认终端",
L10nKey::SettingsDefaultTerminalDesc => {
"将 tty7 设为 Unix 可执行文件、SSH 链接和 man 页面链接的 macOS 默认终端。tty7 仍可打开文件夹和脚本,但不会替换 Finder 的文件夹处理程序。自行指定终端的应用可能不会遵循此设置。"
}
L10nKey::SettingsDefaultTerminalSet => "设为默认终端",
L10nKey::SettingsDefaultTerminalSetSuccess => {
"tty7 已成为受支持终端文件和链接的默认处理程序。"
}
L10nKey::SettingsDefaultTerminalSetFailed => "无法将 tty7 设为默认终端:{error}",
L10nKey::SettingsVersion => "版本",
L10nKey::SettingsUpdates => "更新",
L10nKey::SettingsUpdateAndRelaunch => "更新并重新启动",
+34
View File
@@ -7033,6 +7033,40 @@ impl Tty7App {
.text_color(muted_fg)
.child(t(L10nKey::SettingsAboutDesc1)),
)
.when(cfg!(target_os = "macos"), |this| {
this.child(self.section_rule(cx)).child(
v_flex()
.gap_2()
.child(
div()
.text_sm()
.font_weight(FontWeight::SEMIBOLD)
.text_color(foreground)
.child(t(L10nKey::SettingsDefaultTerminal)),
)
.child(
div()
.text_xs()
.text_color(muted_fg)
.child(t(L10nKey::SettingsDefaultTerminalDesc)),
)
.child(
Button::new("set-default-terminal")
.label(t(L10nKey::SettingsDefaultTerminalSet))
.small()
.on_click(cx.listener(|_, _, window, cx| {
let message = match crate::core::default_terminal::set_as_default_terminal() {
Ok(()) => t(L10nKey::SettingsDefaultTerminalSetSuccess).to_string(),
Err(error) => t_fmt(
L10nKey::SettingsDefaultTerminalSetFailed,
&[("error", &error)],
),
};
window.push_notification(message, cx);
})),
),
)
})
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsUpdates), cx))
.child(
+158 -25
View File
@@ -896,14 +896,19 @@ struct WsState {
/// Leaving it standing after the run ends is what makes a *first* failure
/// wait the cap: the count would still be carrying an outage that is over.
rehydrate_attempts: u32,
/// A folder the launch asked for, waiting for this window's layout.
/// Tabs a launch or a LaunchServices open asked for, waiting for this
/// window's layout.
///
/// Held here rather than opened straight away because a window with a tab
/// in it is one `Adopt::IfEmpty` will not adopt into: the pull would land,
/// decline the layout, and push the single tab back as the whole workspace.
/// Parking it also means a pull that has to be retried still gets the
/// folder opened, on whichever attempt finally lands.
then_open: Option<std::path::PathBuf>,
///
/// A list because more than one can be waiting: a launch parks the folder
/// it was given, and the `x-man-page:` or script Finder sent to the same
/// cold start parks its own tab behind it.
then_open: Vec<ParkedOpen>,
/// A name the user typed for a workspace this window is about to create.
///
/// It has to travel with the create rather than follow it as a rename: the
@@ -938,7 +943,7 @@ impl Default for WsState {
owed_over: Vec::new(),
not_rebuilt: Vec::new(),
rehydrate_attempts: 0,
then_open: None,
then_open: Vec::new(),
chosen_name: None,
said_why_empty: false,
}
@@ -1606,21 +1611,98 @@ pub(crate) fn hydrate_window_then_open(
.windows
.entry(client_ws)
.or_default()
.then_open = Some(path);
.then_open
.push(ParkedOpen::Folder(path));
hydrate(cx, client_ws, Adopt::IfEmpty);
}
/// Opens the folder a launch parked here, now that the layout it waited for is
/// Parks a tab request while this window's layout is still on its way, and
/// says whether it did. A caller told `false` has a settled window and should
/// open the tab itself; one told `true` has handed the request to the pull,
/// which opens it once the layout is up.
///
/// Inserting into a window mid-pull is the failure `then_open` exists to
/// avoid: `Adopt::IfEmpty` declines a window that has a tab, and pushes that
/// one tab back as the whole workspace.
pub(crate) fn park_command_while_pulling(
cx: &mut App,
client_ws: WorkspaceId,
cwd: &std::path::Path,
command: &str,
) -> bool {
if !layout_is_pending(cx, client_ws) {
return false;
}
let parked = parked_for(cx, client_ws);
// Opening a window for this request parks its folder on the way past, in
// `for_workspace_at`. The command belongs in that tab, not in a second one
// beside it running the same shell.
match parked
.iter_mut()
.find(|open| matches!(open, ParkedOpen::Folder(folder) if folder == cwd))
{
Some(open) => {
*open = ParkedOpen::Command {
cwd: cwd.to_path_buf(),
command: command.to_owned(),
}
}
None => parked.push(ParkedOpen::Command {
cwd: cwd.to_path_buf(),
command: command.to_owned(),
}),
}
true
}
/// [`park_command_while_pulling`] for an `ssh://` link, which opens a tab of
/// its own and so races the same pull.
pub(crate) fn park_ssh_while_pulling(
cx: &mut App,
client_ws: WorkspaceId,
ssh: &tty7_core::core::ssh_profile::QuickConnect,
) -> bool {
if !layout_is_pending(cx, client_ws) {
return false;
}
parked_for(cx, client_ws).push(ParkedOpen::Ssh(ssh.clone()));
true
}
/// Whether this window's layout is still on its way: a pull is out, one is
/// owed a retry, or tabs are already waiting on one.
fn layout_is_pending(cx: &mut App, client_ws: WorkspaceId) -> bool {
cx.default_global::<TreeSync>()
.windows
.get(&client_ws)
.is_some_and(|state| {
matches!(state.sync, SyncPhase::Unprimed { priming: true, .. })
|| state.rehydrate.is_some()
|| !state.then_open.is_empty()
})
}
fn parked_for(cx: &mut App, client_ws: WorkspaceId) -> &mut Vec<ParkedOpen> {
&mut cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default()
.then_open
}
/// Opens the tabs a launch parked here, now that the layout they waited for is
/// up. Does nothing for the windows — every other one — that parked nothing.
fn open_parked_path(cx: &mut App, client_ws: WorkspaceId) {
let Some(path) = cx
let parked = cx
.default_global::<TreeSync>()
.windows
.get_mut(&client_ws)
.and_then(|state| state.then_open.take())
else {
.map(|state| std::mem::take(&mut state.then_open))
.unwrap_or_default();
if parked.is_empty() {
return;
};
}
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else {
return;
};
@@ -1630,10 +1712,34 @@ fn open_parked_path(cx: &mut App, client_ws: WorkspaceId) {
return;
};
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| app.new_tab_at(path, window, cx));
app.update(cx, |app, cx| {
for open in parked {
match open {
ParkedOpen::Folder(cwd) => app.new_tab_at(cwd, window, cx),
ParkedOpen::Command { cwd, command } => {
app.new_tab_running(cwd, command, window, cx)
}
ParkedOpen::Ssh(ssh) => app.quick_connect(ssh, window, cx),
}
}
});
});
}
/// A tab parked until its window's layout lands.
enum ParkedOpen {
/// What a launch and an Explorer double-click ask for.
Folder(std::path::PathBuf),
/// A shell in `cwd` with `command` typed into it, from a script or an
/// `x-man-page:` link Finder handed over.
Command {
cwd: std::path::PathBuf,
command: String,
},
/// An `ssh://` link.
Ssh(tty7_core::core::ssh_profile::QuickConnect),
}
#[derive(Clone, Copy, PartialEq)]
enum Adopt {
IfEmpty,
@@ -2930,32 +3036,59 @@ mod tests {
);
crate::ui::windows::WindowRegistry::init(cx);
hydrate_window_then_open(cx, ws, path.clone());
assert_eq!(
let parked = |cx: &mut gpui::App| -> Vec<std::path::PathBuf> {
cx.default_global::<TreeSync>().windows[&ws]
.then_open
.as_ref(),
Some(&path),
.iter()
.map(|open| match open {
ParkedOpen::Folder(cwd) => cwd.clone(),
ParkedOpen::Command { cwd, .. } => cwd.clone(),
ParkedOpen::Ssh(_) => std::path::PathBuf::from("<ssh>"),
})
.collect()
};
hydrate_window_then_open(cx, ws, path.clone());
assert_eq!(
parked(cx),
vec![path.clone()],
"the request must be parked, not opened over a layout still in flight"
);
hydrate_with(cx, ws, Adopt::IfEmpty, Vec::new());
assert_eq!(
cx.default_global::<TreeSync>().windows[&ws]
.then_open
.as_ref(),
Some(&path),
parked(cx),
vec![path.clone()],
"a retry must still owe the folder"
);
// With no window to put it in there is nothing to open, and the
// request must not survive to surface in some unrelated window.
// The command for the folder this window was opened for lands in
// that parked tab rather than adding a second one beside it.
assert!(park_command_while_pulling(cx, ws, &path, "./build.sh"));
assert_eq!(parked(cx), vec![path.clone()]);
assert!(matches!(
&cx.default_global::<TreeSync>().windows[&ws].then_open[0],
ParkedOpen::Command { command, .. } if command == "./build.sh"
));
// A LaunchServices open for somewhere else queues behind it rather
// than replacing it.
let script = std::path::PathBuf::from("/tmp/from-finder");
assert!(park_command_while_pulling(cx, ws, &script, "./deploy.sh"));
assert_eq!(parked(cx), vec![path.clone(), script]);
// With no window to put them in there is nothing to open, and the
// requests must not survive to surface in some unrelated window.
open_parked_path(cx, ws);
assert!(
cx.default_global::<TreeSync>().windows[&ws]
.then_open
.is_none()
);
assert!(parked(cx).is_empty());
// A window whose layout has settled is opened into directly.
cx.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap()
.sync = SyncPhase::Primed(WsMirror::default());
assert!(!park_command_while_pulling(cx, ws, &path, "./build.sh"));
});
}
+109
View File
@@ -306,6 +306,115 @@ pub fn open_from_cli(cx: &mut App, path: Option<std::path::PathBuf>) {
});
}
/// Runs a local command in a new tab of the most recently active local
/// workspace, restoring or creating one if every open window is remote or
/// absent. Takes the same route as [`open_from_cli`], for the same reason: a
/// window whose layout is still being pulled has to be opened into by the
/// pull, not underneath it.
pub fn run_local_command(cx: &mut App, cwd: std::path::PathBuf, command: String) {
let Some(workspace) = WindowRegistry::most_recent_local(cx) else {
open_missing_cli_window_with(cx, Some(cwd), |cx, restore, cwd| {
let Some(cwd) = cwd else { return };
open_at(cx, restore, Some(cwd.clone()));
let Some(workspace) = WindowRegistry::most_recent_local(cx) else {
return;
};
// A window that is pulling its layout parked the folder itself, in
// `for_workspace_at`; the command has to travel with it. One that
// is not opened its first terminal in `cwd` already, so the command
// goes straight there rather than into a second tab.
if crate::ui::tree_sync::park_command_while_pulling(cx, workspace, &cwd, &command) {
activate(cx, workspace);
} else {
run_command_in_active_terminal(cx, workspace, command);
}
});
return;
};
if crate::ui::tree_sync::park_command_while_pulling(cx, workspace, &cwd, &command) {
activate(cx, workspace);
return;
}
run_local_command_in(cx, workspace, cwd, command);
}
fn activate(cx: &mut App, workspace: WorkspaceId) {
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
return;
};
cx.activate(true);
let _ = handle.update(cx, |_, window, _| window.activate_window());
}
fn run_command_in_active_terminal(cx: &mut App, workspace: WorkspaceId, command: String) {
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
return;
};
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
return;
};
cx.activate(true);
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| {
app.run_in_active_terminal(&command, window, cx)
});
window.activate_window();
});
}
fn run_local_command_in(
cx: &mut App,
workspace: WorkspaceId,
cwd: std::path::PathBuf,
command: String,
) {
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
return;
};
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
return;
};
cx.activate(true);
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| app.new_tab_running(cwd, command, window, cx));
window.activate_window();
});
}
/// Opens an `ssh://` link in the most recently active window, restoring or
/// creating one if none is up. Goes through the same restore as every other
/// windowless entry point, so a tray-resident tty7 comes back to the workspace
/// it retired with instead of claiming a fresh one.
pub fn quick_connect_from_url(cx: &mut App, ssh: tty7_core::core::ssh_profile::QuickConnect) {
// An SSH link opens a tab, which any window can hold.
let workspace = WindowRegistry::most_recent_local(cx)
.or_else(|| WindowRegistry::most_recent(cx))
.or_else(|| {
open_missing_cli_window_with(cx, None, open_at);
WindowRegistry::most_recent(cx)
});
let Some(workspace) = workspace else {
return;
};
// A window still pulling its layout would take this tab as the whole
// workspace and push it back over what it is about to restore.
if crate::ui::tree_sync::park_ssh_while_pulling(cx, workspace, &ssh) {
activate(cx, workspace);
return;
}
let Some(handle) = WindowRegistry::window_for(cx, workspace) else {
return;
};
let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else {
return;
};
cx.activate(true);
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| app.quick_connect(ssh, window, cx));
window.activate_window();
});
}
/// What a launch reopens: the workspace, and how many other open windows the
/// restore left detached. Their panes are still running — the count exists so
/// the launch can say so instead of letting them be forgotten (#597).