Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 83 additions & 24 deletions app/src/terminal/view/link_detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ cfg_if::cfg_if! {
util::openable_file_type::FileTarget,
};
use std::path::PathBuf;
use warp_util::listing_command::{
listing_command_argument_dir, DEFAULT_LISTING_COMMANDS,
};
use warp_util::path::CleanPathResult;
use warp_util::path::LineAndColumnArg;
}
Expand Down Expand Up @@ -443,15 +446,49 @@ impl super::TerminalView {
) {
// For AltScreen we scan for relative path with the current working directory.
// For BlockList we scan for relative path with the pwd of the hovered block.
let pwd_to_scan_for = match position {
WithinModel::AltScreen(_) => self.pwd_if_local(ctx),
WithinModel::BlockList(inner) => self
.model
.lock()
.block_list()
.block_at(inner.block_index)
.filter(|block| !self.is_block_considered_remote(block.session_id(), None, ctx)) // Don't scan for file links if the block is on remote sessions
.and_then(|block| block.pwd().map(String::from)),
//
// For BlockList we also look at the block's command: if the block ran a
// directory-listing command (`ls DIR/`, `eza DIR/`, etc.), bare filenames in
// its output are rooted at DIR, not at the block's pwd. `listing_dir_to_scan`
// holds the resolved argument directory (`pwd.join(DIR)`) in that case, and is
// tried first during candidate resolution. If the block's command is not a
// listing command, or has no directory argument, this is `None` and the
// existing pwd-only resolution is used.
//
// `top_level_command` resolves shell aliases (e.g. `ll` → `ls`) via
// `Block::top_level_command`, which delegates to the active session's alias
// table. We pass the resolved name to `listing_command_argument_dir` so
// user-aliased listing commands trigger the fix without needing their alias
// name in the `DEFAULT_LISTING_COMMANDS` list.
let (pwd_to_scan_for, listing_dir_to_scan) = match position {
WithinModel::AltScreen(_) => (self.pwd_if_local(ctx), None),
WithinModel::BlockList(inner) => {
let sessions = self.sessions.as_ref(ctx);
let model_guard = self.model.lock();
let (pwd, command, top_level) = model_guard
.block_list()
.block_at(inner.block_index)
.filter(|block| !self.is_block_considered_remote(block.session_id(), None, ctx))
.map(|block| {
(
block.pwd().map(String::from),
block.command_to_string(),
block.top_level_command(sessions),
)
})
.unwrap_or((None, String::new(), None));
drop(model_guard);

let listing_dir = pwd.as_deref().and_then(|pwd_str| {
listing_command_argument_dir(
&command,
top_level.as_deref(),
std::path::Path::new(pwd_str),
DEFAULT_LISTING_COMMANDS,
)
});
(pwd, listing_dir)
}
};

match pwd_to_scan_for {
Expand All @@ -473,6 +510,7 @@ impl super::TerminalView {
.spawn(move || {
let paths = Self::compute_valid_paths(
&path,
listing_dir_to_scan.as_deref(),
possible_paths,
max_columns,
shell_launch_data,
Expand All @@ -499,20 +537,49 @@ impl super::TerminalView {

fn compute_valid_paths(
working_directory: &str,
listing_dir: Option<&std::path::Path>,
possible_paths: impl Iterator<Item = WithinModel<grid_handler::PossiblePath>>,
max_columns: usize,
shell_launch_data: Option<ShellLaunchData>,
) -> Option<GridHighlightedLink> {
// Try to resolve `clean_path` against the block's listing-command argument
// directory first (if any), then against the block's working directory. The
// listing directory takes precedence so bare filenames from `ls DIR/` output
// resolve into DIR, not into CWD — fixing the silent-misresolution bug where
// a same-named file in CWD would otherwise win.
let try_resolve = |clean_path: &CleanPathResult| -> Option<PathBuf> {
// Only attempt listing-dir resolution for bare entry names (no path
// separators). Candidates like `subdir/README.md`, `./foo`, or absolute
// paths already resolve correctly against CWD and must not be double-joined
// with the listing directory.
if let Some(listing_dir) = listing_dir {
let path_str = &clean_path.path;
let is_bare_name = !path_str.contains('/')
&& !path_str.contains('\\')
&& !path_str.starts_with('~');
if is_bare_name {
if let Some(resolved) = absolute_path_if_valid(
clean_path,
ShellPathType::PlatformNative(listing_dir.to_path_buf()),
shell_launch_data.as_ref(),
) {
return Some(resolved);
}
}
}
absolute_path_if_valid(
clean_path,
ShellPathType::ShellNative(working_directory.to_string()),
shell_launch_data.as_ref(),
)
};

let mut link = None;
'path_loop: for within_model_possible_path in possible_paths {
let possible_path = within_model_possible_path.get_inner();
// We want to check if the clean path result is a valid path and get the canonical
// absolute path back.
let absolute_path = absolute_path_if_valid(
&possible_path.path,
ShellPathType::ShellNative(working_directory.to_string()),
shell_launch_data.as_ref(),
);
let absolute_path = try_resolve(&possible_path.path);

if let Some(absolute_path) = absolute_path {
link = Some(Self::create_valid_link(
Expand All @@ -530,11 +597,7 @@ impl super::TerminalView {
path: new_possible_path.into(),
line_and_column_num: possible_path.path.line_and_column_num,
};
let absolute_path = absolute_path_if_valid(
&new_possible_cleaned_path,
ShellPathType::ShellNative(working_directory.to_string()),
shell_launch_data.as_ref(),
);
let absolute_path = try_resolve(&new_possible_cleaned_path);

// check if new_possible_path is valid
if let Some(absolute_path) = absolute_path {
Expand Down Expand Up @@ -562,11 +625,7 @@ impl super::TerminalView {
path: new_possible_path.into(),
line_and_column_num: possible_path.path.line_and_column_num,
};
let absolute_path = absolute_path_if_valid(
&new_possible_cleaned_path,
ShellPathType::ShellNative(working_directory.to_string()),
shell_launch_data.as_ref(),
);
let absolute_path = try_resolve(&new_possible_cleaned_path);

// check if new_possible_path is valid
if let Some(absolute_path) = absolute_path {
Expand Down
2 changes: 2 additions & 0 deletions crates/warp_util/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ hex.workspace = true
lazy_static.workspace = true
mime_guess.workspace = true
regex.workspace = true
shlex = "1.3.0"
thiserror.workspace = true
typed-path.workspace = true
dunce.workspace = true
Expand All @@ -33,4 +34,5 @@ windows.workspace = true
gloo.workspace = true

[dev-dependencies]
tempfile.workspace = true
warpui.workspace = true
1 change: 1 addition & 0 deletions crates/warp_util/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod assets;
pub mod content_version;
pub mod file;
pub mod file_type;
pub mod listing_command;
pub mod on_cancel;
pub mod path;
pub mod standardized_path;
Expand Down
189 changes: 189 additions & 0 deletions crates/warp_util/src/listing_command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! Helpers for understanding the structure of shell commands whose output contains
//! bare filenames rooted at a directory argument (e.g. `ls SUBDIR/`, `tree SUBDIR`).
//!
//! When the terminal detects a clickable filename in the output of such a command,
//! resolving the candidate against the block's CWD alone is incorrect: bare
//! filenames listed by `ls SUBDIR/` are rooted at `SUBDIR`, not at the CWD. If the
//! user happens to have a same-named file in the CWD (very common for `README.md`),
//! the detector would silently open the wrong file. See sibling module docs for the
//! full bug class.
//!
//! This module provides a narrow helper that parses a command line to extract the
//! first positional path argument, joins it to the block's CWD, and returns it only
//! if the resolved path is a directory on disk.

use std::path::{Path, PathBuf};

/// Default set of directory-listing commands whose bare-filename output should be
/// resolved against their directory argument.
///
/// Exposed as a constant so callers can start from this set and extend it from user
/// settings. Deliberately conservative: `ls` is the canonical case. Modern ls
/// replacements (`exa`, `eza`, `lsd`) behave identically for plain-arg output and
/// are included.
///
/// Not included by default:
/// - `tree` produces output with box-drawing characters that the grid tokenizer
/// currently does not recognize as link separators (tracked separately). Adding
/// `tree` here has no effect until that tokenizer fix lands.
/// - Recursive listings (`ls -R`, `tree`) have multiple root directories within a
/// single block and need per-section resolution, which is out of scope here.
pub const DEFAULT_LISTING_COMMANDS: &[&str] = &["ls", "exa", "eza", "lsd"];

/// Given a shell command line and the block's CWD, return the directory argument
/// (joined to CWD) if the command is a directory-listing command from `listing_commands`
/// and the first positional argument resolves to an existing directory on disk.
///
/// Returns `None` if:
/// - The command cannot be tokenized.
/// - The command name is not in `listing_commands`.
/// - There is no positional argument (e.g. plain `ls`).
/// - The positional argument does not resolve to an existing directory.
/// - The command uses `-R`/`--recursive` (per-section roots).
/// - The command uses `-d`/`--directory` (lists the operand itself, not entries).
/// - The command has multiple positional operands (mixed roots).
///
/// `resolved_command_name` is an optional override for the command-name match. When
/// the caller has already resolved shell aliases (e.g. via `Block::top_level_command`),
/// pass the alias-resolved name here and we match against it instead of the raw first
/// token. Positional-argument extraction still uses the raw command tokens, which is
/// correct for the common case where aliases only add flags (`alias ll='ls -l'` →
/// `ll DIR/` still has `DIR/` as the first positional). Aliases that introduce their
/// own positional arguments (`alias lsd='ls /tmp'`) are a known limitation: we'd pick
/// the user's typed arg, missing the aliased one. Rare and documented.
///
/// ## Examples
///
/// ```text
/// "ls -la subdir/" + cwd=/a/b -> Some(/a/b/subdir) (if /a/b/subdir is a dir)
/// "ls --color=always subdir" + cwd=/a/b -> Some(/a/b/subdir) (if dir)
/// "ls /etc/" + cwd=/a/b -> Some(/etc) (if dir)
/// "ls" + cwd=/a/b -> None
/// "ls subdir1/ subdir2/" + cwd=/a/b -> None (multi-dir rejected)
/// "cat subdir/foo" + cwd=/a/b -> None (cat not in listing_commands)
/// ```
///
/// With `resolved_command_name`:
///
/// ```text
/// "ll subdir/", resolved="ls" + cwd=/a/b -> Some(/a/b/subdir)
/// "ll subdir/", resolved=None + cwd=/a/b -> None (ll not in listing_commands)
/// ```
///
/// ## Shell parsing
///
/// Uses `shlex` to split the command into tokens. This handles POSIX-style quoting
/// (`'...'`, `"..."`, and backslash escapes). It does NOT handle:
/// - Shell variable expansion (`$HOME`, `$VAR`). If a user runs `ls $HOME/foo/`, the
/// command as stored in the block may be either pre- or post-expansion depending on
/// how Warp captures it; this function sees whatever is in the string and will fail
/// gracefully if `$HOME/foo` is not a valid directory name.
/// - Aliases with baked-in positional arguments. `alias lsd='ls /tmp'; lsd DIR/` will
/// resolve to `DIR/` (the user-typed arg), not `/tmp` (the aliased arg). This is a
/// rare edge case; if it matters, callers can expand the full alias upstream and
/// pass the expanded command string.
/// - Environment variable prefixes (`FOO=bar ls DIR/`). Handled by skipping leading
/// `KEY=VALUE`-shaped tokens.
/// - Compound shells (`cd /x && ls DIR/`). We only inspect the first command.
///
/// ## Flag heuristic
///
/// After identifying a listing command, we skip all tokens that start with `-` as
/// flag tokens. This is a simplification: `ls` flags with values like
/// `--color=always` are a single token that starts with `-` (handled correctly);
/// `--color always` (two tokens) would incorrectly consume `always` as a flag, but no
/// `ls` flag actually takes a path-like separate value, so this is safe for `ls`.
pub fn listing_command_argument_dir(
command: &str,
resolved_command_name: Option<&str>,
pwd: &Path,
listing_commands: &[&str],
) -> Option<PathBuf> {
let tokens = shlex::split(command)?;
let mut iter = tokens.iter();

// First non-empty token is the command name. Skip leading env-var assignments
// (e.g. `FOO=bar ls DIR/`) by stepping past any `KEY=VALUE`-shaped tokens.
let raw_command_name = loop {
let token = iter.next()?;
if token.contains('=') && !token.starts_with('-') {
continue;
}
break token.as_str();
};

// Use the alias-resolved name for the listing-command match if provided; otherwise
// fall back to the raw first token. Positional extraction below always uses the
// raw tokens regardless.
let name_for_match = resolved_command_name.unwrap_or(raw_command_name);
if !listing_commands.contains(&name_for_match) {
return None;
}

// Collect remaining tokens, rejecting modes whose output is not a single
// directory's entries.
let mut positionals = Vec::new();
for token in iter {
if token.starts_with('-') {
// Reject recursive listings — output has per-section roots we can't resolve.
// Reject -d/--directory — output names the operand itself, not entries under it.
if token == "--recursive"
|| token == "--directory"
|| (!token.starts_with("--") && (token.contains('R') || token.contains('d')))
{
return None;
}
Comment on lines +128 to +135
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] ls -d DIR / --directory prints the directory operand itself, not entries under it; resolving that bare DIR against DIR first can open DIR/DIR when it exists. Reject directory-as-entry modes the same way recursive listings are rejected.

Suggested change
// Reject recursive listings — output has per-section roots we can't resolve.
if token == "--recursive" || (!token.starts_with("--") && token.contains('R')) {
return None;
}
// Reject modes whose output is not a single directory's entries.
if token == "--recursive"
|| token == "--directory"
|| (!token.starts_with("--") && (token.contains('R') || token.contains('d')))
{
return None;
}

continue;
}
positionals.push(token.as_str());
}

let first_positional = positionals.first()?;

// Expand tilde if present. Without shell-level `$HOME`, `~` by itself or `~/foo`
// won't resolve otherwise. We reuse the minimal expansion here rather than
// depending on `shellexpand` to keep warp_util lean.
let expanded = expand_leading_tilde(first_positional);

// Join with pwd if relative; leave absolute paths alone.
let candidate = if expanded.is_absolute() {
expanded
} else {
pwd.join(&expanded)
};

// Only return the path if it actually resolves to a directory on disk. This
// guards against the user typing a typo'd path, or a path that exists as a file
// (which wouldn't be a listing target anyway).
if !candidate.is_dir() {
return None;
}

// Reject multi-operand commands — `ls DIR FILE` mixes roots (entries under DIR
// alongside bare file operands rooted at CWD), so picking a single listing root
// would misresolve some entries.
if positionals.len() > 1 {
return None;
}

Some(candidate)
}

/// Expands a leading `~` or `~/` in a path to `$HOME` / `$HOME/`. Returns the input
/// unchanged if it does not start with `~`, or if `$HOME` cannot be determined.
fn expand_leading_tilde(token: &str) -> PathBuf {
if let Some(rest) = token.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
} else if token == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(token)
}

#[cfg(test)]
#[path = "listing_command_test.rs"]
mod tests;
Loading