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