Merge branch 'main' into fix-worktree-drag-reorder

This commit is contained in:
Elliot Thomas 2026-05-11 11:45:59 +01:00 committed by GitHub
commit 239953eb42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 14680 additions and 11672 deletions

View file

@ -0,0 +1,73 @@
# Community PR Board — route labeled community PRs to a GitHub Project board
#
# When an area/platform label is added to a community PR (not staff, not bot),
# the PR is added to the project board with a Track field set to the matching
# review area group. Status transitions are driven by assignment, review,
# re-request, and comment events.
#
# See script/community-pr-track-mapping.json for the label→track mapping.
name: Community PR Board
on:
pull_request_target:
types: [labeled, unlabeled, assigned, review_requested]
pull_request_review:
types: [submitted]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to process (re-resolves track from current labels)"
required: true
type: number
permissions:
contents: read
concurrency:
group: community-pr-board-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }}
cancel-in-progress: false
jobs:
route-pr:
if: >-
github.repository == 'zed-industries/zed' &&
(github.event_name != 'issue_comment' ||
(github.event.issue.pull_request &&
github.event.comment.user.login == github.event.issue.user.login))
runs-on: namespace-profile-2x4-ubuntu-2404
timeout-minutes: 5
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
owner: zed-industries
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
sparse-checkout: |
script/github-community-pr-board.py
script/community-pr-track-mapping.json
sparse-checkout-cone-mode: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install dependencies
run: pip install requests
- name: Route PR to board
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PROJECT_NUMBER: "85"
MANUAL_PR_NUMBER: ${{ inputs.pr_number }}
run: python script/github-community-pr-board.py

112
Cargo.lock generated
View file

@ -183,7 +183,6 @@ dependencies = [
"language_models",
"log",
"lsp",
"open",
"parking_lot",
"paths",
"pretty_assertions",
@ -3892,7 +3891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b"
dependencies = [
"bitflags 2.10.0",
"fontdb 0.23.0",
"fontdb",
"harfrust",
"linebender_resource_handle",
"log",
@ -4941,6 +4940,7 @@ dependencies = [
"component",
"ctor",
"editor",
"futures-lite 1.13.0",
"gpui",
"indoc",
"itertools 0.14.0",
@ -6502,20 +6502,6 @@ dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser 0.20.0",
]
[[package]]
name = "fontdb"
version = "0.23.0"
@ -6527,7 +6513,7 @@ dependencies = [
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser 0.25.1",
"ttf-parser",
]
[[package]]
@ -7356,6 +7342,7 @@ dependencies = [
"language",
"language_model",
"menu",
"picker",
"project",
"project_panel",
"rand 0.9.4",
@ -7737,7 +7724,7 @@ dependencies = [
"sum_tree",
"taffy",
"thiserror 2.0.17",
"ttf-parser 0.25.1",
"ttf-parser",
"unicode-segmentation",
"url",
"usvg",
@ -8614,7 +8601,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@ -8632,7 +8619,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.62.2",
]
[[package]]
@ -9246,23 +9233,24 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.90"
version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6"
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "json5"
version = "0.4.1"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c"
dependencies = [
"pest",
"pest_derive",
"serde",
"ucd-trie",
]
[[package]]
@ -10684,18 +10672,18 @@ dependencies = [
[[package]]
name = "mermaid-rs-renderer"
version = "0.2.0"
source = "git+https://github.com/zed-industries/mermaid-rs-renderer?rev=374db9ead5426697c6c2111151d9f246899bc638#374db9ead5426697c6c2111151d9f246899bc638"
version = "0.2.2"
source = "git+https://github.com/zed-industries/mermaid-rs-renderer?rev=782b89a7da3f0e91e51f98d00a93acba679be6fb#782b89a7da3f0e91e51f98d00a93acba679be6fb"
dependencies = [
"anyhow",
"fontdb 0.16.2",
"fontdb",
"json5",
"once_cell",
"regex",
"serde",
"serde_json",
"thiserror 2.0.17",
"ttf-parser 0.20.0",
"ttf-parser",
]
[[package]]
@ -11777,9 +11765,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.3"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
@ -13746,7 +13734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
dependencies = [
"bytes 1.11.1",
"heck 0.4.1",
"heck 0.5.0",
"itertools 0.11.0",
"log",
"multimap",
@ -14013,7 +14001,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.40",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.17",
"tokio",
"tracing",
@ -14050,7 +14038,7 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@ -14934,9 +14922,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "1.3.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419"
checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad"
dependencies = [
"async-trait",
"base64 0.22.1",
@ -14956,9 +14944,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "1.3.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43"
checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@ -15031,13 +15019,13 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rpassword"
version = "7.4.0"
version = "7.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -15450,7 +15438,7 @@ dependencies = [
"core_maths",
"log",
"smallvec",
"ttf-parser 0.25.1",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
@ -16564,7 +16552,7 @@ version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -18951,12 +18939,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
[[package]]
name = "ttf-parser"
version = "0.25.1"
@ -19305,7 +19287,7 @@ dependencies = [
"base64 0.22.1",
"data-url",
"flate2",
"fontdb 0.23.0",
"fontdb",
"imagesize",
"kurbo",
"log",
@ -19700,9 +19682,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.113"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2"
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
dependencies = [
"cfg-if",
"once_cell",
@ -19713,23 +19695,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.63"
version = "0.4.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a"
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.113"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950"
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -19737,9 +19715,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.113"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60"
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
dependencies = [
"bumpalo",
"proc-macro2",
@ -19750,9 +19728,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.113"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5"
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
dependencies = [
"unicode-ident",
]
@ -20391,9 +20369,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.90"
version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97"
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
dependencies = [
"js-sys",
"wasm-bindgen",

View file

@ -388,7 +388,7 @@ markdown_preview = { path = "crates/markdown_preview" }
svg_preview = { path = "crates/svg_preview" }
media = { path = "crates/media" }
menu = { path = "crates/menu" }
mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", rev = "374db9ead5426697c6c2111151d9f246899bc638", default-features = false }
mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", rev = "782b89a7da3f0e91e51f98d00a93acba679be6fb", default-features = false }
migrator = { path = "crates/migrator" }
mistral = { path = "crates/mistral" }
multi_buffer = { path = "crates/multi_buffer" }
@ -802,7 +802,7 @@ wasmtime = { version = "36", default-features = false, features = [
wasmtime-wasi = "36"
wax = "0.7"
which = "6.0.0"
wasm-bindgen = "0.2.113"
wasm-bindgen = "0.2.120"
web-time = "1.1.0"
webrtc-sys = "0.3.23"
wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" }

View file

@ -1117,18 +1117,12 @@
"get_code_actions": true,
"go_to_definition": true,
"list_directory": true,
"project_notifications": false,
"move_path": true,
"now": true,
"rename_symbol": true,
"read_file": true,
"restore_file_from_disk": true,
"save_file": true,
"open": true,
"grep": true,
"spawn_agent": true,
"terminal": true,
"thinking": true,
"update_plan": true,
"search_web": true,
},
@ -1141,17 +1135,13 @@
"diagnostics": true,
"fetch": true,
"list_directory": true,
"project_notifications": false,
"now": true,
"find_path": true,
"find_references": true,
"get_code_actions": true,
"go_to_definition": true,
"read_file": true,
"open": true,
"grep": true,
"spawn_agent": true,
"thinking": true,
"update_plan": true,
"search_web": true,
},

View file

@ -570,6 +570,22 @@ impl From<RequestPermissionOutcome> for acp::RequestPermissionOutcome {
}
}
/// What a `WaitingForConfirmation` prompt represents semantically.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationKind {
/// The user is granting or denying permission for the tool call to
/// proceed. The selected `PermissionOptionKind` determines whether the
/// tool call transitions to `InProgress` (allow) or `Rejected` (reject).
/// This is the default for tool authorization prompts.
PermissionGrant,
/// The user is choosing between actions for the tool to take next
/// (for example, "Save" vs "Discard" before editing a dirty buffer).
/// The tool call always transitions to `InProgress` regardless of the
/// selected `PermissionOptionKind`; the caller interprets the chosen
/// `option_id` to decide what to do.
ActionChoice,
}
#[derive(Debug)]
pub enum ToolCallStatus {
/// The tool call hasn't started running yet, but we start showing it to
@ -579,6 +595,7 @@ pub enum ToolCallStatus {
WaitingForConfirmation {
options: PermissionOptions,
respond_tx: oneshot::Sender<SelectedPermissionOutcome>,
kind: AuthorizationKind,
},
/// The tool call is currently running.
InProgress,
@ -2080,6 +2097,7 @@ impl AcpThread {
&mut self,
tool_call: acp::ToolCallUpdate,
options: PermissionOptions,
kind: AuthorizationKind,
cx: &mut Context<Self>,
) -> Result<Task<RequestPermissionOutcome>> {
let (tx, rx) = oneshot::channel();
@ -2087,6 +2105,7 @@ impl AcpThread {
let status = ToolCallStatus::WaitingForConfirmation {
options,
respond_tx: tx,
kind,
};
let tool_call_id = tool_call.tool_call_id.clone();
@ -2118,15 +2137,25 @@ impl AcpThread {
return;
};
let new_status = match outcome.option_kind {
acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => {
ToolCallStatus::Rejected
let is_action_choice = matches!(
call.status,
ToolCallStatus::WaitingForConfirmation {
kind: AuthorizationKind::ActionChoice,
..
}
acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => {
);
let new_status =
if is_action_choice {
ToolCallStatus::InProgress
}
_ => ToolCallStatus::InProgress,
};
} else {
match outcome.option_kind {
acp::PermissionOptionKind::RejectOnce
| acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected,
acp::PermissionOptionKind::AllowOnce
| acp::PermissionOptionKind::AllowAlways => ToolCallStatus::InProgress,
_ => ToolCallStatus::InProgress,
}
};
let curr_status = mem::replace(&mut call.status, new_status);

View file

@ -641,6 +641,8 @@ mod test_support {
use gpui::{AppContext as _, WeakEntity};
use parking_lot::Mutex;
use crate::AuthorizationKind;
use super::*;
/// Creates a PNG image encoded as base64 for testing.
@ -915,6 +917,7 @@ mod test_support {
thread.request_tool_call_authorization(
tool_call.clone().into(),
options.clone(),
AuthorizationKind::PermissionGrant,
cx,
)
})??

View file

@ -817,4 +817,9 @@ impl StatusItemView for ActivityIndicator {
_: &mut Context<Self>,
) {
}
fn hide_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
// Activity indicator auto-hides when there's no work to display.
None
}
}

View file

@ -46,7 +46,6 @@ language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
open.workspace = true
parking_lot.workspace = true
paths.workspace = true
project.workspace = true

View file

@ -1298,9 +1298,12 @@ impl NativeAgentConnection {
options,
response,
context: _,
kind,
}) => {
let outcome_task = acp_thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(tool_call, options, cx)
thread.request_tool_call_authorization(
tool_call, options, kind, cx,
)
})??;
cx.background_spawn(async move {
if let acp_thread::RequestPermissionOutcome::Selected(outcome) =

View file

@ -39,12 +39,31 @@ pub struct SystemPromptTemplate<'a> {
pub project: &'a prompt_store::ProjectContext,
pub available_tools: Vec<SharedString>,
pub model_name: Option<String>,
pub date: String,
}
impl Template for SystemPromptTemplate<'_> {
const TEMPLATE_NAME: &'static str = "system_prompt.hbs";
}
impl SystemPromptTemplate<'_> {
const EXPERIMENTAL_TEMPLATE_NAME: &'static str = "experimental_system_prompt.hbs";
pub fn render_with_prompt_variant(
&self,
templates: &Templates,
use_experimental_prompt: bool,
) -> Result<String> {
let template_name = if use_experimental_prompt {
Self::EXPERIMENTAL_TEMPLATE_NAME
} else {
<Self as Template>::TEMPLATE_NAME
};
Ok(templates.0.render(template_name, self)?)
}
}
/// Handlebars helper for checking if an item is in a list
fn contains(
h: &handlebars::Helper,
@ -81,11 +100,31 @@ mod tests {
project: &project,
available_tools: vec!["echo".into()],
model_name: Some("test-model".to_string()),
date: "2026-01-01".to_string(),
};
let templates = Templates::new();
let rendered = template.render(&templates).unwrap();
assert!(rendered.contains("You are a highly skilled software engineer"));
assert!(rendered.contains("## Fixing Diagnostics"));
assert!(!rendered.contains("## Planning"));
assert!(rendered.contains("test-model"));
}
#[test]
fn test_experimental_system_prompt_template() {
let project = prompt_store::ProjectContext::default();
let template = SystemPromptTemplate {
project: &project,
available_tools: vec!["echo".into()],
model_name: Some("test-model".to_string()),
date: "2026-01-01".to_string(),
};
let templates = Templates::new();
let rendered = template
.render_with_prompt_variant(&templates, true)
.unwrap();
assert!(rendered.contains("You are the Zed coding agent"));
assert!(rendered.contains("Today's Date: 2026-01-01"));
assert!(rendered.contains("test-model"));
}
}

View file

@ -0,0 +1,193 @@
You are the Zed coding agent running inside the Zed editor. You help users complete software engineering tasks by understanding their codebase, making careful changes, and explaining your work clearly. Use your broad knowledge of programming languages, frameworks, design patterns, and engineering best practices to solve problems pragmatically.
## Communication
- Default to a tone that is concise, direct, and friendly. Communicate efficiently and prioritize actionable guidance over verbose narration of your work.
- Format responses in markdown. Use backticks for file paths, directories, commands, functions, classes, and other code identifiers.
- Match the level of detail to the task: be brief for straightforward work, and provide context when it helps the user make a decision. Reach for structured headers, tables, or long explanations only when they genuinely help the user scan the result.
- Be accurate and truthful. Ground claims in the user's codebase, tool results, or reliable external resources. Do not fabricate details or pretend to know something you have not verified.
- Prioritize technical correctness over affirming the user's assumptions. If something seems wrong or risky, say so respectfully and explain the reasoning.
- Be transparent about uncertainty. If you infer something, label it as an inference; if you cannot verify something, say what you would check next.
- Do not over-apologize when results are unexpected. Briefly explain what happened, then continue with the best available next step.
{{#if (gt (len available_tools) 0)}}
## Tool Use
- Follow the available tool schemas exactly and provide every required argument.
- Use only the tools that are currently available. Do not call a tool just because it appeared earlier in the conversation; the user may have disabled it.
- Prefer the most direct tool for the job. Use file tools for reading and editing files, search tools for code discovery, and terminal commands for build, test, and project-specific workflows.
- Before acting, gather enough context to avoid guessing. Do not use placeholders, invented paths, or assumed command arguments in tool calls.
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead.
- When running commands that may run indefinitely or for a long time, such as builds, tests, servers, or file watchers, specify `timeout_ms` to bound runtime. If a command times out, report that clearly and let the user decide whether to rerun it with a longer timeout.
- Avoid HTML entity escaping; use plain characters instead.
- Do not waste tokens by re-reading files after calling `write_file`, `edit_file`, or similar. The tool call will fail if it didn't work. The same goes for creating folders, deleting folders, etc.
- Before a group of related tool calls, send a brief one- to two-sentence preamble explaining what you're about to do, so the user can follow along. Skip the preamble for trivial single reads or when continuing a clearly described step.
## Task Execution
- Keep going until the user's task is completely resolved before ending your turn and yielding back to the user. Only terminate your turn when you are sure the problem is solved.
- Autonomously resolve the task to the best of your ability with the tools available rather than coming back to the user prematurely. Ask the user only when the information you need is genuinely unavailable from the project, or when proceeding without clarification would be risky.
- Do not guess or make up an answer.
{{#if (contains available_tools 'update_plan') }}
## Planning
- You have access to an `update_plan` tool that tracks steps and progress and renders them to the user.
- Use it to show that you understand the task and to make complex, ambiguous, or multi-phase work easier to follow.
- A good plan is short, concrete, logically ordered, and easy to verify. Each step should describe a real unit of work.
- Mark completed steps promptly before moving to the next phase.
- Do not use plans for simple or single-step queries that you can answer or complete immediately.
- Do not pad plans with filler steps, obvious actions, or work you are not capable of doing.
- After calling `update_plan`, do not repeat the full plan in your response. The UI already displays it. Briefly summarize any important change and continue.
- You can mark multiple steps completed in a single `update_plan` call.
- If the task changes midway through, update the plan so it reflects the new approach.
Use a plan when:
- The task is non-trivial and will require multiple actions over a longer horizon.
- There are logical phases or dependencies where sequencing matters.
- The work has ambiguity that benefits from outlining high-level goals.
- You want intermediate checkpoints for feedback and validation.
- The user asked you to do more than one thing in a single prompt.
- You discover additional steps while working and intend to complete them before yielding to the user.
{{/if}}
## Searching and Reading
If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions.
- When providing paths to tools, the path should always start with the name of a project root directory listed above.
- Before you read or edit a file, you must first know its full project-relative path. Do not guess file paths.
- Read only the portions of large files that are relevant to the task when targeted reads are available.
{{#if (contains available_tools 'grep') }}
- When looking for symbols in the project, prefer the `grep` tool.
- As you learn about the structure of the project, scope searches to targeted subtrees instead of repeatedly searching the whole repository.
- If the user specifies a partial file path and you do not know the full path, use `find_path` rather than `grep` before reading or editing the file.
{{/if}}
## Making Code Changes
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
- Avoid unneeded complexity in your solution.
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
- Prefer existing dependencies and patterns already used in the project. Add new dependencies only when they are justified by the task.
- Keep user work safe. Do not overwrite, remove, or revert changes you did not make unless the user explicitly asks.
- Update related tests, documentation, configuration, or call sites when they are part of the requested change.
- Do not fix unrelated bugs or broken tests. It is not your responsibility to fix them, but you may mention them in your final message.
- Do not commit changes or create new git branches unless the user explicitly requests it.
- Do not add comments that merely restate the code. Add comments only when they explain non-obvious intent, constraints, or tradeoffs.
- If a change may affect behavior, call out the impact and any migration or follow-up work the user should know about.
## Ambition vs. Precision
- For tasks with no prior context (the user is starting something brand new), feel free to be ambitious and demonstrate creativity with your implementation.
- For tasks in an existing codebase, do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (e.g. changing filenames or variables unnecessarily). Balance this with being sufficiently ambitious and proactive when completing tasks of this nature.
- Use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. Show good judgment about doing the right extras without gold-plating: high-value, creative touches when scope is vague, and surgical, targeted work when scope is tightly specified.
## Validation
- If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
- Start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence.
- Do not claim validation passed unless you actually ran it and saw it pass.
- If validation fails, report the failing command and the relevant error. Fix issues you caused when you can identify the root cause.
- If you cannot run validation, state that clearly and explain why.
## Fixing Diagnostics
1. Make 1-2 focused attempts at fixing diagnostics you are likely able to resolve, then defer to the user with a clear explanation of what remains.
2. Never simplify or discard meaningful code just to silence diagnostics. Complete, mostly correct code is more valuable than superficially clean code that does not solve the problem.
## Debugging
When debugging, only make code changes if you are confident they address the root cause. Otherwise, first gather evidence and isolate the problem.
1. Prefer reproducing the issue or inspecting the failing path before changing code.
2. Address the root cause instead of the symptoms.
3. Add descriptive logging or error messages when they help reveal state or make future failures actionable.
4. Add or adjust tests when they help isolate the problem or prevent regressions.
## Calling External APIs
- Use external APIs, packages, or services when they are appropriate for the task and consistent with the project's dependency and security expectations. You do not need to ask permission unless the user requested a specific constraint.
- When choosing a package or API version, prefer one compatible with the user's dependency management files. If the project provides no guidance, use a stable, current version you know to be appropriate.
- If an external API requires an API key or secret, tell the user. Never hardcode secrets or place them where they may be exposed.
- Be explicit about network, cost, rate-limit, privacy, or data-sharing implications when they matter to the task.
{{#if (contains available_tools 'spawn_agent') }}
## Multi-agent delegation
Sub-agents can help you move faster on large tasks when you use them thoughtfully. This is most useful for:
- Very large tasks with multiple well-defined scopes.
- Plans with independent steps that can be executed in parallel.
- Independent information-gathering tasks that can be done in parallel.
- Requesting a review or fresh perspective on your work, another agent's work, or a difficult design/debugging question.
- Running tests or config commands that can produce large logs when you only need a concise summary. Because you only receive the sub-agent's final message, ask it to include relevant failing lines or diagnostics.
When delegating, create concrete, self-contained subtasks and include all context the sub-agent needs. Coordinate the work instead of duplicating it yourself. If multiple agents may edit files, assign disjoint write scopes.
Use this feature wisely. For simple or straightforward tasks, prefer doing the work directly.
{{/if}}
## Final Message
- When you finish a coding task, briefly summarize what changed, reference the relevant files, and state what validation you ran (or why you did not run any).
- Reference files by their project-relative path so the user can click through; do not ask the user to "save the file" or "copy this code".
- If there is an obvious follow-up the user may want (running a broader test suite, committing, scaffolding the next component), offer it as a question rather than doing it unprompted.
{{else}}
You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system other than the context the user provides.
Give the best answer you can from the available context. If you need the user to perform an action, request it explicitly and explain what information or result you need.
If the user references a file, function, type, command, or other project-specific item that is not present in the provided context, do not invent details or assume how it works. Ask for clarification or ask the user to provide the relevant content.
{{/if}}
## System Information
Operating System: {{os}}
Default Shell: {{shell}}
Today's Date: {{date}}
The current project contains the following root directories:
{{#each worktrees}}
- `{{abs_path}}`
{{/each}}
{{#if model_name}}
## Model Information
You are powered by the model named {{model_name}}.
{{/if}}
{{#if (or has_rules has_user_rules)}}
## User's Custom Instructions
The following additional instructions are provided by the user and should be followed to the best of your ability{{#if (gt (len available_tools) 0)}} without interfering with the tool use guidelines{{/if}}.
{{#if has_rules}}
There are project rules that apply to these root directories:
{{#each worktrees}}
{{#if rules_file}}
`{{root_name}}/{{rules_file.path_in_worktree}}`:
``````
{{{rules_file.text}}}
``````
{{/if}}
{{/each}}
{{/if}}
{{#if has_user_rules}}
The user has specified the following rules that should be applied:
{{#each user_rules}}
{{#if title}}
Rules title: {{title}}
{{/if}}
``````
{{contents}}
``````
{{/each}}
{{/if}}
{{/if}}

View file

@ -26,10 +26,11 @@ use gpui::{
use indoc::indoc;
use language_model::{
CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolSchemaFormat,
LanguageModelToolUse, MessageContent, Role, StopReason, TokenUsage,
fake_provider::FakeLanguageModel,
LanguageModelId, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRegistry,
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult,
LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, Role, StopReason,
TokenUsage,
fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
};
use pretty_assertions::assert_eq;
use project::{
@ -40,7 +41,7 @@ use reqwest_client::ReqwestClient;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use settings::{Settings, SettingsStore};
use settings::{LanguageModelProviderSetting, LanguageModelSelection, Settings, SettingsStore};
use std::{
path::Path,
pin::Pin,
@ -5495,6 +5496,90 @@ async fn test_subagent_thread_inherits_parent_thread_properties(cx: &mut TestApp
});
}
#[gpui::test]
async fn test_subagent_thread_uses_configured_subagent_model(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/test"), json!({})).await;
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
let project_context = cx.new(|_cx| ProjectContext::default());
let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let parent_model = Arc::new(FakeLanguageModel::default());
let subagent_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
"fake-corp",
"subagent-model",
"Subagent Model",
true,
));
cx.update(|cx| {
LanguageModelRegistry::test(cx);
let provider = Arc::new(
FakeLanguageModelProvider::new(
LanguageModelProviderId::from("fake-corp".to_string()),
LanguageModelProviderName::from("Fake Corp".to_string()),
)
.with_models(vec![subagent_model.clone()]),
);
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.register_provider(provider, cx);
});
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.subagent_model = Some(LanguageModelSelection {
provider: LanguageModelProviderSetting("fake-corp".to_string()),
model: "subagent-model".to_string(),
enable_thinking: true,
effort: Some("high".to_string()),
speed: None,
});
agent_settings::AgentSettings::override_global(settings, cx);
});
let parent_thread = cx.new(|cx| {
Thread::new(
project.clone(),
project_context,
context_server_registry,
Templates::new(),
Some(parent_model.clone()),
cx,
)
});
let subagent_thread = cx.new(|cx| Thread::new_subagent(&parent_thread, cx));
subagent_thread.read_with(cx, |subagent_thread, _cx| {
assert_eq!(
subagent_thread.model().map(|model| model.id()),
Some(subagent_model.id())
);
assert!(subagent_thread.thinking_enabled());
assert_eq!(subagent_thread.thinking_effort(), Some(&"high".to_string()));
});
parent_thread.update(cx, |parent_thread, _cx| {
parent_thread.register_running_subagent(subagent_thread.downgrade());
});
parent_thread.update(cx, |parent_thread, cx| {
parent_thread.set_model(parent_model.clone(), cx);
parent_thread.set_thinking_enabled(false, cx);
parent_thread.set_thinking_effort(None, cx);
});
subagent_thread.read_with(cx, |subagent_thread, _cx| {
assert_eq!(
subagent_thread.model().map(|model| model.id()),
Some(subagent_model.id())
);
assert!(subagent_thread.thinking_enabled());
assert_eq!(subagent_thread.thinking_effort(), Some(&"high".to_string()));
});
}
#[gpui::test]
async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppContext) {
init_test(cx);
@ -5579,12 +5664,11 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
GetCodeActionsTool::NAME,
ApplyCodeActionTool::NAME,
GoToDefinitionTool::NAME,
RenameTool::NAME,
];
// All LSP tools should be registered on the thread regardless of the flag,
// since the feature flag now only controls exposure to the model rather
// than registration.
// All LSP tools and the rename tool should be registered on the thread
// regardless of the flag, since the feature flags only control exposure
// to the model rather than registration.
thread.read_with(cx, |thread, _| {
for name in &lsp_tool_names {
assert!(
@ -5592,10 +5676,16 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
"expected LSP tool {name} to be registered"
);
}
assert!(
thread.has_registered_tool(RenameTool::NAME),
"expected rename tool to be registered"
);
});
// Without the `lsp-tool` flag, sending a message should produce a
// completion request whose tool list excludes the LSP tools.
// The rename tool is on its own `rename-tool` flag with
// `enabled_for_staff`, so it is already visible in debug builds.
thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["hello"], cx)
@ -5612,6 +5702,11 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
but completion tools were: {tool_names:?}"
);
}
assert!(
tool_names.iter().any(|t| t == RenameTool::NAME),
"expected rename tool to be visible (enabled_for_staff in debug builds), \
but completion tools were: {tool_names:?}"
);
// Sanity check: a non-LSP default tool should still be exposed.
assert!(
tool_names.iter().any(|t| t == ReadFileTool::NAME),
@ -5642,6 +5737,11 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
but completion tools were: {tool_names:?}"
);
}
assert!(
tool_names.iter().any(|t| t == RenameTool::NAME),
"expected rename tool to still be exposed, \
but completion tools were: {tool_names:?}"
);
}
#[gpui::test]
@ -6391,112 +6491,6 @@ async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"normal.txt": "normal content",
"readonly": {
"config.txt": "readonly content"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
SaveFileTool::NAME.into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Allow),
always_allow: vec![],
always_deny: vec![agent_settings::CompiledRegex::new(r"readonly", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
agent_settings::AgentSettings::override_global(settings, cx);
});
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::SaveFileTool::new(project));
let (event_stream, _rx) = crate::ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(crate::SaveFileToolInput {
paths: vec![
std::path::PathBuf::from("root/normal.txt"),
std::path::PathBuf::from("root/readonly/config.txt"),
],
}),
event_stream,
cx,
)
});
let result = task.await;
assert!(
result.is_err(),
"expected save to be blocked due to denied path"
);
assert!(
result.unwrap_err().contains("blocked"),
"error should mention the save was blocked"
);
}
#[gpui::test]
async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"config.secret": "secret config"}))
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
SaveFileTool::NAME.into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Allow),
always_allow: vec![],
always_deny: vec![agent_settings::CompiledRegex::new(r"\.secret$", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
agent_settings::AgentSettings::override_global(settings, cx);
});
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::SaveFileTool::new(project));
let (event_stream, _rx) = crate::ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(crate::SaveFileToolInput {
paths: vec![std::path::PathBuf::from("root/config.secret")],
}),
event_stream,
cx,
)
});
let result = task.await;
assert!(result.is_err(), "expected save to be blocked");
assert!(
result.unwrap_err().contains("blocked"),
"error should mention the save was blocked"
);
}
#[gpui::test]
async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -2,21 +2,23 @@ use crate::{
ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool,
DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool,
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool,
RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template,
Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool,
decide_permission_from_settings,
ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool,
SystemPromptTemplate, Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool,
WebSearchTool, WriteFileTool, decide_permission_from_settings,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
use feature_flags::{FeatureFlagAppExt as _, LspToolFeatureFlag, UpdatePlanToolFeatureFlag};
use feature_flags::{
ExperimentalSystemPromptFeatureFlag, FeatureFlagAppExt as _, LspToolFeatureFlag,
RenameToolFeatureFlag, UpdatePlanToolFeatureFlag,
};
use agent_client_protocol::schema as acp;
use agent_settings::{
AgentProfileId, AgentSettings, SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT,
};
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
use chrono::{DateTime, Local, Utc};
use client::UserStore;
use cloud_api_types::Plan;
use collections::{HashMap, HashSet, IndexMap};
@ -825,7 +827,6 @@ impl ToolPermissionContext {
|| tool_name == WriteFileTool::NAME
|| tool_name == DeletePathTool::NAME
|| tool_name == CreateDirectoryTool::NAME
|| tool_name == SaveFileTool::NAME
{
(
extract_path_pattern(value),
@ -925,6 +926,7 @@ pub struct ToolCallAuthorization {
pub options: acp_thread::PermissionOptions,
pub response: oneshot::Sender<acp_thread::SelectedPermissionOutcome>,
pub context: Option<ToolPermissionContext>,
pub kind: acp_thread::AuthorizationKind,
}
#[derive(Debug, thiserror::Error)]
@ -984,6 +986,7 @@ pub struct Thread {
ui_scroll_position: Option<gpui::ListOffset>,
/// Weak references to running subagent threads for cancellation propagation
running_subagents: Vec<WeakEntity<Thread>>,
inherits_parent_model_settings: bool,
}
impl Thread {
@ -1017,6 +1020,10 @@ impl Thread {
depth: parent_thread.read(cx).depth() + 1,
});
thread.inherit_parent_settings(parent_thread, cx);
if let Some(subagent_model) = AgentSettings::get_global(cx).subagent_model.clone() {
thread.inherits_parent_model_settings = false;
thread.apply_model_selection(&subagent_model, cx);
}
thread
}
@ -1105,6 +1112,7 @@ impl Thread {
draft_prompt: None,
ui_scroll_position: None,
running_subagents: Vec::new(),
inherits_parent_model_settings: true,
}
}
@ -1121,6 +1129,29 @@ impl Thread {
self.profile_id = parent.profile_id.clone();
}
fn apply_model_selection(
&mut self,
selection: &LanguageModelSelection,
cx: &mut Context<Self>,
) {
let Some(model) = Self::resolve_model_from_selection(selection, cx) else {
log::warn!(
"failed to resolve configured subagent model: {}/{}",
selection.provider.0,
selection.model
);
return;
};
self.model = Some(model.clone());
self.thinking_enabled = selection.enable_thinking && model.supports_thinking();
self.thinking_effort = selection.effort.clone();
self.speed = selection.speed.filter(|_| model.supports_fast_mode());
self.prompt_capabilities_tx
.send(Self::prompt_capabilities(self.model.as_deref()))
.log_err();
}
pub fn id(&self) -> &acp::SessionId {
&self.id
}
@ -1338,6 +1369,7 @@ impl Thread {
offset_in_item: gpui::px(sp.offset_in_item),
}),
running_subagents: Vec::new(),
inherits_parent_model_settings: true,
}
}
@ -1441,7 +1473,11 @@ impl Thread {
for subagent in &self.running_subagents {
subagent
.update(cx, |thread, cx| thread.set_model(model.clone(), cx))
.update(cx, |thread, cx| {
if thread.inherits_parent_model_settings {
thread.set_model(model.clone(), cx);
}
})
.ok();
}
@ -1478,7 +1514,11 @@ impl Thread {
for subagent in &self.running_subagents {
subagent
.update(cx, |thread, cx| thread.set_thinking_enabled(enabled, cx))
.update(cx, |thread, cx| {
if thread.inherits_parent_model_settings {
thread.set_thinking_enabled(enabled, cx);
}
})
.ok();
}
cx.notify();
@ -1494,7 +1534,9 @@ impl Thread {
for subagent in &self.running_subagents {
subagent
.update(cx, |thread, cx| {
thread.set_thinking_effort(effort.clone(), cx)
if thread.inherits_parent_model_settings {
thread.set_thinking_effort(effort.clone(), cx)
}
})
.ok();
}
@ -1510,7 +1552,11 @@ impl Thread {
for subagent in &self.running_subagents {
subagent
.update(cx, |thread, cx| thread.set_speed(speed, cx))
.update(cx, |thread, cx| {
if thread.inherits_parent_model_settings {
thread.set_speed(speed, cx);
}
})
.ok();
}
cx.notify();
@ -1561,8 +1607,6 @@ impl Thread {
self.add_tool(GrepTool::new(self.project.clone()));
self.add_tool(ListDirectoryTool::new(self.project.clone()));
self.add_tool(MovePathTool::new(self.project.clone()));
self.add_tool(NowTool);
self.add_tool(OpenTool::new(self.project.clone()));
if cx.has_flag::<UpdatePlanToolFeatureFlag>() {
self.add_tool(UpdatePlanTool);
}
@ -1571,8 +1615,6 @@ impl Thread {
self.action_log.clone(),
update_agent_location,
));
self.add_tool(SaveFileTool::new(self.project.clone()));
self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
self.add_tool(TerminalTool::new(self.project.clone(), environment.clone()));
self.add_tool(WebSearchTool);
@ -2893,16 +2935,13 @@ impl Thread {
None
}
})
.filter(|(tool_name, _)| {
cx.has_flag::<LspToolFeatureFlag>()
|| !matches!(
tool_name.as_ref(),
FindReferencesTool::NAME
| GetCodeActionsTool::NAME
| ApplyCodeActionTool::NAME
| GoToDefinitionTool::NAME
| RenameTool::NAME
)
.filter(|(tool_name, _)| match tool_name.as_ref() {
RenameTool::NAME => cx.has_flag::<RenameToolFeatureFlag>(),
FindReferencesTool::NAME
| GetCodeActionsTool::NAME
| ApplyCodeActionTool::NAME
| GoToDefinitionTool::NAME => cx.has_flag::<LspToolFeatureFlag>(),
_ => true,
})
.collect::<BTreeMap<_, _>>();
@ -3023,12 +3062,14 @@ impl Thread {
self.messages.len()
);
let use_experimental_prompt = cx.has_flag::<ExperimentalSystemPromptFeatureFlag>();
let system_prompt = SystemPromptTemplate {
project: self.project_context.read(cx),
available_tools,
model_name: self.model.as_ref().map(|m| m.name().0.to_string()),
date: Local::now().format("%Y-%m-%d").to_string(),
}
.render(&self.templates)
.render_with_prompt_variant(&self.templates, use_experimental_prompt)
.context("failed to build system prompt")
.expect("Invalid template");
let mut messages = vec![LanguageModelRequestMessage {
@ -3878,6 +3919,57 @@ impl ToolCallEventStream {
self.run_authorization_loop(title, options, Some(context), None, cx)
}
/// Prompts the user to choose between an explicit set of actions and
/// returns the chosen `option_id`.
///
/// Unlike [`Self::authorize`] / [`Self::authorize_always_prompt`], this
/// does not interpret the user's choice as a permission grant — callers
/// are responsible for handling each `option_id` explicitly. Use this
/// when a tool needs the user to pick between several side-effecting
/// actions (for example, "Save" vs "Discard" for a dirty buffer).
pub fn prompt_for_decision(
&self,
title: Option<String>,
message: Option<String>,
options: Vec<acp::PermissionOption>,
cx: &mut App,
) -> Task<Result<acp::PermissionOptionId>> {
let options = acp_thread::PermissionOptions::Flat(options);
let stream = self.stream.clone();
let tool_use_id = self.tool_use_id.clone();
cx.spawn(async move |_cx| {
let mut fields = acp::ToolCallUpdateFields::new();
if let Some(title) = title {
fields = fields.title(title);
}
if let Some(message) = message {
fields = fields.content(vec![acp::ToolCallContent::from(message)]);
}
let (response_tx, response_rx) = oneshot::channel();
if let Err(error) = stream
.0
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
ToolCallAuthorization {
tool_call: acp::ToolCallUpdate::new(tool_use_id.to_string(), fields),
options,
response: response_tx,
context: None,
kind: acp_thread::AuthorizationKind::ActionChoice,
},
)))
{
log::error!("Failed to send tool call decision prompt: {error}");
return Err(anyhow!("Failed to send tool call decision prompt: {error}"));
}
let outcome = response_rx
.await
.map_err(|_| anyhow!("authorization channel closed"))?;
Ok(outcome.option_id)
})
}
/// Prompts the user for authorization.
///
/// When `check_settings` is `Some`, this gate is settings-driven: the
@ -3925,6 +4017,7 @@ impl ToolCallEventStream {
options,
response: response_tx,
context,
kind: acp_thread::AuthorizationKind::PermissionGrant,
},
)))
{

View file

@ -576,6 +576,7 @@ mod tests {
default_height: px(600.),
max_content_width: Some(px(850.)),
default_model: None,
subagent_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
commit_message_model: None,

View file

@ -16,12 +16,8 @@ mod go_to_definition_tool;
mod grep_tool;
mod list_directory_tool;
mod move_path_tool;
mod now_tool;
mod open_tool;
mod read_file_tool;
mod rename_tool;
mod restore_file_from_disk_tool;
mod save_file_tool;
mod spawn_agent_tool;
mod symbol_locator;
mod terminal_tool;
@ -75,12 +71,8 @@ pub use go_to_definition_tool::*;
pub use grep_tool::*;
pub use list_directory_tool::*;
pub use move_path_tool::*;
pub use now_tool::*;
pub use open_tool::*;
pub use read_file_tool::*;
pub use rename_tool::*;
pub use restore_file_from_disk_tool::*;
pub use save_file_tool::*;
pub use spawn_agent_tool::*;
pub use symbol_locator::*;
pub use terminal_tool::*;
@ -172,12 +164,8 @@ tools! {
GrepTool,
ListDirectoryTool,
MovePathTool,
NowTool,
OpenTool,
ReadFileTool,
RenameTool,
RestoreFileFromDiskTool,
SaveFileTool,
SpawnAgentTool,
TerminalTool,
UpdatePlanTool,

View file

@ -23,10 +23,7 @@ const DEFAULT_UI_TEXT: &str = "Editing file";
/// This is a tool for applying edits to an existing file.
///
/// Before using this tool:
///
/// 1. Use the `read_file` tool to understand the file's contents and context
///
/// Before using this tool, use the `read_file` tool to understand the file's contents and context
/// To create a new file or overwrite an existing one with completely new contents, use the `write_file` tool instead.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct EditFileToolInput {
@ -1871,9 +1868,12 @@ mod tests {
assert_eq!(input_path, Some(PathBuf::from("root/test.txt")));
}
/// When the buffer has unsaved changes and the user picks "Save", the
/// pending edits are flushed to disk and the agent's edit then proceeds
/// against the just-saved content.
#[gpui::test]
async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) {
let (edit_tool, project, action_log, _fs, _thread) =
async fn test_streaming_dirty_buffer_save(cx: &mut TestAppContext) {
let (edit_tool, project, action_log, fs, _thread) =
setup_test(cx, json!({"test.txt": "original content"})).await;
let read_tool = Arc::new(crate::ReadFileTool::new(
project.clone(),
@ -1881,7 +1881,6 @@ mod tests {
true,
));
// Read the file first
cx.update(|cx| {
read_tool.clone().run(
ToolInput::resolved(crate::ReadFileToolInput {
@ -1896,7 +1895,6 @@ mod tests {
.await
.unwrap();
// Open the buffer and make it dirty
let project_path = project
.read_with(cx, |project, cx| {
project.find_project_path("root/test.txt", cx)
@ -1909,54 +1907,219 @@ mod tests {
buffer.update(cx, |buffer, cx| {
let end_point = buffer.max_point();
buffer.edit([(end_point..end_point, " added text")], None, cx);
buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
});
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
edit_tool.clone().run(
ToolInput::resolved(EditFileToolInput {
path: "root/test.txt".into(),
edits: vec![Edit {
old_text: "original content plus user edit".into(),
new_text: "replaced content".into(),
}],
}),
stream_tx,
cx,
)
});
let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
assert!(is_dirty, "Buffer should be dirty after in-memory edit");
// Try to edit - should fail because buffer has unsaved changes
let result = cx
.update(|cx| {
edit_tool.clone().run(
ToolInput::resolved(EditFileToolInput {
path: "root/test.txt".into(),
edits: vec![Edit {
old_text: "original content".into(),
new_text: "new content".into(),
}],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await;
let EditFileToolOutput::Error {
error,
diff,
input_path,
} = result.unwrap_err()
let _update = stream_rx.expect_update_fields().await;
let auth = stream_rx.expect_authorization().await;
let content = auth.tool_call.fields.content.as_deref().unwrap_or(&[]);
let acp::ToolCallContent::Content(text) = content.first().expect("expected message body")
else {
panic!("expected error");
panic!("expected text body, got: {:?}", content.first());
};
let acp::ContentBlock::Text(text) = &text.content else {
panic!("expected text body, got: {:?}", text.content);
};
assert!(
error.contains("This file has unsaved changes."),
"Error should mention unsaved changes, got: {}",
error
text.text.contains("unsaved changes")
&& text.text.contains("save")
&& text.text.contains("discard"),
"unexpected message body: {:?}",
text.text,
);
assert!(
error.contains("keep or discard"),
"Error should ask whether to keep or discard changes, got: {}",
error
);
assert!(
error.contains("save or revert the file manually"),
"Error should ask user to manually save or revert when tools aren't available, got: {}",
error
);
assert!(diff.is_empty());
assert!(input_path.is_none());
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("save"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
panic!("expected success");
};
assert_eq!(new_text, "replaced content");
assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap();
assert_eq!(on_disk, "replaced content");
}
/// When the buffer has unsaved changes and the user picks "Discard", the
/// pending edits are reverted to match disk and the agent's edit then
/// proceeds against the on-disk content.
#[gpui::test]
async fn test_streaming_dirty_buffer_discard(cx: &mut TestAppContext) {
let (edit_tool, project, action_log, fs, _thread) =
setup_test(cx, json!({"test.txt": "original content"})).await;
let read_tool = Arc::new(crate::ReadFileTool::new(
project.clone(),
action_log.clone(),
true,
));
cx.update(|cx| {
read_tool.clone().run(
ToolInput::resolved(crate::ReadFileToolInput {
path: "root/test.txt".to_string(),
start_line: None,
end_line: None,
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
let project_path = project
.read_with(cx, |project, cx| {
project.find_project_path("root/test.txt", cx)
})
.expect("Should find project path");
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await
.unwrap();
buffer.update(cx, |buffer, cx| {
let end_point = buffer.max_point();
buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
});
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
edit_tool.clone().run(
ToolInput::resolved(EditFileToolInput {
path: "root/test.txt".into(),
// Match the on-disk content, not the dirty in-memory content.
edits: vec![Edit {
old_text: "original content".into(),
new_text: "replaced content".into(),
}],
}),
stream_tx,
cx,
)
});
let _update = stream_rx.expect_update_fields().await;
let auth = stream_rx.expect_authorization().await;
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("discard"),
acp::PermissionOptionKind::RejectOnce,
))
.unwrap();
let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
panic!("expected success");
};
assert_eq!(new_text, "replaced content");
assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap();
assert_eq!(on_disk, "replaced content");
}
/// When the buffer is dirty and the user resolves it manually — e.g.
/// pressing `cmd-s` while the prompt is visible — the prompt is
/// dismissed automatically and the edit proceeds against the saved
/// content. The user shouldn't have to also click a button.
#[gpui::test]
async fn test_streaming_dirty_buffer_resolved_externally(cx: &mut TestAppContext) {
let (edit_tool, project, action_log, fs, _thread) =
setup_test(cx, json!({"test.txt": "original content"})).await;
let read_tool = Arc::new(crate::ReadFileTool::new(
project.clone(),
action_log.clone(),
true,
));
cx.update(|cx| {
read_tool.clone().run(
ToolInput::resolved(crate::ReadFileToolInput {
path: "root/test.txt".to_string(),
start_line: None,
end_line: None,
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
let project_path = project
.read_with(cx, |project, cx| {
project.find_project_path("root/test.txt", cx)
})
.expect("Should find project path");
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await
.unwrap();
buffer.update(cx, |buffer, cx| {
let end_point = buffer.max_point();
buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
});
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
edit_tool.clone().run(
ToolInput::resolved(EditFileToolInput {
path: "root/test.txt".into(),
edits: vec![Edit {
old_text: "original content plus user edit".into(),
new_text: "replaced content".into(),
}],
}),
stream_tx,
cx,
)
});
let _update = stream_rx.expect_update_fields().await;
let auth = stream_rx.expect_authorization().await;
// Simulate the user saving the buffer manually (e.g. cmd-s) while
// the prompt is visible. The tool should detect the buffer became
// clean and proceed without the user clicking anything.
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
.await
.unwrap();
// The prompt's response channel should drop without a click; the
// tool dismisses the prompt by transitioning the tool call status
// to `InProgress`.
let dismiss = stream_rx.expect_update_fields().await;
assert_eq!(dismiss.status, Some(acp::ToolCallStatus::InProgress));
drop(auth);
let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
panic!("expected success");
};
assert_eq!(new_text, "replaced content");
assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap();
assert_eq!(on_disk, "replaced content");
}
#[gpui::test]

View file

@ -2,17 +2,16 @@ mod reindent;
mod streaming_fuzzy_matcher;
mod streaming_parser;
use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
use super::save_file_tool::SaveFileTool;
use crate::{AgentTool, Thread, ToolCallEventStream};
use crate::{Thread, ToolCallEventStream};
use acp_thread::Diff;
use action_log::ActionLog;
use agent_client_protocol::schema::{ToolCallLocation, ToolCallUpdateFields};
use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields};
use anyhow::Result;
use collections::HashSet;
use futures::{FutureExt, channel::oneshot};
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
use language::language_settings::{self, FormatOnSave};
use language::{Buffer, LanguageRegistry};
use language::{Buffer, BufferEvent, LanguageRegistry};
use language_model::LanguageModelToolResultContent;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use project::{AgentLocation, Project, ProjectPath};
@ -665,7 +664,8 @@ impl EditSession {
.await
.map_err(|e| e.to_string())?;
let file_changed_since_last_read = ensure_buffer_saved(&buffer, &abs_path, &context, cx)?;
let file_changed_since_last_read =
ensure_buffer_saved(&buffer, &abs_path, mode, &context, event_stream, cx).await?;
let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
event_stream.update_diff(diff.clone());
@ -932,53 +932,25 @@ fn agent_edit_buffer<I, S, T>(
});
}
fn ensure_buffer_saved(
async fn ensure_buffer_saved(
buffer: &Entity<Buffer>,
abs_path: &PathBuf,
mode: EditSessionMode,
context: &EditSessionContext,
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<bool, String> {
let last_read_mtime = context
.action_log
.read_with(cx, |log, _| log.file_read_time(abs_path));
let check_result = context.thread.read_with(cx, |thread, cx| {
let current = buffer
.read(cx)
.file()
.and_then(|file| file.disk_state().mtime());
let dirty = buffer.read(cx).is_dirty();
let has_save = thread.has_tool(SaveFileTool::NAME);
let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
(current, dirty, has_save, has_restore)
let (current_mtime, is_dirty) = buffer.read_with(cx, |buffer, _cx| {
let current = buffer.file().and_then(|file| file.disk_state().mtime());
let dirty = buffer.is_dirty();
(current, dirty)
});
let Ok((current_mtime, is_dirty, has_save_tool, has_restore_tool)) = check_result else {
return Ok(false);
};
if is_dirty {
let message = match (has_save_tool, has_restore_tool) {
(true, true) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
}
(true, false) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
}
(false, true) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
}
(false, false) => {
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
then ask them to save or revert the file manually and inform you when it's ok to proceed."
}
};
return Err(message.to_string());
resolve_dirty_buffer(buffer, mode, context, event_stream, cx).await?;
}
if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime)
@ -990,6 +962,99 @@ fn ensure_buffer_saved(
Ok(false)
}
/// Prompts the user about how to handle a dirty buffer that the agent
/// wants to edit (`EditSessionMode::Edit`) or overwrite
/// (`EditSessionMode::Write`), and performs the chosen action so the
/// edit session can proceed (or returns `Err` to cancel).
///
/// If the user resolves the dirty state externally (e.g. cmd-s or
/// reload) while the prompt is visible, the prompt is dismissed
/// automatically.
async fn resolve_dirty_buffer(
buffer: &Entity<Buffer>,
mode: EditSessionMode,
context: &EditSessionContext,
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<(), String> {
let (manual_resolve_tx, manual_resolve_rx) = oneshot::channel::<()>();
let _buffer_subscription = cx.update(|cx| {
let mut tx = Some(manual_resolve_tx);
cx.subscribe(buffer, move |buffer, event: &BufferEvent, cx| {
if matches!(
event,
BufferEvent::Saved | BufferEvent::Reloaded | BufferEvent::DirtyChanged
) && !buffer.read(cx).is_dirty()
&& let Some(tx) = tx.take()
{
tx.send(()).ok();
}
})
});
let prompt_kind = match mode {
EditSessionMode::Edit => super::tool_permissions::DirtyBufferPromptKind::Edit,
EditSessionMode::Write => super::tool_permissions::DirtyBufferPromptKind::Overwrite,
};
let prompt = cx.update(|cx| {
super::tool_permissions::authorize_dirty_buffer(prompt_kind, event_stream, cx)
});
let decision = futures::select_biased! {
_ = manual_resolve_rx.fuse() => {
None
}
decision = prompt.fuse() => {
Some(decision.map_err(|e| e.to_string())?)
}
};
let Some(decision) = decision else {
event_stream.update_fields(
acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress),
);
return match mode {
EditSessionMode::Edit => Ok(()),
EditSessionMode::Write => Err(
"The user saved their unsaved changes while the prompt was visible; \
the file overwrite was cancelled to preserve them. Ask the user how \
they'd like to proceed before retrying."
.to_string(),
),
};
};
match decision {
super::tool_permissions::DirtyBufferDecision::Save => {
context
.project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
.await
.map_err(|e| format!("Failed to save buffer: {e}"))?;
}
super::tool_permissions::DirtyBufferDecision::Discard => {
context
.project
.update(cx, |project, cx| {
project.reload_buffers(HashSet::from_iter([buffer.clone()]), false, cx)
})
.await
.map_err(|e| format!("Failed to discard unsaved changes: {e}"))?;
}
super::tool_permissions::DirtyBufferDecision::Keep => {
let error = "The user chose to keep their unsaved changes; the file overwrite \
was cancelled. Ask the user how they'd like to proceed before \
retrying."
.to_string();
event_stream.update_fields(
acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]),
);
return Err(error);
}
}
Ok(())
}
fn resolve_path(
mode: EditSessionMode,
path: &PathBuf,

View file

@ -370,6 +370,7 @@ impl EditToolTest {
project: &project_context,
available_tools: tool_names,
model_name: None,
date: chrono::Local::now().format("%Y-%m-%d").to_string(),
};
let templates = Templates::new();
template.render(&templates)?

View file

@ -229,6 +229,7 @@ impl TerminalToolTest {
project: &project_context,
available_tools: tool_names,
model_name: None,
date: chrono::Local::now().format("%Y-%m-%d").to_string(),
};
template.render(&Templates::new())?
};

View file

@ -200,6 +200,7 @@ impl WriteToolTest {
project: &project_context,
available_tools: tool_names,
model_name: None,
date: chrono::Local::now().format("%Y-%m-%d").to_string(),
};
let templates = Templates::new();
template.render(&templates)?

View file

@ -11,7 +11,7 @@ use std::fmt::Write;
use std::{cmp, path::PathBuf, sync::Arc};
use util::paths::PathMatcher;
/// Fast file path pattern matching tool that works with any codebase size
/// Find file paths that match a given pattern.
///
/// - Supports glob patterns like "**/*.js" or "src/**/*.ts"
/// - Returns matching file paths sorted alphabetically

View file

@ -11,11 +11,9 @@ use serde::{Deserialize, Serialize};
/// Finds all references to a symbol across the project using the language server.
///
/// Returns a list of locations where the symbol is referenced, including file paths,
/// line numbers, and code snippets for each reference.
/// Returns a list of locations where the symbol is referenced, including file paths, line numbers, and code snippets for each reference.
///
/// Before using this tool, use read_file or grep to find the exact symbol
/// name and line number.
/// Before using this tool, use read_file or grep to find the exact symbol name and line number.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct FindReferencesToolInput {
/// The symbol to find references of.

View file

@ -12,14 +12,11 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput};
/// Gets the list of available code actions at a symbol location from the language server.
///
/// Code actions include quick fixes, refactorings, and other automated transformations
/// suggested by the language server (e.g. "Add missing import", "Extract to function").
/// Code actions include quick fixes, refactorings, and other automated transformations suggested by the language server (e.g. "Add missing import", "Extract to function").
///
/// Returns a numbered list of available actions. Use apply_code_action with the
/// corresponding number to apply one.
/// Returns a numbered list of available actions. Use apply_code_action with the corresponding number to apply one.
///
/// Before using this tool, use read_file or grep to find the exact symbol
/// name and line number.
/// Before using this tool, use read_file or grep to find the exact symbol name and line number.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct GetCodeActionsToolInput {
/// The symbol to get code actions for.

View file

@ -11,11 +11,9 @@ use serde::{Deserialize, Serialize};
/// Jumps to the definition of a symbol using the language server.
///
/// Returns the file path and line number of the symbol's definition,
/// along with a snippet of the source code at that location.
/// Returns the file path and line number of the symbol's definition, along with a snippet of the source code at that location.
///
/// Before using this tool, use read_file or grep to find the exact symbol
/// name and line number of a usage you want to navigate from.
/// Before using this tool, use read_file or grep to find the exact symbol name and line number of a usage you want to navigate from.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct GoToDefinitionToolInput {
/// The symbol to find the definition of.

View file

@ -1,91 +0,0 @@
use std::sync::Arc;
use agent_client_protocol::schema as acp;
use chrono::{Local, Utc};
use gpui::{App, SharedString, Task};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::deserialize_maybe_stringified;
use crate::{AgentTool, ToolCallEventStream, ToolInput};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[schemars(inline)]
pub enum Timezone {
#[serde(alias = "UTC", alias = "Utc")]
Utc,
#[serde(alias = "LOCAL", alias = "Local")]
Local,
}
/// Returns the current datetime in RFC 3339 format.
/// Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct NowToolInput {
/// The timezone to use for the datetime. Use `utc` for UTC, or `local` for the system's local time.
#[serde(deserialize_with = "deserialize_maybe_stringified")]
timezone: Timezone,
}
pub struct NowTool;
impl AgentTool for NowTool {
type Input = NowToolInput;
type Output = String;
const NAME: &'static str = "now";
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
_input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
"Get current time".into()
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String, String>> {
cx.spawn(async move |_cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
let now = match input.timezone {
Timezone::Utc => Utc::now().to_rfc3339(),
Timezone::Local => Local::now().to_rfc3339(),
};
Ok(format!("The current datetime is {now}."))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use serde_json::json;
#[gpui::test]
async fn test_stringified_timezone_input_succeeds(cx: &mut TestAppContext) {
let tool = Arc::new(NowTool);
let (mut sender, input) = ToolInput::<NowToolInput>::test();
let (event_stream, _receiver) = ToolCallEventStream::test();
let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
sender.send_full(json!({
"timezone": "\"utc\""
}));
let result = task.await.unwrap();
assert!(
result.starts_with("The current datetime is "),
"unexpected output: {result}"
);
}
}

View file

@ -1,227 +0,0 @@
use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
resolve_project_path,
};
use crate::{AgentTool, ToolInput};
use agent_client_protocol::schema as acp;
use futures::FutureExt as _;
use gpui::{App, AppContext as _, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, sync::Arc};
use util::markdown::MarkdownEscaped;
/// This tool opens a file or URL with the default application associated with it on the user's operating system:
///
/// - On macOS, it's equivalent to the `open` command
/// - On Windows, it's equivalent to `start`
/// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate
///
/// For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc.
///
/// You MUST ONLY use this tool when the user has explicitly requested opening something. You MUST NEVER assume that the user would like for you to use this tool.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct OpenToolInput {
/// The path or URL to open with the default application.
path_or_url: String,
}
pub struct OpenTool {
project: Entity<Project>,
}
impl OpenTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for OpenTool {
type Input = OpenToolInput;
type Output = String;
const NAME: &'static str = "open";
fn kind() -> acp::ToolKind {
acp::ToolKind::Execute
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
if let Ok(input) = input {
format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into()
} else {
"Open file or URL".into()
}
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
event_stream: crate::ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output, Self::Output>> {
let project = self.project.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
// If path_or_url turns out to be a path in the project, make it absolute.
let (abs_path, initial_title) = cx.update(|cx| {
let abs_path = to_absolute_path(&input.path_or_url, project.clone(), cx);
let initial_title = self.initial_title(Ok(input.clone()), cx);
(abs_path, initial_title)
});
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
// Symlink escape authorization replaces (rather than supplements)
// the normal tool-permission prompt. The symlink prompt already
// requires explicit user approval with the canonical target shown,
// which is strictly more security-relevant than a generic confirm.
let symlink_escape = project.read_with(cx, |project, cx| {
match resolve_project_path(
project,
PathBuf::from(&input.path_or_url),
&canonical_roots,
cx,
) {
Ok(ResolvedProjectPath::SymlinkEscape {
canonical_target, ..
}) => Some(canonical_target),
_ => None,
}
});
let authorize = if let Some(canonical_target) = symlink_escape {
cx.update(|cx| {
authorize_symlink_access(
Self::NAME,
&input.path_or_url,
&canonical_target,
&event_stream,
cx,
)
})
} else {
cx.update(|cx| {
let context = crate::ToolPermissionContext::new(
Self::NAME,
vec![input.path_or_url.clone()],
);
event_stream.authorize(initial_title, context, cx)
})
};
futures::select! {
result = authorize.fuse() => result.map_err(|e| e.to_string())?,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Open cancelled by user".to_string());
}
}
let path_or_url = input.path_or_url.clone();
cx.background_spawn(async move {
match abs_path {
Some(path) => open::that(path),
None => open::that(path_or_url),
}
.map_err(|e| format!("Failed to open URL or file path: {e}"))
})
.await?;
Ok(format!("Successfully opened {}", input.path_or_url))
})
}
}
fn to_absolute_path(
potential_path: &str,
project: Entity<Project>,
cx: &mut App,
) -> Option<PathBuf> {
let project = project.read(cx);
project
.find_project_path(PathBuf::from(potential_path), cx)
.and_then(|project_path| project.absolute_path(&project_path, cx))
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use project::{FakeFs, Project};
use settings::SettingsStore;
use std::path::Path;
use tempfile::TempDir;
#[gpui::test]
async fn test_to_absolute_path(cx: &mut TestAppContext) {
init_test(cx);
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let temp_path = temp_dir.path().to_string_lossy().into_owned();
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
&temp_path,
serde_json::json!({
"src": {
"main.rs": "fn main() {}",
"lib.rs": "pub fn lib_fn() {}"
},
"docs": {
"readme.md": "# Project Documentation"
}
}),
)
.await;
// Use the temp_path as the root directory, not just its filename
let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
// Test cases where the function should return Some
cx.update(|cx| {
// Project-relative paths should return Some
// Create paths using the last segment of the temp path to simulate a project-relative path
let root_dir_name = Path::new(&temp_path)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("temp"))
.to_string_lossy();
assert!(
to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
.is_some(),
"Failed to resolve main.rs path"
);
assert!(
to_absolute_path(
&format!("{root_dir_name}/docs/readme.md",),
project.clone(),
cx,
)
.is_some(),
"Failed to resolve readme.md path"
);
// External URL should return None
let result = to_absolute_path("https://example.com", project.clone(), cx);
assert_eq!(result, None, "External URLs should return None");
// Path outside project
let result = to_absolute_path("../invalid/path", project.clone(), cx);
assert_eq!(result, None, "Paths outside the project should return None");
});
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
}

View file

@ -12,12 +12,9 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput};
/// Renames a symbol across the project using the language server.
///
/// This performs a semantic rename, updating all references to the symbol
/// across all files in the project. The language server determines which
/// occurrences to rename based on the symbol's type and scope.
/// This performs a semantic rename, updating all references to the symbol across all files in the project. The language server determines which occurrences to rename based on the symbol's type and scope.
///
/// Before using this tool, use read_file or grep to find the exact symbol
/// name and line number.
/// Before using this tool, use read_file or grep to find the exact symbol name and line number.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct RenameToolInput {
/// The symbol to rename.

View file

@ -1,673 +0,0 @@
use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
path_has_symlink_escape, resolve_project_path, sensitive_settings_kind,
};
use agent_client_protocol::schema as acp;
use agent_settings::AgentSettings;
use collections::FxHashSet;
use futures::FutureExt as _;
use gpui::{App, Entity, SharedString, Task};
use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
use crate::{
AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
authorize_with_sensitive_settings, decide_permission_for_path,
};
/// Discards unsaved changes in open buffers by reloading file contents from disk.
///
/// Use this tool when:
/// - You attempted to edit files but they have unsaved changes the user does not want to keep.
/// - You want to reset files to the on-disk state before retrying an edit.
///
/// Only use this tool after asking the user for permission, because it will discard unsaved changes.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct RestoreFileFromDiskToolInput {
/// The paths of the files to restore from disk.
pub paths: Vec<PathBuf>,
}
pub struct RestoreFileFromDiskTool {
project: Entity<Project>,
}
impl RestoreFileFromDiskTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for RestoreFileFromDiskTool {
type Input = RestoreFileFromDiskToolInput;
type Output = String;
const NAME: &'static str = "restore_file_from_disk";
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
match input {
Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(),
Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(),
Err(_) => "Restore files from disk".into(),
}
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String, String>> {
let project = self.project.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
// Check for any immediate deny before doing async work.
for path in &input.paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
if let ToolPermissionDecision::Deny(reason) = decision {
return Err(reason);
}
}
let input_paths = input.paths;
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
let mut confirmation_paths: Vec<String> = Vec::new();
for path in &input_paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
let symlink_escape = project.read_with(cx, |project, cx| {
path_has_symlink_escape(project, path, &canonical_roots, cx)
});
match decision {
ToolPermissionDecision::Allow => {
if !symlink_escape {
let is_sensitive = super::tool_permissions::is_sensitive_settings_path(
Path::new(&*path_str),
fs.as_ref(),
)
.await;
if is_sensitive {
confirmation_paths.push(path_str.to_string());
}
}
}
ToolPermissionDecision::Deny(reason) => {
return Err(reason);
}
ToolPermissionDecision::Confirm => {
if !symlink_escape {
confirmation_paths.push(path_str.to_string());
}
}
}
}
if !confirmation_paths.is_empty() {
let title = if confirmation_paths.len() == 1 {
format!(
"Restore {} from disk",
MarkdownInlineCode(&confirmation_paths[0])
)
} else {
let paths: Vec<_> = confirmation_paths
.iter()
.take(3)
.map(|p| p.as_str())
.collect();
if confirmation_paths.len() > 3 {
format!(
"Restore {}, and {} more from disk",
paths.join(", "),
confirmation_paths.len() - 3
)
} else {
format!("Restore {} from disk", paths.join(", "))
}
};
let mut settings_kind = None;
for p in &confirmation_paths {
if let Some(kind) = sensitive_settings_kind(Path::new(p), fs.as_ref()).await {
settings_kind = Some(kind);
break;
}
}
let context = crate::ToolPermissionContext::new(Self::NAME, confirmation_paths);
let authorize = cx.update(|cx| {
authorize_with_sensitive_settings(
settings_kind,
context,
&title,
&event_stream,
cx,
)
});
authorize.await.map_err(|e| e.to_string())?;
}
let mut buffers_to_reload: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut restored_paths: Vec<PathBuf> = Vec::new();
let mut clean_paths: Vec<PathBuf> = Vec::new();
let mut not_found_paths: Vec<PathBuf> = Vec::new();
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
let dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
let mut reload_errors: Vec<String> = Vec::new();
for path in input_paths {
let project_path = match project.read_with(cx, |project, cx| {
resolve_project_path(project, &path, &canonical_roots, cx)
}) {
Ok(resolved) => {
let (project_path, symlink_canonical_target) = match resolved {
ResolvedProjectPath::Safe(path) => (path, None),
ResolvedProjectPath::SymlinkEscape {
project_path,
canonical_target,
} => (project_path, Some(canonical_target)),
};
if let Some(canonical_target) = &symlink_canonical_target {
let path_str = path.to_string_lossy();
let authorize_task = cx.update(|cx| {
authorize_symlink_access(
Self::NAME,
&path_str,
canonical_target,
&event_stream,
cx,
)
});
let result = authorize_task.await;
if let Err(err) = result {
reload_errors.push(format!("{}: {}", path.to_string_lossy(), err));
continue;
}
}
project_path
}
Err(_) => {
not_found_paths.push(path);
continue;
}
};
let open_buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let buffer = futures::select! {
result = open_buffer_task.fuse() => {
match result {
Ok(buffer) => buffer,
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
}
}
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Restore cancelled by user".to_string());
}
};
let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
if is_dirty {
buffers_to_reload.insert(buffer);
restored_paths.push(path);
} else {
clean_paths.push(path);
}
}
if !buffers_to_reload.is_empty() {
let reload_task = project.update(cx, |project, cx| {
project.reload_buffers(buffers_to_reload, true, cx)
});
let result = futures::select! {
result = reload_task.fuse() => result,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Restore cancelled by user".to_string());
}
};
if let Err(error) = result {
reload_errors.push(error.to_string());
}
}
let mut lines: Vec<String> = Vec::new();
if !restored_paths.is_empty() {
lines.push(format!("Restored {} file(s).", restored_paths.len()));
}
if !clean_paths.is_empty() {
lines.push(format!("{} clean.", clean_paths.len()));
}
if !not_found_paths.is_empty() {
lines.push(format!("Not found ({}):", not_found_paths.len()));
for path in &not_found_paths {
lines.push(format!("- {}", path.display()));
}
}
if !open_errors.is_empty() {
lines.push(format!("Open failed ({}):", open_errors.len()));
for (path, error) in &open_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !dirty_check_errors.is_empty() {
lines.push(format!(
"Dirty check failed ({}):",
dirty_check_errors.len()
));
for (path, error) in &dirty_check_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !reload_errors.is_empty() {
lines.push(format!("Reload failed ({}):", reload_errors.len()));
for error in &reload_errors {
lines.push(format!("- {}", error));
}
}
if lines.is_empty() {
Ok("No paths provided.".to_string())
} else {
Ok(lines.join("\n"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs as _;
use gpui::TestAppContext;
use language::LineEnding;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
AgentSettings::override_global(settings, cx);
});
}
#[gpui::test]
async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dirty.txt": "on disk: dirty\n",
"clean.txt": "on disk: clean\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone()));
// Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk.
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should be dirty before restore"
);
// Ensure clean.txt is opened but remains clean.
let clean_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/clean.txt", cx)
.expect("clean.txt should exist in project")
});
let clean_buffer = project
.update(cx, |project, cx| {
project.open_buffer(clean_project_path, cx)
})
.await
.unwrap();
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should start clean"
);
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
// Output should mention restored + clean.
assert!(
output.contains("Restored 1 file(s)."),
"expected restored count line, got:\n{output}"
);
assert!(
output.contains("1 clean."),
"expected clean count line, got:\n{output}"
);
// Effect: dirty buffer should be restored back to disk content and become clean.
let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(
dirty_text, "on disk: dirty\n",
"dirty.txt buffer should be restored to disk contents"
);
assert!(
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should not be dirty after restore"
);
// Disk contents should be unchanged (restore-from-disk should not write).
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
assert_eq!(disk_dirty, "on disk: dirty\n");
// Sanity: clean buffer should remain clean and unchanged.
let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(clean_text, "on disk: clean\n");
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should remain clean"
);
// Test empty paths case.
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![] }),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert_eq!(output, "No paths provided.");
// Test not-found path case (path outside the project root).
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert!(
output.contains("Not found (1):"),
"expected not-found header line, got:\n{output}"
);
assert!(
output.contains("- nonexistent/path.txt"),
"expected not-found path bullet, got:\n{output}"
);
let _ = LineEnding::Unix; // keep import used if the buffer edit API changes
}
#[gpui::test]
async fn test_restore_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(RestoreFileFromDiskTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
let _result = task.await;
}
#[gpui::test]
async fn test_restore_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
"restore_file_from_disk".into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Deny),
..Default::default()
},
);
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(RestoreFileFromDiskTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
})
.await;
assert!(result.is_err(), "Tool should fail when policy denies");
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Deny policy should not emit symlink authorization prompt",
);
}
#[gpui::test]
async fn test_restore_file_symlink_escape_confirm_requires_single_approval(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(RestoreFileFromDiskTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Expected a single authorization prompt",
);
let _result = task.await;
}
}

View file

@ -1,756 +0,0 @@
use agent_client_protocol::schema as acp;
use agent_settings::AgentSettings;
use collections::FxHashSet;
use futures::FutureExt as _;
use gpui::{App, Entity, SharedString, Task};
use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
path_has_symlink_escape, resolve_project_path, sensitive_settings_kind,
};
use crate::{
AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
authorize_with_sensitive_settings, decide_permission_for_path,
};
/// Saves files that have unsaved changes.
///
/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
/// Only use this tool after asking the user for permission to save their unsaved changes.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SaveFileToolInput {
/// The paths of the files to save.
pub paths: Vec<PathBuf>,
}
pub struct SaveFileTool {
project: Entity<Project>,
}
impl SaveFileTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for SaveFileTool {
type Input = SaveFileToolInput;
type Output = String;
const NAME: &'static str = "save_file";
fn kind() -> acp::ToolKind {
acp::ToolKind::Other
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
match input {
Ok(input) if input.paths.len() == 1 => "Save file".into(),
Ok(input) => format!("Save {} files", input.paths.len()).into(),
Err(_) => "Save files".into(),
}
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String, String>> {
let project = self.project.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
// Check for any immediate deny before doing async work.
for path in &input.paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
if let ToolPermissionDecision::Deny(reason) = decision {
return Err(reason);
}
}
let input_paths = input.paths;
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
let mut confirmation_paths: Vec<String> = Vec::new();
for path in &input_paths {
let path_str = path.to_string_lossy();
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
});
let symlink_escape = project.read_with(cx, |project, cx| {
path_has_symlink_escape(project, path, &canonical_roots, cx)
});
match decision {
ToolPermissionDecision::Allow => {
if !symlink_escape {
let is_sensitive = super::tool_permissions::is_sensitive_settings_path(
Path::new(&*path_str),
fs.as_ref(),
)
.await;
if is_sensitive {
confirmation_paths.push(path_str.to_string());
}
}
}
ToolPermissionDecision::Deny(reason) => {
return Err(reason);
}
ToolPermissionDecision::Confirm => {
if !symlink_escape {
confirmation_paths.push(path_str.to_string());
}
}
}
}
if !confirmation_paths.is_empty() {
let title = if confirmation_paths.len() == 1 {
format!("Save {}", MarkdownInlineCode(&confirmation_paths[0]))
} else {
let paths: Vec<_> = confirmation_paths
.iter()
.take(3)
.map(|p| p.as_str())
.collect();
if confirmation_paths.len() > 3 {
format!(
"Save {}, and {} more",
paths.join(", "),
confirmation_paths.len() - 3
)
} else {
format!("Save {}", paths.join(", "))
}
};
let mut settings_kind = None;
for p in &confirmation_paths {
if let Some(kind) = sensitive_settings_kind(Path::new(p), fs.as_ref()).await {
settings_kind = Some(kind);
break;
}
}
let context =
crate::ToolPermissionContext::new(Self::NAME, confirmation_paths.clone());
let authorize = cx.update(|cx| {
authorize_with_sensitive_settings(
settings_kind,
context,
&title,
&event_stream,
cx,
)
});
authorize.await.map_err(|e| e.to_string())?;
}
let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut dirty_count: usize = 0;
let mut clean_paths: Vec<PathBuf> = Vec::new();
let mut not_found_paths: Vec<PathBuf> = Vec::new();
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
let mut authorization_errors: Vec<(PathBuf, String)> = Vec::new();
let mut save_errors: Vec<(String, String)> = Vec::new();
for path in input_paths {
let project_path = match project.read_with(cx, |project, cx| {
resolve_project_path(project, &path, &canonical_roots, cx)
}) {
Ok(resolved) => {
let (project_path, symlink_canonical_target) = match resolved {
ResolvedProjectPath::Safe(path) => (path, None),
ResolvedProjectPath::SymlinkEscape {
project_path,
canonical_target,
} => (project_path, Some(canonical_target)),
};
if let Some(canonical_target) = &symlink_canonical_target {
let path_str = path.to_string_lossy();
let authorize_task = cx.update(|cx| {
authorize_symlink_access(
Self::NAME,
&path_str,
canonical_target,
&event_stream,
cx,
)
});
let result = authorize_task.await;
if let Err(err) = result {
authorization_errors.push((path.clone(), err.to_string()));
continue;
}
}
project_path
}
Err(_) => {
not_found_paths.push(path);
continue;
}
};
let open_buffer_task =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
let buffer = futures::select! {
result = open_buffer_task.fuse() => {
match result {
Ok(buffer) => buffer,
Err(error) => {
open_errors.push((path, error.to_string()));
continue;
}
}
}
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Save cancelled by user".to_string());
}
};
let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
if is_dirty {
buffers_to_save.insert(buffer);
dirty_count += 1;
} else {
clean_paths.push(path);
}
}
// Save each buffer individually since there's no batch save API.
for buffer in buffers_to_save {
let path_for_buffer = buffer
.read_with(cx, |buffer, _| {
buffer
.file()
.map(|file| file.path().to_rel_path_buf())
.map(|path| path.as_rel_path().as_unix_str().to_owned())
})
.unwrap_or_else(|| "<unknown>".to_string());
let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
let save_result = futures::select! {
result = save_task.fuse() => result,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Save cancelled by user".to_string());
}
};
if let Err(error) = save_result {
save_errors.push((path_for_buffer, error.to_string()));
}
}
let mut lines: Vec<String> = Vec::new();
let successful_saves = dirty_count.saturating_sub(save_errors.len());
if successful_saves > 0 {
lines.push(format!("Saved {} file(s).", successful_saves));
}
if !clean_paths.is_empty() {
lines.push(format!("{} clean.", clean_paths.len()));
}
if !not_found_paths.is_empty() {
lines.push(format!("Not found ({}):", not_found_paths.len()));
for path in &not_found_paths {
lines.push(format!("- {}", path.display()));
}
}
if !open_errors.is_empty() {
lines.push(format!("Open failed ({}):", open_errors.len()));
for (path, error) in &open_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !authorization_errors.is_empty() {
lines.push(format!(
"Authorization failed ({}):",
authorization_errors.len()
));
for (path, error) in &authorization_errors {
lines.push(format!("- {}: {}", path.display(), error));
}
}
if !save_errors.is_empty() {
lines.push(format!("Save failed ({}):", save_errors.len()));
for (path, error) in &save_errors {
lines.push(format!("- {}: {}", path, error));
}
}
if lines.is_empty() {
Ok("No paths provided.".to_string())
} else {
Ok(lines.join("\n"))
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs as _;
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
AgentSettings::override_global(settings, cx);
});
}
#[gpui::test]
async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dirty.txt": "on disk: dirty\n",
"clean.txt": "on disk: clean\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let tool = Arc::new(SaveFileTool::new(project.clone()));
// Make dirty.txt dirty in-memory.
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should be dirty before save"
);
// Ensure clean.txt is opened but remains clean.
let clean_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("root/clean.txt", cx)
.expect("clean.txt should exist in project")
});
let clean_buffer = project
.update(cx, |project, cx| {
project.open_buffer(clean_project_path, cx)
})
.await
.unwrap();
assert!(
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"clean.txt buffer should start clean"
);
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
// Output should mention saved + clean.
assert!(
output.contains("Saved 1 file(s)."),
"expected saved count line, got:\n{output}"
);
assert!(
output.contains("1 clean."),
"expected clean count line, got:\n{output}"
);
// Effect: dirty buffer should now be clean and disk should have new content.
assert!(
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt buffer should not be dirty after save"
);
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
assert_eq!(
disk_dirty, "in memory: dirty\n",
"dirty.txt disk content should be updated"
);
// Sanity: clean buffer should remain clean and disk unchanged.
let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
assert_eq!(disk_clean, "on disk: clean\n");
// Test empty paths case.
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput { paths: vec![] }),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert_eq!(output, "No paths provided.");
// Test not-found path case.
let output = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await
.unwrap();
assert!(
output.contains("Not found (1):"),
"expected not-found header line, got:\n{output}"
);
assert!(
output.contains("- nonexistent/path.txt"),
"expected not-found path bullet, got:\n{output}"
);
}
#[gpui::test]
async fn test_save_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
let _result = task.await;
}
#[gpui::test]
async fn test_save_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
"save_file".into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Deny),
..Default::default()
},
);
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
})
.await;
assert!(result.is_err(), "Tool should fail when policy denies");
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Deny policy should not emit symlink authorization prompt",
);
}
#[gpui::test]
async fn test_save_file_symlink_escape_confirm_requires_single_approval(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": {}
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project"),
"Expected symlink escape authorization, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Expected a single authorization prompt",
);
let _result = task.await;
}
#[gpui::test]
async fn test_save_file_symlink_denial_does_not_reduce_success_count(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"dirty.txt": "on disk value\n",
},
"external": {
"secret.txt": "secret content"
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link.txt").as_ref(),
PathBuf::from("../external/secret.txt"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let dirty_project_path = project.read_with(cx, |project, cx| {
project
.find_project_path("project/dirty.txt", cx)
.expect("dirty.txt should exist in project")
});
let dirty_buffer = project
.update(cx, |project, cx| {
project.open_buffer(dirty_project_path, cx)
})
.await
.unwrap();
dirty_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..buffer.len(), "in memory value\n")], None, cx);
});
assert!(
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
"dirty.txt should be dirty before save"
);
let tool = Arc::new(SaveFileTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
ToolInput::resolved(SaveFileToolInput {
paths: vec![
PathBuf::from("project/dirty.txt"),
PathBuf::from("project/link.txt"),
],
}),
event_stream,
cx,
)
});
cx.run_until_parked();
let auth = event_rx.expect_authorization().await;
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("deny"),
acp::PermissionOptionKind::RejectOnce,
))
.unwrap();
let output = task.await.unwrap();
assert!(
output.contains("Saved 1 file(s)."),
"Expected successful save count to remain accurate, got:\n{output}",
);
assert!(
output.contains("Authorization failed (1):"),
"Expected authorization failure section, got:\n{output}",
);
assert!(
!output.contains("Save failed"),
"Authorization denials should not be counted as save failures, got:\n{output}",
);
}
}

View file

@ -17,7 +17,7 @@ use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput};
/// - Subtasks must be concrete, well-defined, and self-contained.
/// - Delegated subtasks must materially advance the main task.
/// - Do not duplicate work between your work and delegated subtasks.
/// - Do not use this tool for tasks you could accomplish directly with one or two tool calls.
/// - Do not use this tool for tasks you could accomplish directly with one or two tool calls. For example, don't ask the agent to read a single file and return the contents, you can do this yourself.
/// - When you delegate work, focus on coordinating and synthesizing results instead of duplicating the same work yourself.
/// - Avoid issuing multiple delegate calls for the same unresolved subproblem unless the new delegated task is genuinely different and necessary.
/// - Narrow the delegated ask to the concrete output you need next.

View file

@ -11,16 +11,13 @@ use text::{Anchor, Point};
/// Identifies a specific symbol (declaration or usage) in the source code.
///
/// Use the file path, line number, and symbol name from file outlines, grep results,
/// or other tool outputs to populate these fields.
/// Use the file path, line number, and symbol name from file outlines, grep results, or other tool outputs to populate these fields.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct SymbolLocator {
/// The relative path of the file containing the symbol
/// (e.g. "crates/editor/src/editor.rs").
/// The relative path of the file containing the symbol (e.g. "crates/editor/src/editor.rs").
pub file_path: String,
/// The 1-based line number where the symbol appears.
/// Use the line numbers from file outlines or grep results.
/// The 1-based line number where the symbol appears. Use the line numbers from file outlines or grep results.
pub line: u32,
/// The name of the symbol (function name, type name, variable name, etc.)

View file

@ -2,6 +2,7 @@ use crate::{
Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision,
decide_permission_for_path,
};
use agent_client_protocol::schema as acp;
use anyhow::{Result, anyhow};
use fs::Fs;
use gpui::{App, Entity, Task, WeakEntity};
@ -521,6 +522,91 @@ pub fn authorize_file_edit(
})
}
/// The user's choice when prompted about how to handle unsaved changes
/// in a buffer that the agent wants to edit or overwrite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyBufferDecision {
/// Save the buffer's pending edits to disk, then proceed.
/// (Edit-mode prompt only.)
Save,
/// Discard the buffer's pending edits (reload from disk), then proceed.
Discard,
/// Keep the buffer's pending edits and cancel the agent's operation.
/// (Overwrite-mode prompt only.)
Keep,
}
/// Which prompt to show when the agent encounters a dirty buffer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyBufferPromptKind {
/// The agent wants to apply targeted edits on top of the current
/// content. Offers Save (persist edits, then edit on top) vs Discard
/// (revert to disk, then edit).
Edit,
/// The agent wants to overwrite the file's entire contents. Offers
/// Keep (cancel the overwrite to preserve the user's work) vs
/// Discard (reload from disk and let the agent overwrite).
Overwrite,
}
/// Prompts the user about how to handle a dirty buffer that the agent
/// wants to edit or overwrite. Returns the chosen action; the caller is
/// responsible for actually performing the corresponding side effect
/// (save / reload / cancel) before continuing.
pub fn authorize_dirty_buffer(
kind: DirtyBufferPromptKind,
event_stream: &ToolCallEventStream,
cx: &mut App,
) -> Task<Result<DirtyBufferDecision>> {
let (message, options) = match kind {
DirtyBufferPromptKind::Edit => (
"This file has unsaved changes. Do you want to save or discard them \
before the agent continues editing?"
.to_string(),
vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new("save"),
"Save",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new("discard"),
"Discard",
acp::PermissionOptionKind::RejectOnce,
),
],
),
DirtyBufferPromptKind::Overwrite => (
"This file has unsaved changes and the agent wants to overwrite it.".to_string(),
vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new("discard"),
"Overwrite",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new("keep"),
"Cancel",
acp::PermissionOptionKind::RejectOnce,
),
],
),
};
let prompt = event_stream.prompt_for_decision(None, Some(message), options, cx);
cx.spawn(async move |_cx| {
let option_id = prompt.await?;
match option_id.0.as_ref() {
"save" => Ok(DirtyBufferDecision::Save),
"discard" => Ok(DirtyBufferDecision::Discard),
"keep" => Ok(DirtyBufferDecision::Keep),
other => Err(anyhow!(
"Unexpected dirty-buffer decision option_id: {other}"
)),
}
})
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -21,10 +21,7 @@ const DEFAULT_UI_TEXT: &str = "Writing file";
///
/// To make granular edits to an existing file, prefer the `edit_file` tool instead.
///
/// Before using this tool:
///
/// 1. Verify the directory path is correct (only applicable when creating new files):
/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
/// Before using this tool, verify the directory path is correct (only applicable when creating new files). Use the `list_directory` tool to verify the parent directory exists and is the correct location
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct WriteFileToolInput {
/// The full path of the file to create or overwrite in the project.
@ -46,8 +43,7 @@ pub struct WriteFileToolInput {
/// </example>
pub path: PathBuf,
/// The complete content for the file.
/// This field should contain the entire file content.
/// The entire content for the file.
pub content: String,
}
@ -1091,6 +1087,212 @@ mod tests {
);
}
/// When the buffer has unsaved user edits and the user picks
/// "Discard my edits", the pending edits are reverted to match disk
/// and the agent's overwrite proceeds.
#[gpui::test]
async fn test_streaming_write_dirty_buffer_discard(cx: &mut TestAppContext) {
let (write_tool, project, _action_log, fs, _thread) =
setup_test(cx, json!({"file.txt": "on disk content"})).await;
let project_path = project
.read_with(cx, |project, cx| {
project.find_project_path("root/file.txt", cx)
})
.expect("Should find project path");
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await
.unwrap();
buffer.update(cx, |buffer, cx| {
let end_point = buffer.max_point();
buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
});
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
write_tool.clone().run(
ToolInput::resolved(WriteFileToolInput {
path: "root/file.txt".into(),
content: "agent overwrote it".into(),
}),
stream_tx,
cx,
)
});
let _update = stream_rx.expect_update_fields().await;
let auth = stream_rx.expect_authorization().await;
// Verify the prompt is the overwrite-mode prompt.
let content = auth.tool_call.fields.content.as_deref().unwrap_or(&[]);
let acp::ToolCallContent::Content(text) = content.first().expect("expected message body")
else {
panic!("expected text body, got: {:?}", content.first());
};
let acp::ContentBlock::Text(text) = &text.content else {
panic!("expected text body, got: {:?}", text.content);
};
assert!(
text.text.contains("overwrite"),
"expected overwrite-mode prompt, got: {:?}",
text.text,
);
// Verify both option ids are present (option_id is the stable contract).
let option_ids: Vec<&str> = match &auth.options {
acp_thread::PermissionOptions::Flat(opts) => {
opts.iter().map(|o| o.option_id.0.as_ref()).collect()
}
other => panic!("expected flat options, got: {other:?}"),
};
assert!(option_ids.contains(&"keep"), "options: {option_ids:?}");
assert!(option_ids.contains(&"discard"), "options: {option_ids:?}");
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("discard"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
let EditSessionOutput::Success { new_text, .. } = task.await.unwrap() else {
panic!("expected success");
};
assert_eq!(new_text, "agent overwrote it");
assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap();
assert_eq!(on_disk, "agent overwrote it");
}
/// When the buffer has unsaved user edits and the user picks
/// "Keep my edits", the overwrite is cancelled with an error and the
/// user's pending edits are preserved.
#[gpui::test]
async fn test_streaming_write_dirty_buffer_keep(cx: &mut TestAppContext) {
let (write_tool, project, _action_log, fs, _thread) =
setup_test(cx, json!({"file.txt": "on disk content"})).await;
let project_path = project
.read_with(cx, |project, cx| {
project.find_project_path("root/file.txt", cx)
})
.expect("Should find project path");
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await
.unwrap();
buffer.update(cx, |buffer, cx| {
let end_point = buffer.max_point();
buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
});
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
write_tool.clone().run(
ToolInput::resolved(WriteFileToolInput {
path: "root/file.txt".into(),
content: "agent overwrote it".into(),
}),
stream_tx,
cx,
)
});
let _update = stream_rx.expect_update_fields().await;
let auth = stream_rx.expect_authorization().await;
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("keep"),
acp::PermissionOptionKind::RejectOnce,
))
.unwrap();
let EditSessionOutput::Error { error, .. } = task.await.unwrap_err() else {
panic!("expected error");
};
assert!(
error.contains("keep") || error.contains("cancelled"),
"expected cancel-style error message, got: {error:?}",
);
// The user's in-memory edits are preserved.
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text());
assert_eq!(buffer_text, "on disk content plus user edit");
// The on-disk content is untouched.
let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap();
assert_eq!(on_disk, "on disk content");
}
/// When the user manually saves the buffer (e.g. cmd-s) while the
/// overwrite prompt is visible, that's treated as "Keep my edits":
/// the user just deliberately persisted their work, so we cancel the
/// agent's overwrite to avoid clobbering it.
#[gpui::test]
async fn test_streaming_write_dirty_buffer_resolved_externally(cx: &mut TestAppContext) {
let (write_tool, project, _action_log, fs, _thread) =
setup_test(cx, json!({"file.txt": "on disk content"})).await;
let project_path = project
.read_with(cx, |project, cx| {
project.find_project_path("root/file.txt", cx)
})
.expect("Should find project path");
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await
.unwrap();
buffer.update(cx, |buffer, cx| {
let end_point = buffer.max_point();
buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
});
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
write_tool.clone().run(
ToolInput::resolved(WriteFileToolInput {
path: "root/file.txt".into(),
content: "agent overwrote it".into(),
}),
stream_tx,
cx,
)
});
let _update = stream_rx.expect_update_fields().await;
let auth = stream_rx.expect_authorization().await;
// User saves manually while the prompt is up.
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
.await
.unwrap();
// The prompt is dismissed by transitioning to InProgress.
let dismiss = stream_rx.expect_update_fields().await;
assert_eq!(dismiss.status, Some(acp::ToolCallStatus::InProgress));
drop(auth);
// The overwrite is cancelled with an error.
let EditSessionOutput::Error { error, .. } = task.await.unwrap_err() else {
panic!("expected error");
};
assert!(
error.contains("saved") || error.contains("cancelled"),
"expected cancel-on-manual-save error, got: {error:?}",
);
// The user's edits were saved to disk and not clobbered.
assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap();
assert_eq!(on_disk, "on disk content plus user edit");
}
async fn setup_test_with_fs(
cx: &mut TestAppContext,
fs: Arc<project::FakeFs>,

View file

@ -3359,6 +3359,7 @@ fn handle_request_permission(
thread.request_tool_call_authorization(
args.tool_call,
acp_thread::PermissionOptions::Flat(args.options),
acp_thread::AuthorizationKind::PermissionGrant,
cx,
)
})

View file

@ -144,6 +144,7 @@ pub struct AgentSettings {
pub default_height: Pixels,
pub max_content_width: Option<Pixels>,
pub default_model: Option<LanguageModelSelection>,
pub subagent_model: Option<LanguageModelSelection>,
pub inline_assistant_model: Option<LanguageModelSelection>,
pub inline_assistant_use_streaming_tools: bool,
pub commit_message_model: Option<LanguageModelSelection>,
@ -640,6 +641,7 @@ impl Settings for AgentSettings {
},
flexible: agent.flexible.unwrap(),
default_model: Some(agent.default_model.unwrap()),
subagent_model: agent.subagent_model,
inline_assistant_model: agent.inline_assistant_model,
inline_assistant_use_streaming_tools: agent
.inline_assistant_use_streaming_tools

View file

@ -685,7 +685,8 @@ pub(crate) struct AgentThread {
struct AgentTerminal {
view: Entity<TerminalView>,
title_editor: Entity<Editor>,
title_editor: Option<Entity<Editor>>,
title_editor_subscription: Option<Subscription>,
last_known_title: String,
created_at: DateTime<Utc>,
has_notification: bool,
@ -708,23 +709,12 @@ impl AgentTerminal {
.unwrap_or_else(|| SharedString::from(view.terminal().read(cx).title(true)))
}
fn refresh_title(&mut self, window: &mut Window, cx: &mut App) -> bool {
let title = self.display_title(cx).to_string();
let changed = self.last_known_title != title;
fn refresh_title(&mut self, cx: &mut App) -> bool {
let title = self.display_title(cx);
let changed = self.last_known_title != title.as_ref();
if changed {
self.last_known_title = title.clone();
self.last_known_title = title.to_string();
}
let should_update_editor = {
let title_editor = self.title_editor.read(cx);
!title_editor.is_focused(window) && title_editor.text(cx) != title
};
if should_update_editor {
self.title_editor.update(cx, |title_editor, cx| {
title_editor.set_text(title, window, cx);
});
}
changed
}
}
@ -1427,37 +1417,11 @@ impl AgentPanel {
return;
}
let terminal_entity = terminal_view.read(cx).terminal().clone();
let title = {
let terminal_view = terminal_view.read(cx);
terminal_view
.custom_title()
.map(ToString::to_string)
.unwrap_or_else(|| terminal_view.terminal().read(cx).title(true))
};
let title_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text(title, window, cx);
editor
});
let title_editor_subscription = cx.subscribe_in(
&title_editor,
window,
move |this, title_editor, event: &editor::EditorEvent, window, cx| {
this.handle_terminal_title_editor_event(
terminal_id,
title_editor,
event,
window,
cx,
);
},
);
let view_subscription = cx.subscribe_in(
let view_subscription = cx.subscribe(
&terminal_view,
window,
move |this, _terminal_view, event: &ItemEvent, window, cx| match event {
move |this, _terminal_view, event: &ItemEvent, cx| match event {
ItemEvent::UpdateTab | ItemEvent::UpdateBreadcrumbs => {
this.refresh_terminal_title(terminal_id, window, cx);
this.refresh_terminal_title(terminal_id, cx);
}
ItemEvent::CloseItem | ItemEvent::Edit => {}
},
@ -1471,7 +1435,7 @@ impl AgentPanel {
TerminalEvent::TitleChanged
| TerminalEvent::Wakeup
| TerminalEvent::BreadcrumbsChanged => {
this.refresh_terminal_title(terminal_id, window, cx);
this.refresh_terminal_title(terminal_id, cx);
}
TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx),
TerminalEvent::CloseTerminal => {
@ -1486,18 +1450,15 @@ impl AgentPanel {
let mut terminal = AgentTerminal {
view: terminal_view,
title_editor,
title_editor: None,
title_editor_subscription: None,
last_known_title: String::new(),
created_at: Utc::now(),
has_notification: false,
_subscriptions: vec![
view_subscription,
terminal_subscription,
title_editor_subscription,
],
_subscriptions: vec![view_subscription, terminal_subscription],
};
self.set_last_created_entry_kind(AgentPanelEntryKind::Terminal, cx);
terminal.refresh_title(window, cx);
terminal.refresh_title(cx);
self.terminals.insert(terminal_id, terminal);
if focus {
self.set_base_view(BaseView::Terminal { terminal_id }, true, window, cx);
@ -1549,18 +1510,80 @@ impl AgentPanel {
cx.notify();
}
fn refresh_terminal_title(
fn refresh_terminal_title(&mut self, terminal_id: TerminalId, cx: &mut Context<Self>) {
if let Some(terminal) = self.terminals.get_mut(&terminal_id)
&& terminal.refresh_title(cx)
{
cx.emit(AgentPanelEvent::EntryChanged);
cx.notify();
}
}
fn edit_terminal_title(
&mut self,
terminal_id: TerminalId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(terminal) = self.terminals.get_mut(&terminal_id)
&& terminal.refresh_title(window, cx)
{
cx.emit(AgentPanelEvent::EntryChanged);
cx.notify();
let Some(terminal) = self.terminals.get_mut(&terminal_id) else {
return;
};
if let Some(title_editor) = terminal.title_editor.as_ref() {
title_editor.focus_handle(cx).focus(window, cx);
return;
}
let title = terminal.display_title(cx).to_string();
let title_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text(title, window, cx);
editor
});
let title_editor_subscription = cx.subscribe_in(
&title_editor,
window,
move |this, title_editor, event: &editor::EditorEvent, window, cx| {
this.handle_terminal_title_editor_event(
terminal_id,
title_editor,
event,
window,
cx,
);
},
);
title_editor.update(cx, |editor, cx| {
editor.select_all(&editor::actions::SelectAll, window, cx);
editor.focus_handle(cx).focus(window, cx);
});
terminal.title_editor = Some(title_editor);
terminal.title_editor_subscription = Some(title_editor_subscription);
cx.notify();
}
fn stop_editing_terminal_title(
&mut self,
terminal_id: TerminalId,
focus_terminal: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(terminal) = self.terminals.get_mut(&terminal_id) else {
return;
};
let terminal_view = terminal.view.clone();
terminal.title_editor = None;
terminal.title_editor_subscription = None;
let title_changed = terminal.refresh_title(cx);
if focus_terminal {
terminal_view.focus_handle(cx).focus(window, cx);
}
if title_changed {
cx.emit(AgentPanelEvent::EntryChanged);
}
cx.notify();
}
fn handle_terminal_title_editor_event(
@ -1576,11 +1599,13 @@ impl AgentPanel {
if !title_editor.read(cx).is_focused(window) {
return;
}
let Some(terminal_view) = self
.terminals
.get(&terminal_id)
.map(|terminal| terminal.view.clone())
else {
let Some(terminal_view) = self.terminals.get(&terminal_id).and_then(|terminal| {
terminal
.title_editor
.as_ref()
.is_some_and(|current_editor| current_editor == title_editor)
.then(|| terminal.view.clone())
}) else {
return;
};
let new_title = title_editor.read(cx).text(cx).trim().to_string();
@ -1602,8 +1627,13 @@ impl AgentPanel {
});
}
editor::EditorEvent::Blurred => {
if let Some(terminal) = self.terminals.get_mut(&terminal_id) {
terminal.refresh_title(window, cx);
if self
.terminals
.get(&terminal_id)
.and_then(|terminal| terminal.title_editor.as_ref())
.is_some_and(|current_editor| current_editor == title_editor)
{
self.stop_editing_terminal_title(terminal_id, false, window, cx);
}
}
_ => {}
@ -3039,6 +3069,12 @@ impl Panel for AgentPanel {
true
}
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
Some(workspace::HideStatusItem::new(|settings| {
settings.agent.get_or_insert_default().button = Some(false);
}))
}
fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
self.zoomed
}
@ -3252,22 +3288,41 @@ impl AgentPanel {
}
}
VisibleSurface::Terminal(_) => {
if let Some((title_editor, terminal_view)) = self
.active_terminal_id()
.and_then(|terminal_id| self.terminals.get(&terminal_id))
.map(|terminal| (terminal.title_editor.clone(), terminal.view.clone()))
if let Some((terminal_id, title_editor, title)) =
self.active_terminal_id().and_then(|terminal_id| {
self.terminals.get(&terminal_id).map(|terminal| {
(
terminal_id,
terminal.title_editor.clone(),
terminal.display_title(cx),
)
})
})
{
let terminal_view_cancel = terminal_view.clone();
div()
.flex_1()
.on_action(move |_: &menu::Confirm, window, cx| {
terminal_view.focus_handle(cx).focus(window, cx);
})
.on_action(move |_: &editor::actions::Cancel, window, cx| {
terminal_view_cancel.focus_handle(cx).focus(window, cx);
})
.child(title_editor)
.into_any_element()
if let Some(title_editor) = title_editor {
div()
.flex_1()
.on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
this.stop_editing_terminal_title(terminal_id, true, window, cx);
}))
.on_action(cx.listener(
move |this, _: &editor::actions::Cancel, window, cx| {
this.stop_editing_terminal_title(terminal_id, true, window, cx);
},
))
.child(title_editor)
.into_any_element()
} else {
div()
.id("terminal-title")
.flex_1()
.cursor_pointer()
.on_click(cx.listener(move |this, _, window, cx| {
this.edit_terminal_title(terminal_id, window, cx);
}))
.child(Label::new(title).truncate())
.into_any_element()
}
} else {
Label::new("Terminal")
.color(Color::Muted)
@ -5391,6 +5446,72 @@ mod tests {
});
}
#[gpui::test]
async fn test_terminal_title_editor_is_created_only_while_editing(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
cx.update(|_, cx| {
cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
});
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Dev Server", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
panel.read_with(&cx, |panel, _cx| {
let terminal = panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel");
assert!(terminal.title_editor.is_none());
});
panel.update(&mut cx, |panel, cx| {
panel.refresh_terminal_title(terminal_id, cx);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, _cx| {
let terminal = panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel");
assert!(terminal.title_editor.is_none());
});
panel.update_in(&mut cx, |panel, window, cx| {
panel.edit_terminal_title(terminal_id, window, cx);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
let terminal = panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel");
let title_editor = terminal
.title_editor
.as_ref()
.expect("terminal title editor should be active while editing");
assert_eq!(title_editor.read(cx).text(cx), "Dev Server");
});
panel.update_in(&mut cx, |panel, window, cx| {
panel.stop_editing_terminal_title(terminal_id, false, window, cx);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, _cx| {
let terminal = panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel");
assert!(terminal.title_editor.is_none());
});
}
#[gpui::test]
async fn test_terminal_bell_marks_and_activation_clears_notification(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;

View file

@ -694,6 +694,7 @@ mod tests {
default_height: px(600.),
max_content_width: Some(px(850.)),
default_model: None,
subagent_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
commit_message_model: None,

View file

@ -1507,18 +1507,6 @@ impl ConversationView {
return;
}
let used_tools = thread.read(cx).used_tools_since_last_user_message();
self.notify_with_sound(
if used_tools {
"Finished running tools"
} else {
"New message"
},
IconName::ZedAssistant,
window,
cx,
);
let should_send_queued = if let Some(active) = self.root_thread_view() {
active.update(cx, |active, cx| {
if active.skip_queue_processing_count > 0 {
@ -1542,7 +1530,23 @@ impl ConversationView {
} else {
false
};
if should_send_queued {
// Skip notifying when a queued message is about to be auto-sent: the agent
// is not actually idle and a notification here would fire just before the
// next turn starts.
if !should_send_queued {
let used_tools = thread.read(cx).used_tools_since_last_user_message();
self.notify_with_sound(
if used_tools {
"Finished running tools"
} else {
"New message"
},
IconName::ZedAssistant,
window,
cx,
);
} else {
self.send_queued_message_at_index(0, false, window, cx);
}
}
@ -3072,6 +3076,71 @@ pub(crate) mod tests {
);
}
#[gpui::test]
async fn test_no_notification_when_queued_message_will_be_auto_sent(cx: &mut TestAppContext) {
init_test(cx);
let connection = StubAgentConnection::new();
let (conversation_view, cx) =
setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
add_to_workspace(conversation_view.clone(), cx);
let message_editor = message_editor(&conversation_view, cx);
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("first", window, cx);
});
active_thread(&conversation_view, cx)
.update_in(cx, |view, window, cx| view.send(window, cx));
cx.run_until_parked();
let session_id = conversation_view.read_with(cx, |view, cx| {
view.active_thread()
.unwrap()
.read(cx)
.thread
.read(cx)
.session_id()
.clone()
});
active_thread(&conversation_view, cx).update_in(cx, |thread, _window, cx| {
thread.add_to_queue(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"queued".to_string(),
))],
vec![],
cx,
);
});
cx.deactivate_window();
cx.run_until_parked();
cx.update(|_, cx| {
connection.send_update(
session_id.clone(),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
"first response".into(),
)),
cx,
);
connection.end_turn(session_id, acp::StopReason::EndTurn);
});
cx.run_until_parked();
assert_eq!(
cx.windows()
.iter()
.filter(|window| window.downcast::<AgentNotification>().is_some())
.count(),
0,
"No notification should fire when a queued message will be auto-sent on Stopped"
);
}
#[gpui::test]
async fn test_notification_for_error(cx: &mut TestAppContext) {
init_test(cx);
@ -7120,6 +7189,7 @@ pub(crate) mod tests {
"Allow",
acp::PermissionOptionKind::AllowOnce,
)]),
acp_thread::AuthorizationKind::PermissionGrant,
cx,
)
.unwrap()

View file

@ -170,6 +170,10 @@ impl MentionSet {
self.mentions.keys().cloned().collect()
}
pub fn is_empty(&self) -> bool {
self.mentions.is_empty()
}
pub fn mentions(&self) -> HashSet<MentionUri> {
self.mentions.values().map(|(uri, _)| uri.clone()).collect()
}

View file

@ -15,7 +15,7 @@ use anyhow::{Result, anyhow};
use editor::{
Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode,
EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
actions::{Copy, Paste},
actions::{Copy, Cut, Paste},
code_context_menus::CodeContextMenu,
display_map::{CreaseId, CreaseSnapshot},
scroll::Autoscroll,
@ -35,7 +35,7 @@ use project::{
use prompt_store::PromptStore;
use rope::Point;
use settings::Settings;
use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc};
use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
use theme_settings::ThemeSettings;
use ui::{ContextMenu, prelude::*};
use util::paths::PathStyle;
@ -1180,7 +1180,7 @@ impl MessageEditor {
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
let Some(text) = self.serialized_copy_text(cx) else {
let Some((text, _)) = self.serialize_selection_with_mentions(false, cx) else {
cx.propagate();
return;
};
@ -1189,6 +1189,24 @@ impl MessageEditor {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
let Some((text, ranges)) = self.serialize_selection_with_mentions(true, cx) else {
cx.propagate();
return;
};
cx.stop_propagation();
self.editor.update(cx, |editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(ranges);
});
editor.insert("", window, cx);
});
});
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
let editor = self.editor.clone();
window.defer(cx, move |window, cx| {
@ -1689,12 +1707,20 @@ impl MessageEditor {
});
}
fn serialized_copy_text(&self, cx: &mut App) -> Option<String> {
fn serialize_selection_with_mentions(
&self,
expand_empty_to_line: bool,
cx: &mut App,
) -> Option<(String, Vec<Range<MultiBufferOffset>>)> {
if self.mention_set.read(cx).is_empty() {
return None;
}
let display_snapshot = self
.editor
.update(cx, |editor, cx| editor.display_snapshot(cx));
let editor = self.editor.read(cx);
if !editor.has_non_empty_selection(&display_snapshot) {
if !expand_empty_to_line && !editor.has_non_empty_selection(&display_snapshot) {
return None;
}
@ -1715,48 +1741,55 @@ impl MessageEditor {
})
.collect::<Vec<_>>();
let line_mode = editor.selections.line_mode();
let max_point = snapshot.max_point();
let point_selections = editor.selections.all::<Point>(&display_snapshot);
let mut text = String::new();
let mut ranges = Vec::with_capacity(point_selections.len());
let mut has_mentions = false;
let mut is_first = true;
let mut prev_was_entire_line = false;
for mut selection in point_selections {
let is_entire_line = (selection.is_empty() && expand_empty_to_line) || line_mode;
if is_entire_line {
selection.start = Point::new(selection.start.row, 0);
if !selection.is_empty() && selection.end.column == 0 {
selection.end = min(max_point, selection.end);
} else {
selection.end = min(max_point, Point::new(selection.end.row + 1, 0));
}
}
let range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot);
for selection in editor
.selections
.all::<MultiBufferOffset>(&display_snapshot)
{
if is_first {
is_first = false;
} else {
} else if !prev_was_entire_line {
text.push('\n');
}
prev_was_entire_line = is_entire_line;
let mut overlapping_mentions = mention_ranges
let mut cursor = range.start;
for (start, end, uri) in mention_ranges
.iter()
.filter(|(start, end, _)| *start < selection.end && selection.start < *end)
.peekable();
if overlapping_mentions.peek().is_none() {
text.extend(snapshot.text_for_range(selection.start..selection.end));
continue;
}
has_mentions = true;
let mut cursor = selection.start;
for (start, end, uri) in overlapping_mentions {
.filter(|(start, end, _)| *start < range.end && range.start < *end)
{
if cursor < *start {
text.extend(snapshot.text_for_range(cursor..*start));
}
write!(text, "{}", uri.as_link()).unwrap();
cursor = *end;
has_mentions = true;
}
if cursor < range.end {
text.extend(snapshot.text_for_range(cursor..range.end));
}
if cursor < selection.end {
text.extend(snapshot.text_for_range(cursor..selection.end));
}
ranges.push(range);
}
has_mentions.then_some(text)
has_mentions.then_some((text, ranges))
}
}
@ -1775,6 +1808,7 @@ impl Render for MessageEditor {
.on_action(cx.listener(Self::chat_with_follow))
.on_action(cx.listener(Self::cancel))
.capture_action(cx.listener(Self::copy))
.capture_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::paste_raw))
.capture_action(cx.listener(Self::paste))
.flex_1()
@ -1991,7 +2025,7 @@ mod tests {
use base64::Engine as _;
use editor::{
AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
actions::Paste,
actions::{Cut, Paste},
};
use fs::FakeFs;
@ -4029,7 +4063,8 @@ mod tests {
let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| {
message_editor
.serialized_copy_text(cx)
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
.expect("selection mentions should serialize")
});
let expected_text = format!(
@ -4094,7 +4129,9 @@ mod tests {
message_editor: Entity<MessageEditor>,
first_uri: MentionUri,
first_range: Range<usize>,
second_uri: MentionUri,
second_range: Range<usize>,
buffer_len: MultiBufferOffset,
}
async fn setup_selection_mention_fixture(
@ -4119,7 +4156,7 @@ mod tests {
line_range: 2..=3,
};
message_editor.update_in(&mut cx, |message_editor, window, cx| {
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.set_text(source_text, window, cx);
let snapshot = message_editor
@ -4174,6 +4211,8 @@ mod tests {
);
});
}
snapshot.len()
});
(
@ -4181,7 +4220,9 @@ mod tests {
message_editor,
first_uri,
first_range,
second_uri,
second_range,
buffer_len,
},
cx,
)
@ -4209,7 +4250,9 @@ mod tests {
let copied = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor.serialized_copy_text(cx)
message_editor
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
});
assert_eq!(copied, Some(fixture.first_uri.as_link().to_string()));
@ -4241,7 +4284,9 @@ mod tests {
let copied = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor.serialized_copy_text(cx)
message_editor
.serialize_selection_with_mentions(false, cx)
.map(|(text, _)| text)
});
assert_eq!(copied, None);
@ -4297,6 +4342,117 @@ mod tests {
}
}
#[gpui::test]
async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let buffer_len = fixture.buffer_len;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([MultiBufferOffset(0)..buffer_len]);
});
});
message_editor.cut(&Cut, window, cx);
});
let expected_text = format!(
"{} needs work\n{} looks fine",
fixture.first_uri.as_link(),
fixture.second_uri.as_link()
);
let clipboard_text = cx
.read_from_clipboard()
.and_then(|item| match item.entries().first().cloned() {
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
_ => None,
})
.expect("cut should write serialized text to clipboard");
assert_eq!(clipboard_text, expected_text);
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
message_editor.editor.read(cx).text(cx)
});
assert_eq!(remaining_text, "");
}
#[gpui::test]
async fn test_cut_with_empty_cursor_on_mention_line_removes_whole_line(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let cursor_offset = MultiBufferOffset(fixture.first_range.end + 4);
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([cursor_offset..cursor_offset]);
});
});
message_editor.cut(&Cut, window, cx);
});
let clipboard_text = cx
.read_from_clipboard()
.and_then(|item| match item.entries().first().cloned() {
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
_ => None,
})
.expect("cut should write serialized text to clipboard");
assert_eq!(
clipboard_text,
format!("{} needs work\n", fixture.first_uri.as_link())
);
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
message_editor.editor.read(cx).text(cx)
});
assert_eq!(remaining_text, "selection looks fine");
}
#[gpui::test]
async fn test_serialized_cut_text_returns_none_when_mentions_outside_selection(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let between_start = fixture.first_range.end;
let between_end = fixture.second_range.start - 1;
fixture
.message_editor
.update_in(&mut cx, |message_editor, window, cx| {
message_editor.editor.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([
MultiBufferOffset(between_start)..MultiBufferOffset(between_end)
]);
});
});
});
let result = fixture
.message_editor
.update(&mut cx, |message_editor, cx| {
message_editor.serialize_selection_with_mentions(true, cx)
});
assert!(
result.is_none(),
"serialize_selection_with_mentions should return None so the default editor cut runs"
);
}
#[gpui::test]
async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
cx: &mut TestAppContext,

View file

@ -102,24 +102,30 @@ impl TimeBucket {
}
}
fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
let mut positions = Vec::new();
let mut query_chars = query.chars().peekable();
for (byte_idx, candidate_char) in text.char_indices() {
if let Some(&query_char) = query_chars.peek() {
if candidate_char.eq_ignore_ascii_case(&query_char) {
positions.push(byte_idx);
query_chars.next();
pub fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
let query_chars: Vec<char> = query.chars().collect();
if query_chars.is_empty() {
return Some(Vec::new());
}
let candidate_chars: Vec<(usize, char)> = candidate.char_indices().collect();
let window_count = candidate_chars.len().checked_sub(query_chars.len() - 1)?;
'outer: for window_start in 0..window_count {
for (qi, &query_char) in query_chars.iter().enumerate() {
let (_, cand_char) = candidate_chars[window_start + qi];
if !cand_char.eq_ignore_ascii_case(&query_char) {
continue 'outer;
}
} else {
break;
}
return Some(
(0..query_chars.len())
.map(|qi| candidate_chars[window_start + qi].0)
.collect(),
);
}
if query_chars.peek().is_none() {
Some(positions)
} else {
None
}
None
}
pub enum ThreadsArchiveViewEvent {

View file

@ -81,6 +81,11 @@ fn linux_rsync_install_hint() -> &'static str {
|| distribution_id == "almalinux"
}) {
Some("Install it with: sudo dnf install rsync")
} else if distribution_ids
.iter()
.any(|distribution_id| distribution_id == "nixos")
{
Some("Install pkgs.rsync from nixpkgs")
} else {
None
};
@ -1104,8 +1109,7 @@ async fn install_release_windows(downloaded_installer: &Path) -> Result<Option<P
let mut cmd = new_command(downloaded_installer);
cmd.arg("/verysilent")
.arg("/update=true")
.arg("!desktopicon")
.arg("!quicklaunchicon");
.arg("/MERGETASKS=!desktopicon");
let output = cmd.output().await?;
anyhow::ensure!(
output.status.success(),

View file

@ -32,8 +32,6 @@ use thiserror::Error;
pub use crate::models::*;
pub const CONTEXT_1M_BETA_HEADER: &str = "context-1m-2025-08-07";
pub async fn stream_completion(
client: bedrock::Client,
request: Request,
@ -70,13 +68,6 @@ pub async fn stream_completion(
_ => {}
}
if request.allow_extended_context {
additional_fields.insert(
"anthropic_beta".to_string(),
Document::Array(vec![Document::String(CONTEXT_1M_BETA_HEADER.to_string())]),
);
}
if !additional_fields.is_empty() {
response = response.additional_model_request_fields(Document::Object(additional_fields));
}
@ -211,7 +202,6 @@ pub struct Request {
pub temperature: Option<f32>,
pub top_k: Option<u32>,
pub top_p: Option<f32>,
pub allow_extended_context: bool,
}
#[derive(Debug, Serialize, Deserialize)]

View file

@ -384,19 +384,15 @@ impl Model {
}
pub fn max_token_count(&self) -> u64 {
self.max_tokens()
}
pub fn max_tokens(&self) -> u64 {
match self {
Self::ClaudeHaiku4_5
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6 => 200_000,
| Self::ClaudeSonnet4_6 => 1_000_000,
Self::ClaudeOpus4_1 => 200_000,
Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000,
Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000,
Self::MagistralSmall | Self::MistralLarge3 | Self::PixtralLarge => 128_000,
@ -526,18 +522,6 @@ impl Model {
}
}
pub fn supports_extended_context(&self) -> bool {
matches!(
self,
Self::ClaudeSonnet4
| Self::ClaudeSonnet4_5
| Self::ClaudeOpus4_5
| Self::ClaudeOpus4_6
| Self::ClaudeOpus4_7
| Self::ClaudeSonnet4_6
)
}
pub fn supports_caching(&self) -> bool {
match self {
Self::ClaudeHaiku4_5
@ -1040,11 +1024,11 @@ mod tests {
}
#[test]
fn test_max_tokens() {
assert_eq!(Model::ClaudeSonnet4_5.max_tokens(), 200_000);
assert_eq!(Model::ClaudeOpus4_6.max_tokens(), 200_000);
assert_eq!(Model::Llama4Scout17B.max_tokens(), 128_000);
assert_eq!(Model::NovaPremier.max_tokens(), 1_000_000);
fn test_max_token_count() {
assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000);
assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000);
assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000);
assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000);
}
#[test]

View file

@ -189,7 +189,7 @@ impl AnyActiveCall for ActiveCallEntity {
let room = self.0.read(cx).room()?.read(cx);
room.remote_participants()
.values()
.find(|p| p.user.id == user_id)
.find(|p| p.user.legacy_id == user_id)
.map(|p| p.peer_id)
}

View file

@ -680,7 +680,7 @@ impl Room {
project_hosts_and_guest_counts
.entry(project.id)
.or_default()
.0 = Some(participant.user.id);
.0 = Some(participant.user.legacy_id);
}
}
@ -689,7 +689,7 @@ impl Room {
project_hosts_and_guest_counts
.entry(project.id)
.or_default()
.0 = Some(user.id);
.0 = Some(user.legacy_id);
}
}
@ -902,7 +902,7 @@ impl Room {
if let Some(livekit_participants) = &livekit_participants
&& let Some(livekit_participant) = livekit_participants
.get(&ParticipantIdentity(user.id.to_string()))
.get(&ParticipantIdentity(user.legacy_id.to_string()))
{
for publication in
livekit_participant.track_publications().into_values()
@ -935,6 +935,11 @@ impl Room {
for sid in participant.video_tracks.keys() {
cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() });
}
if !participant.video_tracks.is_empty() {
cx.emit(Event::RemoteVideoTracksChanged {
participant_id: participant.peer_id,
});
}
false
}
});
@ -943,7 +948,7 @@ impl Room {
if let Some(pending_participants) = pending_participants.log_err() {
this.pending_participants = pending_participants;
for participant in &this.pending_participants {
this.participant_user_ids.insert(participant.id);
this.participant_user_ids.insert(participant.legacy_id);
}
}
@ -1148,13 +1153,16 @@ impl Room {
#[cfg(any(test, feature = "test-support"))]
{
for participant in self.remote_participants.values() {
assert!(self.participant_user_ids.contains(&participant.user.id));
assert_ne!(participant.user.id, self.client.user_id().unwrap());
assert!(
self.participant_user_ids
.contains(&participant.user.legacy_id)
);
assert_ne!(participant.user.legacy_id, self.client.user_id().unwrap());
}
for participant in &self.pending_participants {
assert!(self.participant_user_ids.contains(&participant.id));
assert_ne!(participant.id, self.client.user_id().unwrap());
assert!(self.participant_user_ids.contains(&participant.legacy_id));
assert_ne!(participant.legacy_id, self.client.user_id().unwrap());
}
assert_eq!(

View file

@ -3,7 +3,7 @@ mod channel_index;
use crate::channel_buffer::ChannelBuffer;
use anyhow::{Context as _, Result, anyhow};
use channel_index::ChannelIndex;
use client::{ChannelId, Client, ClientSettings, Subscription, User, UserId, UserStore};
use client::{ChannelId, Client, ClientSettings, LegacyUserId, Subscription, User, UserStore};
use collections::{HashMap, HashSet};
use futures::{Future, FutureExt, StreamExt, channel::mpsc, future::Shared};
use gpui::{
@ -39,7 +39,7 @@ pub struct ChannelStore {
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
channel_states: HashMap<ChannelId, ChannelState>,
favorite_channel_ids: Vec<ChannelId>,
outgoing_invites: HashSet<(ChannelId, UserId)>,
outgoing_invites: HashSet<(ChannelId, LegacyUserId)>,
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
opened_buffers: HashMap<ChannelId, OpenEntityHandle<ChannelBuffer>>,
client: Arc<Client>,
@ -632,7 +632,7 @@ impl ChannelStore {
pub fn invite_member(
&mut self,
channel_id: ChannelId,
user_id: UserId,
user_id: LegacyUserId,
role: proto::ChannelRole,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
@ -694,7 +694,7 @@ impl ChannelStore {
pub fn set_member_role(
&mut self,
channel_id: ChannelId,
user_id: UserId,
user_id: LegacyUserId,
role: proto::ChannelRole,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
@ -830,7 +830,7 @@ impl ChannelStore {
false
}
pub fn has_pending_channel_invite(&self, channel_id: ChannelId, user_id: UserId) -> bool {
pub fn has_pending_channel_invite(&self, channel_id: ChannelId, user_id: LegacyUserId) -> bool {
self.outgoing_invites.contains(&(channel_id, user_id))
}
@ -1167,13 +1167,13 @@ impl ChannelStore {
.iter()
.filter_map(|user_id| {
users
.binary_search_by_key(&user_id, |user| &user.id)
.binary_search_by_key(&user_id, |user| &user.legacy_id)
.ok()
.map(|ix| users[ix].clone())
})
.collect();
participants.sort_by_key(|u| u.id);
participants.sort_by_key(|u| u.legacy_id);
this.channel_participants
.insert(ChannelId(entry.channel_id), participants);

View file

@ -30,7 +30,7 @@ use util::{ResultExt, TryFutureExt as _};
const CURRENT_ORGANIZATION_ID_KEY: &str = "current_organization_id";
pub type UserId = u64;
pub type LegacyUserId = u64;
#[derive(
Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
@ -57,7 +57,7 @@ pub struct ParticipantIndex(pub u32);
#[derive(Default, Debug)]
pub struct User {
pub id: UserId,
pub legacy_id: LegacyUserId,
pub github_login: SharedString,
pub avatar_uri: SharedUri,
pub name: Option<String>,
@ -67,7 +67,7 @@ pub struct User {
pub struct Collaborator {
pub peer_id: proto::PeerId,
pub replica_id: ReplicaId,
pub user_id: UserId,
pub user_id: LegacyUserId,
pub is_host: bool,
pub committer_name: Option<String>,
pub committer_email: Option<String>,
@ -87,7 +87,7 @@ impl Ord for User {
impl PartialEq for User {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.github_login == other.github_login
self.legacy_id == other.legacy_id && self.github_login == other.github_login
}
}
@ -240,7 +240,7 @@ impl UserStore {
let current_user_and_response = if let Some(response) = response {
let user = Arc::new(User {
id: user_id,
legacy_id: user_id,
github_login: response.user.github_login.clone().into(),
avatar_uri: response.user.avatar_url.clone().into(),
name: response.user.name.clone(),
@ -404,7 +404,7 @@ impl UserStore {
this.update(cx, |this, cx| {
// Remove contacts
this.contacts
.retain(|contact| !removed_contacts.contains(&contact.user.id));
.retain(|contact| !removed_contacts.contains(&contact.user.legacy_id));
// Update existing contacts and insert new ones
for updated_contact in updated_contacts {
match this.contacts.binary_search_by_key(
@ -418,7 +418,7 @@ impl UserStore {
// Remove incoming contact requests
this.incoming_contact_requests.retain(|user| {
if removed_incoming_requests.contains(&user.id) {
if removed_incoming_requests.contains(&user.legacy_id) {
cx.emit(Event::Contact {
user: user.clone(),
kind: ContactEventKind::Cancelled,
@ -442,7 +442,7 @@ impl UserStore {
// Remove outgoing contact requests
this.outgoing_contact_requests
.retain(|user| !removed_outgoing_requests.contains(&user.id));
.retain(|user| !removed_outgoing_requests.contains(&user.legacy_id));
// Update existing incoming requests and insert new ones
for request in outgoing_requests {
match this
@ -483,7 +483,7 @@ impl UserStore {
}
pub fn is_contact_request_pending(&self, user: &User) -> bool {
self.pending_contact_requests.contains_key(&user.id)
self.pending_contact_requests.contains_key(&user.legacy_id)
}
pub fn contact_request_status(&self, user: &User) -> ContactRequestStatus {
@ -525,7 +525,7 @@ impl UserStore {
pub fn has_incoming_contact_request(&self, user_id: u64) -> bool {
self.incoming_contact_requests
.iter()
.any(|user| user.id == user_id)
.any(|user| user.legacy_id == user_id)
}
pub fn respond_to_contact_request(
@ -967,13 +967,13 @@ impl UserStore {
let mut ret = Vec::with_capacity(users.len());
for user in users {
let user = User::new(user);
if let Some(old) = self.users.insert(user.id, user.clone())
if let Some(old) = self.users.insert(user.legacy_id, user.clone())
&& old.github_login != user.github_login
{
self.by_github_login.remove(&old.github_login);
}
self.by_github_login
.insert(user.github_login.clone(), user.id);
.insert(user.github_login.clone(), user.legacy_id);
ret.push(user)
}
ret
@ -1023,7 +1023,7 @@ impl UserStore {
impl User {
fn new(message: proto::User) -> Arc<Self> {
Arc::new(User {
id: message.id,
legacy_id: message.id,
github_login: message.github_login.into(),
avatar_uri: message.avatar_url.into(),
name: message.name,
@ -1055,7 +1055,7 @@ impl Collaborator {
Ok(Self {
peer_id: message.peer_id.context("invalid peer id")?,
replica_id: ReplicaId::new(message.replica_id as u16),
user_id: message.user_id as UserId,
user_id: message.user_id as LegacyUserId,
is_host: message.is_host,
committer_name: message.committer_name,
committer_email: message.committer_email,

View file

@ -28,10 +28,34 @@ struct Credentials {
#[derive(Debug, Error)]
pub enum ClientApiError {
/// 401 — credentials are invalid or expired.
#[error("Unauthorized")]
Unauthorized,
#[error(transparent)]
Other(#[from] anyhow::Error),
/// No credentials have been set on the client.
#[error("not signed in")]
NotSignedIn,
/// Connection-level failure: DNS, TCP, TLS, timeout, etc.
/// The HTTP request never received a response.
#[error("connection to {host} failed")]
ConnectionFailed {
host: String,
#[source]
source: anyhow::Error,
},
/// Server returned a non-success HTTP status (other than 401).
#[error("{host} returned {status}")]
ServerError {
host: String,
status: StatusCode,
body: String,
},
/// Failed to read or parse the response body after a successful HTTP status.
#[error("invalid response")]
InvalidResponse(#[source] anyhow::Error),
/// Failed to build the HTTP request (URL construction, serialization, etc.).
/// This typically indicates a programming error.
#[error("failed to build request")]
RequestBuildFailed(#[source] anyhow::Error),
}
pub struct CloudApiClient {
@ -62,25 +86,35 @@ impl CloudApiClient {
*self.credentials.write() = None;
}
fn cloud_host(&self) -> String {
self.http_client
.build_zed_cloud_url("/")
.ok()
.and_then(|url| url.host_str().map(String::from))
.unwrap_or_else(|| "cloud.zed.dev".into())
}
fn build_request(
&self,
req: request::Builder,
body: impl Into<AsyncBody>,
) -> Result<Request<AsyncBody>> {
) -> Result<Request<AsyncBody>, ClientApiError> {
let credentials = self.credentials.read();
let credentials = credentials.as_ref().context("no credentials provided")?;
build_request(req, body, credentials)
let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?;
build_request(req, body, credentials).map_err(ClientApiError::RequestBuildFailed)
}
pub async fn get_authenticated_user(
&self,
system_id: Option<String>,
) -> Result<GetAuthenticatedUserResponse, ClientApiError> {
let host = self.cloud_host();
let request_builder = Request::builder()
.method(Method::GET)
.uri(
self.http_client
.build_zed_cloud_url("/client/users/me")?
.build_zed_cloud_url("/client/users/me")
.map_err(ClientApiError::RequestBuildFailed)?
.as_ref(),
)
.when_some(system_id, |builder, system_id| {
@ -89,7 +123,12 @@ impl CloudApiClient {
let request = self.build_request(request_builder, AsyncBody::default())?;
let mut response = self.http_client.send(request).await?;
let mut response = self.http_client.send(request).await.map_err(|source| {
ClientApiError::ConnectionFailed {
host: host.clone(),
source,
}
})?;
if !response.status().is_success() {
if response.status() == StatusCode::UNAUTHORIZED {
@ -97,16 +136,13 @@ impl CloudApiClient {
}
let mut body = String::new();
response
.body_mut()
.read_to_string(&mut body)
.await
.context("failed to read response body")?;
response.body_mut().read_to_string(&mut body).await.ok();
return Err(ClientApiError::Other(anyhow::anyhow!(
"Failed to get authenticated user.\nStatus: {:?}\nBody: {body}",
response.status()
)));
return Err(ClientApiError::ServerError {
host,
status: response.status(),
body,
});
}
let mut body = String::new();
@ -114,9 +150,9 @@ impl CloudApiClient {
.body_mut()
.read_to_string(&mut body)
.await
.context("failed to read response body")?;
.map_err(|e| ClientApiError::InvalidResponse(e.into()))?;
Ok(serde_json::from_str(&body).context("failed to parse response body")?)
serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into()))
}
pub fn connect(&self, cx: &App) -> Result<Task<Result<Connection>>> {
@ -153,11 +189,13 @@ impl CloudApiClient {
system_id: Option<String>,
organization_id: Option<OrganizationId>,
) -> Result<CreateLlmTokenResponse, ClientApiError> {
let host = self.cloud_host();
let request_builder = Request::builder()
.method(Method::POST)
.uri(
self.http_client
.build_zed_cloud_url("/client/llm_tokens")?
.build_zed_cloud_url("/client/llm_tokens")
.map_err(ClientApiError::RequestBuildFailed)?
.as_ref(),
)
.when_some(system_id, |builder, system_id| {
@ -169,7 +207,12 @@ impl CloudApiClient {
Json(CreateLlmTokenBody { organization_id }),
)?;
let mut response = self.http_client.send(request).await?;
let mut response = self.http_client.send(request).await.map_err(|source| {
ClientApiError::ConnectionFailed {
host: host.clone(),
source,
}
})?;
if !response.status().is_success() {
if response.status() == StatusCode::UNAUTHORIZED {
@ -177,16 +220,13 @@ impl CloudApiClient {
}
let mut body = String::new();
response
.body_mut()
.read_to_string(&mut body)
.await
.context("failed to read response body")?;
response.body_mut().read_to_string(&mut body).await.ok();
return Err(ClientApiError::Other(anyhow::anyhow!(
"Failed to create LLM token.\nStatus: {:?}\nBody: {body}",
response.status()
)));
return Err(ClientApiError::ServerError {
host,
status: response.status(),
body,
});
}
let mut body = String::new();
@ -194,9 +234,9 @@ impl CloudApiClient {
.body_mut()
.read_to_string(&mut body)
.await
.context("failed to read response body")?;
.map_err(|e| ClientApiError::InvalidResponse(e.into()))?;
Ok(serde_json::from_str(&body).context("failed to parse response body")?)
serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into()))
}
pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result<bool> {

View file

@ -1,4 +1,5 @@
mod extension;
pub mod internal_api;
mod known_or_unknown;
mod plan;
mod timestamp;

View file

@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub legacy_user_id: i32,
pub github_login: String,
pub github_user_id: i32,
pub name: Option<String>,
pub admin: bool,
pub connected_once: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LookUpUsersByLegacyIdBody {
pub legacy_user_ids: Vec<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LookUpUsersByLegacyIdResponse {
pub users: Vec<User>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LookUpUserByGithubLoginBody {
pub github_login: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LookUpUserByGithubLoginResponse {
pub user: Option<User>,
}

View file

@ -5,6 +5,7 @@ use std::ops::Range;
use strum::{AsRefStr, EnumString};
pub const PREDICT_EDITS_MODE_HEADER_NAME: &str = "X-Zed-Predict-Edits-Mode";
pub const PREDICT_EDITS_REQUEST_ID_HEADER_NAME: &str = "X-Zed-Predict-Edits-Request-Id";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, EnumString)]
#[serde(rename_all = "snake_case")]

View file

@ -2,8 +2,8 @@ DATABASE_URL = "postgres://postgres@localhost/zed"
# DATABASE_URL = "sqlite:////root/0/zed/db.sqlite3?mode=rwc"
DATABASE_MAX_CONNECTIONS = 5
HTTP_PORT = 8080
API_TOKEN = "secret"
ZED_ENVIRONMENT = "development"
ZED_CLOUD_INTERNAL_API_KEY = "internal-api-key-secret"
LIVEKIT_SERVER = "http://localhost:7880"
LIVEKIT_KEY = "devkey"
LIVEKIT_SECRET = "secret"

View file

@ -87,16 +87,16 @@ spec:
key: url
- name: DATABASE_MAX_CONNECTIONS
value: "${DATABASE_MAX_CONNECTIONS}"
- name: API_TOKEN
valueFrom:
secretKeyRef:
name: api
key: token
- name: ZED_CLIENT_CHECKSUM_SEED
valueFrom:
secretKeyRef:
name: zed-client
key: checksum-seed
- name: ZED_CLOUD_INTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: zed-cloud
key: internal-api-key
- name: LIVEKIT_SERVER
valueFrom:
secretKeyRef:

View file

@ -11,8 +11,7 @@ use std::sync::Arc;
/// Validates the authorization header and adds an Extension<Principal> to the request.
/// Authorization: <user-id> <token>
/// <token> can be an access_token attached to that user, or an access token of an admin
/// or (in development) the string ADMIN:<config.api_token>.
/// <token> is the access_token attached to that user.
/// Authorization: "dev-server-token" <token>
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
let mut auth_header = req

View file

@ -20,7 +20,9 @@ use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};
use util::ResultExt;
use crate::services::{DatabaseUserService, UserService};
use crate::services::{
CloudUserService, DatabaseUserService, TransitionalUserService, UserService,
};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
@ -124,7 +126,6 @@ pub struct Config {
pub database_url: String,
pub seed_path: Option<PathBuf>,
pub database_max_connections: u32,
pub api_token: String,
pub livekit_server: Option<String>,
pub livekit_key: Option<String>,
pub livekit_secret: Option<String>,
@ -140,6 +141,7 @@ pub struct Config {
pub kinesis_access_key: Option<String>,
pub kinesis_secret_key: Option<String>,
pub zed_environment: Arc<str>,
pub zed_cloud_internal_api_key: String,
pub zed_client_checksum_seed: Option<String>,
}
@ -171,13 +173,13 @@ impl Config {
http_port: 0,
database_url: "".into(),
database_max_connections: 0,
api_token: "".into(),
livekit_server: None,
livekit_key: None,
livekit_secret: None,
rust_log: None,
log_json: None,
zed_environment: "test".into(),
zed_cloud_internal_api_key: "test-internal-api-key".into(),
blob_store_url: None,
blob_store_region: None,
blob_store_access_key: None,
@ -254,7 +256,7 @@ impl AppState {
let db = Arc::new(db);
let this = Self {
db: db.clone(),
http_client: Some(http_client),
http_client: Some(http_client.clone()),
livekit_client,
blob_store_client: build_blob_store_client(&config).await.log_err(),
executor,
@ -263,7 +265,19 @@ impl AppState {
} else {
None
},
user_service: Arc::new(DatabaseUserService::new(db)),
user_service: {
let database_user_service = DatabaseUserService::new(db);
let cloud_user_service = CloudUserService::new(
http_client,
config.zed_cloud_url().to_string(),
config.zed_cloud_internal_api_key.clone(),
);
Arc::new(TransitionalUserService::new(
cloud_user_service,
database_user_service,
))
},
config,
};
Ok(Arc::new(this))

View file

@ -1,7 +1,14 @@
use std::sync::Arc;
use anyhow::{Context as _, anyhow};
use async_trait::async_trait;
use cloud_api_types::internal_api::{
self, LookUpUserByGithubLoginBody, LookUpUserByGithubLoginResponse, LookUpUsersByLegacyIdBody,
LookUpUsersByLegacyIdResponse,
};
use reqwest::RequestBuilder;
use rpc::proto;
use serde::de::DeserializeOwned;
use crate::Result;
use crate::db::{Channel, Database, UserId};
@ -36,6 +43,178 @@ pub trait UserService: Send + Sync + 'static {
}
}
/// A [`UserService`] implementation for transitioning from reading from the database to reading from Cloud.
pub struct TransitionalUserService {
cloud_user_service: CloudUserService,
database_user_service: DatabaseUserService,
}
impl TransitionalUserService {
pub fn new(
cloud_user_service: CloudUserService,
database_user_service: DatabaseUserService,
) -> Self {
Self {
cloud_user_service,
database_user_service,
}
}
}
#[async_trait]
impl UserService for TransitionalUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
self.cloud_user_service.get_users_by_ids(ids).await
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
self.cloud_user_service
.get_user_by_github_login(github_login)
.await
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
self.database_user_service
.fuzzy_search_users(query, limit)
.await
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
self.database_user_service
.search_channel_members(channel, query, limit)
.await
}
}
/// A [`UserService`] implementation backed by Cloud.
pub struct CloudUserService {
http_client: reqwest::Client,
zed_cloud_url: String,
internal_api_key: String,
}
impl CloudUserService {
pub fn new(
http_client: reqwest::Client,
zed_cloud_url: String,
internal_api_key: String,
) -> Self {
Self {
http_client,
zed_cloud_url,
internal_api_key,
}
}
async fn send_request<T: DeserializeOwned + 'static>(
&self,
request: RequestBuilder,
) -> Result<T> {
let request = request
.header("Content-Type", "application/json")
.header(
"Authorization",
format!("Bearer {}", &self.internal_api_key),
)
.build()
.context("failed to build request")?;
let response = self
.http_client
.execute(request)
.await
.context("failed to send request to Cloud")?;
let status = response.status();
match response.error_for_status() {
Ok(response) => {
let response_body: T = response
.json()
.await
.context("failed to parse response body")?;
Ok(response_body)
}
Err(_err) => Err(anyhow!("request to Cloud failed with status {status}",))?,
}
}
}
#[async_trait]
impl UserService for CloudUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let response_body: LookUpUsersByLegacyIdResponse = self
.send_request(
self.http_client
.post(format!(
"{}/internal/users/look_up_by_legacy_id",
&self.zed_cloud_url
))
.json(&LookUpUsersByLegacyIdBody {
legacy_user_ids: ids.into_iter().map(|id| id.0).collect(),
}),
)
.await?;
Ok(response_body.users.into_iter().map(User::from).collect())
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let response_body: LookUpUserByGithubLoginResponse = self
.send_request(
self.http_client
.post(format!(
"{}/internal/users/look_up_by_github_login",
&self.zed_cloud_url
))
.json(&LookUpUserByGithubLoginBody {
github_login: github_login.to_string(),
}),
)
.await?;
Ok(response_body.user.map(User::from))
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let _ = query;
let _ = limit;
unimplemented!("not yet implemented in Cloud")
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let _ = channel;
let _ = query;
let _ = limit;
unimplemented!("not yet implemented in Cloud")
}
}
impl From<internal_api::User> for User {
fn from(user: internal_api::User) -> Self {
Self {
id: UserId(user.legacy_user_id),
github_login: user.github_login,
github_user_id: user.github_user_id,
name: user.name,
admin: user.admin,
connected_once: user.connected_once,
}
}
}
/// A [`UserService`] implementation backed by the database.
pub struct DatabaseUserService {
database: Arc<Database>,

View file

@ -1,18 +1,20 @@
use crate::TestServer;
use call::ActiveCall;
use client::ChannelId;
use gpui::{App, BackgroundExecutor, Entity, TestAppContext, TestScreenCaptureSource};
use project::Project;
use serde_json::json;
use util::path;
use workspace::Workspace;
use rpc::proto::PeerId;
use workspace::{AutoWatch, SharedScreen, Workspace};
use super::TestClient;
struct AutoWatchTestSetup {
client_a: TestClient,
_client_b: TestClient,
_client_c: TestClient,
project_a: Entity<Project>,
client_b: TestClient,
client_c: TestClient,
channel_id: ChannelId,
user_a_project: Entity<Project>,
user_b_project: Entity<Project>,
}
async fn setup_auto_watch_test(
@ -20,35 +22,67 @@ async fn setup_auto_watch_test(
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) -> AutoWatchTestSetup {
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, true).await
}
async fn setup_auto_watch_late_joiner_test(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) -> AutoWatchTestSetup {
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, false).await
}
async fn setup_auto_watch_test_with_initial_participants(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
join_user_c: bool,
) -> AutoWatchTestSetup {
let client_a = server.create_client(user_a, "user_a").await;
let client_b = server.create_client(user_b, "user_b").await;
let client_c = server.create_client(user_c, "user_c").await;
server
.create_room(&mut [
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, user_a),
(&client_b, user_b),
(&client_c, user_c),
])
&mut [(&client_b, user_b), (&client_c, user_c)],
)
.await;
let user_a_project = client_a.build_empty_local_project(false, user_a);
let user_b_project = client_b.build_empty_local_project(false, user_b);
let active_call_a = user_a.read(ActiveCall::global);
client_a
.fs()
.insert_tree(path!("/a"), json!({ "file.txt": "content" }))
.await;
let (project_a, _worktree_id) = client_a.build_local_project(path!("/a"), user_a).await;
active_call_a
.update(user_a, |call, cx| call.set_location(Some(&project_a), cx))
.update(user_a, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
let active_call_b = user_b.read(ActiveCall::global);
active_call_b
.update(user_b, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
if join_user_c {
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
}
AutoWatchTestSetup {
client_a,
_client_b: client_b,
_client_c: client_c,
project_a,
client_b,
client_c,
channel_id,
user_a_project,
user_b_project,
}
}
@ -61,7 +95,9 @@ async fn test_auto_watch_opens_existing_share_on_toggle(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
executor.run_until_parked();
start_screen_share(user_b).await;
@ -73,7 +109,11 @@ async fn test_auto_watch_opens_existing_share_on_toggle(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
@ -86,7 +126,9 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -96,7 +138,11 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
@ -109,7 +155,9 @@ async fn test_auto_watch_switches_to_next_share_on_share_end(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -119,7 +167,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
start_screen_share(user_c).await;
@ -129,7 +181,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_c's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
}
@ -142,7 +198,9 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
start_screen_share(user_a).await;
executor.run_until_parked();
@ -155,16 +213,11 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing(
});
executor.run_until_parked();
// Ensure that no screen share is found in user a's tab bar
workspace_a.update(user_a, |workspace, cx| {
let has_shared_screen_tab = workspace
.active_pane()
.read(cx)
.items()
.any(|item| item.tab_content_text(0, cx).contains("screen"));
assert!(
!has_shared_screen_tab,
"should not open anyone's screen share when toggling on while sharing"
assert_no_screen_share_tabs_exist(
workspace,
"should not open anyone's screen share when toggling on while sharing",
cx,
);
});
}
@ -178,7 +231,9 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -193,7 +248,11 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
@ -206,7 +265,9 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open(
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
@ -215,7 +276,11 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open(
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
workspace_a.update_in(user_a, |workspace, window, cx| {
@ -223,19 +288,165 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open(
});
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_reopens_screen_share_from_returning_channel_participant(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_late_joiner_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
let (workspace_b, user_b) = setup
.client_b
.build_workspace(&setup.user_b_project, user_b);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
workspace_b.update_in(user_b, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
.await
.unwrap();
executor.run_until_parked();
start_screen_share(user_c).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
active_call_c
.update(user_c, |call, cx| call.hang_up(cx))
.await
.unwrap();
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_no_screen_share_tabs_exist(
workspace,
"user A should stop seeing user C's screen after user C hangs up",
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_no_screen_share_tabs_exist(
workspace,
"user B should stop seeing user C's screen after user C hangs up",
cx,
);
});
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
.await
.unwrap();
executor.run_until_parked();
start_screen_share(user_c).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_is_disabled_when_following_collaborator(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
let user_b_peer_id = setup.client_b.peer_id().unwrap();
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.follow(user_b_peer_id, window, cx);
});
executor.run_until_parked();
workspace_a.update(user_a, |workspace, _cx| {
assert_eq!(*workspace.auto_watch_state(), AutoWatch::Off);
});
}
#[track_caller]
fn assert_active_matches_title(workspace: &Workspace, expected_title: &str, cx: &App) {
fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) {
let has_shared_screen_tab = workspace
.active_pane()
.read(cx)
.items()
.any(|item| item.downcast::<SharedScreen>().is_some());
assert!(!has_shared_screen_tab, "{message}");
}
#[track_caller]
fn assert_active_item_is_screen_share_for_peer(workspace: &Workspace, peer_id: PeerId, cx: &App) {
let active_item = workspace.active_item(cx).expect("no active item");
assert_eq!(
active_item.tab_content_text(0, cx),
expected_title,
"expected active item to be '{}'",
expected_title
);
let shared_screen = active_item
.downcast::<SharedScreen>()
.expect("expected active item to be a shared screen");
assert_eq!(shared_screen.read(cx).peer_id, peer_id);
}
async fn start_screen_share(cx: &mut TestAppContext) {
@ -260,6 +471,7 @@ async fn start_screen_share(cx: &mut TestAppContext) {
.unwrap();
}
#[track_caller]
fn stop_screen_share(cx: &mut TestAppContext) {
let active_call = cx.read(ActiveCall::global);
active_call

View file

@ -1,7 +1,7 @@
use crate::{TestServer, test_server::open_channel_notes};
use call::ActiveCall;
use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL;
use client::{Collaborator, ParticipantIndex, UserId};
use client::{Collaborator, LegacyUserId, ParticipantIndex};
use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT};
use collab_ui::channel_view::ChannelView;
@ -864,7 +864,10 @@ async fn test_channel_buffer_operations_lost_on_reconnect(
}
#[track_caller]
fn assert_collaborators(collaborators: &HashMap<PeerId, Collaborator>, ids: &[Option<UserId>]) {
fn assert_collaborators(
collaborators: &HashMap<PeerId, Collaborator>,
ids: &[Option<LegacyUserId>],
) {
let mut user_ids = collaborators
.values()
.map(|collaborator| collaborator.user_id)

View file

@ -314,7 +314,7 @@ async fn test_core_channels(
#[track_caller]
fn assert_participants_eq(participants: &[Arc<User>], expected_partitipants: &[u64]) {
assert_eq!(
participants.iter().map(|p| p.id).collect::<Vec<_>>(),
participants.iter().map(|p| p.legacy_id).collect::<Vec<_>>(),
expected_partitipants
);
}
@ -327,7 +327,7 @@ fn assert_members_eq(
assert_eq!(
members
.iter()
.map(|member| (member.user.id, member.role, member.kind))
.map(|member| (member.user.legacy_id, member.role, member.kind))
.collect::<Vec<_>>(),
expected_members
);
@ -1259,7 +1259,7 @@ async fn test_guest_access(
client_a.channel_store().update(cx_a, |channel_store, _| {
let participants = channel_store.channel_participants(channel_a);
assert_eq!(participants.len(), 1);
assert_eq!(participants[0].id, client_b.user_id().unwrap());
assert_eq!(participants[0].legacy_id, client_b.user_id().unwrap());
});
}
@ -1320,7 +1320,7 @@ async fn test_invite_access(
client_a.channel_store().update(cx_a, |channel_store, _| {
let participants = channel_store.channel_participants(channel_b_id);
assert_eq!(participants.len(), 1);
assert_eq!(participants[0].id, client_b.user_id().unwrap());
assert_eq!(participants[0].legacy_id, client_b.user_id().unwrap());
})
}

View file

@ -1399,7 +1399,12 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
"Should have fetched one code lens action, but got: {resulting_lens_actions:?}"
);
assert_eq!(
resulting_lens_actions.first().unwrap().lsp_action.title(),
resulting_lens_actions
.values()
.next()
.unwrap()
.lsp_action
.title(),
"LSP Command 1",
"Only the final code lens action should be in the data"
)

View file

@ -1872,7 +1872,7 @@ async fn test_active_call_events(
mem::take(&mut *events_b.borrow_mut()),
vec![room::Event::RemoteProjectShared {
owner: Arc::new(User {
id: client_a.user_id().unwrap(),
legacy_id: client_a.user_id().unwrap(),
github_login: "user_a".into(),
avatar_uri: "avatar_a".into(),
name: None,
@ -1891,7 +1891,7 @@ async fn test_active_call_events(
mem::take(&mut *events_a.borrow_mut()),
vec![room::Event::RemoteProjectShared {
owner: Arc::new(User {
id: client_b.user_id().unwrap(),
legacy_id: client_b.user_id().unwrap(),
github_login: "user_b".into(),
avatar_uri: "avatar_b".into(),
name: None,

View file

@ -196,7 +196,7 @@ impl RandomizedTest for ProjectCollaborationTest {
if !available_contacts.is_empty() {
let contact = available_contacts.choose(rng).unwrap();
break ClientOperation::InviteContactToCall {
user_id: UserId(contact.user.id as i32),
user_id: UserId(contact.user.legacy_id as i32),
};
}
}
@ -235,7 +235,7 @@ impl RandomizedTest for ProjectCollaborationTest {
None
} else {
Some((
UserId::from_proto(participant.user.id),
UserId::from_proto(participant.user.legacy_id),
project.worktree_root_names[0].clone(),
))
}

View file

@ -586,13 +586,13 @@ impl TestServer {
http_port: 0,
database_url: "".into(),
database_max_connections: 0,
api_token: "".into(),
livekit_server: None,
livekit_key: None,
livekit_secret: None,
rust_log: None,
log_json: None,
zed_environment: "test".into(),
zed_cloud_internal_api_key: "test-internal-api-key".into(),
blob_store_url: None,
blob_store_region: None,
blob_store_access_key: None,
@ -658,11 +658,9 @@ impl TestClient {
}
pub fn current_user_id(&self, cx: &TestAppContext) -> UserId {
UserId::from_proto(
self.app_state
.user_store
.read_with(cx, |user_store, _| user_store.current_user().unwrap().id),
)
UserId::from_proto(self.app_state.user_store.read_with(cx, |user_store, _| {
user_store.current_user().unwrap().legacy_id
}))
}
pub async fn wait_for_current_user(&self, cx: &TestAppContext) {

View file

@ -618,7 +618,7 @@ impl CollabPanel {
executor.clone(),
));
if !matches.is_empty() {
let user_id = user.id;
let user_id = user.legacy_id;
self.entries.push(ListEntry::CallParticipant {
user,
peer_id: None,
@ -648,7 +648,7 @@ impl CollabPanel {
self.match_candidates
.extend(room.remote_participants().values().map(|participant| {
StringMatchCandidate::new(
participant.user.id as usize,
participant.user.legacy_id as usize,
&participant.user.github_login,
)
}));
@ -684,7 +684,7 @@ impl CollabPanel {
self.entries.push(ListEntry::ParticipantProject {
project_id: project.id,
worktree_root_names: project.worktree_root_names.clone(),
host_user_id: participant.user.id,
host_user_id: participant.user.legacy_id,
is_last: projects.peek().is_none() && !participant.has_video_tracks(),
});
}
@ -1024,7 +1024,9 @@ impl CollabPanel {
let contact = &contacts[mat.candidate_id];
self.entries.push(ListEntry::Contact {
contact: contact.clone(),
calling: active_call.pending_invites().contains(&contact.user.id),
calling: active_call
.pending_invites()
.contains(&contact.user.legacy_id),
});
}
}
@ -1122,9 +1124,13 @@ impl CollabPanel {
is_selected: bool,
cx: &mut Context<Self>,
) -> ListItem {
let user_id = user.id;
let is_current_user =
self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
let user_id = user.legacy_id;
let is_current_user = self
.user_store
.read(cx)
.current_user()
.map(|user| user.legacy_id)
== Some(user_id);
let tooltip = format!("Follow {}", user.github_login);
let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
@ -1650,7 +1656,7 @@ impl CollabPanel {
let in_room = ActiveCall::global(cx).read(cx).room().is_some();
let context_menu = ContextMenu::build(window, cx, |mut context_menu, _, _| {
let user_id = contact.user.id;
let user_id = contact.user.legacy_id;
if contact.online && !contact.busy {
let label = if in_room {
@ -1673,7 +1679,7 @@ impl CollabPanel {
move |window, cx| {
this.update(cx, |this, cx| {
this.remove_contact(
contact.user.id,
contact.user.legacy_id,
&contact.user.github_login,
window,
cx,
@ -1777,7 +1783,7 @@ impl CollabPanel {
},
ListEntry::Contact { contact, calling } => {
if contact.online && !contact.busy && !calling {
self.call(contact.user.id, window, cx);
self.call(contact.user.legacy_id, window, cx);
}
}
ListEntry::ParticipantProject {
@ -1834,7 +1840,7 @@ impl CollabPanel {
}
}
ListEntry::IncomingRequest(user) => {
self.respond_to_contact_request(user.id, true, window, cx)
self.respond_to_contact_request(user.legacy_id, true, window, cx)
}
ListEntry::ChannelInvite(channel) => {
self.respond_to_channel_invite(channel.id, true, cx)
@ -2450,7 +2456,7 @@ impl CollabPanel {
}
fn leave_channel(&self, channel_id: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.id) else {
let Some(user_id) = self.user_store.read(cx).current_user().map(|u| u.legacy_id) else {
return;
};
let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) else {
@ -2705,7 +2711,7 @@ impl CollabPanel {
.into_any_element()
}
ListEntry::Contact { contact, calling } => {
self.mark_contact_request_accepted_notifications_read(contact.user.id, cx);
self.mark_contact_request_accepted_notifications_read(contact.user.legacy_id, cx);
self.render_contact(&contact, calling, is_selected, cx)
.into_any_element()
}
@ -2913,6 +2919,13 @@ impl CollabPanel {
if show_auto_watch || show_copy {
Some(
h_flex()
.when_some(channel_link, |this, channel_link| {
this.child(
CopyButton::new("copy-channel-link", channel_link)
.visible_on_hover("section-header")
.tooltip_label("Copy Channel Link"),
)
})
.when(has_auto_watch_flag, |this| {
this.child(
IconButton::new(
@ -2952,13 +2965,6 @@ impl CollabPanel {
)),
)
})
.when_some(channel_link, |this, channel_link| {
this.child(
CopyButton::new("copy-channel-link", channel_link)
.visible_on_hover("section-header")
.tooltip_label("Copy Channel Link"),
)
})
.into_any_element(),
)
} else {
@ -3122,7 +3128,7 @@ impl CollabPanel {
cx: &mut Context<Self>,
) -> impl IntoElement {
let github_login = user.github_login.clone();
let user_id = user.id;
let user_id = user.legacy_id;
let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user);
let color = if is_response_pending {
Color::Muted
@ -3830,6 +3836,12 @@ impl Panel for CollabPanel {
fn activation_priority(&self) -> u32 {
5
}
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
Some(workspace::HideStatusItem::new(|settings| {
settings.collaboration_panel.get_or_insert_default().button = Some(false);
}))
}
}
impl Focusable for CollabPanel {
@ -3848,7 +3860,7 @@ impl PartialEq for ListEntry {
}
ListEntry::CallParticipant { user: user_1, .. } => {
if let ListEntry::CallParticipant { user: user_2, .. } = other {
return user_1.id == user_2.id;
return user_1.legacy_id == user_2.legacy_id;
}
}
ListEntry::ParticipantProject {
@ -3902,12 +3914,12 @@ impl PartialEq for ListEntry {
}
ListEntry::IncomingRequest(user_1) => {
if let ListEntry::IncomingRequest(user_2) = other {
return user_1.id == user_2.id;
return user_1.legacy_id == user_2.legacy_id;
}
}
ListEntry::OutgoingRequest(user_1) => {
if let ListEntry::OutgoingRequest(user_2) = other {
return user_1.id == user_2.id;
return user_1.legacy_id == user_2.legacy_id;
}
}
ListEntry::Contact {
@ -3917,7 +3929,7 @@ impl PartialEq for ListEntry {
contact: contact_2, ..
} = other
{
return contact_1.user.id == contact_2.user.id;
return contact_1.user.legacy_id == contact_2.user.legacy_id;
}
}
ListEntry::ChannelEditor { depth } => {

View file

@ -1,6 +1,6 @@
use channel::{ChannelMembership, ChannelStore};
use client::{
ChannelId, User, UserId, UserStore,
ChannelId, LegacyUserId, User, UserStore,
proto::{self, ChannelRole, ChannelVisibility},
};
use fuzzy::{StringMatchCandidate, match_strings};
@ -364,15 +364,20 @@ impl PickerDelegate for ChannelModalDelegate {
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(selected_user) = self.user_at_index(self.selected_index) {
if Some(selected_user.id) == self.user_store.read(cx).current_user().map(|user| user.id)
if Some(selected_user.legacy_id)
== self
.user_store
.read(cx)
.current_user()
.map(|user| user.legacy_id)
{
return;
}
match self.mode {
Mode::ManageMembers => self.show_context_menu(self.selected_index, window, cx),
Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
Mode::InviteMembers => match self.member_status(selected_user.legacy_id, cx) {
Some(proto::channel_member::Kind::Invitee) => {
self.remove_member(selected_user.id, window, cx);
self.remove_member(selected_user.legacy_id, window, cx);
}
Some(proto::channel_member::Kind::Member) => {}
None => self.invite_member(selected_user, window, cx),
@ -400,8 +405,13 @@ impl PickerDelegate for ChannelModalDelegate {
) -> Option<Self::ListItem> {
let user = self.user_at_index(ix)?;
let membership = self.member_at_index(ix);
let request_status = self.member_status(user.id, cx);
let is_me = self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
let request_status = self.member_status(user.legacy_id, cx);
let is_me = self
.user_store
.read(cx)
.current_user()
.map(|user| user.legacy_id)
== Some(user.legacy_id);
Some(
ListItem::new(ix)
@ -459,10 +469,16 @@ impl PickerDelegate for ChannelModalDelegate {
}
impl ChannelModalDelegate {
fn member_status(&self, user_id: UserId, cx: &App) -> Option<proto::channel_member::Kind> {
fn member_status(
&self,
user_id: LegacyUserId,
cx: &App,
) -> Option<proto::channel_member::Kind> {
self.members
.iter()
.find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
.find_map(|membership| {
(membership.user.legacy_id == user_id).then_some(membership.kind)
})
.or_else(|| {
self.channel_store
.read(cx)
@ -489,7 +505,7 @@ impl ChannelModalDelegate {
fn set_user_role(
&mut self,
user_id: UserId,
user_id: LegacyUserId,
new_role: ChannelRole,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
@ -501,7 +517,11 @@ impl ChannelModalDelegate {
update.await?;
picker.update_in(cx, |picker, window, cx| {
let this = &mut picker.delegate;
if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) {
if let Some(member) = this
.members
.iter_mut()
.find(|m| m.user.legacy_id == user_id)
{
member.role = new_role;
}
cx.focus_self(window);
@ -514,7 +534,7 @@ impl ChannelModalDelegate {
fn remove_member(
&mut self,
user_id: UserId,
user_id: LegacyUserId,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<()> {
@ -525,7 +545,11 @@ impl ChannelModalDelegate {
update.await?;
picker.update_in(cx, |picker, window, cx| {
let this = &mut picker.delegate;
if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
if let Some(ix) = this
.members
.iter_mut()
.position(|m| m.user.legacy_id == user_id)
{
this.members.remove(ix);
this.matching_member_indices.retain_mut(|member_ix| {
if *member_ix == ix {
@ -556,7 +580,7 @@ impl ChannelModalDelegate {
cx: &mut Context<Picker<Self>>,
) {
let invite_member = self.channel_store.update(cx, |store, cx| {
store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
store.invite_member(self.channel_id, user.legacy_id, ChannelRole::Member, cx)
});
cx.spawn_in(window, async move |this, cx| {
@ -588,7 +612,7 @@ impl ChannelModalDelegate {
let Some(membership) = self.member_at_index(ix) else {
return;
};
let user_id = membership.user.id;
let user_id = membership.user.legacy_id;
let picker = cx.entity();
let context_menu = ContextMenu::build(window, cx, |mut menu, _window, _cx| {
let role = membership.role;

View file

@ -122,12 +122,12 @@ impl PickerDelegate for ContactFinderDelegate {
match user_store.contact_request_status(user) {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
self.user_store
.update(cx, |store, cx| store.request_contact(user.id, cx))
.update(cx, |store, cx| store.request_contact(user.legacy_id, cx))
.detach();
}
ContactRequestStatus::RequestSent => {
self.user_store
.update(cx, |store, cx| store.remove_contact(user.id, cx))
.update(cx, |store, cx| store.remove_contact(user.legacy_id, cx))
.detach();
}
_ => {}

View file

@ -71,7 +71,7 @@ impl IncomingCallNotificationState {
let active_call = ActiveCall::global(cx);
if accept {
let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
let caller_user_id = self.call.calling_user.id;
let caller_user_id = self.call.calling_user.legacy_id;
let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
let app_state = self.app_state.clone();
let cx: &mut App = cx;

View file

@ -102,7 +102,7 @@ impl ProjectSharedNotification {
fn join(&mut self, cx: &mut Context<Self>) {
if let Some(app_state) = self.app_state.upgrade() {
workspace::join_in_room_project(self.project_id, self.owner.id, app_state, cx)
workspace::join_in_room_project(self.project_id, self.owner.legacy_id, app_state, cx)
.detach_and_log_err(cx);
}
}

View file

@ -1606,6 +1606,12 @@ impl Panel for DebugPanel {
7
}
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
Some(workspace::HideStatusItem::new(|settings| {
settings.debugger.get_or_insert_default().button = Some(false);
}))
}
fn set_active(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) {}
fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {

View file

@ -19,6 +19,7 @@ collections.workspace = true
component.workspace = true
ctor.workspace = true
editor.workspace = true
futures-lite.workspace = true
gpui.workspace = true
indoc.workspace = true
itertools.workspace = true

View file

@ -981,24 +981,26 @@ async fn context_range_for_entry(
snapshot: BufferSnapshot,
cx: &mut AsyncApp,
) -> Range<text::Anchor> {
let range = if let Some(rows) = heuristic_syntactic_expand(
let expanded_range = heuristic_syntactic_expand(
range.clone(),
DIAGNOSTIC_EXPANSION_ROW_LIMIT,
snapshot.clone(),
cx,
)
.await
.filter(|rows| rows.start() != rows.end())
{
Range {
start: Point::new(*rows.start(), 0),
end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left),
}
.await;
let row_range = expanded_range.unwrap_or_else(|| range.start.row..=range.end.row);
let row_count = row_range.end().saturating_sub(*row_range.start()) + 1;
let target_row_count = context.saturating_mul(2).saturating_add(1);
let row_range = if let Some(rows_to_add) = target_row_count.checked_sub(row_count) {
let rows_before = rows_to_add.div_ceil(2);
let rows_after = rows_to_add / 2;
row_range.start().saturating_sub(rows_before)..=row_range.end().saturating_add(rows_after)
} else {
Range {
start: Point::new(range.start.row.saturating_sub(context), 0),
end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left),
}
row_range
};
let range = Range {
start: Point::new(*row_range.start(), 0),
end: snapshot.clip_point(Point::new(*row_range.end(), u32::MAX), Bias::Left),
};
snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end)
}
@ -1050,47 +1052,41 @@ async fn heuristic_syntactic_expand(
let node_range = node_start..node_end;
let row_count = node_end.row - node_start.row + 1;
let mut ancestor_range = None;
cx.background_executor()
.await_on_background(async {
// Stop if we've exceeded the row count or reached an outline node. Then, find the interval
// of node children which contains the query range. For example, this allows just returning
// the header of a declaration rather than the entire declaration.
if row_count > max_row_count || outline_range == Some(node_range.clone()) {
let mut cursor = node.walk();
let mut included_child_start = None;
let mut included_child_end = None;
let mut previous_end = node_start;
if cursor.goto_first_child() {
loop {
let child_node = cursor.node();
let child_range =
previous_end..Point::from_ts_point(child_node.end_position());
if included_child_start.is_none()
&& child_range.contains(&input_range.start)
{
included_child_start = Some(child_range.start);
}
if child_range.contains(&input_range.end) {
included_child_end = Some(child_range.end);
}
previous_end = child_range.end;
if !cursor.goto_next_sibling() {
break;
}
}
// Stop if we've exceeded the row count or reached an outline node. Then, find the interval
// of node children which contains the query range. For example, this allows just returning
// the header of a declaration rather than the entire declaration.
if row_count > max_row_count || outline_range == Some(node_range.clone()) {
let mut cursor = node.walk();
let mut included_child_start = None;
let mut included_child_end = None;
let mut previous_end = node_start;
if cursor.goto_first_child() {
loop {
let child_node = cursor.node();
let child_range = previous_end..Point::from_ts_point(child_node.end_position());
if included_child_start.is_none() && child_range.contains(&input_range.start) {
included_child_start = Some(child_range.start);
}
let end = included_child_end.unwrap_or(node_range.end);
if let Some(start) = included_child_start {
let row_count = end.row - start.row;
if row_count < max_row_count {
ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row)));
return;
}
if child_range.contains(&input_range.end) {
included_child_end = Some(child_range.end);
}
previous_end = child_range.end;
if !cursor.goto_next_sibling() {
break;
}
ancestor_range = Some(None);
}
})
.await;
}
let end = included_child_end.unwrap_or(node_range.end);
if let Some(start) = included_child_start {
let row_count = end.row - start.row;
if row_count < max_row_count {
ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row)));
}
}
if ancestor_range.is_none() {
ancestor_range = Some(None);
}
}
if let Some(node) = ancestor_range {
return node;
}
@ -1139,6 +1135,7 @@ async fn heuristic_syntactic_expand(
return None;
};
node = parent;
futures_lite::future::yield_now().await;
}
}

View file

@ -2,15 +2,15 @@ use std::time::Duration;
use editor::{Editor, MultiBufferOffset};
use gpui::{
Context, Entity, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, Task,
WeakEntity, Window,
App, Context, Entity, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription,
Task, WeakEntity, Window,
};
use language::Diagnostic;
use project::project_settings::{GoToDiagnosticSeverityFilter, ProjectSettings};
use settings::Settings;
use ui::{Button, ButtonLike, Color, Icon, IconName, Label, Tooltip, h_flex, prelude::*};
use util::ResultExt;
use workspace::{StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle};
use workspace::{HideStatusItem, StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle};
use crate::{Deploy, IncludeWarnings, ProjectDiagnosticsEditor};
@ -224,4 +224,10 @@ impl StatusItemView for DiagnosticIndicator {
}
cx.notify();
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
Some(HideStatusItem::new(|settings| {
settings.diagnostics.get_or_insert_default().button = Some(false);
}))
}
}

View file

@ -3,8 +3,8 @@ use client::{Client, EditPredictionUsage, NeedsLlmTokenRefresh, UserStore, globa
use cloud_api_client::LlmApiToken;
use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody};
use cloud_llm_client::predict_edits_v3::{
PREDICT_EDITS_MODE_HEADER_NAME, PredictEditsMode, PredictEditsV3Request,
PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse,
PREDICT_EDITS_MODE_HEADER_NAME, PREDICT_EDITS_REQUEST_ID_HEADER_NAME, PredictEditsMode,
PredictEditsV3Request, PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse,
};
use cloud_llm_client::{
EditPredictionRejectReason, EditPredictionRejection,
@ -33,16 +33,16 @@ use gpui::{
};
use heapless::Vec as ArrayVec;
use language::{
Anchor, Buffer, BufferSnapshot, EditPredictionsMode, EditPreview, File, OffsetRangeExt, Point,
TextBufferSnapshot, ToOffset, ToPoint, language_settings::all_language_settings,
Anchor, Buffer, BufferSnapshot, EditPredictionPromptFormat, EditPredictionsMode, EditPreview,
File, OffsetRangeExt, Point, TextBufferSnapshot, ToOffset, ToPoint,
language_settings::all_language_settings,
};
use project::{DisableAiSettings, Project, ProjectPath, WorktreeId};
use release_channel::AppVersion;
use semver::Version;
use serde::de::DeserializeOwned;
use settings::{
EditPredictionDataCollectionChoice, EditPredictionPromptFormat, EditPredictionProvider,
Settings as _, update_settings_file,
EditPredictionDataCollectionChoice, EditPredictionProvider, Settings as _, update_settings_file,
};
use std::collections::{VecDeque, hash_map};
use std::env;
@ -2601,6 +2601,7 @@ impl EditPredictionStore {
.build_zed_llm_url("/predict_edits/v3", &[])?;
let request = PredictEditsV3Request { input, trigger };
let request_id = uuid::Uuid::new_v4().to_string();
let json_bytes = serde_json::to_vec(&request)?;
let compressed = zstd::encode_all(&json_bytes[..], 3)?;
@ -2610,7 +2611,8 @@ impl EditPredictionStore {
let builder = builder
.uri(url.as_ref())
.header("Content-Encoding", "zstd")
.header(PREDICT_EDITS_MODE_HEADER_NAME, mode.as_ref());
.header(PREDICT_EDITS_MODE_HEADER_NAME, mode.as_ref())
.header(PREDICT_EDITS_REQUEST_ID_HEADER_NAME, request_id.as_str());
let builder = if let Some(preferred_experiment) = preferred_experiment.as_deref() {
builder.header(PREFERRED_EXPERIMENT_HEADER_NAME, preferred_experiment)
} else {

View file

@ -6,10 +6,9 @@ use crate::{
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, AppContext as _, Entity, Task};
use language::{
Anchor, Buffer, BufferSnapshot, ToOffset, ToPoint as _,
Anchor, Buffer, BufferSnapshot, EditPredictionPromptFormat, ToOffset, ToPoint as _,
language_settings::all_language_settings,
};
use settings::EditPredictionPromptFormat;
use std::{path::Path, sync::Arc, time::Instant};
use zeta_prompt::{ZetaPromptInput, compute_editable_and_context_ranges};

View file

@ -12,11 +12,10 @@ use cloud_llm_client::{
use edit_prediction_types::PredictedCursorPosition;
use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*};
use language::{
Buffer, BufferSnapshot, DiagnosticSeverity, OffsetRangeExt as _, ToOffset as _,
language_settings::all_language_settings, text_diff,
Buffer, BufferSnapshot, DiagnosticSeverity, EditPredictionPromptFormat, OffsetRangeExt as _,
ToOffset as _, ZetaVersion, language_settings::all_language_settings, text_diff,
};
use release_channel::AppVersion;
use settings::EditPredictionPromptFormat;
use text::{Anchor, Bias, Point};
use ui::SharedString;
use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
@ -101,10 +100,30 @@ pub fn request_prediction_with_zeta(
let request_task = cx.background_spawn({
async move {
let zeta_version = raw_config
let local_zeta_version = custom_server_settings
.as_ref()
.and_then(|settings| match settings.prompt_format {
EditPredictionPromptFormat::Zeta(version) => Some(version),
EditPredictionPromptFormat::Infer => {
match settings.model.to_ascii_lowercase().as_str() {
"zeta" | "zeta1" => Some(ZetaVersion::Zeta1),
"zeta2" => Some(ZetaVersion::Zeta2),
"zeta2.1" => Some(ZetaVersion::Zeta2_1),
_ => None,
}
}
_ => None,
})
.unwrap_or_default();
let zeta_format = raw_config
.as_ref()
.map(|config| config.format)
.unwrap_or(ZetaFormat::default());
.or(match local_zeta_version {
ZetaVersion::Zeta1 => None,
ZetaVersion::Zeta2 => Some(ZetaFormat::V0211SeedCoder),
ZetaVersion::Zeta2_1 => Some(ZetaFormat::V0318SeedMultiRegions),
})
.unwrap_or_default();
let cursor_offset = position.to_offset(&snapshot);
let (full_context_offset_range, prompt_input) = zeta2_prompt_input(
@ -119,7 +138,7 @@ pub fn request_prediction_with_zeta(
repo_url,
);
let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_version);
let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_format);
if let Some(debug_tx) = &debug_tx {
debug_tx
@ -139,8 +158,8 @@ pub fn request_prediction_with_zeta(
(if let Some(custom_settings) = &custom_server_settings {
let max_tokens = custom_settings.max_output_tokens * 4;
Some(match custom_settings.prompt_format {
EditPredictionPromptFormat::Zeta => {
Some(match local_zeta_version {
ZetaVersion::Zeta1 => {
let ranges = &prompt_input.excerpt_ranges;
let editable_range_in_excerpt = ranges.editable_350.clone();
let prompt = zeta1::format_zeta1_from_input(
@ -176,11 +195,11 @@ pub fn request_prediction_with_zeta(
(request_id, parsed_output, None, None)
}
EditPredictionPromptFormat::Zeta2 => {
ZetaVersion::Zeta2 | ZetaVersion::Zeta2_1 => {
let Some(prompt) = formatted_prompt.clone() else {
return Ok((None, None));
};
let prefill = get_prefill(&prompt_input, zeta_version);
let prefill = get_prefill(&prompt_input, zeta_format);
let prompt = format!("{prompt}{prefill}");
let (response_text, request_id) = send_custom_server_request(
@ -188,7 +207,7 @@ pub fn request_prediction_with_zeta(
custom_settings,
prompt,
max_tokens,
stop_tokens_for_format(zeta_version)
stop_tokens_for_format(zeta_format)
.iter()
.map(|token| token.to_string())
.collect(),
@ -204,14 +223,13 @@ pub fn request_prediction_with_zeta(
let output = format!("{prefill}{response_text}");
Some(parse_zeta2_model_output(
&output,
zeta_version,
zeta_format,
&prompt_input,
)?)
};
(request_id, output_text, None, None)
}
_ => anyhow::bail!("unsupported prompt format"),
})
} else if let Some(config) = &raw_config {
let Some(prompt) = format_zeta_prompt(&prompt_input, config.format) else {

View file

@ -37,7 +37,7 @@ use ui::{
use util::ResultExt as _;
use workspace::{
StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
HideStatusItem, StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
notifications::NotificationId,
};
use zed_actions::{OpenBrowser, OpenSettingsAt};
@ -594,30 +594,20 @@ impl EditPredictionButton {
continue;
};
let is_current = provider == current_provider;
let is_disabled_zed_provider =
provider == EditPredictionProvider::Zed && is_zed_provider_disabled;
let fs = self.fs.clone();
menu = menu.item(
ContextMenuEntry::new(name)
.toggleable(
IconPosition::Start,
is_current
&& (provider == EditPredictionProvider::Zed
&& !is_zed_provider_disabled),
)
.disabled(
provider == EditPredictionProvider::Zed && is_zed_provider_disabled,
)
.when(
provider == EditPredictionProvider::Zed && is_zed_provider_disabled,
|item| {
item.documentation_aside(DocumentationSide::Left, move |_cx| {
Label::new(
"Edit predictions are disabled for this organization.",
)
.toggleable(IconPosition::Start, is_current && !is_disabled_zed_provider)
.disabled(is_disabled_zed_provider)
.when(is_disabled_zed_provider, |item| {
item.documentation_aside(DocumentationSide::Left, move |_cx| {
Label::new("Edit predictions are disabled for this organization.")
.into_any_element()
})
},
)
})
})
.handler(move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}),
@ -1364,6 +1354,13 @@ impl StatusItemView for EditPredictionButton {
}
cx.notify();
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
// This button is already gated on having a non-disabled edit
// prediction provider, which the user manages through provider/AI
// settings.
None
}
}
async fn open_disabled_globs_setting_in_editor(

View file

@ -1,13 +1,14 @@
use std::sync::Arc;
use collections::{HashMap, HashSet};
use futures::future::join_all;
use futures::{StreamExt as _, future::join_all, stream::FuturesUnordered};
use gpui::{MouseButton, SharedString, Task, TaskExt, WeakEntity};
use itertools::Itertools;
use language::{BufferId, ClientCommand};
use multi_buffer::{Anchor, MultiBufferRow, MultiBufferSnapshot, ToPoint as _};
use project::{CodeAction, TaskSourceKind};
use project::{CodeAction, TaskSourceKind, lsp_store::code_lens::CodeLensActions};
use task::TaskContext;
use text::ToOffset as _;
use ui::{Context, Window, div, prelude::*};
@ -27,7 +28,7 @@ struct CodeLensLine {
#[derive(Clone, Debug)]
struct CodeLensItem {
title: SharedString,
title: Option<SharedString>,
action: CodeAction,
}
@ -39,7 +40,7 @@ pub(super) struct CodeLensBlock {
pub(super) struct CodeLensState {
pub(super) blocks: HashMap<BufferId, Vec<CodeLensBlock>>,
actions: HashMap<BufferId, Vec<CodeAction>>,
actions: HashMap<BufferId, CodeLensActions>,
resolve_task: Task<()>,
}
@ -203,7 +204,7 @@ impl Editor {
.timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
.await;
let Some(tasks) = project
let Some(tasks_per_buffer) = project
.update(cx, |project, cx| {
project.lsp_store().update(cx, |lsp_store, cx| {
buffers_to_query
@ -221,15 +222,15 @@ impl Editor {
return;
};
let results = join_all(tasks).await;
if results.is_empty() {
let code_lens_per_buffer = join_all(tasks_per_buffer).await;
if code_lens_per_buffer.is_empty() {
return;
}
editor
.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
for (buffer_id, result) in results {
for (buffer_id, result) in code_lens_per_buffer {
let actions = match result {
Ok(Some(actions)) => actions,
Ok(None) => continue,
@ -248,58 +249,50 @@ impl Editor {
});
}
/// Reconciles the set of blocks for `buffer_id` with `actions`. For each
/// existing block at row `R`:
/// - if the new fetch has no lens at `R` → remove the block (the lens is
/// gone, e.g. the function was deleted);
/// - if the new fetch has a titled lens at `R` whose rendered text
/// differs from the block's current line → swap the renderer in place
/// via [`Editor::replace_blocks`];
/// - if the new fetch has a titled lens at `R` with the same rendered
/// text → keep the block as-is;
/// - if the new fetch has a lens at `R` but no `command` yet (the server
/// sent a shallow response that needs a separate `resolve`) → keep the
/// block as-is. The previously rendered (resolved) content stays on
/// screen until the next viewport-driven `resolve` produces a new
/// title; only then does the comparison-and-replace happen. This is
/// what keeps the post-edit screen from flickering for shallow servers
/// like `rust-analyzer`.
/// Reconcile blocks for `buffer_id` against the latest `actions`.
///
/// Rows present in the new fetch with a title but no existing block get
/// a fresh block inserted.
/// Lenses without a `command` keep a placeholder block so the line
/// stays reserved while the resolve is in flight — this is what avoids
/// the post-edit flicker on `rust-analyzer`-style servers. Lenses
/// whose resolve already came back without a usable title are dropped
/// (`resolve_visible_code_lenses` won't retry them), otherwise they'd
/// leave a permanent blank line.
///
/// When the new fetch has only placeholders for a row but the old
/// block was already resolved we keep the old block, so the line
/// doesn't blank out until the fresh resolve lands.
fn apply_lens_actions_for_buffer(
&mut self,
buffer_id: BufferId,
actions: Vec<CodeAction>,
actions: CodeLensActions,
snapshot: &MultiBufferSnapshot,
cx: &mut Context<Self>,
) {
let mut rows_with_any_lens = HashSet::default();
let mut titled_lenses = Vec::new();
for action in &actions {
let mut all_lenses = Vec::new();
for (_, action) in actions.iter().sorted_by_key(|(id, _)| **id) {
let Some(position) = snapshot.anchor_in_excerpt(action.range.start) else {
continue;
};
rows_with_any_lens.insert(MultiBufferRow(position.to_point(snapshot).row));
if let project::LspAction::CodeLens(lens) = &action.lsp_action {
if let Some(title) = lens
let title = lens
.command
.as_ref()
.map(|cmd| SharedString::from(&cmd.title))
{
titled_lenses.push((
position,
CodeLensItem {
title,
action: action.clone(),
},
));
.filter(|cmd| !cmd.title.is_empty())
.map(|cmd| SharedString::from(&cmd.title));
if title.is_none() && action.resolved {
continue;
}
all_lenses.push((
position,
CodeLensItem {
title,
action: action.clone(),
},
));
}
}
let mut new_lines_by_row = group_lenses_by_row(titled_lenses, snapshot)
let mut new_lines_by_row = group_lenses_by_row(all_lenses, snapshot)
.map(|line| (MultiBufferRow(line.position.to_point(snapshot).row), line))
.collect::<HashMap<_, _>>();
@ -314,15 +307,17 @@ impl Editor {
for old in old_blocks {
let row = MultiBufferRow(old.anchor.to_point(snapshot).row);
if !rows_with_any_lens.contains(&row) {
let Some(new_line) = new_lines_by_row.remove(&row) else {
blocks_to_remove.insert(old.block_id);
continue;
}
};
covered_rows.insert(row);
let Some(new_line) = new_lines_by_row.remove(&row) else {
let new_all_unresolved = new_line.items.iter().all(|item| item.title.is_none());
let old_has_resolved = old.line.items.iter().any(|item| item.title.is_some());
if new_all_unresolved && old_has_resolved {
kept_blocks.push(old);
continue;
};
}
if rendered_text_matches(&old.line, &new_line) {
kept_blocks.push(old);
} else {
@ -436,61 +431,72 @@ impl Editor {
return;
};
let resolve_tasks = self
.visible_buffer_ranges(cx)
.into_iter()
.filter_map(|(snapshot, visible_range, _)| {
let buffer_id = snapshot.remote_id();
let buffer = self.buffer.read(cx).buffer(buffer_id)?;
let visible_anchor_range = snapshot.anchor_before(visible_range.start)
..snapshot.anchor_after(visible_range.end);
let task = project.update(cx, |project, cx| {
project.lsp_store().update(cx, |lsp_store, cx| {
lsp_store.resolve_visible_code_lenses(&buffer, visible_anchor_range, cx)
})
let lsp_store = project.read(cx).lsp_store();
let mut pending_resolves = Vec::new();
for (buffer_snapshot, visible_range, _) in self.visible_buffer_ranges(cx) {
let buffer_id = buffer_snapshot.remote_id();
let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else {
continue;
};
let Some(actions) = self
.code_lens
.as_ref()
.and_then(|state| state.actions.get(&buffer_id))
else {
continue;
};
for (lens_id, action) in actions {
if action.resolved {
continue;
}
if let project::LspAction::CodeLens(lens) = &action.lsp_action {
if lens.command.is_some() {
continue;
}
}
let action_offset = action.range.start.to_offset(&buffer_snapshot);
if action_offset < visible_range.start.0 || action_offset > visible_range.end.0 {
continue;
}
let resolve_task = lsp_store.update(cx, |lsp_store, cx| {
lsp_store.resolve_code_lens(&buffer, action.server_id, *lens_id, cx)
});
Some((buffer_id, task))
})
.collect::<Vec<_>>();
if resolve_tasks.is_empty() {
pending_resolves.push((buffer_id, resolve_task));
}
}
if pending_resolves.is_empty() {
return;
}
let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
code_lens.resolve_task = cx.spawn(async move |editor, cx| {
let resolved_per_buffer = join_all(
resolve_tasks
.into_iter()
.map(|(buffer_id, task)| async move { (buffer_id, task.await) }),
)
.await;
editor
.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
for (buffer_id, newly_resolved) in resolved_per_buffer {
if newly_resolved.is_empty() {
continue;
}
let mut resolves_in_progress = pending_resolves
.into_iter()
.map(|(buffer_id, task)| async move { (buffer_id, task.await) })
.collect::<FuturesUnordered<_>>();
while let Some((buffer_id, resolve_result)) = resolves_in_progress.next().await {
let Some((resolved_id, resolved)) = resolve_result else {
continue;
};
editor
.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let Some(mut actions) = editor
.code_lens
.as_ref()
.and_then(|state| state.actions.get(&buffer_id))
.cloned()
else {
continue;
return;
};
for resolved in newly_resolved {
if let Some(unresolved) = actions.iter_mut().find(|action| {
action.server_id == resolved.server_id
&& action.range == resolved.range
}) {
*unresolved = resolved;
}
if let Some(slot) = actions.get_mut(&resolved_id) {
*slot = resolved;
}
editor.apply_lens_actions_for_buffer(buffer_id, actions, &snapshot, cx);
}
})
.ok();
})
.ok();
}
});
}
@ -551,12 +557,17 @@ fn group_lenses_by_row(
fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity<Editor>) -> RenderBlock {
Arc::new(move |cx| {
let mut children = Vec::with_capacity((2 * line.items.len()).saturating_sub(1));
let resolved_items = line
.items
.iter()
.filter_map(|item| item.title.as_ref().map(|title| (title, &item.action)))
.collect::<Vec<_>>();
let mut children = Vec::with_capacity((2 * resolved_items.len()).saturating_sub(1));
let text_style = &cx.editor_style.text;
let font = text_style.font();
let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9;
for (i, item) in line.items.iter().enumerate() {
for (i, (title, action)) in resolved_items.iter().enumerate() {
if i > 0 {
children.push(
div()
@ -568,8 +579,8 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity<Editor>) -> R
);
}
let title = item.title.clone();
let action = item.action.clone();
let title = (*title).clone();
let action = (*action).clone();
let position = line.position;
let editor_handle = editor.clone();
@ -928,6 +939,322 @@ mod tests {
}
}
#[gpui::test]
async fn test_code_lens_placeholder_block_before_resolve(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_editor_settings(cx, &|settings| {
settings.code_lens = Some(CodeLens::On);
});
let mut cx = EditorLspTestContext::new_typescript(
lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: Some(true),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let mut code_lens_request =
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
let mut lenses = Vec::new();
lenses.push(lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
command: None,
data: Some(serde_json::json!({"id": "lens_1"})),
});
Ok(Some(lenses))
});
let (resolve_tx, resolve_rx) = futures::channel::oneshot::channel::<()>();
let resolve_rx = std::sync::Mutex::new(Some(resolve_rx));
cx.lsp
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(move |lens, _| {
let rx = resolve_rx.lock().unwrap().take();
async move {
if let Some(rx) = rx {
rx.await.ok();
}
Ok(lsp::CodeLens {
command: Some(lsp::Command {
title: "1 reference".to_owned(),
command: "resolved_cmd".to_owned(),
arguments: None,
}),
..lens
})
}
});
cx.set_state("ˇfunction hello() {}");
assert!(
code_lens_request.next().await.is_some(),
"should have received the initial code lens request"
);
cx.run_until_parked();
cx.editor.read_with(&cx.cx.cx, |editor, _| {
let total_blocks: usize = editor
.code_lens
.as_ref()
.map(|s| s.blocks.values().map(|v| v.len()).sum())
.unwrap_or(0);
assert_eq!(
total_blocks, 1,
"a placeholder block should be reserved before the resolve completes"
);
});
resolve_tx.send(()).ok();
cx.run_until_parked();
cx.editor.read_with(&cx.cx.cx, |editor, _| {
let total_blocks: usize = editor
.code_lens
.as_ref()
.map(|s| s.blocks.values().map(|v| v.len()).sum())
.unwrap_or(0);
assert_eq!(
total_blocks, 1,
"the placeholder block should still be present after resolution"
);
});
}
#[gpui::test]
async fn test_code_lens_block_removed_when_resolve_yields_empty_title(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_editor_settings(cx, &|settings| {
settings.code_lens = Some(CodeLens::On);
});
let mut cx = EditorLspTestContext::new_typescript(
lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: Some(true),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let mut code_lens_request =
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
let mut lenses = Vec::new();
lenses.push(lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
command: None,
data: Some(serde_json::json!({"id": "lens_1"})),
});
Ok(Some(lenses))
});
cx.lsp
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
Ok(lsp::CodeLens {
command: Some(lsp::Command {
title: String::new(),
command: "noop".to_owned(),
arguments: None,
}),
..lens
})
});
cx.set_state("ˇfunction hello() {}");
assert!(
code_lens_request.next().await.is_some(),
"should have received the initial code lens request"
);
cx.run_until_parked();
cx.editor.read_with(&cx.cx.cx, |editor, _| {
let total_blocks: usize = editor
.code_lens
.as_ref()
.map(|s| s.blocks.values().map(|v| v.len()).sum())
.unwrap_or(0);
assert_eq!(
total_blocks, 0,
"placeholder block should be cleaned up when its lens resolves to a blank title"
);
});
}
#[gpui::test]
async fn test_code_lens_same_range_lenses_resolve_independently(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_editor_settings(cx, &|settings| {
settings.code_lens = Some(CodeLens::On);
});
let mut cx = EditorLspTestContext::new_typescript(
lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: Some(true),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
// Two shallow lenses on the same range, distinguished only by `data`
// — exactly the shape vtsls/TypeScript-LS uses for the
// "references" + "implementations" pair on the same line.
let mut code_lens_request =
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
Ok(Some(vec![
lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
command: None,
data: Some(serde_json::json!({"kind": "references"})),
},
lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
command: None,
data: Some(serde_json::json!({"kind": "implementations"})),
},
]))
});
let resolve_calls = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
cx.lsp
.set_request_handler::<lsp::request::CodeLensResolve, _, _>({
let resolve_calls = resolve_calls.clone();
move |lens, _| {
let resolve_calls = resolve_calls.clone();
async move {
let kind = lens
.data
.as_ref()
.and_then(|d| d.get("kind"))
.cloned()
.unwrap_or(serde_json::Value::Null);
resolve_calls.lock().unwrap().push(kind.clone());
let title = match kind.as_str() {
Some("references") => "2 references",
Some("implementations") => "1 implementation",
_ => "",
};
Ok(lsp::CodeLens {
command: Some(lsp::Command {
title: title.to_owned(),
command: "noop".to_owned(),
arguments: None,
}),
..lens
})
}
}
});
cx.set_state("ˇfunction hello() {}");
assert!(
code_lens_request.next().await.is_some(),
"should have received the initial code lens request"
);
cx.run_until_parked();
let calls = resolve_calls.lock().unwrap().clone();
assert_eq!(
calls.len(),
2,
"both same-range lenses should be resolved independently, got {calls:?}"
);
let kinds: Vec<&str> = calls.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(kinds.contains(&"references"), true);
assert_eq!(kinds.contains(&"implementations"), true);
cx.editor.read_with(&cx.cx.cx, |editor, _| {
let blocks = editor
.code_lens
.as_ref()
.map(|s| s.blocks.values().flatten().collect::<Vec<_>>())
.unwrap_or_default();
assert_eq!(
blocks.len(),
1,
"a single block should host both lens items"
);
let titles: Vec<String> = blocks[0]
.line
.items
.iter()
.filter_map(|item| item.title.as_ref().map(|t| t.to_string()))
.collect();
assert_eq!(titles.len(), 2, "both lens titles should be resolved");
assert_eq!(titles.contains(&"2 references".to_string()), true);
assert_eq!(titles.contains(&"1 implementation".to_string()), true);
});
}
#[gpui::test]
async fn test_code_lens_block_removed_when_resolve_yields_no_command(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_editor_settings(cx, &|settings| {
settings.code_lens = Some(CodeLens::On);
});
let mut cx = EditorLspTestContext::new_typescript(
lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: Some(true),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let mut code_lens_request =
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
Ok(Some(vec![lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
command: None,
data: Some(serde_json::json!({"id": "lens_1"})),
}]))
});
// Server acknowledges the resolve but still returns no `command` —
// a real-world scenario for buggy/incomplete servers. Without
// cleanup the placeholder line would be reserved forever because
// `resolve_visible_code_lenses` skips actions with `resolved=true`.
cx.lsp
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
Ok(lsp::CodeLens {
command: None,
..lens
})
});
cx.set_state("ˇfunction hello() {}");
assert!(
code_lens_request.next().await.is_some(),
"should have received the initial code lens request"
);
cx.run_until_parked();
cx.editor.read_with(&cx.cx.cx, |editor, _| {
let total_blocks: usize = editor
.code_lens
.as_ref()
.map(|s| s.blocks.values().map(|v| v.len()).sum())
.unwrap_or(0);
assert_eq!(
total_blocks, 0,
"placeholder block should be cleaned up when resolve yields no command"
);
});
}
#[gpui::test]
async fn test_code_lens_disabled_by_default(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@ -1254,9 +1581,14 @@ mod tests {
.unwrap()
.drain(..)
.collect::<HashSet<_>>();
// Once the lenses are first applied we insert a placeholder block per
// lens row so the line is reserved while the resolve is in flight.
// Those placeholder blocks add display height, so after scrolling to
// the end the visible buffer-row range is slightly smaller than it
// would be without them, and lens row 60 is just outside it.
assert_eq!(
after_scroll_resolved,
HashSet::from_iter([60, 70, 80, 90]),
HashSet::from_iter([70, 80, 90]),
"Only newly visible lenses at the bottom should be resolved, not middle ones"
);
}

File diff suppressed because it is too large Load diff

1095
crates/editor/src/fold.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@ impl Editor {
if !self.lsp_data_enabled() || !self.use_document_folding_ranges {
return;
}
let Some(project) = self.project.clone() else {
let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else {
return;
};
@ -43,7 +43,8 @@ impl Editor {
let Some(tasks) = editor
.update(cx, |_, cx| {
project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
let project = project.upgrade()?;
Some(project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
buffers_to_query
.into_iter()
.map(|buffer| {
@ -52,9 +53,10 @@ impl Editor {
async move { (buffer_id, task.await) }
})
.collect::<Vec<_>>()
})
}))
})
.ok()
.flatten()
else {
return;
};

File diff suppressed because it is too large Load diff

2221
crates/editor/src/input.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,899 @@
use super::*;
impl Editor {
pub fn sync_selections(
&mut self,
other: Entity<Editor>,
cx: &mut Context<Self>,
) -> gpui::Subscription {
let other_selections = other.read(cx).selections.disjoint_anchors().to_vec();
if !other_selections.is_empty() {
self.selections
.change_with(&self.display_snapshot(cx), |selections| {
selections.select_anchors(other_selections);
});
}
let other_subscription = cx.subscribe(&other, |this, other, other_evt, cx| {
if let EditorEvent::SelectionsChanged { local: true } = other_evt {
let other_selections = other.read(cx).selections.disjoint_anchors().to_vec();
if other_selections.is_empty() {
return;
}
let snapshot = this.display_snapshot(cx);
this.selections.change_with(&snapshot, |selections| {
selections.select_anchors(other_selections);
});
}
});
let this_subscription = cx.subscribe_self::<EditorEvent>(move |this, this_evt, cx| {
if let EditorEvent::SelectionsChanged { local: true } = this_evt {
let these_selections = this.selections.disjoint_anchors().to_vec();
if these_selections.is_empty() {
return;
}
other.update(cx, |other_editor, cx| {
let snapshot = other_editor.display_snapshot(cx);
other_editor
.selections
.change_with(&snapshot, |selections| {
selections.select_anchors(these_selections);
})
});
}
});
Subscription::join(other_subscription, this_subscription)
}
/// Changes selections using the provided mutation function. Changes to `self.selections` occur
/// immediately, but when run within `transact` or `with_selection_effects_deferred` other
/// effects of selection change occur at the end of the transaction.
pub fn change_selections<R>(
&mut self,
effects: SelectionEffects,
window: &mut Window,
cx: &mut Context<Self>,
change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R,
) -> R {
let snapshot = self.display_snapshot(cx);
if let Some(state) = &mut self.deferred_selection_effects_state {
state.effects.scroll = effects.scroll.or(state.effects.scroll);
state.effects.completions = effects.completions;
state.effects.nav_history = effects.nav_history.or(state.effects.nav_history);
let (changed, result) = self.selections.change_with(&snapshot, change);
state.changed |= changed;
return result;
}
let mut state = DeferredSelectionEffectsState {
changed: false,
effects,
old_cursor_position: self.selections.newest_anchor().head(),
history_entry: SelectionHistoryEntry {
selections: self.selections.disjoint_anchors_arc(),
select_next_state: self.select_next_state.clone(),
select_prev_state: self.select_prev_state.clone(),
add_selections_state: self.add_selections_state.clone(),
},
};
let (changed, result) = self.selections.change_with(&snapshot, change);
state.changed = state.changed || changed;
if self.defer_selection_effects {
self.deferred_selection_effects_state = Some(state);
} else {
self.apply_selection_effects(state, window, cx);
}
result
}
/// Defers the effects of selection change, so that the effects of multiple calls to
/// `change_selections` are applied at the end. This way these intermediate states aren't added
/// to selection history and the state of popovers based on selection position aren't
/// erroneously updated.
pub fn with_selection_effects_deferred<R>(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
update: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) -> R,
) -> R {
let already_deferred = self.defer_selection_effects;
self.defer_selection_effects = true;
let result = update(self, window, cx);
if !already_deferred {
self.defer_selection_effects = false;
if let Some(state) = self.deferred_selection_effects_state.take() {
self.apply_selection_effects(state, window, cx);
}
}
result
}
pub fn has_non_empty_selection(&self, snapshot: &DisplaySnapshot) -> bool {
self.selections
.all_adjusted(snapshot)
.iter()
.any(|selection| !selection.is_empty())
}
pub fn is_range_selected(&mut self, range: &Range<Anchor>, cx: &mut Context<Self>) -> bool {
if self
.selections
.pending_anchor()
.is_some_and(|pending_selection| {
let snapshot = self.buffer().read(cx).snapshot(cx);
pending_selection.range().includes(range, &snapshot)
})
{
return true;
}
self.selections
.disjoint_in_range::<MultiBufferOffset>(range.clone(), &self.display_snapshot(cx))
.into_iter()
.any(|selection| {
// This is needed to cover a corner case, if we just check for an existing
// selection in the fold range, having a cursor at the start of the fold
// marks it as selected. Non-empty selections don't cause this.
let length = selection.end - selection.start;
length > 0
})
}
pub fn has_pending_nonempty_selection(&self) -> bool {
let pending_nonempty_selection = match self.selections.pending_anchor() {
Some(Selection { start, end, .. }) => start != end,
None => false,
};
pending_nonempty_selection
|| (self.columnar_selection_state.is_some()
&& self.selections.disjoint_anchors().len() > 1)
}
pub fn has_pending_selection(&self) -> bool {
self.selections.pending_anchor().is_some() || self.columnar_selection_state.is_some()
}
pub fn set_selections_from_remote(
&mut self,
selections: Vec<Selection<Anchor>>,
pending_selection: Option<Selection<Anchor>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let old_cursor_position = self.selections.newest_anchor().head();
self.selections
.change_with(&self.display_snapshot(cx), |s| {
s.select_anchors(selections);
if let Some(pending_selection) = pending_selection {
s.set_pending(pending_selection, SelectMode::Character);
} else {
s.clear_pending();
}
});
self.selections_did_change(
false,
&old_cursor_position,
SelectionEffects::default(),
window,
cx,
);
}
pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context<Self>) {
if self.selection_mark_mode {
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |_, sel| {
sel.collapse_to(sel.head(), SelectionGoal::None);
});
})
}
self.selection_mark_mode = true;
cx.notify();
}
pub fn swap_selection_ends(
&mut self,
_: &actions::SwapSelectionEnds,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(&mut |_, sel| {
if sel.start != sel.end {
sel.reversed = !sel.reversed
}
});
});
self.request_autoscroll(Autoscroll::newest(), cx);
cx.notify();
}
pub(super) fn select(
&mut self,
phase: SelectPhase,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.hide_context_menu(window, cx);
match phase {
SelectPhase::Begin {
position,
add,
click_count,
} => self.begin_selection(position, add, click_count, window, cx),
SelectPhase::BeginColumnar {
position,
goal_column,
reset,
mode,
} => self.begin_columnar_selection(position, goal_column, reset, mode, window, cx),
SelectPhase::Extend {
position,
click_count,
} => self.extend_selection(position, click_count, window, cx),
SelectPhase::Update {
position,
goal_column,
scroll_delta,
} => self.update_selection(position, goal_column, scroll_delta, window, cx),
SelectPhase::End => self.end_selection(window, cx),
}
}
pub(super) fn extend_selection(
&mut self,
position: DisplayPoint,
click_count: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let tail = self
.selections
.newest::<MultiBufferOffset>(&display_map)
.tail();
let click_count = click_count.max(match self.selections.select_mode() {
SelectMode::Character => 1,
SelectMode::Word(_) => 2,
SelectMode::Line(_) => 3,
SelectMode::All => 4,
});
self.begin_selection(position, false, click_count, window, cx);
let tail_anchor = display_map.buffer_snapshot().anchor_before(tail);
let current_selection = match self.selections.select_mode() {
SelectMode::Character | SelectMode::All => tail_anchor..tail_anchor,
SelectMode::Word(range) | SelectMode::Line(range) => range.clone(),
};
let Some((mut pending_selection, mut pending_mode)) = self.pending_selection_and_mode()
else {
log::error!("extend_selection dispatched with no pending selection");
return;
};
if pending_selection
.start
.cmp(&current_selection.start, display_map.buffer_snapshot())
== Ordering::Greater
{
pending_selection.start = current_selection.start;
}
if pending_selection
.end
.cmp(&current_selection.end, display_map.buffer_snapshot())
== Ordering::Less
{
pending_selection.end = current_selection.end;
pending_selection.reversed = true;
}
match &mut pending_mode {
SelectMode::Word(range) | SelectMode::Line(range) => *range = current_selection,
_ => {}
}
let effects = if EditorSettings::get_global(cx).autoscroll_on_clicks {
SelectionEffects::scroll(Autoscroll::fit())
} else {
SelectionEffects::no_scroll()
};
self.change_selections(effects, window, cx, |s| {
s.set_pending(pending_selection.clone(), pending_mode);
s.set_is_extending(true);
});
}
pub(super) fn begin_selection(
&mut self,
position: DisplayPoint,
add: bool,
click_count: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.focus_handle.is_focused(window) {
self.last_focused_descendant = None;
window.focus(&self.focus_handle, cx);
}
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let buffer = display_map.buffer_snapshot();
let position = display_map.clip_point(position, Bias::Left);
let start;
let end;
let mode;
let mut auto_scroll;
match click_count {
1 => {
start = buffer.anchor_before(position.to_point(&display_map));
end = start;
mode = SelectMode::Character;
auto_scroll = true;
}
2 => {
let position = display_map
.clip_point(position, Bias::Left)
.to_offset(&display_map, Bias::Left);
let (range, _) = buffer.surrounding_word(position, None);
start = buffer.anchor_before(range.start);
end = buffer.anchor_before(range.end);
mode = SelectMode::Word(start..end);
auto_scroll = true;
}
3 => {
let position = display_map
.clip_point(position, Bias::Left)
.to_point(&display_map);
let line_start = display_map.prev_line_boundary(position).0;
let next_line_start = buffer.clip_point(
display_map.next_line_boundary(position).0 + Point::new(1, 0),
Bias::Left,
);
start = buffer.anchor_before(line_start);
end = buffer.anchor_before(next_line_start);
mode = SelectMode::Line(start..end);
auto_scroll = true;
}
_ => {
start = buffer.anchor_before(MultiBufferOffset(0));
end = buffer.anchor_before(buffer.len());
mode = SelectMode::All;
auto_scroll = false;
}
}
auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks;
let point_to_delete: Option<usize> = {
let selected_points: Vec<Selection<Point>> =
self.selections.disjoint_in_range(start..end, &display_map);
if !add || click_count > 1 {
None
} else if !selected_points.is_empty() {
Some(selected_points[0].id)
} else {
let clicked_point_already_selected =
self.selections.disjoint_anchors().iter().find(|selection| {
selection.start.to_point(buffer) == start.to_point(buffer)
|| selection.end.to_point(buffer) == end.to_point(buffer)
});
clicked_point_already_selected.map(|selection| selection.id)
}
};
let selections_count = self.selections.count();
let effects = if auto_scroll {
SelectionEffects::default()
} else {
SelectionEffects::no_scroll()
};
self.change_selections(effects, window, cx, |s| {
if let Some(point_to_delete) = point_to_delete {
s.delete(point_to_delete);
if selections_count == 1 {
s.set_pending_anchor_range(start..end, mode);
}
} else {
if !add {
s.clear_disjoint();
}
s.set_pending_anchor_range(start..end, mode);
}
});
}
pub(super) fn begin_columnar_selection(
&mut self,
position: DisplayPoint,
goal_column: u32,
reset: bool,
mode: ColumnarMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.focus_handle.is_focused(window) {
self.last_focused_descendant = None;
window.focus(&self.focus_handle, cx);
}
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
if reset {
let pointer_position = display_map
.buffer_snapshot()
.anchor_before(position.to_point(&display_map));
self.change_selections(
SelectionEffects::scroll(Autoscroll::newest()),
window,
cx,
|s| {
s.clear_disjoint();
s.set_pending_anchor_range(
pointer_position..pointer_position,
SelectMode::Character,
);
},
);
};
let tail = self.selections.newest::<Point>(&display_map).tail();
let selection_anchor = display_map.buffer_snapshot().anchor_before(tail);
self.columnar_selection_state = match mode {
ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse {
selection_tail: selection_anchor,
display_point: if reset {
if position.column() != goal_column {
Some(DisplayPoint::new(position.row(), goal_column))
} else {
None
}
} else {
None
},
}),
ColumnarMode::FromSelection => Some(ColumnarSelectionState::FromSelection {
selection_tail: selection_anchor,
}),
};
if !reset {
self.select_columns(position, goal_column, &display_map, window, cx);
}
}
pub(super) fn update_selection(
&mut self,
position: DisplayPoint,
goal_column: u32,
scroll_delta: gpui::Point<f32>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
if self.columnar_selection_state.is_some() {
self.select_columns(position, goal_column, &display_map, window, cx);
} else if let Some((mut pending, mode)) = self.pending_selection_and_mode() {
let buffer = display_map.buffer_snapshot();
let head;
let tail;
match &mode {
SelectMode::Character => {
head = position.to_point(&display_map);
tail = pending.tail().to_point(buffer);
}
SelectMode::Word(original_range) => {
let offset = display_map
.clip_point(position, Bias::Left)
.to_offset(&display_map, Bias::Left);
let original_range = original_range.to_offset(buffer);
let head_offset = if buffer.is_inside_word(offset, None)
|| original_range.contains(&offset)
{
let (word_range, _) = buffer.surrounding_word(offset, None);
if word_range.start < original_range.start {
word_range.start
} else {
word_range.end
}
} else {
offset
};
head = head_offset.to_point(buffer);
if head_offset <= original_range.start {
tail = original_range.end.to_point(buffer);
} else {
tail = original_range.start.to_point(buffer);
}
}
SelectMode::Line(original_range) => {
let original_range = original_range.to_point(display_map.buffer_snapshot());
let position = display_map
.clip_point(position, Bias::Left)
.to_point(&display_map);
let line_start = display_map.prev_line_boundary(position).0;
let next_line_start = buffer.clip_point(
display_map.next_line_boundary(position).0 + Point::new(1, 0),
Bias::Left,
);
if line_start < original_range.start {
head = line_start
} else {
head = next_line_start
}
if head <= original_range.start {
tail = original_range.end;
} else {
tail = original_range.start;
}
}
SelectMode::All => {
return;
}
};
if head < tail {
pending.start = buffer.anchor_before(head);
pending.end = buffer.anchor_before(tail);
pending.reversed = true;
} else {
pending.start = buffer.anchor_before(tail);
pending.end = buffer.anchor_before(head);
pending.reversed = false;
}
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.set_pending(pending.clone(), mode);
});
} else {
log::error!("update_selection dispatched with no pending selection");
return;
}
self.apply_scroll_delta(scroll_delta, window, cx);
cx.notify();
}
pub(super) fn end_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.columnar_selection_state.take();
if let Some(pending_mode) = self.selections.pending_mode() {
let selections = self
.selections
.all::<MultiBufferOffset>(&self.display_snapshot(cx));
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select(selections);
s.clear_pending();
if s.is_extending() {
s.set_is_extending(false);
} else {
s.set_select_mode(pending_mode);
}
});
}
}
fn selections_did_change(
&mut self,
local: bool,
old_cursor_position: &Anchor,
effects: SelectionEffects,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.last_selection_from_search = effects.from_search;
window.invalidate_character_coordinates();
// Copy selections to primary selection buffer
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if local {
let selections = self
.selections
.all::<MultiBufferOffset>(&self.display_snapshot(cx));
let buffer_handle = self.buffer.read(cx).read(cx);
let mut text = String::new();
for (index, selection) in selections.iter().enumerate() {
let text_for_selection = buffer_handle
.text_for_range(selection.start..selection.end)
.collect::<String>();
text.push_str(&text_for_selection);
if index != selections.len() - 1 {
text.push('\n');
}
}
if !text.is_empty() {
cx.write_to_primary(ClipboardItem::new_string(text));
}
}
let selection_anchors = self.selections.disjoint_anchors_arc();
if self.focus_handle.is_focused(window) && self.leader_id.is_none() {
self.buffer.update(cx, |buffer, cx| {
buffer.set_active_selections(
&selection_anchors,
self.selections.line_mode(),
self.cursor_shape,
cx,
)
});
}
let display_map = self
.display_map
.update(cx, |display_map, cx| display_map.snapshot(cx));
let buffer = display_map.buffer_snapshot();
if self.selections.count() == 1 {
self.add_selections_state = None;
}
self.select_next_state = None;
self.select_prev_state = None;
self.select_syntax_node_history.try_clear();
self.invalidate_autoclose_regions(&selection_anchors, buffer);
self.snippet_stack.invalidate(&selection_anchors, buffer);
self.take_rename(false, window, cx);
let newest_selection = self.selections.newest_anchor();
let new_cursor_position = newest_selection.head();
let selection_start = newest_selection.start;
if effects.nav_history.is_none() || effects.nav_history == Some(true) {
self.push_to_nav_history(
*old_cursor_position,
Some(new_cursor_position.to_point(buffer)),
false,
effects.nav_history == Some(true),
cx,
);
}
if local {
if let Some((anchor, _)) = buffer.anchor_to_buffer_anchor(new_cursor_position) {
self.register_buffer(anchor.buffer_id, cx);
}
let mut context_menu = self.context_menu.borrow_mut();
let completion_menu = match context_menu.as_ref() {
Some(CodeContextMenu::Completions(menu)) => Some(menu),
Some(CodeContextMenu::CodeActions(_)) => {
*context_menu = None;
None
}
None => None,
};
let completion_position = completion_menu.map(|menu| menu.initial_position);
drop(context_menu);
if effects.completions
&& let Some(completion_position) = completion_position
{
let start_offset = selection_start.to_offset(buffer);
let position_matches = start_offset == completion_position.to_offset(buffer);
let continue_showing = if let Some((snap, ..)) =
buffer.point_to_buffer_offset(completion_position)
&& !snap.capability.editable()
{
false
} else if position_matches {
if self.snippet_stack.is_empty() {
buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion))
== Some(CharKind::Word)
} else {
// Snippet choices can be shown even when the cursor is in whitespace.
// Dismissing the menu with actions like backspace is handled by
// invalidation regions.
true
}
} else {
false
};
if continue_showing {
self.open_or_update_completions_menu(None, None, false, window, cx);
} else {
self.hide_context_menu(window, cx);
}
}
hide_hover(self, cx);
self.refresh_code_actions_for_selection(window, cx);
self.refresh_document_highlights(cx);
refresh_linked_ranges(self, window, cx);
self.refresh_selected_text_highlights(&display_map, false, window, cx);
self.refresh_matching_bracket_highlights(&display_map, cx);
self.refresh_outline_symbols_at_cursor(cx);
self.update_visible_edit_prediction(window, cx);
self.hide_blame_popover(true, cx);
if self.git_blame_inline_enabled {
self.start_inline_blame_timer(window, cx);
}
}
self.blink_manager.update(cx, BlinkManager::pause_blinking);
if local && !self.suppress_selection_callback {
if let Some(callback) = self.on_local_selections_changed.as_ref() {
let cursor_position = self.selections.newest::<Point>(&display_map).head();
callback(cursor_position, window, cx);
}
}
cx.emit(EditorEvent::SelectionsChanged { local });
let selections = &self.selections.disjoint_anchors_arc();
if local && let Some(buffer_snapshot) = buffer.as_singleton() {
let inmemory_selections = selections
.iter()
.map(|s| {
let start = s.range().start.text_anchor_in(buffer_snapshot);
let end = s.range().end.text_anchor_in(buffer_snapshot);
(start..end).to_point(buffer_snapshot)
})
.collect();
self.update_restoration_data(cx, |data| {
data.selections = inmemory_selections;
});
if WorkspaceSettings::get(None, cx).restore_on_startup
!= RestoreOnStartupBehavior::EmptyTab
&& let Some(workspace_id) = self.workspace_serialization_id(cx)
{
let snapshot = self.buffer().read(cx).snapshot(cx);
let selections = selections.clone();
let background_executor = cx.background_executor().clone();
let editor_id = cx.entity().entity_id().as_u64() as ItemId;
let db = EditorDb::global(cx);
self.serialize_selections = cx.background_spawn(async move {
background_executor.timer(SERIALIZATION_THROTTLE_TIME).await;
let db_selections = selections
.iter()
.map(|selection| {
(
selection.start.to_offset(&snapshot).0,
selection.end.to_offset(&snapshot).0,
)
})
.collect();
db.save_editor_selections(editor_id, workspace_id, db_selections)
.await
.with_context(|| {
format!(
"persisting editor selections for editor {editor_id}, \
workspace {workspace_id:?}"
)
})
.log_err();
});
}
}
cx.notify();
}
fn apply_selection_effects(
&mut self,
state: DeferredSelectionEffectsState,
window: &mut Window,
cx: &mut Context<Self>,
) {
if state.changed {
self.selection_history.push(state.history_entry);
if let Some(autoscroll) = state.effects.scroll {
self.request_autoscroll(autoscroll, cx);
}
let old_cursor_position = &state.old_cursor_position;
self.selections_did_change(true, old_cursor_position, state.effects, window, cx);
if self.should_open_signature_help_automatically(old_cursor_position, cx) {
self.show_signature_help_auto(window, cx);
}
}
}
fn select_columns(
&mut self,
head: DisplayPoint,
goal_column: u32,
display_map: &DisplaySnapshot,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(columnar_state) = self.columnar_selection_state.as_ref() else {
return;
};
let tail = match columnar_state {
ColumnarSelectionState::FromMouse {
selection_tail,
display_point,
} => display_point.unwrap_or_else(|| selection_tail.to_display_point(display_map)),
ColumnarSelectionState::FromSelection { selection_tail } => {
selection_tail.to_display_point(display_map)
}
};
let start_row = cmp::min(tail.row(), head.row());
let end_row = cmp::max(tail.row(), head.row());
let start_column = cmp::min(tail.column(), goal_column);
let end_column = cmp::max(tail.column(), goal_column);
let reversed = start_column < tail.column();
let selection_ranges = (start_row.0..=end_row.0)
.map(DisplayRow)
.filter_map(|row| {
if (matches!(columnar_state, ColumnarSelectionState::FromMouse { .. })
|| start_column <= display_map.line_len(row))
&& !display_map.is_block_line(row)
{
let start = display_map
.clip_point(DisplayPoint::new(row, start_column), Bias::Left)
.to_point(display_map);
let end = display_map
.clip_point(DisplayPoint::new(row, end_column), Bias::Right)
.to_point(display_map);
if reversed {
Some(end..start)
} else {
Some(start..end)
}
} else {
None
}
})
.collect::<Vec<_>>();
if selection_ranges.is_empty() {
return;
}
let ranges = match columnar_state {
ColumnarSelectionState::FromMouse { .. } => {
let mut non_empty_ranges = selection_ranges
.iter()
.filter(|selection_range| selection_range.start != selection_range.end)
.peekable();
if non_empty_ranges.peek().is_some() {
non_empty_ranges.cloned().collect()
} else {
selection_ranges
}
}
_ => selection_ranges,
};
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges(ranges);
});
cx.notify();
}
fn pending_selection_and_mode(&self) -> Option<(Selection<Anchor>, SelectMode)> {
Some((
self.selections.pending_anchor()?.clone(),
self.selections.pending_mode()?,
))
}
}

View file

@ -142,7 +142,10 @@ impl Editor {
);
}
let Some((sema, project)) = self.semantics_provider.clone().zip(self.project.clone())
let Some((sema, project)) = self
.semantics_provider
.clone()
.zip(self.project.as_ref().map(|p| p.downgrade()))
else {
return;
};
@ -283,6 +286,9 @@ impl Editor {
.buffer(buffer_id)
.and_then(|buf| buf.read(cx).language().map(|l| l.name()));
let Some(project) = project.upgrade() else {
return;
};
editor.display_map.update(cx, |display_map, cx| {
project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
let mut token_highlights = Vec::new();

View file

@ -3,13 +3,13 @@ use crate::{EncodingSelector, Toggle};
use editor::Editor;
use encoding_rs::{Encoding, UTF_8};
use gpui::{
Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window,
div,
App, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity,
Window, div,
};
use project::Project;
use ui::{Button, ButtonCommon, Clickable, LabelSize, Tooltip};
use workspace::{
StatusBarSettings, StatusItemView, Workspace,
EncodingDisplayOptions, HideStatusItem, StatusBarSettings, StatusItemView, Workspace,
item::{ItemHandle, Settings},
};
@ -131,4 +131,13 @@ impl StatusItemView for ActiveBufferEncoding {
cx.notify();
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
Some(HideStatusItem::new(|settings| {
settings
.status_bar
.get_or_insert_default()
.active_encoding_button = Some(EncodingDisplayOptions::Disabled);
}))
}
}

View file

@ -35,6 +35,14 @@ impl FeatureFlag for AgentSharingFeatureFlag {
}
register_feature_flag!(AgentSharingFeatureFlag);
pub struct ExperimentalSystemPromptFeatureFlag;
impl FeatureFlag for ExperimentalSystemPromptFeatureFlag {
const NAME: &'static str = "experimental-system-prompt";
type Value = PresenceFlag;
}
register_feature_flag!(ExperimentalSystemPromptFeatureFlag);
pub struct AgentPanelTerminalFeatureFlag;
impl FeatureFlag for AgentPanelTerminalFeatureFlag {
@ -83,6 +91,18 @@ impl FeatureFlag for LspToolFeatureFlag {
}
register_feature_flag!(LspToolFeatureFlag);
pub struct RenameToolFeatureFlag;
impl FeatureFlag for RenameToolFeatureFlag {
const NAME: &'static str = "rename-tool";
type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
true
}
}
register_feature_flag!(RenameToolFeatureFlag);
pub struct ProjectPanelUndoRedoFeatureFlag;
impl FeatureFlag for ProjectPanelUndoRedoFeatureFlag {

View file

@ -87,19 +87,11 @@ impl Watcher for FsWatcher {
let path: Arc<std::path::Path> = path.into();
let registration_path = path.clone();
let registration_id = global_watcher().add(
path.clone(),
self.mode,
move |result: Result<&notify::Event, &notify::Error>| match result {
Ok(event) => {
log::trace!("watcher received event: {event:?}");
push_notify_event(&tx, &pending_path_events, &root_path, path.as_ref(), event);
}
Err(error) => {
push_notify_error(&tx, &pending_path_events, path.as_ref(), error);
}
},
)?;
let registration_id =
global_watcher().add(path.clone(), self.mode, move |event: &notify::Event| {
log::trace!("watcher received event: {event:?}");
push_notify_event(&tx, &pending_path_events, &root_path, path.as_ref(), event);
})?;
self.registrations
.lock()
@ -176,23 +168,6 @@ fn push_notify_event(
enqueue_path_events(tx, pending_path_events, path_events);
}
fn push_notify_error(
tx: &smol::channel::Sender<()>,
pending_path_events: &Arc<Mutex<Vec<PathEvent>>>,
watched_root: &Path,
error: &notify::Error,
) {
log::warn!("watcher error for {watched_root:?}: {error}");
enqueue_path_events(
tx,
pending_path_events,
vec![PathEvent {
path: watched_root.to_path_buf(),
kind: Some(PathEventKind::Rescan),
}],
);
}
fn coalesce_pending_rescans(pending_paths: &mut Vec<PathEvent>, path_events: &mut Vec<PathEvent>) {
if !path_events
.iter()
@ -247,7 +222,7 @@ fn is_covered_rescan(kind: Option<PathEventKind>, path: &Path, ancestor: &Path)
pub struct WatcherRegistrationId(u32);
struct WatcherRegistrationState {
callback: Arc<dyn for<'a> Fn(Result<&'a notify::Event, &'a notify::Error>) + Send + Sync>,
callback: Arc<dyn Fn(&notify::Event) + Send + Sync>,
path: Arc<std::path::Path>,
mode: WatcherMode,
}
@ -283,7 +258,7 @@ impl GlobalWatcher {
&self,
path: Arc<std::path::Path>,
mode: WatcherMode,
cb: impl for<'a> Fn(Result<&'a notify::Event, &'a notify::Error>) + Send + Sync + 'static,
cb: impl Fn(&notify::Event) + Send + Sync + 'static,
) -> anyhow::Result<WatcherRegistrationId> {
let mut state = self.state.lock();
let registrations_for_mode = state.path_registrations(mode);
@ -483,13 +458,11 @@ fn handle_event(mode: WatcherMode, event: Result<notify::Event, notify::Error>)
match event {
Ok(event) => {
for callback in callbacks {
callback(Ok(&event));
callback(&event);
}
}
Err(error) => {
for callback in callbacks {
callback(Err(&error));
}
log::warn!("watcher error for {mode:?}: {error}");
}
}
}

View file

@ -96,6 +96,23 @@ pub struct InitialGraphCommitData {
pub ref_names: Vec<SharedString>,
}
impl InitialGraphCommitData {
pub fn tag_names(&self) -> Vec<&str> {
self.ref_names
.iter()
.filter_map(|ref_name| {
let tag_name = ref_name.strip_prefix("tag: ")?;
if tag_name.is_empty() {
return None;
}
Some(tag_name)
})
.collect()
}
}
struct CommitDataRequest {
sha: Oid,
response_tx: oneshot::Sender<Result<CommitData>>,
@ -3678,6 +3695,24 @@ mod tests {
}
}
#[test]
fn test_initial_graph_commit_data_tag_names() {
let commit = InitialGraphCommitData {
sha: Oid::from_bytes(&[0; 20]).unwrap(),
parents: SmallVec::new(),
ref_names: vec![
SharedString::from("HEAD -> main"),
SharedString::from("origin/main"),
SharedString::from("tag: v1.0.0"),
SharedString::from("tag: v1.1.0"),
SharedString::from("tag: "),
SharedString::from("refs/heads/feature"),
],
};
assert_eq!(commit.tag_names(), ["v1.0.0", "v1.1.0"]);
}
#[gpui::test]
async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
cx.executor().allow_parking();

View file

@ -30,6 +30,7 @@ git_ui.workspace = true
gpui.workspace = true
language.workspace = true
menu.workspace = true
picker.workspace = true
project.workspace = true
project_panel.workspace = true
search.workspace = true

View file

@ -20,6 +20,7 @@ use gpui::{
};
use language::line_diff;
use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use picker::{Picker, PickerDelegate};
use project::{
ProjectPath,
git_store::{
@ -43,14 +44,15 @@ use std::{
use theme::AccentColors;
use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
use ui::{
ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider,
HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table,
TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar,
bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles,
render_table_header, table_row::TableRow,
ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry,
DiffStat, Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing,
RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns,
prelude::*, render_redistributable_columns_resize_handles, render_table_header,
table_row::TableRow,
};
use workspace::{
Workspace,
ModalView, Workspace,
item::{Item, ItemEvent, TabTooltipContent},
};
@ -87,6 +89,106 @@ impl CopiedState {
struct DraggedSplitHandle;
struct CommitTagPicker {
picker: Entity<Picker<CommitTagPickerDelegate>>,
}
impl CommitTagPicker {
fn new(tag_names: Vec<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let delegate = CommitTagPickerDelegate {
picker: cx.entity().downgrade(),
tag_names,
selected_index: 0,
};
let picker = cx.new(|cx| Picker::nonsearchable_uniform_list(delegate, window, cx));
Self { picker }
}
}
impl EventEmitter<DismissEvent> for CommitTagPicker {}
impl ModalView for CommitTagPicker {}
impl Focusable for CommitTagPicker {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for CommitTagPicker {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
v_flex().w(rems(18.)).child(self.picker.clone())
}
}
struct CommitTagPickerDelegate {
picker: WeakEntity<CommitTagPicker>,
tag_names: Vec<SharedString>,
selected_index: usize,
}
impl PickerDelegate for CommitTagPickerDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Copy Tag".into()
}
fn match_count(&self) -> usize {
self.tag_names.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
_query: String,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Task<()> {
Task::ready(())
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(tag_name) = self.tag_names.get(self.selected_index) {
cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string()));
}
self.dismissed(window, cx);
}
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
self.picker
.update(cx, |_this, cx| cx.emit(DismissEvent))
.ok();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(self.tag_names.get(ix)?.clone())),
)
}
}
#[derive(Clone)]
struct ChangedFileEntry {
status: FileStatus,
@ -281,6 +383,8 @@ actions!(
[
/// Copies the SHA of the selected commit to the clipboard.
CopyCommitSha,
/// Copies a tag from the selected commit to the clipboard.
CopyCommitTag,
/// Opens the commit view for the selected commit.
OpenCommitView,
/// Focuses the search field.
@ -1888,6 +1992,45 @@ impl GitGraph {
self.copy_commit_sha(selected_entry_index, cx);
}
fn copy_commit_tag(&mut self, entry_index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(commit) = self.graph_data.commits.get(entry_index) else {
return;
};
let tag_names = commit
.data
.tag_names()
.into_iter()
.map(|tag_name| SharedString::from(tag_name.to_string()))
.collect::<Vec<_>>();
match tag_names.as_slice() {
[] => {}
[tag_name] => cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string())),
_ => {
self.workspace
.update(cx, |workspace, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
CommitTagPicker::new(tag_names, window, cx)
});
})
.ok();
}
}
}
fn copy_selected_commit_tag(
&mut self,
_: &CopyCommitTag,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(selected_entry_index) = self.selected_entry_idx else {
return;
};
self.copy_commit_tag(selected_entry_index, window, cx);
}
fn deploy_entry_context_menu(
&mut self,
position: Point<Pixels>,
@ -1899,6 +2042,14 @@ impl GitGraph {
return;
};
let short_sha = commit.data.sha.display_short();
let tag_names = commit.data.tag_names();
let copy_tag_label = "Copy Tag";
let copy_tag_label: SharedString = match tag_names.as_slice() {
[] => copy_tag_label.into(),
[tag_name] => format!("{copy_tag_label}: {tag_name}").into(),
_ => format!("{copy_tag_label}").into(),
};
let copy_tag_disabled = tag_names.is_empty();
let focus_handle = self.focus_handle.clone();
let git_graph = cx.entity();
@ -1920,6 +2071,14 @@ impl GitGraph {
this.copy_commit_sha(index, cx);
}),
)
.item(
ContextMenuEntry::new(copy_tag_label)
.action(CopyCommitTag.boxed_clone())
.disabled(copy_tag_disabled)
.handler(window.handler_for(&git_graph, move |this, window, cx| {
this.copy_commit_tag(index, window, cx);
})),
)
});
self.set_context_menu(context_menu, position, index, window, cx);
}
@ -3243,6 +3402,7 @@ impl Render for GitGraph {
this.open_selected_commit_view(window, cx);
}))
.on_action(cx.listener(Self::copy_selected_commit_sha))
.on_action(cx.listener(Self::copy_selected_commit_tag))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
this.search_state
@ -5445,6 +5605,164 @@ mod tests {
});
}
#[gpui::test]
async fn test_copy_selected_commit_tag_with_one_tag_copies_to_clipboard(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
Path::new("/project"),
serde_json::json!({
".git": {},
"file.txt": "content",
}),
)
.await;
let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
let commits = vec![Arc::new(InitialGraphCommitData {
sha: commit_sha,
parents: smallvec![],
ref_names: vec![
SharedString::from("HEAD -> main"),
SharedString::from("origin/main"),
SharedString::from("tag: v1.0.0"),
],
})];
fs.set_graph_commits(Path::new("/project/.git"), commits);
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
cx.run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project
.active_repository(cx)
.expect("should have a repository")
});
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
});
let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
let workspace_weak = workspace.downgrade();
let git_graph = cx.new_window_entity(|window, cx| {
GitGraph::new(
repository.read(cx).id,
project.read(cx).git_store().clone(),
workspace_weak,
None,
window,
cx,
)
});
cx.run_until_parked();
git_graph.update_in(cx, |graph, window, cx| {
assert_eq!(graph.graph_data.commits.len(), 1);
graph.selected_entry_idx = Some(0);
graph.copy_selected_commit_tag(&CopyCommitTag, window, cx);
});
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("v1.0.0".to_string())
);
}
#[gpui::test]
async fn test_copy_selected_commit_tag_with_multiple_tags_opens_picker_and_copies_selected_tag(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
Path::new("/project"),
serde_json::json!({
".git": {},
"file.txt": "content",
}),
)
.await;
let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
let commits = vec![Arc::new(InitialGraphCommitData {
sha: commit_sha,
parents: smallvec![],
ref_names: vec![
SharedString::from("HEAD -> main"),
SharedString::from("origin/main"),
SharedString::from("tag: v1.0.0"),
SharedString::from("tag: v1.1.0"),
],
})];
fs.set_graph_commits(Path::new("/project/.git"), commits);
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
cx.run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project
.active_repository(cx)
.expect("should have a repository")
});
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
});
let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
let workspace_weak = workspace.downgrade();
let git_graph = cx.new_window_entity(|window, cx| {
GitGraph::new(
repository.read(cx).id,
project.read(cx).git_store().clone(),
workspace_weak,
None,
window,
cx,
)
});
cx.run_until_parked();
git_graph.update_in(cx, |graph, window, cx| {
assert_eq!(graph.graph_data.commits.len(), 1);
graph.selected_entry_idx = Some(0);
graph.copy_selected_commit_tag(&CopyCommitTag, window, cx);
});
// Ensure that nothing has been copied at this point
assert_eq!(cx.read_from_clipboard().and_then(|item| item.text()), None);
let picker = workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<CommitTagPicker>(cx)
.expect("commit tag picker is not open")
.read(cx)
.picker
.clone()
});
picker.read_with(cx, |picker, _| {
assert_eq!(picker.delegate.selected_index, 0);
assert_eq!(
picker.delegate.tag_names,
[SharedString::from("v1.0.0"), SharedString::from("v1.1.0")]
);
});
cx.dispatch_action(menu::Confirm);
cx.run_until_parked();
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("v1.0.0".to_string())
);
}
#[gpui::test]
async fn test_git_graph_navigation(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -469,11 +469,7 @@ impl CommitModal {
if can_commit {
Tooltip::with_meta_in(
tooltip,
Some(if is_amend_pending {
&git::Amend
} else {
&git::Commit
}),
Some(&git::Commit),
format!(
"git commit{}{}",
if is_amend_pending { " --amend" } else { "" },
@ -506,10 +502,16 @@ impl CommitModal {
}
fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
if self.git_panel.update(cx, |git_panel, cx| {
let is_amend = self.git_panel.read(cx).amend_pending();
let did_execute = self.git_panel.update(cx, |git_panel, cx| {
git_panel.commit(&self.commit_editor.focus_handle(cx), window, cx)
}) {
telemetry::event!("Git Committed", source = "Git Modal");
});
if did_execute {
if is_amend {
telemetry::event!("Git Amended", source = "Git Modal");
} else {
telemetry::event!("Git Committed", source = "Git Modal");
}
cx.emit(DismissEvent);
}
}

View file

@ -18,7 +18,7 @@ use settings::Settings;
use std::{ops::Range, sync::Arc};
use ui::{ButtonLike, Divider, Tooltip, prelude::*};
use util::{ResultExt as _, debug_panic, maybe};
use workspace::{StatusItemView, Workspace, item::ItemHandle};
use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle};
use zed_actions::agent::{
ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
};
@ -678,4 +678,13 @@ impl StatusItemView for MergeConflictIndicator {
_: &mut Context<Self>,
) {
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
Some(HideStatusItem::new(|settings| {
settings
.agent
.get_or_insert_default()
.show_merge_conflict_indicator = Some(false);
}))
}
}

View file

@ -31,7 +31,7 @@ use git::repository::{
};
use git::stash::GitStash;
use git::status::{DiffStat, StageStatus};
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
use git::{Amend, Commit, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
use git::{
ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll,
@ -76,7 +76,7 @@ use ui::{
prelude::*,
};
use util::paths::PathStyle;
use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath};
use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
use workspace::SERIALIZATION_THROTTLE_TIME;
use workspace::{
Workspace,
@ -120,6 +120,14 @@ actions!(
]
);
actions!(
dev,
[
/// Shows the current git job queue debug state for the active repository.
ShowGitJobQueue,
]
);
actions!(
git_graph,
[
@ -259,6 +267,13 @@ pub fn register(workspace: &mut Workspace) {
panel.update(cx, |panel, cx| panel.git_init(window, cx));
}
});
workspace.register_action(|workspace, _: &ShowGitJobQueue, window, cx| {
if let Some(panel) = workspace.panel::<GitPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.show_git_job_queue(window, cx);
});
}
});
}
#[derive(Debug, Clone)]
@ -1406,10 +1421,12 @@ impl GitPanel {
PromptLevel::Warning,
&format!(
"Are you sure you want to discard changes to {}?",
entry
.repo_path
.file_name()
.unwrap_or(entry.repo_path.display(path_style).as_ref()),
MarkdownInlineCode(
entry
.repo_path
.file_name()
.unwrap_or(entry.repo_path.display(path_style).as_ref())
),
),
None,
&["Discard Changes", "Cancel"],
@ -2110,13 +2127,19 @@ impl GitPanel {
}
}
fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
fn on_commit(&mut self, _: &Commit, window: &mut Window, cx: &mut Context<Self>) {
let is_amend = self.amend_pending;
if self.commit(&self.commit_editor.focus_handle(cx), window, cx) {
telemetry::event!("Git Committed", source = "Git Panel");
if is_amend {
telemetry::event!("Git Amended", source = "Git Panel");
} else {
telemetry::event!("Git Committed", source = "Git Panel");
}
}
}
/// Commits staged changes with the current commit message.
/// When `amend_pending` is true, performs an amend commit instead.
///
/// Returns `true` if the commit was executed, `false` otherwise.
pub(crate) fn commit(
@ -2125,14 +2148,10 @@ impl GitPanel {
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if self.amend_pending {
return false;
}
if commit_editor_focus_handle.contains_focused(window, cx) {
self.commit_changes(
CommitOptions {
amend: false,
amend: self.amend_pending,
signoff: self.signoff_enabled,
allow_empty: false,
},
@ -2146,17 +2165,16 @@ impl GitPanel {
}
}
fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
fn on_amend(&mut self, _: &Amend, window: &mut Window, cx: &mut Context<Self>) {
if self.amend(&self.commit_editor.focus_handle(cx), window, cx) {
telemetry::event!("Git Amended", source = "Git Panel");
}
}
/// Amends the most recent commit with staged changes and/or an updated commit message.
///
/// Uses a two-stage workflow where the first invocation loads the commit
/// message for editing, second invocation performs the amend. Returns
/// `true` if the amend was executed, `false` otherwise.
/// Enters the amend state on first invocation, loading the last commit
/// message for editing. On second invocation, performs the amend commit
/// by delegating to [`Self::commit`]. Returns `true` if a commit was
/// executed.
pub(crate) fn amend(
&mut self,
commit_editor_focus_handle: &FocusHandle,
@ -2166,28 +2184,15 @@ impl GitPanel {
if commit_editor_focus_handle.contains_focused(window, cx) {
if self.head_commit(cx).is_some() {
if !self.amend_pending {
self.set_amend_pending(true, cx);
self.load_last_commit_message(cx);
return false;
self.toggle_amend_pending(cx);
} else {
self.commit_changes(
CommitOptions {
amend: true,
signoff: self.signoff_enabled,
allow_empty: false,
},
window,
cx,
);
return true;
return self.commit(commit_editor_focus_handle, window, cx);
}
}
return false;
false
} else {
cx.propagate();
return false;
false
}
}
pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
@ -3880,6 +3885,74 @@ impl GitPanel {
show_error_toast(workspace, action, e, cx)
}
fn show_git_job_queue(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo) = self.active_repository.as_ref() else {
let workspace = self.workspace.clone();
cx.defer(move |cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
struct GitJobQueueToast;
workspace.show_toast(
workspace::Toast::new(
NotificationId::unique::<GitJobQueueToast>(),
"No active repository",
)
.autohide(),
cx,
);
});
}
});
return;
};
let repo_path = repo.read(cx).work_directory_abs_path.display().to_string();
let text = repo.read(cx).job_debug_queue().to_debug_string();
let title = format!("Git Job Queue: {repo_path}");
let json_language = self.project.read(cx).languages().language_for_name("JSON");
let project = self.project.clone();
let workspace = self.workspace.clone();
window
.spawn(cx, async move |cx| {
let json_language = json_language.await.ok();
let buffer = project
.update(cx, |project, cx| {
project.create_buffer(json_language, false, cx)
})
.await?;
buffer.update(cx, |buffer, cx| {
buffer.set_text(text, cx);
buffer.set_capability(language::Capability::ReadWrite, cx);
});
workspace.update_in(cx, |workspace, window, cx| {
let buffer =
cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone()));
workspace.add_item_to_active_pane(
Box::new(cx.new(|cx| {
let mut editor =
Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
editor.set_breadcrumb_header(title);
editor.disable_mouse_wheel_zoom();
editor
})),
None,
true,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
where
E: std::fmt::Debug + std::fmt::Display,
@ -4657,7 +4730,7 @@ impl GitPanel {
if can_commit {
Tooltip::with_meta_in(
tooltip,
Some(if amend { &git::Amend } else { &git::Commit }),
Some(&git::Commit),
format!(
"git commit{}{}",
if amend { " --amend" } else { "" },
@ -6143,6 +6216,12 @@ impl Panel for GitPanel {
fn activation_priority(&self) -> u32 {
3
}
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
Some(workspace::HideStatusItem::new(|settings| {
settings.git_panel.get_or_insert_default().button = Some(false);
}))
}
}
impl PanelHeader for GitPanel {}
@ -7020,6 +7099,72 @@ mod tests {
);
}
#[gpui::test]
async fn test_discard_prompt_escapes_markdown_in_file_name(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/root",
json!({
"project": {
".git": {},
"__somefile__": "modified\n",
},
}),
)
.await;
fs.set_status_for_repo(
Path::new(path!("/root/project/.git")),
&[("__somefile__", StatusCode::Modified.worktree())],
);
let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
let window_handle =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window_handle
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
cx.read(|cx| {
project
.read(cx)
.worktrees(cx)
.next()
.unwrap()
.read(cx)
.as_local()
.unwrap()
.scan_complete()
})
.await;
cx.executor().run_until_parked();
let panel = workspace.update_in(cx, GitPanel::new);
let handle = cx.update_window_entity(&panel, |panel, _, _| {
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
});
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
handle.await;
panel.update_in(cx, |panel, window, cx| {
panel.selected_entry = Some(1);
panel.revert_selected(&git::RestoreFile::default(), window, cx);
});
let (message, _detail) = cx
.pending_prompt()
.expect("discard should show a confirmation prompt");
assert_eq!(
message,
"Are you sure you want to discard changes to `__somefile__`?"
);
}
#[gpui::test]
async fn test_bulk_staging(cx: &mut TestAppContext) {
use GitListEntry::*;

View file

@ -8,7 +8,7 @@ use ui::{
Render, Tooltip, Window, div,
};
use util::paths::FILE_ROW_COLUMN_DELIMITER;
use workspace::{StatusBarSettings, StatusItemView, Workspace, item::ItemHandle};
use workspace::{HideStatusItem, StatusBarSettings, StatusItemView, Workspace, item::ItemHandle};
#[derive(Copy, Clone, Debug, Default, PartialOrd, PartialEq)]
pub(crate) struct SelectionStats {
@ -290,6 +290,15 @@ impl StatusItemView for CursorPosition {
cx.notify();
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
Some(HideStatusItem::new(|settings| {
settings
.status_bar
.get_or_insert_default()
.cursor_position_button = Some(false);
}))
}
}
#[derive(Clone, Copy, PartialEq, Eq, RegisterSetting)]

View file

@ -115,60 +115,6 @@ impl BackgroundExecutor {
}
}
/// Enqueues the given future to be run to completion on a background thread and blocking the current task on it.
///
/// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to
/// completion before the current task is resumed, even if the current task is slated for cancellation.
pub async fn await_on_background<R>(&self, future: impl Future<Output = R> + Send) -> R
where
R: Send,
{
use crate::RunnableMeta;
use parking_lot::{Condvar, Mutex};
struct NotifyOnDrop<'a>(&'a (Condvar, Mutex<bool>));
impl Drop for NotifyOnDrop<'_> {
fn drop(&mut self) {
*self.0.1.lock() = true;
self.0.0.notify_all();
}
}
struct WaitOnDrop<'a>(&'a (Condvar, Mutex<bool>));
impl Drop for WaitOnDrop<'_> {
fn drop(&mut self) {
let mut done = self.0.1.lock();
if !*done {
self.0.0.wait(&mut done);
}
}
}
let dispatcher = self.dispatcher.clone();
let location = core::panic::Location::caller();
let pair = &(Condvar::new(), Mutex::new(false));
let _wait_guard = WaitOnDrop(pair);
let (runnable, task) = unsafe {
async_task::Builder::new()
.metadata(RunnableMeta { location })
.spawn_unchecked(
move |_| async {
let _notify_guard = NotifyOnDrop(pair);
future.await
},
move |runnable| {
dispatcher.dispatch(runnable, Priority::default());
},
)
};
runnable.schedule();
task.await
}
/// Scoped lets you start a number of tasks and waits
/// for all of them to complete before returning.
pub async fn scoped<'scope, F>(&self, scheduler: F)

View file

@ -5764,7 +5764,7 @@ impl From<Arc<std::path::Path>> for ElementId {
impl From<&'static str> for ElementId {
fn from(name: &'static str) -> Self {
ElementId::Name(name.into())
ElementId::Name(SharedString::new_static(name))
}
}
@ -5776,13 +5776,13 @@ impl<'a> From<&'a FocusHandle> for ElementId {
impl From<(&'static str, EntityId)> for ElementId {
fn from((name, id): (&'static str, EntityId)) -> Self {
ElementId::NamedInteger(name.into(), id.as_u64())
ElementId::NamedInteger(SharedString::new_static(name), id.as_u64())
}
}
impl From<(&'static str, usize)> for ElementId {
fn from((name, id): (&'static str, usize)) -> Self {
ElementId::NamedInteger(name.into(), id as u64)
ElementId::NamedInteger(SharedString::new_static(name), id as u64)
}
}
@ -5794,7 +5794,7 @@ impl From<(SharedString, usize)> for ElementId {
impl From<(&'static str, u64)> for ElementId {
fn from((name, id): (&'static str, u64)) -> Self {
ElementId::NamedInteger(name.into(), id)
ElementId::NamedInteger(SharedString::new_static(name), id)
}
}
@ -5806,7 +5806,7 @@ impl From<Uuid> for ElementId {
impl From<(&'static str, u32)> for ElementId {
fn from((name, id): (&'static str, u32)) -> Self {
ElementId::NamedInteger(name.into(), id.into())
ElementId::NamedInteger(SharedString::new_static(name), u64::from(id))
}
}

View file

@ -1,9 +1,9 @@
use gpui::{Context, Entity, IntoElement, ParentElement, Render, Subscription, div};
use gpui::{App, Context, Entity, IntoElement, ParentElement, Render, Subscription, div};
use project::image_store::{ImageFormat, ImageMetadata};
use settings::Settings;
use ui::prelude::*;
use util::size::format_file_size;
use workspace::{ItemHandle, StatusItemView, Workspace};
use workspace::{HideStatusItem, ItemHandle, StatusItemView, Workspace};
use crate::{ImageFileSizeUnit, ImageView, ImageViewerSettings};
@ -102,4 +102,9 @@ impl StatusItemView for ImageInfo {
}
cx.notify();
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
// The image info is only visible when an image viewer item is active.
None
}
}

View file

@ -25,7 +25,10 @@ mod toolchain;
#[cfg(test)]
pub mod buffer_tests;
pub use crate::language_settings::{AutoIndentMode, EditPredictionsMode, IndentGuideSettings};
pub use crate::language_settings::{
AutoIndentMode, EditPredictionPromptFormat, EditPredictionsMode, IndentGuideSettings,
ZetaVersion,
};
use anyhow::{Context as _, Result};
use async_trait::async_trait;
use collections::{HashMap, HashSet};
@ -623,8 +626,8 @@ pub trait LspInstaller {
&self,
_version: &Self::BinaryVersion,
_container_dir: &PathBuf,
_delegate: &dyn LspAdapterDelegate,
) -> impl Send + Future<Output = Option<LanguageServerBinary>> {
_delegate: &Arc<dyn LspAdapterDelegate>,
) -> impl Send + Future<Output = Option<LanguageServerBinary>> + use<Self> {
async { None }
}
@ -632,8 +635,8 @@ pub trait LspInstaller {
&self,
latest_version: Self::BinaryVersion,
container_dir: PathBuf,
delegate: &dyn LspAdapterDelegate,
) -> impl Send + Future<Output = Result<LanguageServerBinary>>;
_delegate: &Arc<dyn LspAdapterDelegate>,
) -> impl Send + Future<Output = Result<LanguageServerBinary>> + use<Self>;
fn cached_server_binary(
&self,
@ -686,11 +689,7 @@ where
if let Some(binary) = cx
.background_executor()
.await_on_background(self.check_if_version_installed(
&latest_version,
&container_dir,
delegate.as_ref(),
))
.spawn(self.check_if_version_installed(&latest_version, &container_dir, &delegate))
.await
{
log::debug!("language server {:?} is already installed", name.0);
@ -701,11 +700,7 @@ where
delegate.update_status(name.clone(), BinaryStatus::Downloading);
let binary = cx
.background_executor()
.await_on_background(self.fetch_server_binary(
latest_version,
container_dir,
delegate.as_ref(),
))
.spawn(self.fetch_server_binary(latest_version, container_dir, delegate))
.await;
delegate.update_status(name.clone(), BinaryStatus::None);
@ -1421,13 +1416,15 @@ impl LspInstaller for FakeLspAdapter {
Some(self.language_server_binary.clone())
}
async fn fetch_server_binary(
fn fetch_server_binary(
&self,
_: (),
_: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
unreachable!();
_: &Arc<dyn LspAdapterDelegate>,
) -> impl Send + Future<Output = Result<LanguageServerBinary>> + use<> {
async {
unreachable!();
}
}
async fn cached_server_binary(

View file

@ -17,7 +17,7 @@ use settings::{DocumentFoldingRanges, DocumentSymbols, IntoGpui, SemanticTokens}
pub use settings::{
AutoIndentMode, CompletionSettingsContent, EditPredictionDataCollectionChoice,
EditPredictionPromptFormat, EditPredictionProvider, EditPredictionsMode, FormatOnSave,
EditPredictionPromptFormatContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave,
Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LineEndingSetting,
LspInsertMode, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode,
};
@ -540,6 +540,46 @@ pub struct OpenAiCompatibleEditPredictionSettings {
pub prompt_format: EditPredictionPromptFormat,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub enum EditPredictionPromptFormat {
#[default]
Infer,
Zeta(ZetaVersion),
CodeLlama,
StarCoder,
DeepseekCoder,
Qwen,
CodeGemma,
Codestral,
Glm,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub enum ZetaVersion {
Zeta1,
Zeta2,
#[default] // NOTE: make latest version default when adding
Zeta2_1,
}
impl From<EditPredictionPromptFormatContent> for EditPredictionPromptFormat {
fn from(value: EditPredictionPromptFormatContent) -> Self {
match value {
EditPredictionPromptFormatContent::Infer => Self::Infer,
EditPredictionPromptFormatContent::Zeta => Self::Zeta(ZetaVersion::Zeta1),
EditPredictionPromptFormatContent::Zeta2 => Self::Zeta(ZetaVersion::Zeta2),
EditPredictionPromptFormatContent::Zeta2_1 => Self::Zeta(ZetaVersion::Zeta2_1),
EditPredictionPromptFormatContent::CodeLlama => Self::CodeLlama,
EditPredictionPromptFormatContent::StarCoder => Self::StarCoder,
EditPredictionPromptFormatContent::DeepseekCoder => Self::DeepseekCoder,
EditPredictionPromptFormatContent::Qwen => Self::Qwen,
EditPredictionPromptFormatContent::CodeGemma => Self::CodeGemma,
EditPredictionPromptFormatContent::Codestral => Self::Codestral,
EditPredictionPromptFormatContent::Glm => Self::Glm,
}
}
}
impl AllLanguageSettings {
/// Returns the [`LanguageSettings`] for the language with the specified name.
pub fn language<'a>(
@ -816,7 +856,7 @@ impl settings::Settings for AllLanguageSettings {
model: model.0,
max_output_tokens: ollama.max_output_tokens.unwrap(),
api_url: ollama.api_url.unwrap().into(),
prompt_format: ollama.prompt_format.unwrap(),
prompt_format: ollama.prompt_format.unwrap().into(),
});
let openai_compatible_settings = edit_predictions.open_ai_compatible_api.unwrap();
let openai_compatible_settings = openai_compatible_settings
@ -831,7 +871,7 @@ impl settings::Settings for AllLanguageSettings {
model,
max_output_tokens: openai_compatible_settings.max_output_tokens.unwrap(),
api_url: api_url.into(),
prompt_format: openai_compatible_settings.prompt_format.unwrap(),
prompt_format: openai_compatible_settings.prompt_format.unwrap().into(),
});
let mut file_types: FxHashMap<Arc<str>, (GlobSet, Vec<String>)> = FxHashMap::default();

Some files were not shown because too many files have changed in this diff Show more