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/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, }; 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 22dcdf6..aa20370 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<()> {