1773 lines · 64.6 KB · Rust
RawDownload
use agent_client_protocol::schema::v1 as acp;use anyhow::{Context as _, Result, bail};use file_icons::FileIcons;use serde::{Deserialize, Serialize};use std::{    borrow::Cow,    fmt,    ops::RangeInclusive,    path::{Path, PathBuf},};use ui::{App, IconName, SharedString};use url::Url;use urlencoding::decode;use util::{    ResultExt,    paths::{PathStyle, PathWithPosition, is_absolute},}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]pub enum MentionUri {    File {        abs_path: PathBuf,    },    PastedImage {        name: String,    },    Directory {        abs_path: PathBuf,    },    Symbol {        abs_path: PathBuf,        name: String,        line_range: RangeInclusive<u32>,    },    Thread {        id: acp::SessionId,        name: String,    },    /// Deprecated: kept so threads from before rules became skills still    /// deserialize. `id` (an opaque `prompt_store::PromptId`) is preserved    /// verbatim so re-saved threads stay loadable by older Zed versions.    Rule {        #[serde(default = "default_deprecated_rule_id")]        id: serde_json::Value,        name: String,    },    Diagnostics {        #[serde(default = "default_include_errors")]        include_errors: bool,        #[serde(default)]        include_warnings: bool,    },    Selection {        #[serde(default, skip_serializing_if = "Option::is_none")]        abs_path: Option<PathBuf>,        line_range: RangeInclusive<u32>,        #[serde(default, skip_serializing_if = "Option::is_none")]        column: Option<u32>,    },    Fetch {        url: Url,    },    TerminalSelection {        line_count: u32,    },    GitDiff {        base_ref: String,    },    MergeConflict {        file_path: String,    },    Skill {        name: String,        source: String,        skill_file_path: PathBuf,    },} impl MentionUri {    pub fn parse(input: &str, path_style: PathStyle) -> Result<Self> {        let input = input            .strip_prefix('`')            .and_then(|input| input.strip_suffix('`'))            .unwrap_or(input);         let parse_column =            |input: Option<String>| -> Option<u32> { input?.parse::<u32>().ok()?.checked_sub(1) };        let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> {            for (key, _) in url.query_pairs() {                if !allowed.contains(&key.as_ref()) {                    bail!("invalid query parameter")                }            }            Ok(())        };         if is_absolute(input, path_style) && !input.contains("://") {            return parse_absolute_path(input)                .with_context(|| format!("Invalid absolute path mention URI: {input}"));        }         let url = url::Url::parse(input)?;        let path = url.path();        match url.scheme() {            "file" => {                let trimmed = if path_style.is_windows() {                    path.trim_start_matches("/")                } else {                    path                };                let decoded = decode(trimmed).unwrap_or(Cow::Borrowed(trimmed));                let normalized: Cow<str> = if path_style.is_windows() {                    match to_native_windows_path(&decoded) {                        Some(native) => Cow::Owned(native),                        None => decoded,                    }                } else {                    decoded                };                let path = normalized.as_ref();                 if let Some(fragment) = url.fragment() {                    validate_query_params(&url, &["symbol", "column"])?;                    let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);                    let column = parse_column(query_param(&url, "column"));                    if let Some(name) = query_param(&url, "symbol") {                        Ok(Self::Symbol {                            name,                            abs_path: path.into(),                            line_range,                        })                    } else {                        Ok(Self::Selection {                            abs_path: Some(path.into()),                            line_range,                            column,                        })                    }                } else if input.ends_with("/") {                    Ok(Self::Directory {                        abs_path: path.into(),                    })                } else {                    Ok(Self::File {                        abs_path: path.into(),                    })                }            }            "zed" => {                if let Some(thread_id) = path.strip_prefix("/agent/thread/") {                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;                    Ok(Self::Thread {                        id: acp::SessionId::new(thread_id),                        name,                    })                } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {                    // Deprecated: parses legacy rule mentions.                    let name = single_query_param(&url, "name")?.context("Missing rule name")?;                    let id = if rule_id.is_empty() {                        default_deprecated_rule_id()                    } else {                        serde_json::json!({ "User": { "uuid": rule_id } })                    };                    Ok(Self::Rule { id, name })                } else if path == "/agent/diagnostics" {                    let mut include_errors = default_include_errors();                    let mut include_warnings = false;                    for (key, value) in url.query_pairs() {                        match key.as_ref() {                            "include_warnings" => include_warnings = value == "true",                            "include_errors" => include_errors = value == "true",                            _ => bail!("invalid query parameter"),                        }                    }                    Ok(Self::Diagnostics {                        include_errors,                        include_warnings,                    })                } else if path.starts_with("/agent/pasted-image") {                    let name =                        single_query_param(&url, "name")?.unwrap_or_else(|| "Image".to_string());                    Ok(Self::PastedImage { name })                } else if path.starts_with("/agent/untitled-buffer") {                    let fragment = url                        .fragment()                        .context("Missing fragment for untitled buffer selection")?;                    let line_range = parse_line_range(fragment)?;                    validate_query_params(&url, &["column"])?;                    Ok(Self::Selection {                        abs_path: None,                        line_range,                        column: parse_column(query_param(&url, "column")),                    })                } else if let Some(name) = path.strip_prefix("/agent/symbol/") {                    let fragment = url                        .fragment()                        .context("Missing fragment for untitled buffer selection")?;                    let line_range = parse_line_range(fragment)?;                    let path =                        single_query_param(&url, "path")?.context("Missing path for symbol")?;                    Ok(Self::Symbol {                        name: name.to_string(),                        abs_path: path.into(),                        line_range,                    })                } else if path.starts_with("/agent/file") {                    let path =                        single_query_param(&url, "path")?.context("Missing path for file")?;                    Ok(Self::File {                        abs_path: path.into(),                    })                } else if path.starts_with("/agent/directory") {                    let path =                        single_query_param(&url, "path")?.context("Missing path for directory")?;                    Ok(Self::Directory {                        abs_path: path.into(),                    })                } else if path.starts_with("/agent/selection") {                    validate_query_params(&url, &["path", "column"])?;                    let fragment = url.fragment().context("Missing fragment for selection")?;                    let line_range = parse_line_range(fragment)?;                    let column = parse_column(query_param(&url, "column"));                    let path = query_param(&url, "path").context("Missing path for selection")?;                    Ok(Self::Selection {                        abs_path: Some(path.into()),                        line_range,                        column,                    })                } else if path.starts_with("/agent/terminal-selection") {                    let line_count = single_query_param(&url, "lines")?                        .unwrap_or_else(|| "0".to_string())                        .parse::<u32>()                        .unwrap_or(0);                    Ok(Self::TerminalSelection { line_count })                } else if path.starts_with("/agent/git-diff") {                    let base_ref =                        single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string());                    Ok(Self::GitDiff { base_ref })                } else if path.starts_with("/agent/merge-conflict") {                    let file_path = single_query_param(&url, "path")?.unwrap_or_default();                    Ok(Self::MergeConflict { file_path })                } else if path.starts_with("/agent/skill") {                    let mut name = None;                    let mut source = None;                    let mut skill_file_path = None;                     for (key, value) in url.query_pairs() {                        match key.as_ref() {                            "name" => {                                if name.replace(value.to_string()).is_some() {                                    bail!("duplicate skill name query parameter");                                }                            }                            "source" => {                                if source.replace(value.to_string()).is_some() {                                    bail!("duplicate skill source query parameter");                                }                            }                            "path" => {                                if skill_file_path                                    .replace(PathBuf::from(value.to_string()))                                    .is_some()                                {                                    bail!("duplicate skill file path query parameter");                                }                            }                            _ => bail!("invalid query parameter"),                        }                    }                     Ok(Self::Skill {                        name: name.context("missing skill name")?,                        source: source.context("missing skill source")?,                        skill_file_path: skill_file_path.context("missing skill file path")?,                    })                } else {                    bail!("invalid zed url: {:?}", input);                }            }            "http" | "https" => Ok(MentionUri::Fetch { url }),            other => bail!("unrecognized scheme {:?}", other),        }    }     /// Parses a hyperlink target from agent-authored Markdown.    ///    /// Unlike [`MentionUri::parse`] — which stays strict so canonical mention    /// URIs round-trip verbatim — bare path targets are normalized first:    /// percent escapes are decoded (see [`decode_path_escapes`]) and    /// Windows-compatible spellings like `/C:/foo` or `/c/foo` become native    /// paths (see [`to_native_windows_path`]).    pub fn parse_hyperlink(input: &str, path_style: PathStyle) -> Result<Self> {        if let Some(target) = bare_path_target(input, path_style) {            return parse_hyperlink_path(target, path_style, DecodePercentEscapes::Yes)                .with_context(|| format!("Invalid hyperlink path target: {input}"));        }        Self::parse(input, path_style)    }     /// Returns the literal (un-decoded) interpretation of a bare-path    /// hyperlink target, for files whose names literally contain an escape    /// sequence (e.g. `a%20b.rs`). Returns `None` when this wouldn't differ    /// from [`MentionUri::parse_hyperlink`], including for URLs, whose    /// escapes are unambiguous.    pub fn parse_hyperlink_literal(input: &str, path_style: PathStyle) -> Option<Self> {        let target = bare_path_target(input, path_style)?;        let (path_input, _) = split_path_fragment(target);        if !matches!(decode_path_escapes(path_input), Cow::Owned(_)) {            return None;        }        parse_hyperlink_path(target, path_style, DecodePercentEscapes::No).ok()    }     /// The absolute path this mention refers to, if it refers to one.    pub fn abs_path(&self) -> Option<&Path> {        match self {            MentionUri::File { abs_path }            | MentionUri::Directory { abs_path }            | MentionUri::Symbol { abs_path, .. } => Some(abs_path),            MentionUri::Selection { abs_path, .. } => abs_path.as_deref(),            MentionUri::Skill {                skill_file_path, ..            } => Some(skill_file_path),            MentionUri::PastedImage { .. }            | MentionUri::Thread { .. }            | MentionUri::Rule { .. }            | MentionUri::Diagnostics { .. }            | MentionUri::Fetch { .. }            | MentionUri::TerminalSelection { .. }            | MentionUri::GitDiff { .. }            | MentionUri::MergeConflict { .. } => None,        }    }     pub fn name(&self) -> String {        match self {            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path                .file_name()                .unwrap_or_default()                .to_string_lossy()                .into_owned(),            MentionUri::PastedImage { name } => name.clone(),            MentionUri::Symbol { name, .. } => name.clone(),            MentionUri::Thread { name, .. } => name.clone(),            MentionUri::Rule { name, .. } => name.clone(),            MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),            MentionUri::TerminalSelection { line_count } => {                if *line_count == 1 {                    "Terminal (1 line)".to_string()                } else {                    format!("Terminal ({} lines)", line_count)                }            }            MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref),            MentionUri::MergeConflict { file_path } => {                let name = Path::new(file_path)                    .file_name()                    .unwrap_or_default()                    .to_string_lossy();                format!("Merge Conflict ({name})")            }            MentionUri::Selection {                abs_path: path,                line_range,                ..            } => selection_name(path.as_deref(), line_range),            MentionUri::Fetch { url } => url.to_string(),            MentionUri::Skill { name, .. } => name.clone(),        }    }     /// Returns a label for this mention at the given disambiguation `detail`    /// level. `detail == 0` is the base name returned by [`Self::name`]; higher    /// levels include progressively more context (e.g. additional parent path    /// components for files, or the source for skills) until a fixed point is    /// reached. Intended to be driven by [`util::disambiguate::compute_disambiguation_details`].    pub fn disambiguated_name(&self, detail: usize) -> String {        if detail == 0 {            return self.name();        }         match self {            MentionUri::Skill { name, source, .. } => {                if source.is_empty() {                    // Must match `SkillSource::display_label()` in agent_skills.                    format!("{} (global)", name)                } else {                    format!("{} ({})", name, source)                }            }            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => {                project::path_suffix(abs_path, detail)            }            _ => self.name(),        }    }     pub fn tooltip_text(&self) -> Option<SharedString> {        match self {            MentionUri::File { abs_path } | MentionUri::Directory { abs_path } => {                Some(abs_path.to_string_lossy().into_owned().into())            }            MentionUri::Symbol {                abs_path,                line_range,                ..            } => Some(                format!(                    "{}:{}-{}",                    abs_path.display(),                    line_range.start(),                    line_range.end()                )                .into(),            ),            MentionUri::Selection {                abs_path: Some(path),                line_range,                ..            } => Some(                format!(                    "{}:{}-{}",                    path.display(),                    line_range.start(),                    line_range.end()                )                .into(),            ),            MentionUri::Skill {                skill_file_path, ..            } => Some(skill_file_path.to_string_lossy().into_owned().into()),            _ => None,        }    }     pub fn icon_path(&self, cx: &mut App) -> SharedString {        match self {            MentionUri::File { abs_path } => {                FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())            }            MentionUri::PastedImage { .. } => IconName::Image.path().into(),            MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)                .unwrap_or_else(|| IconName::Folder.path().into()),            MentionUri::Symbol { .. } => IconName::Code.path().into(),            MentionUri::Thread { .. } => IconName::Thread.path().into(),            MentionUri::Rule { .. } => IconName::Reader.path().into(),            MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),            MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(),            MentionUri::Selection { .. } => IconName::Reader.path().into(),            MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),            MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(),            MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(),            MentionUri::Skill { .. } => IconName::Sparkle.path().into(),        }    }     pub fn as_link<'a>(&'a self) -> MentionLink<'a> {        MentionLink(self)    }     pub fn to_uri(&self) -> Url {        match self {            MentionUri::File { abs_path } => {                let mut url = Url::parse("file:///").unwrap();                url.set_path(&abs_path.to_string_lossy());                url            }            MentionUri::PastedImage { name } => {                let mut url = Url::parse("zed:///agent/pasted-image").unwrap();                url.query_pairs_mut().append_pair("name", name);                url            }            MentionUri::Directory { abs_path } => {                let mut url = Url::parse("file:///").unwrap();                let mut path = abs_path.to_string_lossy().into_owned();                if !path.ends_with('/') && !path.ends_with('\\') {                    path.push('/');                }                url.set_path(&path);                url            }            MentionUri::Symbol {                abs_path,                name,                line_range,                ..            } => {                let mut url = Url::parse("file:///").unwrap();                url.set_path(&abs_path.to_string_lossy());                url.query_pairs_mut().append_pair("symbol", name);                url.set_fragment(Some(&format!(                    "L{}:{}",                    line_range.start() + 1,                    line_range.end() + 1                )));                url            }            MentionUri::Selection {                abs_path,                line_range,

Lines 1–500 of 1773.