logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,454 @@
use anyhow::Result;
use buffer_diff::BufferDiff;
use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task};
use itertools::Itertools;
use language::{
Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _, Point, TextBuffer,
};
use multi_buffer::{MultiBuffer, PathKey, excerpt_context_lines};
use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc};
use util::ResultExt;
pub enum Diff {
Pending(PendingDiff),
Finalized(FinalizedDiff),
}
impl Diff {
pub fn finalized(
path: String,
old_text: Option<String>,
new_text: String,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> Self {
let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly));
let new_buffer = cx.new(|cx| Buffer::local(new_text, cx));
let base_text = old_text.clone().unwrap_or(String::new()).into();
let task = cx.spawn({
let multibuffer = multibuffer.clone();
let path = path.clone();
let buffer = new_buffer.clone();
async move |_, cx| {
let language = language_registry
.load_language_for_file_path(Path::new(&path))
.await
.log_err();
buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx));
buffer.update(cx, |buffer, _| buffer.parsing_idle()).await;
let diff = build_buffer_diff(
old_text.unwrap_or("".into()).into(),
&buffer,
Some(language_registry.clone()),
cx,
)
.await?;
multibuffer.update(cx, |multibuffer, cx| {
let hunk_ranges = {
let buffer = buffer.read(cx);
diff.read(cx)
.snapshot(cx)
.hunks_intersecting_range(
Anchor::min_for_buffer(buffer.remote_id())
..Anchor::max_for_buffer(buffer.remote_id()),
buffer,
)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
.collect::<Vec<_>>()
};
multibuffer.set_excerpts_for_path(
PathKey::for_buffer(&buffer, cx),
buffer.clone(),
hunk_ranges,
excerpt_context_lines(cx),
cx,
);
multibuffer.add_diff(diff, cx);
});
anyhow::Ok(())
}
});
Self::Finalized(FinalizedDiff {
multibuffer,
path,
base_text,
new_buffer,
_update_diff: task,
})
}
pub fn new(buffer: Entity<Buffer>, cx: &mut Context<Self>) -> Self {
let buffer_text_snapshot = buffer.read(cx).text_snapshot();
let language = buffer.read(cx).language().cloned();
let language_registry = buffer.read(cx).language_registry();
let buffer_diff = cx.new(|cx| {
let mut diff = BufferDiff::new_unchanged(&buffer_text_snapshot, cx);
diff.language_changed(language.clone(), language_registry.clone(), cx);
let secondary_diff = cx.new(|cx| {
// For the secondary diff buffer we skip assigning the language as we do not really need to perform any syntax highlighting on
// it. As a result, by skipping it we are potentially shaving off a lot of RSS plus we get a snappier feel for large diff
// view multibuffers.
BufferDiff::new_unchanged(&buffer_text_snapshot, cx)
});
diff.set_secondary_diff(secondary_diff);
diff
});
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::without_headers(Capability::ReadOnly);
multibuffer.add_diff(buffer_diff.clone(), cx);
multibuffer
});
Self::Pending(PendingDiff {
multibuffer,
base_text: Arc::from(buffer_text_snapshot.text().as_str()),
_subscription: cx.observe(&buffer, |this, _, cx| {
if let Diff::Pending(diff) = this {
diff.update(cx);
}
}),
new_buffer: buffer,
diff: buffer_diff,
revealed_ranges: Vec::new(),
update_diff: Task::ready(Ok(())),
})
}
pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
if let Self::Pending(diff) = self {
diff.reveal_range(range, cx);
}
}
pub fn finalize(&mut self, cx: &mut Context<Self>) {
if let Self::Pending(diff) = self {
*self = Self::Finalized(diff.finalize(cx));
}
}
/// Returns the original text before any edits were applied.
pub fn base_text(&self) -> &Arc<str> {
match self {
Self::Pending(PendingDiff { base_text, .. }) => base_text,
Self::Finalized(FinalizedDiff { base_text, .. }) => base_text,
}
}
/// Returns the buffer being edited (for pending diffs) or the snapshot buffer (for finalized diffs).
pub fn buffer(&self) -> &Entity<Buffer> {
match self {
Self::Pending(PendingDiff { new_buffer, .. }) => new_buffer,
Self::Finalized(FinalizedDiff { new_buffer, .. }) => new_buffer,
}
}
pub fn file_path(&self, cx: &App) -> Option<String> {
match self {
Self::Pending(PendingDiff { new_buffer, .. }) => new_buffer
.read(cx)
.file()
.map(|file| file.full_path(cx).to_string_lossy().into_owned()),
Self::Finalized(FinalizedDiff { path, .. }) => Some(path.clone()),
}
}
pub fn multibuffer(&self) -> &Entity<MultiBuffer> {
match self {
Self::Pending(PendingDiff { multibuffer, .. }) => multibuffer,
Self::Finalized(FinalizedDiff { multibuffer, .. }) => multibuffer,
}
}
pub fn to_markdown(&self, cx: &App) -> String {
let buffer_text = self
.multibuffer()
.read(cx)
.all_buffers()
.iter()
.map(|buffer| buffer.read(cx).text())
.join("\n");
let path = match self {
Diff::Pending(PendingDiff {
new_buffer: buffer, ..
}) => buffer
.read(cx)
.file()
.map(|file| file.path().display(file.path_style(cx))),
Diff::Finalized(FinalizedDiff { path, .. }) => Some(path.as_str().into()),
};
format!(
"Diff: {}\n```\n{}\n```\n",
path.unwrap_or("untitled".into()),
buffer_text
)
}
pub fn has_revealed_range(&self, cx: &App) -> bool {
!self.multibuffer().read(cx).is_empty()
}
pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool {
match self {
Diff::Pending(PendingDiff {
base_text,
new_buffer,
..
}) => {
base_text.as_ref() != old_text
|| !new_buffer.read(cx).as_rope().chunks().equals_str(new_text)
}
Diff::Finalized(FinalizedDiff {
base_text,
new_buffer,
..
}) => {
base_text.as_ref() != old_text
|| !new_buffer.read(cx).as_rope().chunks().equals_str(new_text)
}
}
}
}
pub struct PendingDiff {
multibuffer: Entity<MultiBuffer>,
base_text: Arc<str>,
new_buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
revealed_ranges: Vec<Range<Anchor>>,
_subscription: Subscription,
update_diff: Task<Result<()>>,
}
impl PendingDiff {
pub fn update(&mut self, cx: &mut Context<Diff>) {
let buffer = self.new_buffer.clone();
let buffer_diff = self.diff.clone();
let base_text = self.base_text.clone();
self.update_diff = cx.spawn(async move |diff, cx| {
let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
let language = buffer.read_with(cx, |buffer, _| buffer.language().cloned());
let update = buffer_diff
.update(cx, |diff, cx| {
diff.update_diff(
text_snapshot.clone(),
Some(base_text.clone()),
None,
language,
cx,
)
})
.await;
let (task1, task2) = buffer_diff.update(cx, |diff, cx| {
let task1 = diff.set_snapshot(update.clone(), &text_snapshot, cx);
let task2 = diff
.secondary_diff()
.unwrap()
.update(cx, |diff, cx| diff.set_snapshot(update, &text_snapshot, cx));
(task1, task2)
});
task1.await;
task2.await;
diff.update(cx, |diff, cx| {
if let Diff::Pending(diff) = diff {
diff.update_visible_ranges(cx);
}
})
});
}
pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Diff>) {
self.revealed_ranges.push(range);
self.update_visible_ranges(cx);
}
fn finalize(&self, cx: &mut Context<Diff>) -> FinalizedDiff {
let ranges = self.excerpt_ranges(cx);
let base_text = self.base_text.clone();
let new_buffer = self.new_buffer.read(cx);
let language_registry = new_buffer.language_registry();
let path = new_buffer
.file()
.map(|file| file.path().display(file.path_style(cx)))
.unwrap_or("untitled".into())
.into();
let replica_id = new_buffer.replica_id();
// Replace the buffer in the multibuffer with the snapshot
let buffer = cx.new(|cx| {
let language = self.new_buffer.read(cx).language().cloned();
let buffer = TextBuffer::new_normalized(
replica_id,
cx.entity_id().as_non_zero_u64().into(),
self.new_buffer.read(cx).line_ending(),
self.new_buffer.read(cx).as_rope().clone(),
);
let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
buffer.set_language(language, cx);
buffer
});
let buffer_diff = cx.spawn({
let buffer = buffer.clone();
async move |_this, cx| {
buffer.update(cx, |buffer, _| buffer.parsing_idle()).await;
build_buffer_diff(base_text, &buffer, language_registry, cx).await
}
});
let update_diff = cx.spawn(async move |this, cx| {
let buffer_diff = buffer_diff.await?;
this.update(cx, |this, cx| {
this.multibuffer().update(cx, |multibuffer, cx| {
let path_key = PathKey::for_buffer(&buffer, cx);
multibuffer.clear(cx);
multibuffer.set_excerpts_for_path(
path_key,
buffer,
ranges,
excerpt_context_lines(cx),
cx,
);
multibuffer.add_diff(buffer_diff.clone(), cx);
});
cx.notify();
})
});
FinalizedDiff {
path,
base_text: self.base_text.clone(),
multibuffer: self.multibuffer.clone(),
new_buffer: self.new_buffer.clone(),
_update_diff: update_diff,
}
}
fn update_visible_ranges(&mut self, cx: &mut Context<Diff>) {
let ranges = self.excerpt_ranges(cx);
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
PathKey::for_buffer(&self.new_buffer, cx),
self.new_buffer.clone(),
ranges,
excerpt_context_lines(cx),
cx,
);
let end = multibuffer.len(cx);
Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
});
cx.notify();
}
fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
let buffer = self.new_buffer.read(cx);
let mut ranges = self
.diff
.read(cx)
.snapshot(cx)
.hunks_intersecting_range(
Anchor::min_for_buffer(buffer.remote_id())
..Anchor::max_for_buffer(buffer.remote_id()),
buffer,
)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
.collect::<Vec<_>>();
ranges.extend(
self.revealed_ranges
.iter()
.map(|range| range.to_point(buffer)),
);
ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
// Merge adjacent ranges
let mut ranges = ranges.into_iter().peekable();
let mut merged_ranges = Vec::new();
while let Some(mut range) = ranges.next() {
while let Some(next_range) = ranges.peek() {
if range.end >= next_range.start {
range.end = range.end.max(next_range.end);
ranges.next();
} else {
break;
}
}
merged_ranges.push(range);
}
merged_ranges
}
}
pub struct FinalizedDiff {
path: String,
base_text: Arc<str>,
new_buffer: Entity<Buffer>,
multibuffer: Entity<MultiBuffer>,
_update_diff: Task<Result<()>>,
}
async fn build_buffer_diff(
old_text: Arc<str>,
buffer: &Entity<Buffer>,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut AsyncApp,
) -> Result<Entity<BufferDiff>> {
let language = cx.update(|cx| buffer.read(cx).language().cloned());
let text_snapshot = cx.update(|cx| buffer.read(cx).text_snapshot());
let buffer = cx.update(|cx| buffer.read(cx).snapshot());
let secondary_diff = cx.new(|cx| BufferDiff::new(&buffer, cx));
let update = secondary_diff
.update(cx, |secondary_diff, cx| {
secondary_diff.update_diff(
text_snapshot.clone(),
Some(old_text),
Some(false),
language.clone(),
cx,
)
})
.await;
secondary_diff
.update(cx, |secondary_diff, cx| {
secondary_diff.set_snapshot(update.clone(), &buffer, cx)
})
.await;
let diff = cx.new(|cx| BufferDiff::new(&buffer, cx));
diff.update(cx, |diff, cx| {
diff.language_changed(language, language_registry, cx);
diff.set_secondary_diff(secondary_diff);
diff.set_snapshot(update.clone(), &buffer, cx)
})
.await;
Ok(diff)
}
#[cfg(test)]
mod tests {
use gpui::{AppContext as _, TestAppContext};
use language::Buffer;
use crate::Diff;
#[gpui::test]
async fn test_pending_diff(cx: &mut TestAppContext) {
let buffer = cx.new(|cx| Buffer::local("hello!", cx));
let _diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
buffer.update(cx, |buffer, cx| {
buffer.set_text("HELLO!", cx);
});
cx.run_until_parked();
}
}

View File

@@ -0,0 +1,996 @@
use agent_client_protocol::schema as acp;
use anyhow::{Context as _, Result, bail};
use file_icons::FileIcons;
use prompt_store::{PromptId, UserPromptId};
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,
},
Rule {
id: PromptId,
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>,
},
Fetch {
url: Url,
},
TerminalSelection {
line_count: u32,
},
GitDiff {
base_ref: String,
},
MergeConflict {
file_path: String,
},
}
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);
fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
let range = fragment.strip_prefix("L").unwrap_or(fragment);
let (start, end) = if let Some((start, end)) = range.split_once(":") {
(start, end)
} else if let Some((start, end)) = range.split_once("-") {
// Also handle L10-20 or L10-L20 format
(start, end.strip_prefix("L").unwrap_or(end))
} else {
// Single line number like L1872 - treat as a range of one line
(range, range)
};
let start_line = start
.parse::<u32>()
.context("Parsing line range start")?
.checked_sub(1)
.context("Line numbers should be 1-based")?;
let end_line = end
.parse::<u32>()
.context("Parsing line range end")?
.checked_sub(1)
.context("Line numbers should be 1-based")?;
Ok(start_line..=end_line)
}
let parse_absolute_path = |input: &str| -> Result<Self> {
let (path_input, fragment) = input
.split_once('#')
.map_or((input, None), |(path, fragment)| (path, Some(fragment)));
if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) {
return Ok(MentionUri::Selection {
abs_path: Some(path_input.into()),
line_range: fragment,
});
}
let path_with_position = PathWithPosition::parse_str(path_input);
let abs_path = path_with_position.path;
if let Some(row) = path_with_position.row {
let line = row
.checked_sub(1)
.context("Line numbers should be 1-based")?;
// TODO: Preserve column info too.
Ok(MentionUri::Selection {
abs_path: Some(abs_path),
line_range: line..=line,
})
} else {
Ok(MentionUri::File { abs_path })
}
};
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() {
Cow::Owned(decoded.replace('/', "\\"))
} else {
decoded
};
let path = normalized.as_ref();
if let Some(fragment) = url.fragment() {
let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);
if let Some(name) = single_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,
})
}
} 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/") {
let name = single_query_param(&url, "name")?.context("Missing rule name")?;
let rule_id = UserPromptId(rule_id.parse()?);
Ok(Self::Rule {
id: rule_id.into(),
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)?;
Ok(Self::Selection {
abs_path: None,
line_range,
})
} 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") {
let fragment = url.fragment().context("Missing fragment for selection")?;
let line_range = parse_line_range(fragment)?;
let path =
single_query_param(&url, "path")?.context("Missing path for selection")?;
Ok(Self::Selection {
abs_path: Some(path.into()),
line_range,
})
} 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 {
bail!("invalid zed url: {:?}", input);
}
}
"http" | "https" => Ok(MentionUri::Fetch { url }),
other => bail!("unrecognized scheme {:?}", other),
}
}
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(),
}
}
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(),
),
_ => 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(),
}
}
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,
} => {
let mut url = if let Some(path) = abs_path {
let mut url = Url::parse("file:///").unwrap();
url.set_path(&path.to_string_lossy());
url
} else {
let mut url = Url::parse("zed:///").unwrap();
url.set_path("/agent/untitled-buffer");
url
};
url.set_fragment(Some(&format!(
"L{}:{}",
line_range.start() + 1,
line_range.end() + 1
)));
url
}
MentionUri::Thread { name, id } => {
let mut url = Url::parse("zed:///").unwrap();
url.set_path(&format!("/agent/thread/{id}"));
url.query_pairs_mut().append_pair("name", name);
url
}
MentionUri::Rule { name, id } => {
let mut url = Url::parse("zed:///").unwrap();
url.set_path(&format!("/agent/rule/{id}"));
url.query_pairs_mut().append_pair("name", name);
url
}
MentionUri::Diagnostics {
include_errors,
include_warnings,
} => {
let mut url = Url::parse("zed:///").unwrap();
url.set_path("/agent/diagnostics");
if *include_warnings {
url.query_pairs_mut()
.append_pair("include_warnings", "true");
}
if !include_errors {
url.query_pairs_mut().append_pair("include_errors", "false");
}
url
}
MentionUri::Fetch { url } => url.clone(),
MentionUri::TerminalSelection { line_count } => {
let mut url = Url::parse("zed:///agent/terminal-selection").unwrap();
url.query_pairs_mut()
.append_pair("lines", &line_count.to_string());
url
}
MentionUri::GitDiff { base_ref } => {
let mut url = Url::parse("zed:///agent/git-diff").unwrap();
url.query_pairs_mut().append_pair("base", base_ref);
url
}
MentionUri::MergeConflict { file_path } => {
let mut url = Url::parse("zed:///agent/merge-conflict").unwrap();
url.query_pairs_mut().append_pair("path", file_path);
url
}
}
}
}
pub struct MentionLink<'a>(&'a MentionUri);
impl fmt::Display for MentionLink<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
}
}
fn default_include_errors() -> bool {
true
}
fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
let pairs = url.query_pairs().collect::<Vec<_>>();
match pairs.as_slice() {
[] => Ok(None),
[(k, v)] => {
if k != name {
bail!("invalid query parameter")
}
Ok(Some(v.to_string()))
}
_ => bail!("too many query pairs"),
}
}
pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
format!(
"{} ({}:{})",
path.and_then(|path| path.file_name())
.unwrap_or("Untitled".as_ref())
.display(),
*line_range.start() + 1,
*line_range.end() + 1
)
}
#[cfg(test)]
mod tests {
use util::{path, uri};
use super::*;
#[test]
fn test_parse_file_uri() {
let file_uri = uri!("file:///path/to/file.rs");
let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::File { abs_path } => {
assert_eq!(abs_path, Path::new(path!("/path/to/file.rs")));
}
_ => panic!("Expected File variant"),
}
assert_eq!(parsed.to_uri().to_string(), file_uri);
}
#[test]
fn test_parse_directory_uri() {
let file_uri = uri!("file:///path/to/dir/");
let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Directory { abs_path } => {
assert_eq!(abs_path, Path::new(path!("/path/to/dir/")));
}
_ => panic!("Expected Directory variant"),
}
assert_eq!(parsed.to_uri().to_string(), file_uri);
}
#[test]
fn test_parse_file_uris_use_native_separators_on_windows() {
let parsed = MentionUri::parse("file:///C:/path/to/file.rs", PathStyle::Windows).unwrap();
match parsed {
MentionUri::File { abs_path } => {
assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs"));
}
other => panic!("Expected File variant, got {other:?}"),
}
let parsed = MentionUri::parse("file:///C:/path/to/dir/", PathStyle::Windows).unwrap();
match parsed {
MentionUri::Directory { abs_path } => {
assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\dir\\"));
}
other => panic!("Expected Directory variant, got {other:?}"),
}
let parsed = MentionUri::parse(
"file:///C:/path/to/file.rs?symbol=MySymbol#L10:20",
PathStyle::Windows,
)
.unwrap();
match parsed {
MentionUri::Symbol { abs_path, .. } => {
assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs"));
}
other => panic!("Expected Symbol variant, got {other:?}"),
}
let parsed =
MentionUri::parse("file:///C:/path/to/file.rs#L5:15", PathStyle::Windows).unwrap();
match parsed {
MentionUri::Selection {
abs_path: Some(abs_path),
..
} => {
assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs"));
}
other => panic!("Expected Selection variant, got {other:?}"),
}
}
#[test]
fn test_to_directory_uri_without_slash() {
let uri = MentionUri::Directory {
abs_path: PathBuf::from(path!("/path/to/dir/")),
};
let expected = uri!("file:///path/to/dir/");
assert_eq!(uri.to_uri().to_string(), expected);
}
#[test]
fn test_directory_uri_round_trip_without_trailing_slash() {
let uri = MentionUri::Directory {
abs_path: PathBuf::from(path!("/path/to/dir")),
};
let serialized = uri.to_uri().to_string();
assert!(serialized.ends_with('/'), "directory URI must end with /");
let parsed = MentionUri::parse(&serialized, PathStyle::local()).unwrap();
assert!(
matches!(parsed, MentionUri::Directory { .. }),
"expected Directory variant, got {:?}",
parsed
);
}
#[test]
fn test_parse_symbol_uri() {
let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Symbol {
abs_path: path,
name,
line_range,
} => {
assert_eq!(path, Path::new(path!("/path/to/file.rs")));
assert_eq!(name, "MySymbol");
assert_eq!(line_range.start(), &9);
assert_eq!(line_range.end(), &19);
}
_ => panic!("Expected Symbol variant"),
}
assert_eq!(parsed.to_uri().to_string(), symbol_uri);
}
#[test]
fn test_parse_selection_uri() {
let selection_uri = uri!("file:///path/to/file.rs#L5:15");
let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &4);
assert_eq!(line_range.end(), &14);
}
_ => panic!("Expected Selection variant"),
}
assert_eq!(parsed.to_uri().to_string(), selection_uri);
}
#[test]
fn test_parse_file_uri_with_non_ascii() {
let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::File { abs_path } => {
assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
}
_ => panic!("Expected File variant"),
}
assert_eq!(parsed.to_uri().to_string(), file_uri);
}
#[test]
fn test_parse_untitled_selection_uri() {
let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: None,
line_range,
} => {
assert_eq!(line_range.start(), &0);
assert_eq!(line_range.end(), &9);
}
_ => panic!("Expected Selection variant without path"),
}
assert_eq!(parsed.to_uri().to_string(), selection_uri);
}
#[test]
fn test_parse_thread_uri() {
let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Thread {
id: thread_id,
name,
} => {
assert_eq!(thread_id.to_string(), "session123");
assert_eq!(name, "Thread name");
}
_ => panic!("Expected Thread variant"),
}
assert_eq!(parsed.to_uri().to_string(), thread_uri);
}
#[test]
fn test_parse_rule_uri() {
let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Rule { id, name } => {
assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
assert_eq!(name, "Some rule");
}
_ => panic!("Expected Rule variant"),
}
assert_eq!(parsed.to_uri().to_string(), rule_uri);
}
#[test]
fn test_parse_fetch_http_uri() {
let http_uri = "http://example.com/path?query=value#fragment";
let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Fetch { url } => {
assert_eq!(url.to_string(), http_uri);
}
_ => panic!("Expected Fetch variant"),
}
assert_eq!(parsed.to_uri().to_string(), http_uri);
}
#[test]
fn test_parse_fetch_https_uri() {
let https_uri = "https://example.com/api/endpoint";
let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Fetch { url } => {
assert_eq!(url.to_string(), https_uri);
}
_ => panic!("Expected Fetch variant"),
}
assert_eq!(parsed.to_uri().to_string(), https_uri);
}
#[test]
fn test_parse_diagnostics_uri() {
let uri = "zed:///agent/diagnostics?include_warnings=true";
let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Diagnostics {
include_errors,
include_warnings,
} => {
assert!(include_errors);
assert!(include_warnings);
}
_ => panic!("Expected Diagnostics variant"),
}
assert_eq!(parsed.to_uri().to_string(), uri);
}
#[test]
fn test_parse_diagnostics_uri_warnings_only() {
let uri = "zed:///agent/diagnostics?include_warnings=true&include_errors=false";
let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Diagnostics {
include_errors,
include_warnings,
} => {
assert!(!include_errors);
assert!(include_warnings);
}
_ => panic!("Expected Diagnostics variant"),
}
assert_eq!(parsed.to_uri().to_string(), uri);
}
#[test]
fn test_invalid_scheme() {
assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err());
assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err());
assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err());
}
#[test]
fn test_invalid_zed_path() {
assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err());
assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err());
}
#[test]
fn test_parse_absolute_file_path() {
let file_path = path!("/path/to/file.rs");
let parsed = MentionUri::parse(file_path, PathStyle::local()).unwrap();
match &parsed {
MentionUri::File { abs_path } => {
assert_eq!(abs_path, Path::new(file_path));
}
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_parse_absolute_file_path_with_row() {
let file_path = "/path/to/file.rs:42";
let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_absolute_file_path_with_fragment_line() {
let file_path = "/path/to/file.rs#L42";
let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_absolute_windows_path() {
let file_path = "C:\\Users\\zed\\project\\main.rs";
let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
match &parsed {
MentionUri::File { abs_path } => {
assert_eq!(abs_path, Path::new("C:\\Users\\zed\\project\\main.rs"));
}
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_parse_absolute_windows_file_path_with_row() {
let file_path = "C:\\Users\\zed\\project\\main.rs:42";
let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(
path.as_ref().unwrap(),
Path::new("C:\\Users\\zed\\project\\main.rs")
);
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_absolute_windows_file_path_with_fragment_line() {
let file_path = "C:\\Users\\zed\\project\\main.rs#L42";
let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(
path.as_ref().unwrap(),
Path::new("C:\\Users\\zed\\project\\main.rs")
);
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_backticked_absolute_file_path() {
let file_path = "`/path/to/file.rs`";
let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap();
match &parsed {
MentionUri::File { abs_path } => {
assert_eq!(abs_path, Path::new("/path/to/file.rs"));
}
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_parse_backticked_absolute_file_path_with_fragment_line() {
let file_path = "`/path/to/file.rs#L42`";
let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_backticked_absolute_windows_file_path_with_fragment_line() {
let file_path = "`C:\\Users\\zed\\project\\main.rs#L42`";
let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(
path.as_ref().unwrap(),
Path::new("C:\\Users\\zed\\project\\main.rs")
);
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_single_line_number() {
// https://github.com/zed-industries/zed/issues/46114
let uri = uri!("file:///path/to/file.rs#L1872");
let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &1871);
assert_eq!(line_range.end(), &1871);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_dash_separated_line_range() {
let uri = uri!("file:///path/to/file.rs#L10-20");
let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9);
assert_eq!(line_range.end(), &19);
}
_ => panic!("Expected Selection variant"),
}
// Also test L10-L20 format
let uri = uri!("file:///path/to/file.rs#L10-L20");
let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9);
assert_eq!(line_range.end(), &19);
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_terminal_selection_uri() {
let terminal_uri = "zed:///agent/terminal-selection?lines=42";
let parsed = MentionUri::parse(terminal_uri, PathStyle::local()).unwrap();
match &parsed {
MentionUri::TerminalSelection { line_count } => {
assert_eq!(*line_count, 42);
}
_ => panic!("Expected TerminalSelection variant"),
}
assert_eq!(parsed.to_uri().to_string(), terminal_uri);
assert_eq!(parsed.name(), "Terminal (42 lines)");
// Test single line
let single_line_uri = "zed:///agent/terminal-selection?lines=1";
let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap();
assert_eq!(parsed_single.name(), "Terminal (1 line)");
}
}

View File

@@ -0,0 +1,255 @@
use agent_client_protocol::schema as acp;
use anyhow::Result;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, AsyncApp, Context, Entity, Task};
use language::LanguageRegistry;
use markdown::Markdown;
use project::Project;
use std::{
path::PathBuf,
process::ExitStatus,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Instant,
};
use task::Shell;
use util::get_default_system_shell_preferring_bash;
pub struct Terminal {
id: acp::TerminalId,
command: Entity<Markdown>,
working_dir: Option<PathBuf>,
terminal: Entity<terminal::Terminal>,
started_at: Instant,
output: Option<TerminalOutput>,
output_byte_limit: Option<usize>,
_output_task: Shared<Task<acp::TerminalExitStatus>>,
/// Flag indicating whether this terminal was stopped by explicit user action
/// (e.g., clicking the Stop button). This is set before kill() is called
/// so that code awaiting wait_for_exit() can check it deterministically.
user_stopped: Arc<AtomicBool>,
}
pub struct TerminalOutput {
pub ended_at: Instant,
pub exit_status: Option<ExitStatus>,
pub content: String,
pub original_content_len: usize,
pub content_line_count: usize,
}
impl Terminal {
pub fn new(
id: acp::TerminalId,
command_label: &str,
working_dir: Option<PathBuf>,
output_byte_limit: Option<usize>,
terminal: Entity<terminal::Terminal>,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> Self {
let command_task = terminal.read(cx).wait_for_completed_task(cx);
Self {
id,
command: cx.new(|cx| {
Markdown::new(
format!("```\n{}\n```", command_label).into(),
Some(language_registry.clone()),
None,
cx,
)
}),
working_dir,
terminal,
started_at: Instant::now(),
output: None,
output_byte_limit,
user_stopped: Arc::new(AtomicBool::new(false)),
_output_task: cx
.spawn(async move |this, cx| {
let exit_status = command_task.await;
this.update(cx, |this, cx| {
let (content, original_content_len) = this.truncated_output(cx);
let content_line_count = this.terminal.read(cx).total_lines();
this.output = Some(TerminalOutput {
ended_at: Instant::now(),
exit_status,
content,
original_content_len,
content_line_count,
});
cx.notify();
})
.ok();
let exit_status = exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalExitStatus::new()
.exit_code(exit_status.as_ref().map(|e| e.exit_code()))
.signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned)))
})
.shared(),
}
}
pub fn id(&self) -> &acp::TerminalId {
&self.id
}
pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
self._output_task.clone()
}
pub fn kill(&mut self, cx: &mut App) {
self.terminal.update(cx, |terminal, _cx| {
terminal.kill_active_task();
});
}
/// Marks this terminal as stopped by user action and then kills it.
/// This should be called when the user explicitly clicks a Stop button.
pub fn stop_by_user(&mut self, cx: &mut App) {
self.user_stopped.store(true, Ordering::SeqCst);
self.kill(cx);
}
/// Returns whether this terminal was stopped by explicit user action.
pub fn was_stopped_by_user(&self) -> bool {
self.user_stopped.load(Ordering::SeqCst)
}
pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
if let Some(output) = self.output.as_ref() {
let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalOutputResponse::new(
output.content.clone(),
output.original_content_len > output.content.len(),
)
.exit_status(
acp::TerminalExitStatus::new()
.exit_code(exit_status.as_ref().map(|e| e.exit_code()))
.signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))),
)
} else {
let (current_content, original_len) = self.truncated_output(cx);
let truncated = current_content.len() < original_len;
acp::TerminalOutputResponse::new(current_content, truncated)
}
}
fn truncated_output(&self, cx: &App) -> (String, usize) {
let terminal = self.terminal.read(cx);
let mut content = terminal.get_content();
let original_content_len = content.len();
if let Some(limit) = self.output_byte_limit
&& content.len() > limit
{
let mut end_ix = limit.min(content.len());
while !content.is_char_boundary(end_ix) {
end_ix -= 1;
}
// Don't truncate mid-line, clear the remainder of the last line
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
content.truncate(end_ix);
}
(content, original_content_len)
}
pub fn command(&self) -> &Entity<Markdown> {
&self.command
}
pub fn update_command_label(&self, label: &str, cx: &mut App) {
self.command.update(cx, |command, cx| {
command.replace(format!("```\n{}\n```", label), cx);
});
}
pub fn working_dir(&self) -> &Option<PathBuf> {
&self.working_dir
}
pub fn started_at(&self) -> Instant {
self.started_at
}
pub fn output(&self) -> Option<&TerminalOutput> {
self.output.as_ref()
}
pub fn inner(&self) -> &Entity<terminal::Terminal> {
&self.terminal
}
pub fn to_markdown(&self, cx: &App) -> String {
format!(
"Terminal:\n```\n{}\n```\n",
self.terminal.read(cx).get_content()
)
}
}
pub async fn create_terminal_entity(
command: String,
args: &[String],
env_vars: Vec<(String, String)>,
cwd: Option<PathBuf>,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<Entity<terminal::Terminal>> {
let mut env = if let Some(dir) = &cwd {
project
.update(cx, |project, cx| {
project.environment().update(cx, |env, cx| {
env.directory_environment(dir.clone().into(), cx)
})
})
.await
.unwrap_or_default()
} else {
Default::default()
};
// Disable pagers so agent/terminal commands don't hang behind interactive UIs
env.insert("PAGER".into(), "".into());
// Override user core.pager (e.g. delta) which Git prefers over PAGER
env.insert("GIT_PAGER".into(), "cat".into());
env.extend(env_vars);
// Use remote shell or default system shell, as appropriate
let shell = project
.update(cx, |project, cx| {
project
.remote_client()
.and_then(|r| r.read(cx).default_system_shell())
.map(Shell::Program)
})
.unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash()));
let is_windows = project.read_with(cx, |project, cx| project.path_style(cx).is_windows());
let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows)
.redirect_stdin_to_dev_null()
.build(Some(command.clone()), &args);
project
.update(cx, |project, cx| {
project.create_terminal_task(
task::SpawnInTerminal {
command: Some(task_command),
args: task_args,
cwd,
env,
..Default::default()
},
cx,
)
})
.await
}