1use agent_client_protocol as acp;
2use anyhow::{Context as _, Result, bail};
3use file_icons::FileIcons;
4use prompt_store::{PromptId, UserPromptId};
5use serde::{Deserialize, Serialize};
6use std::{
7 borrow::Cow,
8 fmt,
9 ops::RangeInclusive,
10 path::{Path, PathBuf},
11};
12use ui::{App, IconName, SharedString};
13use url::Url;
14use urlencoding::decode;
15use util::{ResultExt, paths::PathStyle};
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
18pub enum MentionUri {
19 File {
20 abs_path: PathBuf,
21 },
22 PastedImage,
23 Directory {
24 abs_path: PathBuf,
25 },
26 Symbol {
27 abs_path: PathBuf,
28 name: String,
29 line_range: RangeInclusive<u32>,
30 },
31 Thread {
32 id: acp::SessionId,
33 name: String,
34 },
35 TextThread {
36 path: PathBuf,
37 name: String,
38 },
39 Rule {
40 id: PromptId,
41 name: String,
42 },
43 Diagnostics {
44 #[serde(default = "default_include_errors")]
45 include_errors: bool,
46 #[serde(default)]
47 include_warnings: bool,
48 },
49 Selection {
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 abs_path: Option<PathBuf>,
52 line_range: RangeInclusive<u32>,
53 },
54 Fetch {
55 url: Url,
56 },
57 TerminalSelection {
58 line_count: u32,
59 },
60 GitDiff {
61 base_ref: String,
62 },
63}
64
65impl MentionUri {
66 pub fn parse(input: &str, path_style: PathStyle) -> Result<Self> {
67 fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
68 let range = fragment.strip_prefix("L").unwrap_or(fragment);
69
70 let (start, end) = if let Some((start, end)) = range.split_once(":") {
71 (start, end)
72 } else if let Some((start, end)) = range.split_once("-") {
73 // Also handle L10-20 or L10-L20 format
74 (start, end.strip_prefix("L").unwrap_or(end))
75 } else {
76 // Single line number like L1872 - treat as a range of one line
77 (range, range)
78 };
79
80 let start_line = start
81 .parse::<u32>()
82 .context("Parsing line range start")?
83 .checked_sub(1)
84 .context("Line numbers should be 1-based")?;
85 let end_line = end
86 .parse::<u32>()
87 .context("Parsing line range end")?
88 .checked_sub(1)
89 .context("Line numbers should be 1-based")?;
90
91 Ok(start_line..=end_line)
92 }
93
94 let url = url::Url::parse(input)?;
95 let path = url.path();
96 match url.scheme() {
97 "file" => {
98 let normalized = if path_style.is_windows() {
99 path.trim_start_matches("/")
100 } else {
101 path
102 };
103 let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized));
104 let path = decoded.as_ref();
105
106 if let Some(fragment) = url.fragment() {
107 let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);
108 if let Some(name) = single_query_param(&url, "symbol")? {
109 Ok(Self::Symbol {
110 name,
111 abs_path: path.into(),
112 line_range,
113 })
114 } else {
115 Ok(Self::Selection {
116 abs_path: Some(path.into()),
117 line_range,
118 })
119 }
120 } else if input.ends_with("/") {
121 Ok(Self::Directory {
122 abs_path: path.into(),
123 })
124 } else {
125 Ok(Self::File {
126 abs_path: path.into(),
127 })
128 }
129 }
130 "zed" => {
131 if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
132 let name = single_query_param(&url, "name")?.context("Missing thread name")?;
133 Ok(Self::Thread {
134 id: acp::SessionId::new(thread_id),
135 name,
136 })
137 } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
138 let name = single_query_param(&url, "name")?.context("Missing thread name")?;
139 Ok(Self::TextThread {
140 path: path.into(),
141 name,
142 })
143 } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
144 let name = single_query_param(&url, "name")?.context("Missing rule name")?;
145 let rule_id = UserPromptId(rule_id.parse()?);
146 Ok(Self::Rule {
147 id: rule_id.into(),
148 name,
149 })
150 } else if path == "/agent/diagnostics" {
151 let mut include_errors = default_include_errors();
152 let mut include_warnings = false;
153 for (key, value) in url.query_pairs() {
154 match key.as_ref() {
155 "include_warnings" => include_warnings = value == "true",
156 "include_errors" => include_errors = value == "true",
157 _ => bail!("invalid query parameter"),
158 }
159 }
160 Ok(Self::Diagnostics {
161 include_errors,
162 include_warnings,
163 })
164 } else if path.starts_with("/agent/pasted-image") {
165 Ok(Self::PastedImage)
166 } else if path.starts_with("/agent/untitled-buffer") {
167 let fragment = url
168 .fragment()
169 .context("Missing fragment for untitled buffer selection")?;
170 let line_range = parse_line_range(fragment)?;
171 Ok(Self::Selection {
172 abs_path: None,
173 line_range,
174 })
175 } else if let Some(name) = path.strip_prefix("/agent/symbol/") {
176 let fragment = url
177 .fragment()
178 .context("Missing fragment for untitled buffer selection")?;
179 let line_range = parse_line_range(fragment)?;
180 let path =
181 single_query_param(&url, "path")?.context("Missing path for symbol")?;
182 Ok(Self::Symbol {
183 name: name.to_string(),
184 abs_path: path.into(),
185 line_range,
186 })
187 } else if path.starts_with("/agent/file") {
188 let path =
189 single_query_param(&url, "path")?.context("Missing path for file")?;
190 Ok(Self::File {
191 abs_path: path.into(),
192 })
193 } else if path.starts_with("/agent/directory") {
194 let path =
195 single_query_param(&url, "path")?.context("Missing path for directory")?;
196 Ok(Self::Directory {
197 abs_path: path.into(),
198 })
199 } else if path.starts_with("/agent/selection") {
200 let fragment = url.fragment().context("Missing fragment for selection")?;
201 let line_range = parse_line_range(fragment)?;
202 let path =
203 single_query_param(&url, "path")?.context("Missing path for selection")?;
204 Ok(Self::Selection {
205 abs_path: Some(path.into()),
206 line_range,
207 })
208 } else if path.starts_with("/agent/terminal-selection") {
209 let line_count = single_query_param(&url, "lines")?
210 .unwrap_or_else(|| "0".to_string())
211 .parse::<u32>()
212 .unwrap_or(0);
213 Ok(Self::TerminalSelection { line_count })
214 } else if path.starts_with("/agent/git-diff") {
215 let base_ref =
216 single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string());
217 Ok(Self::GitDiff { base_ref })
218 } else {
219 bail!("invalid zed url: {:?}", input);
220 }
221 }
222 "http" | "https" => Ok(MentionUri::Fetch { url }),
223 other => bail!("unrecognized scheme {:?}", other),
224 }
225 }
226
227 pub fn name(&self) -> String {
228 match self {
229 MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
230 .file_name()
231 .unwrap_or_default()
232 .to_string_lossy()
233 .into_owned(),
234 MentionUri::PastedImage => "Image".to_string(),
235 MentionUri::Symbol { name, .. } => name.clone(),
236 MentionUri::Thread { name, .. } => name.clone(),
237 MentionUri::TextThread { name, .. } => name.clone(),
238 MentionUri::Rule { name, .. } => name.clone(),
239 MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),
240 MentionUri::TerminalSelection { line_count } => {
241 if *line_count == 1 {
242 "Terminal (1 line)".to_string()
243 } else {
244 format!("Terminal ({} lines)", line_count)
245 }
246 }
247 MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref),
248 MentionUri::Selection {
249 abs_path: path,
250 line_range,
251 ..
252 } => selection_name(path.as_deref(), line_range),
253 MentionUri::Fetch { url } => url.to_string(),
254 }
255 }
256
257 pub fn tooltip_text(&self) -> Option<SharedString> {
258 match self {
259 MentionUri::File { abs_path } | MentionUri::Directory { abs_path } => {
260 Some(abs_path.to_string_lossy().into_owned().into())
261 }
262 MentionUri::Symbol {
263 abs_path,
264 line_range,
265 ..
266 } => Some(
267 format!(
268 "{}:{}-{}",
269 abs_path.display(),
270 line_range.start(),
271 line_range.end()
272 )
273 .into(),
274 ),
275 MentionUri::Selection {
276 abs_path: Some(path),
277 line_range,
278 ..
279 } => Some(
280 format!(
281 "{}:{}-{}",
282 path.display(),
283 line_range.start(),
284 line_range.end()
285 )
286 .into(),
287 ),
288 _ => None,
289 }
290 }
291
292 pub fn icon_path(&self, cx: &mut App) -> SharedString {
293 match self {
294 MentionUri::File { abs_path } => {
295 FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
296 }
297 MentionUri::PastedImage => IconName::Image.path().into(),
298 MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
299 .unwrap_or_else(|| IconName::Folder.path().into()),
300 MentionUri::Symbol { .. } => IconName::Code.path().into(),
301 MentionUri::Thread { .. } => IconName::Thread.path().into(),
302 MentionUri::TextThread { .. } => IconName::Thread.path().into(),
303 MentionUri::Rule { .. } => IconName::Reader.path().into(),
304 MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),
305 MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(),
306 MentionUri::Selection { .. } => IconName::Reader.path().into(),
307 MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
308 MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(),
309 }
310 }
311
312 pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
313 MentionLink(self)
314 }
315
316 pub fn to_uri(&self) -> Url {
317 match self {
318 MentionUri::File { abs_path } => {
319 let mut url = Url::parse("file:///").unwrap();
320 url.set_path(&abs_path.to_string_lossy());
321 url
322 }
323 MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
324 MentionUri::Directory { abs_path } => {
325 let mut url = Url::parse("file:///").unwrap();
326 url.set_path(&abs_path.to_string_lossy());
327 url
328 }
329 MentionUri::Symbol {
330 abs_path,
331 name,
332 line_range,
333 } => {
334 let mut url = Url::parse("file:///").unwrap();
335 url.set_path(&abs_path.to_string_lossy());
336 url.query_pairs_mut().append_pair("symbol", name);
337 url.set_fragment(Some(&format!(
338 "L{}:{}",
339 line_range.start() + 1,
340 line_range.end() + 1
341 )));
342 url
343 }
344 MentionUri::Selection {
345 abs_path,
346 line_range,
347 } => {
348 let mut url = if let Some(path) = abs_path {
349 let mut url = Url::parse("file:///").unwrap();
350 url.set_path(&path.to_string_lossy());
351 url
352 } else {
353 let mut url = Url::parse("zed:///").unwrap();
354 url.set_path("/agent/untitled-buffer");
355 url
356 };
357 url.set_fragment(Some(&format!(
358 "L{}:{}",
359 line_range.start() + 1,
360 line_range.end() + 1
361 )));
362 url
363 }
364 MentionUri::Thread { name, id } => {
365 let mut url = Url::parse("zed:///").unwrap();
366 url.set_path(&format!("/agent/thread/{id}"));
367 url.query_pairs_mut().append_pair("name", name);
368 url
369 }
370 MentionUri::TextThread { path, name } => {
371 let mut url = Url::parse("zed:///").unwrap();
372 url.set_path(&format!(
373 "/agent/text-thread/{}",
374 path.to_string_lossy().trim_start_matches('/')
375 ));
376 url.query_pairs_mut().append_pair("name", name);
377 url
378 }
379 MentionUri::Rule { name, id } => {
380 let mut url = Url::parse("zed:///").unwrap();
381 url.set_path(&format!("/agent/rule/{id}"));
382 url.query_pairs_mut().append_pair("name", name);
383 url
384 }
385 MentionUri::Diagnostics {
386 include_errors,
387 include_warnings,
388 } => {
389 let mut url = Url::parse("zed:///").unwrap();
390 url.set_path("/agent/diagnostics");
391 if *include_warnings {
392 url.query_pairs_mut()
393 .append_pair("include_warnings", "true");
394 }
395 if !include_errors {
396 url.query_pairs_mut().append_pair("include_errors", "false");
397 }
398 url
399 }
400 MentionUri::Fetch { url } => url.clone(),
401 MentionUri::TerminalSelection { line_count } => {
402 let mut url = Url::parse("zed:///agent/terminal-selection").unwrap();
403 url.query_pairs_mut()
404 .append_pair("lines", &line_count.to_string());
405 url
406 }
407 MentionUri::GitDiff { base_ref } => {
408 let mut url = Url::parse("zed:///agent/git-diff").unwrap();
409 url.query_pairs_mut().append_pair("base", base_ref);
410 url
411 }
412 }
413 }
414}
415
416pub struct MentionLink<'a>(&'a MentionUri);
417
418impl fmt::Display for MentionLink<'_> {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
421 }
422}
423
424fn default_include_errors() -> bool {
425 true
426}
427
428fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
429 let pairs = url.query_pairs().collect::<Vec<_>>();
430 match pairs.as_slice() {
431 [] => Ok(None),
432 [(k, v)] => {
433 if k != name {
434 bail!("invalid query parameter")
435 }
436
437 Ok(Some(v.to_string()))
438 }
439 _ => bail!("too many query pairs"),
440 }
441}
442
443pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
444 format!(
445 "{} ({}:{})",
446 path.and_then(|path| path.file_name())
447 .unwrap_or("Untitled".as_ref())
448 .display(),
449 *line_range.start() + 1,
450 *line_range.end() + 1
451 )
452}
453
454#[cfg(test)]
455mod tests {
456 use util::{path, uri};
457
458 use super::*;
459
460 #[test]
461 fn test_parse_file_uri() {
462 let file_uri = uri!("file:///path/to/file.rs");
463 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
464 match &parsed {
465 MentionUri::File { abs_path } => {
466 assert_eq!(abs_path, Path::new(path!("/path/to/file.rs")));
467 }
468 _ => panic!("Expected File variant"),
469 }
470 assert_eq!(parsed.to_uri().to_string(), file_uri);
471 }
472
473 #[test]
474 fn test_parse_directory_uri() {
475 let file_uri = uri!("file:///path/to/dir/");
476 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
477 match &parsed {
478 MentionUri::Directory { abs_path } => {
479 assert_eq!(abs_path, Path::new(path!("/path/to/dir/")));
480 }
481 _ => panic!("Expected Directory variant"),
482 }
483 assert_eq!(parsed.to_uri().to_string(), file_uri);
484 }
485
486 #[test]
487 fn test_to_directory_uri_without_slash() {
488 let uri = MentionUri::Directory {
489 abs_path: PathBuf::from(path!("/path/to/dir/")),
490 };
491 let expected = uri!("file:///path/to/dir/");
492 assert_eq!(uri.to_uri().to_string(), expected);
493 }
494
495 #[test]
496 fn test_parse_symbol_uri() {
497 let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
498 let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap();
499 match &parsed {
500 MentionUri::Symbol {
501 abs_path: path,
502 name,
503 line_range,
504 } => {
505 assert_eq!(path, Path::new(path!("/path/to/file.rs")));
506 assert_eq!(name, "MySymbol");
507 assert_eq!(line_range.start(), &9);
508 assert_eq!(line_range.end(), &19);
509 }
510 _ => panic!("Expected Symbol variant"),
511 }
512 assert_eq!(parsed.to_uri().to_string(), symbol_uri);
513 }
514
515 #[test]
516 fn test_parse_selection_uri() {
517 let selection_uri = uri!("file:///path/to/file.rs#L5:15");
518 let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
519 match &parsed {
520 MentionUri::Selection {
521 abs_path: path,
522 line_range,
523 } => {
524 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
525 assert_eq!(line_range.start(), &4);
526 assert_eq!(line_range.end(), &14);
527 }
528 _ => panic!("Expected Selection variant"),
529 }
530 assert_eq!(parsed.to_uri().to_string(), selection_uri);
531 }
532
533 #[test]
534 fn test_parse_file_uri_with_non_ascii() {
535 let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
536 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
537 match &parsed {
538 MentionUri::File { abs_path } => {
539 assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
540 }
541 _ => panic!("Expected File variant"),
542 }
543 assert_eq!(parsed.to_uri().to_string(), file_uri);
544 }
545
546 #[test]
547 fn test_parse_untitled_selection_uri() {
548 let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
549 let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
550 match &parsed {
551 MentionUri::Selection {
552 abs_path: None,
553 line_range,
554 } => {
555 assert_eq!(line_range.start(), &0);
556 assert_eq!(line_range.end(), &9);
557 }
558 _ => panic!("Expected Selection variant without path"),
559 }
560 assert_eq!(parsed.to_uri().to_string(), selection_uri);
561 }
562
563 #[test]
564 fn test_parse_thread_uri() {
565 let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
566 let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap();
567 match &parsed {
568 MentionUri::Thread {
569 id: thread_id,
570 name,
571 } => {
572 assert_eq!(thread_id.to_string(), "session123");
573 assert_eq!(name, "Thread name");
574 }
575 _ => panic!("Expected Thread variant"),
576 }
577 assert_eq!(parsed.to_uri().to_string(), thread_uri);
578 }
579
580 #[test]
581 fn test_parse_rule_uri() {
582 let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
583 let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
584 match &parsed {
585 MentionUri::Rule { id, name } => {
586 assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
587 assert_eq!(name, "Some rule");
588 }
589 _ => panic!("Expected Rule variant"),
590 }
591 assert_eq!(parsed.to_uri().to_string(), rule_uri);
592 }
593
594 #[test]
595 fn test_parse_fetch_http_uri() {
596 let http_uri = "http://example.com/path?query=value#fragment";
597 let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap();
598 match &parsed {
599 MentionUri::Fetch { url } => {
600 assert_eq!(url.to_string(), http_uri);
601 }
602 _ => panic!("Expected Fetch variant"),
603 }
604 assert_eq!(parsed.to_uri().to_string(), http_uri);
605 }
606
607 #[test]
608 fn test_parse_fetch_https_uri() {
609 let https_uri = "https://example.com/api/endpoint";
610 let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap();
611 match &parsed {
612 MentionUri::Fetch { url } => {
613 assert_eq!(url.to_string(), https_uri);
614 }
615 _ => panic!("Expected Fetch variant"),
616 }
617 assert_eq!(parsed.to_uri().to_string(), https_uri);
618 }
619
620 #[test]
621 fn test_parse_diagnostics_uri() {
622 let uri = "zed:///agent/diagnostics?include_warnings=true";
623 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
624 match &parsed {
625 MentionUri::Diagnostics {
626 include_errors,
627 include_warnings,
628 } => {
629 assert!(include_errors);
630 assert!(include_warnings);
631 }
632 _ => panic!("Expected Diagnostics variant"),
633 }
634 assert_eq!(parsed.to_uri().to_string(), uri);
635 }
636
637 #[test]
638 fn test_parse_diagnostics_uri_warnings_only() {
639 let uri = "zed:///agent/diagnostics?include_warnings=true&include_errors=false";
640 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
641 match &parsed {
642 MentionUri::Diagnostics {
643 include_errors,
644 include_warnings,
645 } => {
646 assert!(!include_errors);
647 assert!(include_warnings);
648 }
649 _ => panic!("Expected Diagnostics variant"),
650 }
651 assert_eq!(parsed.to_uri().to_string(), uri);
652 }
653
654 #[test]
655 fn test_invalid_scheme() {
656 assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err());
657 assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err());
658 assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err());
659 }
660
661 #[test]
662 fn test_invalid_zed_path() {
663 assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err());
664 assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err());
665 }
666
667 #[test]
668 fn test_single_line_number() {
669 // https://github.com/zed-industries/zed/issues/46114
670 let uri = uri!("file:///path/to/file.rs#L1872");
671 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
672 match &parsed {
673 MentionUri::Selection {
674 abs_path: path,
675 line_range,
676 } => {
677 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
678 assert_eq!(line_range.start(), &1871);
679 assert_eq!(line_range.end(), &1871);
680 }
681 _ => panic!("Expected Selection variant"),
682 }
683 }
684
685 #[test]
686 fn test_dash_separated_line_range() {
687 let uri = uri!("file:///path/to/file.rs#L10-20");
688 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
689 match &parsed {
690 MentionUri::Selection {
691 abs_path: path,
692 line_range,
693 } => {
694 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
695 assert_eq!(line_range.start(), &9);
696 assert_eq!(line_range.end(), &19);
697 }
698 _ => panic!("Expected Selection variant"),
699 }
700
701 // Also test L10-L20 format
702 let uri = uri!("file:///path/to/file.rs#L10-L20");
703 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
704 match &parsed {
705 MentionUri::Selection {
706 abs_path: path,
707 line_range,
708 } => {
709 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
710 assert_eq!(line_range.start(), &9);
711 assert_eq!(line_range.end(), &19);
712 }
713 _ => panic!("Expected Selection variant"),
714 }
715 }
716
717 #[test]
718 fn test_parse_terminal_selection_uri() {
719 let terminal_uri = "zed:///agent/terminal-selection?lines=42";
720 let parsed = MentionUri::parse(terminal_uri, PathStyle::local()).unwrap();
721 match &parsed {
722 MentionUri::TerminalSelection { line_count } => {
723 assert_eq!(*line_count, 42);
724 }
725 _ => panic!("Expected TerminalSelection variant"),
726 }
727 assert_eq!(parsed.to_uri().to_string(), terminal_uri);
728 assert_eq!(parsed.name(), "Terminal (42 lines)");
729
730 // Test single line
731 let single_line_uri = "zed:///agent/terminal-selection?lines=1";
732 let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap();
733 assert_eq!(parsed_single.name(), "Terminal (1 line)");
734 }
735}