Synced from monorepo

Changes:
- grok-shell: request workspaces:read/write OAuth2 scopes
- security: fix SSRF bypass via HTTP redirect in hook runner
- fix(grok-build): enterprise STT WSS URL + API-key voice bearer
- Harden identity-change purge and sync-marker invariants
- sandbox + workspace-server: delete the legacy ready-file arm
- Show billing URL when browser cannot open
- fix(pager): show folder-trust UI in minimal mode
- fix(pager): drain task_backgrounded before no-wait headless exit
- grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt
- Split settings_modal into directory module
- Delegate VS Code SSH file links
- grok-shell: release the workspace session binding when a session is removed
- keep skills reachable when their name collides with a client builtin
- Preserve semantic link targets
This commit is contained in:
grokkybara[bot] 2026-07-16 20:27:30 +01:00
commit 8adf9013a0
117 changed files with 16998 additions and 14540 deletions

View file

@ -420,7 +420,7 @@ pub struct ClipboardTextReadError;
/// Read CLIPBOARD text while distinguishing emptiness from failure.
pub fn system_clipboard_read_text() -> Result<Option<String>, ClipboardTextReadError> {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(text) = test_support::hook_text_result() {
return text;
}
@ -438,7 +438,7 @@ pub fn system_clipboard_get() -> Option<String> {
/// Read X11 PRIMARY text for an unmodified Linux middle-button press.
#[cfg(target_os = "linux")]
pub fn system_primary_selection_get() -> Option<String> {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(available) = test_support::hook_x11_primary_available() {
if !available {
return None;
@ -705,7 +705,7 @@ pub fn system_clipboard_probe_attachments(
if !attachment_probe_would_run(clipboard_text) {
return Ok((None, None));
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(canned) = test_support::hook_attachments() {
return canned;
}
@ -786,7 +786,7 @@ pub use xai_grok_shared::clipboard::ImageData;
/// single native pass (macOS native, sub-millisecond, no data read). `(None,
/// false)` off-macOS or when AppKit cannot be loaded.
pub fn clipboard_image_snapshot() -> (Option<u64>, bool) {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(snapshot) = test_support::hook_image_snapshot() {
return snapshot;
}
@ -799,7 +799,7 @@ pub fn clipboard_image_snapshot() -> (Option<u64>, bool) {
/// [`clipboard_image_snapshot`] classification. `None` off-macOS.
pub fn clipboard_change_count() -> Option<u64> {
// Seam consistency: a hooked snapshot's change_count is the changeCount.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some((change_count, _)) = test_support::hook_image_snapshot() {
return change_count;
}
@ -809,7 +809,7 @@ pub fn clipboard_change_count() -> Option<u64> {
/// Whether the fast image probe exists on this platform. Gates the
/// focus-driven clipboard-image tip so non-macOS never probes.
pub fn clipboard_image_probe_supported() -> bool {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(supported) = test_support::hook_image_probe_supported() {
return supported;
}
@ -880,7 +880,7 @@ pub fn system_clipboard_get_image() -> Option<ImageData> {
/// (the off-thread probe reads the REAL pasteboard there), so tests exercise
/// deferral by asserting the enqueued effect and then driving
/// `complete_clipboard_attachment_paste` directly with a canned outcome.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use super::{ClipboardProbeError, ClipboardTextReadError, ImageData};
use std::cell::{Cell, RefCell};
@ -1039,9 +1039,9 @@ pub mod test_support {
}
}
#[cfg(all(test, target_os = "linux"))]
#[cfg(all(any(test, feature = "test-support"), target_os = "linux"))]
pub use test_support::primary_selection_read_call_count;
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub use test_support::{
ClipboardProbeHook, clear_clipboard_probe_hook, clipboard_probe_call_count,
set_clipboard_probe_hook,

View file

@ -404,7 +404,7 @@ impl Game {
/// Whether any movement control is currently held (latched or within
/// the repeat-bridging window). Used to assert hold-clearing behavior.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn any_held(&self) -> bool {
self.hold.iter().any(|&h| h > 0.0)
}

View file

@ -276,7 +276,7 @@ impl GboomState {
/// Whether the game currently holds a latched movement control. Lets the
/// app layer assert that backgrounded games drop their holds.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn any_movement_held(&self) -> bool {
self.game.any_held()
}

View file

@ -4,17 +4,66 @@
//! code path (keyboard navigation, mouse click, action dispatch) can
//! open a link safely without duplicating platform-specific logic.
use std::collections::HashMap;
use crate::terminal::hyperlinks::SchemeFilter;
/// Outcome of attempting to open a URL in the system browser/handler.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenUrlResult {
/// Opener was launched (or the test seam recorded the URL).
Opened,
/// Scheme was rejected by the safety filter.
RejectedScheme,
/// Browser cannot run here (headless / no display) or the opener
/// failed to spawn. Callers should surface the URL for manual open.
BrowserUnavailable,
}
/// Whether the environment looks capable of opening a GUI browser.
///
/// Pure helper for tests. On Linux/BSD, requires a non-empty `DISPLAY` or
/// `WAYLAND_DISPLAY` (or a non-empty `BROWSER` override). macOS/Windows
/// are treated as available at the env level (spawn failure is still
/// reported by [`open_url`]).
pub fn browser_open_likely_available_from_env(env: &HashMap<String, String>) -> bool {
if cfg!(any(target_os = "macos", target_os = "windows")) {
return true;
}
// Explicit BROWSER override: allow even without a display server so
// scripted/headless setups that point at a CLI browser still try.
if env.get("BROWSER").is_some_and(|v| !v.is_empty()) {
return true;
}
env.get("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
|| env.get("DISPLAY").is_some_and(|v| !v.is_empty())
}
/// Whether this process likely has a GUI browser available right now.
pub fn browser_open_likely_available() -> bool {
let env = crate::host::collect_unicode_env();
browser_open_likely_available_from_env(&env)
}
/// User-facing copy when the browser opener cannot run. Includes the full
/// URL on its own line so it is easy to select/copy in the TUI.
pub fn browser_unavailable_message(url: &str) -> String {
format!("Could not open a browser. Open this URL manually:\n{url}")
}
/// Open a URL in the system's default browser/handler.
///
/// Spawns the platform-native opener (`open` on macOS, `xdg-open` on
/// Linux, `cmd /c start` on Windows) with fully detached stdio so it
/// cannot block the pager.
///
/// Returns `true` when the opener was launched (or the test seam recorded
/// the URL). Returns `false` when the environment looks headless or spawn
/// fails — callers should show [`browser_unavailable_message`].
///
/// **Callers handling untrusted input** should call [`is_safe_to_open`]
/// first, or use [`open_url_if_safe`] which combines both steps.
pub fn open_url(url: &str) {
/// first, or use [`open_url_if_safe`] / [`try_open_url`] which combine both.
pub fn open_url(url: &str) -> bool {
// Test seam: PTY e2e must observe the open without launching a real
// browser. When set, append the URL to the file and skip the OS opener.
if let Ok(path) = std::env::var("GROK_TEST_OPEN_URL_FILE") {
@ -28,8 +77,17 @@ pub fn open_url(url: &str) {
.and_then(|mut f| writeln!(f, "{url}"))
{
tracing::warn!(error = %e, path, "GROK_TEST_OPEN_URL_FILE write failed");
return false;
}
return;
return true;
}
// Skip the doomed spawn on headless Linux VMs (no DISPLAY / Wayland)
// so billing Upgrade / Buy-credits clicks can fall back to showing the
// URL instead of silently no-op'ing.
if !browser_open_likely_available() {
tracing::info!("skipping browser open: no display server / BROWSER");
return false;
}
#[cfg(target_os = "macos")]
@ -48,16 +106,20 @@ pub fn open_url(url: &str) {
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_grok_tools::util::detach_std_command(&mut command);
if let Err(e) = command.spawn() {
// Redact URL to avoid leaking sensitive query params to logs.
let redacted = url::Url::parse(url)
.map(|mut u| {
u.set_query(None);
u.set_fragment(None);
u.to_string()
})
.unwrap_or_else(|_| "<unparseable>".to_string());
tracing::warn!(url = %redacted, error = %e, "failed to open URL");
match command.spawn() {
Ok(_) => true,
Err(e) => {
// Redact URL to avoid leaking sensitive query params to logs.
let redacted = url::Url::parse(url)
.map(|mut u| {
u.set_query(None);
u.set_fragment(None);
u.to_string()
})
.unwrap_or_else(|_| "<unparseable>".to_string());
tracing::warn!(url = %redacted, error = %e, "failed to open URL");
false
}
}
}
@ -199,14 +261,26 @@ pub fn is_safe_to_open(url: &str, filter: SchemeFilter) -> bool {
false
}
/// Validate scheme and open a URL if permitted. Returns `true` if opened.
/// Validate scheme and open a URL if permitted.
///
/// Returns `true` only when the scheme is allowed **and** the opener was
/// launched. Distinguishes scheme rejection from browser unavailability
/// via [`try_open_url`].
pub fn open_url_if_safe(url: &str, filter: SchemeFilter) -> bool {
if is_safe_to_open(url, filter) {
open_url(url);
true
} else {
matches!(try_open_url(url, filter), OpenUrlResult::Opened)
}
/// Validate scheme and attempt to open. Prefer this when the caller needs
/// to show a manual-URL fallback on [`OpenUrlResult::BrowserUnavailable`].
pub fn try_open_url(url: &str, filter: SchemeFilter) -> OpenUrlResult {
if !is_safe_to_open(url, filter) {
tracing::debug!(url, "URL scheme not permitted");
false
return OpenUrlResult::RejectedScheme;
}
if open_url(url) {
OpenUrlResult::Opened
} else {
OpenUrlResult::BrowserUnavailable
}
}
@ -446,4 +520,67 @@ mod tests {
SchemeFilter::Standard
));
}
fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn browser_available_with_x11_display() {
assert!(browser_open_likely_available_from_env(&env(&[(
"DISPLAY", ":0"
)])));
}
#[test]
fn browser_available_with_wayland() {
assert!(browser_open_likely_available_from_env(&env(&[(
"WAYLAND_DISPLAY",
"wayland-0"
)])));
}
#[test]
fn browser_available_with_browser_env_override() {
// Headless boxes can still open via BROWSER=… even without DISPLAY.
assert!(browser_open_likely_available_from_env(&env(&[(
"BROWSER", "firefox"
)])));
}
#[test]
fn browser_unavailable_when_display_vars_empty_or_missing() {
if cfg!(any(target_os = "macos", target_os = "windows")) {
// Desktop OSes do not gate on DISPLAY.
assert!(browser_open_likely_available_from_env(&env(&[])));
return;
}
assert!(!browser_open_likely_available_from_env(&env(&[])));
assert!(!browser_open_likely_available_from_env(&env(&[
("DISPLAY", ""),
("WAYLAND_DISPLAY", ""),
("BROWSER", ""),
])));
}
#[test]
fn browser_unavailable_message_includes_full_url() {
let url = "https://grok.com/supergrok?referrer=grok-build";
let msg = browser_unavailable_message(url);
assert!(msg.contains("Could not open a browser"));
assert!(msg.contains(url));
// URL on its own line for easy select/copy in the TUI.
assert!(msg.lines().any(|l| l == url));
}
#[test]
fn try_open_url_rejects_unsafe_scheme_without_opening() {
assert_eq!(
try_open_url("javascript:alert(1)", SchemeFilter::Standard),
OpenUrlResult::RejectedScheme
);
}
}

View file

@ -703,7 +703,7 @@ impl PromptImagePreview {
self.finish(PromptImagePreviewResult::Failed);
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn ready_for_test(bytes: Vec<u8>, dimensions: (u32, u32)) -> Self {
let preview = Self::default();
preview.finish(PromptImagePreviewResult::Ready {

View file

@ -13,13 +13,86 @@ use linkify::{LinkFinder, LinkKind};
use ratatui::text::Line;
use unicode_width::UnicodeWidthStr;
/// Semantic destination of a pager link.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkTarget {
Url(Arc<str>),
File(Arc<Path>),
}
/// Whether the painted text can independently identify its semantic target.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LinkPresentation {
#[default]
Opaque,
SelfResolvingPath,
}
/// Output and activation policy for a semantic link target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLinkTarget {
/// Terminal-owned OSC 8 destination, or `None` when plain text owns discovery.
pub osc8_url: Option<Arc<str>>,
/// App-owned activation target, or `None` when activation is delegated.
pub open_target: Option<LinkTarget>,
}
/// Resolve one semantic target using the current terminal context.
pub fn resolve_link_target(target: &LinkTarget) -> Option<ResolvedLinkTarget> {
resolve_link_target_with_presentation(target, LinkPresentation::Opaque)
}
pub fn resolve_link_target_with_presentation(
target: &LinkTarget,
presentation: LinkPresentation,
) -> Option<ResolvedLinkTarget> {
resolve_link_target_for_context(target, presentation, crate::terminal::terminal_context())
}
/// Resolve one semantic target for both OSC 8 output and app-owned activation.
pub fn resolve_link_target_for_context(
target: &LinkTarget,
presentation: LinkPresentation,
terminal: &crate::terminal::TerminalContext,
) -> Option<ResolvedLinkTarget> {
match target {
LinkTarget::Url(url) => {
let filter = crate::terminal::hyperlinks::SchemeFilter::Standard;
crate::link_opener::is_safe_to_open(url, filter).then(|| ResolvedLinkTarget {
osc8_url: Some(Arc::clone(url)),
open_target: Some(LinkTarget::Url(Arc::clone(url))),
})
}
LinkTarget::File(_)
if terminal.brand == crate::terminal::TerminalName::VsCode
&& terminal.is_official_vscode_remote
&& presentation == LinkPresentation::SelfResolvingPath =>
{
Some(ResolvedLinkTarget {
osc8_url: None,
open_target: None,
})
}
LinkTarget::File(path) => Some(ResolvedLinkTarget {
osc8_url: file_path_to_url(path),
open_target: Some(LinkTarget::File(Arc::clone(path))),
}),
}
}
/// Resolve the target for app-owned activation.
pub fn resolve_link_open_target(target: &LinkTarget) -> Option<LinkTarget> {
resolve_link_target(target).and_then(|resolved| resolved.open_target)
}
/// A single link region on screen.
#[derive(Debug, Clone)]
pub struct OverlayLink {
pub screen_row: u16,
pub col_start: u16,
pub col_end: u16,
pub url: Arc<str>,
pub target: LinkTarget,
pub presentation: LinkPresentation,
pub id: Option<u32>,
}
@ -154,10 +227,10 @@ fn quoted_file_path_regex() -> &'static regex::Regex {
})
}
/// Turn a display path (`/abs/…` or `~/…`) into a `file://` URL, expanding `~/`.
/// Relative paths fail — use [`tool_path_file_url`] to join cwd first.
pub fn path_to_file_url(path: &str) -> Option<Arc<str>> {
tool_path_file_url(path, None)
/// Turn a display path (`/abs/…` or `~/…`) into a semantic filesystem target.
/// Relative paths fail — use [`tool_path_file_target`] to join cwd first.
pub fn path_to_file_target(path: &str) -> Option<LinkTarget> {
tool_path_file_target(path, None)
}
fn file_path_to_url(path: &Path) -> Option<Arc<str>> {
@ -167,24 +240,76 @@ fn file_path_to_url(path: &Path) -> Option<Arc<str>> {
}
#[cfg(test)]
fn tool_path_file_url_with_home(
fn tool_path_file_target_with_home(
path: &str,
cwd: Option<&Path>,
home: Option<&Path>,
) -> Option<Arc<str>> {
) -> Option<LinkTarget> {
let target =
crate::render::tool_paths::resolve_tool_path_target_with_home(Path::new(path), cwd, home)?;
file_path_to_url(&target)
Some(LinkTarget::File(Arc::from(target)))
}
/// `file://` URL for a Read/Edit target, joining ordinary relative paths to `cwd`.
pub fn tool_path_file_url(path: &str, cwd: Option<&Path>) -> Option<Arc<str>> {
let target = crate::render::tool_paths::resolve_tool_path_target(path, cwd)?;
file_path_to_url(&target)
/// Semantic target for a Read/Edit path, joining ordinary relative paths to `cwd`.
pub fn tool_path_file_target(path: &str, cwd: Option<&Path>) -> Option<LinkTarget> {
crate::render::tool_paths::resolve_tool_path_target(path, cwd)
.map(|path| LinkTarget::File(Arc::from(path)))
}
/// Resolve a markdown link destination that names a local file into a `file://`
/// URL, so paths the model emits (`[videos/1.mp4](videos/1.mp4)`) open on click.
fn file_link_presentation_for_resolved(
painted: &str,
target: &LinkTarget,
cwd: Option<&Path>,
resolved: Option<&Path>,
) -> LinkPresentation {
let LinkTarget::File(target_path) = target else {
return LinkPresentation::Opaque;
};
let painted_path = Path::new(painted);
let is_absolute = painted_path.is_absolute()
|| matches!(
painted_path.components().next(),
Some(std::path::Component::Prefix(_))
);
let is_home_relative =
painted == "~" || painted.starts_with("~/") || painted.starts_with(r"~\");
if !is_absolute && !is_home_relative && (!painted.contains(['/', '\\']) || cwd.is_none()) {
return LinkPresentation::Opaque;
}
resolved
.filter(|resolved| *resolved == target_path.as_ref())
.map_or(LinkPresentation::Opaque, |_| {
LinkPresentation::SelfResolvingPath
})
}
/// Classify painted file text only when it independently resolves to `target`.
pub fn file_link_presentation(
painted: &str,
target: &LinkTarget,
cwd: Option<&Path>,
) -> LinkPresentation {
let resolved = crate::render::tool_paths::resolve_tool_path_target(painted, cwd);
file_link_presentation_for_resolved(painted, target, cwd, resolved.as_deref())
}
#[cfg(test)]
fn file_link_presentation_with_home(
painted: &str,
target: &LinkTarget,
cwd: Option<&Path>,
home: Option<&Path>,
) -> LinkPresentation {
let resolved = crate::render::tool_paths::resolve_tool_path_target_with_home(
Path::new(painted),
cwd,
home,
);
file_link_presentation_for_resolved(painted, target, cwd, resolved.as_deref())
}
/// Resolve a markdown link destination that names a local file into a semantic
/// filesystem target, so model paths (`[videos/1.mp4](videos/1.mp4)`) open on click.
///
/// Web/scheme URLs, `mailto:`/`tel:`, and anchors return `None`.
///
@ -195,7 +320,7 @@ pub fn tool_path_file_url(path: &str, cwd: Option<&Path>) -> Option<Arc<str>> {
/// each short path to the exact file its message produced (correct across
/// forks/resumes) and never opens an arbitrary or out-of-session file; an
/// ambiguous or absent match is left unlinked.
pub fn local_link_to_file_url(dest: &str, media_paths: &[PathBuf]) -> Option<Arc<str>> {
pub fn local_link_to_file_target(dest: &str, media_paths: &[PathBuf]) -> Option<LinkTarget> {
let dest = dest.trim();
if dest.is_empty() || dest.starts_with('#') || dest.contains("://") {
return None;
@ -222,9 +347,7 @@ pub fn local_link_to_file_url(dest: &str, media_paths: &[PathBuf]) -> Option<Arc
if !resolved.is_file() {
return None;
}
url::Url::from_file_path(&resolved)
.ok()
.map(|u| Arc::from(u.as_str()))
Some(LinkTarget::File(Arc::from(resolved)))
}
/// Convert a display-cell column to a `u16` suitable for overlay coordinates.
@ -316,7 +439,8 @@ fn push_link_segments(
rows: &[RowSegment],
content_x: u16,
match_range: std::ops::Range<usize>,
url: &Arc<str>,
target: &LinkTarget,
presentation: LinkPresentation,
overlay: &mut LinkOverlay,
) -> bool {
let mut segments: Vec<(u16, u16, u16)> = Vec::new();
@ -349,7 +473,8 @@ fn push_link_segments(
screen_row,
col_start,
col_end,
url: Arc::clone(url),
target: target.clone(),
presentation,
id: None,
});
}
@ -390,13 +515,14 @@ fn scan_logical_line(
.get_or_insert_with(Vec::new)
.push(link.start()..link.end());
let url: Arc<str> = Arc::from(url);
let target = LinkTarget::Url(Arc::from(url));
push_link_segments(
text,
rows,
content_x,
link.start()..link.end(),
&url,
&target,
LinkPresentation::Opaque,
overlay,
);
}
@ -419,7 +545,7 @@ fn scan_logical_line(
if range_overlaps_urls(path_m.start(), path_m.end()) {
continue;
}
let Some(file_url) = path_to_file_url(path_m.as_str()) else {
let Some(file_target) = path_to_file_target(path_m.as_str()) else {
continue;
};
@ -429,7 +555,8 @@ fn scan_logical_line(
rows,
content_x,
path_m.start()..path_m.end(),
&file_url,
&file_target,
file_link_presentation(path_m.as_str(), &file_target, None),
overlay,
) {
path_byte_ranges.push(path_m.start()..path_m.end());
@ -462,7 +589,7 @@ fn scan_logical_line(
continue;
}
let path_end = m.start() + path.len();
let Some(file_url) = path_to_file_url(path) else {
let Some(file_target) = path_to_file_target(path) else {
continue;
};
@ -471,7 +598,8 @@ fn scan_logical_line(
rows,
content_x,
m.start()..path_end,
&file_url,
&file_target,
file_link_presentation(path, &file_target, None),
overlay,
) {
path_byte_ranges.push(m.start()..path_end);
@ -503,7 +631,7 @@ fn scan_logical_line(
let path = m
.as_str()
.trim_end_matches(['.', ',', ';', ':', '!', '?', ')']);
let Some(file_url) = local_link_to_file_url(path, media_paths) else {
let Some(file_target) = local_link_to_file_target(path, media_paths) else {
continue;
};
let path_end = m.start() + path.len();
@ -513,7 +641,8 @@ fn scan_logical_line(
rows,
content_x,
m.start()..path_end,
&file_url,
&file_target,
LinkPresentation::Opaque,
overlay,
) {
path_byte_ranges.push(m.start()..path_end);
@ -541,7 +670,7 @@ mod tests {
scan_lines_for_url_overlays(rows.into_iter(), content_x, media_paths, overlay);
}
// ── local_link_to_file_url ──
// ── local_link_to_file_target ──
#[test]
fn local_link_relative_resolves_to_generated_media() {
@ -551,7 +680,10 @@ mod tests {
let media = vec![dir.path().join("images/1.jpg")];
// Short session-relative path matches the generated media by suffix.
let url = local_link_to_file_url("images/1.jpg", &media).unwrap();
let target = local_link_to_file_target("images/1.jpg", &media).unwrap();
assert_eq!(target, LinkTarget::File(Arc::from(media[0].as_path())));
let resolved = resolve_link_target(&target).expect("resolved target");
let url = resolved.osc8_url.expect("OSC 8 URL");
assert!(
url.starts_with("file://") && url.ends_with("/images/1.jpg"),
"got {url}"
@ -565,13 +697,13 @@ mod tests {
std::fs::write(dir.path().join("images/1.jpg"), b"x").unwrap();
let media = vec![dir.path().join("images/1.jpg")];
assert!(local_link_to_file_url("https://x.ai", &media).is_none());
assert!(local_link_to_file_url("mailto:a@b.c", &media).is_none());
assert!(local_link_to_file_url("#section", &media).is_none());
assert!(local_link_to_file_target("https://x.ai", &media).is_none());
assert!(local_link_to_file_target("mailto:a@b.c", &media).is_none());
assert!(local_link_to_file_target("#section", &media).is_none());
// Relative path that isn't a known generated media file.
assert!(local_link_to_file_url("images/2.jpg", &media).is_none());
assert!(local_link_to_file_target("images/2.jpg", &media).is_none());
// No known media at all.
assert!(local_link_to_file_url("images/1.jpg", &[]).is_none());
assert!(local_link_to_file_target("images/1.jpg", &[]).is_none());
}
#[test]
@ -587,49 +719,82 @@ mod tests {
dir.path().join("a/images/1.jpg"),
dir.path().join("b/images/1.jpg"),
];
assert!(local_link_to_file_url("images/1.jpg", &media).is_none());
assert!(local_link_to_file_target("images/1.jpg", &media).is_none());
// A `..` never matches a clean absolute media path, so it can't escape.
assert!(local_link_to_file_url("../images/1.jpg", &media).is_none());
assert!(local_link_to_file_target("../images/1.jpg", &media).is_none());
}
// ── tool_path_file_url ──
// ── tool_path_file_target ──
#[test]
fn tool_path_file_url_resolves_relative_against_cwd() {
fn tool_path_file_target_resolves_relative_against_cwd() {
let cwd = Path::new("/Users/me/project");
let url = tool_path_file_url("src/main.rs", Some(cwd)).expect("url");
assert!(url.starts_with("file://"), "got {url}");
assert!(url.contains("/Users/me/project/src/main.rs"), "got {url}");
let target = tool_path_file_target("src/main.rs", Some(cwd)).expect("target");
assert_eq!(
target,
LinkTarget::File(Arc::from(Path::new("/Users/me/project/src/main.rs")))
);
assert_eq!(
resolve_link_target(&target)
.unwrap()
.osc8_url
.unwrap()
.as_ref(),
"file:///Users/me/project/src/main.rs"
);
}
#[test]
fn tool_path_file_url_accepts_absolute_without_existing_file() {
let url = tool_path_file_url("/tmp/does-not-exist-xyz/foo.rs", None).expect("url");
assert!(url.starts_with("file://"), "got {url}");
assert!(url.contains("foo.rs"), "got {url}");
fn tool_path_file_target_accepts_absolute_without_existing_file() {
let target = tool_path_file_target("/tmp/does-not-exist-xyz/foo.rs", None).expect("target");
assert_eq!(
target,
LinkTarget::File(Arc::from(Path::new("/tmp/does-not-exist-xyz/foo.rs")))
);
assert!(
resolve_link_target(&target)
.unwrap()
.osc8_url
.unwrap()
.contains("foo.rs")
);
}
#[test]
fn tool_path_file_url_preserves_parent_segments_for_os_resolution() {
let url = tool_path_file_url("/repo/link/../target.rs", None).expect("url");
assert!(url.contains("/repo/link/../target.rs"), "got {url}");
fn tool_path_file_target_preserves_parent_segments_for_os_resolution() {
let target = tool_path_file_target("/repo/link/../target.rs", None).expect("target");
let LinkTarget::File(path) = target else {
panic!("expected file target");
};
assert_eq!(&*path, Path::new("/repo/link/../target.rs"));
assert!(
file_path_to_url(&path)
.unwrap()
.contains("/repo/link/../target.rs")
);
}
#[test]
fn unresolved_tilde_never_manufactures_a_cwd_file_url() {
assert!(
tool_path_file_url_with_home("~/target.rs", Some(Path::new("/repo")), None).is_none()
tool_path_file_target_with_home("~/target.rs", Some(Path::new("/repo")), None)
.is_none()
);
}
#[cfg(unix)]
#[test]
fn tool_path_file_url_preserves_non_utf8_cwd_bytes() {
fn tool_path_file_target_preserves_non_utf8_cwd_bytes() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let cwd = PathBuf::from(OsString::from_vec(b"/tmp/non-utf8-\x80".to_vec()));
let url = tool_path_file_url("main.rs", Some(&cwd)).expect("url");
let target = tool_path_file_target("main.rs", Some(&cwd)).expect("target");
let LinkTarget::File(path) = &target else {
panic!("expected file target");
};
assert_eq!(path.as_os_str().as_bytes(), b"/tmp/non-utf8-\x80/main.rs");
let url = resolve_link_target(&target).unwrap().osc8_url.unwrap();
assert!(url.contains("/tmp/non-utf8-%80/main.rs"), "got {url}");
assert!(
!url.contains("%EF%BF%BD"),
@ -639,6 +804,205 @@ mod tests {
// ── LinkOverlay ──
#[test]
fn resolve_target_keeps_standard_scheme_filter_and_file_open_path() {
let web = LinkTarget::Url(Arc::from("https://example.com/a"));
assert_eq!(
resolve_link_target(&web).unwrap(),
ResolvedLinkTarget {
osc8_url: Some(Arc::from("https://example.com/a")),
open_target: Some(web.clone()),
}
);
assert_eq!(resolve_link_open_target(&web), Some(web));
let unsafe_url = LinkTarget::Url(Arc::from("javascript:alert(1)"));
assert!(resolve_link_target(&unsafe_url).is_none());
assert!(resolve_link_open_target(&unsafe_url).is_none());
let file = LinkTarget::File(Arc::from(Path::new("/tmp/a b.rs")));
assert_eq!(
resolve_link_target(&file).unwrap(),
ResolvedLinkTarget {
osc8_url: Some(Arc::from("file:///tmp/a%20b.rs")),
open_target: Some(file.clone()),
}
);
assert_eq!(resolve_link_open_target(&file), Some(file));
}
#[test]
fn official_vscode_remote_file_delegation_is_exact() {
use crate::terminal::{TerminalContext, TerminalName};
struct Case {
name: &'static str,
terminal: TerminalContext,
target: LinkTarget,
presentation: LinkPresentation,
expected_osc8: Option<&'static str>,
expected_open: bool,
}
let file = LinkTarget::File(Arc::from(Path::new("/worktree/src/main.rs")));
let web = LinkTarget::Url(Arc::from("https://example.com/docs"));
let official_remote = TerminalContext {
brand: TerminalName::VsCode,
is_ssh: true,
is_official_vscode_remote: true,
..Default::default()
};
let cases = [
Case {
name: "local VS Code file",
terminal: TerminalContext {
brand: TerminalName::VsCode,
..Default::default()
},
target: file.clone(),
presentation: LinkPresentation::SelfResolvingPath,
expected_osc8: Some("file:///worktree/src/main.rs"),
expected_open: true,
},
Case {
name: "official VS Code SSH self-resolving file",
terminal: official_remote.clone(),
target: file.clone(),
presentation: LinkPresentation::SelfResolvingPath,
expected_osc8: None,
expected_open: false,
},
Case {
name: "official VS Code SSH opaque file",
terminal: official_remote.clone(),
target: file.clone(),
presentation: LinkPresentation::Opaque,
expected_osc8: Some("file:///worktree/src/main.rs"),
expected_open: true,
},
Case {
name: "unproven VS Code SSH file",
terminal: TerminalContext {
brand: TerminalName::VsCode,
is_ssh: true,
..Default::default()
},
target: file.clone(),
presentation: LinkPresentation::SelfResolvingPath,
expected_osc8: Some("file:///worktree/src/main.rs"),
expected_open: true,
},
Case {
name: "official VS Code SSH web",
terminal: official_remote,
target: web,
presentation: LinkPresentation::Opaque,
expected_osc8: Some("https://example.com/docs"),
expected_open: true,
},
Case {
name: "Cursor SSH file",
terminal: TerminalContext {
brand: TerminalName::Cursor,
is_ssh: true,
..Default::default()
},
target: file.clone(),
presentation: LinkPresentation::SelfResolvingPath,
expected_osc8: Some("file:///worktree/src/main.rs"),
expected_open: true,
},
Case {
name: "Kitty SSH file",
terminal: TerminalContext {
brand: TerminalName::Kitty,
is_ssh: true,
..Default::default()
},
target: file,
presentation: LinkPresentation::SelfResolvingPath,
expected_osc8: Some("file:///worktree/src/main.rs"),
expected_open: true,
},
];
for case in cases {
let resolved =
resolve_link_target_for_context(&case.target, case.presentation, &case.terminal)
.unwrap_or_else(|| panic!("{} should resolve", case.name));
assert_eq!(
resolved.osc8_url.as_deref(),
case.expected_osc8,
"{} OSC 8 policy",
case.name
);
assert_eq!(
resolved.open_target.is_some(),
case.expected_open,
"{} activation policy",
case.name
);
}
}
#[test]
fn file_presentation_requires_exact_path_shaped_resolution() {
let target = LinkTarget::File(Arc::from(Path::new("/worktree/src/main.rs")));
let cwd = Path::new("/worktree");
assert_eq!(
file_link_presentation("/worktree/src/main.rs", &target, Some(cwd)),
LinkPresentation::SelfResolvingPath
);
assert_eq!(
file_link_presentation("src/main.rs", &target, Some(cwd)),
LinkPresentation::SelfResolvingPath
);
assert_eq!(
file_link_presentation("src/main.rs", &target, None),
LinkPresentation::Opaque
);
let home = Path::new("/home/me");
let home_target = LinkTarget::File(Arc::from(home.join("src/main.rs")));
assert_eq!(
file_link_presentation_with_home("~/src/main.rs", &home_target, None, Some(home)),
LinkPresentation::SelfResolvingPath
);
assert_eq!(
file_link_presentation_with_home("~/src/main.rs", &home_target, None, None),
LinkPresentation::Opaque
);
assert_eq!(
file_link_presentation_with_home("~/src/other.rs", &home_target, None, Some(home)),
LinkPresentation::Opaque
);
for painted in [
"main.rs",
"main\u{2026}",
"src/other.rs",
"\u{2026}/src/main.rs",
"src/main.rs (1 of 2)",
] {
assert_eq!(
file_link_presentation(painted, &target, Some(cwd)),
LinkPresentation::Opaque,
"{painted}"
);
}
}
#[test]
fn open_target_preserves_a_relative_file_that_cannot_be_encoded_for_osc8() {
let file = LinkTarget::File(Arc::from(Path::new("relative.rs")));
assert_eq!(
resolve_link_target(&file),
Some(ResolvedLinkTarget {
osc8_url: None,
open_target: Some(file.clone()),
})
);
assert_eq!(resolve_link_open_target(&file), Some(file));
}
#[test]
fn overlay_empty_by_default() {
let overlay = LinkOverlay::new();
@ -653,7 +1017,8 @@ mod tests {
screen_row: 5,
col_start: 10,
col_end: 20,
url: "https://example.com".into(),
target: LinkTarget::Url("https://example.com".into()),
presentation: LinkPresentation::Opaque,
id: Some(1),
});
assert!(!overlay.is_empty());
@ -686,7 +1051,12 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
let link = &overlay.links()[0];
assert_eq!(&*link.url, "https://example.com");
assert_eq!(
&*resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://example.com"
);
assert_eq!(link.screen_row, 5);
// "See " = 4 display cols, content_x = 2
assert_eq!(link.col_start, 6);
@ -701,8 +1071,18 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 2);
assert_eq!(&*overlay.links()[0].url, "https://a.example");
assert_eq!(&*overlay.links()[1].url, "https://b.example");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://a.example"
);
assert_eq!(
&*resolve_link_target(&overlay.links()[1].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://b.example"
);
assert!(overlay.links()[0].col_end <= overlay.links()[1].col_start);
}
@ -717,7 +1097,12 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "https://example.com");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://example.com"
);
// "Visit " = 6 display cols (in first span)
// The URL is in its own span, so col_start = 6
assert_eq!(overlay.links()[0].col_start, 6);
@ -765,7 +1150,9 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
assert_eq!(
&*overlay.links()[0].url,
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://example.com",
"trailing dot should be excluded by linkify"
);
@ -779,7 +1166,9 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
assert_eq!(
&*overlay.links()[0].url,
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://example.com/path?key=val#sec"
);
}
@ -804,7 +1193,12 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "file:///Users/foo/src/main.rs");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///Users/foo/src/main.rs"
);
}
#[test]
@ -828,7 +1222,9 @@ mod tests {
let mut overlay = LinkOverlay::new();
scan_unjoined(std::iter::once((0, &line)), 0, &media, &mut overlay);
assert_eq!(overlay.links().len(), 1, "{line_text}");
let url = &*overlay.links()[0].url;
let url = resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url");
assert!(
url.starts_with("file://") && url.ends_with(suffix),
"got {url}"
@ -865,7 +1261,9 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
assert_eq!(
&*overlay.links()[0].url,
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
// `%` is itself percent-encoded (`%25`) when building the file URL.
"file:///Users/alice/.grok/sessions/%252Fabc/00000000/images/1.jpg",
);
@ -889,7 +1287,12 @@ mod tests {
let expected_url = "file:///Users/alice/.grok/sessions/%252FUsers%252Fali\
ce%252Fcode%252Fxai/00000000-0000-0000-0000-000000000001/images/1.jpg";
for link in overlay.links() {
assert_eq!(&*link.url, expected_url);
assert_eq!(
&*resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
expected_url
);
}
// Row 0: path starts after the prose and runs to the row's end.
let prose = "Image generated and saved to ";
@ -928,7 +1331,9 @@ mod tests {
assert_eq!(overlay.links().len(), 2);
for link in overlay.links() {
assert_eq!(
&*link.url,
&*resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///Users/me/.grok/sessions/%252Fabc/019f3a86/images/1.jpg"
);
}
@ -958,9 +1363,9 @@ mod tests {
assert_eq!(overlay.links().len(), 2);
for link in overlay.links() {
assert!(
link.url.starts_with("file://") && link.url.ends_with("/images/1.png"),
resolve_link_target(&link.target).and_then(|resolved| resolved.osc8_url).is_some_and(|url| url.starts_with("file://") && url.ends_with("/images/1.png")),
"got {}",
link.url
resolve_link_target(&link.target).and_then(|resolved| resolved.osc8_url).expect("url")
);
}
}
@ -976,7 +1381,12 @@ mod tests {
assert_eq!(overlay.links().len(), 2);
for link in overlay.links() {
assert_eq!(&*link.url, "https://example.com/some/long/path?key=val");
assert_eq!(
&*resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://example.com/some/long/path?key=val"
);
}
}
@ -994,7 +1404,12 @@ mod tests {
assert_eq!(overlay.links().len(), 2);
for link in overlay.links() {
assert_eq!(&*link.url, "file:///tmp/release/Demo%20App.app");
assert_eq!(
&*resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///tmp/release/Demo%20App.app"
);
}
// Row 1's region covers only `App.app` (the joiner space belongs
// to no row).
@ -1017,7 +1432,12 @@ mod tests {
scan_lines_for_url_overlays(rows.into_iter(), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "file:///Users/alice");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///Users/alice"
);
assert_eq!(overlay.links()[0].screen_row, 0);
}
@ -1034,7 +1454,12 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "file:///Users/foo/images/1.jpg");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///Users/foo/images/1.jpg"
);
assert_eq!(
overlay.links()[0].col_start,
UnicodeWidthStr::width("Saved to ") as u16
@ -1049,7 +1474,9 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
assert_eq!(
&*overlay.links()[0].url,
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///Users/foo/bar.rs",
"colon-delimited line number should be excluded"
);
@ -1077,7 +1504,9 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
assert_eq!(
&*overlay.links()[0].url,
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"https://example.com/foo/bar",
"URL should be detected, not the path portion"
);
@ -1090,9 +1519,20 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 2);
let urls: Vec<&str> = overlay.links().iter().map(|l| &*l.url).collect();
assert!(urls.contains(&"https://docs.rs/foo"));
assert!(urls.contains(&"file:///Users/me/src/lib.rs"));
let urls: Vec<Arc<str>> = overlay
.links()
.iter()
.map(|l| {
resolve_link_target(&l.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url")
})
.collect();
assert!(urls.iter().any(|url| url.as_ref() == "https://docs.rs/foo"));
assert!(
urls.iter()
.any(|url| url.as_ref() == "file:///Users/me/src/lib.rs")
);
}
#[test]
@ -1102,7 +1542,12 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "file:///tmp/grok-impl-summary.md");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///tmp/grok-impl-summary.md"
);
}
#[test]
@ -1113,7 +1558,9 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
assert_eq!(
&*overlay.links()[0].url,
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///node_modules/@scope/package/index.js"
);
}
@ -1134,7 +1581,10 @@ mod tests {
);
let link = &overlay.links()[0];
assert_eq!(
&*link.url, "file:///Users/alice/src/app/release/mac-arm64/Demo%20App.app",
&*resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///Users/alice/src/app/release/mac-arm64/Demo%20App.app",
"space must be percent-encoded in the file URL"
);
// Clickable region must cover the *entire* displayed path, including
@ -1158,7 +1608,9 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
assert_eq!(
&*overlay.links()[0].url,
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///tmp/release/Demo%20App.app"
);
assert_eq!(overlay.links()[0].col_start, 5); // "open "
@ -1177,7 +1629,12 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "file:///tmp/foo/bar");
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
"file:///tmp/foo/bar"
);
// "See " = 4 cols; path is 12 cols (`/tmp/foo/bar`).
assert_eq!(overlay.links()[0].col_start, 4);
assert_eq!(overlay.links()[0].col_end, 4 + 12);
@ -1202,9 +1659,12 @@ mod tests {
assert_eq!(overlay.links().len(), 1);
let link = &overlay.links()[0];
// `~` is expanded to the home directory in the file URL.
assert_eq!(&*link.url, expected.as_str());
assert!(link.url.starts_with("file:///"));
assert!(!link.url.contains('~'), "tilde must be expanded in the URL");
let url = resolve_link_target(&link.target)
.and_then(|resolved| resolved.osc8_url)
.expect("url");
assert_eq!(&*url, expected.as_str());
assert!(url.starts_with("file:///"));
assert!(!url.contains('~'), "tilde must be expanded in the URL");
// The clickable region covers the displayed `~/…` text, tilde included.
// "Findings report " = 16 display cols.
assert_eq!(link.col_start, 16);
@ -1222,7 +1682,12 @@ mod tests {
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, expected.as_str());
assert_eq!(
&*resolve_link_target(&overlay.links()[0].target)
.and_then(|resolved| resolved.osc8_url)
.expect("url"),
expected.as_str()
);
assert_eq!(overlay.links()[0].col_start, 0);
}
@ -1272,7 +1737,8 @@ mod tests {
screen_row: 5,
col_start: 10,
col_end: 20,
url: Arc::from("https://a.example"),
target: LinkTarget::Url(Arc::from("https://a.example")),
presentation: LinkPresentation::Opaque,
id: None,
});
assert!(overlay.overlaps(5, 10, 20));
@ -1286,7 +1752,8 @@ mod tests {
screen_row: 0,
col_start: 10,
col_end: 20,
url: Arc::from("https://a.example"),
target: LinkTarget::Url(Arc::from("https://a.example")),
presentation: LinkPresentation::Opaque,
id: None,
});
assert!(overlay.overlaps(0, 15, 25)); // right overlap
@ -1305,7 +1772,8 @@ mod tests {
screen_row: 0,
col_start: 4,
col_end: 23,
url: Arc::from("https://example.com"),
target: LinkTarget::Url(Arc::from("https://example.com")),
presentation: LinkPresentation::Opaque,
id: None,
});
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
@ -1322,7 +1790,8 @@ mod tests {
screen_row: 0,
col_start: 50,
col_end: 70,
url: Arc::from("https://first.example"),
target: LinkTarget::Url(Arc::from("https://first.example")),
presentation: LinkPresentation::Opaque,
id: None,
});
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);
@ -1339,7 +1808,8 @@ mod tests {
screen_row: 0,
col_start: 9,
col_end: 31,
url: Arc::from("file:///Users/foo/src/main.rs"),
target: LinkTarget::File(Arc::from(Path::new("/Users/foo/src/main.rs"))),
presentation: LinkPresentation::Opaque,
id: None,
});
scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay);

View file

@ -75,7 +75,7 @@ pub fn scrollback_inline_overlay_forced_off() -> bool {
INLINE_OVERLAY_FORCE_OFF.load(Ordering::Relaxed)
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// Per-test override so tests don't depend on the host terminal or the
/// process-wide `GRAPHICS_PROTOCOL` cache.
@ -85,7 +85,7 @@ thread_local! {
/// Detect and cache the graphics protocol for the current terminal.
pub fn detect_graphics_protocol() -> GraphicsProtocol {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(p) = TEST_PROTOCOL_OVERRIDE.with(|c| c.get()) {
return p;
}
@ -119,12 +119,12 @@ pub fn scrollback_inline_overlay_active() -> bool {
scrollback_inline_overlay_active_for_brand(protocol, terminal_context().brand)
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
fn test_protocol_override_active() -> bool {
TEST_PROTOCOL_OVERRIDE.with(|c| c.get().is_some())
}
#[cfg(not(test))]
#[cfg(not(any(test, feature = "test-support")))]
fn test_protocol_override_active() -> bool {
false
}
@ -145,17 +145,17 @@ fn scrollback_inline_overlay_active_for_brand(
/// Set a per-thread protocol override for tests. Returns a guard that
/// clears it on drop.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_protocol_for_test(p: GraphicsProtocol) -> TestProtocolGuard {
TEST_PROTOCOL_OVERRIDE.with(|c| c.set(Some(p)));
TestProtocolGuard
}
/// RAII guard that clears the test protocol override on drop.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub struct TestProtocolGuard;
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
impl Drop for TestProtocolGuard {
fn drop(&mut self) {
TEST_PROTOCOL_OVERRIDE.with(|c| c.set(None));

View file

@ -49,7 +49,7 @@ impl ModifierDelivery {
/// Construct a delivery from explicit fates. `#[non_exhaustive]` blocks
/// struct-literal construction from other crates, so downstream test
/// builds use this constructor.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn new_for_test(cmd: ModifierFate, opt: ModifierFate) -> Self {
Self { cmd, opt }
}

View file

@ -282,6 +282,8 @@ pub struct TerminalContext {
pub tmux_meta: TmuxClientMeta,
/// Whether the session is inside a remote SSH connection.
pub is_ssh: bool,
/// Positive evidence that SSH is hosted by the official VS Code remote server.
pub is_official_vscode_remote: bool,
/// The raw `TERM` environment variable (e.g. `xterm-256color`, `screen`).
pub term_var: Option<String>,
/// The tmux server version (e.g. `"tmux 3.4"`), populated only when
@ -666,6 +668,16 @@ fn env_get<'a>(env: &'a HashMap<String, String>, key: &str) -> Option<&'a str> {
env.get(key).map(|v| v.as_str()).filter(|v| !v.is_empty())
}
fn is_official_vscode_remote_askpass(path: &str) -> bool {
std::path::Path::new(path).components().any(|component| {
matches!(
component,
std::path::Component::Normal(name)
if name == ".vscode-server" || name == ".vscode-server-insiders"
)
})
}
/// Detect the terminal brand from an injected environment map.
///
/// This is the pure equivalent of the original `detect_terminal_info`.
@ -935,6 +947,8 @@ pub fn build_terminal_context_from_env(env: &HashMap<String, String>) -> Termina
let is_ssh = env_get(env, "SSH_CONNECTION").is_some()
|| env_get(env, "SSH_TTY").is_some()
|| env_get(env, "SSH_CLIENT").is_some();
let is_official_vscode_remote = is_ssh
&& env_get(env, "VSCODE_GIT_ASKPASS_MAIN").is_some_and(is_official_vscode_remote_askpass);
let term_var = env_get(env, "TERM").map(|s| s.to_owned());
let vte_version = env_get(env, "VTE_VERSION").map(|s| s.to_owned());
// SSH strips TERM_PROGRAM_VERSION; iTerm2 LC_TERMINAL_VERSION survives.
@ -950,6 +964,7 @@ pub fn build_terminal_context_from_env(env: &HashMap<String, String>) -> Termina
embedded_editor,
tmux_meta,
is_ssh,
is_official_vscode_remote,
term_var,
tmux_version: None,
vte_version,

View file

@ -1054,6 +1054,52 @@ fn brand_vscode_from_askpass_without_term_program() {
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::VsCode);
}
#[test]
fn context_official_vscode_remote_from_askpass_and_ssh() {
for server_dir in [".vscode-server", ".vscode-server-insiders"] {
let askpass = format!("/home/user/{server_dir}/bin/abc/askpass");
let env = env_from(&[
("VSCODE_GIT_ASKPASS_MAIN", &askpass),
("SSH_CONNECTION", "192.0.2.1 50000 192.0.2.2 22"),
]);
let ctx = build_terminal_context_from_env(&env);
assert_eq!(ctx.brand, TerminalName::VsCode);
assert!(ctx.is_ssh);
assert!(ctx.is_official_vscode_remote, "{server_dir}");
}
}
#[test]
fn context_unofficial_vscode_remote_markers_are_not_official() {
for askpass in [
"/home/user/.vscode-server-oss/bin/abc/askpass",
"/home/user/.vscodium-server/bin/abc/askpass",
"/home/user/.code-oss-server/bin/abc/askpass",
"/home/user/cache/.vscode-server-oss/.vscode-serverish/askpass",
"/usr/local/bin/askpass-main.js",
] {
let env = env_from(&[
("VSCODE_GIT_ASKPASS_MAIN", askpass),
("SSH_CONNECTION", "192.0.2.1 50000 192.0.2.2 22"),
]);
let ctx = build_terminal_context_from_env(&env);
assert_eq!(ctx.brand, TerminalName::VsCode);
assert!(ctx.is_ssh);
assert!(!ctx.is_official_vscode_remote, "{askpass}");
}
}
#[test]
fn official_vscode_server_marker_without_ssh_is_not_remote() {
let env = env_from(&[(
"VSCODE_GIT_ASKPASS_MAIN",
"/home/user/.vscode-server/bin/abc/askpass",
)]);
let ctx = build_terminal_context_from_env(&env);
assert!(!ctx.is_ssh);
assert!(!ctx.is_official_vscode_remote);
}
// -- Zellij detection from ZELLIJ_VERSION (no ZELLIJ or SESSION_NAME) -----
#[test]

View file

@ -21,7 +21,7 @@ use super::system_appearance;
/// `load_from_disk()`, then kept in sync by `set()`.
static CURRENT: AtomicU8 = AtomicU8::new(ThemeKind::GrokNight as u8);
static LOADED: AtomicBool = AtomicBool::new(false);
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
static TEST_LOCK: Mutex<()> = Mutex::new(());
/// Whether auto-switching mode is active. Set when the config file
@ -270,7 +270,7 @@ fn load_auto_theme_config() -> AutoThemeConfig {
// -- Test support ------------------------------------------------------------
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn reset_for_test() {
// Tests are serialized via TEST_LOCK so the AtomicU8/AtomicBool
// pair is safe to reset without any cross-thread coordination.
@ -284,12 +284,12 @@ pub fn reset_for_test() {
/// Seed `AUTO_THEME_CONFIG` with explicit defaults so `auto_theme_config()`
/// never falls through to `load_auto_theme_config()` (which reads the
/// user's real `config.toml`). Call from test setup after `reset_for_test()`.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn seed_auto_theme_defaults_for_test() {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(AutoThemeConfig::default());
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn test_lock() -> &'static Mutex<()> {
&TEST_LOCK
}
@ -300,7 +300,7 @@ pub fn test_lock() -> &'static Mutex<()> {
/// `set_theme` tests mutate) and `Theme::current()` reads the global color
/// level; holding the shared test lock blocks a mid-test theme change. Hold the
/// returned guard for the whole test.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn pin_theme() -> std::sync::MutexGuard<'static, ()> {
let guard = test_lock().lock().unwrap_or_else(|e| e.into_inner());
set(ThemeKind::GrokNight);

View file

@ -36,7 +36,7 @@ pub enum SystemAppearance {
/// directly) is also controllable from tests.
#[must_use]
pub fn detect() -> Option<SystemAppearance> {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(v) = mock_override() {
return v;
}
@ -56,7 +56,7 @@ pub fn detect() -> Option<SystemAppearance> {
/// live [`SystemAppearanceWatcher`] uses [`detect`] (without OSC 11).
#[must_use]
pub fn detect_with_osc11_fallback() -> Option<SystemAppearance> {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(v) = mock_override() {
return v;
}
@ -78,7 +78,7 @@ fn detect_without_mock() -> Option<SystemAppearance> {
///
/// Returns `Some(value)` when a mock is active, `None` when real
/// detection should proceed.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
fn mock_override() -> Option<Option<SystemAppearance>> {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner())
}
@ -175,25 +175,25 @@ impl Drop for SystemAppearanceWatcher {
// -- Test support ----------------------------------------------------------
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
use std::sync::Mutex;
/// Mock override for `detect()`. When set to `Some(value)`, `detect()`
/// returns the mock value instead of calling `dark_light::detect()`.
/// This ensures the `SystemAppearanceWatcher` polling loop (which calls
/// `detect()` directly) is also controllable from tests.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
static MOCK_APPEARANCE: Mutex<Option<Option<SystemAppearance>>> = Mutex::new(None);
/// Override `detect()` for tests. Set to `Some(value)` to mock a specific
/// appearance, or `None` to mock detection failure.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn set_mock(value: Option<SystemAppearance>) {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = Some(value);
}
/// Clear the mock override, restoring real detection behavior.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn clear_mock() {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = None;
}