Merge branch 'main' into fix/windows-cmd-interrupt

This commit is contained in:
galact-byte authored and GitHub committed 2026-09-19 19:41:12 +08:00
commit 2cac13b35b
16 files changed
+1454 -197

No files matched your search

+6 -1
View File
@@ -304,7 +304,12 @@
"sent_to": "Sent to {target}",
"yes": "Yes",
"no": "No",
"confirmation_expired": "The confirmation request changed or is no longer active."
"confirmation_expired": "The confirmation request changed or is no longer active.",
"link_missing_cwd": "Cannot open relative path without working directory: {path}",
"link_file_not_found": "File does not exist: {path}",
"link_open_failed": "Failed to open file: {error}",
"link_unrecognized": "Unrecognized link format: {target}",
"link_open_url_failed": "Failed to open link: {error}"
},
"editor": {
"loading": "Loading file…",
+6 -1
View File
@@ -304,7 +304,12 @@
"sent_to": "已发送到 {target}",
"yes": "是",
"no": "否",
"confirmation_expired": "确认请求已经变更或结束。"
"confirmation_expired": "确认请求已经变更或结束。",
"link_missing_cwd": "无法打开相对路径 (缺少工作目录): {path}",
"link_file_not_found": "文件不存在: {path}",
"link_open_failed": "打开文件失败: {error}",
"link_unrecognized": "无法识别的链接格式: {target}",
"link_open_url_failed": "无法打开链接: {error}"
},
"editor": {
"loading": "正在读取文件…",
+111 -16
View File
@@ -34,10 +34,14 @@ use crate::config::selection::Selection;
use crate::config::terminal::Terminal;
use crate::config::window::WindowConfig;
/// Regex used for the default URL hint.
/// Regex used for default terminal hints (Markdown links, URLs, and local paths).
#[rustfmt::skip]
const URL_REGEX: &str = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\
[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+";
pub const DEFAULT_HINT_REGEX: &str = "(?:\
\\[[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n\\]]+\\]\\((?:[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]|\\([^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]*\\))+\\)|\
(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+|\
(?:(?-u:\\b)[a-z]:[/\\\\]|\\\\\\\\)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+|\
~[/\\\\][^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+(?:[/\\\\][^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+)*\
)";
#[derive(ConfigDeserialize, Serialize, Default, Clone, Debug, PartialEq)]
pub struct UiConfig {
@@ -275,20 +279,12 @@ pub struct Hints {
impl Default for Hints {
fn default() -> Self {
// Add URL hint by default when no other hint is present.
let pattern = LazyRegexVariant::Pattern(String::from(URL_REGEX));
// Add default hints (Markdown links, URLs, and local paths) when no other hint is present.
let pattern = LazyRegexVariant::Pattern(String::from(DEFAULT_HINT_REGEX));
let regex = LazyRegex(Arc::new(Mutex::new(pattern)));
let content = HintContent::new(Some(regex), true);
#[cfg(not(any(target_os = "macos", windows)))]
let action = HintAction::Command(Program::Just(String::from("xdg-open")));
#[cfg(target_os = "macos")]
let action = HintAction::Command(Program::Just(String::from("open")));
#[cfg(windows)]
let action = HintAction::Command(Program::WithArgs {
program: String::from("cmd"),
args: vec!["/c".to_string(), "start".to_string(), "".to_string()],
});
let action = HintAction::Command(default_hint_command());
Self {
enabled: vec![Arc::new(Hint {
@@ -319,6 +315,20 @@ impl Hints {
}
}
/// The system opener used by the default link hint. Custom commands keep their
/// original matched argument and must not be redirected to the system opener.
pub fn default_hint_command() -> Program {
#[cfg(not(any(target_os = "macos", windows)))]
return Program::Just(String::from("xdg-open"));
#[cfg(target_os = "macos")]
return Program::Just(String::from("open"));
#[cfg(windows)]
return Program::WithArgs {
program: String::from("cmd"),
args: vec!["/c".to_string(), "start".to_string(), "".to_string()],
};
}
#[derive(SerdeReplace, Serialize, Clone, Debug, PartialEq, Eq)]
struct HintsAlphabet(String);
@@ -804,6 +814,7 @@ mod tests {
"gopher://gopher.example.org",
"https://www.example.org",
"http://example.org",
"HTTPS://EXAMPLE.COM/A",
"news:some.news.portal",
"file:///C:/Windows/",
"file:/home/user/whatever",
@@ -812,7 +823,7 @@ mod tests {
"ftp://ftp.example.org",
] {
let term = mock_term(regular_url);
let mut regex = RegexSearch::new(URL_REGEX).unwrap();
let mut regex = RegexSearch::new(DEFAULT_HINT_REGEX).unwrap();
let matches = visible_regex_match_iter(&term, &mut regex).collect::<Vec<_>>();
assert_eq!(
matches.len(),
@@ -833,7 +844,7 @@ mod tests {
"mailto:",
] {
let term = mock_term(url_like);
let mut regex = RegexSearch::new(URL_REGEX).unwrap();
let mut regex = RegexSearch::new(DEFAULT_HINT_REGEX).unwrap();
let matches = visible_regex_match_iter(&term, &mut regex).collect::<Vec<_>>();
assert!(
matches.is_empty(),
@@ -841,4 +852,88 @@ mod tests {
)
}
}
#[test]
fn default_hint_parsing_regex_test() {
for hint_text in [
"[全局新手引导框架需求规格](D:/work/git/gt_project_git_extra/docs/brainstorms/2026-09-18-global-tutorial-framework-requirements.md)",
"[Markdown Link](https://example.com/docs)",
"[Markdown With Space](<D:/My Documents/project spec.md>)",
"[app](C:/Program Files (x86)/foo.exe)",
"[Rust](https://en.wikipedia.org/wiki/Rust_(programming_language))",
"[Local File](./README.md)",
"https://www.example.org",
"file:///C:/Windows/",
"D:/work/git/project/file.md",
r"C:\Users\admin\Desktop\notes.txt",
r"[Windows Path With Space](C:\Users\admin\My Documents\notes.txt)",
r"\\server\share\file.txt",
"~/docs/spec.md",
"~/工作文档/新手引导.md",
] {
let term = mock_term(hint_text);
let mut regex = RegexSearch::new(DEFAULT_HINT_REGEX).unwrap();
let matches = visible_regex_match_iter(&term, &mut regex).collect::<Vec<_>>();
assert_eq!(
matches.len(),
1,
"Should have exactly one hint match for {hint_text}, but instead got: {matches:?}"
);
let matched_text = term.bounds_to_string(*matches[0].start(), *matches[0].end());
assert_eq!(
matched_text, hint_text,
"Matched text must match expected hint string for {hint_text}"
);
}
}
#[test]
fn negative_hint_regex_test() {
// Bare posix, bare relative paths, and non-path words with colons should not be treated as hint links.
for text in [
"docker run -v /host:/container img",
"scp file user@host:/tmp/",
"./README.md",
"../Cargo.toml",
r".\README.md",
r"..\Cargo.toml",
"/etc/passwd",
"/usr/bin/gcc",
] {
let term = mock_term(text);
let mut regex = RegexSearch::new(DEFAULT_HINT_REGEX).unwrap();
let matches = visible_regex_match_iter(&term, &mut regex).collect::<Vec<_>>();
assert!(
matches.is_empty(),
"Should NOT match bare path as hint for {text}, but got: {matches:?}"
);
}
}
#[test]
fn cjk_punctuation_separation_test() {
let text1 = "D:/work/spec.md,然后继续";
let term1 = mock_term(text1);
let mut regex = RegexSearch::new(DEFAULT_HINT_REGEX).unwrap();
let matches1 = visible_regex_match_iter(&term1, &mut regex).collect::<Vec<_>>();
assert_eq!(matches1.len(), 1);
assert_eq!(
term1.bounds_to_string(*matches1[0].start(), *matches1[0].end()),
"D:/work/spec.md"
);
let text2 = "见 D:/docs/spec.md、D:/docs/plan.md。";
let term2 = mock_term(text2);
let mut regex = RegexSearch::new(DEFAULT_HINT_REGEX).unwrap();
let matches2 = visible_regex_match_iter(&term2, &mut regex).collect::<Vec<_>>();
assert_eq!(matches2.len(), 2);
assert_eq!(
term2.bounds_to_string(*matches2[0].start(), *matches2[0].end()),
"D:/docs/spec.md"
);
assert_eq!(
term2.bounds_to_string(*matches2[1].start(), *matches2[1].end()),
"D:/docs/plan.md"
);
}
}
+5 -1
View File
@@ -626,7 +626,11 @@ impl<'a, T> HintPostProcessor<'a, T> {
// Truncate trailing characters which are likely to be delimiters.
let start = *regex_match.start();
while iter.point() != start {
if !matches!(c, '.' | ',' | ':' | ';' | '?' | '!' | '(' | '[' | '\'') {
if !matches!(
c,
'.' | ',' | ':' | ';' | '?' | '!' | '(' | '[' | '\''
| '。' | ',' | '、' | ';' | '!' | '?' | ')' | '】' | '》' | '”' | '’'
) {
break;
}
+17 -19
View File
@@ -67,6 +67,7 @@ use crate::window_transition::{NativeWindowStage, NativeWindowStageTracker};
mod agent_runtime;
mod input_state;
mod link_open;
mod proxy;
mod quick_hotkey;
mod runtime_control;
@@ -2123,26 +2124,19 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
match &hint.action() {
// Launch an external program.
HintAction::Command(command) => {
// On Windows, a `file://` OSC 8 link (our clickable `ls`) is
// opened via `explorer.exe` with a translated native path. This
// sidesteps `cmd /c start` mangling spaces/unicode and lets
// WSL/MSYS posix paths (`/mnt/c/…`, `/d/…`) actually resolve.
#[cfg(windows)]
if let Some(path) = crate::file_uri::file_uri_to_local_path(&text) {
crate::display::nebula_link_log(format!(
"trigger_hint file-uri explorer path={path:?} (from {text:?})"
));
self.spawn_daemon("explorer.exe", &[path.as_os_str()]);
return;
if command == &crate::config::ui_config::default_hint_command() {
link_open::open(
command.clone(),
text.into_owned(),
self.display.ui_language(),
self.event_proxy.clone(),
self.display.window.id(),
);
} else {
let mut args = command.args().to_vec();
args.push(text.into_owned());
self.spawn_daemon(command.program(), &args);
}
let mut args = command.args().to_vec();
args.push(text.into());
crate::display::nebula_link_log(format!(
"trigger_hint spawn program={:?} args={args:?}",
command.program()
));
self.spawn_daemon(command.program(), &args);
},
// Copy the text to the clipboard.
HintAction::Action(HintInternalAction::Copy) => {
@@ -2815,6 +2809,10 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
self.ctx.message_buffer.push(message);
self.ctx.display.pending_update.dirty = true;
},
EventType::LinkOpenFailed(message) => {
self.ctx.display.push_toast(message, ToastKind::Warning);
*self.ctx.dirty = true;
},
EventType::Terminal(event) => match event {
// OSC 9;4:程序自报任务进度。旧壳一个窗口只投一次,不像
// GPUI 壳那样先判「这个 pane 是不是正被看着」——旧壳的多
+42
View File
@@ -0,0 +1,42 @@
//! The legacy event loop delegates potentially blocking path metadata/openers.
//! Completion is scoped to the requesting window; a closed window drops it.
use winit::{event_loop::EventLoopProxy, window::WindowId};
use super::{Event, EventType};
use crate::config::ui_config::Program;
use crate::file_uri::LegacyHintOutcome;
use crate::i18n::{Message, UiLanguage};
pub(super) fn open(
command: Program,
text: String,
language: UiLanguage,
proxy: EventLoopProxy<Event>,
window: WindowId,
) {
let errors = proxy.clone();
let worker = std::thread::Builder::new().name("link-opener".into()).spawn(move || {
let result =
match crate::file_uri::handle_legacy_hint_command(&text, command.args(), language) {
LegacyHintOutcome::Handled => Ok(()),
LegacyHintOutcome::Failed(message) => Err(message),
LegacyHintOutcome::SpawnCommand(args) => {
crate::daemon::spawn_detached(command.program(), &args).map_err(|error| {
language.format(
Message::CommonLinkOpenUrlFailed,
&[("error", &error.to_string())],
)
})
},
};
if let Err(message) = result {
let _ = proxy.send_event(Event::new(EventType::LinkOpenFailed(message), window));
}
});
if let Err(error) = worker {
let message =
language.format(Message::CommonLinkOpenUrlFailed, &[("error", &error.to_string())]);
let _ = errors.send_event(Event::new(EventType::LinkOpenFailed(message), window));
}
}
+2
View File
@@ -54,6 +54,8 @@ pub enum EventType {
/// window configs without waiting for a process restart.
TerminalProfilesChanged,
Message(Message),
/// A user-requested local link finished with an error in the opener worker.
LinkOpenFailed(String),
Scroll(Scroll),
CreateWindow(WindowOptions),
#[cfg(unix)]
+1022 -109
View File
File diff suppressed because it is too large. Load diff
@@ -99,3 +99,36 @@ fn details_panel_drag_updates_shared_width_and_releases_on_mouse_up(cx: &mut Tes
assert!(window.selected_text(cx).is_empty(), "resizing must not select reader text");
});
}
/// 目录行高只由「一行」决定,与标题长度无关。
///
/// 目录是导航列,行高必须统一:长标题只截断,不折行。折行之所以是缺陷,是因为
/// 行高在布局测量阶段按单行定型,文字却在最终宽度下折成两行画到行框之外,压住
/// 下一行的标题(用户 09-17 在数学笔记目录上报告的窄面板重叠)。这种「画到框
/// 外」本身在布局 bounds 里看不见,所以这里钉住的是让它不可能发生的前提:面板
/// 压到最小宽度时,最长标题行仍与最短标题行同高。
#[gpui::test]
fn long_outline_headings_stay_one_line_in_a_narrow_details_panel(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("outline.md");
let long = "## 这是一个在窄目录面板里一定会超出可用宽度的很长很长的标题";
std::fs::write(&path, format!("# 短标题\n\n{long}\n")).unwrap();
let (file, mut cx) = open(path, cx);
cx.update(|window, cx| {
file.update(cx, |view, cx| {
view.show_details = true;
view.info = false;
view.details_width = reader_presentation::DETAILS_MIN_WIDTH;
cx.notify();
});
let _ = window.draw(cx);
});
let short_row = cx.debug_bounds("outline-row-0").expect("短标题的目录行");
let long_row = cx.debug_bounds("outline-row-1").expect("长标题的目录行");
assert_eq!(
long_row.size.height, short_row.size.height,
"长标题必须在一行内截断:折行会画到行框之外压住下一行"
);
}
@@ -92,9 +92,28 @@ impl TextFileView {
.flex_1()
.min_w_0()
.py_1()
.whitespace_normal()
// 目录行只在**一行**内截断,绝不折行。行高由父级
// 的布局测量定型,而这一层的横向可用宽度在测量阶段
// 并不可靠(`overflow_y_scroll` 内容层不给百分比宽度
// 兜底)。一旦允许折行,文字会按最终宽度折成两行,却
// 仍被画在按单行算出的行框里——第二行落到行框之外,
// 压在下一行标题上(用户 09-17 报的重叠,窄面板 + 长
// 标题才出现)。改成一个目录行固定一行高之后,行高与
// 标题长度无关,重叠在结构上不可能发生;完整标题走
// 悬停提示与右键“复制标题”。
.truncate()
.child(heading.display_label()),
)
.tooltip({
let label = heading.display_label();
move |window, cx| {
let label = label.clone();
Tooltip::element(move |_, _| {
div().max_w(px(560.0)).whitespace_normal().child(label.clone())
})
.build(window, cx)
}
})
.on_click(cx.listener(move |view, _, window, cx| {
view.jump_to_heading(index, window, cx)
}))
+1
View File
@@ -29,6 +29,7 @@ pub use gpui_component::{
spinner::Spinner,
switch::Switch,
tab::{Tab, TabBar},
tooltip::Tooltip,
v_flex,
};
@@ -441,25 +441,60 @@ impl Render for AnswerReader {
})
.when(self.preparing, |root| {
root.child(div().p_3().text_sm().text_color(muted).child("正在整理完整回答…"))
})
.child(
div().flex_1().min_h_0().min_w_0().px_3().py_2().child(
TextView::new(text)
.selectable(true)
.scrollable(true)
.markdown_extensions(extensions)
.on_link_click(|url, _, _, cx| {
if url.starts_with("https://") || url.starts_with("http://") {
cx.open_url(url);
}
});
let cwd = self.snapshot.cwd.clone();
let markdown = div().flex_1().min_h_0().min_w_0().px_3().py_2().child(
TextView::new(text)
.selectable(true)
.scrollable(true)
.markdown_extensions(extensions)
.on_link_click(move |url, _, window, cx| {
if url.starts_with('#') {
return;
}
let language = super::super::config::ui_language(cx);
let url = url.to_owned();
let cwd = cwd.clone();
// Path metadata and system openers can block on mounted/network
// filesystems. Keep both outside the window's render thread.
let task = cx.background_executor().spawn(async move {
let result =
crate::file_uri::try_open_local_link_with_cwd(&url, cwd.as_deref());
(url, result)
});
window
.spawn(cx, async move |cx| {
let (url, result) = task.await;
let _ = cx.update(|window, cx| {
let message = match result {
Some(Ok(())) => return,
Some(Err(error)) => error.localized_message(language),
None if crate::file_uri::is_web_or_protocol_uri(&url) => {
cx.open_url(&url);
return;
},
None => language.format(
crate::i18n::Message::CommonLinkUnrecognized,
&[("target", &url)],
),
};
crate::gpui_shell::toast::toast(
window,
cx,
crate::display::ToastKind::Warning,
message,
);
});
})
.style(TextViewStyle {
highlight_theme: cx.theme().highlight_theme.clone(),
is_dark: cx.theme().is_dark(),
..TextViewStyle::default()
}),
),
);
.detach();
})
.style(TextViewStyle {
highlight_theme: cx.theme().highlight_theme.clone(),
is_dark: cx.theme().is_dark(),
..TextViewStyle::default()
}),
);
root = root.child(markdown);
if let Some(image) = self.preview.clone() {
root = root.child(
v_flex()
+113 -25
View File
@@ -6,7 +6,7 @@
use std::collections::HashSet;
use std::sync::Arc;
use gpui::{App, ClipboardItem};
use gpui::{App, ClipboardItem, Window};
use nebula_terminal::event::EventListener;
use nebula_terminal::index::Point;
use nebula_terminal::term::{Term, point_to_viewport_from};
@@ -14,7 +14,7 @@ use unicode_width::UnicodeWidthChar;
use winit::keyboard::ModifiersState;
use crate::config::UiConfig;
use crate::config::ui_config::{HintAction, HintInternalAction};
use crate::config::ui_config::{HintAction, HintInternalAction, default_hint_command};
use crate::display::hint::{self, HintMatch};
/// 悬停目标:旧壳 `highlighted_hint` + 已经解码好的预览文案。
@@ -87,10 +87,11 @@ pub(super) fn hover_from_hint<T: EventListener>(
rows: usize,
cols: usize,
) -> Option<LinkHover> {
let uri = hint
let raw = hint
.hyperlink()
.map(|link| link.uri().to_owned())
.or_else(|| hint.text(term).map(|text| text.into_owned()))?;
let uri = crate::file_uri::extract_link_target(&raw);
let origin = term.viewport_origin_for(rows);
let start = *hint.bounds().start();
let vp =
@@ -99,34 +100,121 @@ pub(super) fn hover_from_hint<T: EventListener>(
vp.map(|vp| (vp.line as u16, vp.column.0 as u16)).unwrap_or((0, 0));
const HINT: &str = " · Ctrl+点击";
let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() };
let target = crate::display::strip_file_scheme(&uri);
let target = crate::display::strip_file_scheme(uri);
let budget = cols.saturating_sub(width(HINT) + 1);
let target = crate::display::fit_tail(&target, budget);
Some(LinkHover { hint, preview: format!("{target}{HINT}"), anchor_row, anchor_col })
}
pub(super) fn open_hint<T: EventListener>(hint: &HintMatch, term: &Term<T>, cx: &App) {
let Some(text) = hint.text(term) else { return };
#[cfg(windows)]
if let Some(path) = crate::file_uri::file_uri_to_local_path(&text) {
let _ = crate::daemon::spawn_detached("explorer.exe", &[path.as_os_str()]);
return;
}
match hint.action() {
HintAction::Command(command) => {
let mut args = command.args().to_vec();
args.push(text.into_owned());
let _ = crate::daemon::spawn_detached(command.program(), &args);
},
pub(super) fn open_hint_match(
hint: &HintMatch,
text: &str,
cwd: Option<&std::path::Path>,
window: &mut Window,
cx: &mut App,
) {
dispatch_hint_action(hint.action(), hint.hyperlink().is_some(), text, cwd, window, cx);
}
fn dispatch_hint_action(
action: &HintAction,
hyperlink: bool,
text: &str,
cwd: Option<&std::path::Path>,
window: &mut Window,
cx: &mut App,
) {
// Internal actions operate on the original match, without filesystem work.
let command = match action {
HintAction::Action(HintInternalAction::Copy) => {
cx.write_to_clipboard(ClipboardItem::new_string(text.into_owned()));
},
HintAction::Action(
HintInternalAction::Paste
| HintInternalAction::Select
| HintInternalAction::MoveViModeCursor,
) => {
// 默认 hint 走 Command(打开 URI)。这三项仍由旧壳键盘 hint 使用。
cx.write_to_clipboard(ClipboardItem::new_string(text.to_owned()));
return;
},
HintAction::Action(_) => return,
HintAction::Command(command) => command.clone(),
};
let text = text.to_owned();
let cwd = cwd.map(std::path::Path::to_path_buf);
let language = crate::gpui_shell::config::ui_language(cx);
let task = cx.background_executor().spawn(async move {
let target = if command == default_hint_command() {
if let Some(result) =
crate::file_uri::try_open_local_link_with_cwd(&text, cwd.as_deref())
{
return result.map_err(|error| error.localized_message(language));
}
let target = crate::file_uri::extract_link_target(&text);
if !hyperlink && !crate::file_uri::is_web_or_protocol_uri(target) {
return Err(language
.format(crate::i18n::Message::CommonLinkUnrecognized, &[("target", target)]));
}
target
} else {
&text
};
let mut args = command.args().to_vec();
args.push(target.to_owned());
crate::daemon::spawn_detached(command.program(), &args).map_err(|error| {
language.format(
crate::i18n::Message::CommonLinkOpenUrlFailed,
&[("error", &error.to_string())],
)
})
});
window
.spawn(cx, async move |cx| {
if let Err(message) = task.await {
let _ = cx.update(|window, cx| {
crate::gpui_shell::toast::toast(
window,
cx,
crate::display::ToastKind::Warning,
message,
);
});
}
})
.detach();
}
#[cfg(all(test, feature = "gpui-test-support"))]
mod tests {
use super::*;
use gpui::{Context, IntoElement, Render, TestAppContext};
struct Surface;
impl Render for Surface {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
gpui::div()
}
}
#[gpui::test]
fn copying_local_markdown_and_custom_matches_never_opens_them(cx: &mut TestAppContext) {
let (_, window) = cx.add_window_view(|_, _| Surface);
for text in [
"[notes](./missing notes.md)",
"file:///C:/missing-copy-test.md",
r"\\server\share\copy-only.md",
"custom match without a URL",
"[site](https://example.com)",
] {
window.update(|window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string("before".into()));
dispatch_hint_action(
&HintAction::Action(HintInternalAction::Copy),
false,
text,
None,
window,
cx,
);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()).as_deref(),
Some(text)
);
});
}
cx.run_until_parked();
}
}
@@ -169,11 +169,22 @@ impl TerminalView {
}
}
pub(super) fn try_open_hovered_link(&self, cx: &Context<Self>) {
pub(super) fn try_open_hovered_link(&self, window: &mut Window, cx: &mut Context<Self>) {
let Some(hover) = self.link_hover.as_ref() else { return };
let Some(session) = self.session.as_ref() else { return };
let term = session.term.lock();
super::super::osc_links::open_hint(&hover.hint, &term, cx);
let text = {
let term = session.term.lock();
hover.hint.text(&*term).map(|t| t.into_owned())
};
let Some(text) = text else { return };
let cwd = self.local_cwd();
super::super::osc_links::open_hint_match(
&hover.hint,
&text,
cwd.as_deref(),
window,
cx,
);
}
/// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是
@@ -583,7 +594,7 @@ impl TerminalView {
&& self.selection_is_empty()
&& self.link_hover.is_some();
if open_link {
self.try_open_hovered_link(cx);
self.try_open_hovered_link(window, cx);
} else if self.copy_on_select {
self.copy_selection(false, window, cx);
}
-1
View File
@@ -64,7 +64,6 @@ mod document_io;
mod encrypted_backup;
#[cfg(feature = "legacy-shell")]
mod event;
#[cfg(windows)]
mod file_uri;
mod font_install;
mod git_worktree;
+8 -1
View File
@@ -4,7 +4,14 @@ use std::process::{Command, Stdio};
use super::Platform;
pub fn open(path: &Path) -> std::io::Result<()> {
open_command(Platform::current(), path).spawn().map(|_| ())
let res = open_command(Platform::current(), path).spawn().map(|_| ());
#[cfg(target_os = "linux")]
if res.is_err() {
if command("gio").arg("open").arg(path).spawn().is_ok() {
return Ok(());
}
}
res
}
pub fn reveal(path: &Path) -> std::io::Result<()> {