From c3f79d6a0e3d4dbcd0e29fa66766e91f01469d7d Mon Sep 17 00:00:00 2001 From: DreamEnd Date: Sat, 19 Sep 2026 18:54:24 +0800 Subject: [PATCH 1/2] fix(gpui): keep outline rows single-line so long headings cannot overlap (#191) A heading label in the reader's outline wrapped once it exceeded the panel width. The row's height is fixed during layout measurement, and the horizontal available width of that scroll layer is not reliable there, so the second line was painted outside its row box and landed on the next heading's title. It only appeared with a narrow details panel and long numbered headings. Labels now use `truncate()`, so an outline row is always exactly one line tall and its height no longer depends on the heading text. The full label stays reachable through the row tooltip and the existing "copy heading" context menu. `Tooltip` now comes from the GPUI shell prelude, keeping third-party entry points in the controlled list instead of importing `gpui_component` at the call site. `focus_tests` covers the contract: with the details panel at its minimum width, the longest heading's row must be as tall as the shortest one's. Co-authored-by: tataraDM <2201325624@qq.com> Co-authored-by: Kuddev --- .../src/gpui_shell/file_editor/focus_tests.rs | 33 +++++++++++++++++++ .../gpui_shell/file_editor/outline_view.rs | 21 +++++++++++- nebula_app/src/gpui_shell/prelude.rs | 1 + 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/nebula_app/src/gpui_shell/file_editor/focus_tests.rs b/nebula_app/src/gpui_shell/file_editor/focus_tests.rs index 8425b44..0f92c2c 100644 --- a/nebula_app/src/gpui_shell/file_editor/focus_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/focus_tests.rs @@ -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, + "长标题必须在一行内截断:折行会画到行框之外压住下一行" + ); +} diff --git a/nebula_app/src/gpui_shell/file_editor/outline_view.rs b/nebula_app/src/gpui_shell/file_editor/outline_view.rs index f7bd6e8..95247bd 100644 --- a/nebula_app/src/gpui_shell/file_editor/outline_view.rs +++ b/nebula_app/src/gpui_shell/file_editor/outline_view.rs @@ -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) })) diff --git a/nebula_app/src/gpui_shell/prelude.rs b/nebula_app/src/gpui_shell/prelude.rs index 518c5fe..ab50c27 100644 --- a/nebula_app/src/gpui_shell/prelude.rs +++ b/nebula_app/src/gpui_shell/prelude.rs @@ -29,6 +29,7 @@ pub use gpui_component::{ spinner::Spinner, switch::Switch, tab::{Tab, TabBar}, + tooltip::Tooltip, v_flex, }; From 5d473572f907b8d5d7e2daf155a4e485860e5584 Mon Sep 17 00:00:00 2001 From: unifan <821869798@qq.com> Date: Sat, 19 Sep 2026 19:32:13 +0800 Subject: [PATCH 2/2] feat(terminal): support markdown links and cross-platform local file opening (#204) * feat(terminal): support markdown links and cross-platform local file opening Support terminal Ctrl+Click navigation for Markdown links and local paths across Windows, macOS, and Linux. Addresses https://github.com/Kuddev/pebrel/issues/203. - Improve DEFAULT_HINT_REGEX: use [a-z] for smart-case insensitivity, ASCII word boundaries for drive letters, balanced nested parentheses in Markdown links, and exclusion of CJK punctuation. - Release terminal lock before filesystem/network I/O when opening hints to prevent UI freezing on slow/remote drives. - Pass pane and AnswerReader cwd to resolve relative paths, with localized warning toast when cwd is absent. - Support generic protocol URIs and explicit OSC 8 hyperlinks without rigid whitelist blocking. - Support Unix hostname resolution (gethostname, HOST, HOSTNAME) for file:// URIs without corrupting percent characters in raw disk paths. - Integrate with platform::file_manager as the single authoritative file opener, with fallback to file reveal and failure visibility in both GPUI toasts and legacy shell toasts. * fix(links): preserve hint actions and move file opening off UI threads --------- Co-authored-by: kuddev --- nebula_app/i18n/en-US.json | 7 +- nebula_app/i18n/zh-CN.json | 7 +- nebula_app/src/config/ui_config.rs | 127 +- nebula_app/src/display/hint.rs | 6 +- nebula_app/src/event.rs | 36 +- nebula_app/src/event/link_open.rs | 42 + nebula_app/src/event/types.rs | 2 + nebula_app/src/file_uri.rs | 1131 +++++++++++++++-- .../src/gpui_shell/terminal/answer_reader.rs | 71 +- .../src/gpui_shell/terminal/osc_links.rs | 138 +- .../src/gpui_shell/terminal/view/pointer.rs | 19 +- nebula_app/src/main.rs | 1 - nebula_app/src/platform/file_manager.rs | 9 +- 13 files changed, 1400 insertions(+), 196 deletions(-) create mode 100644 nebula_app/src/event/link_open.rs diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index cea9a51..8c8d688 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -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…", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index b2927dd..55a44fd 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -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": "正在读取文件…", diff --git a/nebula_app/src/config/ui_config.rs b/nebula_app/src/config/ui_config.rs index 93edc90..c2c3578 100644 --- a/nebula_app/src/config/ui_config.rs +++ b/nebula_app/src/config/ui_config.rs @@ -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::>(); 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::>(); 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]()", + "[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::>(); + 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::>(); + 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::>(); + 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::>(); + 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" + ); + } } diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index f8b1b21..c192754 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -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; } diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 9acefc4..c79d119 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -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 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> { 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 是不是正被看着」——旧壳的多 diff --git a/nebula_app/src/event/link_open.rs b/nebula_app/src/event/link_open.rs new file mode 100644 index 0000000..027a02c --- /dev/null +++ b/nebula_app/src/event/link_open.rs @@ -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, + 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)); + } +} diff --git a/nebula_app/src/event/types.rs b/nebula_app/src/event/types.rs index 394b0ef..7242b83 100644 --- a/nebula_app/src/event/types.rs +++ b/nebula_app/src/event/types.rs @@ -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)] diff --git a/nebula_app/src/file_uri.rs b/nebula_app/src/file_uri.rs index 9828068..a0a2b90 100644 --- a/nebula_app/src/file_uri.rs +++ b/nebula_app/src/file_uri.rs @@ -1,66 +1,507 @@ -//! Translate `file://` URIs (from OSC 8 hyperlinks) into native Windows paths. +//! Translate `file:` URIs and filesystem links into native paths across platforms. //! -//! The shells Nebula integrates with all emit clickable `ls` entries as OSC 8 -//! `file://` links, but they disagree on the path dialect inside the URI: +//! The shells Nebula integrates with emit clickable entries as OSC 8 hyperlinks or +//! terminal output paths: //! -//! - **PowerShell** (`Nebula-List`): `file:///C:/Users/me/a%20b.txt` — empty -//! host, real Windows drive path, percent-encoded. -//! - **Git-bash / MSYS** (`ls --hyperlink`): `file://HOST/d/temp/x` — MSYS -//! drive form (`/d/…`), so the leading segment is the drive letter. -//! - **WSL** (`ls --hyperlink`): `file://HOST/mnt/c/x` — the WSL mount form -//! (`/mnt//…`), or `/cygdrive//…` under some coreutils builds. +//! - **Windows**: +//! - PowerShell (`Nebula-List`): `file:///C:/Users/me/a%20b.txt` — empty host, +//! real Windows drive path, percent-encoded. +//! - Raw paths: `D:/work/project/spec.md`, `D:\work\...`, `/D:/...` +//! - Quoted paths: `"C:\Program Files\App\app.exe"`, `'D:\My Documents\notes.txt'` +//! - Git-bash / MSYS (`ls --hyperlink`): `file://HOST/d/temp/x` — MSYS drive form. +//! - WSL (`ls --hyperlink`): `file://HOST/mnt/c/x` or `/cygdrive/c/x`. +//! - UNC shares: `\\server\share\file.txt` or `file://fileserver/share/...`. +//! - **macOS / Linux**: +//! - Standard file URIs: `file:///Users/me/a%20b.txt`, `file:///home/me/...` +//! - Home-relative paths: `~/docs/spec.md`, `~` +//! - **Security & Boundaries**: +//! - Naked POSIX paths without `file:` or `~` (e.g. `/etc/passwd`, `/usr/bin/gcc`) +//! are strictly NOT opened as local files to prevent accidental system access. +//! - Relative paths (`./README.md`) are rejected with user-visible guidance when +//! pane working directory context is unavailable. +//! - **Sentence punctuation**: links emitted at sentence ends often capture +//! trailing punctuation (e.g. `[link](path)。` or `[link](path))`). //! -//! Feeding those posix-looking paths to `cmd /c start ""` fails: `explorer` -//! can't resolve `/d/temp` and `cmd`'s own tokenizer mangles URIs with spaces -//! or non-ASCII bytes. Normalizing to a real Windows path up front lets us hand -//! `explorer.exe` a path it can always open, and keeps unicode filenames intact -//! (percent-decoding is UTF-8 aware). -//! -//! Only compiled on Windows — every other platform keeps the default -//! `xdg-open`/`open` hint action untouched. -#![cfg(windows)] +//! When launching: +//! - Dispatches via [`crate::platform::file_manager::open`]. +//! - On Windows: launches default associated application or Explorer. +//! - On macOS: `open `. +//! - On Linux: `xdg-open `. +//! - Falls back to revealing in the system file manager ([`crate::platform::file_manager::reveal`]) on open failure. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -/// Decode a `file://` URI into a native Windows path. +/// Decode a file URI or local path into a native filesystem PathBuf. /// -/// Returns `None` when the input is not a `file:` URI (so the caller can fall -/// back to the default hint action), or when it names a remote host we won't -/// touch. `drive_exists` gates the ambiguous MSYS `/d/…` form: a single-letter -/// leading segment is only treated as a drive when that drive actually exists, -/// otherwise it's a real directory named `d`. +/// Returns `None` when the input is not a local file path or recognized `file:` +/// URI (allowing the caller to fall back to the default web browser handler). pub fn file_uri_to_local_path(uri: &str) -> Option { - file_uri_to_local_path_with(uri, |drive| drive_exists(drive)) + file_uri_to_local_path_with(uri, |drive| drive_exists(drive), home::home_dir, cfg!(windows)) } -/// Test seam for [`file_uri_to_local_path`]; `drive_exists` is injected so unit -/// tests don't depend on which drive letters the host machine happens to have. -fn file_uri_to_local_path_with(uri: &str, drive_exists: impl Fn(char) -> bool) -> Option { - // Case-insensitive `file:` scheme check without allocating. - let rest = strip_scheme(uri)?; - - // After `file:` the authority is introduced by `//`. Anything else (e.g. - // `file:relative`) is non-standard; bail to the fallback path. - let rest = rest.strip_prefix("//")?; - - // Split `HOST/PATH`. The path always begins at the first '/', and an empty - // host (`file:///C:/…`) leaves that slash at index 0. - let slash = rest.find('/')?; - let host = &rest[..slash]; - let path = percent_decode_utf8(&rest[slash..]); - - if host_is_local(host) { - translate_local_path(&path, &drive_exists) - } else { - // Remote host → UNC share (`\\HOST\path`). The plain `cmd start` link - // action already accepted these; we just build a path `explorer` - // understands instead of a URI. - let host = percent_decode_utf8(host); - let tail = path.trim_start_matches('/').replace('/', "\\"); - (!host.is_empty()).then(|| PathBuf::from(format!("\\\\{host}\\{tail}"))) +/// Open a local path (file or directory) using the platform's default handler. +/// +/// Dispatches via [`crate::platform::file_manager::open`]. If opening directly +/// fails to launch its platform helper, falls back to revealing +/// the item in the system file manager ([`crate::platform::file_manager::reveal`]). +/// Success means the helper process was launched, not that an associated app +/// finished opening the file. Later helper failures are not reported by this API. +pub fn open_local_path(path: &Path) -> std::io::Result<()> { + match crate::platform::file_manager::open(path) { + Ok(()) => { + log::debug!("open_local_path helper launched for {}", path.display()); + Ok(()) + } + Err(open_err) => { + log::debug!( + "open_local_path open failed for {}: {open_err}; falling back to reveal", + path.display() + ); + match crate::platform::file_manager::reveal(path) { + Ok(()) => { + log::debug!( + "open_local_path fallback reveal succeeded for {}", + path.display() + ); + Ok(()) + } + Err(reveal_err) => { + log::debug!( + "open_local_path fallback reveal also failed for {}: {reveal_err}", + path.display() + ); + Err(open_err) + } + } + } } } +/// Detailed error outcomes for local link opening attempts. +#[derive(Debug, PartialEq, Eq)] +pub enum LocalLinkError { + MissingCwd(String), + FileNotFound(PathBuf), + OpenFailed(String), +} + +impl LocalLinkError { + pub fn localized_message(&self, language: crate::i18n::UiLanguage) -> String { + match self { + Self::MissingCwd(rel) => { + language.format(crate::i18n::Message::CommonLinkMissingCwd, &[("path", rel)]) + } + Self::FileNotFound(path) => language.format( + crate::i18n::Message::CommonLinkFileNotFound, + &[("path", &path.display().to_string())], + ), + Self::OpenFailed(err) => { + language.format(crate::i18n::Message::CommonLinkOpenFailed, &[("error", err)]) + } + } + } +} + +/// Classification of a potential link target extracted from the terminal or Markdown. +#[derive(Debug, PartialEq, Eq)] +pub enum LinkTargetKind { + /// A local file/directory path that actually exists on the filesystem. + LocalExisting(PathBuf), + /// A local file/directory path that does NOT exist on the filesystem. + LocalMissing(PathBuf), + /// A relative file path (e.g. `./README.md` or `../docs/spec.md`), which cannot be safely resolved without pane cwd. + RelativePath(String), + /// A recognized web or protocol URI (e.g. `https://...`, `mailto:...`). + ProtocolUri(String), + /// Unrecognized format. + Unrecognized(String), +} + +/// Classify a link target to enforce strict gatekeeping before opening. +#[allow(dead_code)] +pub fn classify_link_target(raw: &str) -> LinkTargetKind { + classify_link_target_with_cwd(raw, None) +} + +/// Classify a link target with an optional working directory for relative path resolution. +pub fn classify_link_target_with_cwd(raw: &str, cwd: Option<&Path>) -> LinkTargetKind { + let target = extract_link_target(raw); + if target.is_empty() || target.starts_with('#') { + return LinkTargetKind::Unrecognized(raw.to_string()); + } + + // 1. Try to resolve as a local absolute, UNC, home-relative path, or explicit file: URI + if let Some(path) = file_uri_to_local_path(target) { + // UNC paths (\\server\share\...): skip synchronous exists() check to prevent blocking UI thread. + let is_unc = path.to_str().map_or(false, |s| s.starts_with(r"\\")); + if is_unc || path.exists() { + return LinkTargetKind::LocalExisting(path); + } else { + return LinkTargetKind::LocalMissing(path); + } + } + + // 2. Check if it's an explicit relative path (e.g. ./x, ../x) + if is_explicit_relative_path(target) { + if let Some(base) = cwd { + let clean = clean_relative_prefix(target); + let resolved = base.join(clean); + if resolved.exists() { + return LinkTargetKind::LocalExisting(resolved); + } else { + return LinkTargetKind::LocalMissing(resolved); + } + } + return LinkTargetKind::RelativePath(target.to_string()); + } + + // 3. Check if it's a recognized web/network protocol URI + if is_web_or_protocol_uri(target) { + return LinkTargetKind::ProtocolUri(target.to_string()); + } + + // 4. Relative paths without explicit ./ prefix (e.g. docs/spec.md, README.md) + if !target.starts_with('/') + && !target.starts_with('\\') + && !is_drive_prefixed(target) + && !has_scheme_prefix(target) + { + if let Some(base) = cwd { + let resolved = base.join(target); + if resolved.exists() { + return LinkTargetKind::LocalExisting(resolved); + } else { + return LinkTargetKind::LocalMissing(resolved); + } + } else { + return LinkTargetKind::RelativePath(target.to_string()); + } + } + + LinkTargetKind::Unrecognized(target.to_string()) +} + +/// Whether the string starts with a recognized network or protocol URI scheme. +pub fn is_web_or_protocol_uri(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + // file: URIs are local filesystem targets, handled by file_uri_to_local_path, not protocol URIs. + if lower.starts_with("file:") { + return false; + } + if lower.starts_with("https://") + || lower.starts_with("http://") + || lower.starts_with("mailto:") + || lower.starts_with("gemini://") + || lower.starts_with("gopher://") + || lower.starts_with("news:") + || lower.starts_with("git://") + || lower.starts_with("ssh://") + || lower.starts_with("ssh:") + || lower.starts_with("ftp://") + || lower.starts_with("ipfs:") + || lower.starts_with("ipns:") + || lower.starts_with("magnet:") + || lower.starts_with("tel:") + || lower.starts_with("sms:") + { + return true; + } + // Generic scheme URI check (e.g. vscode://, obsidian://, idea://, etc.): + // Scheme must start with an ASCII letter, followed by letters/digits/+/-/. (at least 2 chars total), then "://" + if let Some(colon_pos) = s.find("://") { + let scheme = &s[..colon_pos]; + if scheme.len() >= 2 + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') + { + return true; + } + } + false +} + +fn is_explicit_relative_path(s: &str) -> bool { + s.starts_with("./") || s.starts_with(r".\") || s.starts_with("../") || s.starts_with(r"..\") +} + +fn clean_relative_prefix(s: &str) -> &str { + if let Some(tail) = s.strip_prefix("./").or_else(|| s.strip_prefix(r".\")) { + tail + } else { + s + } +} + +fn has_scheme_prefix(s: &str) -> bool { + if let Some(colon) = s.find(':') { + let scheme = &s[..colon]; + return scheme.len() >= 2 + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'); + } + false +} + +/// Try to extract and open an existing local file/directory path from raw text. +#[allow(dead_code)] +pub fn try_open_local_link(text: &str) -> Option> { + try_open_local_link_with_cwd(text, None) +} + +/// Try to extract and open an existing local file/directory path from raw text, resolving +/// relative paths against the provided pane cwd if present. +pub fn try_open_local_link_with_cwd( + text: &str, + cwd: Option<&Path>, +) -> Option> { + match classify_link_target_with_cwd(text, cwd) { + LinkTargetKind::LocalExisting(path) => { + Some(open_local_path(&path).map_err(|e| LocalLinkError::OpenFailed(e.to_string()))) + } + LinkTargetKind::LocalMissing(path) => { + Some(Err(LocalLinkError::FileNotFound(path))) + } + LinkTargetKind::RelativePath(rel) => { + Some(Err(LocalLinkError::MissingCwd(rel))) + } + LinkTargetKind::ProtocolUri(_) | LinkTargetKind::Unrecognized(_) => None, + } +} + +/// Outcome of handling a hint command in legacy event contexts. +#[cfg(any(test, feature = "legacy-shell"))] +#[derive(Debug, PartialEq, Eq)] +pub enum LegacyHintOutcome { + Handled, + SpawnCommand(Vec), + Failed(String), +} + +/// Dispatch a hint command string in legacy event contexts with user-visible notification on failure. +#[cfg(any(test, feature = "legacy-shell"))] +pub fn handle_legacy_hint_command( + text: &str, + default_args: &[String], + language: crate::i18n::UiLanguage, +) -> LegacyHintOutcome { + let target = extract_link_target(text); + if let Some(result) = try_open_local_link(target) { + match result { + Ok(()) => { + log::debug!("trigger_hint opened local link from {target:?}"); + LegacyHintOutcome::Handled + } + Err(err) => { + let msg = err.localized_message(language); + log::debug!("trigger_hint local link failed for {target:?}: {msg}"); + LegacyHintOutcome::Failed(msg) + } + } + } else if is_web_or_protocol_uri(target) { + let mut args: Vec = default_args.iter().map(|s| s.into()).collect(); + args.push(target.into()); + LegacyHintOutcome::SpawnCommand(args) + } else { + let msg = language.format( + crate::i18n::Message::CommonLinkUnrecognized, + &[("target", target)], + ); + log::debug!("trigger_hint ignored non-protocol target: {target:?}"); + LegacyHintOutcome::Failed(msg) + } +} + +/// Extract the link target from potential Markdown link syntax `[title](target)`. +/// +/// For Markdown links, extracts the `target` URL or path, strips surrounding +/// `<...>` or quotes, and trims any trailing sentence punctuation. +/// +/// For naked URLs or paths (e.g. `https://en.wikipedia.org/wiki/Rust_(programming_language)`), +/// leaves the target untouched without stripping legal characters like closing parentheses. +pub fn extract_link_target(raw: &str) -> &str { + let trimmed = raw.trim(); + let unpunct = trim_outer_punctuation(trimmed); + if unpunct.starts_with('[') { + if let Some(open_paren) = unpunct.rfind("](") { + let inside = &unpunct[open_paren + 2..]; + if let Some(target) = inside.strip_suffix(')') { + let is_quote_or_bracket = |c: char| { + matches!(c, '<' | '>' | '"' | '\'' | '‘' | '’' | '“' | '”' | '《' | '》') + }; + let clean = target.trim().trim_matches(is_quote_or_bracket); + return trim_target_punctuation(clean); + } + } + } + // If wrapped in angle brackets ``, strip them. + if unpunct.starts_with('<') && unpunct.ends_with('>') && unpunct.len() >= 2 { + let inside = &unpunct[1..unpunct.len() - 1]; + return trim_target_punctuation(inside); + } + trim_target_punctuation(trimmed) +} + +/// Strip sentence-ending punctuation from outer strings while preserving structural parentheses/brackets. +fn trim_outer_punctuation(s: &str) -> &str { + s.trim_end_matches(|c: char| { + matches!( + c, + '。' | ',' | '、' | ';' | '!' | '?' | ')' | '】' | '》' | '”' | '’' + | ';' | ',' | '.' | ':' + ) + }) +} + +/// Strip trailing sentence punctuation while strictly preserving legal closing parentheses +/// in URLs when parentheses are balanced (e.g. `https://en.wikipedia.org/wiki/Rust_(programming_language)`). +fn trim_target_punctuation(s: &str) -> &str { + let mut end = s.len(); + while end > 0 { + let c = s[..end].chars().next_back().unwrap(); + // Always strip trailing CJK sentence delimiters + if matches!( + c, + '。' | ',' | '、' | ';' | '!' | '?' | ')' | '】' | '》' | '”' | '’' + ) { + end -= c.len_utf8(); + continue; + } + if matches!(c, ';' | ',') { + end -= 1; + continue; + } + // For closing parenthesis, only strip if unmatched (e.g. `(http://...)`) + if c == ')' { + let open_count = s[..end].chars().filter(|&ch| ch == '(').count(); + let close_count = s[..end].chars().filter(|&ch| ch == ')').count(); + if close_count > open_count { + end -= 1; + continue; + } + } + break; + } + &s[..end] +} + +/// Test seam for [`file_uri_to_local_path`]; drives and home directory are injected +/// so unit tests can verify both Windows and Unix behavior deterministically. +fn file_uri_to_local_path_with( + uri: &str, + drive_exists: impl Fn(char) -> bool, + home_dir: impl Fn() -> Option, + is_windows: bool, +) -> Option { + let trimmed = extract_link_target(uri); + if trimmed.is_empty() { + return None; + } + + // 1. Direct home-relative path: "~" or "~/..." or (on Windows) "~\..." + if trimmed == "~" { + return home_dir(); + } + if let Some(tail) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix(r"~\")) { + let home = home_dir()?; + return Some(join_home(home, tail, is_windows)); + } + + // 2. Direct Windows drive path without scheme, e.g.: + // "D:/work/project/file.md" or "D:\work\project\file.md" + if is_drive_prefixed(trimmed) { + return if is_windows { + Some(to_windows(trimmed)) + } else { + Some(PathBuf::from(trimmed)) + }; + } + + // 3. Windows drive path with a stray leading slash, e.g. "/D:/work/..." + if trimmed.starts_with('/') && is_drive_prefixed(&trimmed[1..]) { + let rest = &trimmed[1..]; + return if is_windows { + Some(to_windows(rest)) + } else { + Some(PathBuf::from(rest)) + }; + } + + // 4. Direct UNC path on Windows, e.g. "\\server\share\file.txt" + if is_windows + && trimmed.starts_with(r"\\") + && !trimmed.to_ascii_lowercase().starts_with("file:") + { + return Some(PathBuf::from(trimmed.replace('/', "\\"))); + } + + // 5. Scheme-prefixed `file:` URI + if let Some(rest) = strip_scheme(trimmed) { + if let Some(after_slashes) = rest.strip_prefix("//") { + // 5a. Home-relative URI: "file://~/..." or "file:///~/..." + if let Some(tail) = after_slashes.strip_prefix("~/").or_else(|| after_slashes.strip_prefix("/~/")) { + let home = home_dir()?; + let decoded = percent_decode_utf8(tail); + return Some(join_home(home, &decoded, is_windows)); + } + + // 5b. Two-slash drive path: "file://C:/..." or "file://C:\..." + if is_drive_prefixed(after_slashes) { + let path = percent_decode_utf8(after_slashes); + return if is_windows { + Some(to_windows(&path)) + } else { + Some(PathBuf::from(path)) + }; + } + + // 5c. Split `HOST/PATH` + if let Some(slash) = after_slashes.find('/') { + let host = &after_slashes[..slash]; + let path = percent_decode_utf8(&after_slashes[slash..]); + + // Host that is actually a drive letter (e.g. "C:" from "file://C:/...") + if is_drive_prefixed(host) { + let full = format!("{host}{path}"); + return if is_windows { + Some(to_windows(&full)) + } else { + Some(PathBuf::from(full)) + }; + } + + if host_is_local(host) { + return translate_local_path_impl(&path, &drive_exists, &home_dir, is_windows); + } else if is_windows { + // Remote host → UNC share on Windows (`\\HOST\path`) + let host = percent_decode_utf8(host); + let tail = path.trim_start_matches('/').replace('/', "\\"); + return (!host.is_empty()).then(|| PathBuf::from(format!("\\\\{host}\\{tail}"))); + } else { + return None; + } + } else { + return None; + } + } else if rest.starts_with('/') { + // Single slash: "file:/home/user/..." or "file:/C:/..." + let decoded = percent_decode_utf8(rest); + return translate_local_path_impl(&decoded, &drive_exists, &home_dir, is_windows); + } else if is_drive_prefixed(rest) { + // "file:C:/..." + let decoded = percent_decode_utf8(rest); + return if is_windows { + Some(to_windows(&decoded)) + } else { + Some(PathBuf::from(decoded)) + }; + } + } + + // Naked POSIX paths (e.g. /etc/passwd or /usr/bin/gcc) are strictly NOT opened + // as local files unless prefixed with file: or ~ to prevent accidental access. + None +} + /// Strip a case-insensitive `file:` scheme prefix. fn strip_scheme(uri: &str) -> Option<&str> { let bytes = uri.as_bytes(); @@ -75,49 +516,102 @@ fn host_is_local(host: &str) -> bool { if host == "127.0.0.1" || host == "::1" || host == "[::1]" { return true; } - // `ls --hyperlink` uses the machine's hostname; treat our own name as local. - std::env::var("COMPUTERNAME").ok().is_some_and(|name| name.eq_ignore_ascii_case(host)) + #[cfg(windows)] + if let Ok(name) = std::env::var("COMPUTERNAME") { + if name.eq_ignore_ascii_case(host) { + return true; + } + } + #[cfg(unix)] + { + if let Ok(name) = std::env::var("HOSTNAME") { + if name.eq_ignore_ascii_case(host) { + return true; + } + } + if let Ok(name) = std::env::var("HOST") { + if name.eq_ignore_ascii_case(host) { + return true; + } + } + if let Some(name) = sys_gethostname() { + if name.eq_ignore_ascii_case(host) { + return true; + } + } + } + false } -/// Convert a decoded, local, posix-looking URI path into a Windows path. -/// -/// `path` still has its leading '/' and forward slashes. Handles, in order: -/// Windows drive paths (`/C:/…`), WSL/Cygwin mounts (`/mnt/c/…`, -/// `/cygdrive/c/…`), and the MSYS drive form (`/c/…`, drive-existence gated). -fn translate_local_path(path: &str, drive_exists: &impl Fn(char) -> bool) -> Option { - // `/C:/Users/…` — a real Windows drive path with a stray leading slash. - if is_drive_prefixed(&path[1..]) { - return Some(to_windows(&path[1..])); +#[cfg(unix)] +fn sys_gethostname() -> Option { + unsafe { + let mut buf = [0 as std::ffi::c_char; 256]; + if libc::gethostname(buf.as_mut_ptr(), buf.len()) == 0 { + std::ffi::CStr::from_ptr(buf.as_ptr()).to_str().ok().map(|s| s.to_string()) + } else { + None + } + } +} + +/// Convert a decoded, local, posix-looking URI path into a platform-appropriate PathBuf. +fn translate_local_path_impl( + path: &str, + drive_exists: &impl Fn(char) -> bool, + home_dir: &impl Fn() -> Option, + is_windows: bool, +) -> Option { + if let Some(tail) = path.strip_prefix("/~/").or_else(|| path.strip_prefix("~/")) { + let home = home_dir()?; + return Some(join_home(home, tail, is_windows)); + } + + if let Some(without_slash) = path.strip_prefix('/') { + if is_drive_prefixed(without_slash) { + return if is_windows { + Some(to_windows(without_slash)) + } else { + Some(PathBuf::from(without_slash)) + }; + } + } else if is_drive_prefixed(path) { + return if is_windows { + Some(to_windows(path)) + } else { + Some(PathBuf::from(path)) + }; } let trimmed = path.trim_start_matches('/'); - // `/mnt/c/…` (WSL) or `/cygdrive/c/…` (Cygwin/some MSYS builds). - for mount in ["mnt/", "cygdrive/"] { - if let Some(after) = trimmed.strip_prefix(mount) { - if let Some(win) = mount_drive_path(after) { - return Some(win); + if is_windows { + // `/mnt/c/…` (WSL) or `/cygdrive/c/…` (Cygwin/some MSYS builds). + for mount in ["mnt/", "cygdrive/"] { + if let Some(after) = trimmed.strip_prefix(mount) { + if let Some(win) = mount_drive_path(after) { + return Some(win); + } } } - } - // `/c/temp/…` — MSYS drive form. Only when the drive really exists; a lone - // leading segment named like an existing drive letter is otherwise a - // genuine directory (`/opt`, `/home`, …). - let mut segments = trimmed.splitn(2, '/'); - if let Some(first) = segments.next() { - if let Some(drive) = single_drive_letter(first) { - if drive_exists(drive) { - let tail = segments.next().unwrap_or(""); - return Some(to_windows(&format!("{drive}:/{tail}"))); + // `/c/temp/…` — MSYS drive form. Only when the drive really exists. + let mut segments = trimmed.splitn(2, '/'); + if let Some(first) = segments.next() { + if let Some(drive) = single_drive_letter(first) { + if drive_exists(drive) { + let tail = segments.next().unwrap_or(""); + return Some(to_windows(&format!("{drive}:/{tail}"))); + } } } - } - // A truly posix path with no drive mapping (e.g. a WSL-internal - // `/home/user` path) isn't reachable from Windows `explorer`; give up so - // the caller can fall back. - None + // Pure POSIX path with no drive mapping isn't reachable on Windows. + None + } else { + // On Unix platforms (macOS, Linux), any leading slash indicates a valid absolute path. + Some(PathBuf::from(format!("/{}", trimmed))) + } } /// Build a Windows path from a `/` mount tail (`c/x` → `C:\x`). @@ -143,6 +637,22 @@ fn single_drive_letter(s: &str) -> Option { } } +/// Join a relative path to the home directory with platform-appropriate separators. +fn join_home(home: PathBuf, sub: &str, is_windows: bool) -> PathBuf { + let clean = sub.trim_start_matches(|c| c == '/' || c == '\\'); + if is_windows { + let home_str = home.to_string_lossy(); + let home_trimmed = home_str.trim_end_matches(|c| c == '/' || c == '\\'); + let sub_win = clean.replace('/', "\\"); + PathBuf::from(format!("{home_trimmed}\\{sub_win}")) + } else { + let home_str = home.to_string_lossy(); + let home_trimmed = home_str.trim_end_matches('/'); + let sub_unix = clean.replace('\\', "/"); + PathBuf::from(format!("{home_trimmed}/{sub_unix}")) + } +} + /// Normalize forward slashes to backslashes and uppercase the drive letter. fn to_windows(path: &str) -> PathBuf { let mut out = path.replace('/', "\\"); @@ -154,14 +664,17 @@ fn to_windows(path: &str) -> PathBuf { } /// Whether drive `letter` (e.g. `'C'`) is currently mounted. +#[cfg(windows)] fn drive_exists(letter: char) -> bool { std::path::Path::new(&format!("{letter}:\\")).exists() } -/// UTF-8 aware percent-decoding. `%HH` pairs are collected into a byte buffer -/// and decoded as UTF-8 at the end, so multi-byte characters (e.g. CJK -/// filenames encoded by the shell) round-trip correctly. Malformed escapes are -/// left verbatim. +#[cfg(not(windows))] +fn drive_exists(_letter: char) -> bool { + false +} + +/// UTF-8 aware percent-decoding. fn percent_decode_utf8(s: &str) -> String { let b = s.as_bytes(); let mut out = Vec::with_capacity(b.len()); @@ -193,81 +706,481 @@ fn hex_val(c: u8) -> Option { mod tests { use super::*; - /// Resolve against a fixed set of "mounted" drives so tests are stable. - fn t(uri: &str) -> Option { - file_uri_to_local_path_with(uri, |d| matches!(d, 'C' | 'D')) + fn mock_home() -> Option { + Some(PathBuf::from("/mock/home/user")) + } + + /// Resolve simulating Windows environment with mounted drives 'C' and 'D'. + fn t_win(uri: &str) -> Option { + file_uri_to_local_path_with( + uri, + |d| matches!(d, 'C' | 'D'), + || Some(PathBuf::from(r"C:\Users\testuser")), + true, + ) + .map(|p| p.to_string_lossy().into_owned()) + } + + /// Resolve simulating Unix environment (macOS / Linux). + fn t_unix(uri: &str) -> Option { + file_uri_to_local_path_with(uri, |_| false, mock_home, false) .map(|p| p.to_string_lossy().into_owned()) } #[test] fn powershell_drive_uri() { - assert_eq!(t("file:///C:/Users/me/file.txt"), Some(r"C:\Users\me\file.txt".into())); + assert_eq!(t_win("file:///C:/Users/me/file.txt"), Some(r"C:\Users\me\file.txt".into())); } #[test] fn drive_letter_uppercased() { - assert_eq!(t("file:///d:/temp/x"), Some(r"D:\temp\x".into())); + assert_eq!(t_win("file:///d:/temp/x"), Some(r"D:\temp\x".into())); } #[test] fn percent_encoded_space() { - assert_eq!(t("file:///C:/a%20b/c.txt"), Some(r"C:\a b\c.txt".into())); + assert_eq!(t_win("file:///C:/a%20b/c.txt"), Some(r"C:\a b\c.txt".into())); } #[test] fn utf8_filename_roundtrips() { - // "文档" percent-encoded as UTF-8. - assert_eq!(t("file:///D:/%E6%96%87%E6%A1%A3/x.md"), Some("D:\\文档\\x.md".into())); + assert_eq!(t_win("file:///D:/%E6%96%87%E6%A1%A3/x.md"), Some("D:\\文档\\x.md".into())); } #[test] fn scheme_is_case_insensitive() { - assert_eq!(t("FILE:///C:/x"), Some(r"C:\x".into())); + assert_eq!(t_win("FILE:///C:/x"), Some(r"C:\x".into())); } #[test] fn wsl_mount_path() { - assert_eq!(t("file://localhost/mnt/c/work/a.rs"), Some(r"C:\work\a.rs".into())); + assert_eq!(t_win("file://localhost/mnt/c/work/a.rs"), Some(r"C:\work\a.rs".into())); } #[test] fn cygdrive_mount_path() { - assert_eq!(t("file://localhost/cygdrive/d/proj/b.rs"), Some(r"D:\proj\b.rs".into())); + assert_eq!(t_win("file://localhost/cygdrive/d/proj/b.rs"), Some(r"D:\proj\b.rs".into())); } #[test] fn msys_drive_form_when_drive_exists() { - assert_eq!(t("file://localhost/d/temp_build/x"), Some(r"D:\temp_build\x".into())); + assert_eq!(t_win("file://localhost/d/temp_build/x"), Some(r"D:\temp_build\x".into())); } #[test] fn msys_drive_form_root() { - assert_eq!(t("file://localhost/c/"), Some(r"C:\".into())); + assert_eq!(t_win("file://localhost/c/"), Some(r"C:\".into())); } #[test] - fn leading_segment_that_is_not_a_drive_is_not_mangled() { - // 'z' isn't a mounted drive → not a drive form; and there's no Windows - // mapping for a bare posix path, so we fall back (None). - assert_eq!(t("file://localhost/z/opt/thing"), None); + fn leading_segment_that_is_not_a_drive_is_not_mangled_on_windows() { + assert_eq!(t_win("file://localhost/z/opt/thing"), None); } #[test] - fn pure_posix_path_falls_back() { - assert_eq!(t("file://localhost/home/user/.bashrc"), None); + fn pure_posix_path_falls_back_on_windows() { + assert_eq!(t_win("file://localhost/home/user/.bashrc"), None); } #[test] - fn remote_host_becomes_unc() { + fn remote_host_becomes_unc_on_windows() { assert_eq!( - t("file://fileserver/share/doc.txt"), + t_win("file://fileserver/share/doc.txt"), Some(r"\\fileserver\share\doc.txt".into()) ); } #[test] fn non_file_scheme_falls_back() { - assert_eq!(t("https://example.com/a"), None); - assert_eq!(t("mailto:x@y.z"), None); + assert_eq!(t_win("https://example.com/a"), None); + assert_eq!(t_unix("https://example.com/a"), None); + assert_eq!(t_win("mailto:x@y.z"), None); + } + + #[test] + fn raw_windows_drive_paths() { + assert_eq!( + t_win("D:/work/git/gt_project_git_extra/docs/brainstorms/2026-09-18-global-tutorial-framework-requirements.md"), + Some(r"D:\work\git\gt_project_git_extra\docs\brainstorms\2026-09-18-global-tutorial-framework-requirements.md".into()) + ); + assert_eq!( + t_win(r"D:\work\git\proj\file.txt"), + Some(r"D:\work\git\proj\file.txt".into()) + ); + assert_eq!( + t_win("/C:/Users/me/file.txt"), + Some(r"C:\Users\me\file.txt".into()) + ); + assert_eq!( + t_win(r"C:\work\report%20final.docx"), + Some(r"C:\work\report%20final.docx".into()) + ); + } + + #[test] + fn file_uri_two_slashes_drive() { + assert_eq!( + t_win("file://D:/work/project/doc.md"), + Some(r"D:\work\project\doc.md".into()) + ); + } + + #[test] + fn raw_path_with_trailing_punctuation() { + assert_eq!( + t_win("D:/work/notes/spec.md。"), + Some(r"D:\work\notes\spec.md".into()) + ); + assert_eq!( + t_win("D:/work/notes/spec.md)"), + Some(r"D:\work\notes\spec.md".into()) + ); + assert_eq!( + t_win(""), + Some(r"D:\work\notes\spec.md".into()) + ); + } + + #[test] + fn home_path_expansion() { + assert_eq!( + t_unix("~/docs/spec.md"), + Some("/mock/home/user/docs/spec.md".into()) + ); + assert_eq!( + t_unix("~"), + Some("/mock/home/user".into()) + ); + assert_eq!( + t_unix("file://~/notes.txt"), + Some("/mock/home/user/notes.txt".into()) + ); + assert_eq!( + t_unix("file:///~/notes.txt"), + Some("/mock/home/user/notes.txt".into()) + ); + assert_eq!( + t_win(r"~\notes.txt"), + Some(r"C:\Users\testuser\notes.txt".into()) + ); + } + + #[test] + fn unix_posix_paths() { + // Naked POSIX paths are NOT recognized as local file targets without file: or ~ + assert_eq!(t_unix("/Users/alice/projects/demo/src/main.rs"), None); + assert_eq!(t_unix("/home/bob/work/repo/README.md"), None); + assert_eq!(t_unix("/etc/passwd"), None); + assert_eq!(t_unix("/usr/bin/gcc"), None); + + // Explicit file: URIs resolve properly + assert_eq!( + t_unix("file:///Users/alice/projects/demo/src/main.rs"), + Some("/Users/alice/projects/demo/src/main.rs".into()) + ); + assert_eq!( + t_unix("file:/home/bob/work/repo/README.md"), + Some("/home/bob/work/repo/README.md".into()) + ); + assert_eq!( + t_unix("file://localhost/home/bob/work/repo/README.md"), + Some("/home/bob/work/repo/README.md".into()) + ); + assert_eq!( + t_unix("file:///home/bob/docs/需求规格.md。"), + Some("/home/bob/docs/需求规格.md".into()) + ); + } + + #[test] + fn markdown_link_resolution() { + assert_eq!( + extract_link_target("[全局新手引导框架需求规格](D:/work/git/gt_project_git_extra/docs/brainstorms/2026-09-18-global-tutorial-framework-requirements.md)。"), + "D:/work/git/gt_project_git_extra/docs/brainstorms/2026-09-18-global-tutorial-framework-requirements.md" + ); + assert_eq!( + extract_link_target("[Web](https://github.com)"), + "https://github.com" + ); + assert_eq!( + extract_link_target("[nested [brackets]](D:/path/file.txt)"), + "D:/path/file.txt" + ); + assert_eq!( + extract_link_target("[spec]()"), + "D:/my files/spec.md" + ); + + assert_eq!( + t_win("[全局新手引导框架需求规格](D:/work/git/gt_project_git_extra/docs/brainstorms/2026-09-18-global-tutorial-framework-requirements.md)。"), + Some(r"D:\work\git\gt_project_git_extra\docs\brainstorms\2026-09-18-global-tutorial-framework-requirements.md".into()) + ); + assert_eq!( + t_win("[link](file:///D:/notes/todo.txt)"), + Some(r"D:\notes\todo.txt".into()) + ); + // Naked POSIX in Markdown does NOT resolve without file: or ~ + assert_eq!( + t_unix("[docs](/home/bob/work/repo/README.md)"), + None + ); + assert_eq!( + t_unix("[docs](file:///home/bob/work/repo/README.md)"), + Some("/home/bob/work/repo/README.md".into()) + ); + assert_eq!( + t_unix("[home](~/docs/spec.md)"), + Some("/mock/home/user/docs/spec.md".into()) + ); + assert_eq!( + extract_link_target("https://en.wikipedia.org/wiki/Rust_(programming_language)"), + "https://en.wikipedia.org/wiki/Rust_(programming_language)" + ); + assert_eq!( + extract_link_target("[Rust](https://en.wikipedia.org/wiki/Rust_(programming_language))。"), + "https://en.wikipedia.org/wiki/Rust_(programming_language)" + ); + assert_eq!( + extract_link_target(""), + "https://example.com/docs" + ); + assert_eq!( + extract_link_target("。"), + "D:/x.md" + ); + assert_eq!( + extract_link_target("。"), + "https://example.com/docs" + ); + // Relative paths cannot be resolved without pane cwd context and must fall back. + assert_eq!(t_win("./README.md"), None); + assert_eq!(t_unix("./README.md"), None); + assert_eq!(t_unix("docs/spec.md"), None); + } + + #[test] + fn local_link_contract_gates() { + let lang = crate::i18n::UiLanguage::ZhCn; + + // Gate 1: Relative paths are locked down and rejected with user-visible guidance. + assert_eq!( + classify_link_target("./README.md"), + LinkTargetKind::RelativePath("./README.md".into()) + ); + assert_eq!( + classify_link_target("[Local](./doc.md)"), + LinkTargetKind::RelativePath("./doc.md".into()) + ); + let rel_res = try_open_local_link("./README.md").unwrap(); + assert_eq!( + rel_res, + Err(LocalLinkError::MissingCwd("./README.md".into())) + ); + assert!(rel_res.unwrap_err().localized_message(lang).contains("缺少工作目录")); + + // Gate 2: Nonexistent local paths are locked down and reported as missing without spawning opener. + let missing = PathBuf::from(r"C:\nonexistent_12345_nebula_test\file.md"); + assert_eq!( + classify_link_target(missing.to_str().unwrap()), + LinkTargetKind::LocalMissing(missing.clone()) + ); + let missing_res = try_open_local_link(missing.to_str().unwrap()).unwrap(); + assert_eq!( + missing_res, + Err(LocalLinkError::FileNotFound(missing.clone())) + ); + assert!(missing_res.unwrap_err().localized_message(lang).contains("文件不存在")); + + // Gate 3: Naked POSIX paths like /etc/passwd or /usr/bin/ls are Unrecognized (never LocalExisting). + assert_eq!( + classify_link_target("/etc/passwd"), + LinkTargetKind::Unrecognized("/etc/passwd".into()) + ); + assert_eq!( + classify_link_target("[docs](/etc/passwd)"), + LinkTargetKind::Unrecognized("/etc/passwd".into()) + ); + assert_eq!( + classify_link_target("/usr/bin/gcc"), + LinkTargetKind::Unrecognized("/usr/bin/gcc".into()) + ); + assert_eq!(try_open_local_link("/etc/passwd"), None); + assert_eq!(try_open_local_link("[docs](/etc/passwd)"), None); + + // Gate 4: Protocol URIs (https, wiki, ssh, file:/) are properly classified. + let wiki = "https://en.wikipedia.org/wiki/Rust_(programming_language)"; + assert_eq!( + classify_link_target(wiki), + LinkTargetKind::ProtocolUri(wiki.into()) + ); + assert_eq!(try_open_local_link(wiki), None); + + let ssh_uri = "ssh:git@github.com:user/repo.git"; + assert_eq!( + classify_link_target(ssh_uri), + LinkTargetKind::ProtocolUri(ssh_uri.into()) + ); + assert_eq!(try_open_local_link(ssh_uri), None); + + let vscode_uri = "vscode://file/D:/project/main.rs"; + assert_eq!( + classify_link_target(vscode_uri), + LinkTargetKind::ProtocolUri(vscode_uri.into()) + ); + + // Gate 4a: file:// URIs must be classified as local paths, NEVER ProtocolUri + assert!(!is_web_or_protocol_uri("file:///C:/Windows")); + #[cfg(windows)] + { + assert_eq!( + classify_link_target("file:///C:/Windows"), + LinkTargetKind::LocalExisting(PathBuf::from(r"C:\Windows")) + ); + assert_eq!( + classify_link_target("file:///C:/Users/me/a%20b.txt"), + LinkTargetKind::LocalMissing(PathBuf::from(r"C:\Users\me\a b.txt")) + ); + // UNC paths should be classified as LocalExisting without blocking exists() checks + assert_eq!( + classify_link_target(r"\\server\share\file.txt"), + LinkTargetKind::LocalExisting(PathBuf::from(r"\\server\share\file.txt")) + ); + } + + // Gate 4b: Relative paths resolve when cwd is supplied (with or without ./, and clean of ./) + let repo_root = std::env::current_dir().unwrap(); + assert_eq!( + classify_link_target_with_cwd("./Cargo.toml", Some(&repo_root)), + LinkTargetKind::LocalExisting(repo_root.join("Cargo.toml")) + ); + assert_eq!( + classify_link_target_with_cwd("[spec](Cargo.toml)", Some(&repo_root)), + LinkTargetKind::LocalExisting(repo_root.join("Cargo.toml")) + ); + assert_eq!( + classify_link_target_with_cwd("[missing](nonexistent_nebula_file.xyz)", Some(&repo_root)), + LinkTargetKind::LocalMissing(repo_root.join("nonexistent_nebula_file.xyz")) + ); + assert_eq!( + classify_link_target_with_cwd("docs/nonexistent.md", Some(&repo_root)), + LinkTargetKind::LocalMissing(repo_root.join("docs/nonexistent.md")) + ); + assert_eq!( + classify_link_target_with_cwd("Cargo.toml", None), + LinkTargetKind::RelativePath("Cargo.toml".into()) + ); + assert_eq!( + classify_link_target_with_cwd("[call](tel:123456)", Some(&repo_root)), + LinkTargetKind::ProtocolUri("tel:123456".into()) + ); + assert_eq!( + classify_link_target_with_cwd("[x](custom:foo)", Some(&repo_root)), + LinkTargetKind::Unrecognized("custom:foo".into()) + ); + assert_eq!( + classify_link_target_with_cwd("#section", Some(&repo_root)), + LinkTargetKind::Unrecognized("#section".into()) + ); + assert_eq!(t_win("//cdn.example.com/lib.js"), None); + assert_eq!(t_unix("//cdn.example.com/lib.js"), None); + + // Gate 5: Single-slash file:/ is recognized as local URI on both platforms + #[cfg(windows)] + let file_single = "file:/C:/nonexistent_file_test/file.txt"; + #[cfg(not(windows))] + let file_single = "file:/home/nonexistent_file_test/file.txt"; + assert!(matches!( + classify_link_target(file_single), + LinkTargetKind::LocalMissing(_) | LinkTargetKind::LocalExisting(_) + )); + assert_eq!( + t_unix("file:/home/user/whatever"), + Some("/home/user/whatever".into()) + ); + assert_eq!( + t_win("file:/C:/test.txt"), + Some(r"C:\test.txt".into()) + ); + + // Gate 6: Legacy handler reports failure outcome + let outcome = handle_legacy_hint_command("./README.md", &[], lang); + match outcome { + LegacyHintOutcome::Failed(msg) => assert!(msg.contains("缺少工作目录")), + other => panic!("expected failed outcome, got: {other:?}"), + } + + let outcome = handle_legacy_hint_command("/etc/passwd", &[], lang); + match outcome { + LegacyHintOutcome::Failed(msg) => assert!(msg.contains("无法识别")), + other => panic!("expected failed outcome, got: {other:?}"), + } + } + + #[test] + fn probe_round2_review_coverage() { + // Test all cases from review probe to guarantee zero regressions: + // 1. file:// URIs must classify into local paths, never ProtocolUri + #[cfg(windows)] + { + assert!(matches!( + classify_link_target("file:///C:/Windows/"), + LinkTargetKind::LocalExisting(_) + )); + assert_eq!( + classify_link_target("file:///C:/Users/me/a%20b.txt"), + LinkTargetKind::LocalMissing(PathBuf::from(r"C:\Users\me\a b.txt")) + ); + assert_eq!( + classify_link_target("file://localhost/mnt/c/work/a.rs"), + LinkTargetKind::LocalMissing(PathBuf::from(r"C:\work\a.rs")) + ); + assert_eq!( + classify_link_target("file://fileserver/share/doc.txt"), + LinkTargetKind::LocalExisting(PathBuf::from(r"\\fileserver\share\doc.txt")) + ); + assert_eq!( + classify_link_target("FILE:///C:/x"), + LinkTargetKind::LocalMissing(PathBuf::from(r"C:\x")) + ); + assert_eq!( + classify_link_target("file:/C:/x"), + LinkTargetKind::LocalMissing(PathBuf::from(r"C:\x")) + ); + assert_eq!( + classify_link_target(r"\\server\share\file.txt"), + LinkTargetKind::LocalExisting(PathBuf::from(r"\\server\share\file.txt")) + ); + } + + // 2. Generic protocol URIs must classify into ProtocolUri + assert_eq!( + classify_link_target("vscode://file/D:/x.rs"), + LinkTargetKind::ProtocolUri("vscode://file/D:/x.rs".into()) + ); + + // 3. Raw and markdown disk paths + #[cfg(windows)] + { + assert_eq!( + classify_link_target("D:/work/spec.md"), + LinkTargetKind::LocalMissing(PathBuf::from(r"D:\work\spec.md")) + ); + assert_eq!( + classify_link_target("[x](D:/a/b)。"), + LinkTargetKind::LocalMissing(PathBuf::from(r"D:\a\b")) + ); + assert_eq!( + classify_link_target("。"), + LinkTargetKind::LocalMissing(PathBuf::from(r"D:\x.md")) + ); + assert_eq!( + classify_link_target("[app](C:/Program Files (x86)/foo.exe)"), + LinkTargetKind::LocalMissing(PathBuf::from(r"C:\Program Files (x86)\foo.exe")) + ); + assert_eq!( + classify_link_target("D:/docs/需求(草案).md"), + LinkTargetKind::LocalMissing(PathBuf::from(r"D:\docs\需求(草案).md")) + ); + } } } diff --git a/nebula_app/src/gpui_shell/terminal/answer_reader.rs b/nebula_app/src/gpui_shell/terminal/answer_reader.rs index 7eac986..fefa519 100644 --- a/nebula_app/src/gpui_shell/terminal/answer_reader.rs +++ b/nebula_app/src/gpui_shell/terminal/answer_reader.rs @@ -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() diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index 33a4a61..94a3912 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -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( rows: usize, cols: usize, ) -> Option { - 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( 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(hint: &HintMatch, term: &Term, 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) -> 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(); } } diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 6c92c66..37c1bc1 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -169,11 +169,22 @@ impl TerminalView { } } - pub(super) fn try_open_hovered_link(&self, cx: &Context) { + pub(super) fn try_open_hovered_link(&self, window: &mut Window, cx: &mut Context) { 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); } diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 46a9546..bfdf508 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -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; diff --git a/nebula_app/src/platform/file_manager.rs b/nebula_app/src/platform/file_manager.rs index 2589c68..ea96703 100644 --- a/nebula_app/src/platform/file_manager.rs +++ b/nebula_app/src/platform/file_manager.rs @@ -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<()> {