Merge branch 'read-file-tool' into edit-file-tool

This commit is contained in:
Agus Zubiaga 2025-08-07 20:27:15 -03:00
commit a7bcc0f97a
55 changed files with 2142 additions and 426 deletions

View file

@ -5,6 +5,11 @@ self-hosted-runner:
# GitHub-hosted Runners
- github-8vcpu-ubuntu-2404
- github-16vcpu-ubuntu-2404
- github-32vcpu-ubuntu-2404
- github-8vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
- github-32vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204-arm
- windows-2025-16
- windows-2025-32
- windows-2025-64

View file

@ -13,7 +13,7 @@ runs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
shell: bash -euxo pipefail {0}

View file

@ -16,7 +16,7 @@ jobs:
bump_patch_version:
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-16vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

View file

@ -137,7 +137,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- buildjet-8vcpu-ubuntu-2204
- github-8vcpu-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@ -168,7 +168,7 @@ jobs:
needs: [job_spec]
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-8vcpu-ubuntu-2204
- github-8vcpu-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@ -221,7 +221,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
(needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true')
runs-on:
- buildjet-8vcpu-ubuntu-2204
- github-8vcpu-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@ -328,7 +328,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- buildjet-16vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@ -342,7 +342,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux
@ -380,7 +380,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- buildjet-8vcpu-ubuntu-2204
- github-8vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@ -394,7 +394,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Clang & Mold
run: ./script/remote-server && ./script/install-mold 2.34.0
@ -597,7 +597,8 @@ jobs:
timeout-minutes: 60
name: Linux x86_x64 release bundle
runs-on:
- buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc
- github-16vcpu-ubuntu-2204
# - buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc
if: |
startsWith(github.ref, 'refs/tags/v')
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')
@ -650,7 +651,7 @@ jobs:
timeout-minutes: 60
name: Linux arm64 release bundle
runs-on:
- buildjet-32vcpu-ubuntu-2204-arm
- github-16vcpu-ubuntu-2204-arm
if: |
startsWith(github.ref, 'refs/tags/v')
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')

View file

@ -9,7 +9,7 @@ jobs:
deploy-docs:
name: Deploy Docs
if: github.repository_owner == 'zed-industries'
runs-on: buildjet-16vcpu-ubuntu-2204
runs-on: github-16vcpu-ubuntu-2204
steps:
- name: Checkout repo

View file

@ -61,7 +61,7 @@ jobs:
- style
- tests
runs-on:
- buildjet-16vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
@ -94,7 +94,7 @@ jobs:
needs:
- publish
runs-on:
- buildjet-16vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
steps:
- name: Checkout repo
@ -137,12 +137,14 @@ jobs:
export ZED_SERVICE_NAME=collab
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT
export DATABASE_MAX_CONNECTIONS=850
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"
export ZED_SERVICE_NAME=api
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_API_LOAD_BALANCER_SIZE_UNIT
export DATABASE_MAX_CONNECTIONS=60
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status deployment/$ZED_SERVICE_NAME --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"

View file

@ -32,7 +32,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
(github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-eval'))
runs-on:
- buildjet-16vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@ -46,7 +46,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux

View file

@ -20,7 +20,7 @@ jobs:
matrix:
system:
- os: x86 Linux
runner: buildjet-16vcpu-ubuntu-2204
runner: github-16vcpu-ubuntu-2204
install_nix: true
- os: arm Mac
runner: [macOS, ARM64, test]

View file

@ -20,7 +20,7 @@ jobs:
name: Run randomized tests
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-16vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
steps:
- name: Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View file

@ -128,7 +128,8 @@ jobs:
name: Create a Linux *.tar.gz bundle for x86
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-16vcpu-ubuntu-2004
- github-16vcpu-ubuntu-2204
# - buildjet-16vcpu-ubuntu-2004
needs: tests
steps:
- name: Checkout repo
@ -168,7 +169,7 @@ jobs:
name: Create a Linux *.tar.gz bundle for ARM
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-32vcpu-ubuntu-2204-arm
- github-16vcpu-ubuntu-2204-arm
needs: tests
steps:
- name: Checkout repo

View file

@ -23,7 +23,7 @@ jobs:
timeout-minutes: 60
name: Run unit evals
runs-on:
- buildjet-16vcpu-ubuntu-2204
- github-16vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@ -37,7 +37,7 @@ jobs:
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
# cache-provider: "buildjet"
- name: Install Linux dependencies
run: ./script/linux

7
Cargo.lock generated
View file

@ -138,9 +138,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "0.0.22"
version = "0.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88ea41139e9680f53bbfd0d3a60d92f2832e00645f2ffb1365f76992ff2f6a79"
checksum = "3fad72b7b8ee4331b3a4c8d43c107e982a4725564b4ee658ae5c4e79d2b486e8"
dependencies = [
"anyhow",
"futures 0.3.31",
@ -174,11 +174,13 @@ dependencies = [
"gpui_tokio",
"handlebars 4.5.0",
"indoc",
"itertools 0.14.0",
"language",
"language_model",
"language_models",
"log",
"paths",
"pretty_assertions",
"project",
"prompt_store",
"reqwest_client",
@ -12639,6 +12641,7 @@ dependencies = [
"editor",
"file_icons",
"git",
"git_ui",
"gpui",
"indexmap",
"language",

View file

@ -425,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" }
#
agentic-coding-protocol = "0.0.10"
agent-client-protocol = "0.0.22"
agent-client-protocol = "0.0.23"
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -848,6 +848,7 @@
"ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-ctrl-r": "project_panel::RevealInFileManager",
"ctrl-shift-enter": "project_panel::OpenWithSystem",
"alt-d": "project_panel::CompareMarkedFiles",
"shift-find": "project_panel::NewSearchInDirectory",
"ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",
@ -1102,6 +1103,13 @@
"ctrl-enter": "menu::Confirm"
}
},
{
"context": "OnboardingAiConfigurationModal",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel"
}
},
{
"context": "Diagnostics",
"use_key_equivalents": true,
@ -1178,7 +1186,8 @@
"ctrl-1": "onboarding::ActivateBasicsPage",
"ctrl-2": "onboarding::ActivateEditingPage",
"ctrl-3": "onboarding::ActivateAISetupPage",
"ctrl-escape": "onboarding::Finish"
"ctrl-escape": "onboarding::Finish",
"alt-tab": "onboarding::SignIn"
}
}
]

View file

@ -907,6 +907,7 @@
"cmd-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-cmd-r": "project_panel::RevealInFileManager",
"ctrl-shift-enter": "project_panel::OpenWithSystem",
"alt-d": "project_panel::CompareMarkedFiles",
"cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }],
"cmd-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",
@ -1204,6 +1205,13 @@
"cmd-enter": "menu::Confirm"
}
},
{
"context": "OnboardingAiConfigurationModal",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel"
}
},
{
"context": "Diagnostics",
"use_key_equivalents": true,
@ -1280,7 +1288,8 @@
"cmd-1": "onboarding::ActivateBasicsPage",
"cmd-2": "onboarding::ActivateEditingPage",
"cmd-3": "onboarding::ActivateAISetupPage",
"cmd-escape": "onboarding::Finish"
"cmd-escape": "onboarding::Finish",
"alt-tab": "onboarding::SignIn"
}
}
]

View file

@ -813,6 +813,7 @@
"p": "project_panel::Open",
"x": "project_panel::RevealInFileManager",
"s": "project_panel::OpenWithSystem",
"z d": "project_panel::CompareMarkedFiles",
"] c": "project_panel::SelectNextGitEntry",
"[ c": "project_panel::SelectPrevGitEntry",
"] d": "project_panel::SelectNextDiagnostic",

View file

@ -14,8 +14,8 @@ workspace = true
[dependencies]
acp_thread.workspace = true
agent-client-protocol.workspace = true
agent_settings.workspace = true
agent_servers.workspace = true
agent_settings.workspace = true
anyhow.workspace = true
assistant_tool.workspace = true
assistant_tools.workspace = true
@ -26,6 +26,7 @@ futures.workspace = true
gpui.workspace = true
handlebars = { workspace = true, features = ["rust-embed"] }
indoc.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
language_models.workspace = true
@ -59,3 +60,4 @@ project = { workspace = true, "features" = ["test-support"] }
reqwest_client.workspace = true
settings = { workspace = true, "features" = ["test-support"] }
worktree = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true

View file

@ -1,5 +1,5 @@
use crate::{templates::Templates, AgentResponseEvent, Thread};
use crate::{EditFileTool, FindPathTool, ToolCallAuthorization};
use crate::{EditFileTool, FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization};
use acp_thread::ModelSelector;
use agent_client_protocol as acp;
use anyhow::{anyhow, Context as _, Result};
@ -414,7 +414,9 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
let thread = cx.new(|cx| {
let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model);
thread.add_tool(ThinkingTool);
thread.add_tool(FindPathTool::new(project.clone()));
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
thread.add_tool(EditFileTool::new(project.clone(), cx.entity()));
thread
});

View file

@ -270,14 +270,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
vec![
MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(),
tool_name: tool_call_auth_1.tool_call.title.into(),
tool_name: ToolRequiringPermission.name().into(),
is_error: false,
content: "Allowed".into(),
output: None
}),
MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(),
tool_name: tool_call_auth_2.tool_call.title.into(),
tool_name: ToolRequiringPermission.name().into(),
is_error: true,
content: "Permission to run tool denied by user".into(),
output: None
@ -639,6 +639,77 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool));
let fake_model = model.as_fake();
let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "Think", cx));
cx.run_until_parked();
let input = json!({ "content": "Thinking hard!" });
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "1".into(),
name: ThinkingTool.name().into(),
raw_input: input.to_string(),
input,
is_input_complete: true,
},
));
fake_model.end_last_completion_stream();
cx.run_until_parked();
let tool_call = expect_tool_call(&mut events).await;
assert_eq!(
tool_call,
acp::ToolCall {
id: acp::ToolCallId("1".into()),
title: "Thinking".into(),
kind: acp::ToolKind::Think,
status: acp::ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: Some(json!({ "content": "Thinking hard!" })),
raw_output: None,
}
);
let update = expect_tool_call_update(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate {
id: acp::ToolCallId("1".into()),
fields: acp::ToolCallUpdateFields {
status: Some(acp::ToolCallStatus::InProgress,),
..Default::default()
},
}
);
let update = expect_tool_call_update(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate {
id: acp::ToolCallId("1".into()),
fields: acp::ToolCallUpdateFields {
content: Some(vec!["Thinking hard!".into()]),
..Default::default()
},
}
);
let update = expect_tool_call_update(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate {
id: acp::ToolCallId("1".into()),
fields: acp::ToolCallUpdateFields {
status: Some(acp::ToolCallStatus::Completed),
..Default::default()
},
}
);
}
/// Filters out the stop events for asserting against in tests
fn stop_events(
result_events: Vec<Result<AgentResponseEvent, LanguageModelCompletionError>>,

View file

@ -23,8 +23,8 @@ impl AgentTool for EchoTool {
acp::ToolKind::Other
}
fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool {
false
fn initial_title(&self, _: Self::Input) -> SharedString {
"Echo".into()
}
fn run(
@ -53,12 +53,12 @@ impl AgentTool for DelayTool {
"delay".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Other
fn initial_title(&self, input: Self::Input) -> SharedString {
format!("Delay {}ms", input.ms).into()
}
fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool {
false
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Other
}
fn run(
@ -93,21 +93,21 @@ impl AgentTool for ToolRequiringPermission {
acp::ToolKind::Other
}
fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool {
true
fn initial_title(&self, _input: Self::Input) -> SharedString {
"This tool requires permission".into()
}
fn run(
self: Arc<Self>,
_input: Self::Input,
_event_stream: ToolCallEventStream,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>>
where
Self: Sized,
{
cx.foreground_executor()
.spawn(async move { Ok("Allowed".to_string()) })
) -> Task<Result<String>> {
let auth_check = self.authorize(input, event_stream);
cx.foreground_executor().spawn(async move {
auth_check.await?;
Ok("Allowed".to_string())
})
}
}
@ -127,8 +127,8 @@ impl AgentTool for InfiniteTool {
acp::ToolKind::Other
}
fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool {
false
fn initial_title(&self, _input: Self::Input) -> SharedString {
"This is the tool that never ends... it just goes on and on my friends!".into()
}
fn run(
@ -177,8 +177,8 @@ impl AgentTool for WordListTool {
acp::ToolKind::Other
}
fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool {
false
fn initial_title(&self, _input: Self::Input) -> SharedString {
"List of random words".into()
}
fn run(

View file

@ -2,7 +2,7 @@ use crate::templates::{SystemPromptTemplate, Template, Templates};
use acp_thread::Diff;
use agent_client_protocol as acp;
use anyhow::{anyhow, Context as _, Result};
use assistant_tool::ActionLog;
use assistant_tool::{adapt_schema_to_format, ActionLog};
use cloud_llm_client::{CompletionIntent, CompletionMode};
use collections::HashMap;
use futures::{
@ -20,7 +20,7 @@ use log;
use project::Project;
use prompt_store::ProjectContext;
use schemars::{JsonSchema, Schema};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use smol::stream::StreamExt;
use std::{cell::RefCell, collections::BTreeMap, fmt::Write, future::Future, rc::Rc, sync::Arc};
use util::{markdown::MarkdownCodeBlock, ResultExt};
@ -469,12 +469,7 @@ impl Thread {
});
if push_new_tool_use {
event_stream.send_tool_call(
&tool_use,
tool.as_ref()
.map(|t| t.kind())
.unwrap_or(acp::ToolKind::Other),
);
event_stream.send_tool_call(tool.as_ref(), &tool_use);
last_message
.content
.push(MessageContent::ToolUse(tool_use.clone()));
@ -531,23 +526,13 @@ impl Thread {
event_stream: AgentResponseEventStream,
cx: &mut Context<Self>,
) -> Task<Result<String>> {
// TODO: should we push this down into the tool itself?
let needs_authorization = tool.needs_authorization(tool_use.input.clone(), cx);
cx.spawn(async move |_this, cx| {
if needs_authorization? {
event_stream.authorize_tool_call(&tool_use).await?;
}
let tool_event_stream = ToolCallEventStream::new(tool_use.id, event_stream);
tool_event_stream.send_update(acp::ToolCallUpdateFields {
status: Some(acp::ToolCallStatus::InProgress),
..Default::default()
});
log::trace!(
"Running tool {:?} with input: {}",
tool_use.name,
serde_json::to_string_pretty(&tool_use.input).unwrap_or_default()
);
cx.update(|cx| tool.run(tool_use.input, tool_event_stream, cx))?
.await
})
@ -618,7 +603,7 @@ impl Thread {
name: tool_name,
description: tool.description(cx).to_string(),
input_schema: tool
.input_schema(LanguageModelToolSchemaFormat::JsonSchema)
.input_schema(self.selected_model.tool_input_format())
.log_err()?,
})
})
@ -685,7 +670,7 @@ pub trait AgentTool
where
Self: 'static + Sized,
{
type Input: for<'de> Deserialize<'de> + JsonSchema;
type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema;
fn name(&self) -> SharedString;
@ -701,14 +686,23 @@ where
fn kind(&self) -> acp::ToolKind;
/// The initial tool title to display. Can be updated during the tool run.
fn initial_title(&self, input: Self::Input) -> SharedString;
/// Returns the JSON schema that describes the tool's input.
fn input_schema(&self, _format: LanguageModelToolSchemaFormat) -> Schema {
fn input_schema(&self) -> Schema {
schemars::schema_for!(Self::Input)
}
/// Returns true if the tool needs the users's authorization
/// before running.
fn needs_authorization(&self, input: Self::Input, cx: &App) -> bool;
/// Allows the tool to authorize a given tool call with the user if necessary
fn authorize(
&self,
input: Self::Input,
event_stream: ToolCallEventStream,
) -> impl use<Self> + Future<Output = Result<()>> {
let json_input = serde_json::json!(&input);
event_stream.authorize(self.initial_title(input).into(), self.kind(), json_input)
}
/// Runs the tool with the provided input.
fn run(
@ -729,8 +723,8 @@ pub trait AnyAgentTool {
fn name(&self) -> SharedString;
fn description(&self, cx: &mut App) -> SharedString;
fn kind(&self) -> acp::ToolKind;
fn initial_title(&self, input: serde_json::Value) -> Result<SharedString>;
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value>;
fn needs_authorization(&self, input: serde_json::Value, cx: &mut App) -> Result<bool>;
fn run(
self: Arc<Self>,
input: serde_json::Value,
@ -755,16 +749,15 @@ where
self.0.kind()
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
Ok(serde_json::to_value(self.0.input_schema(format))?)
fn initial_title(&self, input: serde_json::Value) -> Result<SharedString> {
let parsed_input = serde_json::from_value(input)?;
Ok(self.0.initial_title(parsed_input))
}
fn needs_authorization(&self, input: serde_json::Value, cx: &mut App) -> Result<bool> {
let parsed_input: Result<T::Input> = serde_json::from_value(input).map_err(Into::into);
match parsed_input {
Ok(input) => Ok(self.0.needs_authorization(input, cx)),
Err(error) => Err(anyhow!(error)),
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
let mut json = serde_json::to_value(self.0.input_schema())?;
adapt_schema_to_format(&mut json, format)?;
Ok(json)
}
fn run(
@ -801,22 +794,16 @@ impl AgentResponseEventStream {
fn authorize_tool_call(
&self,
tool_use: &LanguageModelToolUse,
id: &LanguageModelToolUseId,
title: String,
kind: acp::ToolKind,
input: serde_json::Value,
) -> impl use<> + Future<Output = Result<()>> {
let (response_tx, response_rx) = oneshot::channel();
self.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
ToolCallAuthorization {
tool_call: acp::ToolCall {
id: acp::ToolCallId(tool_use.id.to_string().into()),
title: tool_use.name.to_string(),
kind: acp::ToolKind::Other,
status: acp::ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: Some(tool_use.input.clone()),
raw_output: None,
},
tool_call: Self::initial_tool_call(id, title, kind, input),
options: vec![
acp::PermissionOption {
id: acp::PermissionOptionId("always_allow".into()),
@ -846,21 +833,41 @@ impl AgentResponseEventStream {
}
}
fn send_tool_call(&self, tool_use: &LanguageModelToolUse, kind: acp::ToolKind) {
fn send_tool_call(
&self,
tool: Option<&Arc<dyn AnyAgentTool>>,
tool_use: &LanguageModelToolUse,
) {
self.0
.unbounded_send(Ok(AgentResponseEvent::ToolCall(acp::ToolCall {
id: acp::ToolCallId(tool_use.id.to_string().into()),
title: tool_use.name.to_string(),
kind,
status: acp::ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: Some(tool_use.input.clone()),
raw_output: None,
})))
.unbounded_send(Ok(AgentResponseEvent::ToolCall(Self::initial_tool_call(
&tool_use.id,
tool.and_then(|t| t.initial_title(tool_use.input.clone()).ok())
.map(|i| i.into())
.unwrap_or_else(|| tool_use.name.to_string()),
tool.map(|t| t.kind()).unwrap_or(acp::ToolKind::Other),
tool_use.input.clone(),
))))
.ok();
}
fn initial_tool_call(
id: &LanguageModelToolUseId,
title: String,
kind: acp::ToolKind,
input: serde_json::Value,
) -> acp::ToolCall {
acp::ToolCall {
id: acp::ToolCallId(id.to_string().into()),
title,
kind,
status: acp::ToolCallStatus::Pending,
content: vec![],
locations: vec![],
raw_input: Some(input),
raw_output: None,
}
}
fn send_tool_call_update(
&self,
tool_use_id: &LanguageModelToolUseId,
@ -932,4 +939,39 @@ impl ToolCallEventStream {
diff,
});
}
pub fn authorize(
&self,
title: String,
kind: acp::ToolKind,
input: serde_json::Value,
) -> impl use<> + Future<Output = Result<()>> {
self.stream
.authorize_tool_call(&self.tool_use_id, title, kind, input)
}
}
#[cfg(test)]
pub struct TestToolCallEventStream {
stream: ToolCallEventStream,
_events_rx: mpsc::UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
}
#[cfg(test)]
impl TestToolCallEventStream {
pub fn new() -> Self {
let (events_tx, events_rx) =
mpsc::unbounded::<Result<AgentResponseEvent, LanguageModelCompletionError>>();
let stream = ToolCallEventStream::new("test".into(), AgentResponseEventStream(events_tx));
Self {
stream,
_events_rx: events_rx,
}
}
pub fn stream(&self) -> ToolCallEventStream {
self.stream.clone()
}
}

View file

@ -1,5 +1,9 @@
mod edit_file_tool;
mod find_path_tool;
mod read_file_tool;
mod thinking_tool;
pub use edit_file_tool::*;
pub use find_path_tool::*;
pub use read_file_tool::*;
pub use thinking_tool::*;

View file

@ -12,7 +12,6 @@ use project::lsp_store::{FormatTrigger, LspFormatTarget};
use project::{Project, ProjectPath};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings as _;
use smol::stream::StreamExt as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@ -112,39 +111,60 @@ impl AgentTool for EditFileTool {
acp::ToolKind::Edit
}
fn needs_authorization(&self, input: Self::Input, cx: &App) -> bool {
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
return false;
}
// If any path component matches the local settings folder, then this could affect
// the editor in ways beyond the project source, so prompt.
let local_settings_folder = paths::local_settings_folder_relative_path();
fn initial_title(&self, input: Self::Input) -> SharedString {
let path = Path::new(&input.path);
let mut description = input.display_description.clone();
// Add context about why confirmation may be needed
let local_settings_folder = paths::local_settings_folder_relative_path();
if path
.components()
.any(|component| component.as_os_str() == local_settings_folder.as_os_str())
.any(|c| c.as_os_str() == local_settings_folder.as_os_str())
{
return true;
}
// It's also possible that the global config dir is configured to be inside the project,
// so check for that edge case too.
if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
description.push_str(" (local settings)");
} else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
if canonical_path.starts_with(paths::config_dir()) {
return true;
description.push_str(" (global settings)");
}
}
// Check if path is inside the global config directory
// First check if it's already inside project - if not, try to canonicalize
let project_path = self.project.read(cx).find_project_path(&input.path, cx);
// If the path is inside the project, and it's not one of the above edge cases,
// then no confirmation is necessary. Otherwise, confirmation is necessary.
project_path.is_none()
description.into()
}
// todo!
// fn needs_authorization(&self, input: Self::Input, cx: &App) -> bool {
// if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
// return false;
// }
// // If any path component matches the local settings folder, then this could affect
// // the editor in ways beyond the project source, so prompt.
// let local_settings_folder = paths::local_settings_folder_relative_path();
// let path = Path::new(&input.path);
// if path
// .components()
// .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
// {
// return true;
// }
// // It's also possible that the global config dir is configured to be inside the project,
// // so check for that edge case too.
// if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
// if canonical_path.starts_with(paths::config_dir()) {
// return true;
// }
// }
// // Check if path is inside the global config directory
// // First check if it's already inside project - if not, try to canonicalize
// let project_path = self.project.read(cx).find_project_path(&input.path, cx);
// // If the path is inside the project, and it's not one of the above edge cases,
// // then no confirmation is necessary. Otherwise, confirmation is necessary.
// project_path.is_none()
// }
fn run(
self: Arc<Self>,
input: Self::Input,
@ -182,6 +202,14 @@ impl AgentTool for EditFileTool {
.await?;
let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?;
event_stream.send_update(acp::ToolCallUpdateFields {
locations: Some(vec![acp::ToolCallLocation {
path: project_path.path.to_path_buf(),
// todo!
line: None
}]),
..Default::default()
});
event_stream.send_diff(diff.clone());
let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
@ -193,20 +221,20 @@ impl AgentTool for EditFileTool {
.await;
let mut events = if matches!(input.mode, EditFileMode::Edit) {
let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
edit_agent.edit(
buffer.clone(),
input.display_description.clone(),
&request,
cx,
).1
)
} else {
edit_agent.overwrite(
buffer.clone(),
input.display_description.clone(),
&request,
cx,
).1
)
};
let mut hallucinated_old_text = false;
@ -234,6 +262,8 @@ impl AgentTool for EditFileTool {
})
.unwrap_or(false);
let _ = output.await?;
if format_on_save_enabled {
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx);
@ -271,6 +301,8 @@ impl AgentTool for EditFileTool {
})
.await;
println!("\n\n{}\n\n", unified_diff);
diff.update(cx, |diff, cx| {
diff.finalize(cx);
}).ok();

View file

@ -66,8 +66,8 @@ impl AgentTool for FindPathTool {
acp::ToolKind::Search
}
fn needs_authorization(&self, _: Self::Input, _: &App) -> bool {
false
fn initial_title(&self, input: Self::Input) -> SharedString {
format!("Find paths matching “`{}`”", input.glob).into()
}
fn run(

View file

@ -0,0 +1,970 @@
use agent_client_protocol::{self as acp};
use anyhow::{anyhow, Result};
use assistant_tool::{outline, ActionLog};
use gpui::{Entity, Task};
use indoc::formatdoc;
use language::{Anchor, Point};
use project::{AgentLocation, Project, WorktreeSettings};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::sync::Arc;
use ui::{App, SharedString};
use crate::{AgentTool, ToolCallEventStream};
/// Reads the content of the given file in the project.
///
/// - Never attempt to read a path that hasn't been previously mentioned.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ReadFileToolInput {
/// The relative path of the file to read.
///
/// This path should never be absolute, and the first component
/// of the path should always be a root directory in a project.
///
/// <example>
/// If the project has the following root directories:
///
/// - /a/b/directory1
/// - /c/d/directory2
///
/// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
/// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
/// </example>
pub path: String,
/// Optional line number to start reading on (1-based index)
#[serde(default)]
pub start_line: Option<u32>,
/// Optional line number to end reading on (1-based index, inclusive)
#[serde(default)]
pub end_line: Option<u32>,
}
pub struct ReadFileTool {
project: Entity<Project>,
action_log: Entity<ActionLog>,
}
impl ReadFileTool {
pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
Self {
project,
action_log,
}
}
}
impl AgentTool for ReadFileTool {
type Input = ReadFileToolInput;
fn name(&self) -> SharedString {
"read_file".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Read
}
fn initial_title(&self, input: Self::Input) -> SharedString {
let path = &input.path;
match (input.start_line, input.end_line) {
(Some(start), Some(end)) => {
format!(
"[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))",
path, start, end, path, start, end
)
}
(Some(start), None) => {
format!(
"[Read file `{}` (from line {})](@selection:{}:({}-{}))",
path, start, path, start, start
)
}
_ => format!("[Read file `{}`](@file:{})", path, path),
}
.into()
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
};
// Error out if this path is either excluded or private in global settings
let global_settings = WorktreeSettings::get_global(cx);
if global_settings.is_path_excluded(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
&input.path
)));
}
if global_settings.is_path_private(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the global `private_files` setting: {}",
&input.path
)));
}
// Error out if this path is either excluded or private in worktree settings
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
if worktree_settings.is_path_excluded(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
&input.path
)));
}
if worktree_settings.is_path_private(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot read file because its path matches the worktree `private_files` setting: {}",
&input.path
)));
}
let file_path = input.path.clone();
event_stream.send_update(acp::ToolCallUpdateFields {
locations: Some(vec![acp::ToolCallLocation {
path: project_path.path.to_path_buf(),
line: input.start_line,
// TODO (tracked): use full range
}]),
..Default::default()
});
// TODO (tracked): images
// if image_store::is_image_file(&self.project, &project_path, cx) {
// let model = &self.thread.read(cx).selected_model;
// if !model.supports_images() {
// return Task::ready(Err(anyhow!(
// "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
// model.name().0
// )))
// .into();
// }
// return cx.spawn(async move |cx| -> Result<ToolResultOutput> {
// let image_entity: Entity<ImageItem> = cx
// .update(|cx| {
// self.project.update(cx, |project, cx| {
// project.open_image(project_path.clone(), cx)
// })
// })?
// .await?;
// let image =
// image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
// let language_model_image = cx
// .update(|cx| LanguageModelImage::from_image(image, cx))?
// .await
// .context("processing image")?;
// Ok(ToolResultOutput {
// content: ToolResultContent::Image(language_model_image),
// output: None,
// })
// });
// }
//
let project = self.project.clone();
let action_log = self.action_log.clone();
cx.spawn(async move |cx| {
let buffer = cx
.update(|cx| {
project.update(cx, |project, cx| project.open_buffer(project_path, cx))
})?
.await?;
if buffer.read_with(cx, |buffer, _| {
buffer
.file()
.as_ref()
.map_or(true, |file| !file.disk_state().exists())
})? {
anyhow::bail!("{file_path} not found");
}
project.update(cx, |project, cx| {
project.set_agent_location(
Some(AgentLocation {
buffer: buffer.downgrade(),
position: Anchor::MIN,
}),
cx,
);
})?;
// Check if specific line ranges are provided
if input.start_line.is_some() || input.end_line.is_some() {
let mut anchor = None;
let result = buffer.read_with(cx, |buffer, _cx| {
let text = buffer.text();
// .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
let start = input.start_line.unwrap_or(1).max(1);
let start_row = start - 1;
if start_row <= buffer.max_point().row {
let column = buffer.line_indent_for_row(start_row).raw_len();
anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
}
let lines = text.split('\n').skip(start_row as usize);
if let Some(end) = input.end_line {
let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
} else {
itertools::intersperse(lines, "\n").collect::<String>()
}
})?;
action_log.update(cx, |log, cx| {
log.buffer_read(buffer.clone(), cx);
})?;
if let Some(anchor) = anchor {
project.update(cx, |project, cx| {
project.set_agent_location(
Some(AgentLocation {
buffer: buffer.downgrade(),
position: anchor,
}),
cx,
);
})?;
}
Ok(result)
} else {
// No line ranges specified, so check file size to see if it's too big.
let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?;
if file_size <= outline::AUTO_OUTLINE_SIZE {
// File is small enough, so return its contents.
let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
action_log.update(cx, |log, cx| {
log.buffer_read(buffer, cx);
})?;
Ok(result)
} else {
// File is too big, so return the outline
// and a suggestion to read again with line numbers.
let outline =
outline::file_outline(project, file_path, action_log, None, cx).await?;
Ok(formatdoc! {"
This file was too big to read all at once.
Here is an outline of its symbols:
{outline}
Using the line numbers in this outline, you can call this tool again
while specifying the start_line and end_line fields to see the
implementations of symbols in the outline.
Alternatively, you can fall back to the `grep` tool (if available)
to search the file for specific content."
})
}
}
})
}
}
#[cfg(test)]
mod test {
use crate::TestToolCallEventStream;
use super::*;
use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher};
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use util::path;
#[gpui::test]
async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/nonexistent_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(
result.unwrap_err().to_string(),
"root/nonexistent_file.txt not found"
);
}
#[gpui::test]
async fn test_read_small_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"small_file.txt": "This is a small file content"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/small_file.txt".into(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "This is a small file content");
}
#[gpui::test]
async fn test_read_large_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(Arc::new(rust_lang()));
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let content = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/large_file.rs".into(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await
.unwrap();
assert_eq!(
content.lines().skip(4).take(6).collect::<Vec<_>>(),
vec![
"struct Test0 [L1-4]",
" a [L2]",
" b [L3]",
"struct Test1 [L5-8]",
" a [L6]",
" b [L7]",
]
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/large_file.rs".into(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
let content = result.unwrap();
let expected_content = (0..1000)
.flat_map(|i| {
vec![
format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
format!(" a [L{}]", i * 4 + 2),
format!(" b [L{}]", i * 4 + 3),
]
})
.collect::<Vec<_>>();
pretty_assertions::assert_eq!(
content
.lines()
.skip(4)
.take(expected_content.len())
.collect::<Vec<_>>(),
expected_content
);
}
#[gpui::test]
async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(2),
end_line: Some(4),
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4");
}
#[gpui::test]
async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
// start_line of 0 should be treated as 1
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(0),
end_line: Some(2),
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 1\nLine 2");
// end_line of 0 should result in at least 1 line
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(1),
end_line: Some(0),
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 1");
// when start_line > end_line, should still return at least 1 line
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/multiline.txt".to_string(),
start_line: Some(3),
end_line: Some(2),
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert_eq!(result.unwrap(), "Line 3");
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
}
fn rust_lang() -> Language {
Language::new(
LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_outline_query(
r#"
(line_comment) @annotation
(struct_item
"struct" @context
name: (_) @name) @item
(enum_item
"enum" @context
name: (_) @name) @item
(enum_variant
name: (_) @name) @item
(field_declaration
name: (_) @name) @item
(impl_item
"impl" @context
trait: (_)? @name
"for"? @context
type: (_) @name
body: (_ "{" (_)* "}")) @item
(function_item
"fn" @context
name: (_) @name) @item
(mod_item
"mod" @context
name: (_) @name) @item
"#,
)
.unwrap()
}
#[gpui::test]
async fn test_read_file_security(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/"),
json!({
"project_root": {
"allowed_file.txt": "This file is in the project",
".mysecrets": "SECRET_KEY=abc123",
".secretdir": {
"config": "special configuration"
},
".mymetadata": "custom metadata",
"subdir": {
"normal_file.txt": "Normal file content",
"special.privatekey": "private key content",
"data.mysensitive": "sensitive data"
}
},
"outside_project": {
"sensitive_file.txt": "This file is outside the project"
}
}),
)
.await;
cx.update(|cx| {
use gpui::UpdateGlobal;
use project::WorktreeSettings;
use settings::SettingsStore;
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |settings| {
settings.file_scan_exclusions = Some(vec![
"**/.secretdir".to_string(),
"**/.mymetadata".to_string(),
]);
settings.private_files = Some(vec![
"**/.mysecrets".to_string(),
"**/*.privatekey".to_string(),
"**/*.mysensitive".to_string(),
]);
});
});
});
let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log));
let event_stream = TestToolCallEventStream::new();
// Reading a file outside the project worktree should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "/outside_project/sensitive_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read an absolute path outside a worktree"
);
// Reading a file within the project should succeed
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/allowed_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_ok(),
"read_file_tool should be able to read files inside worktrees"
);
// Reading files that match file_scan_exclusions should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/.secretdir/config".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/.mymetadata".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
);
// Reading private files should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/.mysecrets".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .mysecrets (private_files)"
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/subdir/special.privatekey".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .privatekey files (private_files)"
);
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/subdir/data.mysensitive".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read .mysensitive files (private_files)"
);
// Reading a normal file should still work, even with private_files configured
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/subdir/normal_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_ok(), "Should be able to read normal files");
assert_eq!(result.unwrap(), "Normal file content");
// Path traversal attempts with .. should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "project_root/../outside_project/sensitive_file.txt".to_string(),
start_line: None,
end_line: None,
};
tool.run(input, event_stream.stream(), cx)
})
.await;
assert!(
result.is_err(),
"read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
);
}
#[gpui::test]
async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
// Create first worktree with its own private_files setting
fs.insert_tree(
path!("/worktree1"),
json!({
"src": {
"main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
"secret.rs": "const API_KEY: &str = \"secret_key_1\";",
"config.toml": "[database]\nurl = \"postgres://localhost/db1\""
},
"tests": {
"test.rs": "mod tests { fn test_it() {} }",
"fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
},
".zed": {
"settings.json": r#"{
"file_scan_exclusions": ["**/fixture.*"],
"private_files": ["**/secret.rs", "**/config.toml"]
}"#
}
}),
)
.await;
// Create second worktree with different private_files setting
fs.insert_tree(
path!("/worktree2"),
json!({
"lib": {
"public.js": "export function greet() { return 'Hello from worktree2'; }",
"private.js": "const SECRET_TOKEN = \"private_token_2\";",
"data.json": "{\"api_key\": \"json_secret_key\"}"
},
"docs": {
"README.md": "# Public Documentation",
"internal.md": "# Internal Secrets and Configuration"
},
".zed": {
"settings.json": r#"{
"file_scan_exclusions": ["**/internal.*"],
"private_files": ["**/private.js", "**/data.json"]
}"#
}
}),
)
.await;
// Set global settings
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |settings| {
settings.file_scan_exclusions =
Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
settings.private_files = Some(vec!["**/.env".to_string()]);
});
});
});
let project = Project::test(
fs.clone(),
[path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
cx,
)
.await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone()));
let event_stream = TestToolCallEventStream::new();
// Test reading allowed files in worktree1
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/src/main.rs".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await
.unwrap();
assert_eq!(result, "fn main() { println!(\"Hello from worktree1\"); }");
// Test reading private file in worktree1 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/src/secret.rs".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `private_files` setting"),
"Error should mention worktree private_files setting"
);
// Test reading excluded file in worktree1 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/tests/fixture.sql".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `file_scan_exclusions` setting"),
"Error should mention worktree file_scan_exclusions setting"
);
// Test reading allowed files in worktree2
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree2/lib/public.js".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await
.unwrap();
assert_eq!(
result,
"export function greet() { return 'Hello from worktree2'; }"
);
// Test reading private file in worktree2 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree2/lib/private.js".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `private_files` setting"),
"Error should mention worktree private_files setting"
);
// Test reading excluded file in worktree2 should fail
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree2/docs/internal.md".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `file_scan_exclusions` setting"),
"Error should mention worktree file_scan_exclusions setting"
);
// Test that files allowed in one worktree but not in another are handled correctly
// (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "worktree1/src/config.toml".to_string(),
start_line: None,
end_line: None,
};
tool.clone().run(input, event_stream.stream(), cx)
})
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("worktree `private_files` setting"),
"Config.toml should be blocked by worktree1's private_files setting"
);
}
}

View file

@ -0,0 +1,48 @@
use agent_client_protocol as acp;
use anyhow::Result;
use gpui::{App, SharedString, Task};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::{AgentTool, ToolCallEventStream};
/// A tool for thinking through problems, brainstorming ideas, or planning without executing any actions.
/// Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ThinkingToolInput {
/// Content to think about. This should be a description of what to think about or
/// a problem to solve.
content: String,
}
pub struct ThinkingTool;
impl AgentTool for ThinkingTool {
type Input = ThinkingToolInput;
fn name(&self) -> SharedString {
"thinking".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Think
}
fn initial_title(&self, _input: Self::Input) -> SharedString {
"Thinking".into()
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
_cx: &mut App,
) -> Task<Result<String>> {
event_stream.send_update(acp::ToolCallUpdateFields {
content: Some(vec![input.content.into()]),
..Default::default()
});
Task::ready(Ok("Finished thinking.".to_string()))
}
}

View file

@ -45,6 +45,11 @@ impl<T> MessageHistory<T> {
None
})
}
#[cfg(test)]
pub fn items(&self) -> &[T] {
&self.items
}
}
#[cfg(test)]
mod tests {

View file

@ -31,7 +31,7 @@ use language::{Buffer, Language};
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
use parking_lot::Mutex;
use project::Project;
use settings::Settings as _;
use settings::{Settings as _, SettingsStore};
use text::{Anchor, BufferSnapshot};
use theme::ThemeSettings;
use ui::{
@ -80,6 +80,7 @@ pub struct AcpThreadView {
editor_expanded: bool,
message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 1],
}
enum ThreadState {
@ -178,6 +179,8 @@ impl AcpThreadView {
let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
let subscription = cx.observe_global_in::<SettingsStore>(window, Self::settings_changed);
Self {
agent: agent.clone(),
workspace: workspace.clone(),
@ -200,6 +203,7 @@ impl AcpThreadView {
plan_expanded: false,
editor_expanded: false,
message_history,
_subscriptions: [subscription],
_cancel_task: None,
}
}
@ -377,6 +381,11 @@ impl AcpThreadView {
editor.display_map.update(cx, |map, cx| {
let snapshot = map.snapshot(cx);
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
// Skip creases that have been edited out of the message buffer.
if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
continue;
}
if let Some(project_path) =
self.mention_set.lock().path_for_crease_id(crease_id)
{
@ -704,15 +713,7 @@ impl AcpThreadView {
editor.set_show_code_actions(false, cx);
editor.set_show_git_diff_gutter(false, cx);
editor.set_expand_all_diff_hunks(cx);
editor.set_text_style_refinement(TextStyleRefinement {
font_size: Some(
TextSize::Small
.rems(cx)
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
.into(),
),
..Default::default()
});
editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
editor
});
let entity_id = multibuffer.entity_id();
@ -2600,6 +2601,15 @@ impl AcpThreadView {
.cursor_default()
.children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
}
fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
for diff_editor in self.diff_editors.values() {
diff_editor.update(cx, |diff_editor, cx| {
diff_editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
cx.notify();
})
}
}
}
impl Focusable for AcpThreadView {
@ -2877,6 +2887,18 @@ fn plan_label_markdown_style(
}
}
fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
TextStyleRefinement {
font_size: Some(
TextSize::Small
.rems(cx)
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
.into(),
),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use agent_client_protocol::SessionId;
@ -2884,8 +2906,12 @@ mod tests {
use fs::FakeFs;
use futures::future::try_join_all;
use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
use lsp::{CompletionContext, CompletionTriggerKind};
use project::CompletionIntent;
use rand::Rng;
use serde_json::json;
use settings::SettingsStore;
use util::path;
use super::*;
@ -2998,6 +3024,109 @@ mod tests {
);
}
#[gpui::test]
async fn test_crease_removal(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
let agent = StubAgentServer::default();
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
AcpThreadView::new(
Rc::new(agent),
workspace.downgrade(),
project,
Rc::new(RefCell::new(MessageHistory::default())),
1,
None,
window,
cx,
)
})
});
cx.run_until_parked();
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
let excerpt_id = message_editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.excerpt_ids()
.into_iter()
.next()
.unwrap()
});
let completions = message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello @", window, cx);
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
let completion_provider = editor.completion_provider().unwrap();
completion_provider.completions(
excerpt_id,
&buffer,
Anchor::MAX,
CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: Some("@".into()),
},
window,
cx,
)
});
let [_, completion]: [_; 2] = completions
.await
.unwrap()
.into_iter()
.flat_map(|response| response.completions)
.collect::<Vec<_>>()
.try_into()
.unwrap();
message_editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let start = snapshot
.anchor_in_excerpt(excerpt_id, completion.replace_range.start)
.unwrap();
let end = snapshot
.anchor_in_excerpt(excerpt_id, completion.replace_range.end)
.unwrap();
editor.edit([(start..end, completion.new_text)], cx);
(completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
});
cx.run_until_parked();
// Backspace over the inserted crease (and the following space).
message_editor.update_in(cx, |editor, window, cx| {
editor.backspace(&Default::default(), window, cx);
editor.backspace(&Default::default(), window, cx);
});
thread_view.update_in(cx, |thread_view, window, cx| {
thread_view.chat(&Chat, window, cx);
});
cx.run_until_parked();
let content = thread_view.update_in(cx, |thread_view, _window, _cx| {
thread_view
.message_history
.borrow()
.items()
.iter()
.flatten()
.cloned()
.collect::<Vec<_>>()
});
// We don't send a resource link for the deleted crease.
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
}
async fn setup_thread_view(
agent: impl AgentServer + 'static,
cx: &mut TestAppContext,

View file

@ -137,7 +137,7 @@ impl RenderOnce for AiUpsellCard {
.size(rems_from_px(72.))
.child(
Vector::new(
VectorName::CertifiedUserStamp,
VectorName::ProUserStamp,
rems_from_px(72.),
rems_from_px(72.),
)

View file

@ -134,10 +134,15 @@ impl Settings for AutoUpdateSetting {
type FileContent = Option<AutoUpdateSettingContent>;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let auto_update = [sources.server, sources.release_channel, sources.user]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
let auto_update = [
sources.server,
sources.release_channel,
sources.operating_system,
sources.user,
]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
Ok(Self(auto_update.0))
}

View file

@ -16,6 +16,7 @@ use clock::SystemClock;
use cloud_api_client::CloudApiClient;
use cloud_api_client::websocket_protocol::MessageToClient;
use credentials_provider::CredentialsProvider;
use feature_flags::FeatureFlagAppExt as _;
use futures::{
AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt,
channel::oneshot, future::BoxFuture,
@ -192,6 +193,8 @@ pub fn init(client: &Arc<Client>, cx: &mut App) {
});
}
pub type MessageToClientHandler = Box<dyn Fn(&MessageToClient, &App) + Send + Sync + 'static>;
struct GlobalClient(Arc<Client>);
impl Global for GlobalClient {}
@ -205,6 +208,7 @@ pub struct Client {
credentials_provider: ClientCredentialsProvider,
state: RwLock<ClientState>,
handler_set: parking_lot::Mutex<ProtoMessageHandlerSet>,
message_to_client_handlers: parking_lot::Mutex<Vec<MessageToClientHandler>>,
#[allow(clippy::type_complexity)]
#[cfg(any(test, feature = "test-support"))]
@ -554,6 +558,7 @@ impl Client {
credentials_provider: ClientCredentialsProvider::new(cx),
state: Default::default(),
handler_set: Default::default(),
message_to_client_handlers: parking_lot::Mutex::new(Vec::new()),
#[cfg(any(test, feature = "test-support"))]
authenticate: Default::default(),
@ -960,25 +965,51 @@ impl Client {
Ok(())
}
/// Performs a sign-in and also connects to Collab.
/// Performs a sign-in and also (optionally) connects to Collab.
///
/// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls
/// to `sign_in` when we're ready to remove auto-connection to Collab.
/// Only Zed staff automatically connect to Collab.
pub async fn sign_in_with_optional_connect(
self: &Arc<Self>,
try_provider: bool,
cx: &AsyncApp,
) -> Result<()> {
let (is_staff_tx, is_staff_rx) = oneshot::channel::<bool>();
let mut is_staff_tx = Some(is_staff_tx);
cx.update(|cx| {
cx.on_flags_ready(move |state, _cx| {
if let Some(is_staff_tx) = is_staff_tx.take() {
is_staff_tx.send(state.is_staff).log_err();
}
})
.detach();
})
.log_err();
let credentials = self.sign_in(try_provider, cx).await?;
self.connect_to_cloud(cx).await.log_err();
let connect_result = match self.connect_with_credentials(credentials, cx).await {
ConnectionResult::Timeout => Err(anyhow!("connection timed out")),
ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")),
ConnectionResult::Result(result) => result.context("client auth and connect"),
};
connect_result.log_err();
cx.update(move |cx| {
cx.spawn({
let client = self.clone();
async move |cx| {
let is_staff = is_staff_rx.await?;
if is_staff {
match client.connect_with_credentials(credentials, cx).await {
ConnectionResult::Timeout => Err(anyhow!("connection timed out")),
ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")),
ConnectionResult::Result(result) => {
result.context("client auth and connect")
}
}
} else {
Ok(())
}
}
})
.detach_and_log_err(cx);
})
.log_err();
Ok(())
}
@ -1651,10 +1682,22 @@ impl Client {
}
}
fn handle_message_to_client(self: &Arc<Client>, message: MessageToClient, _cx: &AsyncApp) {
match message {
MessageToClient::UserUpdated => {}
}
pub fn add_message_to_client_handler(
self: &Arc<Client>,
handler: impl Fn(&MessageToClient, &App) + Send + Sync + 'static,
) {
self.message_to_client_handlers
.lock()
.push(Box::new(handler));
}
fn handle_message_to_client(self: &Arc<Client>, message: MessageToClient, cx: &AsyncApp) {
cx.update(|cx| {
for handler in self.message_to_client_handlers.lock().iter() {
handler(&message, cx);
}
})
.ok();
}
pub fn telemetry(&self) -> &Arc<Telemetry> {

View file

@ -1,6 +1,7 @@
use super::{Client, Status, TypedEnvelope, proto};
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
use cloud_api_client::websocket_protocol::MessageToClient;
use cloud_api_client::{GetAuthenticatedUserResponse, PlanInfo};
use cloud_llm_client::{
EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME,
@ -181,6 +182,12 @@ impl UserStore {
client.add_message_handler(cx.weak_entity(), Self::handle_update_invite_info),
client.add_message_handler(cx.weak_entity(), Self::handle_show_contacts),
];
client.add_message_to_client_handler({
let this = cx.weak_entity();
move |message, cx| Self::handle_message_to_client(this.clone(), message, cx)
});
Self {
users: Default::default(),
by_github_login: Default::default(),
@ -813,6 +820,32 @@ impl UserStore {
cx.emit(Event::PrivateUserInfoUpdated);
}
fn handle_message_to_client(this: WeakEntity<Self>, message: &MessageToClient, cx: &App) {
cx.spawn(async move |cx| {
match message {
MessageToClient::UserUpdated => {
let cloud_client = cx
.update(|cx| {
this.read_with(cx, |this, _cx| {
this.client.upgrade().map(|client| client.cloud_client())
})
})??
.ok_or(anyhow::anyhow!("Failed to get Cloud client"))?;
let response = cloud_client.get_authenticated_user().await?;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.update_authenticated_user(response, cx);
})
})??;
}
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
self.current_user.clone()
}

View file

@ -2,5 +2,6 @@ ZED_ENVIRONMENT=production
RUST_LOG=info
INVITE_LINK_PREFIX=https://zed.dev/invites/
AUTO_JOIN_CHANNEL_ID=283
DATABASE_MAX_CONNECTIONS=250
# Set DATABASE_MAX_CONNECTIONS max connections in the `deploy_collab.yml`:
# https://github.com/zed-industries/zed/blob/main/.github/workflows/deploy_collab.yml
LLM_DATABASE_MAX_CONNECTIONS=25

View file

@ -699,7 +699,10 @@ impl Database {
language_server::Column::ProjectId,
language_server::Column::Id,
])
.update_column(language_server::Column::Name)
.update_columns([
language_server::Column::Name,
language_server::Column::Capabilities,
])
.to_owned(),
)
.exec(&*tx)

View file

@ -3053,7 +3053,7 @@ impl Render for CollabPanel {
.on_action(cx.listener(CollabPanel::move_channel_down))
.track_focus(&self.focus_handle)
.size_full()
.child(if self.user_store.read(cx).current_user().is_none() {
.child(if !self.client.status().borrow().is_connected() {
self.render_signed_out(cx)
} else {
self.render_signed_in(window, cx)

View file

@ -2705,6 +2705,11 @@ impl Editor {
self.completion_provider = provider;
}
#[cfg(any(test, feature = "test-support"))]
pub fn completion_provider(&self) -> Option<Rc<dyn CompletionProvider>> {
self.completion_provider.clone()
}
pub fn semantics_provider(&self) -> Option<Rc<dyn SemanticsProvider>> {
self.semantics_provider.clone()
}

View file

@ -158,6 +158,11 @@ where
}
}
#[derive(Debug)]
pub struct OnFlagsReady {
pub is_staff: bool,
}
pub trait FeatureFlagAppExt {
fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag;
@ -169,6 +174,10 @@ pub trait FeatureFlagAppExt {
fn has_flag<T: FeatureFlag>(&self) -> bool;
fn is_staff(&self) -> bool;
fn on_flags_ready<F>(&mut self, callback: F) -> Subscription
where
F: FnMut(OnFlagsReady, &mut App) + 'static;
fn observe_flag<T: FeatureFlag, F>(&mut self, callback: F) -> Subscription
where
F: FnMut(bool, &mut App) + 'static;
@ -198,6 +207,21 @@ impl FeatureFlagAppExt for App {
.unwrap_or(false)
}
fn on_flags_ready<F>(&mut self, mut callback: F) -> Subscription
where
F: FnMut(OnFlagsReady, &mut App) + 'static,
{
self.observe_global::<FeatureFlags>(move |cx| {
let feature_flags = cx.global::<FeatureFlags>();
callback(
OnFlagsReady {
is_staff: feature_flags.staff,
},
cx,
);
})
}
fn observe_flag<T: FeatureFlag, F>(&mut self, mut callback: F) -> Subscription
where
F: FnMut(bool, &mut App) + 'static,

View file

@ -1,9 +1,6 @@
use notify::EventKind;
use parking_lot::Mutex;
use std::{
collections::HashMap,
sync::{Arc, OnceLock},
};
use std::sync::{Arc, OnceLock};
use util::{ResultExt, paths::SanitizedPath};
use crate::{PathEvent, PathEventKind, Watcher};
@ -11,7 +8,6 @@ use crate::{PathEvent, PathEventKind, Watcher};
pub struct FsWatcher {
tx: smol::channel::Sender<()>,
pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
registrations: Mutex<HashMap<Arc<std::path::Path>, WatcherRegistrationId>>,
}
impl FsWatcher {
@ -22,24 +18,10 @@ impl FsWatcher {
Self {
tx,
pending_path_events,
registrations: Default::default(),
}
}
}
impl Drop for FsWatcher {
fn drop(&mut self) {
let mut registrations = self.registrations.lock();
let registrations = registrations.drain();
let _ = global(|g| {
for (_, registration) in registrations {
g.remove(registration);
}
});
}
}
impl Watcher for FsWatcher {
fn add(&self, path: &std::path::Path) -> anyhow::Result<()> {
let root_path = SanitizedPath::from(path);
@ -47,136 +29,75 @@ impl Watcher for FsWatcher {
let tx = self.tx.clone();
let pending_paths = self.pending_path_events.clone();
let path: Arc<std::path::Path> = path.into();
use notify::Watcher;
if self.registrations.lock().contains_key(&path) {
return Ok(());
}
let registration_id = global({
let path = path.clone();
global({
|g| {
g.add(
path,
notify::RecursiveMode::NonRecursive,
move |event: &notify::Event| {
let kind = match event.kind {
EventKind::Create(_) => Some(PathEventKind::Created),
EventKind::Modify(_) => Some(PathEventKind::Changed),
EventKind::Remove(_) => Some(PathEventKind::Removed),
_ => None,
};
let mut path_events = event
.paths
.iter()
.filter_map(|event_path| {
let event_path = SanitizedPath::from(event_path);
event_path.starts_with(&root_path).then(|| PathEvent {
path: event_path.as_path().to_path_buf(),
kind,
})
g.add(move |event: &notify::Event| {
let kind = match event.kind {
EventKind::Create(_) => Some(PathEventKind::Created),
EventKind::Modify(_) => Some(PathEventKind::Changed),
EventKind::Remove(_) => Some(PathEventKind::Removed),
_ => None,
};
let mut path_events = event
.paths
.iter()
.filter_map(|event_path| {
let event_path = SanitizedPath::from(event_path);
event_path.starts_with(&root_path).then(|| PathEvent {
path: event_path.as_path().to_path_buf(),
kind,
})
.collect::<Vec<_>>();
})
.collect::<Vec<_>>();
if !path_events.is_empty() {
path_events.sort();
let mut pending_paths = pending_paths.lock();
if pending_paths.is_empty() {
tx.try_send(()).ok();
}
util::extend_sorted(
&mut *pending_paths,
path_events,
usize::MAX,
|a, b| a.path.cmp(&b.path),
);
if !path_events.is_empty() {
path_events.sort();
let mut pending_paths = pending_paths.lock();
if pending_paths.is_empty() {
tx.try_send(()).ok();
}
},
)
util::extend_sorted(
&mut *pending_paths,
path_events,
usize::MAX,
|a, b| a.path.cmp(&b.path),
);
}
})
}
})??;
})?;
self.registrations.lock().insert(path, registration_id);
global(|g| {
g.watcher
.lock()
.watch(path, notify::RecursiveMode::NonRecursive)
})??;
Ok(())
}
fn remove(&self, path: &std::path::Path) -> anyhow::Result<()> {
let Some(registration) = self.registrations.lock().remove(path) else {
return Ok(());
};
global(|w| w.remove(registration))
use notify::Watcher;
Ok(global(|w| w.watcher.lock().unwatch(path))??)
}
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct WatcherRegistrationId(u32);
struct WatcherRegistrationState {
callback: Box<dyn Fn(&notify::Event) + Send + Sync>,
path: Arc<std::path::Path>,
}
struct WatcherState {
// two mutexes because calling watcher.add triggers an watcher.event, which needs watchers.
#[cfg(target_os = "linux")]
watcher: notify::INotifyWatcher,
#[cfg(target_os = "freebsd")]
watcher: notify::KqueueWatcher,
#[cfg(target_os = "windows")]
watcher: notify::ReadDirectoryChangesWatcher,
watchers: HashMap<WatcherRegistrationId, WatcherRegistrationState>,
path_registrations: HashMap<Arc<std::path::Path>, u32>,
last_registration: WatcherRegistrationId,
}
pub struct GlobalWatcher {
state: Mutex<WatcherState>,
// two mutexes because calling watcher.add triggers an watcher.event, which needs watchers.
#[cfg(target_os = "linux")]
pub(super) watcher: Mutex<notify::INotifyWatcher>,
#[cfg(target_os = "freebsd")]
pub(super) watcher: Mutex<notify::KqueueWatcher>,
#[cfg(target_os = "windows")]
pub(super) watcher: Mutex<notify::ReadDirectoryChangesWatcher>,
pub(super) watchers: Mutex<Vec<Box<dyn Fn(&notify::Event) + Send + Sync>>>,
}
impl GlobalWatcher {
#[must_use]
fn add(
&self,
path: Arc<std::path::Path>,
mode: notify::RecursiveMode,
cb: impl Fn(&notify::Event) + Send + Sync + 'static,
) -> anyhow::Result<WatcherRegistrationId> {
use notify::Watcher;
let mut state = self.state.lock();
state.watcher.watch(&path, mode)?;
let id = state.last_registration;
state.last_registration = WatcherRegistrationId(id.0 + 1);
let registration_state = WatcherRegistrationState {
callback: Box::new(cb),
path: path.clone(),
};
state.watchers.insert(id, registration_state);
*state.path_registrations.entry(path.clone()).or_insert(0) += 1;
Ok(id)
}
pub fn remove(&self, id: WatcherRegistrationId) {
use notify::Watcher;
let mut state = self.state.lock();
let Some(registration_state) = state.watchers.remove(&id) else {
return;
};
let Some(count) = state.path_registrations.get_mut(&registration_state.path) else {
return;
};
*count -= 1;
if *count == 0 {
state.watcher.unwatch(&registration_state.path).log_err();
state.path_registrations.remove(&registration_state.path);
}
pub(super) fn add(&self, cb: impl Fn(&notify::Event) + Send + Sync + 'static) {
self.watchers.lock().push(Box::new(cb))
}
}
@ -193,10 +114,8 @@ fn handle_event(event: Result<notify::Event, notify::Error>) {
return;
};
global::<()>(move |watcher| {
let state = watcher.state.lock();
for registration in state.watchers.values() {
let callback = &registration.callback;
callback(&event);
for f in watcher.watchers.lock().iter() {
f(&event)
}
})
.log_err();
@ -205,12 +124,8 @@ fn handle_event(event: Result<notify::Event, notify::Error>) {
pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
let result = FS_WATCHER_INSTANCE.get_or_init(|| {
notify::recommended_watcher(handle_event).map(|file_watcher| GlobalWatcher {
state: Mutex::new(WatcherState {
watcher: file_watcher,
watchers: Default::default(),
path_registrations: Default::default(),
last_registration: Default::default(),
}),
watcher: Mutex::new(file_watcher),
watchers: Default::default(),
})
});
match result {

View file

@ -308,10 +308,14 @@ impl Settings for LineIndicatorFormat {
type FileContent = Option<LineIndicatorFormatContent>;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
let format = [sources.release_channel, sources.user]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
let format = [
sources.release_channel,
sources.operating_system,
sources.user,
]
.into_iter()
.find_map(|value| value.copied().flatten())
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
Ok(format.0)
}

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use ai_onboarding::{AiUpsellCard, SignInStatus};
use client::UserStore;
use ai_onboarding::AiUpsellCard;
use client::{Client, UserStore};
use fs::Fs;
use gpui::{
Action, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity,
@ -12,8 +12,8 @@ use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageMod
use project::DisableAiSettings;
use settings::{Settings, update_settings_file};
use ui::{
Badge, ButtonLike, Divider, Modal, ModalFooter, ModalHeader, Section, SwitchField, ToggleState,
prelude::*, tooltip_container,
Badge, ButtonLike, Divider, KeyBinding, Modal, ModalFooter, ModalHeader, Section, SwitchField,
ToggleState, prelude::*, tooltip_container,
};
use util::ResultExt;
use workspace::{ModalView, Workspace};
@ -88,7 +88,7 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i
h_flex()
.gap_2()
.justify_between()
.child(Label::new("We don't train models using your data"))
.child(Label::new("Privacy is the default for Zed"))
.child(
h_flex().gap_1().child(privacy_badge()).child(
Button::new("learn_more", "Learn More")
@ -109,7 +109,7 @@ fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> i
)
.child(
Label::new(
"Feel confident in the security and privacy of your projects using Zed.",
"Any use or storage of your data is with your explicit, single-use, opt-in consent.",
)
.size(LabelSize::Small)
.color(Color::Muted),
@ -240,6 +240,7 @@ fn render_llm_provider_card(
pub(crate) fn render_ai_setup_page(
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
client: Arc<Client>,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
@ -283,15 +284,16 @@ pub(crate) fn render_ai_setup_page(
v_flex()
.mt_2()
.gap_6()
.child(AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
account_too_young: user_store.read(cx).account_too_young(),
user_plan: user_store.read(cx).plan(),
tab_index: Some({
.child({
let mut ai_upsell_card =
AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx);
ai_upsell_card.tab_index = Some({
tab_index += 1;
tab_index - 1
}),
});
ai_upsell_card
})
.child(render_llm_provider_section(
&mut tab_index,
@ -336,6 +338,10 @@ impl AiConfigurationModal {
selected_provider,
}
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl ModalView for AiConfigurationModal {}
@ -349,11 +355,15 @@ impl Focusable for AiConfigurationModal {
}
impl Render for AiConfigurationModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("OnboardingAiConfigurationModal")
.w(rems(34.))
.elevation_3(cx)
.track_focus(&self.focus_handle)
.on_action(
cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
)
.child(
Modal::new("onboarding-ai-setup-modal", None)
.header(
@ -368,18 +378,19 @@ impl Render for AiConfigurationModal {
.section(Section::new().child(self.configuration_view.clone()))
.footer(
ModalFooter::new().end_slot(
h_flex()
.gap_1()
.child(
Button::new("onboarding-closing-cancel", "Cancel")
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
Button::new("ai-onb-modal-Done", "Done")
.key_binding(
KeyBinding::for_action_in(
&menu::Cancel,
&self.focus_handle.clone(),
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.child(Button::new("save-btn", "Done").on_click(cx.listener(
|_, _, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx);
cx.emit(DismissEvent);
},
))),
.on_click(cx.listener(|this, _event, _window, cx| {
this.cancel(&menu::Cancel, cx)
})),
),
),
)
@ -396,7 +407,7 @@ impl AiPrivacyTooltip {
impl Render for AiPrivacyTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const DESCRIPTION: &'static str = "One of Zed's most important principles is transparency. This is why we are and value open-source so much. And it wouldn't be any different with AI.";
const DESCRIPTION: &'static str = "We believe in opt-in data sharing as the default for building AI products, rather than opt-out. We'll only use or store your data if you affirmatively send it to us. ";
tooltip_container(window, cx, move |this, _, _| {
this.child(
@ -407,7 +418,7 @@ impl Render for AiPrivacyTooltip {
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new("Privacy Principle")),
.child(Label::new("Privacy First")),
)
.child(
div().max_w_64().child(

View file

@ -201,12 +201,15 @@ fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement
let fs = <dyn Fs>::global(cx);
v_flex()
.pt_6()
.gap_4()
.border_t_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.child(Label::new("Telemetry").size(LabelSize::Large))
.child(SwitchField::new(
"onboarding-telemetry-metrics",
"Help Improve Zed",
Some("Sending anonymous usage data helps us build the right features and create the best experience.".into()),
Some("Anonymous usage data helps us build the right features and improve your experience.".into()),
if TelemetrySettings::get_global(cx).metrics {
ui::ToggleState::Selected
} else {
@ -294,7 +297,7 @@ fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoE
ToggleButtonWithIcon::new("Emacs", IconName::EditorEmacs, |_, _, cx| {
write_keymap_base(BaseKeymap::Emacs, cx);
}),
ToggleButtonWithIcon::new("Cursor (Beta)", IconName::EditorCursor, |_, _, cx| {
ToggleButtonWithIcon::new("Cursor", IconName::EditorCursor, |_, _, cx| {
write_keymap_base(BaseKeymap::Cursor, cx);
}),
],
@ -326,10 +329,7 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
SwitchField::new(
"onboarding-vim-mode",
"Vim Mode",
Some(
"Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back."
.into(),
),
Some("Coming from Neovim? Use our first-class implementation of Vim Mode.".into()),
toggle_state,
{
let fs = <dyn Fs>::global(cx);

View file

@ -584,11 +584,15 @@ fn render_popular_settings_section(
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
const LIGATURE_TOOLTIP: &'static str = "Ligatures are when a font creates a special character out of combining two characters into one. For example, with ligatures turned on, =/= would become ≠.";
const LIGATURE_TOOLTIP: &'static str =
"Font ligatures combine two characters into one. For example, turning =/= into ≠.";
v_flex()
.gap_5()
.child(Label::new("Popular Settings").size(LabelSize::Large).mt_8())
.pt_6()
.gap_4()
.border_t_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.child(Label::new("Popular Settings").size(LabelSize::Large))
.child(render_font_customization_section(tab_index, window, cx))
.child(
SwitchField::new(
@ -683,7 +687,10 @@ fn render_popular_settings_section(
[
ToggleButtonSimple::new("Auto", |_, _, cx| {
write_show_mini_map(ShowMinimap::Auto, cx);
}),
})
.tooltip(Tooltip::text(
"Show the minimap if the editor's scrollbar is visible.",
)),
ToggleButtonSimple::new("Always", |_, _, cx| {
write_show_mini_map(ShowMinimap::Always, cx);
}),
@ -707,7 +714,7 @@ fn render_popular_settings_section(
pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement {
let mut tab_index = 0;
v_flex()
.gap_4()
.gap_6()
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_popular_settings_section(&mut tab_index, window, cx))
}

View file

@ -77,6 +77,8 @@ actions!(
ActivateAISetupPage,
/// Finish the onboarding process.
Finish,
/// Sign in while in the onboarding flow.
SignIn
]
);
@ -376,6 +378,7 @@ impl Onboarding {
cx,
)
.map(|kb| kb.size(rems_from_px(12.)));
if ai_setup_page {
this.child(
ButtonLike::new("start_building")
@ -387,14 +390,7 @@ impl Onboarding {
.w_full()
.justify_between()
.child(Label::new("Start Building"))
.child(keybinding.map_or_else(
|| {
Icon::new(IconName::Check)
.size(IconSize::Small)
.into_any_element()
},
IntoElement::into_any_element,
)),
.children(keybinding),
)
.on_click(|_, window, cx| {
window.dispatch_action(Finish.boxed_clone(), cx);
@ -409,11 +405,10 @@ impl Onboarding {
.ml_1()
.w_full()
.justify_between()
.child(Label::new("Skip All"))
.child(keybinding.map_or_else(
|| gpui::Empty.into_any_element(),
IntoElement::into_any_element,
)),
.child(
Label::new("Skip All").color(Color::Muted),
)
.children(keybinding),
)
.on_click(|_, window, cx| {
window.dispatch_action(Finish.boxed_clone(), cx);
@ -435,23 +430,39 @@ impl Onboarding {
Button::new("sign_in", "Sign In")
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.key_binding(
KeyBinding::for_action_in(&SignIn, &self.focus_handle, window, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
let client = Client::global(cx);
window
.spawn(cx, async move |cx| {
client
.sign_in_with_optional_connect(true, &cx)
.await
.notify_async_err(cx);
})
.detach();
window.dispatch_action(SignIn.boxed_clone(), cx);
})
.into_any_element()
},
)
}
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
go_to_welcome_page(cx);
}
fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
let client = Client::global(cx);
window
.spawn(cx, async move |cx| {
client
.sign_in_with_optional_connect(true, &cx)
.await
.notify_async_err(cx);
})
.detach();
}
fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let client = Client::global(cx);
match self.selected_page {
SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(),
SelectedPage::Editing => {
@ -460,16 +471,13 @@ impl Onboarding {
SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page(
self.workspace.clone(),
self.user_store.clone(),
client,
window,
cx,
)
.into_any_element(),
}
}
fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) {
go_to_welcome_page(cx);
}
}
impl Render for Onboarding {
@ -486,6 +494,7 @@ impl Render for Onboarding {
.size_full()
.bg(cx.theme().colors().editor_background)
.on_action(Self::on_finish)
.on_action(Self::handle_sign_in)
.on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| {
this.set_page(SelectedPage::Basics, cx);
}))

View file

@ -19,6 +19,7 @@ command_palette_hooks.workspace = true
db.workspace = true
editor.workspace = true
file_icons.workspace = true
git_ui.workspace = true
indexmap.workspace = true
git.workspace = true
gpui.workspace = true

View file

@ -16,6 +16,7 @@ use editor::{
};
use file_icons::FileIcons;
use git::status::GitSummary;
use git_ui::file_diff_view::FileDiffView;
use gpui::{
Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths,
@ -93,7 +94,7 @@ pub struct ProjectPanel {
unfolded_dir_ids: HashSet<ProjectEntryId>,
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
selection: Option<SelectedEntry>,
marked_entries: BTreeSet<SelectedEntry>,
marked_entries: Vec<SelectedEntry>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
edit_state: Option<EditState>,
filename_editor: Entity<Editor>,
@ -280,6 +281,8 @@ actions!(
SelectNextDirectory,
/// Selects the previous directory.
SelectPrevDirectory,
/// Opens a diff view to compare two marked files.
CompareMarkedFiles,
]
);
@ -376,7 +379,7 @@ struct DraggedProjectEntryView {
selection: SelectedEntry,
details: EntryDetails,
click_offset: Point<Pixels>,
selections: Arc<BTreeSet<SelectedEntry>>,
selections: Arc<[SelectedEntry]>,
}
struct ItemColors {
@ -442,7 +445,15 @@ impl ProjectPanel {
}
}
project::Event::ActiveEntryChanged(None) => {
this.marked_entries.clear();
let is_active_item_file_diff_view = this
.workspace
.upgrade()
.and_then(|ws| ws.read(cx).active_item(cx))
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
.unwrap_or(false);
if !is_active_item_file_diff_view {
this.marked_entries.clear();
}
}
project::Event::RevealInProjectPanel(entry_id) => {
if let Some(()) = this
@ -676,7 +687,7 @@ impl ProjectPanel {
project_panel.update(cx, |project_panel, _| {
let entry = SelectedEntry { worktree_id, entry_id };
project_panel.marked_entries.clear();
project_panel.marked_entries.insert(entry);
project_panel.marked_entries.push(entry);
project_panel.selection = Some(entry);
});
if !focus_opened_item {
@ -887,6 +898,7 @@ impl ProjectPanel {
let should_hide_rename = is_root
&& (cfg!(target_os = "windows")
|| (settings.hide_root && visible_worktrees_count == 1));
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
menu.context(self.focus_handle.clone()).map(|menu| {
@ -918,6 +930,10 @@ impl ProjectPanel {
.when(is_foldable, |menu| {
menu.action("Fold Directory", Box::new(FoldDirectory))
})
.when(should_show_compare, |menu| {
menu.separator()
.action("Compare marked files", Box::new(CompareMarkedFiles))
})
.separator()
.action("Cut", Box::new(Cut))
.action("Copy", Box::new(Copy))
@ -1262,7 +1278,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
cx.notify();
@ -2007,7 +2023,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
@ -2244,7 +2260,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
cx.notify();
@ -2572,6 +2588,43 @@ impl ProjectPanel {
}
}
fn file_abs_paths_to_diff(&self, cx: &Context<Self>) -> Option<(PathBuf, PathBuf)> {
let mut selections_abs_path = self
.marked_entries
.iter()
.filter_map(|entry| {
let project = self.project.read(cx);
let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
let entry = worktree.read(cx).entry_for_id(entry.entry_id)?;
if !entry.is_file() {
return None;
}
worktree.read(cx).absolutize(&entry.path).ok()
})
.rev();
let last_path = selections_abs_path.next()?;
let previous_to_last = selections_abs_path.next()?;
Some((previous_to_last, last_path))
}
fn compare_marked_files(
&mut self,
_: &CompareMarkedFiles,
window: &mut Window,
cx: &mut Context<Self>,
) {
let selected_files = self.file_abs_paths_to_diff(cx);
if let Some((file_path1, file_path2)) = selected_files {
self.workspace
.update(cx, |workspace, cx| {
FileDiffView::open(file_path1, file_path2, workspace, window, cx)
.detach_and_log_err(cx);
})
.ok();
}
}
fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
if let Some((worktree, entry)) = self.selected_entry(cx) {
let abs_path = worktree.abs_path().join(&entry.path);
@ -3914,11 +3967,9 @@ impl ProjectPanel {
let depth = details.depth;
let worktree_id = details.worktree_id;
let selections = Arc::new(self.marked_entries.clone());
let dragged_selection = DraggedSelection {
active_selection: selection,
marked_selections: selections,
marked_selections: Arc::from(self.marked_entries.clone()),
};
let bg_color = if is_marked {
@ -4089,7 +4140,7 @@ impl ProjectPanel {
});
if drag_state.items().count() == 1 {
this.marked_entries.clear();
this.marked_entries.insert(drag_state.active_selection);
this.marked_entries.push(drag_state.active_selection);
}
this.hover_expand_task.take();
@ -4156,65 +4207,69 @@ impl ProjectPanel {
}),
)
.on_click(
cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
if event.is_right_click() || event.first_focus()
|| show_editor
{
return;
}
if event.standard_click() {
this.mouse_down = false;
project_panel.mouse_down = false;
}
cx.stop_propagation();
if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
let current_selection = this.index_for_selection(selection);
if let Some(selection) = project_panel.selection.filter(|_| event.modifiers().shift) {
let current_selection = project_panel.index_for_selection(selection);
let clicked_entry = SelectedEntry {
entry_id,
worktree_id,
};
let target_selection = this.index_for_selection(clicked_entry);
let target_selection = project_panel.index_for_selection(clicked_entry);
if let Some(((_, _, source_index), (_, _, target_index))) =
current_selection.zip(target_selection)
{
let range_start = source_index.min(target_index);
let range_end = source_index.max(target_index) + 1;
let mut new_selections = BTreeSet::new();
this.for_each_visible_entry(
let mut new_selections = Vec::new();
project_panel.for_each_visible_entry(
range_start..range_end,
window,
cx,
|entry_id, details, _, _| {
new_selections.insert(SelectedEntry {
new_selections.push(SelectedEntry {
entry_id,
worktree_id: details.worktree_id,
});
},
);
this.marked_entries = this
.marked_entries
.union(&new_selections)
.cloned()
.collect();
for selection in &new_selections {
if !project_panel.marked_entries.contains(selection) {
project_panel.marked_entries.push(*selection);
}
}
this.selection = Some(clicked_entry);
this.marked_entries.insert(clicked_entry);
project_panel.selection = Some(clicked_entry);
if !project_panel.marked_entries.contains(&clicked_entry) {
project_panel.marked_entries.push(clicked_entry);
}
}
} else if event.modifiers().secondary() {
if event.click_count() > 1 {
this.split_entry(entry_id, cx);
project_panel.split_entry(entry_id, cx);
} else {
this.selection = Some(selection);
if !this.marked_entries.insert(selection) {
this.marked_entries.remove(&selection);
project_panel.selection = Some(selection);
if let Some(position) = project_panel.marked_entries.iter().position(|e| *e == selection) {
project_panel.marked_entries.remove(position);
} else {
project_panel.marked_entries.push(selection);
}
}
} else if kind.is_dir() {
this.marked_entries.clear();
project_panel.marked_entries.clear();
if is_sticky {
if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) {
this.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
if let Some((_, _, index)) = project_panel.index_for_entry(entry_id, worktree_id) {
project_panel.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
cx.notify();
// move down by 1px so that clicked item
// don't count as sticky anymore
@ -4230,16 +4285,16 @@ impl ProjectPanel {
}
}
if event.modifiers().alt {
this.toggle_expand_all(entry_id, window, cx);
project_panel.toggle_expand_all(entry_id, window, cx);
} else {
this.toggle_expanded(entry_id, window, cx);
project_panel.toggle_expanded(entry_id, window, cx);
}
} else {
let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
let click_count = event.click_count();
let focus_opened_item = !preview_tabs_enabled || click_count > 1;
let allow_preview = preview_tabs_enabled && click_count == 1;
this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
}
}),
)
@ -4810,12 +4865,21 @@ impl ProjectPanel {
{
anyhow::bail!("can't reveal an ignored entry in the project panel");
}
let is_active_item_file_diff_view = self
.workspace
.upgrade()
.and_then(|ws| ws.read(cx).active_item(cx))
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
.unwrap_or(false);
if is_active_item_file_diff_view {
return Ok(());
}
let worktree_id = worktree.id();
self.expand_entry(worktree_id, entry_id, cx);
self.update_visible_entries(Some((worktree_id, entry_id)), cx);
self.marked_entries.clear();
self.marked_entries.insert(SelectedEntry {
self.marked_entries.push(SelectedEntry {
worktree_id,
entry_id,
});
@ -5170,6 +5234,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::unfold_directory))
.on_action(cx.listener(Self::fold_directory))
.on_action(cx.listener(Self::remove_from_project))
.on_action(cx.listener(Self::compare_marked_files))
.when(!project.is_read_only(cx), |el| {
el.on_action(cx.listener(Self::new_file))
.on_action(cx.listener(Self::new_directory))

View file

@ -8,7 +8,7 @@ use settings::SettingsStore;
use std::path::{Path, PathBuf};
use util::path;
use workspace::{
AppState, Pane,
AppState, ItemHandle, Pane,
item::{Item, ProjectItem},
register_project_item,
};
@ -3068,7 +3068,7 @@ async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
panel.update(cx, |this, cx| {
let drag = DraggedSelection {
active_selection: this.selection.unwrap(),
marked_selections: Arc::new(this.marked_entries.clone()),
marked_selections: this.marked_entries.clone().into(),
};
let target_entry = this
.project
@ -5562,10 +5562,10 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
marked_selections: Arc::new([SelectedEntry {
worktree_id,
entry_id: child_file.id,
}])),
}]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
@ -5604,7 +5604,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([
marked_selections: Arc::new([
SelectedEntry {
worktree_id,
entry_id: child_file.id,
@ -5613,7 +5613,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: sibling_file.id,
},
])),
]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
@ -5821,6 +5821,186 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) {
}
}
#[gpui::test]
async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"file1.txt": "content of file1",
"file2.txt": "content of file2",
"dir1": {
"file3.txt": "content of file3"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
let file1_path = path!("root/file1.txt");
let file2_path = path!("root/file2.txt");
select_path_with_mark(&panel, file1_path, cx);
select_path_with_mark(&panel, file2_path, cx);
panel.update_in(cx, |panel, window, cx| {
panel.compare_marked_files(&CompareMarkedFiles, window, cx);
});
cx.executor().run_until_parked();
workspace
.update(cx, |workspace, _, cx| {
let active_items = workspace
.panes()
.iter()
.filter_map(|pane| pane.read(cx).active_item())
.collect::<Vec<_>>();
assert_eq!(active_items.len(), 1);
let diff_view = active_items
.into_iter()
.next()
.unwrap()
.downcast::<FileDiffView>()
.expect("Open item should be an FileDiffView");
assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
assert_eq!(
diff_view.tab_tooltip_text(cx).unwrap(),
format!("{}{}", file1_path, file2_path)
);
})
.unwrap();
let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
let worktree_id = panel.update(cx, |panel, cx| {
panel
.project
.read(cx)
.worktrees(cx)
.next()
.unwrap()
.read(cx)
.id()
});
let expected_entries = [
SelectedEntry {
worktree_id,
entry_id: file1_entry_id,
},
SelectedEntry {
worktree_id,
entry_id: file2_entry_id,
},
];
panel.update(cx, |panel, _cx| {
assert_eq!(
&panel.marked_entries, &expected_entries,
"Should keep marked entries after comparison"
);
});
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
})
});
panel.update(cx, |panel, _cx| {
assert_eq!(
&panel.marked_entries, &expected_entries,
"Marked entries should persist after focusing back on the project panel"
);
});
}
#[gpui::test]
async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"file1.txt": "content of file1",
"file2.txt": "content of file2",
"dir1": {},
"dir2": {
"file3.txt": "content of file3"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Test 1: When only one file is selected, there should be no compare option
select_path(&panel, "root/file1.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert_eq!(
selected_files, None,
"Should not have compare option when only one file is selected"
);
// Test 2: When multiple files are selected, there should be a compare option
select_path_with_mark(&panel, "root/file1.txt", cx);
select_path_with_mark(&panel, "root/file2.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Should have files selected for comparison"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file1.txt")
&& file2.to_string_lossy().ends_with("file2.txt"),
"Should have file1.txt and file2.txt as the selected files when multi-selecting"
);
}
// Test 3: Selecting a directory shouldn't count as a comparable file
select_path_with_mark(&panel, "root/dir1", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Directory selection should not affect comparable files"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file1.txt")
&& file2.to_string_lossy().ends_with("file2.txt"),
"Selecting a directory should not affect the number of comparable files"
);
}
// Test 4: Selecting one more file
select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Directory selection should not affect comparable files"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file2.txt")
&& file2.to_string_lossy().ends_with("file3.txt"),
"Selecting a directory should not affect the number of comparable files"
);
}
}
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
let path = path.as_ref();
panel.update(cx, |panel, cx| {
@ -5855,7 +6035,7 @@ fn select_path_with_mark(
entry_id,
};
if !panel.marked_entries.contains(&entry) {
panel.marked_entries.insert(entry);
panel.marked_entries.push(entry);
}
panel.selection = Some(entry);
return;

View file

@ -16,6 +16,7 @@ use serde_json::{Value, json};
use smallvec::SmallVec;
use std::{
any::{Any, TypeId, type_name},
env,
fmt::Debug,
ops::Range,
path::{Path, PathBuf},
@ -126,6 +127,8 @@ pub struct SettingsSources<'a, T> {
pub user: Option<&'a T>,
/// The user settings for the current release channel.
pub release_channel: Option<&'a T>,
/// The user settings for the current operating system.
pub operating_system: Option<&'a T>,
/// The settings associated with an enabled settings profile
pub profile: Option<&'a T>,
/// The server's settings.
@ -147,6 +150,7 @@ impl<'a, T: Serialize> SettingsSources<'a, T> {
.chain(self.extensions)
.chain(self.user)
.chain(self.release_channel)
.chain(self.operating_system)
.chain(self.profile)
.chain(self.server)
.chain(self.project.iter().copied())
@ -336,6 +340,11 @@ impl SettingsStore {
.log_err();
}
let mut os_settings_value = None;
if let Some(os_settings) = &self.raw_user_settings.get(env::consts::OS) {
os_settings_value = setting_value.deserialize_setting(os_settings).log_err();
}
let mut profile_value = None;
if let Some(active_profile) = cx.try_global::<ActiveSettingsProfileName>() {
if let Some(profiles) = self.raw_user_settings.get("profiles") {
@ -366,6 +375,7 @@ impl SettingsStore {
extensions: extension_value.as_ref(),
user: user_value.as_ref(),
release_channel: release_channel_value.as_ref(),
operating_system: os_settings_value.as_ref(),
profile: profile_value.as_ref(),
server: server_value.as_ref(),
project: &[],
@ -1092,7 +1102,7 @@ impl SettingsStore {
"$schema": meta_schema,
"title": "Zed Settings",
"unevaluatedProperties": false,
// ZedSettings + settings overrides for each release stage / profiles
// ZedSettings + settings overrides for each release stage / OS / profiles
"allOf": [
zed_settings_ref,
{
@ -1101,6 +1111,9 @@ impl SettingsStore {
"nightly": zed_settings_override_ref,
"stable": zed_settings_override_ref,
"preview": zed_settings_override_ref,
"linux": zed_settings_override_ref,
"macos": zed_settings_override_ref,
"windows": zed_settings_override_ref,
"profiles": {
"type": "object",
"description": "Configures any number of settings profiles.",
@ -1164,6 +1177,13 @@ impl SettingsStore {
}
}
let mut os_settings = None;
if let Some(settings) = &self.raw_user_settings.get(env::consts::OS) {
if let Some(settings) = setting_value.deserialize_setting(settings).log_err() {
os_settings = Some(settings);
}
}
let mut profile_settings = None;
if let Some(active_profile) = cx.try_global::<ActiveSettingsProfileName>() {
if let Some(profiles) = self.raw_user_settings.get("profiles") {
@ -1184,6 +1204,7 @@ impl SettingsStore {
extensions: extension_settings.as_ref(),
user: user_settings.as_ref(),
release_channel: release_channel_settings.as_ref(),
operating_system: os_settings.as_ref(),
profile: profile_settings.as_ref(),
server: server_settings.as_ref(),
project: &[],
@ -1237,6 +1258,7 @@ impl SettingsStore {
extensions: extension_settings.as_ref(),
user: user_settings.as_ref(),
release_channel: release_channel_settings.as_ref(),
operating_system: os_settings.as_ref(),
profile: profile_settings.as_ref(),
server: server_settings.as_ref(),
project: &project_settings_stack.iter().collect::<Vec<_>>(),
@ -1363,6 +1385,9 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
release_channel: values
.release_channel
.map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
operating_system: values
.operating_system
.map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
profile: values
.profile
.map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),

View file

@ -867,6 +867,7 @@ impl settings::Settings for ThemeSettings {
.user
.into_iter()
.chain(sources.release_channel)
.chain(sources.operating_system)
.chain(sources.profile)
.chain(sources.server)
{

View file

@ -1,6 +1,8 @@
use std::rc::Rc;
use gpui::{AnyView, ClickEvent};
use crate::{ButtonLike, ButtonLikeRounding, ElevationIndex, TintColor, prelude::*};
use crate::{ButtonLike, ButtonLikeRounding, ElevationIndex, TintColor, Tooltip, prelude::*};
/// The position of a [`ToggleButton`] within a group of buttons.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
@ -301,6 +303,7 @@ pub struct ButtonConfiguration {
icon: Option<IconName>,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
selected: bool,
tooltip: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
}
mod private {
@ -315,6 +318,7 @@ pub struct ToggleButtonSimple {
label: SharedString,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
selected: bool,
tooltip: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
}
impl ToggleButtonSimple {
@ -326,6 +330,7 @@ impl ToggleButtonSimple {
label: label.into(),
on_click: Box::new(on_click),
selected: false,
tooltip: None,
}
}
@ -333,6 +338,11 @@ impl ToggleButtonSimple {
self.selected = selected;
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Rc::new(tooltip));
self
}
}
impl private::ToggleButtonStyle for ToggleButtonSimple {}
@ -344,6 +354,7 @@ impl ButtonBuilder for ToggleButtonSimple {
icon: None,
on_click: self.on_click,
selected: self.selected,
tooltip: self.tooltip,
}
}
}
@ -353,6 +364,7 @@ pub struct ToggleButtonWithIcon {
icon: IconName,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
selected: bool,
tooltip: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
}
impl ToggleButtonWithIcon {
@ -366,6 +378,7 @@ impl ToggleButtonWithIcon {
icon,
on_click: Box::new(on_click),
selected: false,
tooltip: None,
}
}
@ -373,6 +386,11 @@ impl ToggleButtonWithIcon {
self.selected = selected;
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Rc::new(tooltip));
self
}
}
impl private::ToggleButtonStyle for ToggleButtonWithIcon {}
@ -384,6 +402,7 @@ impl ButtonBuilder for ToggleButtonWithIcon {
icon: Some(self.icon),
on_click: self.on_click,
selected: self.selected,
tooltip: self.tooltip,
}
}
}
@ -486,11 +505,13 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
icon,
on_click,
selected,
tooltip,
} = button.into_configuration();
let entry_index = row_index * COLS + col_index;
ButtonLike::new((self.group_name, entry_index))
.rounding(None)
.when_some(self.tab_index, |this, tab_index| {
this.tab_index(tab_index + entry_index as isize)
})
@ -498,7 +519,6 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
this.toggle_state(true)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
})
.rounding(None)
.when(self.style == ToggleButtonGroupStyle::Filled, |button| {
button.style(ButtonStyle::Filled)
})
@ -527,6 +547,9 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
|this| this.color(Color::Accent),
)),
)
.when_some(tooltip, |this, tooltip| {
this.tooltip(move |window, cx| tooltip(window, cx))
})
.on_click(on_click)
.into_any_element()
})
@ -920,6 +943,23 @@ impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> Component
),
],
)])
.children(vec![single_example(
"With Tooltips",
ToggleButtonGroup::single_row(
"with_tooltips",
[
ToggleButtonSimple::new("First", |_, _, _| {})
.tooltip(Tooltip::text("This is a tooltip. Hello!")),
ToggleButtonSimple::new("Second", |_, _, _| {})
.tooltip(Tooltip::text("This is a tooltip. Hey?")),
ToggleButtonSimple::new("Third", |_, _, _| {})
.tooltip(Tooltip::text("This is a tooltip. Get out of here now!")),
],
)
.selected_index(1)
.button_width(rems_from_px(100.))
.into_any_element(),
)])
.into_any_element(),
)
}

View file

@ -14,10 +14,10 @@ use crate::prelude::*;
#[strum(serialize_all = "snake_case")]
pub enum VectorName {
AiGrid,
CertifiedUserStamp,
DebuggerGrid,
Grid,
ProTrialStamp,
ProUserStamp,
ZedLogo,
ZedXCopilot,
}

View file

@ -62,7 +62,7 @@ pub struct SelectedEntry {
#[derive(Debug)]
pub struct DraggedSelection {
pub active_selection: SelectedEntry,
pub marked_selections: Arc<BTreeSet<SelectedEntry>>,
pub marked_selections: Arc<[SelectedEntry]>,
}
impl DraggedSelection {

View file

@ -1086,6 +1086,7 @@ pub struct Workspace {
follower_states: HashMap<CollaboratorId, FollowerState>,
last_leaders_by_pane: HashMap<WeakEntity<Pane>, CollaboratorId>,
window_edited: bool,
last_window_title: Option<String>,
dirty_items: HashMap<EntityId, Subscription>,
active_call: Option<(Entity<ActiveCall>, Vec<Subscription>)>,
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
@ -1418,6 +1419,7 @@ impl Workspace {
last_leaders_by_pane: Default::default(),
dispatching_keystrokes: Default::default(),
window_edited: false,
last_window_title: None,
dirty_items: Default::default(),
active_call,
database_id: workspace_id,
@ -4403,7 +4405,13 @@ impl Workspace {
title.push_str("");
}
if let Some(last_title) = self.last_window_title.as_ref() {
if &title == last_title {
return;
}
}
window.set_window_title(&title);
self.last_window_title = Some(title);
}
fn update_window_edited(&mut self, window: &mut Window, cx: &mut App) {