Compare commits
70 Commits
debugger-c
...
github-tok
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a5f55a05 | ||
|
|
dda614091a | ||
|
|
fa9da6ad5b | ||
|
|
d082cfdbec | ||
|
|
c71791d64e | ||
|
|
244d8517f1 | ||
|
|
3884de937b | ||
|
|
8af984ae70 | ||
|
|
9d533f9d30 | ||
|
|
274a40b7e0 | ||
|
|
9c7b1d19ce | ||
|
|
3d9881121f | ||
|
|
a2e98e9f0e | ||
|
|
7c64737e00 | ||
|
|
8191a5339d | ||
|
|
17c3b741ec | ||
|
|
52770cd3ad | ||
|
|
4ac67ac5ae | ||
|
|
8c1b549683 | ||
|
|
ff6ac60bad | ||
|
|
f8ab51307a | ||
|
|
0a2186c87b | ||
|
|
c3653f4cb1 | ||
|
|
8b28941c14 | ||
|
|
aefb798090 | ||
|
|
2c5aa5891d | ||
|
|
7d54d9f45e | ||
|
|
cde47e60cd | ||
|
|
79f96a5afe | ||
|
|
81058ee172 | ||
|
|
89743117c6 | ||
|
|
6de37fa57c | ||
|
|
beb0d49dc4 | ||
|
|
c9aadadc4b | ||
|
|
bcd182f480 | ||
|
|
3987b60738 | ||
|
|
827103908e | ||
|
|
8e9e3ba1a5 | ||
|
|
676ed8fb8a | ||
|
|
4304521655 | ||
|
|
04716a0e4a | ||
|
|
5e38915d45 | ||
|
|
f9257b0efe | ||
|
|
5d0c96872b | ||
|
|
071e684be4 | ||
|
|
2280594408 | ||
|
|
09a1d51e9a | ||
|
|
ac15194d11 | ||
|
|
988d834c33 | ||
|
|
48eacf3f2a | ||
|
|
030d4d2631 | ||
|
|
10df7b5eb9 | ||
|
|
55120c4231 | ||
|
|
8227c45a11 | ||
|
|
d23359e19a | ||
|
|
936ad0bf10 | ||
|
|
faa0bb51c9 | ||
|
|
2db2271e3c | ||
|
|
79b1dd7db8 | ||
|
|
81f8e2ed4a | ||
|
|
b9256dd469 | ||
|
|
27d3da678c | ||
|
|
03357f3f7b | ||
|
|
4aabba6cf6 | ||
|
|
8c46a4f594 | ||
|
|
522abe8e59 | ||
|
|
5ae8c4cf09 | ||
|
|
d8195a8fd7 | ||
|
|
2645591cd5 | ||
|
|
526a7c0702 |
26
.github/actions/build_docs/action.yml
vendored
Normal file
26
.github/actions/build_docs/action.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: "Build docs"
|
||||||
|
description: "Build the docs"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Setup mdBook
|
||||||
|
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
|
||||||
|
with:
|
||||||
|
mdbook-version: "0.4.37"
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
||||||
|
with:
|
||||||
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
cache-provider: "buildjet"
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
shell: bash -euxo pipefail {0}
|
||||||
|
run: ./script/linux
|
||||||
|
|
||||||
|
- name: Build book
|
||||||
|
shell: bash -euxo pipefail {0}
|
||||||
|
run: |
|
||||||
|
mkdir -p target/deploy
|
||||||
|
mdbook build ./docs --dest-dir=../target/deploy/docs/
|
||||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -191,6 +191,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
config: ./typos.toml
|
config: ./typos.toml
|
||||||
|
|
||||||
|
check_docs:
|
||||||
|
timeout-minutes: 60
|
||||||
|
name: Check docs
|
||||||
|
needs: [job_spec]
|
||||||
|
if: github.repository_owner == 'zed-industries'
|
||||||
|
runs-on:
|
||||||
|
- buildjet-8vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
with:
|
||||||
|
clean: false
|
||||||
|
|
||||||
|
- name: Configure CI
|
||||||
|
run: |
|
||||||
|
mkdir -p ./../.cargo
|
||||||
|
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
|
||||||
|
|
||||||
|
- name: Build docs
|
||||||
|
uses: ./.github/actions/build_docs
|
||||||
|
|
||||||
macos_tests:
|
macos_tests:
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
name: (macOS) Run Clippy and tests
|
name: (macOS) Run Clippy and tests
|
||||||
|
|||||||
19
.github/workflows/deploy_cloudflare.yml
vendored
19
.github/workflows/deploy_cloudflare.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
deploy-docs:
|
deploy-docs:
|
||||||
name: Deploy Docs
|
name: Deploy Docs
|
||||||
if: github.repository_owner == 'zed-industries'
|
if: github.repository_owner == 'zed-industries'
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
@@ -17,24 +17,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
clean: false
|
clean: false
|
||||||
|
|
||||||
- name: Setup mdBook
|
|
||||||
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
|
|
||||||
with:
|
|
||||||
mdbook-version: "0.4.37"
|
|
||||||
|
|
||||||
- name: Set up default .cargo/config.toml
|
- name: Set up default .cargo/config.toml
|
||||||
run: cp ./.cargo/collab-config.toml ./.cargo/config.toml
|
run: cp ./.cargo/collab-config.toml ./.cargo/config.toml
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Build docs
|
||||||
run: |
|
uses: ./.github/actions/build_docs
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install libxkbcommon-dev libxkbcommon-x11-dev
|
|
||||||
|
|
||||||
- name: Build book
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p target/deploy
|
|
||||||
mdbook build ./docs --dest-dir=../target/deploy/docs/
|
|
||||||
|
|
||||||
- name: Deploy Docs
|
- name: Deploy Docs
|
||||||
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
|
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
|
||||||
|
|||||||
85
.github/workflows/unit_evals.yml
vendored
Normal file
85
.github/workflows/unit_evals.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: Run Unit Evals
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub might drop jobs at busy times, so we choose a random time in the middle of the night.
|
||||||
|
- cron: "47 1 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Allow only one workflow per any non-`main` branch.
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit_evals:
|
||||||
|
timeout-minutes: 60
|
||||||
|
name: Run unit evals
|
||||||
|
runs-on:
|
||||||
|
- buildjet-16vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- name: Add Rust to the PATH
|
||||||
|
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
with:
|
||||||
|
clean: false
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
||||||
|
with:
|
||||||
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
cache-provider: "buildjet"
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
run: ./script/linux
|
||||||
|
|
||||||
|
- name: Configure CI
|
||||||
|
run: |
|
||||||
|
mkdir -p ./../.cargo
|
||||||
|
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
shell: bash -euxo pipefail {0}
|
||||||
|
run: |
|
||||||
|
cargo install cargo-nextest --locked
|
||||||
|
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
|
||||||
|
- name: Limit target directory size
|
||||||
|
shell: bash -euxo pipefail {0}
|
||||||
|
run: script/clear-target-dir-if-larger-than 100
|
||||||
|
|
||||||
|
- name: Run unit evals
|
||||||
|
shell: bash -euxo pipefail {0}
|
||||||
|
run: cargo nextest run --workspace --no-fail-fast --features eval --no-capture -E 'test(::eval_)' --test-threads 1
|
||||||
|
env:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
|
||||||
|
- name: Send the pull request link into the Slack channel
|
||||||
|
if: ${{ failure() }}
|
||||||
|
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52
|
||||||
|
with:
|
||||||
|
method: chat.postMessage
|
||||||
|
token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }}
|
||||||
|
payload: |
|
||||||
|
channel: C04UDRNNJFQ
|
||||||
|
text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}"
|
||||||
|
|
||||||
|
# Even the Linux runner is not stateful, in theory there is no need to do this cleanup.
|
||||||
|
# But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code
|
||||||
|
# to clean up the config file, I’ve included the cleanup code here as a precaution.
|
||||||
|
# While it’s not strictly necessary at this moment, I believe it’s better to err on the side of caution.
|
||||||
|
- name: Clean CI config file
|
||||||
|
if: always()
|
||||||
|
run: rm -rf ./../.cargo
|
||||||
6
.rules
6
.rules
@@ -5,6 +5,12 @@
|
|||||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||||
|
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||||
|
- Propagate errors with `?` when the calling function should handle them
|
||||||
|
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||||
|
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||||
|
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||||
|
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||||
* Never create files with `mod.rs` paths - prefer `src/some_module.rs` instead of `src/some_module/mod.rs`.
|
* Never create files with `mod.rs` paths - prefer `src/some_module.rs` instead of `src/some_module/mod.rs`.
|
||||||
|
|
||||||
# GPUI
|
# GPUI
|
||||||
|
|||||||
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -631,6 +631,7 @@ name = "assistant_tool"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-watch",
|
||||||
"buffer_diff",
|
"buffer_diff",
|
||||||
"clock",
|
"clock",
|
||||||
"collections",
|
"collections",
|
||||||
@@ -4542,6 +4543,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
"command_palette",
|
||||||
|
"gpui",
|
||||||
"mdbook",
|
"mdbook",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4549,6 +4552,7 @@ dependencies = [
|
|||||||
"settings",
|
"settings",
|
||||||
"util",
|
"util",
|
||||||
"workspace-hack",
|
"workspace-hack",
|
||||||
|
"zed",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12113,6 +12117,7 @@ dependencies = [
|
|||||||
"unindent",
|
"unindent",
|
||||||
"url",
|
"url",
|
||||||
"util",
|
"util",
|
||||||
|
"uuid",
|
||||||
"which 6.0.3",
|
"which 6.0.3",
|
||||||
"workspace-hack",
|
"workspace-hack",
|
||||||
"worktree",
|
"worktree",
|
||||||
@@ -16508,9 +16513,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tree-sitter"
|
name = "tree-sitter"
|
||||||
version = "0.25.5"
|
version = "0.25.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac5fff5c47490dfdf473b5228039bfacad9d765d9b6939d26bf7cc064c1c7822"
|
checksum = "a7cf18d43cbf0bfca51f657132cc616a5097edc4424d538bae6fa60142eaf9f0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -16523,9 +16528,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tree-sitter-bash"
|
name = "tree-sitter-bash"
|
||||||
version = "0.23.3"
|
version = "0.25.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
|
checksum = "871b0606e667e98a1237ebdc1b0d7056e0aebfdc3141d12b399865d4cb6ed8a6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"tree-sitter-language",
|
"tree-sitter-language",
|
||||||
@@ -17129,6 +17134,7 @@ dependencies = [
|
|||||||
"futures-lite 1.13.0",
|
"futures-lite 1.13.0",
|
||||||
"git2",
|
"git2",
|
||||||
"globset",
|
"globset",
|
||||||
|
"indoc",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -19706,7 +19712,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zed"
|
name = "zed"
|
||||||
version = "0.190.0"
|
version = "0.191.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activity_indicator",
|
"activity_indicator",
|
||||||
"agent",
|
"agent",
|
||||||
|
|||||||
@@ -574,8 +574,8 @@ tokio = { version = "1" }
|
|||||||
tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
|
tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tower-http = "0.4.4"
|
tower-http = "0.4.4"
|
||||||
tree-sitter = { version = "0.25.5", features = ["wasm"] }
|
tree-sitter = { version = "0.25.6", features = ["wasm"] }
|
||||||
tree-sitter-bash = "0.23"
|
tree-sitter-bash = "0.25.0"
|
||||||
tree-sitter-c = "0.23"
|
tree-sitter-c = "0.23"
|
||||||
tree-sitter-cpp = "0.23"
|
tree-sitter-cpp = "0.23"
|
||||||
tree-sitter-css = "0.23"
|
tree-sitter-css = "0.23"
|
||||||
|
|||||||
1
assets/icons/list_todo.svg
Normal file
1
assets/icons/list_todo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>
|
||||||
|
After Width: | Height: | Size: 373 B |
@@ -120,7 +120,7 @@
|
|||||||
"ctrl-'": "editor::ToggleSelectedDiffHunks",
|
"ctrl-'": "editor::ToggleSelectedDiffHunks",
|
||||||
"ctrl-\"": "editor::ExpandAllDiffHunks",
|
"ctrl-\"": "editor::ExpandAllDiffHunks",
|
||||||
"ctrl-i": "editor::ShowSignatureHelp",
|
"ctrl-i": "editor::ShowSignatureHelp",
|
||||||
"alt-g b": "editor::ToggleGitBlame",
|
"alt-g b": "git::Blame",
|
||||||
"menu": "editor::OpenContextMenu",
|
"menu": "editor::OpenContextMenu",
|
||||||
"shift-f10": "editor::OpenContextMenu",
|
"shift-f10": "editor::OpenContextMenu",
|
||||||
"ctrl-shift-e": "editor::ToggleEditPrediction",
|
"ctrl-shift-e": "editor::ToggleEditPrediction",
|
||||||
@@ -278,7 +278,9 @@
|
|||||||
"enter": "agent::Chat",
|
"enter": "agent::Chat",
|
||||||
"ctrl-enter": "agent::ChatWithFollow",
|
"ctrl-enter": "agent::ChatWithFollow",
|
||||||
"ctrl-i": "agent::ToggleProfileSelector",
|
"ctrl-i": "agent::ToggleProfileSelector",
|
||||||
"shift-ctrl-r": "agent::OpenAgentDiff"
|
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||||
|
"ctrl-shift-y": "agent::KeepAll",
|
||||||
|
"ctrl-shift-n": "agent::RejectAll"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -510,14 +512,14 @@
|
|||||||
{
|
{
|
||||||
"context": "Workspace",
|
"context": "Workspace",
|
||||||
"bindings": {
|
"bindings": {
|
||||||
|
"alt-open": ["projects::OpenRecent", { "create_new_window": false }],
|
||||||
// Change the default action on `menu::Confirm` by setting the parameter
|
// Change the default action on `menu::Confirm` by setting the parameter
|
||||||
// "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }],
|
// "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }],
|
||||||
"alt-open": "projects::OpenRecent",
|
"alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": false }],
|
||||||
"alt-ctrl-o": "projects::OpenRecent",
|
"alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
|
||||||
"alt-shift-open": "projects::OpenRemote",
|
|
||||||
"alt-ctrl-shift-o": "projects::OpenRemote",
|
|
||||||
// Change to open path modal for existing remote connection by setting the parameter
|
// Change to open path modal for existing remote connection by setting the parameter
|
||||||
// "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
|
// "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
|
||||||
|
"alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
|
||||||
"alt-ctrl-shift-b": "branches::OpenRecent",
|
"alt-ctrl-shift-b": "branches::OpenRecent",
|
||||||
"alt-shift-enter": "toast::RunAction",
|
"alt-shift-enter": "toast::RunAction",
|
||||||
"ctrl-~": "workspace::NewTerminal",
|
"ctrl-~": "workspace::NewTerminal",
|
||||||
@@ -909,7 +911,9 @@
|
|||||||
"context": "CollabPanel && not_editing",
|
"context": "CollabPanel && not_editing",
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"ctrl-backspace": "collab_panel::Remove",
|
"ctrl-backspace": "collab_panel::Remove",
|
||||||
"space": "menu::Confirm"
|
"space": "menu::Confirm",
|
||||||
|
"ctrl-up": "collab_panel::MoveChannelUp",
|
||||||
|
"ctrl-down": "collab_panel::MoveChannelDown"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
"cmd-;": "editor::ToggleLineNumbers",
|
"cmd-;": "editor::ToggleLineNumbers",
|
||||||
"cmd-'": "editor::ToggleSelectedDiffHunks",
|
"cmd-'": "editor::ToggleSelectedDiffHunks",
|
||||||
"cmd-\"": "editor::ExpandAllDiffHunks",
|
"cmd-\"": "editor::ExpandAllDiffHunks",
|
||||||
"cmd-alt-g b": "editor::ToggleGitBlame",
|
"cmd-alt-g b": "git::Blame",
|
||||||
"cmd-i": "editor::ShowSignatureHelp",
|
"cmd-i": "editor::ShowSignatureHelp",
|
||||||
"f9": "editor::ToggleBreakpoint",
|
"f9": "editor::ToggleBreakpoint",
|
||||||
"shift-f9": "editor::EditLogBreakpoint",
|
"shift-f9": "editor::EditLogBreakpoint",
|
||||||
@@ -315,7 +315,9 @@
|
|||||||
"enter": "agent::Chat",
|
"enter": "agent::Chat",
|
||||||
"cmd-enter": "agent::ChatWithFollow",
|
"cmd-enter": "agent::ChatWithFollow",
|
||||||
"cmd-i": "agent::ToggleProfileSelector",
|
"cmd-i": "agent::ToggleProfileSelector",
|
||||||
"shift-ctrl-r": "agent::OpenAgentDiff"
|
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||||
|
"cmd-shift-y": "agent::KeepAll",
|
||||||
|
"cmd-shift-n": "agent::RejectAll"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -582,9 +584,9 @@
|
|||||||
"bindings": {
|
"bindings": {
|
||||||
// Change the default action on `menu::Confirm` by setting the parameter
|
// Change the default action on `menu::Confirm` by setting the parameter
|
||||||
// "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }],
|
// "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }],
|
||||||
"alt-cmd-o": "projects::OpenRecent",
|
"alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }],
|
||||||
"ctrl-cmd-o": "projects::OpenRemote",
|
"ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
|
||||||
"ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true }],
|
"ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }],
|
||||||
"alt-cmd-b": "branches::OpenRecent",
|
"alt-cmd-b": "branches::OpenRecent",
|
||||||
"ctrl-~": "workspace::NewTerminal",
|
"ctrl-~": "workspace::NewTerminal",
|
||||||
"cmd-s": "workspace::Save",
|
"cmd-s": "workspace::Save",
|
||||||
@@ -965,7 +967,9 @@
|
|||||||
"use_key_equivalents": true,
|
"use_key_equivalents": true,
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"ctrl-backspace": "collab_panel::Remove",
|
"ctrl-backspace": "collab_panel::Remove",
|
||||||
"space": "menu::Confirm"
|
"space": "menu::Confirm",
|
||||||
|
"cmd-up": "collab_panel::MoveChannelUp",
|
||||||
|
"cmd-down": "collab_panel::MoveChannelDown"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -198,6 +198,8 @@
|
|||||||
"9": ["vim::Number", 9],
|
"9": ["vim::Number", 9],
|
||||||
"ctrl-w d": "editor::GoToDefinitionSplit",
|
"ctrl-w d": "editor::GoToDefinitionSplit",
|
||||||
"ctrl-w g d": "editor::GoToDefinitionSplit",
|
"ctrl-w g d": "editor::GoToDefinitionSplit",
|
||||||
|
"ctrl-w ]": "editor::GoToDefinitionSplit",
|
||||||
|
"ctrl-w ctrl-]": "editor::GoToDefinitionSplit",
|
||||||
"ctrl-w shift-d": "editor::GoToTypeDefinitionSplit",
|
"ctrl-w shift-d": "editor::GoToTypeDefinitionSplit",
|
||||||
"ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit",
|
"ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit",
|
||||||
"ctrl-w space": "editor::OpenExcerptsSplit",
|
"ctrl-w space": "editor::OpenExcerptsSplit",
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ You are a highly skilled software engineer with extensive knowledge in many prog
|
|||||||
4. Use only the tools that are currently available.
|
4. Use only the tools that are currently available.
|
||||||
5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off.
|
5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off.
|
||||||
6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers.
|
6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers.
|
||||||
|
7. Avoid HTML entity escaping - use plain characters instead.
|
||||||
|
|
||||||
## Searching and Reading
|
## Searching and Reading
|
||||||
|
|
||||||
If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions.
|
If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions.
|
||||||
|
|
||||||
{{! TODO: If there are files, we should mention it but otherwise omit that fact }}
|
{{! TODO: If there are files, we should mention it but otherwise omit that fact }}
|
||||||
{{#if has_tools}}
|
|
||||||
If appropriate, use tool calls to explore the current project, which contains the following root directories:
|
If appropriate, use tool calls to explore the current project, which contains the following root directories:
|
||||||
|
|
||||||
{{#each worktrees}}
|
{{#each worktrees}}
|
||||||
@@ -38,7 +38,6 @@ If appropriate, use tool calls to explore the current project, which contains th
|
|||||||
- As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project.
|
- As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project.
|
||||||
- The user might specify a partial file path. If you don't know the full path, use `find_path` (not `grep`) before you read the file.
|
- The user might specify a partial file path. If you don't know the full path, use `find_path` (not `grep`) before you read the file.
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system (other than any context the user might have provided to you).
|
You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system (other than any context the user might have provided to you).
|
||||||
|
|
||||||
|
|||||||
@@ -533,6 +533,9 @@
|
|||||||
"function": false
|
"function": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Whether to resize all the panels in a dock when resizing the dock.
|
||||||
|
// Can be a combination of "left", "right" and "bottom".
|
||||||
|
"resize_all_panels_in_dock": ["left"],
|
||||||
"project_panel": {
|
"project_panel": {
|
||||||
// Whether to show the project panel button in the status bar
|
// Whether to show the project panel button in the status bar
|
||||||
"button": true,
|
"button": true,
|
||||||
@@ -1497,11 +1500,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"LaTeX": {
|
"LaTeX": {
|
||||||
"format_on_save": "on",
|
|
||||||
"formatter": "language_server",
|
"formatter": "language_server",
|
||||||
"language_servers": ["texlab", "..."],
|
"language_servers": ["texlab", "..."],
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"allowed": false
|
"allowed": true,
|
||||||
|
"plugins": ["prettier-plugin-latex"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Markdown": {
|
"Markdown": {
|
||||||
@@ -1525,7 +1528,7 @@
|
|||||||
"allow_rewrap": "anywhere"
|
"allow_rewrap": "anywhere"
|
||||||
},
|
},
|
||||||
"Ruby": {
|
"Ruby": {
|
||||||
"language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "..."]
|
"language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."]
|
||||||
},
|
},
|
||||||
"SCSS": {
|
"SCSS": {
|
||||||
"prettier": {
|
"prettier": {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use crate::AgentPanel;
|
|
||||||
use crate::context::{AgentContextHandle, RULES_ICON};
|
use crate::context::{AgentContextHandle, RULES_ICON};
|
||||||
use crate::context_picker::{ContextPicker, MentionLink};
|
use crate::context_picker::{ContextPicker, MentionLink};
|
||||||
use crate::context_store::ContextStore;
|
use crate::context_store::ContextStore;
|
||||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||||
use crate::message_editor::insert_message_creases;
|
use crate::message_editor::{extract_message_creases, insert_message_creases};
|
||||||
use crate::thread::{
|
use crate::thread::{
|
||||||
LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
|
LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
|
||||||
ThreadEvent, ThreadFeedback, ThreadSummary,
|
ThreadEvent, ThreadFeedback, ThreadSummary,
|
||||||
@@ -13,6 +12,7 @@ use crate::tool_use::{PendingToolUseStatus, ToolUse};
|
|||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
AddedContext, AgentNotification, AgentNotificationEvent, AnimatedLabel, ContextPill,
|
AddedContext, AgentNotification, AgentNotificationEvent, AnimatedLabel, ContextPill,
|
||||||
};
|
};
|
||||||
|
use crate::{AgentPanel, ModelUsageContext};
|
||||||
use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
|
use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use assistant_tool::ToolUseStatus;
|
use assistant_tool::ToolUseStatus;
|
||||||
@@ -1348,6 +1348,7 @@ impl ActiveThread {
|
|||||||
Some(self.text_thread_store.downgrade()),
|
Some(self.text_thread_store.downgrade()),
|
||||||
context_picker_menu_handle.clone(),
|
context_picker_menu_handle.clone(),
|
||||||
SuggestContextKind::File,
|
SuggestContextKind::File,
|
||||||
|
ModelUsageContext::Thread(self.thread.clone()),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -1517,31 +1518,7 @@ impl ActiveThread {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context<Self>) {
|
fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let images = cx
|
attach_pasted_images_as_context(&self.context_store, cx);
|
||||||
.read_from_clipboard()
|
|
||||||
.map(|item| {
|
|
||||||
item.into_entries()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
if let ClipboardEntry::Image(image) = entry {
|
|
||||||
Some(image)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if images.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cx.stop_propagation();
|
|
||||||
|
|
||||||
self.context_store.update(cx, |store, cx| {
|
|
||||||
for image in images {
|
|
||||||
store.add_image_instance(Arc::new(image), cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_editing_message(
|
fn cancel_editing_message(
|
||||||
@@ -1586,6 +1563,8 @@ impl ActiveThread {
|
|||||||
|
|
||||||
let edited_text = state.editor.read(cx).text(cx);
|
let edited_text = state.editor.read(cx).text(cx);
|
||||||
|
|
||||||
|
let creases = state.editor.update(cx, extract_message_creases);
|
||||||
|
|
||||||
let new_context = self
|
let new_context = self
|
||||||
.context_store
|
.context_store
|
||||||
.read(cx)
|
.read(cx)
|
||||||
@@ -1610,6 +1589,7 @@ impl ActiveThread {
|
|||||||
message_id,
|
message_id,
|
||||||
Role::User,
|
Role::User,
|
||||||
vec![MessageSegment::Text(edited_text)],
|
vec![MessageSegment::Text(edited_text)],
|
||||||
|
creases,
|
||||||
Some(context.loaded_context),
|
Some(context.loaded_context),
|
||||||
checkpoint.ok(),
|
checkpoint.ok(),
|
||||||
cx,
|
cx,
|
||||||
@@ -1823,9 +1803,10 @@ impl ActiveThread {
|
|||||||
|
|
||||||
// Get all the data we need from thread before we start using it in closures
|
// Get all the data we need from thread before we start using it in closures
|
||||||
let checkpoint = thread.checkpoint_for_message(message_id);
|
let checkpoint = thread.checkpoint_for_message(message_id);
|
||||||
|
let configured_model = thread.configured_model().map(|m| m.model);
|
||||||
let added_context = thread
|
let added_context = thread
|
||||||
.context_for_message(message_id)
|
.context_for_message(message_id)
|
||||||
.map(|context| AddedContext::new_attached(context, cx))
|
.map(|context| AddedContext::new_attached(context, configured_model.as_ref(), cx))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let tool_uses = thread.tool_uses_for_message(message_id, cx);
|
let tool_uses = thread.tool_uses_for_message(message_id, cx);
|
||||||
@@ -3648,6 +3629,38 @@ pub(crate) fn open_context(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attach_pasted_images_as_context(
|
||||||
|
context_store: &Entity<ContextStore>,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> bool {
|
||||||
|
let images = cx
|
||||||
|
.read_from_clipboard()
|
||||||
|
.map(|item| {
|
||||||
|
item.into_entries()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
if let ClipboardEntry::Image(image) = entry {
|
||||||
|
Some(image)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if images.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cx.stop_propagation();
|
||||||
|
|
||||||
|
context_store.update(cx, |store, cx| {
|
||||||
|
for image in images {
|
||||||
|
store.add_image_instance(Arc::new(image), cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn open_editor_at_position(
|
fn open_editor_at_position(
|
||||||
project_path: project::ProjectPath,
|
project_path: project::ProjectPath,
|
||||||
target_position: Point,
|
target_position: Point,
|
||||||
@@ -3677,10 +3690,13 @@ fn open_editor_at_position(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use assistant_tool::{ToolRegistry, ToolWorkingSet};
|
use assistant_tool::{ToolRegistry, ToolWorkingSet};
|
||||||
use editor::EditorSettings;
|
use editor::{EditorSettings, display_map::CreaseMetadata};
|
||||||
use fs::FakeFs;
|
use fs::FakeFs;
|
||||||
use gpui::{AppContext, TestAppContext, VisualTestContext};
|
use gpui::{AppContext, TestAppContext, VisualTestContext};
|
||||||
use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
|
use language_model::{
|
||||||
|
ConfiguredModel, LanguageModel, LanguageModelRegistry,
|
||||||
|
fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
|
||||||
|
};
|
||||||
use project::Project;
|
use project::Project;
|
||||||
use prompt_store::PromptBuilder;
|
use prompt_store::PromptBuilder;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -3741,6 +3757,87 @@ mod tests {
|
|||||||
assert!(!cx.read(|cx| workspace.read(cx).is_being_followed(CollaboratorId::Agent)));
|
assert!(!cx.read(|cx| workspace.read(cx).is_being_followed(CollaboratorId::Agent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_reinserting_creases_for_edited_message(cx: &mut TestAppContext) {
|
||||||
|
init_test_settings(cx);
|
||||||
|
|
||||||
|
let project = create_test_project(cx, json!({})).await;
|
||||||
|
|
||||||
|
let (cx, active_thread, _, thread, model) =
|
||||||
|
setup_test_environment(cx, project.clone()).await;
|
||||||
|
cx.update(|_, cx| {
|
||||||
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
||||||
|
registry.set_default_model(
|
||||||
|
Some(ConfiguredModel {
|
||||||
|
provider: Arc::new(FakeLanguageModelProvider),
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let creases = vec![MessageCrease {
|
||||||
|
range: 14..22,
|
||||||
|
metadata: CreaseMetadata {
|
||||||
|
icon_path: "icon".into(),
|
||||||
|
label: "foo.txt".into(),
|
||||||
|
},
|
||||||
|
context: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let message = thread.update(cx, |thread, cx| {
|
||||||
|
let message_id = thread.insert_user_message(
|
||||||
|
"Tell me about @foo.txt",
|
||||||
|
ContextLoadResult::default(),
|
||||||
|
None,
|
||||||
|
creases,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
thread.message(message_id).cloned().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
active_thread.update_in(cx, |active_thread, window, cx| {
|
||||||
|
active_thread.start_editing_message(
|
||||||
|
message.id,
|
||||||
|
message.segments.as_slice(),
|
||||||
|
message.creases.as_slice(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let editor = active_thread
|
||||||
|
.editing_message
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.editor
|
||||||
|
.clone();
|
||||||
|
editor.update(cx, |editor, cx| editor.edit([(0..13, "modified")], cx));
|
||||||
|
active_thread.confirm_editing_message(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let message = thread.update(cx, |thread, _| thread.message(message.id).cloned().unwrap());
|
||||||
|
active_thread.update_in(cx, |active_thread, window, cx| {
|
||||||
|
active_thread.start_editing_message(
|
||||||
|
message.id,
|
||||||
|
message.segments.as_slice(),
|
||||||
|
message.creases.as_slice(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let editor = active_thread
|
||||||
|
.editing_message
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.editor
|
||||||
|
.clone();
|
||||||
|
let text = editor.update(cx, |editor, cx| editor.text(cx));
|
||||||
|
assert_eq!(text, "modified @foo.txt");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn init_test_settings(cx: &mut TestAppContext) {
|
fn init_test_settings(cx: &mut TestAppContext) {
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
let settings_store = SettingsStore::test(cx);
|
let settings_store = SettingsStore::test(cx);
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ use assistant_slash_command::SlashCommandRegistry;
|
|||||||
use client::Client;
|
use client::Client;
|
||||||
use feature_flags::FeatureFlagAppExt as _;
|
use feature_flags::FeatureFlagAppExt as _;
|
||||||
use fs::Fs;
|
use fs::Fs;
|
||||||
use gpui::{App, actions, impl_actions};
|
use gpui::{App, Entity, actions, impl_actions};
|
||||||
use language::LanguageRegistry;
|
use language::LanguageRegistry;
|
||||||
use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry};
|
use language_model::{
|
||||||
|
ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
|
||||||
|
};
|
||||||
use prompt_store::PromptBuilder;
|
use prompt_store::PromptBuilder;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -115,6 +117,28 @@ impl ManageProfiles {
|
|||||||
|
|
||||||
impl_actions!(agent, [NewThread, ManageProfiles]);
|
impl_actions!(agent, [NewThread, ManageProfiles]);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) enum ModelUsageContext {
|
||||||
|
Thread(Entity<Thread>),
|
||||||
|
InlineAssistant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelUsageContext {
|
||||||
|
pub fn configured_model(&self, cx: &App) -> Option<ConfiguredModel> {
|
||||||
|
match self {
|
||||||
|
Self::Thread(thread) => thread.read(cx).configured_model(),
|
||||||
|
Self::InlineAssistant => {
|
||||||
|
LanguageModelRegistry::read_global(cx).inline_assistant_model()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn language_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||||
|
self.configured_model(cx)
|
||||||
|
.map(|configured_model| configured_model.model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initializes the `agent` crate.
|
/// Initializes the `agent` crate.
|
||||||
pub fn init(
|
pub fn init(
|
||||||
fs: Arc<dyn Fs>,
|
fs: Arc<dyn Fs>,
|
||||||
|
|||||||
@@ -1086,7 +1086,7 @@ impl Render for AgentDiffToolbar {
|
|||||||
.child(vertical_divider())
|
.child(vertical_divider())
|
||||||
.when_some(editor.read(cx).workspace(), |this, _workspace| {
|
.when_some(editor.read(cx).workspace(), |this, _workspace| {
|
||||||
this.child(
|
this.child(
|
||||||
IconButton::new("review", IconName::ListCollapse)
|
IconButton::new("review", IconName::ListTodo)
|
||||||
.icon_size(IconSize::Small)
|
.icon_size(IconSize::Small)
|
||||||
.tooltip(Tooltip::for_action_title_in(
|
.tooltip(Tooltip::for_action_title_in(
|
||||||
"Review All Files",
|
"Review All Files",
|
||||||
@@ -1116,8 +1116,13 @@ impl Render for AgentDiffToolbar {
|
|||||||
return Empty.into_any();
|
return Empty.into_any();
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_generating = agent_diff.read(cx).thread.read(cx).is_generating();
|
let has_pending_edit_tool_use = agent_diff
|
||||||
if is_generating {
|
.read(cx)
|
||||||
|
.thread
|
||||||
|
.read(cx)
|
||||||
|
.has_pending_edit_tool_uses();
|
||||||
|
|
||||||
|
if has_pending_edit_tool_use {
|
||||||
return div().px_2().child(spinner_icon).into_any();
|
return div().px_2().child(spinner_icon).into_any();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1507,7 +1512,7 @@ impl AgentDiff {
|
|||||||
multibuffer.add_diff(diff_handle.clone(), cx);
|
multibuffer.add_diff(diff_handle.clone(), cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
let new_state = if thread.read(cx).is_generating() {
|
let new_state = if thread.read(cx).has_pending_edit_tool_uses() {
|
||||||
EditorState::Generating
|
EditorState::Generating
|
||||||
} else {
|
} else {
|
||||||
EditorState::Reviewing
|
EditorState::Reviewing
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use fs::Fs;
|
|||||||
use gpui::{Entity, FocusHandle, SharedString};
|
use gpui::{Entity, FocusHandle, SharedString};
|
||||||
use picker::popover_menu::PickerPopoverMenu;
|
use picker::popover_menu::PickerPopoverMenu;
|
||||||
|
|
||||||
use crate::Thread;
|
use crate::ModelUsageContext;
|
||||||
use assistant_context_editor::language_model_selector::{
|
use assistant_context_editor::language_model_selector::{
|
||||||
LanguageModelSelector, ToggleModelSelector, language_model_selector,
|
LanguageModelSelector, ToggleModelSelector, language_model_selector,
|
||||||
};
|
};
|
||||||
@@ -12,12 +12,6 @@ use settings::update_settings_file;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use ui::{PopoverMenuHandle, Tooltip, prelude::*};
|
use ui::{PopoverMenuHandle, Tooltip, prelude::*};
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum ModelType {
|
|
||||||
Default(Entity<Thread>),
|
|
||||||
InlineAssistant,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AgentModelSelector {
|
pub struct AgentModelSelector {
|
||||||
selector: Entity<LanguageModelSelector>,
|
selector: Entity<LanguageModelSelector>,
|
||||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||||
@@ -29,7 +23,7 @@ impl AgentModelSelector {
|
|||||||
fs: Arc<dyn Fs>,
|
fs: Arc<dyn Fs>,
|
||||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
model_type: ModelType,
|
model_usage_context: ModelUsageContext,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -38,19 +32,14 @@ impl AgentModelSelector {
|
|||||||
let fs = fs.clone();
|
let fs = fs.clone();
|
||||||
language_model_selector(
|
language_model_selector(
|
||||||
{
|
{
|
||||||
let model_type = model_type.clone();
|
let model_context = model_usage_context.clone();
|
||||||
move |cx| match &model_type {
|
move |cx| model_context.configured_model(cx)
|
||||||
ModelType::Default(thread) => thread.read(cx).configured_model(),
|
|
||||||
ModelType::InlineAssistant => {
|
|
||||||
LanguageModelRegistry::read_global(cx).inline_assistant_model()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
move |model, cx| {
|
move |model, cx| {
|
||||||
let provider = model.provider_id().0.to_string();
|
let provider = model.provider_id().0.to_string();
|
||||||
let model_id = model.id().0.to_string();
|
let model_id = model.id().0.to_string();
|
||||||
match &model_type {
|
match &model_usage_context {
|
||||||
ModelType::Default(thread) => {
|
ModelUsageContext::Thread(thread) => {
|
||||||
thread.update(cx, |thread, cx| {
|
thread.update(cx, |thread, cx| {
|
||||||
let registry = LanguageModelRegistry::read_global(cx);
|
let registry = LanguageModelRegistry::read_global(cx);
|
||||||
if let Some(provider) = registry.provider(&model.provider_id())
|
if let Some(provider) = registry.provider(&model.provider_id())
|
||||||
@@ -72,7 +61,7 @@ impl AgentModelSelector {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ModelType::InlineAssistant => {
|
ModelUsageContext::InlineAssistant => {
|
||||||
update_settings_file::<AgentSettings>(
|
update_settings_file::<AgentSettings>(
|
||||||
fs.clone(),
|
fs.clone(),
|
||||||
cx,
|
cx,
|
||||||
|
|||||||
@@ -745,6 +745,7 @@ pub struct ImageContext {
|
|||||||
pub enum ImageStatus {
|
pub enum ImageStatus {
|
||||||
Loading,
|
Loading,
|
||||||
Error,
|
Error,
|
||||||
|
Warning,
|
||||||
Ready,
|
Ready,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,11 +762,17 @@ impl ImageContext {
|
|||||||
self.image_task.clone().now_or_never().flatten()
|
self.image_task.clone().now_or_never().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> ImageStatus {
|
pub fn status(&self, model: Option<&Arc<dyn language_model::LanguageModel>>) -> ImageStatus {
|
||||||
match self.image_task.clone().now_or_never() {
|
match self.image_task.clone().now_or_never() {
|
||||||
None => ImageStatus::Loading,
|
None => ImageStatus::Loading,
|
||||||
Some(None) => ImageStatus::Error,
|
Some(None) => ImageStatus::Error,
|
||||||
Some(Some(_)) => ImageStatus::Ready,
|
Some(Some(_)) => {
|
||||||
|
if model.is_some_and(|model| !model.supports_images()) {
|
||||||
|
ImageStatus::Warning
|
||||||
|
} else {
|
||||||
|
ImageStatus::Ready
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -926,8 +926,9 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
|||||||
&self,
|
&self,
|
||||||
buffer: &Entity<language::Buffer>,
|
buffer: &Entity<language::Buffer>,
|
||||||
position: language::Anchor,
|
position: language::Anchor,
|
||||||
_: &str,
|
_text: &str,
|
||||||
_: bool,
|
_trigger_in_words: bool,
|
||||||
|
_menu_is_open: bool,
|
||||||
cx: &mut Context<Editor>,
|
cx: &mut Context<Editor>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let buffer = buffer.read(cx);
|
let buffer = buffer.read(cx);
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ impl Tool for ContextServerTool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
||||||
let mut schema = self.tool.input_schema.clone();
|
let mut schema = self.tool.input_schema.clone();
|
||||||
assistant_tool::adapt_schema_to_format(&mut schema, format)?;
|
assistant_tool::adapt_schema_to_format(&mut schema, format)?;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use crate::thread_store::{TextThreadStore, ThreadStore};
|
|||||||
use crate::ui::{AddedContext, ContextPill};
|
use crate::ui::{AddedContext, ContextPill};
|
||||||
use crate::{
|
use crate::{
|
||||||
AcceptSuggestedContext, AgentPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
|
AcceptSuggestedContext, AgentPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
|
||||||
RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
|
ModelUsageContext, RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ContextStrip {
|
pub struct ContextStrip {
|
||||||
@@ -37,6 +37,7 @@ pub struct ContextStrip {
|
|||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
focused_index: Option<usize>,
|
focused_index: Option<usize>,
|
||||||
children_bounds: Option<Vec<Bounds<Pixels>>>,
|
children_bounds: Option<Vec<Bounds<Pixels>>>,
|
||||||
|
model_usage_context: ModelUsageContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextStrip {
|
impl ContextStrip {
|
||||||
@@ -47,6 +48,7 @@ impl ContextStrip {
|
|||||||
text_thread_store: Option<WeakEntity<TextThreadStore>>,
|
text_thread_store: Option<WeakEntity<TextThreadStore>>,
|
||||||
context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
|
context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
|
||||||
suggest_context_kind: SuggestContextKind,
|
suggest_context_kind: SuggestContextKind,
|
||||||
|
model_usage_context: ModelUsageContext,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -81,6 +83,7 @@ impl ContextStrip {
|
|||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
focused_index: None,
|
focused_index: None,
|
||||||
children_bounds: None,
|
children_bounds: None,
|
||||||
|
model_usage_context,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,11 +101,20 @@ impl ContextStrip {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|thread_store| thread_store.upgrade())
|
.and_then(|thread_store| thread_store.upgrade())
|
||||||
.and_then(|thread_store| thread_store.read(cx).prompt_store().as_ref());
|
.and_then(|thread_store| thread_store.read(cx).prompt_store().as_ref());
|
||||||
|
|
||||||
|
let current_model = self.model_usage_context.language_model(cx);
|
||||||
|
|
||||||
self.context_store
|
self.context_store
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.context()
|
.context()
|
||||||
.flat_map(|context| {
|
.flat_map(|context| {
|
||||||
AddedContext::new_pending(context.clone(), prompt_store, project, cx)
|
AddedContext::new_pending(
|
||||||
|
context.clone(),
|
||||||
|
prompt_store,
|
||||||
|
project,
|
||||||
|
current_model.as_ref(),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::agent_model_selector::{AgentModelSelector, ModelType};
|
use crate::agent_model_selector::AgentModelSelector;
|
||||||
use crate::buffer_codegen::BufferCodegen;
|
use crate::buffer_codegen::BufferCodegen;
|
||||||
use crate::context::ContextCreasesAddon;
|
use crate::context::ContextCreasesAddon;
|
||||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
||||||
@@ -7,12 +7,13 @@ use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
|||||||
use crate::message_editor::{extract_message_creases, insert_message_creases};
|
use crate::message_editor::{extract_message_creases, insert_message_creases};
|
||||||
use crate::terminal_codegen::TerminalCodegen;
|
use crate::terminal_codegen::TerminalCodegen;
|
||||||
use crate::thread_store::{TextThreadStore, ThreadStore};
|
use crate::thread_store::{TextThreadStore, ThreadStore};
|
||||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist};
|
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
|
||||||
use crate::{RemoveAllContext, ToggleContextPicker};
|
use crate::{RemoveAllContext, ToggleContextPicker};
|
||||||
use assistant_context_editor::language_model_selector::ToggleModelSelector;
|
use assistant_context_editor::language_model_selector::ToggleModelSelector;
|
||||||
use client::ErrorExt;
|
use client::ErrorExt;
|
||||||
use collections::VecDeque;
|
use collections::VecDeque;
|
||||||
use db::kvp::Dismissable;
|
use db::kvp::Dismissable;
|
||||||
|
use editor::actions::Paste;
|
||||||
use editor::display_map::EditorMargins;
|
use editor::display_map::EditorMargins;
|
||||||
use editor::{
|
use editor::{
|
||||||
ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
|
ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
|
||||||
@@ -99,6 +100,7 @@ impl<T: 'static> Render for PromptEditor<T> {
|
|||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.key_context("PromptEditor")
|
.key_context("PromptEditor")
|
||||||
|
.capture_action(cx.listener(Self::paste))
|
||||||
.bg(cx.theme().colors().editor_background)
|
.bg(cx.theme().colors().editor_background)
|
||||||
.block_mouse_except_scroll()
|
.block_mouse_except_scroll()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
@@ -303,6 +305,10 @@ impl<T: 'static> PromptEditor<T> {
|
|||||||
self.editor.read(cx).text(cx)
|
self.editor.read(cx).text(cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx);
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle_rate_limit_notice(
|
fn toggle_rate_limit_notice(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &ClickEvent,
|
_: &ClickEvent,
|
||||||
@@ -912,6 +918,7 @@ impl PromptEditor<BufferCodegen> {
|
|||||||
text_thread_store.clone(),
|
text_thread_store.clone(),
|
||||||
context_picker_menu_handle.clone(),
|
context_picker_menu_handle.clone(),
|
||||||
SuggestContextKind::Thread,
|
SuggestContextKind::Thread,
|
||||||
|
ModelUsageContext::InlineAssistant,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -930,7 +937,7 @@ impl PromptEditor<BufferCodegen> {
|
|||||||
fs,
|
fs,
|
||||||
model_selector_menu_handle,
|
model_selector_menu_handle,
|
||||||
prompt_editor.focus_handle(cx),
|
prompt_editor.focus_handle(cx),
|
||||||
ModelType::InlineAssistant,
|
ModelUsageContext::InlineAssistant,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -1083,6 +1090,7 @@ impl PromptEditor<TerminalCodegen> {
|
|||||||
text_thread_store.clone(),
|
text_thread_store.clone(),
|
||||||
context_picker_menu_handle.clone(),
|
context_picker_menu_handle.clone(),
|
||||||
SuggestContextKind::Thread,
|
SuggestContextKind::Thread,
|
||||||
|
ModelUsageContext::InlineAssistant,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -1101,7 +1109,7 @@ impl PromptEditor<TerminalCodegen> {
|
|||||||
fs,
|
fs,
|
||||||
model_selector_menu_handle.clone(),
|
model_selector_menu_handle.clone(),
|
||||||
prompt_editor.focus_handle(cx),
|
prompt_editor.focus_handle(cx),
|
||||||
ModelType::InlineAssistant,
|
ModelUsageContext::InlineAssistant,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use std::collections::BTreeMap;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::agent_model_selector::{AgentModelSelector, ModelType};
|
use crate::agent_model_selector::AgentModelSelector;
|
||||||
use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
|
use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
|
||||||
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
|
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
AnimatedLabel, MaxModeTooltip,
|
MaxModeTooltip,
|
||||||
preview::{AgentPreview, UsageCallout},
|
preview::{AgentPreview, UsageCallout},
|
||||||
};
|
};
|
||||||
use agent_settings::{AgentSettings, CompletionMode};
|
use agent_settings::{AgentSettings, CompletionMode};
|
||||||
@@ -24,10 +24,10 @@ use fs::Fs;
|
|||||||
use futures::future::Shared;
|
use futures::future::Shared;
|
||||||
use futures::{FutureExt as _, future};
|
use futures::{FutureExt as _, future};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription,
|
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, Subscription, Task, TextStyle,
|
||||||
Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
|
WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
|
||||||
};
|
};
|
||||||
use language::{Buffer, Language};
|
use language::{Buffer, Language, Point};
|
||||||
use language_model::{
|
use language_model::{
|
||||||
ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage,
|
ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage,
|
||||||
ZED_CLOUD_PROVIDER_ID,
|
ZED_CLOUD_PROVIDER_ID,
|
||||||
@@ -51,9 +51,9 @@ use crate::profile_selector::ProfileSelector;
|
|||||||
use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
|
use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
|
||||||
use crate::thread_store::{TextThreadStore, ThreadStore};
|
use crate::thread_store::{TextThreadStore, ThreadStore};
|
||||||
use crate::{
|
use crate::{
|
||||||
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, NewThread,
|
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
|
||||||
OpenAgentDiff, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector,
|
ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode,
|
||||||
register_agent_preview,
|
ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(RegisterComponent)]
|
#[derive(RegisterComponent)]
|
||||||
@@ -169,6 +169,7 @@ impl MessageEditor {
|
|||||||
Some(text_thread_store.clone()),
|
Some(text_thread_store.clone()),
|
||||||
context_picker_menu_handle.clone(),
|
context_picker_menu_handle.clone(),
|
||||||
SuggestContextKind::File,
|
SuggestContextKind::File,
|
||||||
|
ModelUsageContext::Thread(thread.clone()),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -197,7 +198,7 @@ impl MessageEditor {
|
|||||||
fs.clone(),
|
fs.clone(),
|
||||||
model_selector_menu_handle,
|
model_selector_menu_handle,
|
||||||
editor.focus_handle(cx),
|
editor.focus_handle(cx),
|
||||||
ModelType::Default(thread.clone()),
|
ModelUsageContext::Thread(thread.clone()),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -431,39 +432,24 @@ impl MessageEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
|
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
let images = cx
|
crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx);
|
||||||
.read_from_clipboard()
|
|
||||||
.map(|item| {
|
|
||||||
item.into_entries()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
if let ClipboardEntry::Image(image) = entry {
|
|
||||||
Some(image)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if images.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cx.stop_propagation();
|
|
||||||
|
|
||||||
self.context_store.update(cx, |store, cx| {
|
|
||||||
for image in images {
|
|
||||||
store.add_image_instance(Arc::new(image), cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.thread.read(cx).has_pending_edit_tool_uses() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.edits_expanded = true;
|
self.edits_expanded = true;
|
||||||
AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
|
AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_edit_bar_expand(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.edits_expanded = !self.edits_expanded;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_file_click(
|
fn handle_file_click(
|
||||||
&self,
|
&self,
|
||||||
buffer: Entity<Buffer>,
|
buffer: Entity<Buffer>,
|
||||||
@@ -494,6 +480,40 @@ impl MessageEditor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_accept_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.thread.read(cx).has_pending_edit_tool_uses() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.thread.update(cx, |thread, cx| {
|
||||||
|
thread.keep_all_edits(cx);
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_reject_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.thread.read(cx).has_pending_edit_tool_uses() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since there's no reject_all_edits method in the thread API,
|
||||||
|
// we need to iterate through all buffers and reject their edits
|
||||||
|
let action_log = self.thread.read(cx).action_log().clone();
|
||||||
|
let changed_buffers = action_log.read(cx).changed_buffers(cx);
|
||||||
|
|
||||||
|
for (buffer, _) in changed_buffers {
|
||||||
|
self.thread.update(cx, |thread, cx| {
|
||||||
|
let buffer_snapshot = buffer.read(cx);
|
||||||
|
let start = buffer_snapshot.anchor_before(Point::new(0, 0));
|
||||||
|
let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point());
|
||||||
|
thread
|
||||||
|
.reject_edits_in_ranges(buffer, vec![start..end], cx)
|
||||||
|
.detach();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn render_max_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
fn render_max_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||||
let thread = self.thread.read(cx);
|
let thread = self.thread.read(cx);
|
||||||
let model = thread.configured_model();
|
let model = thread.configured_model();
|
||||||
@@ -615,6 +635,12 @@ impl MessageEditor {
|
|||||||
.on_action(cx.listener(Self::move_up))
|
.on_action(cx.listener(Self::move_up))
|
||||||
.on_action(cx.listener(Self::expand_message_editor))
|
.on_action(cx.listener(Self::expand_message_editor))
|
||||||
.on_action(cx.listener(Self::toggle_burn_mode))
|
.on_action(cx.listener(Self::toggle_burn_mode))
|
||||||
|
.on_action(
|
||||||
|
cx.listener(|this, _: &KeepAll, window, cx| this.handle_accept_all(window, cx)),
|
||||||
|
)
|
||||||
|
.on_action(
|
||||||
|
cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)),
|
||||||
|
)
|
||||||
.capture_action(cx.listener(Self::paste))
|
.capture_action(cx.listener(Self::paste))
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.p_2()
|
.p_2()
|
||||||
@@ -870,7 +896,10 @@ impl MessageEditor {
|
|||||||
let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
|
let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
|
||||||
|
|
||||||
let is_edit_changes_expanded = self.edits_expanded;
|
let is_edit_changes_expanded = self.edits_expanded;
|
||||||
let is_generating = self.thread.read(cx).is_generating();
|
let thread = self.thread.read(cx);
|
||||||
|
let pending_edits = thread.has_pending_edit_tool_uses();
|
||||||
|
|
||||||
|
const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.mt_1()
|
.mt_1()
|
||||||
@@ -888,31 +917,28 @@ impl MessageEditor {
|
|||||||
}])
|
}])
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.id("edits-container")
|
.p_1()
|
||||||
.cursor_pointer()
|
|
||||||
.p_1p5()
|
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.when(is_edit_changes_expanded, |this| {
|
.when(is_edit_changes_expanded, |this| {
|
||||||
this.border_b_1().border_color(border_color)
|
this.border_b_1().border_color(border_color)
|
||||||
})
|
})
|
||||||
.on_click(
|
|
||||||
cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.id("edits-container")
|
||||||
|
.cursor_pointer()
|
||||||
|
.w_full()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
Disclosure::new("edits-disclosure", is_edit_changes_expanded)
|
Disclosure::new("edits-disclosure", is_edit_changes_expanded)
|
||||||
.on_click(cx.listener(|this, _ev, _window, cx| {
|
.on_click(cx.listener(|this, _, _, cx| {
|
||||||
this.edits_expanded = !this.edits_expanded;
|
this.handle_edit_bar_expand(cx)
|
||||||
cx.notify();
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if is_generating {
|
if pending_edits {
|
||||||
this.child(
|
this.child(
|
||||||
AnimatedLabel::new(format!(
|
Label::new(format!(
|
||||||
"Editing {} {}",
|
"Editing {} {}…",
|
||||||
changed_buffers.len(),
|
changed_buffers.len(),
|
||||||
if changed_buffers.len() == 1 {
|
if changed_buffers.len() == 1 {
|
||||||
"file"
|
"file"
|
||||||
@@ -920,7 +946,15 @@ impl MessageEditor {
|
|||||||
"files"
|
"files"
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
.size(LabelSize::Small),
|
.color(Color::Muted)
|
||||||
|
.size(LabelSize::Small)
|
||||||
|
.with_animation(
|
||||||
|
"edit-label",
|
||||||
|
Animation::new(Duration::from_secs(2))
|
||||||
|
.repeat()
|
||||||
|
.with_easing(pulsating_between(0.3, 0.7)),
|
||||||
|
|label, delta| label.alpha(delta),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -945,23 +979,74 @@ impl MessageEditor {
|
|||||||
.color(Color::Muted),
|
.color(Color::Muted),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
|
.on_click(
|
||||||
|
cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("review", "Review Changes")
|
h_flex()
|
||||||
.label_size(LabelSize::Small)
|
.gap_1()
|
||||||
.key_binding(
|
.child(
|
||||||
KeyBinding::for_action_in(
|
IconButton::new("review-changes", IconName::ListTodo)
|
||||||
&OpenAgentDiff,
|
.icon_size(IconSize::Small)
|
||||||
&focus_handle,
|
.tooltip({
|
||||||
window,
|
let focus_handle = focus_handle.clone();
|
||||||
cx,
|
move |window, cx| {
|
||||||
)
|
Tooltip::for_action_in(
|
||||||
.map(|kb| kb.size(rems_from_px(12.))),
|
"Review Changes",
|
||||||
|
&OpenAgentDiff,
|
||||||
|
&focus_handle,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
|
this.handle_review_click(window, cx)
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
.on_click(cx.listener(|this, _, window, cx| {
|
.child(ui::Divider::vertical().color(ui::DividerColor::Border))
|
||||||
this.handle_review_click(window, cx)
|
.child(
|
||||||
})),
|
Button::new("reject-all-changes", "Reject All")
|
||||||
|
.label_size(LabelSize::Small)
|
||||||
|
.disabled(pending_edits)
|
||||||
|
.when(pending_edits, |this| {
|
||||||
|
this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
|
||||||
|
})
|
||||||
|
.key_binding(
|
||||||
|
KeyBinding::for_action_in(
|
||||||
|
&RejectAll,
|
||||||
|
&focus_handle.clone(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.map(|kb| kb.size(rems_from_px(10.))),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
|
this.handle_reject_all(window, cx)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("accept-all-changes", "Accept All")
|
||||||
|
.label_size(LabelSize::Small)
|
||||||
|
.disabled(pending_edits)
|
||||||
|
.when(pending_edits, |this| {
|
||||||
|
this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
|
||||||
|
})
|
||||||
|
.key_binding(
|
||||||
|
KeyBinding::for_action_in(
|
||||||
|
&KeepAll,
|
||||||
|
&focus_handle,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.map(|kb| kb.size(rems_from_px(10.))),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
|
this.handle_accept_all(window, cx)
|
||||||
|
})),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.when(is_edit_changes_expanded, |parent| {
|
.when(is_edit_changes_expanded, |parent| {
|
||||||
|
|||||||
@@ -871,7 +871,16 @@ impl Thread {
|
|||||||
self.tool_use
|
self.tool_use
|
||||||
.pending_tool_uses()
|
.pending_tool_uses()
|
||||||
.iter()
|
.iter()
|
||||||
.all(|tool_use| tool_use.status.is_error())
|
.all(|pending_tool_use| pending_tool_use.status.is_error())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether any pending tool uses may perform edits
|
||||||
|
pub fn has_pending_edit_tool_uses(&self) -> bool {
|
||||||
|
self.tool_use
|
||||||
|
.pending_tool_uses()
|
||||||
|
.iter()
|
||||||
|
.filter(|pending_tool_use| !pending_tool_use.status.is_error())
|
||||||
|
.any(|pending_tool_use| pending_tool_use.may_perform_edits)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
|
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
|
||||||
@@ -1023,6 +1032,7 @@ impl Thread {
|
|||||||
id: MessageId,
|
id: MessageId,
|
||||||
new_role: Role,
|
new_role: Role,
|
||||||
new_segments: Vec<MessageSegment>,
|
new_segments: Vec<MessageSegment>,
|
||||||
|
creases: Vec<MessageCrease>,
|
||||||
loaded_context: Option<LoadedContext>,
|
loaded_context: Option<LoadedContext>,
|
||||||
checkpoint: Option<GitStoreCheckpoint>,
|
checkpoint: Option<GitStoreCheckpoint>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
@@ -1032,6 +1042,7 @@ impl Thread {
|
|||||||
};
|
};
|
||||||
message.role = new_role;
|
message.role = new_role;
|
||||||
message.segments = new_segments;
|
message.segments = new_segments;
|
||||||
|
message.creases = creases;
|
||||||
if let Some(context) = loaded_context {
|
if let Some(context) = loaded_context {
|
||||||
message.loaded_context = context;
|
message.loaded_context = context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,15 @@ impl Column for DataType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const RULES_FILE_NAMES: [&'static str; 6] = [
|
const RULES_FILE_NAMES: [&'static str; 8] = [
|
||||||
".rules",
|
".rules",
|
||||||
".cursorrules",
|
".cursorrules",
|
||||||
".windsurfrules",
|
".windsurfrules",
|
||||||
".clinerules",
|
".clinerules",
|
||||||
".github/copilot-instructions.md",
|
".github/copilot-instructions.md",
|
||||||
"CLAUDE.md",
|
"CLAUDE.md",
|
||||||
|
"AGENT.md",
|
||||||
|
"AGENTS.md",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
|
|||||||
@@ -337,6 +337,12 @@ impl ToolUseState {
|
|||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
|
let may_perform_edits = self
|
||||||
|
.tools
|
||||||
|
.read(cx)
|
||||||
|
.tool(&tool_use.name, cx)
|
||||||
|
.is_some_and(|tool| tool.may_perform_edits());
|
||||||
|
|
||||||
self.pending_tool_uses_by_id.insert(
|
self.pending_tool_uses_by_id.insert(
|
||||||
tool_use.id.clone(),
|
tool_use.id.clone(),
|
||||||
PendingToolUse {
|
PendingToolUse {
|
||||||
@@ -345,6 +351,7 @@ impl ToolUseState {
|
|||||||
name: tool_use.name.clone(),
|
name: tool_use.name.clone(),
|
||||||
ui_text: ui_text.clone(),
|
ui_text: ui_text.clone(),
|
||||||
input: tool_use.input,
|
input: tool_use.input,
|
||||||
|
may_perform_edits,
|
||||||
status,
|
status,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -518,6 +525,7 @@ pub struct PendingToolUse {
|
|||||||
pub ui_text: Arc<str>,
|
pub ui_text: Arc<str>,
|
||||||
pub input: serde_json::Value,
|
pub input: serde_json::Value,
|
||||||
pub status: PendingToolUseStatus,
|
pub status: PendingToolUseStatus,
|
||||||
|
pub may_perform_edits: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -93,20 +93,9 @@ impl ContextPill {
|
|||||||
Self::Suggested {
|
Self::Suggested {
|
||||||
icon_path: Some(icon_path),
|
icon_path: Some(icon_path),
|
||||||
..
|
..
|
||||||
}
|
|
||||||
| Self::Added {
|
|
||||||
context:
|
|
||||||
AddedContext {
|
|
||||||
icon_path: Some(icon_path),
|
|
||||||
..
|
|
||||||
},
|
|
||||||
..
|
|
||||||
} => Icon::from_path(icon_path),
|
} => Icon::from_path(icon_path),
|
||||||
Self::Suggested { kind, .. }
|
Self::Suggested { kind, .. } => Icon::new(kind.icon()),
|
||||||
| Self::Added {
|
Self::Added { context, .. } => context.icon(),
|
||||||
context: AddedContext { kind, .. },
|
|
||||||
..
|
|
||||||
} => Icon::new(kind.icon()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +122,7 @@ impl RenderOnce for ContextPill {
|
|||||||
on_click,
|
on_click,
|
||||||
} => {
|
} => {
|
||||||
let status_is_error = matches!(context.status, ContextStatus::Error { .. });
|
let status_is_error = matches!(context.status, ContextStatus::Error { .. });
|
||||||
|
let status_is_warning = matches!(context.status, ContextStatus::Warning { .. });
|
||||||
|
|
||||||
base_pill
|
base_pill
|
||||||
.pr(if on_remove.is_some() { px(2.) } else { px(4.) })
|
.pr(if on_remove.is_some() { px(2.) } else { px(4.) })
|
||||||
@@ -140,6 +130,9 @@ impl RenderOnce for ContextPill {
|
|||||||
if status_is_error {
|
if status_is_error {
|
||||||
pill.bg(cx.theme().status().error_background)
|
pill.bg(cx.theme().status().error_background)
|
||||||
.border_color(cx.theme().status().error_border)
|
.border_color(cx.theme().status().error_border)
|
||||||
|
} else if status_is_warning {
|
||||||
|
pill.bg(cx.theme().status().warning_background)
|
||||||
|
.border_color(cx.theme().status().warning_border)
|
||||||
} else if *focused {
|
} else if *focused {
|
||||||
pill.bg(color.element_background)
|
pill.bg(color.element_background)
|
||||||
.border_color(color.border_focused)
|
.border_color(color.border_focused)
|
||||||
@@ -195,7 +188,8 @@ impl RenderOnce for ContextPill {
|
|||||||
|label, delta| label.opacity(delta),
|
|label, delta| label.opacity(delta),
|
||||||
)
|
)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
ContextStatus::Error { message } => element
|
ContextStatus::Warning { message }
|
||||||
|
| ContextStatus::Error { message } => element
|
||||||
.tooltip(ui::Tooltip::text(message.clone()))
|
.tooltip(ui::Tooltip::text(message.clone()))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
}),
|
}),
|
||||||
@@ -270,6 +264,7 @@ pub enum ContextStatus {
|
|||||||
Ready,
|
Ready,
|
||||||
Loading { message: SharedString },
|
Loading { message: SharedString },
|
||||||
Error { message: SharedString },
|
Error { message: SharedString },
|
||||||
|
Warning { message: SharedString },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(RegisterComponent)]
|
#[derive(RegisterComponent)]
|
||||||
@@ -285,6 +280,19 @@ pub struct AddedContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AddedContext {
|
impl AddedContext {
|
||||||
|
pub fn icon(&self) -> Icon {
|
||||||
|
match &self.status {
|
||||||
|
ContextStatus::Warning { .. } => Icon::new(IconName::Warning).color(Color::Warning),
|
||||||
|
ContextStatus::Error { .. } => Icon::new(IconName::XCircle).color(Color::Error),
|
||||||
|
_ => {
|
||||||
|
if let Some(icon_path) = &self.icon_path {
|
||||||
|
Icon::from_path(icon_path)
|
||||||
|
} else {
|
||||||
|
Icon::new(self.kind.icon())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
|
/// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
|
||||||
/// `None` if `DirectoryContext` or `RulesContext` no longer exist.
|
/// `None` if `DirectoryContext` or `RulesContext` no longer exist.
|
||||||
///
|
///
|
||||||
@@ -293,6 +301,7 @@ impl AddedContext {
|
|||||||
handle: AgentContextHandle,
|
handle: AgentContextHandle,
|
||||||
prompt_store: Option<&Entity<PromptStore>>,
|
prompt_store: Option<&Entity<PromptStore>>,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
|
model: Option<&Arc<dyn language_model::LanguageModel>>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Option<AddedContext> {
|
) -> Option<AddedContext> {
|
||||||
match handle {
|
match handle {
|
||||||
@@ -304,11 +313,15 @@ impl AddedContext {
|
|||||||
AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
|
AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
|
||||||
AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
|
AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
|
||||||
AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
|
AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
|
||||||
AgentContextHandle::Image(handle) => Some(Self::image(handle, cx)),
|
AgentContextHandle::Image(handle) => Some(Self::image(handle, model, cx)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_attached(context: &AgentContext, cx: &App) -> AddedContext {
|
pub fn new_attached(
|
||||||
|
context: &AgentContext,
|
||||||
|
model: Option<&Arc<dyn language_model::LanguageModel>>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AddedContext {
|
||||||
match context {
|
match context {
|
||||||
AgentContext::File(context) => Self::attached_file(context, cx),
|
AgentContext::File(context) => Self::attached_file(context, cx),
|
||||||
AgentContext::Directory(context) => Self::attached_directory(context),
|
AgentContext::Directory(context) => Self::attached_directory(context),
|
||||||
@@ -318,7 +331,7 @@ impl AddedContext {
|
|||||||
AgentContext::Thread(context) => Self::attached_thread(context),
|
AgentContext::Thread(context) => Self::attached_thread(context),
|
||||||
AgentContext::TextThread(context) => Self::attached_text_thread(context),
|
AgentContext::TextThread(context) => Self::attached_text_thread(context),
|
||||||
AgentContext::Rules(context) => Self::attached_rules(context),
|
AgentContext::Rules(context) => Self::attached_rules(context),
|
||||||
AgentContext::Image(context) => Self::image(context.clone(), cx),
|
AgentContext::Image(context) => Self::image(context.clone(), model, cx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +606,11 @@ impl AddedContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image(context: ImageContext, cx: &App) -> AddedContext {
|
fn image(
|
||||||
|
context: ImageContext,
|
||||||
|
model: Option<&Arc<dyn language_model::LanguageModel>>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AddedContext {
|
||||||
let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() {
|
let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() {
|
||||||
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
|
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
|
||||||
let (name, parent) =
|
let (name, parent) =
|
||||||
@@ -604,21 +621,30 @@ impl AddedContext {
|
|||||||
("Image".into(), None, None)
|
("Image".into(), None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let status = match context.status(model) {
|
||||||
|
ImageStatus::Loading => ContextStatus::Loading {
|
||||||
|
message: "Loading…".into(),
|
||||||
|
},
|
||||||
|
ImageStatus::Error => ContextStatus::Error {
|
||||||
|
message: "Failed to load Image".into(),
|
||||||
|
},
|
||||||
|
ImageStatus::Warning => ContextStatus::Warning {
|
||||||
|
message: format!(
|
||||||
|
"{} doesn't support attaching Images as Context",
|
||||||
|
model.map(|m| m.name().0).unwrap_or_else(|| "Model".into())
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
},
|
||||||
|
ImageStatus::Ready => ContextStatus::Ready,
|
||||||
|
};
|
||||||
|
|
||||||
AddedContext {
|
AddedContext {
|
||||||
kind: ContextKind::Image,
|
kind: ContextKind::Image,
|
||||||
name,
|
name,
|
||||||
parent,
|
parent,
|
||||||
tooltip: None,
|
tooltip: None,
|
||||||
icon_path,
|
icon_path,
|
||||||
status: match context.status() {
|
status,
|
||||||
ImageStatus::Loading => ContextStatus::Loading {
|
|
||||||
message: "Loading…".into(),
|
|
||||||
},
|
|
||||||
ImageStatus::Error => ContextStatus::Error {
|
|
||||||
message: "Failed to load image".into(),
|
|
||||||
},
|
|
||||||
ImageStatus::Ready => ContextStatus::Ready,
|
|
||||||
},
|
|
||||||
render_hover: Some(Rc::new({
|
render_hover: Some(Rc::new({
|
||||||
let image = context.original_image.clone();
|
let image = context.original_image.clone();
|
||||||
move |_, cx| {
|
move |_, cx| {
|
||||||
@@ -787,6 +813,7 @@ impl Component for AddedContext {
|
|||||||
original_image: Arc::new(Image::empty()),
|
original_image: Arc::new(Image::empty()),
|
||||||
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
|
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
cx,
|
cx,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -806,6 +833,7 @@ impl Component for AddedContext {
|
|||||||
})
|
})
|
||||||
.shared(),
|
.shared(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
cx,
|
cx,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -820,6 +848,7 @@ impl Component for AddedContext {
|
|||||||
original_image: Arc::new(Image::empty()),
|
original_image: Arc::new(Image::empty()),
|
||||||
image_task: Task::ready(None).shared(),
|
image_task: Task::ready(None).shared(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
cx,
|
cx,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -841,3 +870,60 @@ impl Component for AddedContext {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use gpui::App;
|
||||||
|
use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_image_context_warning_for_unsupported_model(cx: &mut App) {
|
||||||
|
let model: Arc<dyn LanguageModel> = Arc::new(FakeLanguageModel::default());
|
||||||
|
assert!(!model.supports_images());
|
||||||
|
|
||||||
|
let image_context = ImageContext {
|
||||||
|
context_id: ContextId::zero(),
|
||||||
|
project_path: None,
|
||||||
|
original_image: Arc::new(Image::empty()),
|
||||||
|
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
|
||||||
|
full_path: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let added_context = AddedContext::image(image_context, Some(&model), cx);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
added_context.status,
|
||||||
|
ContextStatus::Warning { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(added_context.kind, ContextKind::Image));
|
||||||
|
assert_eq!(added_context.name.as_ref(), "Image");
|
||||||
|
assert!(added_context.parent.is_none());
|
||||||
|
assert!(added_context.icon_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_image_context_ready_for_no_model(cx: &mut App) {
|
||||||
|
let image_context = ImageContext {
|
||||||
|
context_id: ContextId::zero(),
|
||||||
|
project_path: None,
|
||||||
|
original_image: Arc::new(Image::empty()),
|
||||||
|
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
|
||||||
|
full_path: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let added_context = AddedContext::image(image_context, None, cx);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(added_context.status, ContextStatus::Ready),
|
||||||
|
"Expected ready status when no model provided"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(added_context.kind, ContextKind::Image));
|
||||||
|
assert_eq!(added_context.name.as_ref(), "Image");
|
||||||
|
assert!(added_context.parent.is_none());
|
||||||
|
assert!(added_context.icon_path.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
|
|||||||
position: language::Anchor,
|
position: language::Anchor,
|
||||||
_text: &str,
|
_text: &str,
|
||||||
_trigger_in_words: bool,
|
_trigger_in_words: bool,
|
||||||
|
_menu_is_open: bool,
|
||||||
cx: &mut Context<Editor>,
|
cx: &mut Context<Editor>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let buffer = buffer.read(cx);
|
let buffer = buffer.read(cx);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ path = "src/assistant_tool.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
async-watch.workspace = true
|
||||||
buffer_diff.workspace = true
|
buffer_diff.workspace = true
|
||||||
clock.workspace = true
|
clock.workspace = true
|
||||||
collections.workspace = true
|
collections.workspace = true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use buffer_diff::BufferDiff;
|
use buffer_diff::BufferDiff;
|
||||||
use collections::BTreeMap;
|
use collections::BTreeMap;
|
||||||
use futures::{StreamExt, channel::mpsc};
|
use futures::{FutureExt, StreamExt, channel::mpsc};
|
||||||
use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
|
use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
|
||||||
use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
|
use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
|
||||||
use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
|
use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
|
||||||
@@ -92,21 +92,21 @@ impl ActionLog {
|
|||||||
let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
|
let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
|
||||||
let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
|
let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
|
||||||
let diff_base;
|
let diff_base;
|
||||||
let unreviewed_changes;
|
let unreviewed_edits;
|
||||||
if is_created {
|
if is_created {
|
||||||
diff_base = Rope::default();
|
diff_base = Rope::default();
|
||||||
unreviewed_changes = Patch::new(vec![Edit {
|
unreviewed_edits = Patch::new(vec![Edit {
|
||||||
old: 0..1,
|
old: 0..1,
|
||||||
new: 0..text_snapshot.max_point().row + 1,
|
new: 0..text_snapshot.max_point().row + 1,
|
||||||
}])
|
}])
|
||||||
} else {
|
} else {
|
||||||
diff_base = buffer.read(cx).as_rope().clone();
|
diff_base = buffer.read(cx).as_rope().clone();
|
||||||
unreviewed_changes = Patch::default();
|
unreviewed_edits = Patch::default();
|
||||||
}
|
}
|
||||||
TrackedBuffer {
|
TrackedBuffer {
|
||||||
buffer: buffer.clone(),
|
buffer: buffer.clone(),
|
||||||
diff_base,
|
diff_base,
|
||||||
unreviewed_changes,
|
unreviewed_edits: unreviewed_edits,
|
||||||
snapshot: text_snapshot.clone(),
|
snapshot: text_snapshot.clone(),
|
||||||
status,
|
status,
|
||||||
version: buffer.read(cx).version(),
|
version: buffer.read(cx).version(),
|
||||||
@@ -175,7 +175,7 @@ impl ActionLog {
|
|||||||
.map_or(false, |file| file.disk_state() != DiskState::Deleted)
|
.map_or(false, |file| file.disk_state() != DiskState::Deleted)
|
||||||
{
|
{
|
||||||
// If the buffer had been deleted by a tool, but it got
|
// If the buffer had been deleted by a tool, but it got
|
||||||
// resurrected externally, we want to clear the changes we
|
// resurrected externally, we want to clear the edits we
|
||||||
// were tracking and reset the buffer's state.
|
// were tracking and reset the buffer's state.
|
||||||
self.tracked_buffers.remove(&buffer);
|
self.tracked_buffers.remove(&buffer);
|
||||||
self.track_buffer_internal(buffer, false, cx);
|
self.track_buffer_internal(buffer, false, cx);
|
||||||
@@ -188,108 +188,274 @@ impl ActionLog {
|
|||||||
async fn maintain_diff(
|
async fn maintain_diff(
|
||||||
this: WeakEntity<Self>,
|
this: WeakEntity<Self>,
|
||||||
buffer: Entity<Buffer>,
|
buffer: Entity<Buffer>,
|
||||||
mut diff_update: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
|
mut buffer_updates: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
|
||||||
cx: &mut AsyncApp,
|
cx: &mut AsyncApp,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
while let Some((author, buffer_snapshot)) = diff_update.next().await {
|
let git_store = this.read_with(cx, |this, cx| this.project.read(cx).git_store().clone())?;
|
||||||
let (rebase, diff, language, language_registry) =
|
let git_diff = this
|
||||||
this.read_with(cx, |this, cx| {
|
.update(cx, |this, cx| {
|
||||||
let tracked_buffer = this
|
this.project.update(cx, |project, cx| {
|
||||||
.tracked_buffers
|
project.open_uncommitted_diff(buffer.clone(), cx)
|
||||||
.get(&buffer)
|
})
|
||||||
.context("buffer not tracked")?;
|
})?
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
let buffer_repo = git_store.read_with(cx, |git_store, cx| {
|
||||||
|
git_store.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
|
||||||
|
})?;
|
||||||
|
|
||||||
let rebase = cx.background_spawn({
|
let (git_diff_updates_tx, mut git_diff_updates_rx) = async_watch::channel(());
|
||||||
let mut base_text = tracked_buffer.diff_base.clone();
|
let _repo_subscription =
|
||||||
let old_snapshot = tracked_buffer.snapshot.clone();
|
if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) {
|
||||||
let new_snapshot = buffer_snapshot.clone();
|
cx.update(|cx| {
|
||||||
let unreviewed_changes = tracked_buffer.unreviewed_changes.clone();
|
let mut old_head = buffer_repo.read(cx).head_commit.clone();
|
||||||
async move {
|
Some(cx.subscribe(git_diff, move |_, event, cx| match event {
|
||||||
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
|
buffer_diff::BufferDiffEvent::DiffChanged { .. } => {
|
||||||
if let ChangeAuthor::User = author {
|
let new_head = buffer_repo.read(cx).head_commit.clone();
|
||||||
apply_non_conflicting_edits(
|
if new_head != old_head {
|
||||||
&unreviewed_changes,
|
old_head = new_head;
|
||||||
edits,
|
git_diff_updates_tx.send(()).ok();
|
||||||
&mut base_text,
|
|
||||||
new_snapshot.as_rope(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
(Arc::new(base_text.to_string()), base_text)
|
|
||||||
}
|
}
|
||||||
});
|
_ => {}
|
||||||
|
}))
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
anyhow::Ok((
|
loop {
|
||||||
rebase,
|
futures::select_biased! {
|
||||||
tracked_buffer.diff.clone(),
|
buffer_update = buffer_updates.next() => {
|
||||||
tracked_buffer.buffer.read(cx).language().cloned(),
|
if let Some((author, buffer_snapshot)) = buffer_update {
|
||||||
tracked_buffer.buffer.read(cx).language_registry(),
|
Self::track_edits(&this, &buffer, author, buffer_snapshot, cx).await?;
|
||||||
))
|
} else {
|
||||||
})??;
|
break;
|
||||||
|
}
|
||||||
let (new_base_text, new_diff_base) = rebase.await;
|
}
|
||||||
let diff_snapshot = BufferDiff::update_diff(
|
_ = git_diff_updates_rx.changed().fuse() => {
|
||||||
diff.clone(),
|
if let Some(git_diff) = git_diff.as_ref() {
|
||||||
buffer_snapshot.clone(),
|
Self::keep_committed_edits(&this, &buffer, &git_diff, cx).await?;
|
||||||
Some(new_base_text),
|
}
|
||||||
true,
|
}
|
||||||
false,
|
|
||||||
language,
|
|
||||||
language_registry,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut unreviewed_changes = Patch::default();
|
|
||||||
if let Ok(diff_snapshot) = diff_snapshot {
|
|
||||||
unreviewed_changes = cx
|
|
||||||
.background_spawn({
|
|
||||||
let diff_snapshot = diff_snapshot.clone();
|
|
||||||
let buffer_snapshot = buffer_snapshot.clone();
|
|
||||||
let new_diff_base = new_diff_base.clone();
|
|
||||||
async move {
|
|
||||||
let mut unreviewed_changes = Patch::default();
|
|
||||||
for hunk in diff_snapshot.hunks_intersecting_range(
|
|
||||||
Anchor::MIN..Anchor::MAX,
|
|
||||||
&buffer_snapshot,
|
|
||||||
) {
|
|
||||||
let old_range = new_diff_base
|
|
||||||
.offset_to_point(hunk.diff_base_byte_range.start)
|
|
||||||
..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end);
|
|
||||||
let new_range = hunk.range.start..hunk.range.end;
|
|
||||||
unreviewed_changes.push(point_to_row_edit(
|
|
||||||
Edit {
|
|
||||||
old: old_range,
|
|
||||||
new: new_range,
|
|
||||||
},
|
|
||||||
&new_diff_base,
|
|
||||||
&buffer_snapshot.as_rope(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
unreviewed_changes
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
diff.update(cx, |diff, cx| {
|
|
||||||
diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
let tracked_buffer = this
|
|
||||||
.tracked_buffers
|
|
||||||
.get_mut(&buffer)
|
|
||||||
.context("buffer not tracked")?;
|
|
||||||
tracked_buffer.diff_base = new_diff_base;
|
|
||||||
tracked_buffer.snapshot = buffer_snapshot;
|
|
||||||
tracked_buffer.unreviewed_changes = unreviewed_changes;
|
|
||||||
cx.notify();
|
|
||||||
anyhow::Ok(())
|
|
||||||
})??;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn track_edits(
|
||||||
|
this: &WeakEntity<ActionLog>,
|
||||||
|
buffer: &Entity<Buffer>,
|
||||||
|
author: ChangeAuthor,
|
||||||
|
buffer_snapshot: text::BufferSnapshot,
|
||||||
|
cx: &mut AsyncApp,
|
||||||
|
) -> Result<()> {
|
||||||
|
let rebase = this.read_with(cx, |this, cx| {
|
||||||
|
let tracked_buffer = this
|
||||||
|
.tracked_buffers
|
||||||
|
.get(buffer)
|
||||||
|
.context("buffer not tracked")?;
|
||||||
|
|
||||||
|
let rebase = cx.background_spawn({
|
||||||
|
let mut base_text = tracked_buffer.diff_base.clone();
|
||||||
|
let old_snapshot = tracked_buffer.snapshot.clone();
|
||||||
|
let new_snapshot = buffer_snapshot.clone();
|
||||||
|
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
|
||||||
|
async move {
|
||||||
|
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
|
||||||
|
if let ChangeAuthor::User = author {
|
||||||
|
apply_non_conflicting_edits(
|
||||||
|
&unreviewed_edits,
|
||||||
|
edits,
|
||||||
|
&mut base_text,
|
||||||
|
new_snapshot.as_rope(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Arc::new(base_text.to_string()), base_text)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
anyhow::Ok(rebase)
|
||||||
|
})??;
|
||||||
|
let (new_base_text, new_diff_base) = rebase.await;
|
||||||
|
Self::update_diff(
|
||||||
|
this,
|
||||||
|
buffer,
|
||||||
|
buffer_snapshot,
|
||||||
|
new_base_text,
|
||||||
|
new_diff_base,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn keep_committed_edits(
|
||||||
|
this: &WeakEntity<ActionLog>,
|
||||||
|
buffer: &Entity<Buffer>,
|
||||||
|
git_diff: &Entity<BufferDiff>,
|
||||||
|
cx: &mut AsyncApp,
|
||||||
|
) -> Result<()> {
|
||||||
|
let buffer_snapshot = this.read_with(cx, |this, _cx| {
|
||||||
|
let tracked_buffer = this
|
||||||
|
.tracked_buffers
|
||||||
|
.get(buffer)
|
||||||
|
.context("buffer not tracked")?;
|
||||||
|
anyhow::Ok(tracked_buffer.snapshot.clone())
|
||||||
|
})??;
|
||||||
|
let (new_base_text, new_diff_base) = this
|
||||||
|
.read_with(cx, |this, cx| {
|
||||||
|
let tracked_buffer = this
|
||||||
|
.tracked_buffers
|
||||||
|
.get(buffer)
|
||||||
|
.context("buffer not tracked")?;
|
||||||
|
let old_unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
|
||||||
|
let agent_diff_base = tracked_buffer.diff_base.clone();
|
||||||
|
let git_diff_base = git_diff.read(cx).base_text().as_rope().clone();
|
||||||
|
let buffer_text = tracked_buffer.snapshot.as_rope().clone();
|
||||||
|
anyhow::Ok(cx.background_spawn(async move {
|
||||||
|
let mut old_unreviewed_edits = old_unreviewed_edits.into_iter().peekable();
|
||||||
|
let committed_edits = language::line_diff(
|
||||||
|
&agent_diff_base.to_string(),
|
||||||
|
&git_diff_base.to_string(),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(old, new)| Edit { old, new });
|
||||||
|
|
||||||
|
let mut new_agent_diff_base = agent_diff_base.clone();
|
||||||
|
let mut row_delta = 0i32;
|
||||||
|
for committed in committed_edits {
|
||||||
|
while let Some(unreviewed) = old_unreviewed_edits.peek() {
|
||||||
|
// If the committed edit matches the unreviewed
|
||||||
|
// edit, assume the user wants to keep it.
|
||||||
|
if committed.old == unreviewed.old {
|
||||||
|
let unreviewed_new =
|
||||||
|
buffer_text.slice_rows(unreviewed.new.clone()).to_string();
|
||||||
|
let committed_new =
|
||||||
|
git_diff_base.slice_rows(committed.new.clone()).to_string();
|
||||||
|
if unreviewed_new == committed_new {
|
||||||
|
let old_byte_start =
|
||||||
|
new_agent_diff_base.point_to_offset(Point::new(
|
||||||
|
(unreviewed.old.start as i32 + row_delta) as u32,
|
||||||
|
0,
|
||||||
|
));
|
||||||
|
let old_byte_end =
|
||||||
|
new_agent_diff_base.point_to_offset(cmp::min(
|
||||||
|
Point::new(
|
||||||
|
(unreviewed.old.end as i32 + row_delta) as u32,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
new_agent_diff_base.max_point(),
|
||||||
|
));
|
||||||
|
new_agent_diff_base
|
||||||
|
.replace(old_byte_start..old_byte_end, &unreviewed_new);
|
||||||
|
row_delta +=
|
||||||
|
unreviewed.new_len() as i32 - unreviewed.old_len() as i32;
|
||||||
|
}
|
||||||
|
} else if unreviewed.old.start >= committed.old.end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
old_unreviewed_edits.next().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
Arc::new(new_agent_diff_base.to_string()),
|
||||||
|
new_agent_diff_base,
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
})??
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Self::update_diff(
|
||||||
|
this,
|
||||||
|
buffer,
|
||||||
|
buffer_snapshot,
|
||||||
|
new_base_text,
|
||||||
|
new_diff_base,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_diff(
|
||||||
|
this: &WeakEntity<ActionLog>,
|
||||||
|
buffer: &Entity<Buffer>,
|
||||||
|
buffer_snapshot: text::BufferSnapshot,
|
||||||
|
new_base_text: Arc<String>,
|
||||||
|
new_diff_base: Rope,
|
||||||
|
cx: &mut AsyncApp,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (diff, language, language_registry) = this.read_with(cx, |this, cx| {
|
||||||
|
let tracked_buffer = this
|
||||||
|
.tracked_buffers
|
||||||
|
.get(buffer)
|
||||||
|
.context("buffer not tracked")?;
|
||||||
|
anyhow::Ok((
|
||||||
|
tracked_buffer.diff.clone(),
|
||||||
|
buffer.read(cx).language().cloned(),
|
||||||
|
buffer.read(cx).language_registry().clone(),
|
||||||
|
))
|
||||||
|
})??;
|
||||||
|
let diff_snapshot = BufferDiff::update_diff(
|
||||||
|
diff.clone(),
|
||||||
|
buffer_snapshot.clone(),
|
||||||
|
Some(new_base_text),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
language,
|
||||||
|
language_registry,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut unreviewed_edits = Patch::default();
|
||||||
|
if let Ok(diff_snapshot) = diff_snapshot {
|
||||||
|
unreviewed_edits = cx
|
||||||
|
.background_spawn({
|
||||||
|
let diff_snapshot = diff_snapshot.clone();
|
||||||
|
let buffer_snapshot = buffer_snapshot.clone();
|
||||||
|
let new_diff_base = new_diff_base.clone();
|
||||||
|
async move {
|
||||||
|
let mut unreviewed_edits = Patch::default();
|
||||||
|
for hunk in diff_snapshot
|
||||||
|
.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer_snapshot)
|
||||||
|
{
|
||||||
|
let old_range = new_diff_base
|
||||||
|
.offset_to_point(hunk.diff_base_byte_range.start)
|
||||||
|
..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end);
|
||||||
|
let new_range = hunk.range.start..hunk.range.end;
|
||||||
|
unreviewed_edits.push(point_to_row_edit(
|
||||||
|
Edit {
|
||||||
|
old: old_range,
|
||||||
|
new: new_range,
|
||||||
|
},
|
||||||
|
&new_diff_base,
|
||||||
|
&buffer_snapshot.as_rope(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
unreviewed_edits
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
diff.update(cx, |diff, cx| {
|
||||||
|
diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
let tracked_buffer = this
|
||||||
|
.tracked_buffers
|
||||||
|
.get_mut(buffer)
|
||||||
|
.context("buffer not tracked")?;
|
||||||
|
tracked_buffer.diff_base = new_diff_base;
|
||||||
|
tracked_buffer.snapshot = buffer_snapshot;
|
||||||
|
tracked_buffer.unreviewed_edits = unreviewed_edits;
|
||||||
|
cx.notify();
|
||||||
|
anyhow::Ok(())
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
/// Track a buffer as read, so we can notify the model about user edits.
|
/// Track a buffer as read, so we can notify the model about user edits.
|
||||||
pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
|
pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
|
||||||
self.track_buffer_internal(buffer, false, cx);
|
self.track_buffer_internal(buffer, false, cx);
|
||||||
@@ -350,7 +516,7 @@ impl ActionLog {
|
|||||||
buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
|
buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
|
||||||
let mut delta = 0i32;
|
let mut delta = 0i32;
|
||||||
|
|
||||||
tracked_buffer.unreviewed_changes.retain_mut(|edit| {
|
tracked_buffer.unreviewed_edits.retain_mut(|edit| {
|
||||||
edit.old.start = (edit.old.start as i32 + delta) as u32;
|
edit.old.start = (edit.old.start as i32 + delta) as u32;
|
||||||
edit.old.end = (edit.old.end as i32 + delta) as u32;
|
edit.old.end = (edit.old.end as i32 + delta) as u32;
|
||||||
|
|
||||||
@@ -461,7 +627,7 @@ impl ActionLog {
|
|||||||
.project
|
.project
|
||||||
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
|
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
|
||||||
|
|
||||||
// Clear all tracked changes for this buffer and start over as if we just read it.
|
// Clear all tracked edits for this buffer and start over as if we just read it.
|
||||||
self.tracked_buffers.remove(&buffer);
|
self.tracked_buffers.remove(&buffer);
|
||||||
self.buffer_read(buffer.clone(), cx);
|
self.buffer_read(buffer.clone(), cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -477,7 +643,7 @@ impl ActionLog {
|
|||||||
.peekable();
|
.peekable();
|
||||||
|
|
||||||
let mut edits_to_revert = Vec::new();
|
let mut edits_to_revert = Vec::new();
|
||||||
for edit in tracked_buffer.unreviewed_changes.edits() {
|
for edit in tracked_buffer.unreviewed_edits.edits() {
|
||||||
let new_range = tracked_buffer
|
let new_range = tracked_buffer
|
||||||
.snapshot
|
.snapshot
|
||||||
.anchor_before(Point::new(edit.new.start, 0))
|
.anchor_before(Point::new(edit.new.start, 0))
|
||||||
@@ -529,7 +695,7 @@ impl ActionLog {
|
|||||||
.retain(|_buffer, tracked_buffer| match tracked_buffer.status {
|
.retain(|_buffer, tracked_buffer| match tracked_buffer.status {
|
||||||
TrackedBufferStatus::Deleted => false,
|
TrackedBufferStatus::Deleted => false,
|
||||||
_ => {
|
_ => {
|
||||||
tracked_buffer.unreviewed_changes.clear();
|
tracked_buffer.unreviewed_edits.clear();
|
||||||
tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
|
tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
|
||||||
tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
|
tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
|
||||||
true
|
true
|
||||||
@@ -538,11 +704,11 @@ impl ActionLog {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the set of buffers that contain changes that haven't been reviewed by the user.
|
/// Returns the set of buffers that contain edits that haven't been reviewed by the user.
|
||||||
pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, Entity<BufferDiff>> {
|
pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, Entity<BufferDiff>> {
|
||||||
self.tracked_buffers
|
self.tracked_buffers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, tracked)| tracked.has_changes(cx))
|
.filter(|(_, tracked)| tracked.has_edits(cx))
|
||||||
.map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
|
.map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -662,11 +828,7 @@ fn point_to_row_edit(edit: Edit<Point>, old_text: &Rope, new_text: &Rope) -> Edi
|
|||||||
old: edit.old.start.row + 1..edit.old.end.row + 1,
|
old: edit.old.start.row + 1..edit.old.end.row + 1,
|
||||||
new: edit.new.start.row + 1..edit.new.end.row + 1,
|
new: edit.new.start.row + 1..edit.new.end.row + 1,
|
||||||
}
|
}
|
||||||
} else if edit.old.start.column == 0
|
} else if edit.old.start.column == 0 && edit.old.end.column == 0 && edit.new.end.column == 0 {
|
||||||
&& edit.old.end.column == 0
|
|
||||||
&& edit.new.end.column == 0
|
|
||||||
&& edit.old.end != old_text.max_point()
|
|
||||||
{
|
|
||||||
Edit {
|
Edit {
|
||||||
old: edit.old.start.row..edit.old.end.row,
|
old: edit.old.start.row..edit.old.end.row,
|
||||||
new: edit.new.start.row..edit.new.end.row,
|
new: edit.new.start.row..edit.new.end.row,
|
||||||
@@ -694,7 +856,7 @@ enum TrackedBufferStatus {
|
|||||||
struct TrackedBuffer {
|
struct TrackedBuffer {
|
||||||
buffer: Entity<Buffer>,
|
buffer: Entity<Buffer>,
|
||||||
diff_base: Rope,
|
diff_base: Rope,
|
||||||
unreviewed_changes: Patch<u32>,
|
unreviewed_edits: Patch<u32>,
|
||||||
status: TrackedBufferStatus,
|
status: TrackedBufferStatus,
|
||||||
version: clock::Global,
|
version: clock::Global,
|
||||||
diff: Entity<BufferDiff>,
|
diff: Entity<BufferDiff>,
|
||||||
@@ -706,7 +868,7 @@ struct TrackedBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TrackedBuffer {
|
impl TrackedBuffer {
|
||||||
fn has_changes(&self, cx: &App) -> bool {
|
fn has_edits(&self, cx: &App) -> bool {
|
||||||
self.diff
|
self.diff
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.hunks(&self.buffer.read(cx), cx)
|
.hunks(&self.buffer.read(cx), cx)
|
||||||
@@ -727,8 +889,6 @@ pub struct ChangedBuffer {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::env;
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use buffer_diff::DiffHunkStatusKind;
|
use buffer_diff::DiffHunkStatusKind;
|
||||||
use gpui::TestAppContext;
|
use gpui::TestAppContext;
|
||||||
@@ -737,6 +897,7 @@ mod tests {
|
|||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
|
use std::env;
|
||||||
use util::{RandomCharIter, path};
|
use util::{RandomCharIter, path};
|
||||||
|
|
||||||
#[ctor::ctor]
|
#[ctor::ctor]
|
||||||
@@ -1751,15 +1912,15 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let is_agent_change = rng.gen_bool(0.5);
|
let is_agent_edit = rng.gen_bool(0.5);
|
||||||
if is_agent_change {
|
if is_agent_edit {
|
||||||
log::info!("agent edit");
|
log::info!("agent edit");
|
||||||
} else {
|
} else {
|
||||||
log::info!("user edit");
|
log::info!("user edit");
|
||||||
}
|
}
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
|
buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
|
||||||
if is_agent_change {
|
if is_agent_edit {
|
||||||
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
|
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1784,7 +1945,7 @@ mod tests {
|
|||||||
let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap();
|
let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap();
|
||||||
let mut old_text = tracked_buffer.diff_base.clone();
|
let mut old_text = tracked_buffer.diff_base.clone();
|
||||||
let new_text = buffer.read(cx).as_rope();
|
let new_text = buffer.read(cx).as_rope();
|
||||||
for edit in tracked_buffer.unreviewed_changes.edits() {
|
for edit in tracked_buffer.unreviewed_edits.edits() {
|
||||||
let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
|
let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
|
||||||
let old_end = old_text.point_to_offset(cmp::min(
|
let old_end = old_text.point_to_offset(cmp::min(
|
||||||
Point::new(edit.new.start + edit.old_len(), 0),
|
Point::new(edit.new.start + edit.old_len(), 0),
|
||||||
@@ -1800,6 +1961,171 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_keep_edits_on_commit(cx: &mut gpui::TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.background_executor.clone());
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/project"),
|
||||||
|
json!({
|
||||||
|
".git": {},
|
||||||
|
"file.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
fs.set_head_for_repo(
|
||||||
|
path!("/project/.git").as_ref(),
|
||||||
|
&[("file.txt".into(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())],
|
||||||
|
"0000000",
|
||||||
|
);
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||||
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||||
|
|
||||||
|
let file_path = project
|
||||||
|
.read_with(cx, |project, cx| {
|
||||||
|
project.find_project_path(path!("/project/file.txt"), cx)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let buffer = project
|
||||||
|
.update(cx, |project, cx| project.open_buffer(file_path, cx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
|
||||||
|
buffer.update(cx, |buffer, cx| {
|
||||||
|
buffer.edit(
|
||||||
|
[
|
||||||
|
// Edit at the very start: a -> A
|
||||||
|
(Point::new(0, 0)..Point::new(0, 1), "A"),
|
||||||
|
// Deletion in the middle: remove lines d and e
|
||||||
|
(Point::new(3, 0)..Point::new(5, 0), ""),
|
||||||
|
// Modification: g -> GGG
|
||||||
|
(Point::new(6, 0)..Point::new(6, 1), "GGG"),
|
||||||
|
// Addition: insert new line after h
|
||||||
|
(Point::new(7, 1)..Point::new(7, 1), "\nNEW"),
|
||||||
|
// Edit the very last character: j -> J
|
||||||
|
(Point::new(9, 0)..Point::new(9, 1), "J"),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
unreviewed_hunks(&action_log, cx),
|
||||||
|
vec![(
|
||||||
|
buffer.clone(),
|
||||||
|
vec![
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(0, 0)..Point::new(1, 0),
|
||||||
|
diff_status: DiffHunkStatusKind::Modified,
|
||||||
|
old_text: "a\n".into()
|
||||||
|
},
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(3, 0)..Point::new(3, 0),
|
||||||
|
diff_status: DiffHunkStatusKind::Deleted,
|
||||||
|
old_text: "d\ne\n".into()
|
||||||
|
},
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(4, 0)..Point::new(5, 0),
|
||||||
|
diff_status: DiffHunkStatusKind::Modified,
|
||||||
|
old_text: "g\n".into()
|
||||||
|
},
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(6, 0)..Point::new(7, 0),
|
||||||
|
diff_status: DiffHunkStatusKind::Added,
|
||||||
|
old_text: "".into()
|
||||||
|
},
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(8, 0)..Point::new(8, 1),
|
||||||
|
diff_status: DiffHunkStatusKind::Modified,
|
||||||
|
old_text: "j".into()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate a git commit that matches some edits but not others:
|
||||||
|
// - Accepts the first edit (a -> A)
|
||||||
|
// - Accepts the deletion (remove d and e)
|
||||||
|
// - Makes a different change to g (g -> G instead of GGG)
|
||||||
|
// - Ignores the NEW line addition
|
||||||
|
// - Ignores the last line edit (j stays as j)
|
||||||
|
fs.set_head_for_repo(
|
||||||
|
path!("/project/.git").as_ref(),
|
||||||
|
&[("file.txt".into(), "A\nb\nc\nf\nG\nh\ni\nj".into())],
|
||||||
|
"0000001",
|
||||||
|
);
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
unreviewed_hunks(&action_log, cx),
|
||||||
|
vec![(
|
||||||
|
buffer.clone(),
|
||||||
|
vec![
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(4, 0)..Point::new(5, 0),
|
||||||
|
diff_status: DiffHunkStatusKind::Modified,
|
||||||
|
old_text: "g\n".into()
|
||||||
|
},
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(6, 0)..Point::new(7, 0),
|
||||||
|
diff_status: DiffHunkStatusKind::Added,
|
||||||
|
old_text: "".into()
|
||||||
|
},
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(8, 0)..Point::new(8, 1),
|
||||||
|
diff_status: DiffHunkStatusKind::Modified,
|
||||||
|
old_text: "j".into()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make another commit that accepts the NEW line but with different content
|
||||||
|
fs.set_head_for_repo(
|
||||||
|
path!("/project/.git").as_ref(),
|
||||||
|
&[(
|
||||||
|
"file.txt".into(),
|
||||||
|
"A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into(),
|
||||||
|
)],
|
||||||
|
"0000002",
|
||||||
|
);
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
unreviewed_hunks(&action_log, cx),
|
||||||
|
vec![(
|
||||||
|
buffer.clone(),
|
||||||
|
vec![
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(6, 0)..Point::new(7, 0),
|
||||||
|
diff_status: DiffHunkStatusKind::Added,
|
||||||
|
old_text: "".into()
|
||||||
|
},
|
||||||
|
HunkStatus {
|
||||||
|
range: Point::new(8, 0)..Point::new(8, 1),
|
||||||
|
diff_status: DiffHunkStatusKind::Modified,
|
||||||
|
old_text: "j".into()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Final commit that accepts all remaining edits
|
||||||
|
fs.set_head_for_repo(
|
||||||
|
path!("/project/.git").as_ref(),
|
||||||
|
&[("file.txt".into(), "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())],
|
||||||
|
"0000003",
|
||||||
|
);
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct HunkStatus {
|
struct HunkStatus {
|
||||||
range: Range<Point>,
|
range: Range<Point>,
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ pub trait Tool: 'static + Send + Sync {
|
|||||||
/// before having permission to run.
|
/// before having permission to run.
|
||||||
fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool;
|
fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool;
|
||||||
|
|
||||||
|
/// Returns true if the tool may perform edits.
|
||||||
|
fn may_perform_edits(&self) -> bool;
|
||||||
|
|
||||||
/// Returns the JSON schema that describes the tool's input.
|
/// Returns the JSON schema that describes the tool's input.
|
||||||
fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
||||||
Ok(serde_json::Value::Object(serde_json::Map::default()))
|
Ok(serde_json::Value::Object(serde_json::Map::default()))
|
||||||
|
|||||||
@@ -16,11 +16,24 @@ pub fn adapt_schema_to_format(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match format {
|
match format {
|
||||||
LanguageModelToolSchemaFormat::JsonSchema => Ok(()),
|
LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json),
|
||||||
LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json),
|
LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn preprocess_json_schema(json: &mut Value) -> Result<()> {
|
||||||
|
// `additionalProperties` defaults to `false` unless explicitly specified.
|
||||||
|
// This prevents models from hallucinating tool parameters.
|
||||||
|
if let Value::Object(obj) = json {
|
||||||
|
if let Some(Value::String(type_str)) = obj.get("type") {
|
||||||
|
if type_str == "object" && !obj.contains_key("additionalProperties") {
|
||||||
|
obj.insert("additionalProperties".to_string(), Value::Bool(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Tries to adapt the json schema so that it is compatible with https://ai.google.dev/api/caching#Schema
|
/// Tries to adapt the json schema so that it is compatible with https://ai.google.dev/api/caching#Schema
|
||||||
fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
|
fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
|
||||||
if let Value::Object(obj) = json {
|
if let Value::Object(obj) = json {
|
||||||
@@ -237,4 +250,59 @@ mod tests {
|
|||||||
|
|
||||||
assert!(adapt_to_json_schema_subset(&mut json).is_err());
|
assert!(adapt_to_json_schema_subset(&mut json).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preprocess_json_schema_adds_additional_properties() {
|
||||||
|
let mut json = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
preprocess_json_schema(&mut json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preprocess_json_schema_preserves_additional_properties() {
|
||||||
|
let mut json = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
});
|
||||||
|
|
||||||
|
preprocess_json_schema(&mut json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ use crate::diagnostics_tool::DiagnosticsTool;
|
|||||||
use crate::edit_file_tool::EditFileTool;
|
use crate::edit_file_tool::EditFileTool;
|
||||||
use crate::fetch_tool::FetchTool;
|
use crate::fetch_tool::FetchTool;
|
||||||
use crate::find_path_tool::FindPathTool;
|
use crate::find_path_tool::FindPathTool;
|
||||||
use crate::grep_tool::GrepTool;
|
|
||||||
use crate::list_directory_tool::ListDirectoryTool;
|
use crate::list_directory_tool::ListDirectoryTool;
|
||||||
use crate::now_tool::NowTool;
|
use crate::now_tool::NowTool;
|
||||||
use crate::thinking_tool::ThinkingTool;
|
use crate::thinking_tool::ThinkingTool;
|
||||||
|
|
||||||
pub use edit_file_tool::{EditFileMode, EditFileToolInput};
|
pub use edit_file_tool::{EditFileMode, EditFileToolInput};
|
||||||
pub use find_path_tool::FindPathToolInput;
|
pub use find_path_tool::FindPathToolInput;
|
||||||
|
pub use grep_tool::{GrepTool, GrepToolInput};
|
||||||
pub use open_tool::OpenTool;
|
pub use open_tool::OpenTool;
|
||||||
pub use read_file_tool::{ReadFileTool, ReadFileToolInput};
|
pub use read_file_tool::{ReadFileTool, ReadFileToolInput};
|
||||||
pub use terminal_tool::TerminalTool;
|
pub use terminal_tool::TerminalTool;
|
||||||
@@ -126,6 +126,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["location"],
|
"required": ["location"],
|
||||||
|
"additionalProperties": false
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ impl Tool for CopyPathTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./copy_path_tool/description.md").into()
|
include_str!("./copy_path_tool/description.md").into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,16 @@ impl Tool for CreateDirectoryTool {
|
|||||||
"create_directory".into()
|
"create_directory".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
include_str!("./create_directory_tool/description.md").into()
|
||||||
|
}
|
||||||
|
|
||||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn may_perform_edits(&self) -> bool {
|
||||||
include_str!("./create_directory_tool/description.md").into()
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon(&self) -> IconName {
|
fn icon(&self) -> IconName {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ impl Tool for DeletePathTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./delete_path_tool/description.md").into()
|
include_str!("./delete_path_tool/description.md").into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ impl Tool for DiagnosticsTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./diagnostics_tool/description.md").into()
|
include_str!("./diagnostics_tool/description.md").into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ impl Template for EditFilePromptTemplate {
|
|||||||
pub enum EditAgentOutputEvent {
|
pub enum EditAgentOutputEvent {
|
||||||
ResolvingEditRange(Range<Anchor>),
|
ResolvingEditRange(Range<Anchor>),
|
||||||
UnresolvedEditRange,
|
UnresolvedEditRange,
|
||||||
|
AmbiguousEditRange(Vec<Range<usize>>),
|
||||||
Edited,
|
Edited,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,16 +270,29 @@ impl EditAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (edit_events_, resolved_old_text) = resolve_old_text.await?;
|
let (edit_events_, mut resolved_old_text) = resolve_old_text.await?;
|
||||||
edit_events = edit_events_;
|
edit_events = edit_events_;
|
||||||
|
|
||||||
// If we can't resolve the old text, restart the loop waiting for a
|
// If we can't resolve the old text, restart the loop waiting for a
|
||||||
// new edit (or for the stream to end).
|
// new edit (or for the stream to end).
|
||||||
let Some(resolved_old_text) = resolved_old_text else {
|
let resolved_old_text = match resolved_old_text.len() {
|
||||||
output_events
|
1 => resolved_old_text.pop().unwrap(),
|
||||||
.unbounded_send(EditAgentOutputEvent::UnresolvedEditRange)
|
0 => {
|
||||||
.ok();
|
output_events
|
||||||
continue;
|
.unbounded_send(EditAgentOutputEvent::UnresolvedEditRange)
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let ranges = resolved_old_text
|
||||||
|
.into_iter()
|
||||||
|
.map(|text| text.range)
|
||||||
|
.collect();
|
||||||
|
output_events
|
||||||
|
.unbounded_send(EditAgentOutputEvent::AmbiguousEditRange(ranges))
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compute edits in the background and apply them as they become
|
// Compute edits in the background and apply them as they become
|
||||||
@@ -405,7 +419,7 @@ impl EditAgent {
|
|||||||
mut edit_events: T,
|
mut edit_events: T,
|
||||||
cx: &mut AsyncApp,
|
cx: &mut AsyncApp,
|
||||||
) -> (
|
) -> (
|
||||||
Task<Result<(T, Option<ResolvedOldText>)>>,
|
Task<Result<(T, Vec<ResolvedOldText>)>>,
|
||||||
async_watch::Receiver<Option<Range<usize>>>,
|
async_watch::Receiver<Option<Range<usize>>>,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
@@ -425,21 +439,29 @@ impl EditAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let old_range = matcher.finish();
|
let matches = matcher.finish();
|
||||||
old_range_tx.send(old_range.clone())?;
|
|
||||||
if let Some(old_range) = old_range {
|
let old_range = if matches.len() == 1 {
|
||||||
let line_indent =
|
matches.first()
|
||||||
LineIndent::from_iter(matcher.query_lines().first().unwrap().chars());
|
|
||||||
Ok((
|
|
||||||
edit_events,
|
|
||||||
Some(ResolvedOldText {
|
|
||||||
range: old_range,
|
|
||||||
indent: line_indent,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
Ok((edit_events, None))
|
// No matches or multiple ambiguous matches
|
||||||
}
|
None
|
||||||
|
};
|
||||||
|
old_range_tx.send(old_range.cloned())?;
|
||||||
|
|
||||||
|
let indent = LineIndent::from_iter(
|
||||||
|
matcher
|
||||||
|
.query_lines()
|
||||||
|
.first()
|
||||||
|
.unwrap_or(&String::new())
|
||||||
|
.chars(),
|
||||||
|
);
|
||||||
|
let resolved_old_texts = matches
|
||||||
|
.into_iter()
|
||||||
|
.map(|range| ResolvedOldText { range, indent })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok((edit_events, resolved_old_texts))
|
||||||
});
|
});
|
||||||
|
|
||||||
(task, old_range_rx)
|
(task, old_range_rx)
|
||||||
@@ -1322,6 +1344,76 @@ mod tests {
|
|||||||
EditAgent::new(model, project, action_log, Templates::new())
|
EditAgent::new(model, project, action_log, Templates::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test(iterations = 10)]
|
||||||
|
async fn test_non_unique_text_error(cx: &mut TestAppContext, mut rng: StdRng) {
|
||||||
|
let agent = init_test(cx).await;
|
||||||
|
let original_text = indoc! {"
|
||||||
|
function foo() {
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar() {
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baz() {
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
"};
|
||||||
|
let buffer = cx.new(|cx| Buffer::local(original_text, cx));
|
||||||
|
let (apply, mut events) = agent.edit(
|
||||||
|
buffer.clone(),
|
||||||
|
String::new(),
|
||||||
|
&LanguageModelRequest::default(),
|
||||||
|
&mut cx.to_async(),
|
||||||
|
);
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
// When <old_text> matches text in more than one place
|
||||||
|
simulate_llm_output(
|
||||||
|
&agent,
|
||||||
|
indoc! {"
|
||||||
|
<old_text>
|
||||||
|
return 42;
|
||||||
|
</old_text>
|
||||||
|
<new_text>
|
||||||
|
return 100;
|
||||||
|
</new_text>
|
||||||
|
"},
|
||||||
|
&mut rng,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
apply.await.unwrap();
|
||||||
|
|
||||||
|
// Then the text should remain unchanged
|
||||||
|
let result_text = buffer.read_with(cx, |buffer, _| buffer.snapshot().text());
|
||||||
|
assert_eq!(
|
||||||
|
result_text,
|
||||||
|
indoc! {"
|
||||||
|
function foo() {
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar() {
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baz() {
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
"},
|
||||||
|
"Text should remain unchanged when there are multiple matches"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And AmbiguousEditRange even should be emitted
|
||||||
|
let events = drain_events(&mut events);
|
||||||
|
let ambiguous_ranges = vec![17..31, 52..66, 87..101];
|
||||||
|
assert!(
|
||||||
|
events.contains(&EditAgentOutputEvent::AmbiguousEditRange(ambiguous_ranges)),
|
||||||
|
"Should emit AmbiguousEditRange for non-unique text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn drain_events(
|
fn drain_events(
|
||||||
stream: &mut UnboundedReceiver<EditAgentOutputEvent>,
|
stream: &mut UnboundedReceiver<EditAgentOutputEvent>,
|
||||||
) -> Vec<EditAgentOutputEvent> {
|
) -> Vec<EditAgentOutputEvent> {
|
||||||
|
|||||||
@@ -1351,7 +1351,7 @@ fn eval(iterations: usize, expected_pass_ratio: f32, mut eval: EvalInput) {
|
|||||||
|
|
||||||
let mismatched_tag_ratio =
|
let mismatched_tag_ratio =
|
||||||
cumulative_parser_metrics.mismatched_tags as f32 / cumulative_parser_metrics.tags as f32;
|
cumulative_parser_metrics.mismatched_tags as f32 / cumulative_parser_metrics.tags as f32;
|
||||||
if mismatched_tag_ratio > 0.05 {
|
if mismatched_tag_ratio > 0.10 {
|
||||||
for eval_output in eval_outputs {
|
for eval_output in eval_outputs {
|
||||||
println!("{}", eval_output);
|
println!("{}", eval_output);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub struct StreamingFuzzyMatcher {
|
|||||||
snapshot: TextBufferSnapshot,
|
snapshot: TextBufferSnapshot,
|
||||||
query_lines: Vec<String>,
|
query_lines: Vec<String>,
|
||||||
incomplete_line: String,
|
incomplete_line: String,
|
||||||
best_match: Option<Range<usize>>,
|
best_matches: Vec<Range<usize>>,
|
||||||
matrix: SearchMatrix,
|
matrix: SearchMatrix,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ impl StreamingFuzzyMatcher {
|
|||||||
snapshot,
|
snapshot,
|
||||||
query_lines: Vec::new(),
|
query_lines: Vec::new(),
|
||||||
incomplete_line: String::new(),
|
incomplete_line: String::new(),
|
||||||
best_match: None,
|
best_matches: Vec::new(),
|
||||||
matrix: SearchMatrix::new(buffer_line_count + 1),
|
matrix: SearchMatrix::new(buffer_line_count + 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,31 +55,41 @@ impl StreamingFuzzyMatcher {
|
|||||||
|
|
||||||
self.incomplete_line.replace_range(..last_pos + 1, "");
|
self.incomplete_line.replace_range(..last_pos + 1, "");
|
||||||
|
|
||||||
self.best_match = self.resolve_location_fuzzy();
|
self.best_matches = self.resolve_location_fuzzy();
|
||||||
}
|
|
||||||
|
|
||||||
self.best_match.clone()
|
if let Some(first_match) = self.best_matches.first() {
|
||||||
|
Some(first_match.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(first_match) = self.best_matches.first() {
|
||||||
|
Some(first_match.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finish processing and return the final best match.
|
/// Finish processing and return the final best match(es).
|
||||||
///
|
///
|
||||||
/// This processes any remaining incomplete line before returning the final
|
/// This processes any remaining incomplete line before returning the final
|
||||||
/// match result.
|
/// match result.
|
||||||
pub fn finish(&mut self) -> Option<Range<usize>> {
|
pub fn finish(&mut self) -> Vec<Range<usize>> {
|
||||||
// Process any remaining incomplete line
|
// Process any remaining incomplete line
|
||||||
if !self.incomplete_line.is_empty() {
|
if !self.incomplete_line.is_empty() {
|
||||||
self.query_lines.push(self.incomplete_line.clone());
|
self.query_lines.push(self.incomplete_line.clone());
|
||||||
self.best_match = self.resolve_location_fuzzy();
|
self.incomplete_line.clear();
|
||||||
|
self.best_matches = self.resolve_location_fuzzy();
|
||||||
}
|
}
|
||||||
|
self.best_matches.clone()
|
||||||
self.best_match.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_location_fuzzy(&mut self) -> Option<Range<usize>> {
|
fn resolve_location_fuzzy(&mut self) -> Vec<Range<usize>> {
|
||||||
let new_query_line_count = self.query_lines.len();
|
let new_query_line_count = self.query_lines.len();
|
||||||
let old_query_line_count = self.matrix.rows.saturating_sub(1);
|
let old_query_line_count = self.matrix.rows.saturating_sub(1);
|
||||||
if new_query_line_count == old_query_line_count {
|
if new_query_line_count == old_query_line_count {
|
||||||
return None;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.matrix.resize_rows(new_query_line_count + 1);
|
self.matrix.resize_rows(new_query_line_count + 1);
|
||||||
@@ -132,53 +142,61 @@ impl StreamingFuzzyMatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traceback to find the best match
|
// Find all matches with the best cost
|
||||||
let buffer_line_count = self.snapshot.max_point().row as usize + 1;
|
let buffer_line_count = self.snapshot.max_point().row as usize + 1;
|
||||||
let mut buffer_row_end = buffer_line_count as u32;
|
|
||||||
let mut best_cost = u32::MAX;
|
let mut best_cost = u32::MAX;
|
||||||
|
let mut matches_with_best_cost = Vec::new();
|
||||||
|
|
||||||
for col in 1..=buffer_line_count {
|
for col in 1..=buffer_line_count {
|
||||||
let cost = self.matrix.get(new_query_line_count, col).cost;
|
let cost = self.matrix.get(new_query_line_count, col).cost;
|
||||||
if cost < best_cost {
|
if cost < best_cost {
|
||||||
best_cost = cost;
|
best_cost = cost;
|
||||||
buffer_row_end = col as u32;
|
matches_with_best_cost.clear();
|
||||||
|
matches_with_best_cost.push(col as u32);
|
||||||
|
} else if cost == best_cost {
|
||||||
|
matches_with_best_cost.push(col as u32);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut matched_lines = 0;
|
// Find ranges for the matches
|
||||||
let mut query_row = new_query_line_count;
|
let mut valid_matches = Vec::new();
|
||||||
let mut buffer_row_start = buffer_row_end;
|
for &buffer_row_end in &matches_with_best_cost {
|
||||||
while query_row > 0 && buffer_row_start > 0 {
|
let mut matched_lines = 0;
|
||||||
let current = self.matrix.get(query_row, buffer_row_start as usize);
|
let mut query_row = new_query_line_count;
|
||||||
match current.direction {
|
let mut buffer_row_start = buffer_row_end;
|
||||||
SearchDirection::Diagonal => {
|
while query_row > 0 && buffer_row_start > 0 {
|
||||||
query_row -= 1;
|
let current = self.matrix.get(query_row, buffer_row_start as usize);
|
||||||
buffer_row_start -= 1;
|
match current.direction {
|
||||||
matched_lines += 1;
|
SearchDirection::Diagonal => {
|
||||||
}
|
query_row -= 1;
|
||||||
SearchDirection::Up => {
|
buffer_row_start -= 1;
|
||||||
query_row -= 1;
|
matched_lines += 1;
|
||||||
}
|
}
|
||||||
SearchDirection::Left => {
|
SearchDirection::Up => {
|
||||||
buffer_row_start -= 1;
|
query_row -= 1;
|
||||||
|
}
|
||||||
|
SearchDirection::Left => {
|
||||||
|
buffer_row_start -= 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let matched_buffer_row_count = buffer_row_end - buffer_row_start;
|
||||||
|
let matched_ratio = matched_lines as f32
|
||||||
|
/ (matched_buffer_row_count as f32).max(new_query_line_count as f32);
|
||||||
|
if matched_ratio >= 0.8 {
|
||||||
|
let buffer_start_ix = self
|
||||||
|
.snapshot
|
||||||
|
.point_to_offset(Point::new(buffer_row_start, 0));
|
||||||
|
let buffer_end_ix = self.snapshot.point_to_offset(Point::new(
|
||||||
|
buffer_row_end - 1,
|
||||||
|
self.snapshot.line_len(buffer_row_end - 1),
|
||||||
|
));
|
||||||
|
valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let matched_buffer_row_count = buffer_row_end - buffer_row_start;
|
valid_matches.into_iter().map(|(_, range)| range).collect()
|
||||||
let matched_ratio = matched_lines as f32
|
|
||||||
/ (matched_buffer_row_count as f32).max(new_query_line_count as f32);
|
|
||||||
if matched_ratio >= 0.8 {
|
|
||||||
let buffer_start_ix = self
|
|
||||||
.snapshot
|
|
||||||
.point_to_offset(Point::new(buffer_row_start, 0));
|
|
||||||
let buffer_end_ix = self.snapshot.point_to_offset(Point::new(
|
|
||||||
buffer_row_end - 1,
|
|
||||||
self.snapshot.line_len(buffer_row_end - 1),
|
|
||||||
));
|
|
||||||
Some(buffer_start_ix..buffer_end_ix)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,28 +656,35 @@ mod tests {
|
|||||||
matcher.push(chunk);
|
matcher.push(chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = matcher.finish();
|
let actual_ranges = matcher.finish();
|
||||||
|
|
||||||
// If no expected ranges, we expect no match
|
// If no expected ranges, we expect no match
|
||||||
if expected_ranges.is_empty() {
|
if expected_ranges.is_empty() {
|
||||||
assert_eq!(
|
assert!(
|
||||||
result, None,
|
actual_ranges.is_empty(),
|
||||||
"Expected no match for query: {:?}, but found: {:?}",
|
"Expected no match for query: {:?}, but found: {:?}",
|
||||||
query, result
|
query,
|
||||||
|
actual_ranges
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let mut actual_ranges = Vec::new();
|
|
||||||
if let Some(range) = result {
|
|
||||||
actual_ranges.push(range);
|
|
||||||
}
|
|
||||||
|
|
||||||
let text_with_actual_range = generate_marked_text(&text, &actual_ranges, false);
|
let text_with_actual_range = generate_marked_text(&text, &actual_ranges, false);
|
||||||
pretty_assertions::assert_eq!(
|
pretty_assertions::assert_eq!(
|
||||||
text_with_actual_range,
|
text_with_actual_range,
|
||||||
text_with_expected_range,
|
text_with_expected_range,
|
||||||
"Query: {:?}, Chunks: {:?}",
|
indoc! {"
|
||||||
|
Query: {:?}
|
||||||
|
Chunks: {:?}
|
||||||
|
Expected marked text: {}
|
||||||
|
Actual marked text: {}
|
||||||
|
Expected ranges: {:?}
|
||||||
|
Actual ranges: {:?}"
|
||||||
|
},
|
||||||
query,
|
query,
|
||||||
chunks
|
chunks,
|
||||||
|
text_with_expected_range,
|
||||||
|
text_with_actual_range,
|
||||||
|
expected_ranges,
|
||||||
|
actual_ranges
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,8 +712,11 @@ mod tests {
|
|||||||
|
|
||||||
fn finish(mut finder: StreamingFuzzyMatcher) -> Option<String> {
|
fn finish(mut finder: StreamingFuzzyMatcher) -> Option<String> {
|
||||||
let snapshot = finder.snapshot.clone();
|
let snapshot = finder.snapshot.clone();
|
||||||
finder
|
let matches = finder.finish();
|
||||||
.finish()
|
if let Some(range) = matches.first() {
|
||||||
.map(|range| snapshot.text_for_range(range).collect::<String>())
|
Some(snapshot.text_for_range(range.clone()).collect::<String>())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ impl Tool for EditFileTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("edit_file_tool/description.md").to_string()
|
include_str!("edit_file_tool/description.md").to_string()
|
||||||
}
|
}
|
||||||
@@ -235,6 +239,7 @@ impl Tool for EditFileTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut hallucinated_old_text = false;
|
let mut hallucinated_old_text = false;
|
||||||
|
let mut ambiguous_ranges = Vec::new();
|
||||||
while let Some(event) = events.next().await {
|
while let Some(event) = events.next().await {
|
||||||
match event {
|
match event {
|
||||||
EditAgentOutputEvent::Edited => {
|
EditAgentOutputEvent::Edited => {
|
||||||
@@ -243,6 +248,7 @@ impl Tool for EditFileTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
|
EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
|
||||||
|
EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
|
||||||
EditAgentOutputEvent::ResolvingEditRange(range) => {
|
EditAgentOutputEvent::ResolvingEditRange(range) => {
|
||||||
if let Some(card) = card_clone.as_ref() {
|
if let Some(card) = card_clone.as_ref() {
|
||||||
card.update(cx, |card, cx| card.reveal_range(range, cx))?;
|
card.update(cx, |card, cx| card.reveal_range(range, cx))?;
|
||||||
@@ -325,6 +331,17 @@ impl Tool for EditFileTool {
|
|||||||
I can perform the requested edits.
|
I can perform the requested edits.
|
||||||
"}
|
"}
|
||||||
);
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
ambiguous_ranges.is_empty(),
|
||||||
|
// TODO: Include ambiguous_ranges, converted to line numbers.
|
||||||
|
// This would work best if we add `line_hint` parameter
|
||||||
|
// to edit_file_tool
|
||||||
|
formatdoc! {"
|
||||||
|
<old_text> matches more than one position in the file. Read the
|
||||||
|
relevant sections of {input_path} again and extend <old_text> so
|
||||||
|
that I can perform the requested edits.
|
||||||
|
"}
|
||||||
|
);
|
||||||
Ok(ToolResultOutput {
|
Ok(ToolResultOutput {
|
||||||
content: ToolResultContent::Text("No edits were made.".into()),
|
content: ToolResultContent::Text("No edits were made.".into()),
|
||||||
output: serde_json::to_value(output).ok(),
|
output: serde_json::to_value(output).ok(),
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ impl Tool for FetchTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||||
true
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ impl Tool for FindPathTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./find_path_tool/description.md").into()
|
include_str!("./find_path_tool/description.md").into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ use gpui::{AnyWindowHandle, App, Entity, Task};
|
|||||||
use language::{OffsetRangeExt, ParseStatus, Point};
|
use language::{OffsetRangeExt, ParseStatus, Point};
|
||||||
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
|
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
|
||||||
use project::{
|
use project::{
|
||||||
Project,
|
Project, WorktreeSettings,
|
||||||
search::{SearchQuery, SearchResult},
|
search::{SearchQuery, SearchResult},
|
||||||
};
|
};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use settings::Settings;
|
||||||
use std::{cmp, fmt::Write, sync::Arc};
|
use std::{cmp, fmt::Write, sync::Arc};
|
||||||
use ui::IconName;
|
use ui::IconName;
|
||||||
use util::RangeExt;
|
use util::RangeExt;
|
||||||
@@ -60,6 +61,10 @@ impl Tool for GrepTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./grep_tool/description.md").into()
|
include_str!("./grep_tool/description.md").into()
|
||||||
}
|
}
|
||||||
@@ -126,6 +131,23 @@ impl Tool for GrepTool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Exclude global file_scan_exclusions and private_files settings
|
||||||
|
let exclude_matcher = {
|
||||||
|
let global_settings = WorktreeSettings::get_global(cx);
|
||||||
|
let exclude_patterns = global_settings
|
||||||
|
.file_scan_exclusions
|
||||||
|
.sources()
|
||||||
|
.iter()
|
||||||
|
.chain(global_settings.private_files.sources().iter());
|
||||||
|
|
||||||
|
match PathMatcher::new(exclude_patterns) {
|
||||||
|
Ok(matcher) => matcher,
|
||||||
|
Err(error) => {
|
||||||
|
return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))).into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let query = match SearchQuery::regex(
|
let query = match SearchQuery::regex(
|
||||||
&input.regex,
|
&input.regex,
|
||||||
false,
|
false,
|
||||||
@@ -133,7 +155,7 @@ impl Tool for GrepTool {
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
include_matcher,
|
include_matcher,
|
||||||
PathMatcher::default(), // For now, keep it simple and don't enable an exclude pattern.
|
exclude_matcher,
|
||||||
true, // Always match file include pattern against *full project paths* that start with a project root.
|
true, // Always match file include pattern against *full project paths* that start with a project root.
|
||||||
None,
|
None,
|
||||||
) {
|
) {
|
||||||
@@ -156,12 +178,24 @@ impl Tool for GrepTool {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (Some(path), mut parse_status) = buffer.read_with(cx, |buffer, cx| {
|
let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| {
|
||||||
(buffer.file().map(|file| file.full_path(cx)), buffer.parse_status())
|
(buffer.file().map(|file| file.full_path(cx)), buffer.parse_status())
|
||||||
})? else {
|
}) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if this file should be excluded based on its worktree settings
|
||||||
|
if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| {
|
||||||
|
project.find_project_path(&path, cx)
|
||||||
|
}) {
|
||||||
|
if cx.update(|cx| {
|
||||||
|
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
|
||||||
|
worktree_settings.is_path_excluded(&project_path.path)
|
||||||
|
|| worktree_settings.is_path_private(&project_path.path)
|
||||||
|
}).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while *parse_status.borrow() != ParseStatus::Idle {
|
while *parse_status.borrow() != ParseStatus::Idle {
|
||||||
parse_status.changed().await?;
|
parse_status.changed().await?;
|
||||||
@@ -280,10 +314,11 @@ impl Tool for GrepTool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use assistant_tool::Tool;
|
use assistant_tool::Tool;
|
||||||
use gpui::{AppContext, TestAppContext};
|
use gpui::{AppContext, TestAppContext, UpdateGlobal};
|
||||||
use language::{Language, LanguageConfig, LanguageMatcher};
|
use language::{Language, LanguageConfig, LanguageMatcher};
|
||||||
use language_model::fake_provider::FakeLanguageModel;
|
use language_model::fake_provider::FakeLanguageModel;
|
||||||
use project::{FakeFs, Project};
|
use project::{FakeFs, Project, WorktreeSettings};
|
||||||
|
use serde_json::json;
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
use unindent::Unindent;
|
use unindent::Unindent;
|
||||||
use util::path;
|
use util::path;
|
||||||
@@ -295,7 +330,7 @@ mod tests {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor().clone());
|
let fs = FakeFs::new(cx.executor().clone());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/root",
|
path!("/root"),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"src": {
|
"src": {
|
||||||
"main.rs": "fn main() {\n println!(\"Hello, world!\");\n}",
|
"main.rs": "fn main() {\n println!(\"Hello, world!\");\n}",
|
||||||
@@ -383,7 +418,7 @@ mod tests {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor().clone());
|
let fs = FakeFs::new(cx.executor().clone());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/root",
|
path!("/root"),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
|
"case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
|
||||||
}),
|
}),
|
||||||
@@ -464,7 +499,7 @@ mod tests {
|
|||||||
|
|
||||||
// Create test file with syntax structures
|
// Create test file with syntax structures
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/root",
|
path!("/root"),
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"test_syntax.rs": r#"
|
"test_syntax.rs": r#"
|
||||||
fn top_level_function() {
|
fn top_level_function() {
|
||||||
@@ -785,4 +820,488 @@ mod tests {
|
|||||||
.with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
|
.with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/"),
|
||||||
|
json!({
|
||||||
|
"project_root": {
|
||||||
|
"allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
|
||||||
|
".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
|
||||||
|
".secretdir": {
|
||||||
|
"config": "fn special_configuration() { /* excluded */ }"
|
||||||
|
},
|
||||||
|
".mymetadata": "fn custom_metadata() { /* excluded */ }",
|
||||||
|
"subdir": {
|
||||||
|
"normal_file.rs": "fn normal_file_content() { /* Normal */ }",
|
||||||
|
"special.privatekey": "fn private_key_content() { /* private */ }",
|
||||||
|
"data.mysensitive": "fn sensitive_data() { /* private */ }"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outside_project": {
|
||||||
|
"sensitive_file.rs": "fn outside_function() { /* 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 model = Arc::new(FakeLanguageModel::default());
|
||||||
|
|
||||||
|
// Searching for files outside the project worktree should return no results
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "outside_function"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"grep_tool should not find files outside the project worktree"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Searching within the project should succeed
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "main"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("allowed_file.rs")),
|
||||||
|
"grep_tool should be able to search files inside worktrees"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Searching files that match file_scan_exclusions should return no results
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "special_configuration"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"grep_tool should not search files in .secretdir (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "custom_metadata"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"grep_tool should not search .mymetadata files (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Searching private files should return no results
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "SECRET_KEY"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"grep_tool should not search .mysecrets (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "private_key_content"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"grep_tool should not search .privatekey files (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "sensitive_data"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"grep_tool should not search .mysensitive files (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Searching a normal file should still work, even with private_files configured
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "normal_file_content"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("normal_file.rs")),
|
||||||
|
"Should be able to search normal files"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Path traversal attempts with .. in include_pattern should not escape project
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "outside_function",
|
||||||
|
"include_pattern": "../outside_project/**/*.rs"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let results = result.unwrap();
|
||||||
|
let paths = extract_paths_from_results(&results.content.as_str().unwrap());
|
||||||
|
assert!(
|
||||||
|
paths.is_empty(),
|
||||||
|
"grep_tool should not allow escaping project boundaries with relative paths"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
|
||||||
|
// Create first worktree with its own private files
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/worktree1"),
|
||||||
|
json!({
|
||||||
|
".zed": {
|
||||||
|
"settings.json": r#"{
|
||||||
|
"file_scan_exclusions": ["**/fixture.*"],
|
||||||
|
"private_files": ["**/secret.rs"]
|
||||||
|
}"#
|
||||||
|
},
|
||||||
|
"src": {
|
||||||
|
"main.rs": "fn main() { let secret_key = \"hidden\"; }",
|
||||||
|
"secret.rs": "const API_KEY: &str = \"secret_value\";",
|
||||||
|
"utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"test.rs": "fn test_secret() { assert!(true); }",
|
||||||
|
"fixture.sql": "SELECT * FROM secret_table;"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create second worktree with different private files
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/worktree2"),
|
||||||
|
json!({
|
||||||
|
".zed": {
|
||||||
|
"settings.json": r#"{
|
||||||
|
"file_scan_exclusions": ["**/internal.*"],
|
||||||
|
"private_files": ["**/private.js", "**/data.json"]
|
||||||
|
}"#
|
||||||
|
},
|
||||||
|
"lib": {
|
||||||
|
"public.js": "export function getSecret() { return 'public'; }",
|
||||||
|
"private.js": "const SECRET_KEY = \"private_value\";",
|
||||||
|
"data.json": "{\"secret_data\": \"hidden\"}"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"README.md": "# Documentation with secret info",
|
||||||
|
"internal.md": "Internal secret documentation"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
|
||||||
|
// Wait for worktrees to be fully scanned
|
||||||
|
cx.executor().run_until_parked();
|
||||||
|
|
||||||
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||||
|
let model = Arc::new(FakeLanguageModel::default());
|
||||||
|
|
||||||
|
// Search for "secret" - should exclude files based on worktree-specific settings
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "secret",
|
||||||
|
"case_sensitive": false
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
let paths = extract_paths_from_results(&content);
|
||||||
|
|
||||||
|
// Should find matches in non-private files
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("main.rs")),
|
||||||
|
"Should find 'secret' in worktree1/src/main.rs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("test.rs")),
|
||||||
|
"Should find 'secret' in worktree1/tests/test.rs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("public.js")),
|
||||||
|
"Should find 'secret' in worktree2/lib/public.js"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("README.md")),
|
||||||
|
"Should find 'secret' in worktree2/docs/README.md"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT find matches in private/excluded files based on worktree settings
|
||||||
|
assert!(
|
||||||
|
!paths.iter().any(|p| p.contains("secret.rs")),
|
||||||
|
"Should not search in worktree1/src/secret.rs (local private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!paths.iter().any(|p| p.contains("fixture.sql")),
|
||||||
|
"Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!paths.iter().any(|p| p.contains("private.js")),
|
||||||
|
"Should not search in worktree2/lib/private.js (local private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!paths.iter().any(|p| p.contains("data.json")),
|
||||||
|
"Should not search in worktree2/lib/data.json (local private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!paths.iter().any(|p| p.contains("internal.md")),
|
||||||
|
"Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test with `include_pattern` specific to one worktree
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"regex": "secret",
|
||||||
|
"include_pattern": "worktree1/**/*.rs"
|
||||||
|
});
|
||||||
|
Arc::new(GrepTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
let paths = extract_paths_from_results(&content);
|
||||||
|
|
||||||
|
// Should only find matches in worktree1 *.rs files (excluding private ones)
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("main.rs")),
|
||||||
|
"Should find match in worktree1/src/main.rs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.contains("test.rs")),
|
||||||
|
"Should find match in worktree1/tests/test.rs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!paths.iter().any(|p| p.contains("secret.rs")),
|
||||||
|
"Should not find match in excluded worktree1/src/secret.rs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
paths.iter().all(|p| !p.contains("worktree2")),
|
||||||
|
"Should not find any matches in worktree2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to extract file paths from grep results
|
||||||
|
fn extract_paths_from_results(results: &str) -> Vec<String> {
|
||||||
|
results
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.starts_with("## Matches in "))
|
||||||
|
.map(|line| {
|
||||||
|
line.strip_prefix("## Matches in ")
|
||||||
|
.unwrap()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ Searches the contents of files in the project with a regular expression
|
|||||||
- Never use this tool to search for paths. Only search file contents with this tool.
|
- Never use this tool to search for paths. Only search file contents with this tool.
|
||||||
- Use this tool when you need to find files containing specific patterns
|
- Use this tool when you need to find files containing specific patterns
|
||||||
- Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.
|
- Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.
|
||||||
|
- DO NOT use HTML entities solely to escape characters in the tool parameters.
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ use anyhow::{Result, anyhow};
|
|||||||
use assistant_tool::{ActionLog, Tool, ToolResult};
|
use assistant_tool::{ActionLog, Tool, ToolResult};
|
||||||
use gpui::{AnyWindowHandle, App, Entity, Task};
|
use gpui::{AnyWindowHandle, App, Entity, Task};
|
||||||
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
|
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
|
||||||
use project::Project;
|
use project::{Project, WorktreeSettings};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use settings::Settings;
|
||||||
use std::{fmt::Write, path::Path, sync::Arc};
|
use std::{fmt::Write, path::Path, sync::Arc};
|
||||||
use ui::IconName;
|
use ui::IconName;
|
||||||
use util::markdown::MarkdownInlineCode;
|
use util::markdown::MarkdownInlineCode;
|
||||||
@@ -48,6 +49,10 @@ impl Tool for ListDirectoryTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./list_directory_tool/description.md").into()
|
include_str!("./list_directory_tool/description.md").into()
|
||||||
}
|
}
|
||||||
@@ -115,21 +120,80 @@ impl Tool for ListDirectoryTool {
|
|||||||
else {
|
else {
|
||||||
return Task::ready(Err(anyhow!("Worktree not found"))).into();
|
return Task::ready(Err(anyhow!("Worktree not found"))).into();
|
||||||
};
|
};
|
||||||
let worktree = worktree.read(cx);
|
|
||||||
|
|
||||||
let Some(entry) = worktree.entry_for_path(&project_path.path) else {
|
// Check if the directory whose contents we're listing is itself excluded or private
|
||||||
|
let global_settings = WorktreeSettings::get_global(cx);
|
||||||
|
if global_settings.is_path_excluded(&project_path.path) {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
if global_settings.is_path_private(&project_path.path) {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Cannot list directory because its path matches the user's global `private_files` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
if worktree_settings.is_path_private(&project_path.path) {
|
||||||
|
return Task::ready(Err(anyhow!(
|
||||||
|
"Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
|
||||||
|
&input.path
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let worktree_snapshot = worktree.read(cx).snapshot();
|
||||||
|
let worktree_root_name = worktree.read(cx).root_name().to_string();
|
||||||
|
|
||||||
|
let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
|
||||||
return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
|
return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
|
||||||
};
|
};
|
||||||
|
|
||||||
if !entry.is_dir() {
|
if !entry.is_dir() {
|
||||||
return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into();
|
return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into();
|
||||||
}
|
}
|
||||||
|
let worktree_snapshot = worktree.read(cx).snapshot();
|
||||||
|
|
||||||
let mut folders = Vec::new();
|
let mut folders = Vec::new();
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
|
|
||||||
for entry in worktree.child_entries(&project_path.path) {
|
for entry in worktree_snapshot.child_entries(&project_path.path) {
|
||||||
let full_path = Path::new(worktree.root_name())
|
// Skip private and excluded files and directories
|
||||||
|
if global_settings.is_path_private(&entry.path)
|
||||||
|
|| global_settings.is_path_excluded(&entry.path)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if project
|
||||||
|
.read(cx)
|
||||||
|
.find_project_path(&entry.path, cx)
|
||||||
|
.map(|project_path| {
|
||||||
|
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
|
||||||
|
|
||||||
|
worktree_settings.is_path_excluded(&project_path.path)
|
||||||
|
|| worktree_settings.is_path_private(&project_path.path)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_path = Path::new(&worktree_root_name)
|
||||||
.join(&entry.path)
|
.join(&entry.path)
|
||||||
.display()
|
.display()
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -162,10 +226,10 @@ impl Tool for ListDirectoryTool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use assistant_tool::Tool;
|
use assistant_tool::Tool;
|
||||||
use gpui::{AppContext, TestAppContext};
|
use gpui::{AppContext, TestAppContext, UpdateGlobal};
|
||||||
use indoc::indoc;
|
use indoc::indoc;
|
||||||
use language_model::fake_provider::FakeLanguageModel;
|
use language_model::fake_provider::FakeLanguageModel;
|
||||||
use project::{FakeFs, Project};
|
use project::{FakeFs, Project, WorktreeSettings};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
use util::path;
|
use util::path;
|
||||||
@@ -193,7 +257,7 @@ mod tests {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/project",
|
path!("/project"),
|
||||||
json!({
|
json!({
|
||||||
"src": {
|
"src": {
|
||||||
"main.rs": "fn main() {}",
|
"main.rs": "fn main() {}",
|
||||||
@@ -323,7 +387,7 @@ mod tests {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/project",
|
path!("/project"),
|
||||||
json!({
|
json!({
|
||||||
"empty_dir": {}
|
"empty_dir": {}
|
||||||
}),
|
}),
|
||||||
@@ -355,7 +419,7 @@ mod tests {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/project",
|
path!("/project"),
|
||||||
json!({
|
json!({
|
||||||
"file.txt": "content"
|
"file.txt": "content"
|
||||||
}),
|
}),
|
||||||
@@ -408,4 +472,394 @@ mod tests {
|
|||||||
.contains("is not a directory")
|
.contains("is not a directory")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_list_directory_security(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/project"),
|
||||||
|
json!({
|
||||||
|
"normal_dir": {
|
||||||
|
"file1.txt": "content",
|
||||||
|
"file2.txt": "content"
|
||||||
|
},
|
||||||
|
".mysecrets": "SECRET_KEY=abc123",
|
||||||
|
".secretdir": {
|
||||||
|
"config": "special configuration",
|
||||||
|
"secret.txt": "secret content"
|
||||||
|
},
|
||||||
|
".mymetadata": "custom metadata",
|
||||||
|
"visible_dir": {
|
||||||
|
"normal.txt": "normal content",
|
||||||
|
"special.privatekey": "private key content",
|
||||||
|
"data.mysensitive": "sensitive data",
|
||||||
|
".hidden_subdir": {
|
||||||
|
"hidden_file.txt": "hidden content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Configure settings explicitly
|
||||||
|
cx.update(|cx| {
|
||||||
|
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(),
|
||||||
|
"**/.hidden_subdir".to_string(),
|
||||||
|
]);
|
||||||
|
settings.private_files = Some(vec![
|
||||||
|
"**/.mysecrets".to_string(),
|
||||||
|
"**/*.privatekey".to_string(),
|
||||||
|
"**/*.mysensitive".to_string(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||||
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||||
|
let model = Arc::new(FakeLanguageModel::default());
|
||||||
|
let tool = Arc::new(ListDirectoryTool);
|
||||||
|
|
||||||
|
// Listing root directory should exclude private and excluded files
|
||||||
|
let input = json!({
|
||||||
|
"path": "project"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
|
||||||
|
// Should include normal directories
|
||||||
|
assert!(content.contains("normal_dir"), "Should list normal_dir");
|
||||||
|
assert!(content.contains("visible_dir"), "Should list visible_dir");
|
||||||
|
|
||||||
|
// Should NOT include excluded or private files
|
||||||
|
assert!(
|
||||||
|
!content.contains(".secretdir"),
|
||||||
|
"Should not list .secretdir (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains(".mymetadata"),
|
||||||
|
"Should not list .mymetadata (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains(".mysecrets"),
|
||||||
|
"Should not list .mysecrets (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trying to list an excluded directory should fail
|
||||||
|
let input = json!({
|
||||||
|
"path": "project/.secretdir"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Should not be able to list excluded directory"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("file_scan_exclusions"),
|
||||||
|
"Error should mention file_scan_exclusions"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listing a directory should exclude private files within it
|
||||||
|
let input = json!({
|
||||||
|
"path": "project/visible_dir"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
|
||||||
|
// Should include normal files
|
||||||
|
assert!(content.contains("normal.txt"), "Should list normal.txt");
|
||||||
|
|
||||||
|
// Should NOT include private files
|
||||||
|
assert!(
|
||||||
|
!content.contains("privatekey"),
|
||||||
|
"Should not list .privatekey files (private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains("mysensitive"),
|
||||||
|
"Should not list .mysensitive files (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT include subdirectories that match exclusions
|
||||||
|
assert!(
|
||||||
|
!content.contains(".hidden_subdir"),
|
||||||
|
"Should not list .hidden_subdir (file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
|
||||||
|
// Create first worktree with its own private files
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/worktree1"),
|
||||||
|
json!({
|
||||||
|
".zed": {
|
||||||
|
"settings.json": r#"{
|
||||||
|
"file_scan_exclusions": ["**/fixture.*"],
|
||||||
|
"private_files": ["**/secret.rs", "**/config.toml"]
|
||||||
|
}"#
|
||||||
|
},
|
||||||
|
"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));"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create second worktree with different private files
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/worktree2"),
|
||||||
|
json!({
|
||||||
|
".zed": {
|
||||||
|
"settings.json": r#"{
|
||||||
|
"file_scan_exclusions": ["**/internal.*"],
|
||||||
|
"private_files": ["**/private.js", "**/data.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"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
|
||||||
|
// Wait for worktrees to be fully scanned
|
||||||
|
cx.executor().run_until_parked();
|
||||||
|
|
||||||
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||||
|
let model = Arc::new(FakeLanguageModel::default());
|
||||||
|
let tool = Arc::new(ListDirectoryTool);
|
||||||
|
|
||||||
|
// Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree1/src"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
assert!(content.contains("main.rs"), "Should list main.rs");
|
||||||
|
assert!(
|
||||||
|
!content.contains("secret.rs"),
|
||||||
|
"Should not list secret.rs (local private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains("config.toml"),
|
||||||
|
"Should not list config.toml (local private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing worktree1/tests - should exclude fixture.sql based on local settings
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree1/tests"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
assert!(content.contains("test.rs"), "Should list test.rs");
|
||||||
|
assert!(
|
||||||
|
!content.contains("fixture.sql"),
|
||||||
|
"Should not list fixture.sql (local file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing worktree2/lib - should exclude private.js and data.json based on local settings
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree2/lib"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
assert!(content.contains("public.js"), "Should list public.js");
|
||||||
|
assert!(
|
||||||
|
!content.contains("private.js"),
|
||||||
|
"Should not list private.js (local private_files)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains("data.json"),
|
||||||
|
"Should not list data.json (local private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test listing worktree2/docs - should exclude internal.md based on local settings
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree2/docs"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = result.content.as_str().unwrap();
|
||||||
|
assert!(content.contains("README.md"), "Should list README.md");
|
||||||
|
assert!(
|
||||||
|
!content.contains("internal.md"),
|
||||||
|
"Should not list internal.md (local file_scan_exclusions)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test trying to list an excluded directory directly
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree1/src/secret.rs"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// This should fail because we're trying to list a file, not a directory
|
||||||
|
assert!(result.is_err(), "Should fail when trying to list a file");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ impl Tool for MovePathTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./move_path_tool/description.md").into()
|
include_str!("./move_path_tool/description.md").into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ impl Tool for NowTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
"Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into()
|
"Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ impl Tool for OpenTool {
|
|||||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./open_tool/description.md").to_string()
|
include_str!("./open_tool/description.md").to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ use language::{Anchor, Point};
|
|||||||
use language_model::{
|
use language_model::{
|
||||||
LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat,
|
LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat,
|
||||||
};
|
};
|
||||||
use project::{AgentLocation, Project};
|
use project::{AgentLocation, Project, WorktreeSettings};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use settings::Settings;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use ui::IconName;
|
use ui::IconName;
|
||||||
use util::markdown::MarkdownInlineCode;
|
use util::markdown::MarkdownInlineCode;
|
||||||
@@ -58,6 +59,10 @@ impl Tool for ReadFileTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./read_file_tool/description.md").into()
|
include_str!("./read_file_tool/description.md").into()
|
||||||
}
|
}
|
||||||
@@ -103,12 +108,48 @@ impl Tool for ReadFileTool {
|
|||||||
return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into();
|
return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
let file_path = input.path.clone();
|
let file_path = input.path.clone();
|
||||||
|
|
||||||
if image_store::is_image_file(&project, &project_path, cx) {
|
if image_store::is_image_file(&project, &project_path, cx) {
|
||||||
if !model.supports_images() {
|
if !model.supports_images() {
|
||||||
return Task::ready(Err(anyhow!(
|
return Task::ready(Err(anyhow!(
|
||||||
"Attempted to read an image, but Zed doesn't currently sending images to {}.",
|
"Attempted to read an image, but Zed doesn't currently support sending images to {}.",
|
||||||
model.name().0
|
model.name().0
|
||||||
)))
|
)))
|
||||||
.into();
|
.into();
|
||||||
@@ -248,10 +289,10 @@ impl Tool for ReadFileTool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gpui::{AppContext, TestAppContext};
|
use gpui::{AppContext, TestAppContext, UpdateGlobal};
|
||||||
use language::{Language, LanguageConfig, LanguageMatcher};
|
use language::{Language, LanguageConfig, LanguageMatcher};
|
||||||
use language_model::fake_provider::FakeLanguageModel;
|
use language_model::fake_provider::FakeLanguageModel;
|
||||||
use project::{FakeFs, Project};
|
use project::{FakeFs, Project, WorktreeSettings};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
use util::path;
|
use util::path;
|
||||||
@@ -261,7 +302,7 @@ mod test {
|
|||||||
init_test(cx);
|
init_test(cx);
|
||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree("/root", json!({})).await;
|
fs.insert_tree(path!("/root"), json!({})).await;
|
||||||
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||||
let model = Arc::new(FakeLanguageModel::default());
|
let model = Arc::new(FakeLanguageModel::default());
|
||||||
@@ -295,7 +336,7 @@ mod test {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/root",
|
path!("/root"),
|
||||||
json!({
|
json!({
|
||||||
"small_file.txt": "This is a small file content"
|
"small_file.txt": "This is a small file content"
|
||||||
}),
|
}),
|
||||||
@@ -334,7 +375,7 @@ mod test {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/root",
|
path!("/root"),
|
||||||
json!({
|
json!({
|
||||||
"large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
|
"large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
|
||||||
}),
|
}),
|
||||||
@@ -425,7 +466,7 @@ mod test {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/root",
|
path!("/root"),
|
||||||
json!({
|
json!({
|
||||||
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
||||||
}),
|
}),
|
||||||
@@ -466,7 +507,7 @@ mod test {
|
|||||||
|
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.insert_tree(
|
fs.insert_tree(
|
||||||
"/root",
|
path!("/root"),
|
||||||
json!({
|
json!({
|
||||||
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
||||||
}),
|
}),
|
||||||
@@ -597,4 +638,544 @@ mod test {
|
|||||||
)
|
)
|
||||||
.unwrap()
|
.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 model = Arc::new(FakeLanguageModel::default());
|
||||||
|
|
||||||
|
// Reading a file outside the project worktree should fail
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"path": "/outside_project/sensitive_file.txt"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.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 = json!({
|
||||||
|
"path": "project_root/allowed_file.txt"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.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 = json!({
|
||||||
|
"path": "project_root/.secretdir/config"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.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 = json!({
|
||||||
|
"path": "project_root/.mymetadata"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.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 = json!({
|
||||||
|
"path": "project_root/.mysecrets"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"read_file_tool should error when attempting to read .mysecrets (private_files)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"path": "project_root/subdir/special.privatekey"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.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 = json!({
|
||||||
|
"path": "project_root/subdir/data.mysensitive"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.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 = json!({
|
||||||
|
"path": "project_root/subdir/normal_file.txt"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok(), "Should be able to read normal files");
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap().content.as_str().unwrap(),
|
||||||
|
"Normal file content"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Path traversal attempts with .. should fail
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
let input = json!({
|
||||||
|
"path": "project_root/../outside_project/sensitive_file.txt"
|
||||||
|
});
|
||||||
|
Arc::new(ReadFileTool)
|
||||||
|
.run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.output
|
||||||
|
})
|
||||||
|
.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 model = Arc::new(FakeLanguageModel::default());
|
||||||
|
let tool = Arc::new(ReadFileTool);
|
||||||
|
|
||||||
|
// Test reading allowed files in worktree1
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree1/src/main.rs"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.content.as_str().unwrap(),
|
||||||
|
"fn main() { println!(\"Hello from worktree1\"); }"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test reading private file in worktree1 should fail
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree1/src/secret.rs"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.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 input = json!({
|
||||||
|
"path": "worktree1/tests/fixture.sql"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.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 input = json!({
|
||||||
|
"path": "worktree2/lib/public.js"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.content.as_str().unwrap(),
|
||||||
|
"export function greet() { return 'Hello from worktree2'; }"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test reading private file in worktree2 should fail
|
||||||
|
let input = json!({
|
||||||
|
"path": "worktree2/lib/private.js"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.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 input = json!({
|
||||||
|
"path": "worktree2/docs/internal.md"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.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 input = json!({
|
||||||
|
"path": "worktree1/src/config.toml"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| {
|
||||||
|
tool.clone().run(
|
||||||
|
input,
|
||||||
|
Arc::default(),
|
||||||
|
project.clone(),
|
||||||
|
action_log.clone(),
|
||||||
|
model.clone(),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.output
|
||||||
|
.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ impl Tool for TerminalTool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./terminal_tool/description.md").to_string()
|
include_str!("./terminal_tool/description.md").to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ impl Tool for ThinkingTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
include_str!("./thinking_tool/description.md").to_string()
|
include_str!("./thinking_tool/description.md").to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ impl Tool for WebSearchTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn may_perform_edits(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
"Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into()
|
"Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,20 +71,22 @@ pub enum Model {
|
|||||||
// DeepSeek
|
// DeepSeek
|
||||||
DeepSeekR1,
|
DeepSeekR1,
|
||||||
// Meta models
|
// Meta models
|
||||||
MetaLlama3_8BInstruct,
|
MetaLlama38BInstructV1,
|
||||||
MetaLlama3_70BInstruct,
|
MetaLlama370BInstructV1,
|
||||||
MetaLlama31_8BInstruct,
|
MetaLlama318BInstructV1_128k,
|
||||||
MetaLlama31_70BInstruct,
|
MetaLlama318BInstructV1,
|
||||||
MetaLlama31_405BInstruct,
|
MetaLlama3170BInstructV1_128k,
|
||||||
MetaLlama32_1BInstruct,
|
MetaLlama3170BInstructV1,
|
||||||
MetaLlama32_3BInstruct,
|
MetaLlama31405BInstructV1,
|
||||||
MetaLlama32_11BMultiModal,
|
MetaLlama321BInstructV1,
|
||||||
MetaLlama32_90BMultiModal,
|
MetaLlama323BInstructV1,
|
||||||
MetaLlama33_70BInstruct,
|
MetaLlama3211BInstructV1,
|
||||||
|
MetaLlama3290BInstructV1,
|
||||||
|
MetaLlama3370BInstructV1,
|
||||||
#[allow(non_camel_case_types)]
|
#[allow(non_camel_case_types)]
|
||||||
MetaLlama4Scout_17BInstruct,
|
MetaLlama4Scout17BInstructV1,
|
||||||
#[allow(non_camel_case_types)]
|
#[allow(non_camel_case_types)]
|
||||||
MetaLlama4Maverick_17BInstruct,
|
MetaLlama4Maverick17BInstructV1,
|
||||||
// Mistral models
|
// Mistral models
|
||||||
MistralMistral7BInstructV0,
|
MistralMistral7BInstructV0,
|
||||||
MistralMixtral8x7BInstructV0,
|
MistralMixtral8x7BInstructV0,
|
||||||
@@ -129,6 +131,64 @@ impl Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> &str {
|
pub fn id(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Model::ClaudeSonnet4 => "claude-4-sonnet",
|
||||||
|
Model::ClaudeSonnet4Thinking => "claude-4-sonnet-thinking",
|
||||||
|
Model::ClaudeOpus4 => "claude-4-opus",
|
||||||
|
Model::ClaudeOpus4Thinking => "claude-4-opus-thinking",
|
||||||
|
Model::Claude3_5SonnetV2 => "claude-3-5-sonnet-v2",
|
||||||
|
Model::Claude3_5Sonnet => "claude-3-5-sonnet",
|
||||||
|
Model::Claude3Opus => "claude-3-opus",
|
||||||
|
Model::Claude3Sonnet => "claude-3-sonnet",
|
||||||
|
Model::Claude3Haiku => "claude-3-haiku",
|
||||||
|
Model::Claude3_5Haiku => "claude-3-5-haiku",
|
||||||
|
Model::Claude3_7Sonnet => "claude-3-7-sonnet",
|
||||||
|
Model::Claude3_7SonnetThinking => "claude-3-7-sonnet-thinking",
|
||||||
|
Model::AmazonNovaLite => "amazon-nova-lite",
|
||||||
|
Model::AmazonNovaMicro => "amazon-nova-micro",
|
||||||
|
Model::AmazonNovaPro => "amazon-nova-pro",
|
||||||
|
Model::AmazonNovaPremier => "amazon-nova-premier",
|
||||||
|
Model::DeepSeekR1 => "deepseek-r1",
|
||||||
|
Model::AI21J2GrandeInstruct => "ai21-j2-grande-instruct",
|
||||||
|
Model::AI21J2JumboInstruct => "ai21-j2-jumbo-instruct",
|
||||||
|
Model::AI21J2Mid => "ai21-j2-mid",
|
||||||
|
Model::AI21J2MidV1 => "ai21-j2-mid-v1",
|
||||||
|
Model::AI21J2Ultra => "ai21-j2-ultra",
|
||||||
|
Model::AI21J2UltraV1_8k => "ai21-j2-ultra-v1-8k",
|
||||||
|
Model::AI21J2UltraV1 => "ai21-j2-ultra-v1",
|
||||||
|
Model::AI21JambaInstructV1 => "ai21-jamba-instruct-v1",
|
||||||
|
Model::AI21Jamba15LargeV1 => "ai21-jamba-1-5-large-v1",
|
||||||
|
Model::AI21Jamba15MiniV1 => "ai21-jamba-1-5-mini-v1",
|
||||||
|
Model::CohereCommandTextV14_4k => "cohere-command-text-v14-4k",
|
||||||
|
Model::CohereCommandRV1 => "cohere-command-r-v1",
|
||||||
|
Model::CohereCommandRPlusV1 => "cohere-command-r-plus-v1",
|
||||||
|
Model::CohereCommandLightTextV14_4k => "cohere-command-light-text-v14-4k",
|
||||||
|
Model::MetaLlama38BInstructV1 => "meta-llama3-8b-instruct-v1",
|
||||||
|
Model::MetaLlama370BInstructV1 => "meta-llama3-70b-instruct-v1",
|
||||||
|
Model::MetaLlama318BInstructV1_128k => "meta-llama3-1-8b-instruct-v1-128k",
|
||||||
|
Model::MetaLlama318BInstructV1 => "meta-llama3-1-8b-instruct-v1",
|
||||||
|
Model::MetaLlama3170BInstructV1_128k => "meta-llama3-1-70b-instruct-v1-128k",
|
||||||
|
Model::MetaLlama3170BInstructV1 => "meta-llama3-1-70b-instruct-v1",
|
||||||
|
Model::MetaLlama31405BInstructV1 => "meta-llama3-1-405b-instruct-v1",
|
||||||
|
Model::MetaLlama321BInstructV1 => "meta-llama3-2-1b-instruct-v1",
|
||||||
|
Model::MetaLlama323BInstructV1 => "meta-llama3-2-3b-instruct-v1",
|
||||||
|
Model::MetaLlama3211BInstructV1 => "meta-llama3-2-11b-instruct-v1",
|
||||||
|
Model::MetaLlama3290BInstructV1 => "meta-llama3-2-90b-instruct-v1",
|
||||||
|
Model::MetaLlama3370BInstructV1 => "meta-llama3-3-70b-instruct-v1",
|
||||||
|
Model::MetaLlama4Scout17BInstructV1 => "meta-llama4-scout-17b-instruct-v1",
|
||||||
|
Model::MetaLlama4Maverick17BInstructV1 => "meta-llama4-maverick-17b-instruct-v1",
|
||||||
|
Model::MistralMistral7BInstructV0 => "mistral-7b-instruct-v0",
|
||||||
|
Model::MistralMixtral8x7BInstructV0 => "mistral-mixtral-8x7b-instruct-v0",
|
||||||
|
Model::MistralMistralLarge2402V1 => "mistral-large-2402-v1",
|
||||||
|
Model::MistralMistralSmall2402V1 => "mistral-small-2402-v1",
|
||||||
|
Model::MistralPixtralLarge2502V1 => "mistral-pixtral-large-2502-v1",
|
||||||
|
Model::PalmyraWriterX4 => "palmyra-writer-x4",
|
||||||
|
Model::PalmyraWriterX5 => "palmyra-writer-x5",
|
||||||
|
Self::Custom { name, .. } => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_id(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Model::ClaudeSonnet4 | Model::ClaudeSonnet4Thinking => {
|
Model::ClaudeSonnet4 | Model::ClaudeSonnet4Thinking => {
|
||||||
"anthropic.claude-sonnet-4-20250514-v1:0"
|
"anthropic.claude-sonnet-4-20250514-v1:0"
|
||||||
@@ -164,18 +224,20 @@ impl Model {
|
|||||||
Model::CohereCommandRV1 => "cohere.command-r-v1:0",
|
Model::CohereCommandRV1 => "cohere.command-r-v1:0",
|
||||||
Model::CohereCommandRPlusV1 => "cohere.command-r-plus-v1:0",
|
Model::CohereCommandRPlusV1 => "cohere.command-r-plus-v1:0",
|
||||||
Model::CohereCommandLightTextV14_4k => "cohere.command-light-text-v14:7:4k",
|
Model::CohereCommandLightTextV14_4k => "cohere.command-light-text-v14:7:4k",
|
||||||
Model::MetaLlama3_8BInstruct => "meta.llama3-8b-instruct-v1:0",
|
Model::MetaLlama38BInstructV1 => "meta.llama3-8b-instruct-v1:0",
|
||||||
Model::MetaLlama3_70BInstruct => "meta.llama3-70b-instruct-v1:0",
|
Model::MetaLlama370BInstructV1 => "meta.llama3-70b-instruct-v1:0",
|
||||||
Model::MetaLlama31_8BInstruct => "meta.llama3-1-8b-instruct-v1:0",
|
Model::MetaLlama318BInstructV1_128k => "meta.llama3-1-8b-instruct-v1:0",
|
||||||
Model::MetaLlama31_70BInstruct => "meta.llama3-1-70b-instruct-v1:0",
|
Model::MetaLlama318BInstructV1 => "meta.llama3-1-8b-instruct-v1:0",
|
||||||
Model::MetaLlama31_405BInstruct => "meta.llama3-1-405b-instruct-v1:0",
|
Model::MetaLlama3170BInstructV1_128k => "meta.llama3-1-70b-instruct-v1:0",
|
||||||
Model::MetaLlama32_11BMultiModal => "meta.llama3-2-11b-instruct-v1:0",
|
Model::MetaLlama3170BInstructV1 => "meta.llama3-1-70b-instruct-v1:0",
|
||||||
Model::MetaLlama32_90BMultiModal => "meta.llama3-2-90b-instruct-v1:0",
|
Model::MetaLlama31405BInstructV1 => "meta.llama3-1-405b-instruct-v1:0",
|
||||||
Model::MetaLlama32_1BInstruct => "meta.llama3-2-1b-instruct-v1:0",
|
Model::MetaLlama3211BInstructV1 => "meta.llama3-2-11b-instruct-v1:0",
|
||||||
Model::MetaLlama32_3BInstruct => "meta.llama3-2-3b-instruct-v1:0",
|
Model::MetaLlama3290BInstructV1 => "meta.llama3-2-90b-instruct-v1:0",
|
||||||
Model::MetaLlama33_70BInstruct => "meta.llama3-3-70b-instruct-v1:0",
|
Model::MetaLlama321BInstructV1 => "meta.llama3-2-1b-instruct-v1:0",
|
||||||
Model::MetaLlama4Scout_17BInstruct => "meta.llama4-scout-17b-instruct-v1:0",
|
Model::MetaLlama323BInstructV1 => "meta.llama3-2-3b-instruct-v1:0",
|
||||||
Model::MetaLlama4Maverick_17BInstruct => "meta.llama4-maverick-17b-instruct-v1:0",
|
Model::MetaLlama3370BInstructV1 => "meta.llama3-3-70b-instruct-v1:0",
|
||||||
|
Model::MetaLlama4Scout17BInstructV1 => "meta.llama4-scout-17b-instruct-v1:0",
|
||||||
|
Model::MetaLlama4Maverick17BInstructV1 => "meta.llama4-maverick-17b-instruct-v1:0",
|
||||||
Model::MistralMistral7BInstructV0 => "mistral.mistral-7b-instruct-v0:2",
|
Model::MistralMistral7BInstructV0 => "mistral.mistral-7b-instruct-v0:2",
|
||||||
Model::MistralMixtral8x7BInstructV0 => "mistral.mixtral-8x7b-instruct-v0:1",
|
Model::MistralMixtral8x7BInstructV0 => "mistral.mixtral-8x7b-instruct-v0:1",
|
||||||
Model::MistralMistralLarge2402V1 => "mistral.mistral-large-2402-v1:0",
|
Model::MistralMistralLarge2402V1 => "mistral.mistral-large-2402-v1:0",
|
||||||
@@ -220,18 +282,20 @@ impl Model {
|
|||||||
Self::CohereCommandRV1 => "Cohere Command R V1",
|
Self::CohereCommandRV1 => "Cohere Command R V1",
|
||||||
Self::CohereCommandRPlusV1 => "Cohere Command R Plus V1",
|
Self::CohereCommandRPlusV1 => "Cohere Command R Plus V1",
|
||||||
Self::CohereCommandLightTextV14_4k => "Cohere Command Light Text V14 4K",
|
Self::CohereCommandLightTextV14_4k => "Cohere Command Light Text V14 4K",
|
||||||
Self::MetaLlama3_8BInstruct => "Meta Llama 3 8B Instruct",
|
Self::MetaLlama38BInstructV1 => "Meta Llama 3 8B Instruct",
|
||||||
Self::MetaLlama3_70BInstruct => "Meta Llama 3 70B Instruct",
|
Self::MetaLlama370BInstructV1 => "Meta Llama 3 70B Instruct",
|
||||||
Self::MetaLlama31_8BInstruct => "Meta Llama 3.1 8B Instruct",
|
Self::MetaLlama318BInstructV1_128k => "Meta Llama 3.1 8B Instruct 128K",
|
||||||
Self::MetaLlama31_70BInstruct => "Meta Llama 3.1 70B Instruct",
|
Self::MetaLlama318BInstructV1 => "Meta Llama 3.1 8B Instruct",
|
||||||
Self::MetaLlama31_405BInstruct => "Meta Llama 3.1 405B Instruct",
|
Self::MetaLlama3170BInstructV1_128k => "Meta Llama 3.1 70B Instruct 128K",
|
||||||
Self::MetaLlama32_11BMultiModal => "Meta Llama 3.2 11B Vision Instruct",
|
Self::MetaLlama3170BInstructV1 => "Meta Llama 3.1 70B Instruct",
|
||||||
Self::MetaLlama32_90BMultiModal => "Meta Llama 3.2 90B Vision Instruct",
|
Self::MetaLlama31405BInstructV1 => "Meta Llama 3.1 405B Instruct",
|
||||||
Self::MetaLlama32_1BInstruct => "Meta Llama 3.2 1B Instruct",
|
Self::MetaLlama3211BInstructV1 => "Meta Llama 3.2 11B Instruct",
|
||||||
Self::MetaLlama32_3BInstruct => "Meta Llama 3.2 3B Instruct",
|
Self::MetaLlama3290BInstructV1 => "Meta Llama 3.2 90B Instruct",
|
||||||
Self::MetaLlama33_70BInstruct => "Meta Llama 3.3 70B Instruct",
|
Self::MetaLlama321BInstructV1 => "Meta Llama 3.2 1B Instruct",
|
||||||
Self::MetaLlama4Scout_17BInstruct => "Meta Llama 4 Scout 17B Instruct",
|
Self::MetaLlama323BInstructV1 => "Meta Llama 3.2 3B Instruct",
|
||||||
Self::MetaLlama4Maverick_17BInstruct => "Meta Llama 4 Maverick 17B Instruct",
|
Self::MetaLlama3370BInstructV1 => "Meta Llama 3.3 70B Instruct",
|
||||||
|
Self::MetaLlama4Scout17BInstructV1 => "Meta Llama 4 Scout 17B Instruct",
|
||||||
|
Self::MetaLlama4Maverick17BInstructV1 => "Meta Llama 4 Maverick 17B Instruct",
|
||||||
Self::MistralMistral7BInstructV0 => "Mistral 7B Instruct V0",
|
Self::MistralMistral7BInstructV0 => "Mistral 7B Instruct V0",
|
||||||
Self::MistralMixtral8x7BInstructV0 => "Mistral Mixtral 8x7B Instruct V0",
|
Self::MistralMixtral8x7BInstructV0 => "Mistral Mixtral 8x7B Instruct V0",
|
||||||
Self::MistralMistralLarge2402V1 => "Mistral Large 2402 V1",
|
Self::MistralMistralLarge2402V1 => "Mistral Large 2402 V1",
|
||||||
@@ -253,7 +317,9 @@ impl Model {
|
|||||||
| Self::Claude3_5Haiku
|
| Self::Claude3_5Haiku
|
||||||
| Self::Claude3_7Sonnet
|
| Self::Claude3_7Sonnet
|
||||||
| Self::ClaudeSonnet4
|
| Self::ClaudeSonnet4
|
||||||
| Self::ClaudeOpus4 => 200_000,
|
| Self::ClaudeOpus4
|
||||||
|
| Self::ClaudeSonnet4Thinking
|
||||||
|
| Self::ClaudeOpus4Thinking => 200_000,
|
||||||
Self::AmazonNovaPremier => 1_000_000,
|
Self::AmazonNovaPremier => 1_000_000,
|
||||||
Self::PalmyraWriterX5 => 1_000_000,
|
Self::PalmyraWriterX5 => 1_000_000,
|
||||||
Self::PalmyraWriterX4 => 128_000,
|
Self::PalmyraWriterX4 => 128_000,
|
||||||
@@ -362,11 +428,11 @@ impl Model {
|
|||||||
anyhow::bail!("Unsupported Region {region}");
|
anyhow::bail!("Unsupported Region {region}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let model_id = self.id();
|
let model_id = self.request_id();
|
||||||
|
|
||||||
match (self, region_group) {
|
match (self, region_group) {
|
||||||
// Custom models can't have CRI IDs
|
// Custom models can't have CRI IDs
|
||||||
(Model::Custom { .. }, _) => Ok(self.id().into()),
|
(Model::Custom { .. }, _) => Ok(self.request_id().into()),
|
||||||
|
|
||||||
// Models with US Gov only
|
// Models with US Gov only
|
||||||
(Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => {
|
(Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => {
|
||||||
@@ -390,16 +456,18 @@ impl Model {
|
|||||||
| Model::Claude3Opus
|
| Model::Claude3Opus
|
||||||
| Model::Claude3Sonnet
|
| Model::Claude3Sonnet
|
||||||
| Model::DeepSeekR1
|
| Model::DeepSeekR1
|
||||||
| Model::MetaLlama31_405BInstruct
|
| Model::MetaLlama31405BInstructV1
|
||||||
| Model::MetaLlama31_70BInstruct
|
| Model::MetaLlama3170BInstructV1_128k
|
||||||
| Model::MetaLlama31_8BInstruct
|
| Model::MetaLlama3170BInstructV1
|
||||||
| Model::MetaLlama32_11BMultiModal
|
| Model::MetaLlama318BInstructV1_128k
|
||||||
| Model::MetaLlama32_1BInstruct
|
| Model::MetaLlama318BInstructV1
|
||||||
| Model::MetaLlama32_3BInstruct
|
| Model::MetaLlama3211BInstructV1
|
||||||
| Model::MetaLlama32_90BMultiModal
|
| Model::MetaLlama321BInstructV1
|
||||||
| Model::MetaLlama33_70BInstruct
|
| Model::MetaLlama323BInstructV1
|
||||||
| Model::MetaLlama4Maverick_17BInstruct
|
| Model::MetaLlama3290BInstructV1
|
||||||
| Model::MetaLlama4Scout_17BInstruct
|
| Model::MetaLlama3370BInstructV1
|
||||||
|
| Model::MetaLlama4Maverick17BInstructV1
|
||||||
|
| Model::MetaLlama4Scout17BInstructV1
|
||||||
| Model::MistralPixtralLarge2502V1
|
| Model::MistralPixtralLarge2502V1
|
||||||
| Model::PalmyraWriterX4
|
| Model::PalmyraWriterX4
|
||||||
| Model::PalmyraWriterX5,
|
| Model::PalmyraWriterX5,
|
||||||
@@ -413,8 +481,8 @@ impl Model {
|
|||||||
| Model::Claude3_7SonnetThinking
|
| Model::Claude3_7SonnetThinking
|
||||||
| Model::Claude3Haiku
|
| Model::Claude3Haiku
|
||||||
| Model::Claude3Sonnet
|
| Model::Claude3Sonnet
|
||||||
| Model::MetaLlama32_1BInstruct
|
| Model::MetaLlama321BInstructV1
|
||||||
| Model::MetaLlama32_3BInstruct
|
| Model::MetaLlama323BInstructV1
|
||||||
| Model::MistralPixtralLarge2502V1,
|
| Model::MistralPixtralLarge2502V1,
|
||||||
"eu",
|
"eu",
|
||||||
) => Ok(format!("{}.{}", region_group, model_id)),
|
) => Ok(format!("{}.{}", region_group, model_id)),
|
||||||
@@ -429,7 +497,7 @@ impl Model {
|
|||||||
) => Ok(format!("{}.{}", region_group, model_id)),
|
) => Ok(format!("{}.{}", region_group, model_id)),
|
||||||
|
|
||||||
// Any other combination is not supported
|
// Any other combination is not supported
|
||||||
_ => Ok(self.id().into()),
|
_ => Ok(self.request_id().into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,15 +574,15 @@ mod tests {
|
|||||||
fn test_meta_models_inference_ids() -> anyhow::Result<()> {
|
fn test_meta_models_inference_ids() -> anyhow::Result<()> {
|
||||||
// Test Meta models
|
// Test Meta models
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Model::MetaLlama3_70BInstruct.cross_region_inference_id("us-east-1")?,
|
Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?,
|
||||||
"meta.llama3-70b-instruct-v1:0"
|
"meta.llama3-70b-instruct-v1:0"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Model::MetaLlama31_70BInstruct.cross_region_inference_id("us-east-1")?,
|
Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1")?,
|
||||||
"us.meta.llama3-1-70b-instruct-v1:0"
|
"us.meta.llama3-1-70b-instruct-v1:0"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Model::MetaLlama32_1BInstruct.cross_region_inference_id("eu-west-1")?,
|
Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?,
|
||||||
"eu.meta.llama3-2-1b-instruct-v1:0"
|
"eu.meta.llama3-2-1b-instruct-v1:0"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -584,4 +652,39 @@ mod tests {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_friendly_id_vs_request_id() {
|
||||||
|
// Test that id() returns friendly identifiers
|
||||||
|
assert_eq!(Model::Claude3_5SonnetV2.id(), "claude-3-5-sonnet-v2");
|
||||||
|
assert_eq!(Model::AmazonNovaLite.id(), "amazon-nova-lite");
|
||||||
|
assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1");
|
||||||
|
assert_eq!(
|
||||||
|
Model::MetaLlama38BInstructV1.id(),
|
||||||
|
"meta-llama3-8b-instruct-v1"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test that request_id() returns actual backend model IDs
|
||||||
|
assert_eq!(
|
||||||
|
Model::Claude3_5SonnetV2.request_id(),
|
||||||
|
"anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||||
|
);
|
||||||
|
assert_eq!(Model::AmazonNovaLite.request_id(), "amazon.nova-lite-v1:0");
|
||||||
|
assert_eq!(Model::DeepSeekR1.request_id(), "deepseek.r1-v1:0");
|
||||||
|
assert_eq!(
|
||||||
|
Model::MetaLlama38BInstructV1.request_id(),
|
||||||
|
"meta.llama3-8b-instruct-v1:0"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test thinking models have different friendly IDs but same request IDs
|
||||||
|
assert_eq!(Model::ClaudeSonnet4.id(), "claude-4-sonnet");
|
||||||
|
assert_eq!(
|
||||||
|
Model::ClaudeSonnet4Thinking.id(),
|
||||||
|
"claude-4-sonnet-thinking"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Model::ClaudeSonnet4.request_id(),
|
||||||
|
Model::ClaudeSonnet4Thinking.request_id()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub struct Channel {
|
|||||||
pub name: SharedString,
|
pub name: SharedString,
|
||||||
pub visibility: proto::ChannelVisibility,
|
pub visibility: proto::ChannelVisibility,
|
||||||
pub parent_path: Vec<ChannelId>,
|
pub parent_path: Vec<ChannelId>,
|
||||||
|
pub channel_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Default, Debug)]
|
||||||
@@ -614,7 +615,24 @@ impl ChannelStore {
|
|||||||
to: to.0,
|
to: to.0,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reorder_channel(
|
||||||
|
&mut self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
direction: proto::reorder_channel::Direction,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<()>> {
|
||||||
|
let client = self.client.clone();
|
||||||
|
cx.spawn(async move |_, _| {
|
||||||
|
client
|
||||||
|
.request(proto::ReorderChannel {
|
||||||
|
channel_id: channel_id.0,
|
||||||
|
direction: direction.into(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1027,6 +1045,18 @@ impl ChannelStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.channel_invitations.clear();
|
||||||
|
self.channel_index.clear();
|
||||||
|
self.channel_participants.clear();
|
||||||
|
self.outgoing_invites.clear();
|
||||||
|
self.opened_buffers.clear();
|
||||||
|
self.opened_chats.clear();
|
||||||
|
self.disconnect_channel_buffers_task = None;
|
||||||
|
self.channel_states.clear();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn update_channels(
|
pub(crate) fn update_channels(
|
||||||
&mut self,
|
&mut self,
|
||||||
payload: proto::UpdateChannels,
|
payload: proto::UpdateChannels,
|
||||||
@@ -1051,6 +1081,7 @@ impl ChannelStore {
|
|||||||
visibility: channel.visibility(),
|
visibility: channel.visibility(),
|
||||||
name: channel.name.into(),
|
name: channel.name.into(),
|
||||||
parent_path: channel.parent_path.into_iter().map(ChannelId).collect(),
|
parent_path: channel.parent_path.into_iter().map(ChannelId).collect(),
|
||||||
|
channel_order: channel.channel_order,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ impl ChannelPathsInsertGuard<'_> {
|
|||||||
|
|
||||||
ret = existing_channel.visibility != channel_proto.visibility()
|
ret = existing_channel.visibility != channel_proto.visibility()
|
||||||
|| existing_channel.name != channel_proto.name
|
|| existing_channel.name != channel_proto.name
|
||||||
|| existing_channel.parent_path != parent_path;
|
|| existing_channel.parent_path != parent_path
|
||||||
|
|| existing_channel.channel_order != channel_proto.channel_order;
|
||||||
|
|
||||||
existing_channel.visibility = channel_proto.visibility();
|
existing_channel.visibility = channel_proto.visibility();
|
||||||
existing_channel.name = channel_proto.name.into();
|
existing_channel.name = channel_proto.name.into();
|
||||||
existing_channel.parent_path = parent_path;
|
existing_channel.parent_path = parent_path;
|
||||||
|
existing_channel.channel_order = channel_proto.channel_order;
|
||||||
} else {
|
} else {
|
||||||
self.channels_by_id.insert(
|
self.channels_by_id.insert(
|
||||||
ChannelId(channel_proto.id),
|
ChannelId(channel_proto.id),
|
||||||
@@ -74,6 +76,7 @@ impl ChannelPathsInsertGuard<'_> {
|
|||||||
visibility: channel_proto.visibility(),
|
visibility: channel_proto.visibility(),
|
||||||
name: channel_proto.name.into(),
|
name: channel_proto.name.into(),
|
||||||
parent_path,
|
parent_path,
|
||||||
|
channel_order: channel_proto.channel_order,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
self.insert_root(ChannelId(channel_proto.id));
|
self.insert_root(ChannelId(channel_proto.id));
|
||||||
@@ -100,17 +103,18 @@ impl Drop for ChannelPathsInsertGuard<'_> {
|
|||||||
fn channel_path_sorting_key(
|
fn channel_path_sorting_key(
|
||||||
id: ChannelId,
|
id: ChannelId,
|
||||||
channels_by_id: &BTreeMap<ChannelId, Arc<Channel>>,
|
channels_by_id: &BTreeMap<ChannelId, Arc<Channel>>,
|
||||||
) -> impl Iterator<Item = (&str, ChannelId)> {
|
) -> impl Iterator<Item = (i32, ChannelId)> {
|
||||||
let (parent_path, name) = channels_by_id
|
let (parent_path, order_and_id) =
|
||||||
.get(&id)
|
channels_by_id
|
||||||
.map_or((&[] as &[_], None), |channel| {
|
.get(&id)
|
||||||
(
|
.map_or((&[] as &[_], None), |channel| {
|
||||||
channel.parent_path.as_slice(),
|
(
|
||||||
Some((channel.name.as_ref(), channel.id)),
|
channel.parent_path.as_slice(),
|
||||||
)
|
Some((channel.channel_order, channel.id)),
|
||||||
});
|
)
|
||||||
|
});
|
||||||
parent_path
|
parent_path
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| Some((channels_by_id.get(id)?.name.as_ref(), *id)))
|
.filter_map(|id| Some((channels_by_id.get(id)?.channel_order, *id)))
|
||||||
.chain(name)
|
.chain(order_and_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ fn test_update_channels(cx: &mut App) {
|
|||||||
name: "b".to_string(),
|
name: "b".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: Vec::new(),
|
parent_path: Vec::new(),
|
||||||
|
channel_order: 1,
|
||||||
},
|
},
|
||||||
proto::Channel {
|
proto::Channel {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "a".to_string(),
|
name: "a".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: Vec::new(),
|
parent_path: Vec::new(),
|
||||||
|
channel_order: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -37,8 +39,8 @@ fn test_update_channels(cx: &mut App) {
|
|||||||
&channel_store,
|
&channel_store,
|
||||||
&[
|
&[
|
||||||
//
|
//
|
||||||
(0, "a".to_string()),
|
|
||||||
(0, "b".to_string()),
|
(0, "b".to_string()),
|
||||||
|
(0, "a".to_string()),
|
||||||
],
|
],
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -52,12 +54,14 @@ fn test_update_channels(cx: &mut App) {
|
|||||||
name: "x".to_string(),
|
name: "x".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: vec![1],
|
parent_path: vec![1],
|
||||||
|
channel_order: 1,
|
||||||
},
|
},
|
||||||
proto::Channel {
|
proto::Channel {
|
||||||
id: 4,
|
id: 4,
|
||||||
name: "y".to_string(),
|
name: "y".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: vec![2],
|
parent_path: vec![2],
|
||||||
|
channel_order: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -67,15 +71,111 @@ fn test_update_channels(cx: &mut App) {
|
|||||||
assert_channels(
|
assert_channels(
|
||||||
&channel_store,
|
&channel_store,
|
||||||
&[
|
&[
|
||||||
(0, "a".to_string()),
|
|
||||||
(1, "y".to_string()),
|
|
||||||
(0, "b".to_string()),
|
(0, "b".to_string()),
|
||||||
(1, "x".to_string()),
|
(1, "x".to_string()),
|
||||||
|
(0, "a".to_string()),
|
||||||
|
(1, "y".to_string()),
|
||||||
],
|
],
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_update_channels_order_independent(cx: &mut App) {
|
||||||
|
/// Based on: https://stackoverflow.com/a/59939809
|
||||||
|
fn unique_permutations<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
|
||||||
|
if items.len() == 1 {
|
||||||
|
vec![items]
|
||||||
|
} else {
|
||||||
|
let mut output: Vec<Vec<T>> = vec![];
|
||||||
|
|
||||||
|
for (ix, first) in items.iter().enumerate() {
|
||||||
|
let mut remaining_elements = items.clone();
|
||||||
|
remaining_elements.remove(ix);
|
||||||
|
for mut permutation in unique_permutations(remaining_elements) {
|
||||||
|
permutation.insert(0, first.clone());
|
||||||
|
output.push(permutation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let test_data = vec![
|
||||||
|
proto::Channel {
|
||||||
|
id: 6,
|
||||||
|
name: "β".to_string(),
|
||||||
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
|
parent_path: vec![1, 3],
|
||||||
|
channel_order: 1,
|
||||||
|
},
|
||||||
|
proto::Channel {
|
||||||
|
id: 5,
|
||||||
|
name: "α".to_string(),
|
||||||
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
|
parent_path: vec![1],
|
||||||
|
channel_order: 2,
|
||||||
|
},
|
||||||
|
proto::Channel {
|
||||||
|
id: 3,
|
||||||
|
name: "x".to_string(),
|
||||||
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
|
parent_path: vec![1],
|
||||||
|
channel_order: 1,
|
||||||
|
},
|
||||||
|
proto::Channel {
|
||||||
|
id: 4,
|
||||||
|
name: "y".to_string(),
|
||||||
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
|
parent_path: vec![2],
|
||||||
|
channel_order: 1,
|
||||||
|
},
|
||||||
|
proto::Channel {
|
||||||
|
id: 1,
|
||||||
|
name: "b".to_string(),
|
||||||
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
|
parent_path: Vec::new(),
|
||||||
|
channel_order: 1,
|
||||||
|
},
|
||||||
|
proto::Channel {
|
||||||
|
id: 2,
|
||||||
|
name: "a".to_string(),
|
||||||
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
|
parent_path: Vec::new(),
|
||||||
|
channel_order: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let channel_store = init_test(cx);
|
||||||
|
let permutations = unique_permutations(test_data);
|
||||||
|
|
||||||
|
for test_instance in permutations {
|
||||||
|
channel_store.update(cx, |channel_store, _| channel_store.reset());
|
||||||
|
|
||||||
|
update_channels(
|
||||||
|
&channel_store,
|
||||||
|
proto::UpdateChannels {
|
||||||
|
channels: test_instance,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_channels(
|
||||||
|
&channel_store,
|
||||||
|
&[
|
||||||
|
(0, "b".to_string()),
|
||||||
|
(1, "x".to_string()),
|
||||||
|
(2, "β".to_string()),
|
||||||
|
(1, "α".to_string()),
|
||||||
|
(0, "a".to_string()),
|
||||||
|
(1, "y".to_string()),
|
||||||
|
],
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn test_dangling_channel_paths(cx: &mut App) {
|
fn test_dangling_channel_paths(cx: &mut App) {
|
||||||
let channel_store = init_test(cx);
|
let channel_store = init_test(cx);
|
||||||
@@ -89,18 +189,21 @@ fn test_dangling_channel_paths(cx: &mut App) {
|
|||||||
name: "a".to_string(),
|
name: "a".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: vec![],
|
parent_path: vec![],
|
||||||
|
channel_order: 1,
|
||||||
},
|
},
|
||||||
proto::Channel {
|
proto::Channel {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "b".to_string(),
|
name: "b".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: vec![0],
|
parent_path: vec![0],
|
||||||
|
channel_order: 1,
|
||||||
},
|
},
|
||||||
proto::Channel {
|
proto::Channel {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "c".to_string(),
|
name: "c".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: vec![0, 1],
|
parent_path: vec![0, 1],
|
||||||
|
channel_order: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -147,6 +250,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
|||||||
name: "the-channel".to_string(),
|
name: "the-channel".to_string(),
|
||||||
visibility: proto::ChannelVisibility::Members as i32,
|
visibility: proto::ChannelVisibility::Members as i32,
|
||||||
parent_path: vec![],
|
parent_path: vec![],
|
||||||
|
channel_order: 1,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,11 +266,14 @@ CREATE TABLE "channels" (
|
|||||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"visibility" VARCHAR NOT NULL,
|
"visibility" VARCHAR NOT NULL,
|
||||||
"parent_path" TEXT NOT NULL,
|
"parent_path" TEXT NOT NULL,
|
||||||
"requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE
|
"requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
"channel_order" INTEGER NOT NULL DEFAULT 1
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
|
CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
|
||||||
|
|
||||||
|
CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order");
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
|
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
|
||||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
"user_id" INTEGER NOT NULL REFERENCES users (id),
|
"user_id" INTEGER NOT NULL REFERENCES users (id),
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Add channel_order column to channels table with default value
|
||||||
|
ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- Update channel_order for existing channels using ROW_NUMBER for deterministic ordering
|
||||||
|
UPDATE channels
|
||||||
|
SET channel_order = (
|
||||||
|
SELECT ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY parent_path
|
||||||
|
ORDER BY name, id
|
||||||
|
)
|
||||||
|
FROM channels c2
|
||||||
|
WHERE c2.id = channels.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for efficient ordering queries
|
||||||
|
CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order");
|
||||||
@@ -582,6 +582,7 @@ pub struct Channel {
|
|||||||
pub visibility: ChannelVisibility,
|
pub visibility: ChannelVisibility,
|
||||||
/// parent_path is the channel ids from the root to this one (not including this one)
|
/// parent_path is the channel ids from the root to this one (not including this one)
|
||||||
pub parent_path: Vec<ChannelId>,
|
pub parent_path: Vec<ChannelId>,
|
||||||
|
pub channel_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Channel {
|
impl Channel {
|
||||||
@@ -591,6 +592,7 @@ impl Channel {
|
|||||||
visibility: value.visibility,
|
visibility: value.visibility,
|
||||||
name: value.clone().name,
|
name: value.clone().name,
|
||||||
parent_path: value.ancestors().collect(),
|
parent_path: value.ancestors().collect(),
|
||||||
|
channel_order: value.channel_order,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,8 +602,13 @@ impl Channel {
|
|||||||
name: self.name.clone(),
|
name: self.name.clone(),
|
||||||
visibility: self.visibility.into(),
|
visibility: self.visibility.into(),
|
||||||
parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
|
parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
|
||||||
|
channel_order: self.channel_order,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn root_id(&self) -> ChannelId {
|
||||||
|
self.parent_path.first().copied().unwrap_or(self.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use rpc::{
|
|||||||
ErrorCode, ErrorCodeExt,
|
ErrorCode, ErrorCodeExt,
|
||||||
proto::{ChannelBufferVersion, VectorClockEntry, channel_member::Kind},
|
proto::{ChannelBufferVersion, VectorClockEntry, channel_member::Kind},
|
||||||
};
|
};
|
||||||
use sea_orm::{DbBackend, TryGetableMany};
|
use sea_orm::{ActiveValue, DbBackend, TryGetableMany};
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -59,16 +59,32 @@ impl Database {
|
|||||||
parent = Some(parent_channel);
|
parent = Some(parent_channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parent_path = parent
|
||||||
|
.as_ref()
|
||||||
|
.map_or(String::new(), |parent| parent.path());
|
||||||
|
|
||||||
|
// Find the maximum channel_order among siblings to set the new channel at the end
|
||||||
|
let max_order = if parent_path.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
max_order(&parent_path, &tx).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Creating channel '{}' with parent_path='{}', max_order={}, new_order={}",
|
||||||
|
name,
|
||||||
|
parent_path,
|
||||||
|
max_order,
|
||||||
|
max_order + 1
|
||||||
|
);
|
||||||
|
|
||||||
let channel = channel::ActiveModel {
|
let channel = channel::ActiveModel {
|
||||||
id: ActiveValue::NotSet,
|
id: ActiveValue::NotSet,
|
||||||
name: ActiveValue::Set(name.to_string()),
|
name: ActiveValue::Set(name.to_string()),
|
||||||
visibility: ActiveValue::Set(ChannelVisibility::Members),
|
visibility: ActiveValue::Set(ChannelVisibility::Members),
|
||||||
parent_path: ActiveValue::Set(
|
parent_path: ActiveValue::Set(parent_path),
|
||||||
parent
|
|
||||||
.as_ref()
|
|
||||||
.map_or(String::new(), |parent| parent.path()),
|
|
||||||
),
|
|
||||||
requires_zed_cla: ActiveValue::NotSet,
|
requires_zed_cla: ActiveValue::NotSet,
|
||||||
|
channel_order: ActiveValue::Set(max_order + 1),
|
||||||
}
|
}
|
||||||
.insert(&*tx)
|
.insert(&*tx)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -531,11 +547,7 @@ impl Database {
|
|||||||
.get_channel_descendants_excluding_self(channels.iter(), tx)
|
.get_channel_descendants_excluding_self(channels.iter(), tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for channel in channels {
|
descendants.extend(channels);
|
||||||
if let Err(ix) = descendants.binary_search_by_key(&channel.path(), |c| c.path()) {
|
|
||||||
descendants.insert(ix, channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let roles_by_channel_id = channel_memberships
|
let roles_by_channel_id = channel_memberships
|
||||||
.iter()
|
.iter()
|
||||||
@@ -952,11 +964,14 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let root_id = channel.root_id();
|
let root_id = channel.root_id();
|
||||||
|
let new_parent_path = new_parent.path();
|
||||||
let old_path = format!("{}{}/", channel.parent_path, channel.id);
|
let old_path = format!("{}{}/", channel.parent_path, channel.id);
|
||||||
let new_path = format!("{}{}/", new_parent.path(), channel.id);
|
let new_path = format!("{}{}/", &new_parent_path, channel.id);
|
||||||
|
let new_order = max_order(&new_parent_path, &tx).await? + 1;
|
||||||
|
|
||||||
let mut model = channel.into_active_model();
|
let mut model = channel.into_active_model();
|
||||||
model.parent_path = ActiveValue::Set(new_parent.path());
|
model.parent_path = ActiveValue::Set(new_parent.path());
|
||||||
|
model.channel_order = ActiveValue::Set(new_order);
|
||||||
let channel = model.update(&*tx).await?;
|
let channel = model.update(&*tx).await?;
|
||||||
|
|
||||||
let descendent_ids =
|
let descendent_ids =
|
||||||
@@ -986,6 +1001,137 @@ impl Database {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn reorder_channel(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
direction: proto::reorder_channel::Direction,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<Vec<Channel>> {
|
||||||
|
self.transaction(|tx| async move {
|
||||||
|
let mut channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||||
|
|
||||||
|
if channel.is_root() {
|
||||||
|
log::info!("Skipping reorder of root channel {}", channel.id,);
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Reordering channel {} (parent_path: '{}', order: {})",
|
||||||
|
channel.id,
|
||||||
|
channel.parent_path,
|
||||||
|
channel.channel_order
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if user is admin of the channel
|
||||||
|
self.check_user_is_channel_admin(&channel, user_id, &tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Find the sibling channel to swap with
|
||||||
|
let sibling_channel = match direction {
|
||||||
|
proto::reorder_channel::Direction::Up => {
|
||||||
|
log::info!(
|
||||||
|
"Looking for sibling with parent_path='{}' and order < {}",
|
||||||
|
channel.parent_path,
|
||||||
|
channel.channel_order
|
||||||
|
);
|
||||||
|
// Find channel with highest order less than current
|
||||||
|
channel::Entity::find()
|
||||||
|
.filter(
|
||||||
|
channel::Column::ParentPath
|
||||||
|
.eq(&channel.parent_path)
|
||||||
|
.and(channel::Column::ChannelOrder.lt(channel.channel_order)),
|
||||||
|
)
|
||||||
|
.order_by_desc(channel::Column::ChannelOrder)
|
||||||
|
.one(&*tx)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
proto::reorder_channel::Direction::Down => {
|
||||||
|
log::info!(
|
||||||
|
"Looking for sibling with parent_path='{}' and order > {}",
|
||||||
|
channel.parent_path,
|
||||||
|
channel.channel_order
|
||||||
|
);
|
||||||
|
// Find channel with lowest order greater than current
|
||||||
|
channel::Entity::find()
|
||||||
|
.filter(
|
||||||
|
channel::Column::ParentPath
|
||||||
|
.eq(&channel.parent_path)
|
||||||
|
.and(channel::Column::ChannelOrder.gt(channel.channel_order)),
|
||||||
|
)
|
||||||
|
.order_by_asc(channel::Column::ChannelOrder)
|
||||||
|
.one(&*tx)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sibling_channel = match sibling_channel {
|
||||||
|
Some(sibling) => {
|
||||||
|
log::info!(
|
||||||
|
"Found sibling {} (parent_path: '{}', order: {})",
|
||||||
|
sibling.id,
|
||||||
|
sibling.parent_path,
|
||||||
|
sibling.channel_order
|
||||||
|
);
|
||||||
|
sibling
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::warn!("No sibling found to swap with");
|
||||||
|
// No sibling to swap with
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_order = channel.channel_order;
|
||||||
|
let sibling_order = sibling_channel.channel_order;
|
||||||
|
|
||||||
|
channel::ActiveModel {
|
||||||
|
id: ActiveValue::Unchanged(sibling_channel.id),
|
||||||
|
channel_order: ActiveValue::Set(current_order),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.update(&*tx)
|
||||||
|
.await?;
|
||||||
|
sibling_channel.channel_order = current_order;
|
||||||
|
|
||||||
|
channel::ActiveModel {
|
||||||
|
id: ActiveValue::Unchanged(channel.id),
|
||||||
|
channel_order: ActiveValue::Set(sibling_order),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.update(&*tx)
|
||||||
|
.await?;
|
||||||
|
channel.channel_order = sibling_order;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Reorder complete. Swapped channels {} and {}",
|
||||||
|
channel.id,
|
||||||
|
sibling_channel.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let swapped_channels = vec![
|
||||||
|
Channel::from_model(channel),
|
||||||
|
Channel::from_model(sibling_channel),
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok(swapped_channels)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn max_order(parent_path: &str, tx: &TransactionHandle) -> Result<i32> {
|
||||||
|
let max_order = channel::Entity::find()
|
||||||
|
.filter(channel::Column::ParentPath.eq(parent_path))
|
||||||
|
.select_only()
|
||||||
|
.column_as(channel::Column::ChannelOrder.max(), "max_order")
|
||||||
|
.into_tuple::<Option<i32>>()
|
||||||
|
.one(&**tx)
|
||||||
|
.await?
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(max_order)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||||
|
|||||||
@@ -66,6 +66,87 @@ impl Database {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete all channel chat participants from previous servers
|
||||||
|
pub async fn delete_stale_channel_chat_participants(
|
||||||
|
&self,
|
||||||
|
environment: &str,
|
||||||
|
new_server_id: ServerId,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.transaction(|tx| async move {
|
||||||
|
let stale_server_epochs = self
|
||||||
|
.stale_server_ids(environment, new_server_id, &tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
channel_chat_participant::Entity::delete_many()
|
||||||
|
.filter(
|
||||||
|
channel_chat_participant::Column::ConnectionServerId
|
||||||
|
.is_in(stale_server_epochs.iter().copied()),
|
||||||
|
)
|
||||||
|
.exec(&*tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_old_worktree_entries(&self, server_id: ServerId) -> Result<()> {
|
||||||
|
self.transaction(|tx| async move {
|
||||||
|
use sea_orm::Statement;
|
||||||
|
use sea_orm::sea_query::{Expr, Query};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let delete_query = Query::delete()
|
||||||
|
.from_table(worktree_entry::Entity)
|
||||||
|
.and_where(
|
||||||
|
Expr::tuple([
|
||||||
|
Expr::col((worktree_entry::Entity, worktree_entry::Column::ProjectId))
|
||||||
|
.into(),
|
||||||
|
Expr::col((worktree_entry::Entity, worktree_entry::Column::WorktreeId))
|
||||||
|
.into(),
|
||||||
|
Expr::col((worktree_entry::Entity, worktree_entry::Column::Id)).into(),
|
||||||
|
])
|
||||||
|
.in_subquery(
|
||||||
|
Query::select()
|
||||||
|
.columns([
|
||||||
|
(worktree_entry::Entity, worktree_entry::Column::ProjectId),
|
||||||
|
(worktree_entry::Entity, worktree_entry::Column::WorktreeId),
|
||||||
|
(worktree_entry::Entity, worktree_entry::Column::Id),
|
||||||
|
])
|
||||||
|
.from(worktree_entry::Entity)
|
||||||
|
.inner_join(
|
||||||
|
project::Entity,
|
||||||
|
Expr::col((project::Entity, project::Column::Id)).equals((
|
||||||
|
worktree_entry::Entity,
|
||||||
|
worktree_entry::Column::ProjectId,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.and_where(project::Column::HostConnectionServerId.ne(server_id))
|
||||||
|
.limit(10000)
|
||||||
|
.to_owned(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let statement = Statement::from_sql_and_values(
|
||||||
|
tx.get_database_backend(),
|
||||||
|
delete_query
|
||||||
|
.to_string(sea_orm::sea_query::PostgresQueryBuilder)
|
||||||
|
.as_str(),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = tx.execute(statement).await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes any stale servers in the environment that don't match the `new_server_id`.
|
/// Deletes any stale servers in the environment that don't match the `new_server_id`.
|
||||||
pub async fn delete_stale_servers(
|
pub async fn delete_stale_servers(
|
||||||
&self,
|
&self,
|
||||||
@@ -86,7 +167,7 @@ impl Database {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stale_server_ids(
|
pub async fn stale_server_ids(
|
||||||
&self,
|
&self,
|
||||||
environment: &str,
|
environment: &str,
|
||||||
new_server_id: ServerId,
|
new_server_id: ServerId,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ pub struct Model {
|
|||||||
pub visibility: ChannelVisibility,
|
pub visibility: ChannelVisibility,
|
||||||
pub parent_path: String,
|
pub parent_path: String,
|
||||||
pub requires_zed_cla: bool,
|
pub requires_zed_cla: bool,
|
||||||
|
/// The order of this channel relative to its siblings within the same parent.
|
||||||
|
/// Lower values appear first. Channels are sorted by parent_path first, then by channel_order.
|
||||||
|
pub channel_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Model {
|
impl Model {
|
||||||
|
|||||||
@@ -172,16 +172,40 @@ impl Drop for TestDb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn assert_channel_tree_matches(actual: Vec<Channel>, expected: Vec<Channel>) {
|
||||||
|
let expected_channels = expected.into_iter().collect::<HashSet<_>>();
|
||||||
|
let actual_channels = actual.into_iter().collect::<HashSet<_>>();
|
||||||
|
pretty_assertions::assert_eq!(expected_channels, actual_channels);
|
||||||
|
}
|
||||||
|
|
||||||
fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec<Channel> {
|
fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec<Channel> {
|
||||||
channels
|
use std::collections::HashMap;
|
||||||
.iter()
|
|
||||||
.map(|(id, parent_path, name)| Channel {
|
let mut result = Vec::new();
|
||||||
|
let mut order_by_parent: HashMap<Vec<ChannelId>, i32> = HashMap::new();
|
||||||
|
|
||||||
|
for (id, parent_path, name) in channels {
|
||||||
|
let parent_key = parent_path.to_vec();
|
||||||
|
let order = if parent_key.is_empty() {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
*order_by_parent
|
||||||
|
.entry(parent_key.clone())
|
||||||
|
.and_modify(|e| *e += 1)
|
||||||
|
.or_insert(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push(Channel {
|
||||||
id: *id,
|
id: *id,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
visibility: ChannelVisibility::Members,
|
visibility: ChannelVisibility::Members,
|
||||||
parent_path: parent_path.to_vec(),
|
parent_path: parent_key,
|
||||||
})
|
channel_order: order,
|
||||||
.collect()
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
|
static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
|
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
|
||||||
tests::{channel_tree, new_test_connection, new_test_user},
|
tests::{assert_channel_tree_matches, channel_tree, new_test_connection, new_test_user},
|
||||||
},
|
},
|
||||||
test_both_dbs,
|
test_both_dbs,
|
||||||
};
|
};
|
||||||
use rpc::{
|
use rpc::{
|
||||||
ConnectionId,
|
ConnectionId,
|
||||||
proto::{self},
|
proto::{self, reorder_channel},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::{collections::HashSet, sync::Arc};
|
||||||
|
|
||||||
test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
|
test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
|
||||||
|
|
||||||
@@ -59,28 +59,28 @@ async fn test_channels(db: &Arc<Database>) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = db.get_channels_for_user(a_id).await.unwrap();
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||||
assert_eq!(
|
assert_channel_tree_matches(
|
||||||
result.channels,
|
result.channels,
|
||||||
channel_tree(&[
|
channel_tree(&[
|
||||||
(zed_id, &[], "zed"),
|
(zed_id, &[], "zed"),
|
||||||
(crdb_id, &[zed_id], "crdb"),
|
(crdb_id, &[zed_id], "crdb"),
|
||||||
(livestreaming_id, &[zed_id], "livestreaming",),
|
(livestreaming_id, &[zed_id], "livestreaming"),
|
||||||
(replace_id, &[zed_id], "replace"),
|
(replace_id, &[zed_id], "replace"),
|
||||||
(rust_id, &[], "rust"),
|
(rust_id, &[], "rust"),
|
||||||
(cargo_id, &[rust_id], "cargo"),
|
(cargo_id, &[rust_id], "cargo"),
|
||||||
(cargo_ra_id, &[rust_id, cargo_id], "cargo-ra",)
|
(cargo_ra_id, &[rust_id, cargo_id], "cargo-ra"),
|
||||||
],)
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = db.get_channels_for_user(b_id).await.unwrap();
|
let result = db.get_channels_for_user(b_id).await.unwrap();
|
||||||
assert_eq!(
|
assert_channel_tree_matches(
|
||||||
result.channels,
|
result.channels,
|
||||||
channel_tree(&[
|
channel_tree(&[
|
||||||
(zed_id, &[], "zed"),
|
(zed_id, &[], "zed"),
|
||||||
(crdb_id, &[zed_id], "crdb"),
|
(crdb_id, &[zed_id], "crdb"),
|
||||||
(livestreaming_id, &[zed_id], "livestreaming",),
|
(livestreaming_id, &[zed_id], "livestreaming"),
|
||||||
(replace_id, &[zed_id], "replace")
|
(replace_id, &[zed_id], "replace"),
|
||||||
],)
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update member permissions
|
// Update member permissions
|
||||||
@@ -94,14 +94,14 @@ async fn test_channels(db: &Arc<Database>) {
|
|||||||
assert!(set_channel_admin.is_ok());
|
assert!(set_channel_admin.is_ok());
|
||||||
|
|
||||||
let result = db.get_channels_for_user(b_id).await.unwrap();
|
let result = db.get_channels_for_user(b_id).await.unwrap();
|
||||||
assert_eq!(
|
assert_channel_tree_matches(
|
||||||
result.channels,
|
result.channels,
|
||||||
channel_tree(&[
|
channel_tree(&[
|
||||||
(zed_id, &[], "zed"),
|
(zed_id, &[], "zed"),
|
||||||
(crdb_id, &[zed_id], "crdb"),
|
(crdb_id, &[zed_id], "crdb"),
|
||||||
(livestreaming_id, &[zed_id], "livestreaming",),
|
(livestreaming_id, &[zed_id], "livestreaming"),
|
||||||
(replace_id, &[zed_id], "replace")
|
(replace_id, &[zed_id], "replace"),
|
||||||
],)
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a single channel
|
// Remove a single channel
|
||||||
@@ -313,8 +313,8 @@ async fn test_channel_renames(db: &Arc<Database>) {
|
|||||||
|
|
||||||
test_both_dbs!(
|
test_both_dbs!(
|
||||||
test_db_channel_moving,
|
test_db_channel_moving,
|
||||||
test_channels_moving_postgres,
|
test_db_channel_moving_postgres,
|
||||||
test_channels_moving_sqlite
|
test_db_channel_moving_sqlite
|
||||||
);
|
);
|
||||||
|
|
||||||
async fn test_db_channel_moving(db: &Arc<Database>) {
|
async fn test_db_channel_moving(db: &Arc<Database>) {
|
||||||
@@ -343,16 +343,14 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let livestreaming_dag_id = db
|
let livestreaming_sub_id = db
|
||||||
.create_sub_channel("livestreaming_dag", livestreaming_id, a_id)
|
.create_sub_channel("livestreaming_sub", livestreaming_id, a_id)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// sanity check
|
// sanity check
|
||||||
// Initial DAG:
|
|
||||||
// /- gpui2
|
// /- gpui2
|
||||||
// zed -- crdb - livestreaming - livestreaming_dag
|
// zed -- crdb - livestreaming - livestreaming_sub
|
||||||
let result = db.get_channels_for_user(a_id).await.unwrap();
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||||
assert_channel_tree(
|
assert_channel_tree(
|
||||||
result.channels,
|
result.channels,
|
||||||
@@ -360,10 +358,242 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
|
|||||||
(zed_id, &[]),
|
(zed_id, &[]),
|
||||||
(crdb_id, &[zed_id]),
|
(crdb_id, &[zed_id]),
|
||||||
(livestreaming_id, &[zed_id, crdb_id]),
|
(livestreaming_id, &[zed_id, crdb_id]),
|
||||||
(livestreaming_dag_id, &[zed_id, crdb_id, livestreaming_id]),
|
(livestreaming_sub_id, &[zed_id, crdb_id, livestreaming_id]),
|
||||||
(gpui2_id, &[zed_id]),
|
(gpui2_id, &[zed_id]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check that we can do a simple leaf -> leaf move
|
||||||
|
db.move_channel(livestreaming_sub_id, crdb_id, a_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// /- gpui2
|
||||||
|
// zed -- crdb -- livestreaming
|
||||||
|
// \- livestreaming_sub
|
||||||
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||||
|
assert_channel_tree(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(zed_id, &[]),
|
||||||
|
(crdb_id, &[zed_id]),
|
||||||
|
(livestreaming_id, &[zed_id, crdb_id]),
|
||||||
|
(livestreaming_sub_id, &[zed_id, crdb_id]),
|
||||||
|
(gpui2_id, &[zed_id]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that we can move a whole subtree at once
|
||||||
|
db.move_channel(crdb_id, gpui2_id, a_id).await.unwrap();
|
||||||
|
|
||||||
|
// zed -- gpui2 -- crdb -- livestreaming
|
||||||
|
// \- livestreaming_sub
|
||||||
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
||||||
|
assert_channel_tree(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(zed_id, &[]),
|
||||||
|
(gpui2_id, &[zed_id]),
|
||||||
|
(crdb_id, &[zed_id, gpui2_id]),
|
||||||
|
(livestreaming_id, &[zed_id, gpui2_id, crdb_id]),
|
||||||
|
(livestreaming_sub_id, &[zed_id, gpui2_id, crdb_id]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test_both_dbs!(
|
||||||
|
test_channel_reordering,
|
||||||
|
test_channel_reordering_postgres,
|
||||||
|
test_channel_reordering_sqlite
|
||||||
|
);
|
||||||
|
|
||||||
|
async fn test_channel_reordering(db: &Arc<Database>) {
|
||||||
|
let admin_id = db
|
||||||
|
.create_user(
|
||||||
|
"admin@example.com",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
NewUserParams {
|
||||||
|
github_login: "admin".into(),
|
||||||
|
github_user_id: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.user_id;
|
||||||
|
|
||||||
|
let user_id = db
|
||||||
|
.create_user(
|
||||||
|
"user@example.com",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
NewUserParams {
|
||||||
|
github_login: "user".into(),
|
||||||
|
github_user_id: 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.user_id;
|
||||||
|
|
||||||
|
// Create a root channel with some sub-channels
|
||||||
|
let root_id = db.create_root_channel("root", admin_id).await.unwrap();
|
||||||
|
|
||||||
|
// Invite user to root channel so they can see the sub-channels
|
||||||
|
db.invite_channel_member(root_id, user_id, admin_id, ChannelRole::Member)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
db.respond_to_channel_invite(root_id, user_id, true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let alpha_id = db
|
||||||
|
.create_sub_channel("alpha", root_id, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let beta_id = db
|
||||||
|
.create_sub_channel("beta", root_id, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let gamma_id = db
|
||||||
|
.create_sub_channel("gamma", root_id, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Initial order should be: root, alpha (order=1), beta (order=2), gamma (order=3)
|
||||||
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||||
|
assert_channel_tree_order(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(root_id, &[], 1),
|
||||||
|
(alpha_id, &[root_id], 1),
|
||||||
|
(beta_id, &[root_id], 2),
|
||||||
|
(gamma_id, &[root_id], 3),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test moving beta up (should swap with alpha)
|
||||||
|
let updated_channels = db
|
||||||
|
.reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify that beta and alpha were returned as updated
|
||||||
|
assert_eq!(updated_channels.len(), 2);
|
||||||
|
let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
|
||||||
|
assert!(updated_ids.contains(&alpha_id));
|
||||||
|
assert!(updated_ids.contains(&beta_id));
|
||||||
|
|
||||||
|
// Now order should be: root, beta (order=1), alpha (order=2), gamma (order=3)
|
||||||
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||||
|
assert_channel_tree_order(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(root_id, &[], 1),
|
||||||
|
(beta_id, &[root_id], 1),
|
||||||
|
(alpha_id, &[root_id], 2),
|
||||||
|
(gamma_id, &[root_id], 3),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test moving gamma down (should be no-op since it's already last)
|
||||||
|
let updated_channels = db
|
||||||
|
.reorder_channel(gamma_id, reorder_channel::Direction::Down, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Should return just nothing
|
||||||
|
assert_eq!(updated_channels.len(), 0);
|
||||||
|
|
||||||
|
// Test moving alpha down (should swap with gamma)
|
||||||
|
let updated_channels = db
|
||||||
|
.reorder_channel(alpha_id, reorder_channel::Direction::Down, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify that alpha and gamma were returned as updated
|
||||||
|
assert_eq!(updated_channels.len(), 2);
|
||||||
|
let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
|
||||||
|
assert!(updated_ids.contains(&alpha_id));
|
||||||
|
assert!(updated_ids.contains(&gamma_id));
|
||||||
|
|
||||||
|
// Now order should be: root, beta (order=1), gamma (order=2), alpha (order=3)
|
||||||
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||||
|
assert_channel_tree_order(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(root_id, &[], 1),
|
||||||
|
(beta_id, &[root_id], 1),
|
||||||
|
(gamma_id, &[root_id], 2),
|
||||||
|
(alpha_id, &[root_id], 3),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test that non-admin cannot reorder
|
||||||
|
let reorder_result = db
|
||||||
|
.reorder_channel(beta_id, reorder_channel::Direction::Up, user_id)
|
||||||
|
.await;
|
||||||
|
assert!(reorder_result.is_err());
|
||||||
|
|
||||||
|
// Test moving beta up (should be no-op since it's already first)
|
||||||
|
let updated_channels = db
|
||||||
|
.reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Should return nothing
|
||||||
|
assert_eq!(updated_channels.len(), 0);
|
||||||
|
|
||||||
|
// Adding a channel to an existing ordering should add it to the end
|
||||||
|
let delta_id = db
|
||||||
|
.create_sub_channel("delta", root_id, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||||
|
assert_channel_tree_order(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(root_id, &[], 1),
|
||||||
|
(beta_id, &[root_id], 1),
|
||||||
|
(gamma_id, &[root_id], 2),
|
||||||
|
(alpha_id, &[root_id], 3),
|
||||||
|
(delta_id, &[root_id], 4),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// And moving a channel into an existing ordering should add it to the end
|
||||||
|
let eta_id = db
|
||||||
|
.create_sub_channel("eta", delta_id, admin_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||||
|
assert_channel_tree_order(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(root_id, &[], 1),
|
||||||
|
(beta_id, &[root_id], 1),
|
||||||
|
(gamma_id, &[root_id], 2),
|
||||||
|
(alpha_id, &[root_id], 3),
|
||||||
|
(delta_id, &[root_id], 4),
|
||||||
|
(eta_id, &[root_id, delta_id], 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
db.move_channel(eta_id, root_id, admin_id).await.unwrap();
|
||||||
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
||||||
|
assert_channel_tree_order(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(root_id, &[], 1),
|
||||||
|
(beta_id, &[root_id], 1),
|
||||||
|
(gamma_id, &[root_id], 2),
|
||||||
|
(alpha_id, &[root_id], 3),
|
||||||
|
(delta_id, &[root_id], 4),
|
||||||
|
(eta_id, &[root_id], 5),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test_both_dbs!(
|
test_both_dbs!(
|
||||||
@@ -422,6 +652,20 @@ async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
|
|||||||
(livestreaming_id, &[zed_id, projects_id]),
|
(livestreaming_id, &[zed_id, projects_id]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Can't un-root a root channel
|
||||||
|
db.move_channel(zed_id, livestreaming_id, user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
let result = db.get_channels_for_user(user_id).await.unwrap();
|
||||||
|
assert_channel_tree(
|
||||||
|
result.channels,
|
||||||
|
&[
|
||||||
|
(zed_id, &[]),
|
||||||
|
(projects_id, &[zed_id]),
|
||||||
|
(livestreaming_id, &[zed_id, projects_id]),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test_both_dbs!(
|
test_both_dbs!(
|
||||||
@@ -745,10 +989,29 @@ fn assert_channel_tree(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId
|
|||||||
let actual = actual
|
let actual = actual
|
||||||
.iter()
|
.iter()
|
||||||
.map(|channel| (channel.id, channel.parent_path.as_slice()))
|
.map(|channel| (channel.id, channel.parent_path.as_slice()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<HashSet<_>>();
|
||||||
pretty_assertions::assert_eq!(
|
let expected = expected
|
||||||
actual,
|
.iter()
|
||||||
expected.to_vec(),
|
.map(|(id, parents)| (*id, *parents))
|
||||||
"wrong channel ids and parent paths"
|
.collect::<HashSet<_>>();
|
||||||
);
|
pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn assert_channel_tree_order(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId], i32)]) {
|
||||||
|
let actual = actual
|
||||||
|
.iter()
|
||||||
|
.map(|channel| {
|
||||||
|
(
|
||||||
|
channel.id,
|
||||||
|
channel.parent_path.as_slice(),
|
||||||
|
channel.channel_order,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let expected = expected
|
||||||
|
.iter()
|
||||||
|
.map(|(id, parents, order)| (*id, *parents, *order))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ impl Server {
|
|||||||
.add_request_handler(get_notifications)
|
.add_request_handler(get_notifications)
|
||||||
.add_request_handler(mark_notification_as_read)
|
.add_request_handler(mark_notification_as_read)
|
||||||
.add_request_handler(move_channel)
|
.add_request_handler(move_channel)
|
||||||
|
.add_request_handler(reorder_channel)
|
||||||
.add_request_handler(follow)
|
.add_request_handler(follow)
|
||||||
.add_message_handler(unfollow)
|
.add_message_handler(unfollow)
|
||||||
.add_message_handler(update_followers)
|
.add_message_handler(update_followers)
|
||||||
@@ -433,6 +434,16 @@ impl Server {
|
|||||||
tracing::info!("waiting for cleanup timeout");
|
tracing::info!("waiting for cleanup timeout");
|
||||||
timeout.await;
|
timeout.await;
|
||||||
tracing::info!("cleanup timeout expired, retrieving stale rooms");
|
tracing::info!("cleanup timeout expired, retrieving stale rooms");
|
||||||
|
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.delete_stale_channel_chat_participants(
|
||||||
|
&app_state.config.zed_environment,
|
||||||
|
server_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.trace_err();
|
||||||
|
|
||||||
if let Some((room_ids, channel_ids)) = app_state
|
if let Some((room_ids, channel_ids)) = app_state
|
||||||
.db
|
.db
|
||||||
.stale_server_resource_ids(&app_state.config.zed_environment, server_id)
|
.stale_server_resource_ids(&app_state.config.zed_environment, server_id)
|
||||||
@@ -554,6 +565,21 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.delete_stale_channel_chat_participants(
|
||||||
|
&app_state.config.zed_environment,
|
||||||
|
server_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.trace_err();
|
||||||
|
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.clear_old_worktree_entries(server_id)
|
||||||
|
.await
|
||||||
|
.trace_err();
|
||||||
|
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.delete_stale_servers(&app_state.config.zed_environment, server_id)
|
.delete_stale_servers(&app_state.config.zed_environment, server_id)
|
||||||
@@ -3195,6 +3221,51 @@ async fn move_channel(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reorder_channel(
|
||||||
|
request: proto::ReorderChannel,
|
||||||
|
response: Response<proto::ReorderChannel>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<()> {
|
||||||
|
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||||
|
let direction = request.direction();
|
||||||
|
|
||||||
|
let updated_channels = session
|
||||||
|
.db()
|
||||||
|
.await
|
||||||
|
.reorder_channel(channel_id, direction, session.user_id())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(root_id) = updated_channels.first().map(|channel| channel.root_id()) {
|
||||||
|
let connection_pool = session.connection_pool().await;
|
||||||
|
for (connection_id, role) in connection_pool.channel_connection_ids(root_id) {
|
||||||
|
let channels = updated_channels
|
||||||
|
.iter()
|
||||||
|
.filter_map(|channel| {
|
||||||
|
if role.can_see_channel(channel.visibility) {
|
||||||
|
Some(channel.to_proto())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if channels.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let update = proto::UpdateChannels {
|
||||||
|
channels,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
session.peer.send(connection_id, update.clone())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.send(Ack {})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the list of channel members
|
/// Get the list of channel members
|
||||||
async fn get_channel_members(
|
async fn get_channel_members(
|
||||||
request: proto::GetChannelMembers,
|
request: proto::GetChannelMembers,
|
||||||
|
|||||||
@@ -2624,6 +2624,7 @@ async fn test_git_diff_base_change(
|
|||||||
client_a.fs().set_head_for_repo(
|
client_a.fs().set_head_for_repo(
|
||||||
Path::new("/dir/.git"),
|
Path::new("/dir/.git"),
|
||||||
&[("a.txt".into(), committed_text.clone())],
|
&[("a.txt".into(), committed_text.clone())],
|
||||||
|
"deadbeef",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the buffer
|
// Create the buffer
|
||||||
@@ -2717,6 +2718,7 @@ async fn test_git_diff_base_change(
|
|||||||
client_a.fs().set_head_for_repo(
|
client_a.fs().set_head_for_repo(
|
||||||
Path::new("/dir/.git"),
|
Path::new("/dir/.git"),
|
||||||
&[("a.txt".into(), new_committed_text.clone())],
|
&[("a.txt".into(), new_committed_text.clone())],
|
||||||
|
"deadbeef",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for buffer_local_a to receive it
|
// Wait for buffer_local_a to receive it
|
||||||
@@ -3006,6 +3008,7 @@ async fn test_git_status_sync(
|
|||||||
client_a.fs().set_head_for_repo(
|
client_a.fs().set_head_for_repo(
|
||||||
path!("/dir/.git").as_ref(),
|
path!("/dir/.git").as_ref(),
|
||||||
&[("b.txt".into(), "B".into()), ("c.txt".into(), "c".into())],
|
&[("b.txt".into(), "B".into()), ("c.txt".into(), "c".into())],
|
||||||
|
"deadbeef",
|
||||||
);
|
);
|
||||||
client_a.fs().set_index_for_repo(
|
client_a.fs().set_index_for_repo(
|
||||||
path!("/dir/.git").as_ref(),
|
path!("/dir/.git").as_ref(),
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ impl CompletionProvider for MessageEditorCompletionProvider {
|
|||||||
_position: language::Anchor,
|
_position: language::Anchor,
|
||||||
text: &str,
|
text: &str,
|
||||||
_trigger_in_words: bool,
|
_trigger_in_words: bool,
|
||||||
|
_menu_is_open: bool,
|
||||||
_cx: &mut Context<Editor>,
|
_cx: &mut Context<Editor>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
text == "@"
|
text == "@"
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ use fuzzy::{StringMatchCandidate, match_strings};
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
|
AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
|
||||||
Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
|
Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
|
||||||
ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render,
|
KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
|
||||||
SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions, anchored,
|
Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions,
|
||||||
canvas, deferred, div, fill, list, point, prelude::*, px,
|
anchored, canvas, deferred, div, fill, list, point, prelude::*, px,
|
||||||
};
|
};
|
||||||
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
|
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
|
||||||
use project::{Fs, Project};
|
use project::{Fs, Project};
|
||||||
@@ -52,6 +52,8 @@ actions!(
|
|||||||
StartMoveChannel,
|
StartMoveChannel,
|
||||||
MoveSelected,
|
MoveSelected,
|
||||||
InsertSpace,
|
InsertSpace,
|
||||||
|
MoveChannelUp,
|
||||||
|
MoveChannelDown,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1961,6 +1963,33 @@ impl CollabPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn move_channel_up(&mut self, _: &MoveChannelUp, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Some(channel) = self.selected_channel() {
|
||||||
|
self.channel_store.update(cx, |store, cx| {
|
||||||
|
store
|
||||||
|
.reorder_channel(channel.id, proto::reorder_channel::Direction::Up, cx)
|
||||||
|
.detach_and_prompt_err("Failed to move channel up", window, cx, |_, _, _| None)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_channel_down(
|
||||||
|
&mut self,
|
||||||
|
_: &MoveChannelDown,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
if let Some(channel) = self.selected_channel() {
|
||||||
|
self.channel_store.update(cx, |store, cx| {
|
||||||
|
store
|
||||||
|
.reorder_channel(channel.id, proto::reorder_channel::Direction::Down, cx)
|
||||||
|
.detach_and_prompt_err("Failed to move channel down", window, cx, |_, _, _| {
|
||||||
|
None
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn open_channel_notes(
|
fn open_channel_notes(
|
||||||
&mut self,
|
&mut self,
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
@@ -1974,7 +2003,7 @@ impl CollabPanel {
|
|||||||
|
|
||||||
fn show_inline_context_menu(
|
fn show_inline_context_menu(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &menu::SecondaryConfirm,
|
_: &Secondary,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
@@ -2003,6 +2032,21 @@ impl CollabPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
|
||||||
|
let mut dispatch_context = KeyContext::new_with_defaults();
|
||||||
|
dispatch_context.add("CollabPanel");
|
||||||
|
dispatch_context.add("menu");
|
||||||
|
|
||||||
|
let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window) {
|
||||||
|
"editing"
|
||||||
|
} else {
|
||||||
|
"not_editing"
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch_context.add(identifier);
|
||||||
|
dispatch_context
|
||||||
|
}
|
||||||
|
|
||||||
fn selected_channel(&self) -> Option<&Arc<Channel>> {
|
fn selected_channel(&self) -> Option<&Arc<Channel>> {
|
||||||
self.selection
|
self.selection
|
||||||
.and_then(|ix| self.entries.get(ix))
|
.and_then(|ix| self.entries.get(ix))
|
||||||
@@ -2965,7 +3009,7 @@ fn render_tree_branch(
|
|||||||
impl Render for CollabPanel {
|
impl Render for CollabPanel {
|
||||||
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()
|
v_flex()
|
||||||
.key_context("CollabPanel")
|
.key_context(self.dispatch_context(window, cx))
|
||||||
.on_action(cx.listener(CollabPanel::cancel))
|
.on_action(cx.listener(CollabPanel::cancel))
|
||||||
.on_action(cx.listener(CollabPanel::select_next))
|
.on_action(cx.listener(CollabPanel::select_next))
|
||||||
.on_action(cx.listener(CollabPanel::select_previous))
|
.on_action(cx.listener(CollabPanel::select_previous))
|
||||||
@@ -2977,6 +3021,8 @@ impl Render for CollabPanel {
|
|||||||
.on_action(cx.listener(CollabPanel::collapse_selected_channel))
|
.on_action(cx.listener(CollabPanel::collapse_selected_channel))
|
||||||
.on_action(cx.listener(CollabPanel::expand_selected_channel))
|
.on_action(cx.listener(CollabPanel::expand_selected_channel))
|
||||||
.on_action(cx.listener(CollabPanel::start_move_selected_channel))
|
.on_action(cx.listener(CollabPanel::start_move_selected_channel))
|
||||||
|
.on_action(cx.listener(CollabPanel::move_channel_up))
|
||||||
|
.on_action(cx.listener(CollabPanel::move_channel_down))
|
||||||
.track_focus(&self.focus_handle(cx))
|
.track_focus(&self.focus_handle(cx))
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(if self.user_store.read(cx).current_user().is_none() {
|
.child(if self.user_store.read(cx).current_user().is_none() {
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ impl PickerDelegate for CommandPaletteDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn humanize_action_name(name: &str) -> String {
|
pub fn humanize_action_name(name: &str) -> String {
|
||||||
let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
|
let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
|
||||||
let mut result = String::with_capacity(capacity);
|
let mut result = String::with_capacity(capacity);
|
||||||
for char in name.chars() {
|
for char in name.chars() {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ impl ComponentMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Implement this trait to define a UI component. This will allow you to
|
/// Implement this trait to define a UI component. This will allow you to
|
||||||
/// derive `RegisterComponent` on it, in tutn allowing you to preview the
|
/// derive `RegisterComponent` on it, in turn allowing you to preview the
|
||||||
/// contents of the preview fn in `workspace: open component preview`.
|
/// contents of the preview fn in `workspace: open component preview`.
|
||||||
///
|
///
|
||||||
/// This can be useful for visual debugging and testing, documenting UI
|
/// This can be useful for visual debugging and testing, documenting UI
|
||||||
|
|||||||
@@ -333,24 +333,6 @@ pub async fn download_adapter_from_github(
|
|||||||
Ok(version_path)
|
Ok(version_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_latest_adapter_version_from_github(
|
|
||||||
github_repo: GithubRepo,
|
|
||||||
delegate: &dyn DapDelegate,
|
|
||||||
) -> Result<AdapterVersion> {
|
|
||||||
let release = latest_github_release(
|
|
||||||
&format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
delegate.http_client(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(AdapterVersion {
|
|
||||||
tag_name: release.tag_name,
|
|
||||||
url: release.zipball_url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait(?Send)]
|
#[async_trait(?Send)]
|
||||||
pub trait DebugAdapter: 'static + Send + Sync {
|
pub trait DebugAdapter: 'static + Send + Sync {
|
||||||
fn name(&self) -> DebugAdapterName;
|
fn name(&self) -> DebugAdapterName;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
|
use dap::adapters::latest_github_release;
|
||||||
use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
|
use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
|
||||||
use gpui::{AsyncApp, SharedString};
|
use gpui::{AppContext, AsyncApp, SharedString};
|
||||||
use json_dotpath::DotPaths;
|
use json_dotpath::DotPaths;
|
||||||
use language::{LanguageName, Toolchain};
|
use language::{LanguageName, Toolchain};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -21,12 +22,13 @@ pub(crate) struct PythonDebugAdapter {
|
|||||||
|
|
||||||
impl PythonDebugAdapter {
|
impl PythonDebugAdapter {
|
||||||
const ADAPTER_NAME: &'static str = "Debugpy";
|
const ADAPTER_NAME: &'static str = "Debugpy";
|
||||||
|
const DEBUG_ADAPTER_NAME: DebugAdapterName =
|
||||||
|
DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
|
||||||
const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
|
const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
|
||||||
const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
|
const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
|
||||||
const LANGUAGE_NAME: &'static str = "Python";
|
const LANGUAGE_NAME: &'static str = "Python";
|
||||||
|
|
||||||
async fn generate_debugpy_arguments(
|
async fn generate_debugpy_arguments(
|
||||||
&self,
|
|
||||||
host: &Ipv4Addr,
|
host: &Ipv4Addr,
|
||||||
port: u16,
|
port: u16,
|
||||||
user_installed_path: Option<&Path>,
|
user_installed_path: Option<&Path>,
|
||||||
@@ -54,7 +56,7 @@ impl PythonDebugAdapter {
|
|||||||
format!("--port={}", port),
|
format!("--port={}", port),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
|
let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
|
||||||
let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
|
let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
|
||||||
|
|
||||||
let debugpy_dir =
|
let debugpy_dir =
|
||||||
@@ -107,22 +109,21 @@ impl PythonDebugAdapter {
|
|||||||
repo_owner: "microsoft".into(),
|
repo_owner: "microsoft".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
adapters::fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
|
fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn install_binary(
|
async fn install_binary(
|
||||||
&self,
|
adapter_name: DebugAdapterName,
|
||||||
version: AdapterVersion,
|
version: AdapterVersion,
|
||||||
delegate: &Arc<dyn DapDelegate>,
|
delegate: Arc<dyn DapDelegate>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let version_path = adapters::download_adapter_from_github(
|
let version_path = adapters::download_adapter_from_github(
|
||||||
self.name(),
|
adapter_name,
|
||||||
version,
|
version,
|
||||||
adapters::DownloadedFileType::Zip,
|
adapters::DownloadedFileType::GzipTar,
|
||||||
delegate.as_ref(),
|
delegate.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// only needed when you install the latest version for the first time
|
// only needed when you install the latest version for the first time
|
||||||
if let Some(debugpy_dir) =
|
if let Some(debugpy_dir) =
|
||||||
util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
|
util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
|
||||||
@@ -171,14 +172,13 @@ impl PythonDebugAdapter {
|
|||||||
let python_command = python_path.context("failed to find binary path for Python")?;
|
let python_command = python_path.context("failed to find binary path for Python")?;
|
||||||
log::debug!("Using Python executable: {}", python_command);
|
log::debug!("Using Python executable: {}", python_command);
|
||||||
|
|
||||||
let arguments = self
|
let arguments = Self::generate_debugpy_arguments(
|
||||||
.generate_debugpy_arguments(
|
&host,
|
||||||
&host,
|
port,
|
||||||
port,
|
user_installed_path.as_deref(),
|
||||||
user_installed_path.as_deref(),
|
installed_in_venv,
|
||||||
installed_in_venv,
|
)
|
||||||
)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Starting debugpy adapter with command: {} {}",
|
"Starting debugpy adapter with command: {} {}",
|
||||||
@@ -204,7 +204,7 @@ impl PythonDebugAdapter {
|
|||||||
#[async_trait(?Send)]
|
#[async_trait(?Send)]
|
||||||
impl DebugAdapter for PythonDebugAdapter {
|
impl DebugAdapter for PythonDebugAdapter {
|
||||||
fn name(&self) -> DebugAdapterName {
|
fn name(&self) -> DebugAdapterName {
|
||||||
DebugAdapterName(Self::ADAPTER_NAME.into())
|
Self::DEBUG_ADAPTER_NAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn adapter_language_name(&self) -> Option<LanguageName> {
|
fn adapter_language_name(&self) -> Option<LanguageName> {
|
||||||
@@ -635,7 +635,9 @@ impl DebugAdapter for PythonDebugAdapter {
|
|||||||
if self.checked.set(()).is_ok() {
|
if self.checked.set(()).is_ok() {
|
||||||
delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
|
delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
|
||||||
if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
|
if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
|
||||||
self.install_binary(version, delegate).await?;
|
cx.background_spawn(Self::install_binary(self.name(), version, delegate.clone()))
|
||||||
|
.await
|
||||||
|
.context("Failed to install debugpy")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +646,24 @@ impl DebugAdapter for PythonDebugAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fetch_latest_adapter_version_from_github(
|
||||||
|
github_repo: GithubRepo,
|
||||||
|
delegate: &dyn DapDelegate,
|
||||||
|
) -> Result<AdapterVersion> {
|
||||||
|
let release = latest_github_release(
|
||||||
|
&format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
delegate.http_client(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(AdapterVersion {
|
||||||
|
tag_name: release.tag_name,
|
||||||
|
url: release.tarball_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -651,20 +671,18 @@ mod tests {
|
|||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_debugpy_install_path_cases() {
|
async fn test_debugpy_install_path_cases() {
|
||||||
let adapter = PythonDebugAdapter::default();
|
|
||||||
let host = Ipv4Addr::new(127, 0, 0, 1);
|
let host = Ipv4Addr::new(127, 0, 0, 1);
|
||||||
let port = 5678;
|
let port = 5678;
|
||||||
|
|
||||||
// Case 1: User-defined debugpy path (highest precedence)
|
// Case 1: User-defined debugpy path (highest precedence)
|
||||||
let user_path = PathBuf::from("/custom/path/to/debugpy");
|
let user_path = PathBuf::from("/custom/path/to/debugpy");
|
||||||
let user_args = adapter
|
let user_args =
|
||||||
.generate_debugpy_arguments(&host, port, Some(&user_path), false)
|
PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), false)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
|
// Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
|
||||||
let venv_args = adapter
|
let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, true)
|
||||||
.generate_debugpy_arguments(&host, port, None, true)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -679,9 +697,4 @@ mod tests {
|
|||||||
|
|
||||||
// Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
|
// Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_adapter_path_constant() {
|
|
||||||
assert_eq!(PythonDebugAdapter::ADAPTER_PATH, "src/debugpy/adapter");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ pub(crate) fn new_debugger_pane(
|
|||||||
&new_pane,
|
&new_pane,
|
||||||
item_id_to_move,
|
item_id_to_move,
|
||||||
new_pane.read(cx).active_item_index(),
|
new_pane.read(cx).active_item_index(),
|
||||||
|
true,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
|
|||||||
_position: language::Anchor,
|
_position: language::Anchor,
|
||||||
_text: &str,
|
_text: &str,
|
||||||
_trigger_in_words: bool,
|
_trigger_in_words: bool,
|
||||||
|
_menu_is_open: bool,
|
||||||
_cx: &mut Context<Editor>,
|
_cx: &mut Context<Editor>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ settings.workspace = true
|
|||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
util.workspace = true
|
util.workspace = true
|
||||||
workspace-hack.workspace = true
|
workspace-hack.workspace = true
|
||||||
|
zed.workspace = true
|
||||||
|
gpui.workspace = true
|
||||||
|
command_palette.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use mdbook::book::{Book, Chapter};
|
|||||||
use mdbook::preprocess::CmdPreprocessor;
|
use mdbook::preprocess::CmdPreprocessor;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use settings::KeymapFile;
|
use settings::KeymapFile;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::io::{self, Read};
|
use std::io::{self, Read};
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
@@ -17,6 +18,8 @@ static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
|
|||||||
load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
|
load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
|
||||||
|
|
||||||
pub fn make_app() -> Command {
|
pub fn make_app() -> Command {
|
||||||
Command::new("zed-docs-preprocessor")
|
Command::new("zed-docs-preprocessor")
|
||||||
.about("Preprocesses Zed Docs content to provide rich action & keybinding support and more")
|
.about("Preprocesses Zed Docs content to provide rich action & keybinding support and more")
|
||||||
@@ -29,6 +32,9 @@ pub fn make_app() -> Command {
|
|||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let matches = make_app().get_matches();
|
let matches = make_app().get_matches();
|
||||||
|
// call a zed:: function so everything in `zed` crate is linked and
|
||||||
|
// all actions in the actual app are registered
|
||||||
|
zed::stdout_is_a_pty();
|
||||||
|
|
||||||
if let Some(sub_args) = matches.subcommand_matches("supports") {
|
if let Some(sub_args) = matches.subcommand_matches("supports") {
|
||||||
handle_supports(sub_args);
|
handle_supports(sub_args);
|
||||||
@@ -39,6 +45,43 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
enum Error {
|
||||||
|
ActionNotFound { action_name: String },
|
||||||
|
DeprecatedActionUsed { used: String, should_be: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
fn new_for_not_found_action(action_name: String) -> Self {
|
||||||
|
for action in &*ALL_ACTIONS {
|
||||||
|
for alias in action.deprecated_aliases {
|
||||||
|
if alias == &action_name {
|
||||||
|
return Error::DeprecatedActionUsed {
|
||||||
|
used: action_name.clone(),
|
||||||
|
should_be: action.name.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Error::ActionNotFound {
|
||||||
|
action_name: action_name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::ActionNotFound { action_name } => write!(f, "Action not found: {}", action_name),
|
||||||
|
Error::DeprecatedActionUsed { used, should_be } => write!(
|
||||||
|
f,
|
||||||
|
"Deprecated action used: {} should be {}",
|
||||||
|
used, should_be
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_preprocessing() -> Result<()> {
|
fn handle_preprocessing() -> Result<()> {
|
||||||
let mut stdin = io::stdin();
|
let mut stdin = io::stdin();
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
@@ -46,8 +89,19 @@ fn handle_preprocessing() -> Result<()> {
|
|||||||
|
|
||||||
let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
|
let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
|
||||||
|
|
||||||
template_keybinding(&mut book);
|
let mut errors = HashSet::<Error>::new();
|
||||||
template_action(&mut book);
|
|
||||||
|
template_and_validate_keybindings(&mut book, &mut errors);
|
||||||
|
template_and_validate_actions(&mut book, &mut errors);
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
const ANSI_RED: &'static str = "\x1b[31m";
|
||||||
|
const ANSI_RESET: &'static str = "\x1b[0m";
|
||||||
|
for error in &errors {
|
||||||
|
eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
|
||||||
|
}
|
||||||
|
|
||||||
serde_json::to_writer(io::stdout(), &book)?;
|
serde_json::to_writer(io::stdout(), &book)?;
|
||||||
|
|
||||||
@@ -66,13 +120,17 @@ fn handle_supports(sub_args: &ArgMatches) -> ! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn template_keybinding(book: &mut Book) {
|
fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error>) {
|
||||||
let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
|
let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
|
||||||
|
|
||||||
for_each_chapter_mut(book, |chapter| {
|
for_each_chapter_mut(book, |chapter| {
|
||||||
chapter.content = regex
|
chapter.content = regex
|
||||||
.replace_all(&chapter.content, |caps: ®ex::Captures| {
|
.replace_all(&chapter.content, |caps: ®ex::Captures| {
|
||||||
let action = caps[1].trim();
|
let action = caps[1].trim();
|
||||||
|
if find_action_by_name(action).is_none() {
|
||||||
|
errors.insert(Error::new_for_not_found_action(action.to_string()));
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
let macos_binding = find_binding("macos", action).unwrap_or_default();
|
let macos_binding = find_binding("macos", action).unwrap_or_default();
|
||||||
let linux_binding = find_binding("linux", action).unwrap_or_default();
|
let linux_binding = find_binding("linux", action).unwrap_or_default();
|
||||||
|
|
||||||
@@ -86,35 +144,30 @@ fn template_keybinding(book: &mut Book) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn template_action(book: &mut Book) {
|
fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
|
||||||
let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
|
let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
|
||||||
|
|
||||||
for_each_chapter_mut(book, |chapter| {
|
for_each_chapter_mut(book, |chapter| {
|
||||||
chapter.content = regex
|
chapter.content = regex
|
||||||
.replace_all(&chapter.content, |caps: ®ex::Captures| {
|
.replace_all(&chapter.content, |caps: ®ex::Captures| {
|
||||||
let name = caps[1].trim();
|
let name = caps[1].trim();
|
||||||
|
let Some(action) = find_action_by_name(name) else {
|
||||||
let formatted_name = name
|
errors.insert(Error::new_for_not_found_action(name.to_string()));
|
||||||
.chars()
|
return String::new();
|
||||||
.enumerate()
|
};
|
||||||
.map(|(i, c)| {
|
format!("<code class=\"hljs\">{}</code>", &action.human_name)
|
||||||
if i > 0 && c.is_uppercase() {
|
|
||||||
format!(" {}", c.to_lowercase())
|
|
||||||
} else {
|
|
||||||
c.to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
.replace("::", ":");
|
|
||||||
|
|
||||||
format!("<code class=\"hljs\">{}</code>", formatted_name)
|
|
||||||
})
|
})
|
||||||
.into_owned()
|
.into_owned()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_action_by_name(name: &str) -> Option<&ActionDef> {
|
||||||
|
ALL_ACTIONS
|
||||||
|
.binary_search_by(|action| action.name.cmp(name))
|
||||||
|
.ok()
|
||||||
|
.map(|index| &ALL_ACTIONS[index])
|
||||||
|
}
|
||||||
|
|
||||||
fn find_binding(os: &str, action: &str) -> Option<String> {
|
fn find_binding(os: &str, action: &str) -> Option<String> {
|
||||||
let keymap = match os {
|
let keymap = match os {
|
||||||
"macos" => &KEYMAP_MACOS,
|
"macos" => &KEYMAP_MACOS,
|
||||||
@@ -180,3 +233,25 @@ where
|
|||||||
func(chapter);
|
func(chapter);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct ActionDef {
|
||||||
|
name: &'static str,
|
||||||
|
human_name: String,
|
||||||
|
deprecated_aliases: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dump_all_gpui_actions() -> Vec<ActionDef> {
|
||||||
|
let mut actions = gpui::generate_list_of_all_registered_actions()
|
||||||
|
.into_iter()
|
||||||
|
.map(|action| ActionDef {
|
||||||
|
name: action.name,
|
||||||
|
human_name: command_palette::humanize_action_name(action.name),
|
||||||
|
deprecated_aliases: action.aliases,
|
||||||
|
})
|
||||||
|
.collect::<Vec<ActionDef>>();
|
||||||
|
|
||||||
|
actions.sort_by_key(|a| a.name);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ pub enum ContextMenuOrigin {
|
|||||||
|
|
||||||
pub struct CompletionsMenu {
|
pub struct CompletionsMenu {
|
||||||
pub id: CompletionId,
|
pub id: CompletionId,
|
||||||
|
pub source: CompletionsMenuSource,
|
||||||
sort_completions: bool,
|
sort_completions: bool,
|
||||||
pub initial_position: Anchor,
|
pub initial_position: Anchor,
|
||||||
pub initial_query: Option<Arc<String>>,
|
pub initial_query: Option<Arc<String>>,
|
||||||
@@ -208,7 +209,6 @@ pub struct CompletionsMenu {
|
|||||||
scroll_handle: UniformListScrollHandle,
|
scroll_handle: UniformListScrollHandle,
|
||||||
resolve_completions: bool,
|
resolve_completions: bool,
|
||||||
show_completion_documentation: bool,
|
show_completion_documentation: bool,
|
||||||
pub(super) ignore_completion_provider: bool,
|
|
||||||
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
|
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
|
||||||
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
|
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
|
||||||
language_registry: Option<Arc<LanguageRegistry>>,
|
language_registry: Option<Arc<LanguageRegistry>>,
|
||||||
@@ -227,6 +227,13 @@ enum MarkdownCacheKey {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CompletionsMenuSource {
|
||||||
|
Normal,
|
||||||
|
SnippetChoices,
|
||||||
|
Words,
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: There should really be a wrapper around fuzzy match tasks that does this.
|
// TODO: There should really be a wrapper around fuzzy match tasks that does this.
|
||||||
impl Drop for CompletionsMenu {
|
impl Drop for CompletionsMenu {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -237,9 +244,9 @@ impl Drop for CompletionsMenu {
|
|||||||
impl CompletionsMenu {
|
impl CompletionsMenu {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: CompletionId,
|
id: CompletionId,
|
||||||
|
source: CompletionsMenuSource,
|
||||||
sort_completions: bool,
|
sort_completions: bool,
|
||||||
show_completion_documentation: bool,
|
show_completion_documentation: bool,
|
||||||
ignore_completion_provider: bool,
|
|
||||||
initial_position: Anchor,
|
initial_position: Anchor,
|
||||||
initial_query: Option<Arc<String>>,
|
initial_query: Option<Arc<String>>,
|
||||||
is_incomplete: bool,
|
is_incomplete: bool,
|
||||||
@@ -258,13 +265,13 @@ impl CompletionsMenu {
|
|||||||
|
|
||||||
let completions_menu = Self {
|
let completions_menu = Self {
|
||||||
id,
|
id,
|
||||||
|
source,
|
||||||
sort_completions,
|
sort_completions,
|
||||||
initial_position,
|
initial_position,
|
||||||
initial_query,
|
initial_query,
|
||||||
is_incomplete,
|
is_incomplete,
|
||||||
buffer,
|
buffer,
|
||||||
show_completion_documentation,
|
show_completion_documentation,
|
||||||
ignore_completion_provider,
|
|
||||||
completions: RefCell::new(completions).into(),
|
completions: RefCell::new(completions).into(),
|
||||||
match_candidates,
|
match_candidates,
|
||||||
entries: Rc::new(RefCell::new(Box::new([]))),
|
entries: Rc::new(RefCell::new(Box::new([]))),
|
||||||
@@ -328,6 +335,7 @@ impl CompletionsMenu {
|
|||||||
.collect();
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
|
source: CompletionsMenuSource::SnippetChoices,
|
||||||
sort_completions,
|
sort_completions,
|
||||||
initial_position: selection.start,
|
initial_position: selection.start,
|
||||||
initial_query: None,
|
initial_query: None,
|
||||||
@@ -342,7 +350,6 @@ impl CompletionsMenu {
|
|||||||
scroll_handle: UniformListScrollHandle::new(),
|
scroll_handle: UniformListScrollHandle::new(),
|
||||||
resolve_completions: false,
|
resolve_completions: false,
|
||||||
show_completion_documentation: false,
|
show_completion_documentation: false,
|
||||||
ignore_completion_provider: false,
|
|
||||||
last_rendered_range: RefCell::new(None).into(),
|
last_rendered_range: RefCell::new(None).into(),
|
||||||
markdown_cache: RefCell::new(VecDeque::new()).into(),
|
markdown_cache: RefCell::new(VecDeque::new()).into(),
|
||||||
language_registry: None,
|
language_registry: None,
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ pub struct HighlightedChunk<'a> {
|
|||||||
pub text: &'a str,
|
pub text: &'a str,
|
||||||
pub style: Option<HighlightStyle>,
|
pub style: Option<HighlightStyle>,
|
||||||
pub is_tab: bool,
|
pub is_tab: bool,
|
||||||
|
pub is_inlay: bool,
|
||||||
pub replacement: Option<ChunkReplacement>,
|
pub replacement: Option<ChunkReplacement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +653,7 @@ impl<'a> HighlightedChunk<'a> {
|
|||||||
let style = self.style;
|
let style = self.style;
|
||||||
let is_tab = self.is_tab;
|
let is_tab = self.is_tab;
|
||||||
let renderer = self.replacement;
|
let renderer = self.replacement;
|
||||||
|
let is_inlay = self.is_inlay;
|
||||||
iter::from_fn(move || {
|
iter::from_fn(move || {
|
||||||
let mut prefix_len = 0;
|
let mut prefix_len = 0;
|
||||||
while let Some(&ch) = chars.peek() {
|
while let Some(&ch) = chars.peek() {
|
||||||
@@ -667,6 +669,7 @@ impl<'a> HighlightedChunk<'a> {
|
|||||||
text: prefix,
|
text: prefix,
|
||||||
style,
|
style,
|
||||||
is_tab,
|
is_tab,
|
||||||
|
is_inlay,
|
||||||
replacement: renderer.clone(),
|
replacement: renderer.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -693,6 +696,7 @@ impl<'a> HighlightedChunk<'a> {
|
|||||||
text: prefix,
|
text: prefix,
|
||||||
style: Some(invisible_style),
|
style: Some(invisible_style),
|
||||||
is_tab: false,
|
is_tab: false,
|
||||||
|
is_inlay,
|
||||||
replacement: Some(ChunkReplacement::Str(replacement.into())),
|
replacement: Some(ChunkReplacement::Str(replacement.into())),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -716,6 +720,7 @@ impl<'a> HighlightedChunk<'a> {
|
|||||||
text: prefix,
|
text: prefix,
|
||||||
style: Some(invisible_style),
|
style: Some(invisible_style),
|
||||||
is_tab: false,
|
is_tab: false,
|
||||||
|
is_inlay,
|
||||||
replacement: renderer.clone(),
|
replacement: renderer.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -728,6 +733,7 @@ impl<'a> HighlightedChunk<'a> {
|
|||||||
text: remainder,
|
text: remainder,
|
||||||
style,
|
style,
|
||||||
is_tab,
|
is_tab,
|
||||||
|
is_inlay,
|
||||||
replacement: renderer.clone(),
|
replacement: renderer.clone(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -961,7 +967,10 @@ impl DisplaySnapshot {
|
|||||||
if chunk.is_unnecessary {
|
if chunk.is_unnecessary {
|
||||||
diagnostic_highlight.fade_out = Some(editor_style.unnecessary_code_fade);
|
diagnostic_highlight.fade_out = Some(editor_style.unnecessary_code_fade);
|
||||||
}
|
}
|
||||||
if chunk.underline && editor_style.show_underlines {
|
if chunk.underline
|
||||||
|
&& editor_style.show_underlines
|
||||||
|
&& !(chunk.is_unnecessary && severity > lsp::DiagnosticSeverity::WARNING)
|
||||||
|
{
|
||||||
let diagnostic_color = super::diagnostic_style(severity, &editor_style.status);
|
let diagnostic_color = super::diagnostic_style(severity, &editor_style.status);
|
||||||
diagnostic_highlight.underline = Some(UnderlineStyle {
|
diagnostic_highlight.underline = Some(UnderlineStyle {
|
||||||
color: Some(diagnostic_color),
|
color: Some(diagnostic_color),
|
||||||
@@ -981,6 +990,7 @@ impl DisplaySnapshot {
|
|||||||
text: chunk.text,
|
text: chunk.text,
|
||||||
style: highlight_style,
|
style: highlight_style,
|
||||||
is_tab: chunk.is_tab,
|
is_tab: chunk.is_tab,
|
||||||
|
is_inlay: chunk.is_inlay,
|
||||||
replacement: chunk.renderer.map(ChunkReplacement::Renderer),
|
replacement: chunk.renderer.map(ChunkReplacement::Renderer),
|
||||||
}
|
}
|
||||||
.highlight_invisibles(editor_style)
|
.highlight_invisibles(editor_style)
|
||||||
@@ -2512,7 +2522,9 @@ pub mod tests {
|
|||||||
cx.update(|cx| syntax_chunks(DisplayRow(0)..DisplayRow(5), &map, &theme, cx)),
|
cx.update(|cx| syntax_chunks(DisplayRow(0)..DisplayRow(5), &map, &theme, cx)),
|
||||||
[
|
[
|
||||||
("fn \n".to_string(), None),
|
("fn \n".to_string(), None),
|
||||||
("oute\nr".to_string(), Some(Hsla::blue())),
|
("oute".to_string(), Some(Hsla::blue())),
|
||||||
|
("\n".to_string(), None),
|
||||||
|
("r".to_string(), Some(Hsla::blue())),
|
||||||
("() \n{}\n\n".to_string(), None),
|
("() \n{}\n\n".to_string(), None),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -2535,8 +2547,11 @@ pub mod tests {
|
|||||||
[
|
[
|
||||||
("out".to_string(), Some(Hsla::blue())),
|
("out".to_string(), Some(Hsla::blue())),
|
||||||
("⋯\n".to_string(), None),
|
("⋯\n".to_string(), None),
|
||||||
(" \nfn ".to_string(), Some(Hsla::red())),
|
(" ".to_string(), Some(Hsla::red())),
|
||||||
("i\n".to_string(), Some(Hsla::blue()))
|
("\n".to_string(), None),
|
||||||
|
("fn ".to_string(), Some(Hsla::red())),
|
||||||
|
("i".to_string(), Some(Hsla::blue())),
|
||||||
|
("\n".to_string(), None)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1259,6 +1259,8 @@ pub struct Chunk<'a> {
|
|||||||
pub underline: bool,
|
pub underline: bool,
|
||||||
/// Whether this chunk of text was originally a tab character.
|
/// Whether this chunk of text was originally a tab character.
|
||||||
pub is_tab: bool,
|
pub is_tab: bool,
|
||||||
|
/// Whether this chunk of text was originally a tab character.
|
||||||
|
pub is_inlay: bool,
|
||||||
/// An optional recipe for how the chunk should be presented.
|
/// An optional recipe for how the chunk should be presented.
|
||||||
pub renderer: Option<ChunkRenderer>,
|
pub renderer: Option<ChunkRenderer>,
|
||||||
}
|
}
|
||||||
@@ -1424,6 +1426,7 @@ impl<'a> Iterator for FoldChunks<'a> {
|
|||||||
diagnostic_severity: chunk.diagnostic_severity,
|
diagnostic_severity: chunk.diagnostic_severity,
|
||||||
is_unnecessary: chunk.is_unnecessary,
|
is_unnecessary: chunk.is_unnecessary,
|
||||||
is_tab: chunk.is_tab,
|
is_tab: chunk.is_tab,
|
||||||
|
is_inlay: chunk.is_inlay,
|
||||||
underline: chunk.underline,
|
underline: chunk.underline,
|
||||||
renderer: None,
|
renderer: None,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ impl<'a> Iterator for InlayChunks<'a> {
|
|||||||
Chunk {
|
Chunk {
|
||||||
text: chunk,
|
text: chunk,
|
||||||
highlight_style,
|
highlight_style,
|
||||||
|
is_inlay: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ impl<'a> Iterator for WrapChunks<'a> {
|
|||||||
self.transforms.next(&());
|
self.transforms.next(&());
|
||||||
return Some(Chunk {
|
return Some(Chunk {
|
||||||
text: &display_text[start_ix..end_ix],
|
text: &display_text[start_ix..end_ix],
|
||||||
..self.input_chunk.clone()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,8 +211,11 @@ use workspace::{
|
|||||||
searchable::SearchEvent,
|
searchable::SearchEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::hover_links::{find_url, find_url_from_range};
|
|
||||||
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
|
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
|
||||||
|
use crate::{
|
||||||
|
code_context_menus::CompletionsMenuSource,
|
||||||
|
hover_links::{find_url, find_url_from_range},
|
||||||
|
};
|
||||||
|
|
||||||
pub const FILE_HEADER_HEIGHT: u32 = 2;
|
pub const FILE_HEADER_HEIGHT: u32 = 2;
|
||||||
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
|
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
|
||||||
@@ -4510,30 +4513,40 @@ impl Editor {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let ignore_completion_provider = self
|
let completions_source = self
|
||||||
.context_menu
|
.context_menu
|
||||||
.borrow()
|
.borrow()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|menu| match menu {
|
.and_then(|menu| match menu {
|
||||||
CodeContextMenu::Completions(completions_menu) => {
|
CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source),
|
||||||
completions_menu.ignore_completion_provider
|
CodeContextMenu::CodeActions(_) => None,
|
||||||
}
|
});
|
||||||
CodeContextMenu::CodeActions(_) => false,
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if ignore_completion_provider {
|
match completions_source {
|
||||||
self.show_word_completions(&ShowWordCompletions, window, cx);
|
Some(CompletionsMenuSource::Words) => {
|
||||||
} else if self.is_completion_trigger(text, trigger_in_words, cx) {
|
self.show_word_completions(&ShowWordCompletions, window, cx)
|
||||||
self.show_completions(
|
}
|
||||||
&ShowCompletions {
|
Some(CompletionsMenuSource::Normal)
|
||||||
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
|
| Some(CompletionsMenuSource::SnippetChoices)
|
||||||
},
|
| None
|
||||||
window,
|
if self.is_completion_trigger(
|
||||||
cx,
|
text,
|
||||||
);
|
trigger_in_words,
|
||||||
} else {
|
completions_source.is_some(),
|
||||||
self.hide_context_menu(window, cx);
|
cx,
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
self.show_completions(
|
||||||
|
&ShowCompletions {
|
||||||
|
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.hide_context_menu(window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4541,6 +4554,7 @@ impl Editor {
|
|||||||
&self,
|
&self,
|
||||||
text: &str,
|
text: &str,
|
||||||
trigger_in_words: bool,
|
trigger_in_words: bool,
|
||||||
|
menu_is_open: bool,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let position = self.selections.newest_anchor().head();
|
let position = self.selections.newest_anchor().head();
|
||||||
@@ -4558,6 +4572,7 @@ impl Editor {
|
|||||||
position.text_anchor,
|
position.text_anchor,
|
||||||
text,
|
text,
|
||||||
trigger_in_words,
|
trigger_in_words,
|
||||||
|
menu_is_open,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -5008,7 +5023,7 @@ impl Editor {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.open_or_update_completions_menu(true, None, window, cx);
|
self.open_or_update_completions_menu(Some(CompletionsMenuSource::Words), None, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show_completions(
|
pub fn show_completions(
|
||||||
@@ -5017,12 +5032,12 @@ impl Editor {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.open_or_update_completions_menu(false, options.trigger.as_deref(), window, cx);
|
self.open_or_update_completions_menu(None, options.trigger.as_deref(), window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_or_update_completions_menu(
|
fn open_or_update_completions_menu(
|
||||||
&mut self,
|
&mut self,
|
||||||
ignore_completion_provider: bool,
|
requested_source: Option<CompletionsMenuSource>,
|
||||||
trigger: Option<&str>,
|
trigger: Option<&str>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
@@ -5047,10 +5062,13 @@ impl Editor {
|
|||||||
Self::completion_query(&self.buffer.read(cx).read(cx), position)
|
Self::completion_query(&self.buffer.read(cx).read(cx), position)
|
||||||
.map(|query| query.into());
|
.map(|query| query.into());
|
||||||
|
|
||||||
let provider = if ignore_completion_provider {
|
let provider = match requested_source {
|
||||||
None
|
Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(),
|
||||||
} else {
|
Some(CompletionsMenuSource::Words) => None,
|
||||||
self.completion_provider.clone()
|
Some(CompletionsMenuSource::SnippetChoices) => {
|
||||||
|
log::error!("bug: SnippetChoices requested_source is not handled");
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let sort_completions = provider
|
let sort_completions = provider
|
||||||
@@ -5106,14 +5124,15 @@ impl Editor {
|
|||||||
trigger_kind,
|
trigger_kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (replace_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position);
|
let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) =
|
||||||
let (replace_range, word_to_exclude) = if word_kind == Some(CharKind::Word) {
|
buffer_snapshot.surrounding_word(buffer_position)
|
||||||
|
{
|
||||||
let word_to_exclude = buffer_snapshot
|
let word_to_exclude = buffer_snapshot
|
||||||
.text_for_range(replace_range.clone())
|
.text_for_range(word_range.clone())
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
(
|
(
|
||||||
buffer_snapshot.anchor_before(replace_range.start)
|
buffer_snapshot.anchor_before(word_range.start)
|
||||||
..buffer_snapshot.anchor_after(replace_range.end),
|
..buffer_snapshot.anchor_after(buffer_position),
|
||||||
Some(word_to_exclude),
|
Some(word_to_exclude),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -5221,7 +5240,7 @@ impl Editor {
|
|||||||
words.remove(&lsp_completion.new_text);
|
words.remove(&lsp_completion.new_text);
|
||||||
}
|
}
|
||||||
completions.extend(words.into_iter().map(|(word, word_range)| Completion {
|
completions.extend(words.into_iter().map(|(word, word_range)| Completion {
|
||||||
replace_range: replace_range.clone(),
|
replace_range: word_replace_range.clone(),
|
||||||
new_text: word.clone(),
|
new_text: word.clone(),
|
||||||
label: CodeLabel::plain(word, None),
|
label: CodeLabel::plain(word, None),
|
||||||
icon_path: None,
|
icon_path: None,
|
||||||
@@ -5245,9 +5264,9 @@ impl Editor {
|
|||||||
.map(|workspace| workspace.read(cx).app_state().languages.clone());
|
.map(|workspace| workspace.read(cx).app_state().languages.clone());
|
||||||
let menu = CompletionsMenu::new(
|
let menu = CompletionsMenu::new(
|
||||||
id,
|
id,
|
||||||
|
requested_source.unwrap_or(CompletionsMenuSource::Normal),
|
||||||
sort_completions,
|
sort_completions,
|
||||||
show_completion_documentation,
|
show_completion_documentation,
|
||||||
ignore_completion_provider,
|
|
||||||
position,
|
position,
|
||||||
query.clone(),
|
query.clone(),
|
||||||
is_incomplete,
|
is_incomplete,
|
||||||
@@ -5531,14 +5550,12 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut common_prefix_len = 0;
|
let common_prefix_len = old_text
|
||||||
for (a, b) in old_text.chars().zip(new_text.chars()) {
|
.chars()
|
||||||
if a == b {
|
.zip(new_text.chars())
|
||||||
common_prefix_len += a.len_utf8();
|
.take_while(|(a, b)| a == b)
|
||||||
} else {
|
.map(|(a, _)| a.len_utf8())
|
||||||
break;
|
.sum::<usize>();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.emit(EditorEvent::InputHandled {
|
cx.emit(EditorEvent::InputHandled {
|
||||||
utf16_range_to_replace: None,
|
utf16_range_to_replace: None,
|
||||||
@@ -10856,14 +10873,54 @@ impl Editor {
|
|||||||
pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context<Self>) {
|
pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context<Self>) {
|
||||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||||
let selections = self.selections.all::<Point>(cx);
|
let selections = self.selections.all::<Point>(cx);
|
||||||
let mut selections = selections.iter().peekable();
|
|
||||||
|
// Shrink and split selections to respect paragraph boundaries.
|
||||||
|
let ranges = selections.into_iter().flat_map(|selection| {
|
||||||
|
let language_settings = buffer.language_settings_at(selection.head(), cx);
|
||||||
|
let language_scope = buffer.language_scope_at(selection.head());
|
||||||
|
|
||||||
|
let Some(start_row) = (selection.start.row..=selection.end.row)
|
||||||
|
.find(|row| !buffer.is_line_blank(MultiBufferRow(*row)))
|
||||||
|
else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let Some(end_row) = (selection.start.row..=selection.end.row)
|
||||||
|
.rev()
|
||||||
|
.find(|row| !buffer.is_line_blank(MultiBufferRow(*row)))
|
||||||
|
else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut row = start_row;
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
while let Some(blank_row) =
|
||||||
|
(row..end_row).find(|row| buffer.is_line_blank(MultiBufferRow(*row)))
|
||||||
|
{
|
||||||
|
let next_paragraph_start = (blank_row + 1..=end_row)
|
||||||
|
.find(|row| !buffer.is_line_blank(MultiBufferRow(*row)))
|
||||||
|
.unwrap();
|
||||||
|
ranges.push((
|
||||||
|
language_settings.clone(),
|
||||||
|
language_scope.clone(),
|
||||||
|
Point::new(row, 0)..Point::new(blank_row - 1, 0),
|
||||||
|
));
|
||||||
|
row = next_paragraph_start;
|
||||||
|
}
|
||||||
|
ranges.push((
|
||||||
|
language_settings.clone(),
|
||||||
|
language_scope.clone(),
|
||||||
|
Point::new(row, 0)..Point::new(end_row, 0),
|
||||||
|
));
|
||||||
|
|
||||||
|
ranges
|
||||||
|
});
|
||||||
|
|
||||||
let mut edits = Vec::new();
|
let mut edits = Vec::new();
|
||||||
let mut rewrapped_row_ranges = Vec::<RangeInclusive<u32>>::new();
|
let mut rewrapped_row_ranges = Vec::<RangeInclusive<u32>>::new();
|
||||||
|
|
||||||
while let Some(selection) = selections.next() {
|
for (language_settings, language_scope, range) in ranges {
|
||||||
let mut start_row = selection.start.row;
|
let mut start_row = range.start.row;
|
||||||
let mut end_row = selection.end.row;
|
let mut end_row = range.end.row;
|
||||||
|
|
||||||
// Skip selections that overlap with a range that has already been rewrapped.
|
// Skip selections that overlap with a range that has already been rewrapped.
|
||||||
let selection_range = start_row..end_row;
|
let selection_range = start_row..end_row;
|
||||||
@@ -10874,7 +10931,7 @@ impl Editor {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tab_size = buffer.language_settings_at(selection.head(), cx).tab_size;
|
let tab_size = language_settings.tab_size;
|
||||||
|
|
||||||
// Since not all lines in the selection may be at the same indent
|
// Since not all lines in the selection may be at the same indent
|
||||||
// level, choose the indent size that is the most common between all
|
// level, choose the indent size that is the most common between all
|
||||||
@@ -10905,25 +10962,20 @@ impl Editor {
|
|||||||
let mut line_prefix = indent_size.chars().collect::<String>();
|
let mut line_prefix = indent_size.chars().collect::<String>();
|
||||||
|
|
||||||
let mut inside_comment = false;
|
let mut inside_comment = false;
|
||||||
if let Some(comment_prefix) =
|
if let Some(comment_prefix) = language_scope.and_then(|language| {
|
||||||
buffer
|
language
|
||||||
.language_scope_at(selection.head())
|
.line_comment_prefixes()
|
||||||
.and_then(|language| {
|
.iter()
|
||||||
language
|
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
|
||||||
.line_comment_prefixes()
|
.cloned()
|
||||||
.iter()
|
}) {
|
||||||
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
|
|
||||||
.cloned()
|
|
||||||
})
|
|
||||||
{
|
|
||||||
line_prefix.push_str(&comment_prefix);
|
line_prefix.push_str(&comment_prefix);
|
||||||
inside_comment = true;
|
inside_comment = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let language_settings = buffer.language_settings_at(selection.head(), cx);
|
|
||||||
let allow_rewrap_based_on_language = match language_settings.allow_rewrap {
|
let allow_rewrap_based_on_language = match language_settings.allow_rewrap {
|
||||||
RewrapBehavior::InComments => inside_comment,
|
RewrapBehavior::InComments => inside_comment,
|
||||||
RewrapBehavior::InSelections => !selection.is_empty(),
|
RewrapBehavior::InSelections => !range.is_empty(),
|
||||||
RewrapBehavior::Anywhere => true,
|
RewrapBehavior::Anywhere => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -10934,11 +10986,12 @@ impl Editor {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if selection.is_empty() {
|
if range.is_empty() {
|
||||||
'expand_upwards: while start_row > 0 {
|
'expand_upwards: while start_row > 0 {
|
||||||
let prev_row = start_row - 1;
|
let prev_row = start_row - 1;
|
||||||
if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix)
|
if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix)
|
||||||
&& buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len()
|
&& buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len()
|
||||||
|
&& !buffer.is_line_blank(MultiBufferRow(prev_row))
|
||||||
{
|
{
|
||||||
start_row = prev_row;
|
start_row = prev_row;
|
||||||
} else {
|
} else {
|
||||||
@@ -10950,6 +11003,7 @@ impl Editor {
|
|||||||
let next_row = end_row + 1;
|
let next_row = end_row + 1;
|
||||||
if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix)
|
if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix)
|
||||||
&& buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len()
|
&& buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len()
|
||||||
|
&& !buffer.is_line_blank(MultiBufferRow(next_row))
|
||||||
{
|
{
|
||||||
end_row = next_row;
|
end_row = next_row;
|
||||||
} else {
|
} else {
|
||||||
@@ -20294,6 +20348,7 @@ pub trait CompletionProvider {
|
|||||||
position: language::Anchor,
|
position: language::Anchor,
|
||||||
text: &str,
|
text: &str,
|
||||||
trigger_in_words: bool,
|
trigger_in_words: bool,
|
||||||
|
menu_is_open: bool,
|
||||||
cx: &mut Context<Editor>,
|
cx: &mut Context<Editor>,
|
||||||
) -> bool;
|
) -> bool;
|
||||||
|
|
||||||
@@ -20611,6 +20666,7 @@ impl CompletionProvider for Entity<Project> {
|
|||||||
position: language::Anchor,
|
position: language::Anchor,
|
||||||
text: &str,
|
text: &str,
|
||||||
trigger_in_words: bool,
|
trigger_in_words: bool,
|
||||||
|
menu_is_open: bool,
|
||||||
cx: &mut Context<Editor>,
|
cx: &mut Context<Editor>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let mut chars = text.chars();
|
let mut chars = text.chars();
|
||||||
@@ -20625,7 +20681,7 @@ impl CompletionProvider for Entity<Project> {
|
|||||||
|
|
||||||
let buffer = buffer.read(cx);
|
let buffer = buffer.read(cx);
|
||||||
let snapshot = buffer.snapshot();
|
let snapshot = buffer.snapshot();
|
||||||
if !snapshot.settings_at(position, cx).show_completions_on_input {
|
if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let classifier = snapshot.char_classifier_at(position).for_completion(true);
|
let classifier = snapshot.char_classifier_at(position).for_completion(true);
|
||||||
|
|||||||
@@ -1912,19 +1912,19 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
|
|||||||
assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx);
|
assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx);
|
||||||
|
|
||||||
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
||||||
assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n ˇ{baz.qux()}", editor, cx);
|
assert_selection_ranges("use stdˇ::str::{foo, bar}\n\nˇ {baz.qux()}", editor, cx);
|
||||||
|
|
||||||
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
||||||
assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ {baz.qux()}", editor, cx);
|
assert_selection_ranges("use ˇstd::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
|
||||||
|
|
||||||
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
|
||||||
assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
|
|
||||||
|
|
||||||
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
||||||
assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n {baz.qux()}", editor, cx);
|
assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n {baz.qux()}", editor, cx);
|
||||||
|
|
||||||
|
editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
|
||||||
|
assert_selection_ranges("ˇuse std::str::{foo, ˇbar}\n\n {baz.qux()}", editor, cx);
|
||||||
|
|
||||||
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
|
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
|
||||||
assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n {baz.qux()}", editor, cx);
|
assert_selection_ranges("useˇ std::str::{foo, barˇ}\n\n {baz.qux()}", editor, cx);
|
||||||
|
|
||||||
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
|
editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
|
||||||
assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
|
assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
|
||||||
@@ -1942,7 +1942,7 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
|
|||||||
|
|
||||||
editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx);
|
editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx);
|
||||||
assert_selection_ranges(
|
assert_selection_ranges(
|
||||||
"use std«ˇ::s»tr::{foo, bar}\n\n «ˇ{b»az.qux()}",
|
"use std«ˇ::s»tr::{foo, bar}\n\n«ˇ {b»az.qux()}",
|
||||||
editor,
|
editor,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -5111,7 +5111,7 @@ async fn test_rewrap(cx: &mut TestAppContext) {
|
|||||||
nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in.
|
nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in.
|
||||||
Integer sit amet scelerisque nisi.
|
Integer sit amet scelerisque nisi.
|
||||||
"},
|
"},
|
||||||
plaintext_language,
|
plaintext_language.clone(),
|
||||||
&mut cx,
|
&mut cx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5174,6 +5174,69 @@ async fn test_rewrap(cx: &mut TestAppContext) {
|
|||||||
&mut cx,
|
&mut cx,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert_rewrap(
|
||||||
|
indoc! {"
|
||||||
|
«ˇone one one one one one one one one one one one one one one one one one one one one one one one one
|
||||||
|
|
||||||
|
two»
|
||||||
|
|
||||||
|
three
|
||||||
|
|
||||||
|
«ˇ\t
|
||||||
|
|
||||||
|
four four four four four four four four four four four four four four four four four four four four»
|
||||||
|
|
||||||
|
«ˇfive five five five five five five five five five five five five five five five five five five five
|
||||||
|
\t»
|
||||||
|
six six six six six six six six six six six six six six six six six six six six six six six six six
|
||||||
|
"},
|
||||||
|
indoc! {"
|
||||||
|
«ˇone one one one one one one one one one one one one one one one one one one one
|
||||||
|
one one one one one
|
||||||
|
|
||||||
|
two»
|
||||||
|
|
||||||
|
three
|
||||||
|
|
||||||
|
«ˇ\t
|
||||||
|
|
||||||
|
four four four four four four four four four four four four four four four four
|
||||||
|
four four four four»
|
||||||
|
|
||||||
|
«ˇfive five five five five five five five five five five five five five five five
|
||||||
|
five five five five
|
||||||
|
\t»
|
||||||
|
six six six six six six six six six six six six six six six six six six six six six six six six six
|
||||||
|
"},
|
||||||
|
plaintext_language.clone(),
|
||||||
|
&mut cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_rewrap(
|
||||||
|
indoc! {"
|
||||||
|
//ˇ long long long long long long long long long long long long long long long long long long long long long long long long long long long long
|
||||||
|
//ˇ
|
||||||
|
//ˇ long long long long long long long long long long long long long long long long long long long long long long long long long long long long
|
||||||
|
//ˇ short short short
|
||||||
|
int main(void) {
|
||||||
|
return 17;
|
||||||
|
}
|
||||||
|
"},
|
||||||
|
indoc! {"
|
||||||
|
//ˇ long long long long long long long long long long long long long long long
|
||||||
|
// long long long long long long long long long long long long long
|
||||||
|
//ˇ
|
||||||
|
//ˇ long long long long long long long long long long long long long long long
|
||||||
|
//ˇ long long long long long long long long long long long long long short short
|
||||||
|
// short
|
||||||
|
int main(void) {
|
||||||
|
return 17;
|
||||||
|
}
|
||||||
|
"},
|
||||||
|
language_with_c_comments,
|
||||||
|
&mut cx,
|
||||||
|
);
|
||||||
|
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
fn assert_rewrap(
|
fn assert_rewrap(
|
||||||
unwrapped_text: &str,
|
unwrapped_text: &str,
|
||||||
@@ -17860,6 +17923,7 @@ async fn test_display_diff_hunks(cx: &mut TestAppContext) {
|
|||||||
("file-2".into(), "two\n".into()),
|
("file-2".into(), "two\n".into()),
|
||||||
("file-3".into(), "three\n".into()),
|
("file-3".into(), "three\n".into()),
|
||||||
],
|
],
|
||||||
|
"deadbeef",
|
||||||
);
|
);
|
||||||
|
|
||||||
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
|
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
|
||||||
@@ -21227,6 +21291,7 @@ fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
|
|||||||
point..point
|
point..point
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Context<Editor>) {
|
fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Context<Editor>) {
|
||||||
let (text, ranges) = marked_text_ranges(marked_text, true);
|
let (text, ranges) = marked_text_ranges(marked_text, true);
|
||||||
assert_eq!(editor.text(cx), text);
|
assert_eq!(editor.text(cx), text);
|
||||||
|
|||||||
@@ -6871,6 +6871,7 @@ impl LineWithInvisibles {
|
|||||||
text: "\n",
|
text: "\n",
|
||||||
style: None,
|
style: None,
|
||||||
is_tab: false,
|
is_tab: false,
|
||||||
|
is_inlay: false,
|
||||||
replacement: None,
|
replacement: None,
|
||||||
}]) {
|
}]) {
|
||||||
if let Some(replacement) = highlighted_chunk.replacement {
|
if let Some(replacement) = highlighted_chunk.replacement {
|
||||||
@@ -7004,7 +7005,7 @@ impl LineWithInvisibles {
|
|||||||
strikethrough: text_style.strikethrough,
|
strikethrough: text_style.strikethrough,
|
||||||
});
|
});
|
||||||
|
|
||||||
if editor_mode.is_full() {
|
if editor_mode.is_full() && !highlighted_chunk.is_inlay {
|
||||||
// Line wrap pads its contents with fake whitespaces,
|
// Line wrap pads its contents with fake whitespaces,
|
||||||
// avoid printing them
|
// avoid printing them
|
||||||
let is_soft_wrapped = is_row_soft_wrapped(row);
|
let is_soft_wrapped = is_row_soft_wrapped(row);
|
||||||
|
|||||||
@@ -264,7 +264,18 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa
|
|||||||
let raw_point = point.to_point(map);
|
let raw_point = point.to_point(map);
|
||||||
let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
|
let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
|
||||||
|
|
||||||
|
let mut is_first_iteration = true;
|
||||||
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
|
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
|
||||||
|
// Make alt-left skip punctuation on Mac OS to respect Mac VSCode behaviour. For example: hello.| goes to |hello.
|
||||||
|
if is_first_iteration
|
||||||
|
&& classifier.is_punctuation(right)
|
||||||
|
&& !classifier.is_punctuation(left)
|
||||||
|
{
|
||||||
|
is_first_iteration = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
is_first_iteration = false;
|
||||||
|
|
||||||
(classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right))
|
(classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right))
|
||||||
|| left == '\n'
|
|| left == '\n'
|
||||||
})
|
})
|
||||||
@@ -305,8 +316,18 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
|
|||||||
pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
|
pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
|
||||||
let raw_point = point.to_point(map);
|
let raw_point = point.to_point(map);
|
||||||
let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
|
let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
|
||||||
|
let mut is_first_iteration = true;
|
||||||
find_boundary(map, point, FindRange::MultiLine, |left, right| {
|
find_boundary(map, point, FindRange::MultiLine, |left, right| {
|
||||||
|
// Make alt-right skip punctuation on Mac OS to respect the Mac behaviour. For example: |.hello goes to .hello|
|
||||||
|
if is_first_iteration
|
||||||
|
&& classifier.is_punctuation(left)
|
||||||
|
&& !classifier.is_punctuation(right)
|
||||||
|
{
|
||||||
|
is_first_iteration = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
is_first_iteration = false;
|
||||||
|
|
||||||
(classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(left))
|
(classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(left))
|
||||||
|| right == '\n'
|
|| right == '\n'
|
||||||
})
|
})
|
||||||
@@ -782,10 +803,15 @@ mod tests {
|
|||||||
|
|
||||||
fn assert(marked_text: &str, cx: &mut gpui::App) {
|
fn assert(marked_text: &str, cx: &mut gpui::App) {
|
||||||
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
|
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
|
||||||
assert_eq!(
|
let actual = previous_word_start(&snapshot, display_points[1]);
|
||||||
previous_word_start(&snapshot, display_points[1]),
|
let expected = display_points[0];
|
||||||
display_points[0]
|
if actual != expected {
|
||||||
);
|
eprintln!(
|
||||||
|
"previous_word_start mismatch for '{}': actual={:?}, expected={:?}",
|
||||||
|
marked_text, actual, expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(actual, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert("\nˇ ˇlorem", cx);
|
assert("\nˇ ˇlorem", cx);
|
||||||
@@ -796,12 +822,17 @@ mod tests {
|
|||||||
assert("\nlorem\nˇ ˇipsum", cx);
|
assert("\nlorem\nˇ ˇipsum", cx);
|
||||||
assert("\n\nˇ\nˇ", cx);
|
assert("\n\nˇ\nˇ", cx);
|
||||||
assert(" ˇlorem ˇipsum", cx);
|
assert(" ˇlorem ˇipsum", cx);
|
||||||
assert("loremˇ-ˇipsum", cx);
|
assert("ˇlorem-ˇipsum", cx);
|
||||||
assert("loremˇ-#$@ˇipsum", cx);
|
assert("loremˇ-#$@ˇipsum", cx);
|
||||||
assert("ˇlorem_ˇipsum", cx);
|
assert("ˇlorem_ˇipsum", cx);
|
||||||
assert(" ˇdefγˇ", cx);
|
assert(" ˇdefγˇ", cx);
|
||||||
assert(" ˇbcΔˇ", cx);
|
assert(" ˇbcΔˇ", cx);
|
||||||
assert(" abˇ——ˇcd", cx);
|
// Test punctuation skipping behavior
|
||||||
|
assert("ˇhello.ˇ", cx);
|
||||||
|
assert("helloˇ...ˇ", cx);
|
||||||
|
assert("helloˇ.---..ˇtest", cx);
|
||||||
|
assert("test ˇ.--ˇtest", cx);
|
||||||
|
assert("oneˇ,;:!?ˇtwo", cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
@@ -955,10 +986,15 @@ mod tests {
|
|||||||
|
|
||||||
fn assert(marked_text: &str, cx: &mut gpui::App) {
|
fn assert(marked_text: &str, cx: &mut gpui::App) {
|
||||||
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
|
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
|
||||||
assert_eq!(
|
let actual = next_word_end(&snapshot, display_points[0]);
|
||||||
next_word_end(&snapshot, display_points[0]),
|
let expected = display_points[1];
|
||||||
display_points[1]
|
if actual != expected {
|
||||||
);
|
eprintln!(
|
||||||
|
"next_word_end mismatch for '{}': actual={:?}, expected={:?}",
|
||||||
|
marked_text, actual, expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(actual, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert("\nˇ loremˇ", cx);
|
assert("\nˇ loremˇ", cx);
|
||||||
@@ -967,11 +1003,18 @@ mod tests {
|
|||||||
assert(" loremˇ ˇ\nipsum\n", cx);
|
assert(" loremˇ ˇ\nipsum\n", cx);
|
||||||
assert("\nˇ\nˇ\n\n", cx);
|
assert("\nˇ\nˇ\n\n", cx);
|
||||||
assert("loremˇ ipsumˇ ", cx);
|
assert("loremˇ ipsumˇ ", cx);
|
||||||
assert("loremˇ-ˇipsum", cx);
|
assert("loremˇ-ipsumˇ", cx);
|
||||||
assert("loremˇ#$@-ˇipsum", cx);
|
assert("loremˇ#$@-ˇipsum", cx);
|
||||||
assert("loremˇ_ipsumˇ", cx);
|
assert("loremˇ_ipsumˇ", cx);
|
||||||
assert(" ˇbcΔˇ", cx);
|
assert(" ˇbcΔˇ", cx);
|
||||||
assert(" abˇ——ˇcd", cx);
|
assert(" abˇ——ˇcd", cx);
|
||||||
|
// Test punctuation skipping behavior
|
||||||
|
assert("ˇ.helloˇ", cx);
|
||||||
|
assert("display_pointsˇ[0ˇ]", cx);
|
||||||
|
assert("ˇ...ˇhello", cx);
|
||||||
|
assert("helloˇ.---..ˇtest", cx);
|
||||||
|
assert("testˇ.--ˇ test", cx);
|
||||||
|
assert("oneˇ,;:!?ˇtwo", cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub fn test_font() -> Font {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns a snapshot from text containing '|' character markers with the markers removed, and DisplayPoints for each one.
|
// Returns a snapshot from text containing '|' character markers with the markers removed, and DisplayPoints for each one.
|
||||||
|
#[track_caller]
|
||||||
pub fn marked_display_snapshot(
|
pub fn marked_display_snapshot(
|
||||||
text: &str,
|
text: &str,
|
||||||
cx: &mut gpui::App,
|
cx: &mut gpui::App,
|
||||||
@@ -83,6 +84,7 @@ pub fn marked_display_snapshot(
|
|||||||
(snapshot, markers)
|
(snapshot, markers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
pub fn select_ranges(
|
pub fn select_ranges(
|
||||||
editor: &mut Editor,
|
editor: &mut Editor,
|
||||||
marked_text: &str,
|
marked_text: &str,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ impl EditorTestContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
pub fn new_multibuffer<const COUNT: usize>(
|
pub fn new_multibuffer<const COUNT: usize>(
|
||||||
cx: &mut gpui::TestAppContext,
|
cx: &mut gpui::TestAppContext,
|
||||||
excerpts: [&str; COUNT],
|
excerpts: [&str; COUNT],
|
||||||
@@ -303,6 +304,7 @@ impl EditorTestContext {
|
|||||||
fs.set_head_for_repo(
|
fs.set_head_for_repo(
|
||||||
&Self::root_path().join(".git"),
|
&Self::root_path().join(".git"),
|
||||||
&[(path.into(), diff_base.to_string())],
|
&[(path.into(), diff_base.to_string())],
|
||||||
|
"deadbeef",
|
||||||
);
|
);
|
||||||
self.cx.run_until_parked();
|
self.cx.run_until_parked();
|
||||||
}
|
}
|
||||||
@@ -351,6 +353,7 @@ impl EditorTestContext {
|
|||||||
/// editor state was needed to cause the failure.
|
/// editor state was needed to cause the failure.
|
||||||
///
|
///
|
||||||
/// See the `util::test::marked_text_ranges` function for more information.
|
/// See the `util::test::marked_text_ranges` function for more information.
|
||||||
|
#[track_caller]
|
||||||
pub fn set_state(&mut self, marked_text: &str) -> ContextHandle {
|
pub fn set_state(&mut self, marked_text: &str) -> ContextHandle {
|
||||||
let state_context = self.add_assertion_context(format!(
|
let state_context = self.add_assertion_context(format!(
|
||||||
"Initial Editor State: \"{}\"",
|
"Initial Editor State: \"{}\"",
|
||||||
@@ -367,6 +370,7 @@ impl EditorTestContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Only change the editor's selections
|
/// Only change the editor's selections
|
||||||
|
#[track_caller]
|
||||||
pub fn set_selections_state(&mut self, marked_text: &str) -> ContextHandle {
|
pub fn set_selections_state(&mut self, marked_text: &str) -> ContextHandle {
|
||||||
let state_context = self.add_assertion_context(format!(
|
let state_context = self.add_assertion_context(format!(
|
||||||
"Initial Editor State: \"{}\"",
|
"Initial Editor State: \"{}\"",
|
||||||
|
|||||||
59
crates/eval/src/examples/grep_params_escapement.rs
Normal file
59
crates/eval/src/examples/grep_params_escapement.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use agent_settings::AgentProfileId;
|
||||||
|
use anyhow::Result;
|
||||||
|
use assistant_tools::GrepToolInput;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::example::{Example, ExampleContext, ExampleMetadata};
|
||||||
|
|
||||||
|
pub struct GrepParamsEscapementExample;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
This eval checks that the model doesn't use HTML escapement for characters like `<` and
|
||||||
|
`>` in tool parameters.
|
||||||
|
|
||||||
|
original +system_prompt change +tool description
|
||||||
|
claude-opus-4 89% 92% 97%+
|
||||||
|
claude-sonnet-4 100%
|
||||||
|
gpt-4.1-mini 100%
|
||||||
|
gemini-2.5-pro 98%
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[async_trait(?Send)]
|
||||||
|
impl Example for GrepParamsEscapementExample {
|
||||||
|
fn meta(&self) -> ExampleMetadata {
|
||||||
|
ExampleMetadata {
|
||||||
|
name: "grep_params_escapement".to_string(),
|
||||||
|
url: "https://github.com/octocat/hello-world".to_string(),
|
||||||
|
revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(),
|
||||||
|
language_server: None,
|
||||||
|
max_assertions: Some(1),
|
||||||
|
profile_id: AgentProfileId::default(),
|
||||||
|
existing_thread_json: None,
|
||||||
|
max_turns: Some(2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> {
|
||||||
|
// cx.push_user_message("How does the precedence/specificity work with Keymap contexts? I am seeing that `MessageEditor > Editor` is lower precendence than `Editor` which is surprising to me, but might be how it works");
|
||||||
|
cx.push_user_message("Search for files containing the characters `>` or `<`");
|
||||||
|
let response = cx.run_turns(2).await?;
|
||||||
|
let grep_input = response
|
||||||
|
.find_tool_call("grep")
|
||||||
|
.and_then(|tool_use| tool_use.parse_input::<GrepToolInput>().ok());
|
||||||
|
|
||||||
|
cx.assert_some(grep_input.as_ref(), "`grep` tool should be called")?;
|
||||||
|
|
||||||
|
cx.assert(
|
||||||
|
!contains_html_entities(&grep_input.unwrap().regex),
|
||||||
|
"Tool parameters should not be escaped",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_html_entities(pattern: &str) -> bool {
|
||||||
|
regex::Regex::new(r"&[a-zA-Z]+;|&#[0-9]+;|&#x[0-9a-fA-F]+;")
|
||||||
|
.unwrap()
|
||||||
|
.is_match(pattern)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ mod add_arg_to_trait_method;
|
|||||||
mod code_block_citations;
|
mod code_block_citations;
|
||||||
mod comment_translation;
|
mod comment_translation;
|
||||||
mod file_search;
|
mod file_search;
|
||||||
|
mod grep_params_escapement;
|
||||||
mod overwrite_file;
|
mod overwrite_file;
|
||||||
mod planets;
|
mod planets;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ pub fn all(examples_dir: &Path) -> Vec<Rc<dyn Example>> {
|
|||||||
Rc::new(planets::Planets),
|
Rc::new(planets::Planets),
|
||||||
Rc::new(comment_translation::CommentTranslation),
|
Rc::new(comment_translation::CommentTranslation),
|
||||||
Rc::new(overwrite_file::FileOverwriteExample),
|
Rc::new(overwrite_file::FileOverwriteExample),
|
||||||
|
Rc::new(grep_params_escapement::GrepParamsEscapementExample),
|
||||||
];
|
];
|
||||||
|
|
||||||
for example_path in list_declarative_examples(examples_dir).unwrap() {
|
for example_path in list_declarative_examples(examples_dir).unwrap() {
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ pub fn init(cx: &mut App) {
|
|||||||
directories: true,
|
directories: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
},
|
},
|
||||||
DirectoryLister::Local(workspace.app_state().fs.clone()),
|
DirectoryLister::Local(
|
||||||
|
workspace.project().clone(),
|
||||||
|
workspace.app_state().fs.clone(),
|
||||||
|
),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ mod file_finder_tests;
|
|||||||
mod open_path_prompt_tests;
|
mod open_path_prompt_tests;
|
||||||
|
|
||||||
pub mod file_finder_settings;
|
pub mod file_finder_settings;
|
||||||
mod new_path_prompt;
|
|
||||||
mod open_path_prompt;
|
mod open_path_prompt;
|
||||||
|
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
@@ -20,7 +19,6 @@ use gpui::{
|
|||||||
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
|
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
|
||||||
Window, actions,
|
Window, actions,
|
||||||
};
|
};
|
||||||
use new_path_prompt::NewPathPrompt;
|
|
||||||
use open_path_prompt::OpenPathPrompt;
|
use open_path_prompt::OpenPathPrompt;
|
||||||
use picker::{Picker, PickerDelegate};
|
use picker::{Picker, PickerDelegate};
|
||||||
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
||||||
@@ -85,8 +83,8 @@ pub fn init_settings(cx: &mut App) {
|
|||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
init_settings(cx);
|
init_settings(cx);
|
||||||
cx.observe_new(FileFinder::register).detach();
|
cx.observe_new(FileFinder::register).detach();
|
||||||
cx.observe_new(NewPathPrompt::register).detach();
|
|
||||||
cx.observe_new(OpenPathPrompt::register).detach();
|
cx.observe_new(OpenPathPrompt::register).detach();
|
||||||
|
cx.observe_new(OpenPathPrompt::register_new_path).detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileFinder {
|
impl FileFinder {
|
||||||
|
|||||||
@@ -1,526 +0,0 @@
|
|||||||
use futures::channel::oneshot;
|
|
||||||
use fuzzy::PathMatch;
|
|
||||||
use gpui::{Entity, HighlightStyle, StyledText};
|
|
||||||
use picker::{Picker, PickerDelegate};
|
|
||||||
use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
|
||||||
use std::{
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::{
|
|
||||||
Arc,
|
|
||||||
atomic::{self, AtomicBool},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use ui::{Context, ListItem, Window};
|
|
||||||
use ui::{LabelLike, ListItemSpacing, highlight_ranges, prelude::*};
|
|
||||||
use util::ResultExt;
|
|
||||||
use workspace::Workspace;
|
|
||||||
|
|
||||||
pub(crate) struct NewPathPrompt;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct Match {
|
|
||||||
path_match: Option<PathMatch>,
|
|
||||||
suffix: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Match {
|
|
||||||
fn entry<'a>(&'a self, project: &'a Project, cx: &'a App) -> Option<&'a Entry> {
|
|
||||||
if let Some(suffix) = &self.suffix {
|
|
||||||
let (worktree, path) = if let Some(path_match) = &self.path_match {
|
|
||||||
(
|
|
||||||
project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx),
|
|
||||||
path_match.path.join(suffix),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(project.worktrees(cx).next(), PathBuf::from(suffix))
|
|
||||||
};
|
|
||||||
|
|
||||||
worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path))
|
|
||||||
} else if let Some(path_match) = &self.path_match {
|
|
||||||
let worktree =
|
|
||||||
project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?;
|
|
||||||
worktree.read(cx).entry_for_path(path_match.path.as_ref())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_dir(&self, project: &Project, cx: &App) -> bool {
|
|
||||||
self.entry(project, cx).is_some_and(|e| e.is_dir())
|
|
||||||
|| self.suffix.as_ref().is_some_and(|s| s.ends_with('/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relative_path(&self) -> String {
|
|
||||||
if let Some(path_match) = &self.path_match {
|
|
||||||
if let Some(suffix) = &self.suffix {
|
|
||||||
format!(
|
|
||||||
"{}/{}",
|
|
||||||
path_match.path.to_string_lossy(),
|
|
||||||
suffix.trim_end_matches('/')
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
path_match.path.to_string_lossy().to_string()
|
|
||||||
}
|
|
||||||
} else if let Some(suffix) = &self.suffix {
|
|
||||||
suffix.trim_end_matches('/').to_string()
|
|
||||||
} else {
|
|
||||||
"".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn project_path(&self, project: &Project, cx: &App) -> Option<ProjectPath> {
|
|
||||||
let worktree_id = if let Some(path_match) = &self.path_match {
|
|
||||||
WorktreeId::from_usize(path_match.worktree_id)
|
|
||||||
} else if let Some(worktree) = project.visible_worktrees(cx).find(|worktree| {
|
|
||||||
worktree
|
|
||||||
.read(cx)
|
|
||||||
.root_entry()
|
|
||||||
.is_some_and(|entry| entry.is_dir())
|
|
||||||
}) {
|
|
||||||
worktree.read(cx).id()
|
|
||||||
} else {
|
|
||||||
// todo(): we should find_or_create a workspace.
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let path = PathBuf::from(self.relative_path());
|
|
||||||
|
|
||||||
Some(ProjectPath {
|
|
||||||
worktree_id,
|
|
||||||
path: Arc::from(path),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn existing_prefix(&self, project: &Project, cx: &App) -> Option<PathBuf> {
|
|
||||||
let worktree = project.worktrees(cx).next()?.read(cx);
|
|
||||||
let mut prefix = PathBuf::new();
|
|
||||||
let parts = self.suffix.as_ref()?.split('/');
|
|
||||||
for part in parts {
|
|
||||||
if worktree.entry_for_path(prefix.join(&part)).is_none() {
|
|
||||||
return Some(prefix);
|
|
||||||
}
|
|
||||||
prefix = prefix.join(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn styled_text(&self, project: &Project, window: &Window, cx: &App) -> StyledText {
|
|
||||||
let mut text = "./".to_string();
|
|
||||||
let mut highlights = Vec::new();
|
|
||||||
let mut offset = text.len();
|
|
||||||
|
|
||||||
let separator = '/';
|
|
||||||
let dir_indicator = "[…]";
|
|
||||||
|
|
||||||
if let Some(path_match) = &self.path_match {
|
|
||||||
text.push_str(&path_match.path.to_string_lossy());
|
|
||||||
let mut whole_path = PathBuf::from(path_match.path_prefix.to_string());
|
|
||||||
whole_path = whole_path.join(path_match.path.clone());
|
|
||||||
for (range, style) in highlight_ranges(
|
|
||||||
&whole_path.to_string_lossy(),
|
|
||||||
&path_match.positions,
|
|
||||||
gpui::HighlightStyle::color(Color::Accent.color(cx)),
|
|
||||||
) {
|
|
||||||
highlights.push((range.start + offset..range.end + offset, style))
|
|
||||||
}
|
|
||||||
text.push(separator);
|
|
||||||
offset = text.len();
|
|
||||||
|
|
||||||
if let Some(suffix) = &self.suffix {
|
|
||||||
text.push_str(suffix);
|
|
||||||
let entry = self.entry(project, cx);
|
|
||||||
let color = if let Some(entry) = entry {
|
|
||||||
if entry.is_dir() {
|
|
||||||
Color::Accent
|
|
||||||
} else {
|
|
||||||
Color::Conflict
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Color::Created
|
|
||||||
};
|
|
||||||
highlights.push((
|
|
||||||
offset..offset + suffix.len(),
|
|
||||||
HighlightStyle::color(color.color(cx)),
|
|
||||||
));
|
|
||||||
offset += suffix.len();
|
|
||||||
if entry.is_some_and(|e| e.is_dir()) {
|
|
||||||
text.push(separator);
|
|
||||||
offset += separator.len_utf8();
|
|
||||||
|
|
||||||
text.push_str(dir_indicator);
|
|
||||||
highlights.push((
|
|
||||||
offset..offset + dir_indicator.len(),
|
|
||||||
HighlightStyle::color(Color::Muted.color(cx)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text.push_str(dir_indicator);
|
|
||||||
highlights.push((
|
|
||||||
offset..offset + dir_indicator.len(),
|
|
||||||
HighlightStyle::color(Color::Muted.color(cx)),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} else if let Some(suffix) = &self.suffix {
|
|
||||||
text.push_str(suffix);
|
|
||||||
let existing_prefix_len = self
|
|
||||||
.existing_prefix(project, cx)
|
|
||||||
.map(|prefix| prefix.to_string_lossy().len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
if existing_prefix_len > 0 {
|
|
||||||
highlights.push((
|
|
||||||
offset..offset + existing_prefix_len,
|
|
||||||
HighlightStyle::color(Color::Accent.color(cx)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
highlights.push((
|
|
||||||
offset + existing_prefix_len..offset + suffix.len(),
|
|
||||||
HighlightStyle::color(if self.entry(project, cx).is_some() {
|
|
||||||
Color::Conflict.color(cx)
|
|
||||||
} else {
|
|
||||||
Color::Created.color(cx)
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
offset += suffix.len();
|
|
||||||
if suffix.ends_with('/') {
|
|
||||||
text.push_str(dir_indicator);
|
|
||||||
highlights.push((
|
|
||||||
offset..offset + dir_indicator.len(),
|
|
||||||
HighlightStyle::color(Color::Muted.color(cx)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText::new(text).with_default_highlights(&window.text_style().clone(), highlights)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct NewPathDelegate {
|
|
||||||
project: Entity<Project>,
|
|
||||||
tx: Option<oneshot::Sender<Option<ProjectPath>>>,
|
|
||||||
selected_index: usize,
|
|
||||||
matches: Vec<Match>,
|
|
||||||
last_selected_dir: Option<String>,
|
|
||||||
cancel_flag: Arc<AtomicBool>,
|
|
||||||
should_dismiss: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewPathPrompt {
|
|
||||||
pub(crate) fn register(
|
|
||||||
workspace: &mut Workspace,
|
|
||||||
_window: Option<&mut Window>,
|
|
||||||
_cx: &mut Context<Workspace>,
|
|
||||||
) {
|
|
||||||
workspace.set_prompt_for_new_path(Box::new(|workspace, window, cx| {
|
|
||||||
let (tx, rx) = futures::channel::oneshot::channel();
|
|
||||||
Self::prompt_for_new_path(workspace, tx, window, cx);
|
|
||||||
rx
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_for_new_path(
|
|
||||||
workspace: &mut Workspace,
|
|
||||||
tx: oneshot::Sender<Option<ProjectPath>>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Workspace>,
|
|
||||||
) {
|
|
||||||
let project = workspace.project().clone();
|
|
||||||
workspace.toggle_modal(window, cx, |window, cx| {
|
|
||||||
let delegate = NewPathDelegate {
|
|
||||||
project,
|
|
||||||
tx: Some(tx),
|
|
||||||
selected_index: 0,
|
|
||||||
matches: vec![],
|
|
||||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
|
||||||
last_selected_dir: None,
|
|
||||||
should_dismiss: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
Picker::uniform_list(delegate, window, cx).width(rems(34.))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PickerDelegate for NewPathDelegate {
|
|
||||||
type ListItem = ui::ListItem;
|
|
||||||
|
|
||||||
fn match_count(&self) -> usize {
|
|
||||||
self.matches.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selected_index(&self) -> usize {
|
|
||||||
self.selected_index
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_selected_index(
|
|
||||||
&mut self,
|
|
||||||
ix: usize,
|
|
||||||
_: &mut Window,
|
|
||||||
cx: &mut Context<picker::Picker<Self>>,
|
|
||||||
) {
|
|
||||||
self.selected_index = ix;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_matches(
|
|
||||||
&mut self,
|
|
||||||
query: String,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<picker::Picker<Self>>,
|
|
||||||
) -> gpui::Task<()> {
|
|
||||||
let query = query
|
|
||||||
.trim()
|
|
||||||
.trim_start_matches("./")
|
|
||||||
.trim_start_matches('/');
|
|
||||||
|
|
||||||
let (dir, suffix) = if let Some(index) = query.rfind('/') {
|
|
||||||
let suffix = if index + 1 < query.len() {
|
|
||||||
Some(query[index + 1..].to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
(query[0..index].to_string(), suffix)
|
|
||||||
} else {
|
|
||||||
(query.to_string(), None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let worktrees = self
|
|
||||||
.project
|
|
||||||
.read(cx)
|
|
||||||
.visible_worktrees(cx)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let include_root_name = worktrees.len() > 1;
|
|
||||||
let candidate_sets = worktrees
|
|
||||||
.into_iter()
|
|
||||||
.map(|worktree| {
|
|
||||||
let worktree = worktree.read(cx);
|
|
||||||
PathMatchCandidateSet {
|
|
||||||
snapshot: worktree.snapshot(),
|
|
||||||
include_ignored: worktree
|
|
||||||
.root_entry()
|
|
||||||
.map_or(false, |entry| entry.is_ignored),
|
|
||||||
include_root_name,
|
|
||||||
candidates: project::Candidates::Directories,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
|
|
||||||
self.cancel_flag = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
let cancel_flag = self.cancel_flag.clone();
|
|
||||||
let query = query.to_string();
|
|
||||||
let prefix = dir.clone();
|
|
||||||
cx.spawn_in(window, async move |picker, cx| {
|
|
||||||
let matches = fuzzy::match_path_sets(
|
|
||||||
candidate_sets.as_slice(),
|
|
||||||
&dir,
|
|
||||||
None,
|
|
||||||
false,
|
|
||||||
100,
|
|
||||||
&cancel_flag,
|
|
||||||
cx.background_executor().clone(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
|
|
||||||
if did_cancel {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
picker
|
|
||||||
.update(cx, |picker, cx| {
|
|
||||||
picker
|
|
||||||
.delegate
|
|
||||||
.set_search_matches(query, prefix, suffix, matches, cx)
|
|
||||||
})
|
|
||||||
.log_err();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn confirm_completion(
|
|
||||||
&mut self,
|
|
||||||
_: String,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Picker<Self>>,
|
|
||||||
) -> Option<String> {
|
|
||||||
self.confirm_update_query(window, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn confirm_update_query(
|
|
||||||
&mut self,
|
|
||||||
_: &mut Window,
|
|
||||||
cx: &mut Context<Picker<Self>>,
|
|
||||||
) -> Option<String> {
|
|
||||||
let m = self.matches.get(self.selected_index)?;
|
|
||||||
if m.is_dir(self.project.read(cx), cx) {
|
|
||||||
let path = m.relative_path();
|
|
||||||
let result = format!("{}/", path);
|
|
||||||
self.last_selected_dir = Some(path);
|
|
||||||
Some(result)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
|
|
||||||
let Some(m) = self.matches.get(self.selected_index) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let exists = m.entry(self.project.read(cx), cx).is_some();
|
|
||||||
if exists {
|
|
||||||
self.should_dismiss = false;
|
|
||||||
let answer = window.prompt(
|
|
||||||
gpui::PromptLevel::Critical,
|
|
||||||
&format!("{} already exists. Do you want to replace it?", m.relative_path()),
|
|
||||||
Some(
|
|
||||||
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
|
|
||||||
),
|
|
||||||
&["Replace", "Cancel"],
|
|
||||||
cx);
|
|
||||||
let m = m.clone();
|
|
||||||
cx.spawn_in(window, async move |picker, cx| {
|
|
||||||
let answer = answer.await.ok();
|
|
||||||
picker
|
|
||||||
.update(cx, |picker, cx| {
|
|
||||||
picker.delegate.should_dismiss = true;
|
|
||||||
if answer != Some(0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
|
|
||||||
if let Some(tx) = picker.delegate.tx.take() {
|
|
||||||
tx.send(Some(path)).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cx.emit(gpui::DismissEvent);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(path) = m.project_path(self.project.read(cx), cx) {
|
|
||||||
if let Some(tx) = self.tx.take() {
|
|
||||||
tx.send(Some(path)).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cx.emit(gpui::DismissEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_dismiss(&self) -> bool {
|
|
||||||
self.should_dismiss
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
|
|
||||||
if let Some(tx) = self.tx.take() {
|
|
||||||
tx.send(None).ok();
|
|
||||||
}
|
|
||||||
cx.emit(gpui::DismissEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_match(
|
|
||||||
&self,
|
|
||||||
ix: usize,
|
|
||||||
selected: bool,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<picker::Picker<Self>>,
|
|
||||||
) -> Option<Self::ListItem> {
|
|
||||||
let m = self.matches.get(ix)?;
|
|
||||||
|
|
||||||
Some(
|
|
||||||
ListItem::new(ix)
|
|
||||||
.spacing(ListItemSpacing::Sparse)
|
|
||||||
.inset(true)
|
|
||||||
.toggle_state(selected)
|
|
||||||
.child(LabelLike::new().child(m.styled_text(self.project.read(cx), window, cx))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
|
||||||
Some("Type a path...".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
|
||||||
Arc::from("[directory/]filename.ext")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewPathDelegate {
|
|
||||||
fn set_search_matches(
|
|
||||||
&mut self,
|
|
||||||
query: String,
|
|
||||||
prefix: String,
|
|
||||||
suffix: Option<String>,
|
|
||||||
matches: Vec<PathMatch>,
|
|
||||||
cx: &mut Context<Picker<Self>>,
|
|
||||||
) {
|
|
||||||
cx.notify();
|
|
||||||
if query.is_empty() {
|
|
||||||
self.matches = self
|
|
||||||
.project
|
|
||||||
.read(cx)
|
|
||||||
.worktrees(cx)
|
|
||||||
.flat_map(|worktree| {
|
|
||||||
let worktree_id = worktree.read(cx).id();
|
|
||||||
worktree
|
|
||||||
.read(cx)
|
|
||||||
.child_entries(Path::new(""))
|
|
||||||
.filter_map(move |entry| {
|
|
||||||
entry.is_dir().then(|| Match {
|
|
||||||
path_match: Some(PathMatch {
|
|
||||||
score: 1.0,
|
|
||||||
positions: Default::default(),
|
|
||||||
worktree_id: worktree_id.to_usize(),
|
|
||||||
path: entry.path.clone(),
|
|
||||||
path_prefix: "".into(),
|
|
||||||
is_dir: entry.is_dir(),
|
|
||||||
distance_to_relative_ancestor: 0,
|
|
||||||
}),
|
|
||||||
suffix: None,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut directory_exists = false;
|
|
||||||
|
|
||||||
self.matches = matches
|
|
||||||
.into_iter()
|
|
||||||
.map(|m| {
|
|
||||||
if m.path.as_ref().to_string_lossy() == prefix {
|
|
||||||
directory_exists = true
|
|
||||||
}
|
|
||||||
Match {
|
|
||||||
path_match: Some(m),
|
|
||||||
suffix: suffix.clone(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !directory_exists {
|
|
||||||
if suffix.is_none()
|
|
||||||
|| self
|
|
||||||
.last_selected_dir
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|d| query.starts_with(d))
|
|
||||||
{
|
|
||||||
self.matches.insert(
|
|
||||||
0,
|
|
||||||
Match {
|
|
||||||
path_match: None,
|
|
||||||
suffix: Some(query.clone()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
self.matches.push(Match {
|
|
||||||
path_match: None,
|
|
||||||
suffix: Some(query.clone()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ use crate::file_finder_settings::FileFinderSettings;
|
|||||||
use file_icons::FileIcons;
|
use file_icons::FileIcons;
|
||||||
use futures::channel::oneshot;
|
use futures::channel::oneshot;
|
||||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||||
|
use gpui::{HighlightStyle, StyledText, Task};
|
||||||
use picker::{Picker, PickerDelegate};
|
use picker::{Picker, PickerDelegate};
|
||||||
use project::{DirectoryItem, DirectoryLister};
|
use project::{DirectoryItem, DirectoryLister};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
@@ -12,61 +13,136 @@ use std::{
|
|||||||
atomic::{self, AtomicBool},
|
atomic::{self, AtomicBool},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use ui::{Context, ListItem, Window};
|
use ui::{Context, LabelLike, ListItem, Window};
|
||||||
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
|
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
|
||||||
use util::{maybe, paths::compare_paths};
|
use util::{maybe, paths::compare_paths};
|
||||||
use workspace::Workspace;
|
use workspace::Workspace;
|
||||||
|
|
||||||
pub(crate) struct OpenPathPrompt;
|
pub(crate) struct OpenPathPrompt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const PROMPT_ROOT: &str = "C:\\";
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
const PROMPT_ROOT: &str = "/";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct OpenPathDelegate {
|
pub struct OpenPathDelegate {
|
||||||
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
|
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
|
||||||
lister: DirectoryLister,
|
lister: DirectoryLister,
|
||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
directory_state: Option<DirectoryState>,
|
directory_state: DirectoryState,
|
||||||
matches: Vec<usize>,
|
|
||||||
string_matches: Vec<StringMatch>,
|
string_matches: Vec<StringMatch>,
|
||||||
cancel_flag: Arc<AtomicBool>,
|
cancel_flag: Arc<AtomicBool>,
|
||||||
should_dismiss: bool,
|
should_dismiss: bool,
|
||||||
|
replace_prompt: Task<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenPathDelegate {
|
impl OpenPathDelegate {
|
||||||
pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
|
pub fn new(
|
||||||
|
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
||||||
|
lister: DirectoryLister,
|
||||||
|
creating_path: bool,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tx: Some(tx),
|
tx: Some(tx),
|
||||||
lister,
|
lister,
|
||||||
selected_index: 0,
|
selected_index: 0,
|
||||||
directory_state: None,
|
directory_state: DirectoryState::None {
|
||||||
matches: Vec::new(),
|
create: creating_path,
|
||||||
|
},
|
||||||
string_matches: Vec::new(),
|
string_matches: Vec::new(),
|
||||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||||
should_dismiss: true,
|
should_dismiss: true,
|
||||||
|
replace_prompt: Task::ready(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_entry(&self, selected_match_index: usize) -> Option<CandidateInfo> {
|
||||||
|
match &self.directory_state {
|
||||||
|
DirectoryState::List { entries, .. } => {
|
||||||
|
let id = self.string_matches.get(selected_match_index)?.candidate_id;
|
||||||
|
entries.iter().find(|entry| entry.path.id == id).cloned()
|
||||||
|
}
|
||||||
|
DirectoryState::Create {
|
||||||
|
user_input,
|
||||||
|
entries,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let mut i = selected_match_index;
|
||||||
|
if let Some(user_input) = user_input {
|
||||||
|
if !user_input.exists || !user_input.is_dir {
|
||||||
|
if i == 0 {
|
||||||
|
return Some(CandidateInfo {
|
||||||
|
path: user_input.file.clone(),
|
||||||
|
is_dir: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let id = self.string_matches.get(i)?.candidate_id;
|
||||||
|
entries.iter().find(|entry| entry.path.id == id).cloned()
|
||||||
|
}
|
||||||
|
DirectoryState::None { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-support"))]
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
pub fn collect_match_candidates(&self) -> Vec<String> {
|
pub fn collect_match_candidates(&self) -> Vec<String> {
|
||||||
if let Some(state) = self.directory_state.as_ref() {
|
match &self.directory_state {
|
||||||
self.matches
|
DirectoryState::List { entries, .. } => self
|
||||||
|
.string_matches
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&index| {
|
.filter_map(|string_match| {
|
||||||
state
|
entries
|
||||||
.match_candidates
|
.iter()
|
||||||
.get(index)
|
.find(|entry| entry.path.id == string_match.candidate_id)
|
||||||
.map(|candidate| candidate.path.string.clone())
|
.map(|candidate| candidate.path.string.clone())
|
||||||
})
|
})
|
||||||
.collect()
|
.collect(),
|
||||||
} else {
|
DirectoryState::Create {
|
||||||
Vec::new()
|
user_input,
|
||||||
|
entries,
|
||||||
|
..
|
||||||
|
} => user_input
|
||||||
|
.into_iter()
|
||||||
|
.filter(|user_input| !user_input.exists || !user_input.is_dir)
|
||||||
|
.map(|user_input| user_input.file.string.clone())
|
||||||
|
.chain(self.string_matches.iter().filter_map(|string_match| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.path.id == string_match.candidate_id)
|
||||||
|
.map(|candidate| candidate.path.string.clone())
|
||||||
|
}))
|
||||||
|
.collect(),
|
||||||
|
DirectoryState::None { .. } => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct DirectoryState {
|
enum DirectoryState {
|
||||||
path: String,
|
List {
|
||||||
match_candidates: Vec<CandidateInfo>,
|
parent_path: String,
|
||||||
error: Option<SharedString>,
|
entries: Vec<CandidateInfo>,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
},
|
||||||
|
Create {
|
||||||
|
parent_path: String,
|
||||||
|
user_input: Option<UserInput>,
|
||||||
|
entries: Vec<CandidateInfo>,
|
||||||
|
},
|
||||||
|
None {
|
||||||
|
create: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct UserInput {
|
||||||
|
file: StringMatchCandidate,
|
||||||
|
exists: bool,
|
||||||
|
is_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -83,7 +159,19 @@ impl OpenPathPrompt {
|
|||||||
) {
|
) {
|
||||||
workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
|
workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
|
||||||
let (tx, rx) = futures::channel::oneshot::channel();
|
let (tx, rx) = futures::channel::oneshot::channel();
|
||||||
Self::prompt_for_open_path(workspace, lister, tx, window, cx);
|
Self::prompt_for_open_path(workspace, lister, false, tx, window, cx);
|
||||||
|
rx
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn register_new_path(
|
||||||
|
workspace: &mut Workspace,
|
||||||
|
_window: Option<&mut Window>,
|
||||||
|
_: &mut Context<Workspace>,
|
||||||
|
) {
|
||||||
|
workspace.set_prompt_for_new_path(Box::new(|workspace, lister, window, cx| {
|
||||||
|
let (tx, rx) = futures::channel::oneshot::channel();
|
||||||
|
Self::prompt_for_open_path(workspace, lister, true, tx, window, cx);
|
||||||
rx
|
rx
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -91,13 +179,13 @@ impl OpenPathPrompt {
|
|||||||
fn prompt_for_open_path(
|
fn prompt_for_open_path(
|
||||||
workspace: &mut Workspace,
|
workspace: &mut Workspace,
|
||||||
lister: DirectoryLister,
|
lister: DirectoryLister,
|
||||||
|
creating_path: bool,
|
||||||
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Workspace>,
|
cx: &mut Context<Workspace>,
|
||||||
) {
|
) {
|
||||||
workspace.toggle_modal(window, cx, |window, cx| {
|
workspace.toggle_modal(window, cx, |window, cx| {
|
||||||
let delegate = OpenPathDelegate::new(tx, lister.clone());
|
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
|
||||||
|
|
||||||
let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
|
let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
|
||||||
let query = lister.default_query(cx);
|
let query = lister.default_query(cx);
|
||||||
picker.set_query(query, window, cx);
|
picker.set_query(query, window, cx);
|
||||||
@@ -110,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate {
|
|||||||
type ListItem = ui::ListItem;
|
type ListItem = ui::ListItem;
|
||||||
|
|
||||||
fn match_count(&self) -> usize {
|
fn match_count(&self) -> usize {
|
||||||
self.matches.len()
|
let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state {
|
||||||
|
user_input
|
||||||
|
.as_ref()
|
||||||
|
.filter(|input| !input.exists || !input.is_dir)
|
||||||
|
.into_iter()
|
||||||
|
.count()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
self.string_matches.len() + user_input
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_index(&self) -> usize {
|
fn selected_index(&self) -> usize {
|
||||||
@@ -127,127 +224,196 @@ impl PickerDelegate for OpenPathDelegate {
|
|||||||
query: String,
|
query: String,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Picker<Self>>,
|
cx: &mut Context<Picker<Self>>,
|
||||||
) -> gpui::Task<()> {
|
) -> Task<()> {
|
||||||
let lister = self.lister.clone();
|
let lister = &self.lister;
|
||||||
let query_path = Path::new(&query);
|
let last_item = Path::new(&query)
|
||||||
let last_item = query_path
|
|
||||||
.file_name()
|
.file_name()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string_lossy()
|
.to_string_lossy();
|
||||||
.to_string();
|
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
|
||||||
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
|
(dir.to_string(), last_item.into_owned())
|
||||||
(dir.to_string(), last_item)
|
|
||||||
} else {
|
} else {
|
||||||
(query, String::new())
|
(query, String::new())
|
||||||
};
|
};
|
||||||
|
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
#[cfg(not(target_os = "windows"))]
|
dir = PROMPT_ROOT.to_string();
|
||||||
{
|
|
||||||
dir = "/".to_string();
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
dir = "C:\\".to_string();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = if self
|
let query = match &self.directory_state {
|
||||||
.directory_state
|
DirectoryState::List { parent_path, .. } => {
|
||||||
.as_ref()
|
if parent_path == &dir {
|
||||||
.map_or(false, |s| s.path == dir)
|
None
|
||||||
{
|
} else {
|
||||||
None
|
Some(lister.list_directory(dir.clone(), cx))
|
||||||
} else {
|
}
|
||||||
Some(lister.list_directory(dir.clone(), cx))
|
}
|
||||||
|
DirectoryState::Create {
|
||||||
|
parent_path,
|
||||||
|
user_input,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if parent_path == &dir
|
||||||
|
&& user_input.as_ref().map(|input| &input.file.string) == Some(&suffix)
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(lister.list_directory(dir.clone(), cx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DirectoryState::None { .. } => Some(lister.list_directory(dir.clone(), cx)),
|
||||||
};
|
};
|
||||||
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
|
self.cancel_flag.store(true, atomic::Ordering::Release);
|
||||||
self.cancel_flag = Arc::new(AtomicBool::new(false));
|
self.cancel_flag = Arc::new(AtomicBool::new(false));
|
||||||
let cancel_flag = self.cancel_flag.clone();
|
let cancel_flag = self.cancel_flag.clone();
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
if let Some(query) = query {
|
if let Some(query) = query {
|
||||||
let paths = query.await;
|
let paths = query.await;
|
||||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |this, _| {
|
if this
|
||||||
this.delegate.directory_state = Some(match paths {
|
.update(cx, |this, _| {
|
||||||
Ok(mut paths) => {
|
let new_state = match &this.delegate.directory_state {
|
||||||
if dir == "/" {
|
DirectoryState::None { create: false }
|
||||||
paths.push(DirectoryItem {
|
| DirectoryState::List { .. } => match paths {
|
||||||
is_dir: true,
|
Ok(paths) => DirectoryState::List {
|
||||||
path: Default::default(),
|
entries: path_candidates(&dir, paths),
|
||||||
});
|
parent_path: dir.clone(),
|
||||||
}
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => DirectoryState::List {
|
||||||
|
entries: Vec::new(),
|
||||||
|
parent_path: dir.clone(),
|
||||||
|
error: Some(SharedString::from(e.to_string())),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DirectoryState::None { create: true }
|
||||||
|
| DirectoryState::Create { .. } => match paths {
|
||||||
|
Ok(paths) => {
|
||||||
|
let mut entries = path_candidates(&dir, paths);
|
||||||
|
let mut exists = false;
|
||||||
|
let mut is_dir = false;
|
||||||
|
let mut new_id = None;
|
||||||
|
entries.retain(|entry| {
|
||||||
|
new_id = new_id.max(Some(entry.path.id));
|
||||||
|
if entry.path.string == suffix {
|
||||||
|
exists = true;
|
||||||
|
is_dir = entry.is_dir;
|
||||||
|
}
|
||||||
|
!exists || is_dir
|
||||||
|
});
|
||||||
|
|
||||||
paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
|
let new_id = new_id.map(|id| id + 1).unwrap_or(0);
|
||||||
let match_candidates = paths
|
let user_input = if suffix.is_empty() {
|
||||||
.iter()
|
None
|
||||||
.enumerate()
|
} else {
|
||||||
.map(|(ix, item)| CandidateInfo {
|
Some(UserInput {
|
||||||
path: StringMatchCandidate::new(
|
file: StringMatchCandidate::new(new_id, &suffix),
|
||||||
ix,
|
exists,
|
||||||
&item.path.to_string_lossy(),
|
is_dir,
|
||||||
),
|
})
|
||||||
is_dir: item.is_dir,
|
};
|
||||||
})
|
DirectoryState::Create {
|
||||||
.collect::<Vec<_>>();
|
entries,
|
||||||
|
parent_path: dir.clone(),
|
||||||
DirectoryState {
|
user_input,
|
||||||
match_candidates,
|
}
|
||||||
path: dir,
|
}
|
||||||
error: None,
|
Err(_) => DirectoryState::Create {
|
||||||
}
|
entries: Vec::new(),
|
||||||
}
|
parent_path: dir.clone(),
|
||||||
Err(err) => DirectoryState {
|
user_input: Some(UserInput {
|
||||||
match_candidates: vec![],
|
exists: false,
|
||||||
path: dir,
|
is_dir: false,
|
||||||
error: Some(err.to_string().into()),
|
file: StringMatchCandidate::new(0, &suffix),
|
||||||
},
|
}),
|
||||||
});
|
},
|
||||||
})
|
},
|
||||||
.ok();
|
};
|
||||||
|
this.delegate.directory_state = new_state;
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let match_candidates = this
|
let Ok(mut new_entries) =
|
||||||
.update(cx, |this, cx| {
|
this.update(cx, |this, _| match &this.delegate.directory_state {
|
||||||
let directory_state = this.delegate.directory_state.as_ref()?;
|
DirectoryState::List {
|
||||||
if directory_state.error.is_some() {
|
entries,
|
||||||
this.delegate.matches.clear();
|
error: None,
|
||||||
this.delegate.selected_index = 0;
|
..
|
||||||
cx.notify();
|
}
|
||||||
return None;
|
| DirectoryState::Create { entries, .. } => entries.clone(),
|
||||||
|
DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
|
||||||
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(directory_state.match_candidates.clone())
|
|
||||||
})
|
})
|
||||||
.unwrap_or(None);
|
else {
|
||||||
|
|
||||||
let Some(mut match_candidates) = match_candidates else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !suffix.starts_with('.') {
|
if !suffix.starts_with('.') {
|
||||||
match_candidates.retain(|m| !m.path.string.starts_with('.'));
|
new_entries.retain(|entry| !entry.path.string.starts_with('.'));
|
||||||
}
|
}
|
||||||
|
if suffix.is_empty() {
|
||||||
if suffix == "" {
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.delegate.matches.clear();
|
this.delegate.selected_index = 0;
|
||||||
this.delegate.string_matches.clear();
|
this.delegate.string_matches = new_entries
|
||||||
this.delegate
|
.iter()
|
||||||
.matches
|
.map(|m| StringMatch {
|
||||||
.extend(match_candidates.iter().map(|m| m.path.id));
|
candidate_id: m.path.id,
|
||||||
|
score: 0.0,
|
||||||
|
positions: Vec::new(),
|
||||||
|
string: m.path.string.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
this.delegate.directory_state =
|
||||||
|
match &this.delegate.directory_state {
|
||||||
|
DirectoryState::None { create: false }
|
||||||
|
| DirectoryState::List { .. } => DirectoryState::List {
|
||||||
|
parent_path: dir.clone(),
|
||||||
|
entries: new_entries,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
DirectoryState::None { create: true }
|
||||||
|
| DirectoryState::Create { .. } => DirectoryState::Create {
|
||||||
|
parent_path: dir.clone(),
|
||||||
|
user_input: None,
|
||||||
|
entries: new_entries,
|
||||||
|
},
|
||||||
|
};
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
|
let Ok(is_create_state) =
|
||||||
|
this.update(cx, |this, _| match &this.delegate.directory_state {
|
||||||
|
DirectoryState::Create { .. } => true,
|
||||||
|
DirectoryState::List { .. } => false,
|
||||||
|
DirectoryState::None { create } => *create,
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let candidates = new_entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
if is_create_state && !entry.is_dir && Some(&suffix) == Some(&entry.path.string)
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&entry.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let matches = fuzzy::match_strings(
|
let matches = fuzzy::match_strings(
|
||||||
candidates.as_slice(),
|
candidates.as_slice(),
|
||||||
&suffix,
|
&suffix,
|
||||||
@@ -257,27 +423,57 @@ impl PickerDelegate for OpenPathDelegate {
|
|||||||
cx.background_executor().clone(),
|
cx.background_executor().clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.delegate.matches.clear();
|
this.delegate.selected_index = 0;
|
||||||
this.delegate.string_matches = matches.clone();
|
this.delegate.string_matches = matches.clone();
|
||||||
this.delegate
|
this.delegate.string_matches.sort_by_key(|m| {
|
||||||
.matches
|
|
||||||
.extend(matches.into_iter().map(|m| m.candidate_id));
|
|
||||||
this.delegate.matches.sort_by_key(|m| {
|
|
||||||
(
|
(
|
||||||
this.delegate.directory_state.as_ref().and_then(|d| {
|
new_entries
|
||||||
d.match_candidates
|
.iter()
|
||||||
.get(*m)
|
.find(|entry| entry.path.id == m.candidate_id)
|
||||||
.map(|c| !c.path.string.starts_with(&suffix))
|
.map(|entry| &entry.path)
|
||||||
}),
|
.map(|candidate| !candidate.string.starts_with(&suffix)),
|
||||||
*m,
|
m.candidate_id,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
this.delegate.selected_index = 0;
|
this.delegate.directory_state = match &this.delegate.directory_state {
|
||||||
|
DirectoryState::None { create: false } | DirectoryState::List { .. } => {
|
||||||
|
DirectoryState::List {
|
||||||
|
entries: new_entries,
|
||||||
|
parent_path: dir.clone(),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DirectoryState::None { create: true } => DirectoryState::Create {
|
||||||
|
entries: new_entries,
|
||||||
|
parent_path: dir.clone(),
|
||||||
|
user_input: Some(UserInput {
|
||||||
|
file: StringMatchCandidate::new(0, &suffix),
|
||||||
|
exists: false,
|
||||||
|
is_dir: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
DirectoryState::Create { user_input, .. } => {
|
||||||
|
let (new_id, exists, is_dir) = user_input
|
||||||
|
.as_ref()
|
||||||
|
.map(|input| (input.file.id, input.exists, input.is_dir))
|
||||||
|
.unwrap_or_else(|| (0, false, false));
|
||||||
|
DirectoryState::Create {
|
||||||
|
entries: new_entries,
|
||||||
|
parent_path: dir.clone(),
|
||||||
|
user_input: Some(UserInput {
|
||||||
|
file: StringMatchCandidate::new(new_id, &suffix),
|
||||||
|
exists,
|
||||||
|
is_dir,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -290,49 +486,107 @@ impl PickerDelegate for OpenPathDelegate {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
_: &mut Context<Picker<Self>>,
|
_: &mut Context<Picker<Self>>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
let candidate = self.get_entry(self.selected_index)?;
|
||||||
Some(
|
Some(
|
||||||
maybe!({
|
maybe!({
|
||||||
let m = self.matches.get(self.selected_index)?;
|
match &self.directory_state {
|
||||||
let directory_state = self.directory_state.as_ref()?;
|
DirectoryState::Create { parent_path, .. } => Some(format!(
|
||||||
let candidate = directory_state.match_candidates.get(*m)?;
|
"{}{}{}",
|
||||||
Some(format!(
|
parent_path,
|
||||||
"{}{}{}",
|
candidate.path.string,
|
||||||
directory_state.path,
|
if candidate.is_dir {
|
||||||
candidate.path.string,
|
MAIN_SEPARATOR_STR
|
||||||
if candidate.is_dir {
|
} else {
|
||||||
MAIN_SEPARATOR_STR
|
""
|
||||||
} else {
|
}
|
||||||
""
|
)),
|
||||||
}
|
DirectoryState::List { parent_path, .. } => Some(format!(
|
||||||
))
|
"{}{}{}",
|
||||||
|
parent_path,
|
||||||
|
candidate.path.string,
|
||||||
|
if candidate.is_dir {
|
||||||
|
MAIN_SEPARATOR_STR
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
DirectoryState::None { .. } => return None,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or(query),
|
.unwrap_or(query),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||||
let Some(m) = self.matches.get(self.selected_index) else {
|
let Some(candidate) = self.get_entry(self.selected_index) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(directory_state) = self.directory_state.as_ref() else {
|
|
||||||
return;
|
match &self.directory_state {
|
||||||
};
|
DirectoryState::None { .. } => return,
|
||||||
let Some(candidate) = directory_state.match_candidates.get(*m) else {
|
DirectoryState::List { parent_path, .. } => {
|
||||||
return;
|
let confirmed_path =
|
||||||
};
|
if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
|
||||||
let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
|
PathBuf::from(PROMPT_ROOT)
|
||||||
PathBuf::from("/")
|
} else {
|
||||||
} else {
|
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
|
||||||
Path::new(
|
.join(&candidate.path.string)
|
||||||
self.lister
|
};
|
||||||
.resolve_tilde(&directory_state.path, cx)
|
if let Some(tx) = self.tx.take() {
|
||||||
.as_ref(),
|
tx.send(Some(vec![confirmed_path])).ok();
|
||||||
)
|
}
|
||||||
.join(&candidate.path.string)
|
}
|
||||||
};
|
DirectoryState::Create {
|
||||||
if let Some(tx) = self.tx.take() {
|
parent_path,
|
||||||
tx.send(Some(vec![result])).ok();
|
user_input,
|
||||||
|
..
|
||||||
|
} => match user_input {
|
||||||
|
None => return,
|
||||||
|
Some(user_input) => {
|
||||||
|
if user_input.is_dir {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let prompted_path =
|
||||||
|
if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
|
||||||
|
PathBuf::from(PROMPT_ROOT)
|
||||||
|
} else {
|
||||||
|
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
|
||||||
|
.join(&user_input.file.string)
|
||||||
|
};
|
||||||
|
if user_input.exists {
|
||||||
|
self.should_dismiss = false;
|
||||||
|
let answer = window.prompt(
|
||||||
|
gpui::PromptLevel::Critical,
|
||||||
|
&format!("{prompted_path:?} already exists. Do you want to replace it?"),
|
||||||
|
Some(
|
||||||
|
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
|
||||||
|
),
|
||||||
|
&["Replace", "Cancel"],
|
||||||
|
cx
|
||||||
|
);
|
||||||
|
self.replace_prompt = cx.spawn_in(window, async move |picker, cx| {
|
||||||
|
let answer = answer.await.ok();
|
||||||
|
picker
|
||||||
|
.update(cx, |picker, cx| {
|
||||||
|
picker.delegate.should_dismiss = true;
|
||||||
|
if answer != Some(0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(tx) = picker.delegate.tx.take() {
|
||||||
|
tx.send(Some(vec![prompted_path])).ok();
|
||||||
|
}
|
||||||
|
cx.emit(gpui::DismissEvent);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if let Some(tx) = self.tx.take() {
|
||||||
|
tx.send(Some(vec![prompted_path])).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.emit(gpui::DismissEvent);
|
cx.emit(gpui::DismissEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,19 +605,30 @@ impl PickerDelegate for OpenPathDelegate {
|
|||||||
&self,
|
&self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
_window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Picker<Self>>,
|
cx: &mut Context<Picker<Self>>,
|
||||||
) -> Option<Self::ListItem> {
|
) -> Option<Self::ListItem> {
|
||||||
let settings = FileFinderSettings::get_global(cx);
|
let settings = FileFinderSettings::get_global(cx);
|
||||||
let m = self.matches.get(ix)?;
|
let candidate = self.get_entry(ix)?;
|
||||||
let directory_state = self.directory_state.as_ref()?;
|
let match_positions = match &self.directory_state {
|
||||||
let candidate = directory_state.match_candidates.get(*m)?;
|
DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
|
||||||
let highlight_positions = self
|
DirectoryState::Create { user_input, .. } => {
|
||||||
.string_matches
|
if let Some(user_input) = user_input {
|
||||||
.iter()
|
if !user_input.exists || !user_input.is_dir {
|
||||||
.find(|string_match| string_match.candidate_id == *m)
|
if ix == 0 {
|
||||||
.map(|string_match| string_match.positions.clone())
|
Vec::new()
|
||||||
.unwrap_or_default();
|
} else {
|
||||||
|
self.string_matches.get(ix - 1)?.positions.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.string_matches.get(ix)?.positions.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.string_matches.get(ix)?.positions.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DirectoryState::None { .. } => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
let file_icon = maybe!({
|
let file_icon = maybe!({
|
||||||
if !settings.file_icons {
|
if !settings.file_icons {
|
||||||
@@ -378,34 +643,128 @@ impl PickerDelegate for OpenPathDelegate {
|
|||||||
Some(Icon::from_path(icon).color(Color::Muted))
|
Some(Icon::from_path(icon).color(Color::Muted))
|
||||||
});
|
});
|
||||||
|
|
||||||
Some(
|
match &self.directory_state {
|
||||||
ListItem::new(ix)
|
DirectoryState::List { parent_path, .. } => Some(
|
||||||
.spacing(ListItemSpacing::Sparse)
|
ListItem::new(ix)
|
||||||
.start_slot::<Icon>(file_icon)
|
.spacing(ListItemSpacing::Sparse)
|
||||||
.inset(true)
|
.start_slot::<Icon>(file_icon)
|
||||||
.toggle_state(selected)
|
.inset(true)
|
||||||
.child(HighlightedLabel::new(
|
.toggle_state(selected)
|
||||||
if directory_state.path == "/" {
|
.child(HighlightedLabel::new(
|
||||||
format!("/{}", candidate.path.string)
|
if parent_path == PROMPT_ROOT {
|
||||||
} else {
|
format!("{}{}", PROMPT_ROOT, candidate.path.string)
|
||||||
candidate.path.string.clone()
|
} else {
|
||||||
},
|
candidate.path.string.clone()
|
||||||
highlight_positions,
|
},
|
||||||
)),
|
match_positions,
|
||||||
)
|
)),
|
||||||
|
),
|
||||||
|
DirectoryState::Create {
|
||||||
|
parent_path,
|
||||||
|
user_input,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let (label, delta) = if parent_path == PROMPT_ROOT {
|
||||||
|
(
|
||||||
|
format!("{}{}", PROMPT_ROOT, candidate.path.string),
|
||||||
|
PROMPT_ROOT.len(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(candidate.path.string.clone(), 0)
|
||||||
|
};
|
||||||
|
let label_len = label.len();
|
||||||
|
|
||||||
|
let label_with_highlights = match user_input {
|
||||||
|
Some(user_input) => {
|
||||||
|
if user_input.file.string == candidate.path.string {
|
||||||
|
if user_input.exists {
|
||||||
|
let label = if user_input.is_dir {
|
||||||
|
label
|
||||||
|
} else {
|
||||||
|
format!("{label} (replace)")
|
||||||
|
};
|
||||||
|
StyledText::new(label)
|
||||||
|
.with_default_highlights(
|
||||||
|
&window.text_style().clone(),
|
||||||
|
vec![(
|
||||||
|
delta..delta + label_len,
|
||||||
|
HighlightStyle::color(Color::Conflict.color(cx)),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
StyledText::new(format!("{label} (create)"))
|
||||||
|
.with_default_highlights(
|
||||||
|
&window.text_style().clone(),
|
||||||
|
vec![(
|
||||||
|
delta..delta + label_len,
|
||||||
|
HighlightStyle::color(Color::Created.color(cx)),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut highlight_positions = match_positions;
|
||||||
|
highlight_positions.iter_mut().for_each(|position| {
|
||||||
|
*position += delta;
|
||||||
|
});
|
||||||
|
HighlightedLabel::new(label, highlight_positions).into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut highlight_positions = match_positions;
|
||||||
|
highlight_positions.iter_mut().for_each(|position| {
|
||||||
|
*position += delta;
|
||||||
|
});
|
||||||
|
HighlightedLabel::new(label, highlight_positions).into_any_element()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(
|
||||||
|
ListItem::new(ix)
|
||||||
|
.spacing(ListItemSpacing::Sparse)
|
||||||
|
.start_slot::<Icon>(file_icon)
|
||||||
|
.inset(true)
|
||||||
|
.toggle_state(selected)
|
||||||
|
.child(LabelLike::new().child(label_with_highlights)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DirectoryState::None { .. } => return None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||||
let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
|
Some(match &self.directory_state {
|
||||||
{
|
DirectoryState::Create { .. } => SharedString::from("Type a path…"),
|
||||||
error
|
DirectoryState::List {
|
||||||
} else {
|
error: Some(error), ..
|
||||||
"No such file or directory".into()
|
} => error.clone(),
|
||||||
};
|
DirectoryState::List { .. } | DirectoryState::None { .. } => {
|
||||||
Some(text)
|
SharedString::from("No such file or directory")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||||
Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
|
Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
|
||||||
|
if *parent_path == PROMPT_ROOT {
|
||||||
|
children.push(DirectoryItem {
|
||||||
|
is_dir: true,
|
||||||
|
path: PathBuf::default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
children.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
|
||||||
|
children
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, item)| CandidateInfo {
|
||||||
|
path: StringMatchCandidate::new(ix, &item.path.to_string_lossy()),
|
||||||
|
is_dir: item.is_dir,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
|
|||||||
|
|
||||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||||
|
|
||||||
let (picker, cx) = build_open_path_prompt(project, cx);
|
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||||
|
|
||||||
let query = path!("/root");
|
let query = path!("/root");
|
||||||
insert_query(query, &picker, cx).await;
|
insert_query(query, &picker, cx).await;
|
||||||
@@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
|
|||||||
|
|
||||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||||
|
|
||||||
let (picker, cx) = build_open_path_prompt(project, cx);
|
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||||
|
|
||||||
// Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
|
// Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
|
||||||
let query = path!("/root");
|
let query = path!("/root");
|
||||||
@@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
|
|||||||
|
|
||||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||||
|
|
||||||
let (picker, cx) = build_open_path_prompt(project, cx);
|
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||||
|
|
||||||
// Support both forward and backward slashes.
|
// Support both forward and backward slashes.
|
||||||
let query = "C:/root/";
|
let query = "C:/root/";
|
||||||
@@ -251,6 +251,54 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_new_path_prompt(cx: &mut TestAppContext) {
|
||||||
|
let app_state = init_test(cx);
|
||||||
|
app_state
|
||||||
|
.fs
|
||||||
|
.as_fake()
|
||||||
|
.insert_tree(
|
||||||
|
path!("/root"),
|
||||||
|
json!({
|
||||||
|
"a1": "A1",
|
||||||
|
"a2": "A2",
|
||||||
|
"a3": "A3",
|
||||||
|
"dir1": {},
|
||||||
|
"dir2": {
|
||||||
|
"c": "C",
|
||||||
|
"d1": "D1",
|
||||||
|
"d2": "D2",
|
||||||
|
"d3": "D3",
|
||||||
|
"dir3": {},
|
||||||
|
"dir4": {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||||
|
|
||||||
|
let (picker, cx) = build_open_path_prompt(project, true, cx);
|
||||||
|
|
||||||
|
insert_query(path!("/root"), &picker, cx).await;
|
||||||
|
assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
|
||||||
|
|
||||||
|
insert_query(path!("/root/d"), &picker, cx).await;
|
||||||
|
assert_eq!(
|
||||||
|
collect_match_candidates(&picker, cx),
|
||||||
|
vec!["d", "dir1", "dir2"]
|
||||||
|
);
|
||||||
|
|
||||||
|
insert_query(path!("/root/dir1"), &picker, cx).await;
|
||||||
|
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
|
||||||
|
|
||||||
|
insert_query(path!("/root/dir12"), &picker, cx).await;
|
||||||
|
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir12"]);
|
||||||
|
|
||||||
|
insert_query(path!("/root/dir1"), &picker, cx).await;
|
||||||
|
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
|
||||||
|
}
|
||||||
|
|
||||||
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
let state = AppState::test(cx);
|
let state = AppState::test(cx);
|
||||||
@@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
|||||||
|
|
||||||
fn build_open_path_prompt(
|
fn build_open_path_prompt(
|
||||||
project: Entity<Project>,
|
project: Entity<Project>,
|
||||||
|
creating_path: bool,
|
||||||
cx: &mut TestAppContext,
|
cx: &mut TestAppContext,
|
||||||
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
|
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
|
||||||
let (tx, _) = futures::channel::oneshot::channel();
|
let (tx, _) = futures::channel::oneshot::channel();
|
||||||
let lister = project::DirectoryLister::Project(project.clone());
|
let lister = project::DirectoryLister::Project(project.clone());
|
||||||
let delegate = OpenPathDelegate::new(tx, lister.clone());
|
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
|
||||||
|
|
||||||
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
|
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -597,7 +597,9 @@ impl Fs for RealFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
|
async fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
|
||||||
Ok(smol::fs::canonicalize(path).await?)
|
Ok(smol::fs::canonicalize(path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("canonicalizing {path:?}"))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_file(&self, path: &Path) -> bool {
|
async fn is_file(&self, path: &Path) -> bool {
|
||||||
@@ -1454,7 +1456,12 @@ impl FakeFs {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_head_for_repo(&self, dot_git: &Path, head_state: &[(RepoPath, String)]) {
|
pub fn set_head_for_repo(
|
||||||
|
&self,
|
||||||
|
dot_git: &Path,
|
||||||
|
head_state: &[(RepoPath, String)],
|
||||||
|
sha: impl Into<String>,
|
||||||
|
) {
|
||||||
self.with_git_state(dot_git, true, |state| {
|
self.with_git_state(dot_git, true, |state| {
|
||||||
state.head_contents.clear();
|
state.head_contents.clear();
|
||||||
state.head_contents.extend(
|
state.head_contents.extend(
|
||||||
@@ -1462,6 +1469,7 @@ impl FakeFs {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(path, content)| (path.clone(), content.clone())),
|
.map(|(path, content)| (path.clone(), content.clone())),
|
||||||
);
|
);
|
||||||
|
state.refs.insert("HEAD".into(), sha.into());
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1387,6 +1387,7 @@ mod tests {
|
|||||||
fs.set_head_for_repo(
|
fs.set_head_for_repo(
|
||||||
path!("/project/.git").as_ref(),
|
path!("/project/.git").as_ref(),
|
||||||
&[("foo.txt".into(), "foo\n".into())],
|
&[("foo.txt".into(), "foo\n".into())],
|
||||||
|
"deadbeef",
|
||||||
);
|
);
|
||||||
fs.set_index_for_repo(
|
fs.set_index_for_repo(
|
||||||
path!("/project/.git").as_ref(),
|
path!("/project/.git").as_ref(),
|
||||||
@@ -1523,6 +1524,7 @@ mod tests {
|
|||||||
fs.set_head_for_repo(
|
fs.set_head_for_repo(
|
||||||
path!("/project/.git").as_ref(),
|
path!("/project/.git").as_ref(),
|
||||||
&[("foo".into(), "original\n".into())],
|
&[("foo".into(), "original\n".into())],
|
||||||
|
"deadbeef",
|
||||||
);
|
);
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,18 @@ impl ActionRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a list of all the registered actions.
|
||||||
|
/// Useful for transforming the list of available actions into a
|
||||||
|
/// format suited for static analysis such as in validating keymaps, or
|
||||||
|
/// generating documentation.
|
||||||
|
pub fn generate_list_of_all_registered_actions() -> Vec<MacroActionData> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for builder in inventory::iter::<MacroActionBuilder> {
|
||||||
|
actions.push(builder.0());
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
/// Defines and registers unit structs that can be used as actions.
|
/// Defines and registers unit structs that can be used as actions.
|
||||||
///
|
///
|
||||||
/// To use more complex data types as actions, use `impl_actions!`
|
/// To use more complex data types as actions, use `impl_actions!`
|
||||||
@@ -333,7 +345,6 @@ macro_rules! action_as {
|
|||||||
::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq,
|
::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq,
|
||||||
)]
|
)]
|
||||||
pub struct $name;
|
pub struct $name;
|
||||||
|
|
||||||
gpui::__impl_action!(
|
gpui::__impl_action!(
|
||||||
$namespace,
|
$namespace,
|
||||||
$name,
|
$name,
|
||||||
|
|||||||
@@ -1559,6 +1559,11 @@ impl App {
|
|||||||
self.active_drag.is_some()
|
self.active_drag.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets the cursor style of the currently active drag operation.
|
||||||
|
pub fn active_drag_cursor_style(&self) -> Option<CursorStyle> {
|
||||||
|
self.active_drag.as_ref().and_then(|drag| drag.cursor_style)
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops active drag and clears any related effects.
|
/// Stops active drag and clears any related effects.
|
||||||
pub fn stop_active_drag(&mut self, window: &mut Window) -> bool {
|
pub fn stop_active_drag(&mut self, window: &mut Window) -> bool {
|
||||||
if self.active_drag.is_some() {
|
if self.active_drag.is_some() {
|
||||||
@@ -1570,6 +1575,21 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the cursor style for the currently active drag operation.
|
||||||
|
pub fn set_active_drag_cursor_style(
|
||||||
|
&mut self,
|
||||||
|
cursor_style: CursorStyle,
|
||||||
|
window: &mut Window,
|
||||||
|
) -> bool {
|
||||||
|
if let Some(ref mut drag) = self.active_drag {
|
||||||
|
drag.cursor_style = Some(cursor_style);
|
||||||
|
window.refresh();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the prompt renderer for GPUI. This will replace the default or platform specific
|
/// Set the prompt renderer for GPUI. This will replace the default or platform specific
|
||||||
/// prompts with this custom implementation.
|
/// prompts with this custom implementation.
|
||||||
pub fn set_prompt_builder(
|
pub fn set_prompt_builder(
|
||||||
|
|||||||
@@ -635,12 +635,8 @@ impl WaylandWindowStatePtr {
|
|||||||
let mut bounds: Option<Bounds<Pixels>> = None;
|
let mut bounds: Option<Bounds<Pixels>> = None;
|
||||||
if let Some(mut input_handler) = state.input_handler.take() {
|
if let Some(mut input_handler) = state.input_handler.take() {
|
||||||
drop(state);
|
drop(state);
|
||||||
if let Some(selection) = input_handler.selected_text_range(true) {
|
if let Some(selection) = input_handler.marked_text_range() {
|
||||||
bounds = input_handler.bounds_for_range(if selection.reversed {
|
bounds = input_handler.bounds_for_range(selection.start..selection.start);
|
||||||
selection.range.start..selection.range.start
|
|
||||||
} else {
|
|
||||||
selection.range.end..selection.range.end
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
self.state.borrow_mut().input_handler = Some(input_handler);
|
self.state.borrow_mut().input_handler = Some(input_handler);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user