From cddeec78b3a5943e0ee37dd45235368b7ada9429 Mon Sep 17 00:00:00 2001 From: YinBuLiao Date: Wed, 23 Sep 2026 15:22:24 +0800 Subject: [PATCH] fix(macos): open terminal links with Command-click --- nebula_app/i18n/de-DE.json | 2 + nebula_app/i18n/en-US.json | 2 + nebula_app/i18n/es-ES.json | 2 + nebula_app/i18n/fr-FR.json | 2 + nebula_app/i18n/it-IT.json | 2 + nebula_app/i18n/ja-JP.json | 2 + nebula_app/i18n/ko-KR.json | 2 + nebula_app/i18n/pt-BR.json | 2 + nebula_app/i18n/ru-RU.json | 2 + nebula_app/i18n/zh-CN.json | 2 + nebula_app/i18n/zh-TW.json | 2 + nebula_app/src/display/hint.rs | 16 +- .../src/gpui_shell/terminal/osc_links.rs | 29 ++- nebula_app/src/gpui_shell/terminal/view.rs | 2 +- .../src/gpui_shell/terminal/view/pointer.rs | 65 ++++--- .../gpui_shell/terminal/view/pointer/tests.rs | 175 ++++++++++++++++++ 16 files changed, 274 insertions(+), 35 deletions(-) diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index af6269f..a68ccae 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+Linksklick zum Öffnen", + "link_ctrl_click": "Strg+Linksklick zum Öffnen", "settings": "Einstellungen", "language": "Sprache", "about": "Über", diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index c74b422..706623c 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -209,6 +209,8 @@ } }, "common": { + "link_command_click": "Command+left click to open", + "link_ctrl_click": "Ctrl+left click to open", "settings": "Settings", "language": "Language", "about": "About", diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index 080b3c6..eae402b 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+clic izquierdo para abrir", + "link_ctrl_click": "Ctrl+clic izquierdo para abrir", "settings": "Configuración", "language": "Idioma", "about": "Acerca de", diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index 1c2afd0..3b18763 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -95,6 +95,8 @@ } }, "common": { + "link_command_click": "Command+clic gauche pour ouvrir", + "link_ctrl_click": "Ctrl+clic gauche pour ouvrir", "settings": "Paramètres", "language": "Langue", "about": "À propos", diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 2c05661..a65ec0e 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+clic sinistro per aprire", + "link_ctrl_click": "Ctrl+clic sinistro per aprire", "settings": "Impostazioni", "language": "Lingua", "about": "Informazioni", diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index 0a56ca2..46e8a24 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+左クリックで開く", + "link_ctrl_click": "Ctrl+左クリックで開く", "settings": "設定", "language": "言語", "about": "このアプリについて", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 467393f..7e3168d 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+왼쪽 클릭으로 열기", + "link_ctrl_click": "Ctrl+왼쪽 클릭으로 열기", "settings": "설정", "language": "언어", "about": "정보", diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index d8a00b9..97714ff 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+clique esquerdo para abrir", + "link_ctrl_click": "Ctrl+clique esquerdo para abrir", "settings": "Configurações", "language": "Idioma", "about": "Sobre", diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index 14e5a1e..a12f93f 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+щелчок левой кнопкой для открытия", + "link_ctrl_click": "Ctrl+щелчок левой кнопкой для открытия", "settings": "Настройки", "language": "Язык", "about": "О программе", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 9bacfd5..a853d52 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -209,6 +209,8 @@ } }, "common": { + "link_command_click": "Command+左键跳转", + "link_ctrl_click": "Ctrl+左键跳转", "settings": "设置", "language": "语言", "about": "关于", diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index 9f3abf3..b185d84 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -69,6 +69,8 @@ } }, "common": { + "link_command_click": "Command+左鍵開啟", + "link_ctrl_click": "Ctrl+左鍵開啟", "settings": "設定", "language": "語言", "about": "關於", diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index 7e3b6ae..761d539 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -469,6 +469,18 @@ pub fn highlighted_at( config: &UiConfig, point: Point, mouse_mods: ModifiersState, +) -> Option { + highlighted_at_with_mouse_override(term, config, point, mouse_mods, false) +} + +/// Allow a shell's explicit link gesture to override application mouse reporting. +/// Configured hint modifiers and the mouse-enabled flag are still respected. +pub fn highlighted_at_with_mouse_override( + term: &Term, + config: &UiConfig, + point: Point, + mouse_mods: ModifiersState, + override_mouse_mode: bool, ) -> Option { let mouse_mode = term.mode().intersects(TermMode::MOUSE_MODE); @@ -477,7 +489,9 @@ pub fn highlighted_at( let highlight = hint.mouse.is_some_and(|mouse| { mouse.enabled && mouse_mods.contains(mouse.mods.0) - && (!mouse_mode || mouse_mods.contains(ModifiersState::SHIFT)) + && (!mouse_mode + || override_mouse_mode + || mouse_mods.contains(ModifiersState::SHIFT)) }); if !highlight { return None; diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index 94a3912..64a588a 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -1,4 +1,4 @@ -//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、Ctrl+点击打开。 +//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、平台修饰键+点击打开。 //! //! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做 //! 视口坐标、GPUI 修饰键和打开入口。 @@ -16,6 +16,15 @@ use winit::keyboard::ModifiersState; use crate::config::UiConfig; use crate::config::ui_config::{HintAction, HintInternalAction, default_hint_command}; use crate::display::hint::{self, HintMatch}; +use crate::i18n::{Message, UiLanguage}; +use crate::platform::Platform; + +pub(super) fn link_modifier(mods: &gpui::Modifiers) -> bool { + match Platform::current() { + Platform::MacOS => mods.platform, + Platform::Windows | Platform::Linux => mods.control, + } +} /// 悬停目标:旧壳 `highlighted_hint` + 已经解码好的预览文案。 #[derive(Clone)] @@ -78,7 +87,13 @@ pub(super) fn highlighted_at( point: Point, mods: &gpui::Modifiers, ) -> Option { - hint::highlighted_at(term, config, point, winit_mouse_mods(mods)) + hint::highlighted_at_with_mouse_override( + term, + config, + point, + winit_mouse_mods(mods), + link_modifier(mods), + ) } pub(super) fn hover_from_hint( @@ -86,6 +101,7 @@ pub(super) fn hover_from_hint( hint: HintMatch, rows: usize, cols: usize, + language: UiLanguage, ) -> Option { let raw = hint .hyperlink() @@ -98,12 +114,15 @@ pub(super) fn hover_from_hint( point_to_viewport_from(origin, start).filter(|vp| vp.line < rows && vp.column.0 < cols); let (anchor_row, anchor_col) = vp.map(|vp| (vp.line as u16, vp.column.0 as u16)).unwrap_or((0, 0)); - const HINT: &str = " · Ctrl+点击"; + let gesture = language.text(match Platform::current() { + Platform::MacOS => Message::CommonLinkCommandClick, + Platform::Windows | Platform::Linux => Message::CommonLinkCtrlClick, + }); let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() }; let target = crate::display::strip_file_scheme(uri); - let budget = cols.saturating_sub(width(HINT) + 1); + let budget = cols.saturating_sub(width(gesture) + width(" · ") + 1); let target = crate::display::fit_tail(&target, budget); - Some(LinkHover { hint, preview: format!("{target}{HINT}"), anchor_row, anchor_col }) + Some(LinkHover { hint, preview: format!("{target} · {gesture}"), anchor_row, anchor_col }) } pub(super) fn open_hint_match( diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index dea90ae..a3a488d 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -402,7 +402,7 @@ pub struct TerminalView { selection_scroll_epoch: u64, /// 是否已有一条自动回滚定时器链在跑——每次 move 都开一条会叠出 N 倍速。 selection_scroll_active: bool, - /// OSC 8 / 正则 URL:虚线下划线、悬停预览、Ctrl+点击打开。 + /// OSC 8 / 正则 URL:虚线下划线、悬停预览、平台修饰键+点击打开。 pub(super) hint_config: Arc, pub(super) link_hover: Option, pending_link_open: bool, diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 25367d1..64074d7 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -1,3 +1,4 @@ +use super::super::osc_links::link_modifier; use super::*; use gpui_component::WindowExt as _; @@ -143,7 +144,13 @@ impl TerminalView { let term = session.term.lock(); super::super::osc_links::highlighted_at(&term, &self.hint_config, point, mods) .and_then(|hint| { - super::super::osc_links::hover_from_hint(&term, hint, self.rows, self.cols) + super::super::osc_links::hover_from_hint( + &term, + hint, + self.rows, + self.cols, + crate::gpui_shell::config::ui_language(cx), + ) }) }) }; @@ -355,6 +362,7 @@ impl TerminalView { cx: &mut Context, ) { window.focus(&self.focus_handle, cx); + self.pending_link_open = false; cx.emit(TerminalViewEvent::FocusRequested); if self.session.is_none() { return; @@ -400,6 +408,17 @@ impl TerminalView { cx.notify(); return; } + if link_modifier(&event.modifiers) && event.click_count == 1 { + self.update_link_hover(event.position, &event.modifiers, cx); + if self.link_hover.is_some() { + if let Some(session) = &self.session { + session.term.lock().selection = None; + } + self.selecting = false; + self.pending_link_open = true; + return; + } + } if self.mouse_mode_active(&event.modifiers) { self.send_mouse_report( event.position, @@ -409,28 +428,6 @@ impl TerminalView { ); return; } - if event.modifiers.control && event.click_count == 1 { - let (point, _) = self.grid_point(event.position); - let hit = self.session.as_ref().is_some_and(|session| { - let term = session.term.lock(); - super::super::osc_links::highlighted_at( - &term, - &self.hint_config, - point, - &event.modifiers, - ) - .is_some() - }); - if hit { - if let Some(session) = &self.session { - session.term.lock().selection = None; - } - self.selecting = false; - self.pending_link_open = true; - self.update_link_hover(event.position, &event.modifiers, cx); - return; - } - } let (point, side) = self.grid_point(event.position); let ty = match event.click_count { 1 => SelectionType::Simple, @@ -465,6 +462,11 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + // Retain the pressed link until release; dragging must not retarget it + // or leak part of the consumed gesture to the application. + if self.pending_link_open { + return; + } if self.move_completion_popup_scrollbar(event, cx) { cx.stop_propagation(); return; @@ -524,6 +526,12 @@ impl TerminalView { window.focus(&self.focus_handle, cx); cx.emit(TerminalViewEvent::FocusRequested); } + if event.pressed_button.is_none() && link_modifier(&event.modifiers) { + self.update_link_hover(event.position, &event.modifiers, cx); + if self.link_hover.is_some() { + return; + } + } if self.mouse_mode_active(&event.modifiers) { self.clear_link_hover(cx); // 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一), @@ -574,7 +582,8 @@ impl TerminalView { cx.notify(); return; } - if !self.selecting && self.mouse_mode_active(&event.modifiers) { + let pending_link_open = std::mem::take(&mut self.pending_link_open); + if !pending_link_open && !self.selecting && self.mouse_mode_active(&event.modifiers) { self.send_mouse_report( event.position, mouse_protocol::BUTTON_LEFT, @@ -584,15 +593,15 @@ impl TerminalView { return; } self.selecting = false; - let open_link = (self.pending_link_open || event.modifiers.control) + let (point, _) = self.grid_point(event.position); + let open_link = pending_link_open && self.selection_is_empty() - && self.link_hover.is_some(); + && self.link_hover.as_ref().is_some_and(|hover| hover.hint.bounds().contains(&point)); if open_link { self.try_open_hovered_link(window, cx); } else if self.copy_on_select { self.copy_selection(false, window, cx); } - self.pending_link_open = false; cx.notify(); } @@ -612,6 +621,7 @@ impl TerminalView { } self.stop_selection_scroll(); let dragging_scrollbar = self.scrollbar_drag.take().is_some(); + self.pending_link_open = false; if !self.selecting { if dragging_scrollbar { cx.notify(); @@ -619,7 +629,6 @@ impl TerminalView { return; } self.selecting = false; - self.pending_link_open = false; // 指针不在终端上,链接打开不该发生;只补选中即复制这一条收尾。 if self.copy_on_select { self.copy_selection(false, window, cx); diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs index afaf667..4baa4a4 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -151,3 +151,178 @@ fn hover_does_not_steal_focus_during_drag_overlay_dialog_or_inactive_window( move_over(&mut cx, None); assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0); } + +fn link_modifiers() -> Modifiers { + match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => Modifiers { platform: true, ..Modifiers::default() }, + _ => Modifiers { control: true, ..Modifiers::default() }, + } +} + +fn link_fixture( + cx: &mut TestAppContext, + text: &[u8], +) -> (Entity, VisualTestContext, std::sync::mpsc::Receiver) { + cx.update(crate::gpui_shell::math_view::register); + let (probe, mut cx) = open(cx); + let terminal = probe.read_with(&cx, |probe, _| probe.terminal.clone()); + let receiver = terminal.update(&mut cx, |view, cx| { + let (session, receiver) = session::test_session(); + view.session = Some(session); + view.error = None; + view.exited = None; + view.copy_on_select = false; + // Use the production matcher and dispatcher with a deterministic action; + // tests must not launch a user's browser or editor. + let mut config = UiConfig::default(); + Arc::make_mut(&mut config.hints.enabled[0]).action = + crate::config::ui_config::HintAction::Action( + crate::config::ui_config::HintInternalAction::Copy, + ); + view.hint_config = Arc::new(config); + super::super::startup_tests::feed(view, text); + cx.notify(); + receiver + }); + draw(&mut cx); + cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into()))); + (terminal, cx, receiver) +} + +fn cell(view: &Entity, cx: &VisualTestContext, col: usize) -> Point { + view.read_with(cx, |view, _| { + point( + view.origin.x + view.cell_width * (col as f32 + 0.5), + view.origin.y + view.line_height * 0.5, + ) + }) +} + +fn clipboard(cx: &mut VisualTestContext) -> Option { + cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())) +} + +#[gpui::test] +fn link_gesture_opens_regex_files_and_osc8_with_or_without_mouse_reporting( + cx: &mut TestAppContext, +) { + for (output, target) in [ + ("https://example.com", "https://example.com"), + ("file:///tmp/pebrel-notes.md", "file:///tmp/pebrel-notes.md"), + ("[notes](./notes.md)", "[notes](./notes.md)"), + ("\x1b]8;;https://example.com/osc8\x1b\\site\x1b]8;;\x1b\\", "https://example.com/osc8"), + ] { + for mouse_mode in [false, true] { + let output = if mouse_mode { + format!("\x1b[?1003h\x1b[?1006h{output}") + } else { + output.to_owned() + }; + let (view, mut window, receiver) = link_fixture(cx, output.as_bytes()); + let position = cell(&view, &window, 1); + window.simulate_mouse_move(position, None, link_modifiers()); + assert!(view.read_with(&window, |view, _| view.link_hover.is_some())); + window.simulate_mouse_down(position, MouseButton::Left, link_modifiers()); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + assert!(view.read_with(&window, |view, _| view.pending_link_open)); + // Releasing Command/Ctrl first must not send an orphan mouse-up to the TUI. + window.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); + assert_eq!(clipboard(&mut window).as_deref(), Some(target)); + assert!(!receiver.try_iter().any(|event| matches!(event, Msg::Input(_)))); + } + } +} + +#[gpui::test] +fn plain_click_and_wrong_modifier_do_not_open_links(cx: &mut TestAppContext) { + let (view, mut window, _) = link_fixture(cx, b"https://example.com"); + let position = cell(&view, &window, 1); + let wrong = if link_modifiers().platform { + Modifiers { control: true, ..Modifiers::default() } + } else { + Modifiers { platform: true, ..Modifiers::default() } + }; + for modifiers in [Modifiers::default(), wrong] { + window.simulate_mouse_move(position, None, modifiers); + window.simulate_mouse_down(position, MouseButton::Left, modifiers); + window.simulate_mouse_up(position, MouseButton::Left, modifiers); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + } +} + +#[gpui::test] +fn link_drag_cannot_retarget_or_leave_a_pending_open(cx: &mut TestAppContext) { + let (view, mut window, _) = link_fixture(cx, b"https://one.test https://two.test"); + let start = cell(&view, &window, 1); + for end in [cell(&view, &window, 20), point(px(10.0), px(10.0))] { + window.simulate_mouse_down(start, MouseButton::Left, link_modifiers()); + window.simulate_mouse_move(end, Some(MouseButton::Left), link_modifiers()); + window.simulate_mouse_up(end, MouseButton::Left, link_modifiers()); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + assert!(!view.read_with(&window, |view, _| view.pending_link_open)); + } +} + +#[gpui::test] +fn mouse_reporting_still_receives_ordinary_and_non_link_clicks(cx: &mut TestAppContext) { + let (view, mut window, receiver) = + link_fixture(cx, b"\x1b[?1000h\x1b[?1006hhttps://example.com plain"); + for (col, modifiers) in [(1, Modifiers::default()), (22, link_modifiers())] { + let position = cell(&view, &window, col); + window.simulate_mouse_down(position, MouseButton::Left, modifiers); + window.simulate_mouse_up(position, MouseButton::Left, modifiers); + let reports: Vec<_> = receiver + .try_iter() + .filter_map(|event| match event { + Msg::Input(bytes) => Some(bytes.into_owned()), + _ => None, + }) + .collect(); + assert_eq!(reports.len(), 2); + assert!(reports[0].ends_with(b"M")); + assert!(reports[1].ends_with(b"m")); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + } +} + +#[gpui::test] +fn link_hover_uses_current_language_and_platform(cx: &mut TestAppContext) { + use crate::i18n::UiLanguage; + let (view, mut window, _) = link_fixture(cx, b"https://example.com"); + let position = cell(&view, &window, 1); + let modifier = if link_modifiers().platform { "Command" } else { "Ctrl" }; + for (language, suffix) in [ + (UiLanguage::ZhCn, "+左键跳转"), + (UiLanguage::EnUs, "+left click to open"), + (UiLanguage::KoKr, "+왼쪽 클릭으로 열기"), + ] { + window.update(|_, cx| cx.global_mut::().ui_language = language); + window.simulate_mouse_move(position, None, Modifiers::default()); + let preview = + view.read_with(&window, |view, _| view.link_hover.as_ref().unwrap().preview.clone()); + assert!(preview.ends_with(&format!(" · {modifier}{suffix}")), "{preview}"); + draw(&mut window); + } +} + +#[gpui::test] +fn explicit_link_gesture_respects_disabled_hints_and_required_modifiers(cx: &mut TestAppContext) { + let (view, mut window, _) = link_fixture(cx, b"\x1b[?1000hhttps://example.com"); + let position = cell(&view, &window, 1); + for enabled in [false, true] { + view.update(&mut window, |view, _| { + let config = Arc::make_mut(&mut view.hint_config); + let hint = Arc::make_mut(&mut config.hints.enabled[0]); + let mouse = hint.mouse.as_mut().unwrap(); + mouse.enabled = enabled; + mouse.mods.0 = winit::keyboard::ModifiersState::SHIFT; + }); + window.simulate_mouse_down(position, MouseButton::Left, link_modifiers()); + window.simulate_mouse_up(position, MouseButton::Left, link_modifiers()); + assert_eq!(clipboard(&mut window).as_deref(), Some("before")); + } + let modifiers = Modifiers { shift: true, ..link_modifiers() }; + window.simulate_mouse_down(position, MouseButton::Left, modifiers); + window.simulate_mouse_up(position, MouseButton::Left, modifiers); + assert_eq!(clipboard(&mut window).as_deref(), Some("https://example.com")); +}