Merge branch 'read-file-tool' into edit-file-tool
This commit is contained in:
commit
a7bcc0f97a
55 changed files with 2142 additions and 426 deletions
5
.github/actionlint.yml
vendored
5
.github/actionlint.yml
vendored
|
@ -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
|
||||
|
|
2
.github/actions/build_docs/action.yml
vendored
2
.github/actions/build_docs/action.yml
vendored
|
@ -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}
|
||||
|
|
2
.github/workflows/bump_patch_version.yml
vendored
2
.github/workflows/bump_patch_version.yml
vendored
|
@ -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
|
||||
|
|
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
|
@ -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')
|
||||
|
|
2
.github/workflows/deploy_cloudflare.yml
vendored
2
.github/workflows/deploy_cloudflare.yml
vendored
|
@ -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
|
||||
|
|
6
.github/workflows/deploy_collab.yml
vendored
6
.github/workflows/deploy_collab.yml
vendored
|
@ -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}"
|
||||
|
|
4
.github/workflows/eval.yml
vendored
4
.github/workflows/eval.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/nix.yml
vendored
2
.github/workflows/nix.yml
vendored
|
@ -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]
|
||||
|
|
2
.github/workflows/randomized_tests.yml
vendored
2
.github/workflows/randomized_tests.yml
vendored
|
@ -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
|
||||
|
|
5
.github/workflows/release_nightly.yml
vendored
5
.github/workflows/release_nightly.yml
vendored
|
@ -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
|
||||
|
|
4
.github/workflows/unit_evals.yml
vendored
4
.github/workflows/unit_evals.yml
vendored
|
@ -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
7
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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 |
1
assets/images/pro_user_stamp.svg
Normal file
1
assets/images/pro_user_stamp.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.5 KiB |
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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>>,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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(
|
||||
|
|
970
crates/agent2/src/tools/read_file_tool.rs
Normal file
970
crates/agent2/src/tools/read_file_tool.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
48
crates/agent2/src/tools/thinking_tool.rs
Normal file
48
crates/agent2/src/tools/thinking_tool.rs
Normal 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()))
|
||||
}
|
||||
}
|
|
@ -45,6 +45,11 @@ impl<T> MessageHistory<T> {
|
|||
None
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn items(&self) -> &[T] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.),
|
||||
)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: ¬ify::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: ¬ify::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(¬ify::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(¬ify::Event) + Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl GlobalWatcher {
|
||||
#[must_use]
|
||||
fn add(
|
||||
&self,
|
||||
path: Arc<std::path::Path>,
|
||||
mode: notify::RecursiveMode,
|
||||
cb: impl Fn(¬ify::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(®istration_state.path) else {
|
||||
return;
|
||||
};
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
state.watcher.unwatch(®istration_state.path).log_err();
|
||||
state.path_registrations.remove(®istration_state.path);
|
||||
}
|
||||
pub(super) fn add(&self, cb: impl Fn(¬ify::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 = ®istration.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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,10 +14,10 @@ use crate::prelude::*;
|
|||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum VectorName {
|
||||
AiGrid,
|
||||
CertifiedUserStamp,
|
||||
DebuggerGrid,
|
||||
Grid,
|
||||
ProTrialStamp,
|
||||
ProUserStamp,
|
||||
ZedLogo,
|
||||
ZedXCopilot,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue