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:
parent
c68e39f604
commit
8adf9013a0
117 changed files with 16998 additions and 14540 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue