Compare commits
114 Commits
dap-fix-in
...
fix-confli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48382d1a2 | ||
|
|
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 | ||
|
|
e793740168 | ||
|
|
dea0a58727 | ||
|
|
b7abc9d493 | ||
|
|
01a77bb231 | ||
|
|
de225fd242 | ||
|
|
1bc052d76b | ||
|
|
29cb95a3ca | ||
|
|
1307b81721 | ||
|
|
203754d0db | ||
|
|
c9c603b1d1 | ||
|
|
e13b494c9e | ||
|
|
c0397727e0 | ||
|
|
9c2b90fb8f | ||
|
|
d108e5f53c | ||
|
|
2551bde1d3 | ||
|
|
e7de80c6ae | ||
|
|
ae210eced8 | ||
|
|
a9d99d8347 | ||
|
|
3e6435eddc | ||
|
|
9e75871d48 | ||
|
|
707a4c7f20 | ||
|
|
854076f96d | ||
|
|
cf931247d0 | ||
|
|
b74477d12e | ||
|
|
3077abf9cf | ||
|
|
07dab4e94a | ||
|
|
59686f1f44 | ||
|
|
a60bea8a3d | ||
|
|
b820aa1fcd | ||
|
|
55d91bce53 | ||
|
|
b798392050 | ||
|
|
657c8b1084 | ||
|
|
2bb8aa2f73 | ||
|
|
beeb42da29 | ||
|
|
6d66ff1d95 | ||
|
|
e0b818af62 | ||
|
|
58a400b1ee | ||
|
|
8ab7d44d51 | ||
|
|
56d4c0af9f | ||
|
|
feeda7fa37 | ||
|
|
4a5c55a8f2 | ||
|
|
7c1ae9bcc3 | ||
|
|
6f97da3435 | ||
|
|
63c1033448 | ||
|
|
b16911e756 | ||
|
|
b14401f817 | ||
|
|
17cf865d1e | ||
|
|
b7ec437b13 | ||
|
|
f1aab1120d | ||
|
|
3f90bc81bd | ||
|
|
9d5fb3c3f3 | ||
|
|
864767ad35 | ||
|
|
ec69b68e72 | ||
|
|
9dd18e5ee1 | ||
|
|
2ebe16a52f | ||
|
|
1ed4647203 | ||
|
|
ebed567adb | ||
|
|
a6544c70c5 | ||
|
|
b363e1a482 | ||
|
|
65e3e84cbc | ||
|
|
1e1d4430c2 | ||
|
|
c874f1fa9d | ||
|
|
9a9e96ed5a | ||
|
|
8c46e290df | ||
|
|
aacbb9c2f4 | ||
|
|
f90333f92e | ||
|
|
b24f614ca3 | ||
|
|
cefa0cbed8 | ||
|
|
3fb1023667 | ||
|
|
9c715b470e |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
- macOS
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
needs.job_spec.outputs.run_tests == 'true'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
- macOS
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
4
.github/workflows/deploy_collab.yml
vendored
4
.github/workflows/deploy_collab.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
- macOS
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
name: Run tests
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
- macOS
|
||||
needs: style
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
|
||||
4
.github/workflows/release_nightly.yml
vendored
4
.github/workflows/release_nightly.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
- macOS
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
- macOS
|
||||
needs: style
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
|
||||
21
Cargo.lock
generated
21
Cargo.lock
generated
@@ -114,6 +114,7 @@ dependencies = [
|
||||
"serde_json_lenient",
|
||||
"settings",
|
||||
"smol",
|
||||
"sqlez",
|
||||
"streaming_diff",
|
||||
"telemetry",
|
||||
"telemetry_events",
|
||||
@@ -133,6 +134,7 @@ dependencies = [
|
||||
"workspace-hack",
|
||||
"zed_actions",
|
||||
"zed_llm_client",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8760,6 +8762,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"shellexpand 2.1.2",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"streaming-iterator",
|
||||
@@ -8861,6 +8864,7 @@ dependencies = [
|
||||
"mistral",
|
||||
"ollama",
|
||||
"open_ai",
|
||||
"open_router",
|
||||
"partial-json-fixer",
|
||||
"project",
|
||||
"proto",
|
||||
@@ -10705,6 +10709,19 @@ dependencies = [
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "open_router"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures 0.3.31",
|
||||
"http_client",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opener"
|
||||
version = "0.7.2"
|
||||
@@ -12096,6 +12113,7 @@ dependencies = [
|
||||
"unindent",
|
||||
"url",
|
||||
"util",
|
||||
"uuid",
|
||||
"which 6.0.3",
|
||||
"workspace-hack",
|
||||
"worktree",
|
||||
@@ -17112,6 +17130,7 @@ dependencies = [
|
||||
"futures-lite 1.13.0",
|
||||
"git2",
|
||||
"globset",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -19689,7 +19708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.190.0"
|
||||
version = "0.191.0"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"agent",
|
||||
|
||||
@@ -100,6 +100,7 @@ members = [
|
||||
"crates/notifications",
|
||||
"crates/ollama",
|
||||
"crates/open_ai",
|
||||
"crates/open_router",
|
||||
"crates/outline",
|
||||
"crates/outline_panel",
|
||||
"crates/panel",
|
||||
@@ -307,6 +308,7 @@ node_runtime = { path = "crates/node_runtime" }
|
||||
notifications = { path = "crates/notifications" }
|
||||
ollama = { path = "crates/ollama" }
|
||||
open_ai = { path = "crates/open_ai" }
|
||||
open_router = { path = "crates/open_router", features = ["schemars"] }
|
||||
outline = { path = "crates/outline" }
|
||||
outline_panel = { path = "crates/outline_panel" }
|
||||
panel = { path = "crates/panel" }
|
||||
|
||||
8
assets/icons/ai_open_router.svg
Normal file
8
assets/icons/ai_open_router.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
|
||||
<g clip-path="url(#clip0_205_3)">
|
||||
<path d="M0.094 7.78c0.469 0 2.281 -0.405 3.219 -0.936s0.938 -0.531 2.875 -1.906c2.453 -1.741 4.188 -1.158 7.031 -1.158" stroke-width="2.8125" />
|
||||
<path d="m15.969 3.797 -4.805 2.774V1.023z" />
|
||||
<path d="M0 7.781c0.469 0 2.281 0.405 3.219 0.936s0.938 0.531 2.875 1.906C8.547 12.364 10.281 11.781 13.125 11.781" stroke-width="2.8125" />
|
||||
<path d="m15.875 11.764 -4.805 -2.774v5.548z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 575 B |
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 |
@@ -1 +1,3 @@
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.97942 1.25171L6.9585 1.30199L5.58662 4.60039C5.54342 4.70426 5.44573 4.77523 5.3336 4.78422L1.7727 5.0697L1.71841 5.07405L1.38687 5.10063L1.08608 5.12475C0.820085 5.14607 0.712228 5.47802 0.914889 5.65162L1.14406 5.84793L1.39666 6.06431L1.43802 6.09974L4.15105 8.42374C4.23648 8.49692 4.2738 8.61176 4.24769 8.72118L3.41882 12.196L3.40618 12.249L3.32901 12.5725L3.25899 12.866C3.19708 13.1256 3.47945 13.3308 3.70718 13.1917L3.9647 13.0344L4.24854 12.861L4.29502 12.8326L7.34365 10.9705C7.43965 10.9119 7.5604 10.9119 7.6564 10.9705L10.705 12.8326L10.7515 12.861L11.0354 13.0344L11.2929 13.1917C11.5206 13.3308 11.803 13.1256 11.7411 12.866L11.671 12.5725L11.5939 12.249L11.5812 12.196L10.7524 8.72118C10.7263 8.61176 10.7636 8.49692 10.849 8.42374L13.562 6.09974L13.6034 6.06431L13.856 5.84793L14.0852 5.65162C14.2878 5.47802 14.18 5.14607 13.914 5.12475L13.6132 5.10063L13.2816 5.07405L13.2274 5.0697L9.66645 4.78422C9.55432 4.77523 9.45663 4.70426 9.41343 4.60039L8.04155 1.30199L8.02064 1.25171L7.89291 0.944609L7.77702 0.665992C7.67454 0.419604 7.32551 0.419604 7.22303 0.665992L7.10715 0.944609L6.97942 1.25171ZM7.50003 2.60397L6.50994 4.98442C6.32273 5.43453 5.89944 5.74207 5.41351 5.78103L2.84361 5.98705L4.8016 7.66428C5.17183 7.98142 5.33351 8.47903 5.2204 8.95321L4.62221 11.461L6.8224 10.1171C7.23842 9.86302 7.76164 9.86302 8.17766 10.1171L10.3778 11.461L9.77965 8.95321C9.66654 8.47903 9.82822 7.98142 10.1984 7.66428L12.1564 5.98705L9.58654 5.78103C9.10061 5.74207 8.67732 5.43453 8.49011 4.98442L7.50003 2.60397Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.68323 1.53C7.71245 1.47097 7.75758 1.42129 7.81353 1.38655C7.86949 1.35181 7.93404 1.3334 7.9999 1.3334C8.06576 1.3334 8.13031 1.35181 8.18626 1.38655C8.24222 1.42129 8.28735 1.47097 8.31656 1.53L9.85656 4.64933C9.95802 4.85465 10.1078 5.03227 10.293 5.16697C10.4782 5.30167 10.6933 5.38941 10.9199 5.42267L14.3639 5.92667C14.4292 5.93612 14.4905 5.96365 14.5409 6.00613C14.5913 6.04862 14.6289 6.10437 14.6492 6.16707C14.6696 6.22978 14.6721 6.29694 14.6563 6.36096C14.6405 6.42498 14.6071 6.4833 14.5599 6.52933L12.0692 8.95467C11.905 9.11473 11.7821 9.31232 11.7111 9.53042C11.6402 9.74852 11.6233 9.98059 11.6619 10.2067L12.2499 13.6333C12.2614 13.6986 12.2544 13.7657 12.2296 13.8271C12.2048 13.8885 12.1632 13.9417 12.1096 13.9807C12.056 14.0196 11.9926 14.0427 11.9265 14.0473C11.8604 14.0519 11.7944 14.0378 11.7359 14.0067L8.65723 12.388C8.45438 12.2815 8.22868 12.2258 7.99956 12.2258C7.77044 12.2258 7.54475 12.2815 7.3419 12.388L4.2639 14.0067C4.20545 14.0376 4.1395 14.0515 4.07353 14.0468C4.00757 14.0421 3.94424 14.019 3.89076 13.9801C3.83728 13.9413 3.79579 13.8881 3.771 13.8268C3.74622 13.7655 3.73914 13.6985 3.75056 13.6333L4.3379 10.2073C4.3767 9.98116 4.35989 9.74893 4.28892 9.5307C4.21796 9.31246 4.09497 9.11477 3.93056 8.95467L1.4399 6.53C1.39229 6.48402 1.35856 6.4256 1.34254 6.36138C1.32652 6.29717 1.32886 6.22975 1.34928 6.16679C1.36971 6.10384 1.40741 6.04789 1.45808 6.00532C1.50876 5.96275 1.57037 5.93527 1.6359 5.926L5.07923 5.42267C5.30607 5.38967 5.52149 5.30204 5.70695 5.16733C5.89242 5.03261 6.04237 4.85485 6.1439 4.64933L7.68323 1.53Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1 +1,3 @@
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.22303 0.665992C7.32551 0.419604 7.67454 0.419604 7.77702 0.665992L9.41343 4.60039C9.45663 4.70426 9.55432 4.77523 9.66645 4.78422L13.914 5.12475C14.18 5.14607 14.2878 5.47802 14.0852 5.65162L10.849 8.42374C10.7636 8.49692 10.7263 8.61176 10.7524 8.72118L11.7411 12.866C11.803 13.1256 11.5206 13.3308 11.2929 13.1917L7.6564 10.9705C7.5604 10.9119 7.43965 10.9119 7.34365 10.9705L3.70718 13.1917C3.47945 13.3308 3.19708 13.1256 3.25899 12.866L4.24769 8.72118C4.2738 8.61176 4.23648 8.49692 4.15105 8.42374L0.914889 5.65162C0.712228 5.47802 0.820086 5.14607 1.08608 5.12475L5.3336 4.78422C5.44573 4.77523 5.54342 4.70426 5.58662 4.60039L7.22303 0.665992Z" fill="currentColor"></path></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.68323 1.53C7.71245 1.47097 7.75758 1.42129 7.81353 1.38655C7.86949 1.35181 7.93404 1.3334 7.9999 1.3334C8.06576 1.3334 8.13031 1.35181 8.18626 1.38655C8.24222 1.42129 8.28735 1.47097 8.31656 1.53L9.85656 4.64933C9.95802 4.85465 10.1078 5.03227 10.293 5.16697C10.4782 5.30167 10.6933 5.38941 10.9199 5.42267L14.3639 5.92667C14.4292 5.93612 14.4905 5.96365 14.5409 6.00613C14.5913 6.04862 14.6289 6.10437 14.6492 6.16707C14.6696 6.22978 14.6721 6.29694 14.6563 6.36096C14.6405 6.42498 14.6071 6.4833 14.5599 6.52933L12.0692 8.95467C11.905 9.11473 11.7821 9.31232 11.7111 9.53042C11.6402 9.74852 11.6233 9.98059 11.6619 10.2067L12.2499 13.6333C12.2614 13.6986 12.2544 13.7657 12.2296 13.8271C12.2048 13.8885 12.1632 13.9417 12.1096 13.9807C12.056 14.0196 11.9926 14.0427 11.9265 14.0473C11.8604 14.0519 11.7944 14.0378 11.7359 14.0067L8.65723 12.388C8.45438 12.2815 8.22868 12.2258 7.99956 12.2258C7.77044 12.2258 7.54475 12.2815 7.3419 12.388L4.2639 14.0067C4.20545 14.0376 4.1395 14.0515 4.07353 14.0468C4.00757 14.0421 3.94424 14.019 3.89076 13.9801C3.83728 13.9413 3.79579 13.8881 3.771 13.8268C3.74622 13.7655 3.73914 13.6985 3.75056 13.6333L4.3379 10.2073C4.3767 9.98116 4.35989 9.74893 4.28892 9.5307C4.21796 9.31246 4.09497 9.11477 3.93056 8.95467L1.4399 6.53C1.39229 6.48402 1.35856 6.4256 1.34254 6.36138C1.32652 6.29717 1.32886 6.22975 1.34928 6.16679C1.36971 6.10384 1.40741 6.04789 1.45808 6.00532C1.50876 5.96275 1.57037 5.93527 1.6359 5.926L5.07923 5.42267C5.30607 5.38967 5.52149 5.30204 5.70695 5.16733C5.89242 5.03261 6.04237 4.85485 6.1439 4.64933L7.68323 1.53Z" fill="black" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 794 B After Width: | Height: | Size: 1.7 KiB |
@@ -31,8 +31,6 @@
|
||||
"ctrl-,": "zed::OpenSettings",
|
||||
"ctrl-q": "zed::Quit",
|
||||
"f4": "debugger::Start",
|
||||
"alt-f4": "debugger::RerunLastSession",
|
||||
"f5": "debugger::Continue",
|
||||
"shift-f5": "debugger::Stop",
|
||||
"ctrl-shift-f5": "debugger::Restart",
|
||||
"f6": "debugger::Pause",
|
||||
@@ -280,7 +278,9 @@
|
||||
"enter": "agent::Chat",
|
||||
"ctrl-enter": "agent::ChatWithFollow",
|
||||
"ctrl-i": "agent::ToggleProfileSelector",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff"
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -583,11 +583,24 @@
|
||||
"ctrl-alt-r": "task::Rerun",
|
||||
"alt-t": "task::Rerun",
|
||||
"alt-shift-t": "task::Spawn",
|
||||
"alt-shift-r": ["task::Spawn", { "reveal_target": "center" }]
|
||||
"alt-shift-r": ["task::Spawn", { "reveal_target": "center" }],
|
||||
// also possible to spawn tasks by name:
|
||||
// "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }]
|
||||
// or by tag:
|
||||
// "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
|
||||
"f5": "debugger::RerunLastSession"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace && debugger_running",
|
||||
"bindings": {
|
||||
"f5": "zed::NoAction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace && debugger_stopped",
|
||||
"bindings": {
|
||||
"f5": "debugger::Continue"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -873,7 +886,8 @@
|
||||
"context": "DebugPanel",
|
||||
"bindings": {
|
||||
"ctrl-t": "debugger::ToggleThreadPicker",
|
||||
"ctrl-i": "debugger::ToggleSessionPicker"
|
||||
"ctrl-i": "debugger::ToggleSessionPicker",
|
||||
"shift-alt-escape": "debugger::ToggleExpandItem"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -928,6 +942,13 @@
|
||||
"tab": "channel_modal::ToggleMode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "FileFinder",
|
||||
"bindings": {
|
||||
"ctrl-shift-a": "file_finder::ToggleSplitMenu",
|
||||
"ctrl-shift-i": "file_finder::ToggleFilterMenu"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)",
|
||||
"bindings": {
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"f4": "debugger::Start",
|
||||
"alt-f4": "debugger::RerunLastSession",
|
||||
"f5": "debugger::Continue",
|
||||
"shift-f5": "debugger::Stop",
|
||||
"shift-cmd-f5": "debugger::Restart",
|
||||
"f6": "debugger::Pause",
|
||||
@@ -317,7 +315,9 @@
|
||||
"enter": "agent::Chat",
|
||||
"cmd-enter": "agent::ChatWithFollow",
|
||||
"cmd-i": "agent::ToggleProfileSelector",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff"
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -635,7 +635,8 @@
|
||||
"cmd-k shift-right": "workspace::SwapPaneRight",
|
||||
"cmd-k shift-up": "workspace::SwapPaneUp",
|
||||
"cmd-k shift-down": "workspace::SwapPaneDown",
|
||||
"cmd-shift-x": "zed::Extensions"
|
||||
"cmd-shift-x": "zed::Extensions",
|
||||
"f5": "debugger::RerunLastSession"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -652,6 +653,20 @@
|
||||
// "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace && debugger_running",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"f5": "zed::NoAction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace && debugger_stopped",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"f5": "debugger::Continue"
|
||||
}
|
||||
},
|
||||
// Bindings from Sublime Text
|
||||
{
|
||||
"context": "Editor",
|
||||
@@ -936,7 +951,8 @@
|
||||
"context": "DebugPanel",
|
||||
"bindings": {
|
||||
"cmd-t": "debugger::ToggleThreadPicker",
|
||||
"cmd-i": "debugger::ToggleSessionPicker"
|
||||
"cmd-i": "debugger::ToggleSessionPicker",
|
||||
"shift-alt-escape": "debugger::ToggleExpandItem"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -987,6 +1003,14 @@
|
||||
"tab": "channel_modal::ToggleMode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "FileFinder",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-shift-a": "file_finder::ToggleSplitMenu",
|
||||
"cmd-shift-i": "file_finder::ToggleFilterMenu"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)",
|
||||
"use_key_equivalents": true,
|
||||
|
||||
@@ -52,10 +52,10 @@
|
||||
"shift-alt-m": "markdown::OpenPreviewToTheSide",
|
||||
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-delete": "editor::DeleteToNextWordEnd",
|
||||
"ctrl-right": "editor::MoveToNextSubwordEnd",
|
||||
"ctrl-left": "editor::MoveToPreviousSubwordStart",
|
||||
"ctrl-shift-right": "editor::SelectToNextSubwordEnd",
|
||||
"ctrl-shift-left": "editor::SelectToPreviousSubwordStart"
|
||||
"alt-right": "editor::MoveToNextSubwordEnd",
|
||||
"alt-left": "editor::MoveToPreviousSubwordStart",
|
||||
"alt-shift-right": "editor::SelectToNextSubwordEnd",
|
||||
"alt-shift-left": "editor::SelectToPreviousSubwordStart"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"9": ["vim::Number", 9],
|
||||
"ctrl-w 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 g shift-d": "editor::GoToTypeDefinitionSplit",
|
||||
"ctrl-w space": "editor::OpenExcerptsSplit",
|
||||
@@ -838,6 +840,19 @@
|
||||
"tab": "editor::AcceptEditPrediction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "MessageEditor > Editor && VimControl",
|
||||
"bindings": {
|
||||
"enter": "agent::Chat",
|
||||
// TODO: Implement search
|
||||
"/": null,
|
||||
"?": null,
|
||||
"#": null,
|
||||
"*": null,
|
||||
"n": null,
|
||||
"shift-n": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "os != macos && Editor && edit_prediction_conflict",
|
||||
"bindings": {
|
||||
|
||||
@@ -73,9 +73,6 @@
|
||||
"unnecessary_code_fade": 0.3,
|
||||
// Active pane styling settings.
|
||||
"active_pane_modifiers": {
|
||||
// The factor to grow the active pane by. Defaults to 1.0
|
||||
// which gives the same size as all other panes.
|
||||
"magnification": 1.0,
|
||||
// Inset border size of the active pane, in pixels.
|
||||
"border_size": 0.0,
|
||||
// Opacity of the inactive panes. 0 means transparent, 1 means opaque.
|
||||
@@ -128,6 +125,8 @@
|
||||
//
|
||||
// Default: true
|
||||
"restore_on_file_reopen": true,
|
||||
// Whether to automatically close files that have been deleted on disk.
|
||||
"close_on_file_delete": false,
|
||||
// Size of the drop target in the editor.
|
||||
"drop_target_size": 0.2,
|
||||
// Whether the window should be closed when using 'close active item' on a window with no tabs.
|
||||
@@ -731,13 +730,6 @@
|
||||
// The model to use.
|
||||
"model": "claude-sonnet-4"
|
||||
},
|
||||
// The model to use when applying edits from the agent.
|
||||
"editor_model": {
|
||||
// The provider to use.
|
||||
"provider": "zed.dev",
|
||||
// The model to use.
|
||||
"model": "claude-sonnet-4"
|
||||
},
|
||||
// Additional parameters for language model requests. When making a request to a model, parameters will be taken
|
||||
// from the last entry in this list that matches the model's provider and name. In each entry, both provider
|
||||
// and model are optional, so that you can specify parameters for either one.
|
||||
@@ -1505,11 +1497,11 @@
|
||||
}
|
||||
},
|
||||
"LaTeX": {
|
||||
"format_on_save": "on",
|
||||
"formatter": "language_server",
|
||||
"language_servers": ["texlab", "..."],
|
||||
"prettier": {
|
||||
"allowed": false
|
||||
"allowed": true,
|
||||
"plugins": ["prettier-plugin-latex"]
|
||||
}
|
||||
},
|
||||
"Markdown": {
|
||||
@@ -1533,7 +1525,7 @@
|
||||
"allow_rewrap": "anywhere"
|
||||
},
|
||||
"Ruby": {
|
||||
"language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "..."]
|
||||
"language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."]
|
||||
},
|
||||
"SCSS": {
|
||||
"prettier": {
|
||||
@@ -1610,6 +1602,9 @@
|
||||
"version": "1",
|
||||
"api_url": "https://api.openai.com/v1"
|
||||
},
|
||||
"open_router": {
|
||||
"api_url": "https://openrouter.ai/api/v1"
|
||||
},
|
||||
"lmstudio": {
|
||||
"api_url": "http://localhost:1234/api/v0"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Some example tasks for common languages.
|
||||
//
|
||||
// For more documentation on how to configure debug tasks,
|
||||
// see: https://zed.dev/docs/debugger
|
||||
[
|
||||
{
|
||||
"label": "Debug active PHP file",
|
||||
|
||||
5
assets/settings/initial_local_debug_tasks.json
Normal file
5
assets/settings/initial_local_debug_tasks.json
Normal file
@@ -0,0 +1,5 @@
|
||||
// Project-local debug tasks
|
||||
//
|
||||
// For more documentation on how to configure debug tasks,
|
||||
// see: https://zed.dev/docs/debugger
|
||||
[]
|
||||
@@ -46,6 +46,7 @@ git.workspace = true
|
||||
gpui.workspace = true
|
||||
heed.workspace = true
|
||||
html_to_markdown.workspace = true
|
||||
indoc.workspace = true
|
||||
http_client.workspace = true
|
||||
indexed_docs.workspace = true
|
||||
inventory.workspace = true
|
||||
@@ -78,6 +79,7 @@ serde_json.workspace = true
|
||||
serde_json_lenient.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
sqlez.workspace = true
|
||||
streaming_diff.workspace = true
|
||||
telemetry.workspace = true
|
||||
telemetry_events.workspace = true
|
||||
@@ -97,6 +99,7 @@ workspace-hack.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
zed_llm_client.workspace = true
|
||||
zstd.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
buffer_diff = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::context::{AgentContextHandle, RULES_ICON};
|
||||
use crate::context_picker::{ContextPicker, MentionLink};
|
||||
use crate::context_store::ContextStore;
|
||||
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::{
|
||||
LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
|
||||
ThreadEvent, ThreadFeedback, ThreadSummary,
|
||||
@@ -999,7 +999,7 @@ impl ActiveThread {
|
||||
ThreadEvent::Stopped(reason) => match reason {
|
||||
Ok(StopReason::EndTurn | StopReason::MaxTokens) => {
|
||||
let used_tools = self.thread.read(cx).used_tools_since_last_user_message();
|
||||
self.play_notification_sound(cx);
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification(
|
||||
if used_tools {
|
||||
"Finished running tools"
|
||||
@@ -1014,9 +1014,18 @@ impl ActiveThread {
|
||||
_ => {}
|
||||
},
|
||||
ThreadEvent::ToolConfirmationNeeded => {
|
||||
self.play_notification_sound(cx);
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification("Waiting for tool confirmation", IconName::Info, window, cx);
|
||||
}
|
||||
ThreadEvent::ToolUseLimitReached => {
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification(
|
||||
"Consecutive tool use limit reached.",
|
||||
IconName::Warning,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
ThreadEvent::StreamedAssistantText(message_id, text) => {
|
||||
if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
|
||||
rendered_message.append_text(text, cx);
|
||||
@@ -1151,9 +1160,9 @@ impl ActiveThread {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn play_notification_sound(&self, cx: &mut App) {
|
||||
fn play_notification_sound(&self, window: &Window, cx: &mut App) {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
if settings.play_sound_when_agent_done {
|
||||
if settings.play_sound_when_agent_done && !window.is_window_active() {
|
||||
Audio::play_sound(Sound::AgentDone, cx);
|
||||
}
|
||||
}
|
||||
@@ -1577,6 +1586,8 @@ impl ActiveThread {
|
||||
|
||||
let edited_text = state.editor.read(cx).text(cx);
|
||||
|
||||
let creases = state.editor.update(cx, extract_message_creases);
|
||||
|
||||
let new_context = self
|
||||
.context_store
|
||||
.read(cx)
|
||||
@@ -1601,6 +1612,7 @@ impl ActiveThread {
|
||||
message_id,
|
||||
Role::User,
|
||||
vec![MessageSegment::Text(edited_text)],
|
||||
creases,
|
||||
Some(context.loaded_context),
|
||||
checkpoint.ok(),
|
||||
cx,
|
||||
@@ -3668,10 +3680,13 @@ fn open_editor_at_position(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use assistant_tool::{ToolRegistry, ToolWorkingSet};
|
||||
use editor::EditorSettings;
|
||||
use editor::{EditorSettings, display_map::CreaseMetadata};
|
||||
use fs::FakeFs;
|
||||
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 prompt_store::PromptBuilder;
|
||||
use serde_json::json;
|
||||
@@ -3732,6 +3747,87 @@ mod tests {
|
||||
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) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
|
||||
@@ -33,9 +33,11 @@ use assistant_slash_command::SlashCommandRegistry;
|
||||
use client::Client;
|
||||
use feature_flags::FeatureFlagAppExt as _;
|
||||
use fs::Fs;
|
||||
use gpui::{App, actions, impl_actions};
|
||||
use gpui::{App, Entity, actions, impl_actions};
|
||||
use language::LanguageRegistry;
|
||||
use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
|
||||
};
|
||||
use prompt_store::PromptBuilder;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
@@ -115,6 +117,23 @@ impl 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the `agent` crate.
|
||||
pub fn init(
|
||||
fs: Arc<dyn Fs>,
|
||||
|
||||
@@ -1086,7 +1086,7 @@ impl Render for AgentDiffToolbar {
|
||||
.child(vertical_divider())
|
||||
.when_some(editor.read(cx).workspace(), |this, _workspace| {
|
||||
this.child(
|
||||
IconButton::new("review", IconName::ListCollapse)
|
||||
IconButton::new("review", IconName::ListTodo)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Review All Files",
|
||||
@@ -1116,8 +1116,13 @@ impl Render for AgentDiffToolbar {
|
||||
return Empty.into_any();
|
||||
};
|
||||
|
||||
let is_generating = agent_diff.read(cx).thread.read(cx).is_generating();
|
||||
if is_generating {
|
||||
let has_pending_edit_tool_use = agent_diff
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -1372,6 +1377,7 @@ impl AgentDiff {
|
||||
| ThreadEvent::ToolFinished { .. }
|
||||
| ThreadEvent::CheckpointChanged
|
||||
| ThreadEvent::ToolConfirmationNeeded
|
||||
| ThreadEvent::ToolUseLimitReached
|
||||
| ThreadEvent::CancelEditing => {}
|
||||
}
|
||||
}
|
||||
@@ -1464,7 +1470,10 @@ impl AgentDiff {
|
||||
if !AgentSettings::get_global(cx).single_file_review {
|
||||
for (editor, _) in self.reviewing_editors.drain() {
|
||||
editor
|
||||
.update(cx, |editor, cx| editor.end_temporary_diff_override(cx))
|
||||
.update(cx, |editor, cx| {
|
||||
editor.end_temporary_diff_override(cx);
|
||||
editor.unregister_addon::<EditorAgentDiffAddon>();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
return;
|
||||
@@ -1503,7 +1512,7 @@ impl AgentDiff {
|
||||
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
|
||||
} else {
|
||||
EditorState::Reviewing
|
||||
@@ -1560,7 +1569,10 @@ impl AgentDiff {
|
||||
|
||||
if in_workspace {
|
||||
editor
|
||||
.update(cx, |editor, cx| editor.end_temporary_diff_override(cx))
|
||||
.update(cx, |editor, cx| {
|
||||
editor.end_temporary_diff_override(cx);
|
||||
editor.unregister_addon::<EditorAgentDiffAddon>();
|
||||
})
|
||||
.ok();
|
||||
self.reviewing_editors.remove(&editor);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use fs::Fs;
|
||||
use gpui::{Entity, FocusHandle, SharedString};
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
|
||||
use crate::Thread;
|
||||
use crate::ModelUsageContext;
|
||||
use assistant_context_editor::language_model_selector::{
|
||||
LanguageModelSelector, ToggleModelSelector, language_model_selector,
|
||||
};
|
||||
@@ -12,12 +12,6 @@ use settings::update_settings_file;
|
||||
use std::sync::Arc;
|
||||
use ui::{PopoverMenuHandle, Tooltip, prelude::*};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ModelType {
|
||||
Default(Entity<Thread>),
|
||||
InlineAssistant,
|
||||
}
|
||||
|
||||
pub struct AgentModelSelector {
|
||||
selector: Entity<LanguageModelSelector>,
|
||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
@@ -29,7 +23,7 @@ impl AgentModelSelector {
|
||||
fs: Arc<dyn Fs>,
|
||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
focus_handle: FocusHandle,
|
||||
model_type: ModelType,
|
||||
model_usage_context: ModelUsageContext,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -38,19 +32,14 @@ impl AgentModelSelector {
|
||||
let fs = fs.clone();
|
||||
language_model_selector(
|
||||
{
|
||||
let model_type = model_type.clone();
|
||||
move |cx| match &model_type {
|
||||
ModelType::Default(thread) => thread.read(cx).configured_model(),
|
||||
ModelType::InlineAssistant => {
|
||||
LanguageModelRegistry::read_global(cx).inline_assistant_model()
|
||||
}
|
||||
}
|
||||
let model_context = model_usage_context.clone();
|
||||
move |cx| model_context.configured_model(cx)
|
||||
},
|
||||
move |model, cx| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model_id = model.id().0.to_string();
|
||||
match &model_type {
|
||||
ModelType::Default(thread) => {
|
||||
match &model_usage_context {
|
||||
ModelUsageContext::Thread(thread) => {
|
||||
thread.update(cx, |thread, cx| {
|
||||
let registry = LanguageModelRegistry::read_global(cx);
|
||||
if let Some(provider) = registry.provider(&model.provider_id())
|
||||
@@ -72,7 +61,7 @@ impl AgentModelSelector {
|
||||
},
|
||||
);
|
||||
}
|
||||
ModelType::InlineAssistant => {
|
||||
ModelUsageContext::InlineAssistant => {
|
||||
update_settings_file::<AgentSettings>(
|
||||
fs.clone(),
|
||||
cx,
|
||||
|
||||
@@ -734,6 +734,7 @@ impl Display for RulesContext {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageContext {
|
||||
pub project_path: Option<ProjectPath>,
|
||||
pub full_path: Option<Arc<Path>>,
|
||||
pub original_image: Arc<gpui::Image>,
|
||||
// TODO: handle this elsewhere and remove `ignore-interior-mutability` opt-out in clippy.toml
|
||||
// needed due to a false positive of `clippy::mutable_key_type`.
|
||||
|
||||
@@ -14,7 +14,7 @@ use http_client::HttpClientWithUrl;
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, CodeLabel, HighlightId};
|
||||
use lsp::CompletionContext;
|
||||
use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId};
|
||||
use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId};
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
use text::{Anchor, OffsetRangeExt, ToPoint};
|
||||
@@ -746,7 +746,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
_trigger: CompletionContext,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Option<Vec<Completion>>>> {
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
let state = buffer.update(cx, |buffer, _cx| {
|
||||
let position = buffer_position.to_point(buffer);
|
||||
let line_start = Point::new(position.row, 0);
|
||||
@@ -756,13 +756,13 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
MentionCompletion::try_parse(line, offset_to_line)
|
||||
});
|
||||
let Some(state) = state else {
|
||||
return Task::ready(Ok(None));
|
||||
return Task::ready(Ok(Vec::new()));
|
||||
};
|
||||
|
||||
let Some((workspace, context_store)) =
|
||||
self.workspace.upgrade().zip(self.context_store.upgrade())
|
||||
else {
|
||||
return Task::ready(Ok(None));
|
||||
return Task::ready(Ok(Vec::new()));
|
||||
};
|
||||
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
@@ -815,10 +815,10 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
cx.spawn(async move |_, cx| {
|
||||
let matches = search_task.await;
|
||||
let Some(editor) = editor.upgrade() else {
|
||||
return Ok(None);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
Ok(Some(cx.update(|cx| {
|
||||
let completions = cx.update(|cx| {
|
||||
matches
|
||||
.into_iter()
|
||||
.filter_map(|mat| match mat {
|
||||
@@ -901,7 +901,14 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
})?))
|
||||
})?;
|
||||
|
||||
Ok(vec![CompletionResponse {
|
||||
completions,
|
||||
// Since this does its own filtering (see `filter_completions()` returns false),
|
||||
// there is no benefit to computing whether this set of completions is incomplete.
|
||||
is_incomplete: true,
|
||||
}])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -919,8 +926,9 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
&self,
|
||||
buffer: &Entity<language::Buffer>,
|
||||
position: language::Anchor,
|
||||
_: &str,
|
||||
_: bool,
|
||||
_text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
let buffer = buffer.read(cx);
|
||||
|
||||
@@ -51,6 +51,10 @@ impl Tool for ContextServerTool {
|
||||
true
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
||||
let mut schema = self.tool.input_schema.clone();
|
||||
assistant_tool::adapt_schema_to_format(&mut schema, format)?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use assistant_context_editor::AssistantContext;
|
||||
use collections::{HashSet, IndexSet};
|
||||
use futures::{self, FutureExt};
|
||||
use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity};
|
||||
use language::Buffer;
|
||||
use language::{Buffer, File as _};
|
||||
use language_model::LanguageModelImage;
|
||||
use project::image_store::is_image_file;
|
||||
use project::{Project, ProjectItem, ProjectPath, Symbol};
|
||||
@@ -304,11 +304,13 @@ impl ContextStore {
|
||||
project.open_image(project_path.clone(), cx)
|
||||
})?;
|
||||
let image_item = open_image_task.await?;
|
||||
let image = image_item.read_with(cx, |image_item, _| image_item.image.clone())?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let item = image_item.read(cx);
|
||||
this.insert_image(
|
||||
Some(image_item.read(cx).project_path(cx)),
|
||||
image,
|
||||
Some(item.project_path(cx)),
|
||||
Some(item.file.full_path(cx).into()),
|
||||
item.image.clone(),
|
||||
remove_if_exists,
|
||||
cx,
|
||||
)
|
||||
@@ -317,12 +319,13 @@ impl ContextStore {
|
||||
}
|
||||
|
||||
pub fn add_image_instance(&mut self, image: Arc<Image>, cx: &mut Context<ContextStore>) {
|
||||
self.insert_image(None, image, false, cx);
|
||||
self.insert_image(None, None, image, false, cx);
|
||||
}
|
||||
|
||||
fn insert_image(
|
||||
&mut self,
|
||||
project_path: Option<ProjectPath>,
|
||||
full_path: Option<Arc<Path>>,
|
||||
image: Arc<Image>,
|
||||
remove_if_exists: bool,
|
||||
cx: &mut Context<ContextStore>,
|
||||
@@ -330,6 +333,7 @@ impl ContextStore {
|
||||
let image_task = LanguageModelImage::from_image(image.clone(), cx).shared();
|
||||
let context = AgentContextHandle::Image(ImageContext {
|
||||
project_path,
|
||||
full_path,
|
||||
original_image: image,
|
||||
image_task,
|
||||
context_id: self.next_context_id.post_inc(),
|
||||
|
||||
@@ -152,7 +152,7 @@ impl HistoryStore {
|
||||
let entries = join_all(entries)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|result| result.log_err())
|
||||
.filter_map(|result| result.log_with_level(log::Level::Debug))
|
||||
.collect::<VecDeque<_>>();
|
||||
|
||||
this.update(cx, |this, _| {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::agent_model_selector::{AgentModelSelector, ModelType};
|
||||
use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::buffer_codegen::BufferCodegen;
|
||||
use crate::context::ContextCreasesAddon;
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
||||
@@ -7,7 +7,7 @@ use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::message_editor::{extract_message_creases, insert_message_creases};
|
||||
use crate::terminal_codegen::TerminalCodegen;
|
||||
use crate::thread_store::{TextThreadStore, ThreadStore};
|
||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist};
|
||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
|
||||
use crate::{RemoveAllContext, ToggleContextPicker};
|
||||
use assistant_context_editor::language_model_selector::ToggleModelSelector;
|
||||
use client::ErrorExt;
|
||||
@@ -930,7 +930,7 @@ impl PromptEditor<BufferCodegen> {
|
||||
fs,
|
||||
model_selector_menu_handle,
|
||||
prompt_editor.focus_handle(cx),
|
||||
ModelType::InlineAssistant,
|
||||
ModelUsageContext::InlineAssistant,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1101,7 +1101,7 @@ impl PromptEditor<TerminalCodegen> {
|
||||
fs,
|
||||
model_selector_menu_handle.clone(),
|
||||
prompt_editor.focus_handle(cx),
|
||||
ModelType::InlineAssistant,
|
||||
ModelUsageContext::InlineAssistant,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -2,11 +2,11 @@ use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
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::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
|
||||
use crate::ui::{
|
||||
AnimatedLabel, MaxModeTooltip,
|
||||
MaxModeTooltip,
|
||||
preview::{AgentPreview, UsageCallout},
|
||||
};
|
||||
use agent_settings::{AgentSettings, CompletionMode};
|
||||
@@ -27,7 +27,7 @@ use gpui::{
|
||||
Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription,
|
||||
Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
|
||||
};
|
||||
use language::{Buffer, Language};
|
||||
use language::{Buffer, Language, Point};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage,
|
||||
ZED_CLOUD_PROVIDER_ID,
|
||||
@@ -51,9 +51,9 @@ use crate::profile_selector::ProfileSelector;
|
||||
use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
|
||||
use crate::thread_store::{TextThreadStore, ThreadStore};
|
||||
use crate::{
|
||||
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, NewThread,
|
||||
OpenAgentDiff, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector,
|
||||
register_agent_preview,
|
||||
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
|
||||
ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode,
|
||||
ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
|
||||
};
|
||||
|
||||
#[derive(RegisterComponent)]
|
||||
@@ -112,6 +112,7 @@ pub(crate) fn create_editor(
|
||||
editor.set_placeholder_text("Message the agent – @ to include context", cx);
|
||||
editor.set_show_indent_guides(false, cx);
|
||||
editor.set_soft_wrap();
|
||||
editor.set_use_modal_editing(true);
|
||||
editor.set_context_menu_options(ContextMenuOptions {
|
||||
min_entries_visible: 12,
|
||||
max_entries_visible: 12,
|
||||
@@ -196,7 +197,7 @@ impl MessageEditor {
|
||||
fs.clone(),
|
||||
model_selector_menu_handle,
|
||||
editor.focus_handle(cx),
|
||||
ModelType::Default(thread.clone()),
|
||||
ModelUsageContext::Thread(thread.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -458,11 +459,20 @@ impl MessageEditor {
|
||||
}
|
||||
|
||||
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;
|
||||
AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
|
||||
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(
|
||||
&self,
|
||||
buffer: Entity<Buffer>,
|
||||
@@ -493,6 +503,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> {
|
||||
let thread = self.thread.read(cx);
|
||||
let model = thread.configured_model();
|
||||
@@ -614,6 +658,12 @@ impl MessageEditor {
|
||||
.on_action(cx.listener(Self::move_up))
|
||||
.on_action(cx.listener(Self::expand_message_editor))
|
||||
.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))
|
||||
.gap_2()
|
||||
.p_2()
|
||||
@@ -869,7 +919,10 @@ impl MessageEditor {
|
||||
let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
|
||||
|
||||
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()
|
||||
.mt_1()
|
||||
@@ -887,31 +940,28 @@ impl MessageEditor {
|
||||
}])
|
||||
.child(
|
||||
h_flex()
|
||||
.id("edits-container")
|
||||
.cursor_pointer()
|
||||
.p_1p5()
|
||||
.p_1()
|
||||
.justify_between()
|
||||
.when(is_edit_changes_expanded, |this| {
|
||||
this.border_b_1().border_color(border_color)
|
||||
})
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.id("edits-container")
|
||||
.cursor_pointer()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.child(
|
||||
Disclosure::new("edits-disclosure", is_edit_changes_expanded)
|
||||
.on_click(cx.listener(|this, _ev, _window, cx| {
|
||||
this.edits_expanded = !this.edits_expanded;
|
||||
cx.notify();
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.handle_edit_bar_expand(cx)
|
||||
})),
|
||||
)
|
||||
.map(|this| {
|
||||
if is_generating {
|
||||
if pending_edits {
|
||||
this.child(
|
||||
AnimatedLabel::new(format!(
|
||||
"Editing {} {}",
|
||||
Label::new(format!(
|
||||
"Editing {} {}…",
|
||||
changed_buffers.len(),
|
||||
if changed_buffers.len() == 1 {
|
||||
"file"
|
||||
@@ -919,7 +969,15 @@ impl MessageEditor {
|
||||
"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 {
|
||||
this.child(
|
||||
@@ -944,23 +1002,74 @@ impl MessageEditor {
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}
|
||||
}),
|
||||
})
|
||||
.on_click(
|
||||
cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("review", "Review Changes")
|
||||
.label_size(LabelSize::Small)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&OpenAgentDiff,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new("review-changes", IconName::ListTodo)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"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| {
|
||||
this.handle_review_click(window, cx)
|
||||
})),
|
||||
.child(ui::Divider::vertical().color(ui::DividerColor::Border))
|
||||
.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| {
|
||||
|
||||
@@ -179,18 +179,17 @@ impl TerminalTransaction {
|
||||
// Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal
|
||||
let input = Self::sanitize_input(hunk);
|
||||
self.terminal
|
||||
.update(cx, |terminal, _| terminal.input(input));
|
||||
.update(cx, |terminal, _| terminal.input(input.into_bytes()));
|
||||
}
|
||||
|
||||
pub fn undo(&self, cx: &mut App) {
|
||||
self.terminal
|
||||
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
|
||||
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
|
||||
}
|
||||
|
||||
pub fn complete(&self, cx: &mut App) {
|
||||
self.terminal.update(cx, |terminal, _| {
|
||||
terminal.input(CARRIAGE_RETURN.to_string())
|
||||
});
|
||||
self.terminal
|
||||
.update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes()));
|
||||
}
|
||||
|
||||
fn sanitize_input(mut input: String) -> String {
|
||||
|
||||
@@ -106,7 +106,7 @@ impl TerminalInlineAssistant {
|
||||
});
|
||||
let prompt_editor_render = prompt_editor.clone();
|
||||
let block = terminal_view::BlockProperties {
|
||||
height: 2,
|
||||
height: 4,
|
||||
render: Box::new(move |_| prompt_editor_render.clone().into_any_element()),
|
||||
};
|
||||
terminal_view.update(cx, |terminal_view, cx| {
|
||||
@@ -202,7 +202,7 @@ impl TerminalInlineAssistant {
|
||||
.update(cx, |terminal, cx| {
|
||||
terminal
|
||||
.terminal()
|
||||
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
|
||||
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
|
||||
})
|
||||
.log_err();
|
||||
|
||||
|
||||
@@ -871,7 +871,16 @@ impl Thread {
|
||||
self.tool_use
|
||||
.pending_tool_uses()
|
||||
.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> {
|
||||
@@ -1023,6 +1032,7 @@ impl Thread {
|
||||
id: MessageId,
|
||||
new_role: Role,
|
||||
new_segments: Vec<MessageSegment>,
|
||||
creases: Vec<MessageCrease>,
|
||||
loaded_context: Option<LoadedContext>,
|
||||
checkpoint: Option<GitStoreCheckpoint>,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -1032,6 +1042,7 @@ impl Thread {
|
||||
};
|
||||
message.role = new_role;
|
||||
message.segments = new_segments;
|
||||
message.creases = creases;
|
||||
if let Some(context) = loaded_context {
|
||||
message.loaded_context = context;
|
||||
}
|
||||
@@ -1673,6 +1684,7 @@ impl Thread {
|
||||
}
|
||||
CompletionRequestStatus::ToolUseLimitReached => {
|
||||
thread.tool_use_limit_reached = true;
|
||||
cx.emit(ThreadEvent::ToolUseLimitReached);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2843,6 +2855,7 @@ pub enum ThreadEvent {
|
||||
},
|
||||
CheckpointChanged,
|
||||
ToolConfirmationNeeded,
|
||||
ToolUseLimitReached,
|
||||
CancelEditing,
|
||||
CompletionCanceled,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cell::{Ref, RefCell};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agent_settings::{AgentProfile, AgentProfileId, AgentSettings, CompletionMode};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
@@ -17,8 +16,7 @@ use gpui::{
|
||||
App, BackgroundExecutor, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString,
|
||||
Subscription, Task, prelude::*,
|
||||
};
|
||||
use heed::Database;
|
||||
use heed::types::SerdeBincode;
|
||||
|
||||
use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage};
|
||||
use project::context_server_store::{ContextServerStatus, ContextServerStore};
|
||||
use project::{Project, ProjectItem, ProjectPath, Worktree};
|
||||
@@ -35,14 +33,52 @@ use crate::context_server_tool::ContextServerTool;
|
||||
use crate::thread::{
|
||||
DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId,
|
||||
};
|
||||
use indoc::indoc;
|
||||
use sqlez::{
|
||||
bindable::{Bind, Column},
|
||||
connection::Connection,
|
||||
statement::Statement,
|
||||
};
|
||||
|
||||
const RULES_FILE_NAMES: [&'static str; 6] = [
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataType {
|
||||
#[serde(rename = "json")]
|
||||
Json,
|
||||
#[serde(rename = "zstd")]
|
||||
Zstd,
|
||||
}
|
||||
|
||||
impl Bind for DataType {
|
||||
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
|
||||
let value = match self {
|
||||
DataType::Json => "json",
|
||||
DataType::Zstd => "zstd",
|
||||
};
|
||||
value.bind(statement, start_index)
|
||||
}
|
||||
}
|
||||
|
||||
impl Column for DataType {
|
||||
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
|
||||
let (value, next_index) = String::column(statement, start_index)?;
|
||||
let data_type = match value.as_str() {
|
||||
"json" => DataType::Json,
|
||||
"zstd" => DataType::Zstd,
|
||||
_ => anyhow::bail!("Unknown data type: {}", value),
|
||||
};
|
||||
Ok((data_type, next_index))
|
||||
}
|
||||
}
|
||||
|
||||
const RULES_FILE_NAMES: [&'static str; 8] = [
|
||||
".rules",
|
||||
".cursorrules",
|
||||
".windsurfrules",
|
||||
".clinerules",
|
||||
".github/copilot-instructions.md",
|
||||
"CLAUDE.md",
|
||||
"AGENT.md",
|
||||
"AGENTS.md",
|
||||
];
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
@@ -866,25 +902,27 @@ impl Global for GlobalThreadsDatabase {}
|
||||
|
||||
pub(crate) struct ThreadsDatabase {
|
||||
executor: BackgroundExecutor,
|
||||
env: heed::Env,
|
||||
threads: Database<SerdeBincode<ThreadId>, SerializedThread>,
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl heed::BytesEncode<'_> for SerializedThread {
|
||||
type EItem = SerializedThread;
|
||||
impl ThreadsDatabase {
|
||||
fn connection(&self) -> Arc<Mutex<Connection>> {
|
||||
self.connection.clone()
|
||||
}
|
||||
|
||||
fn bytes_encode(item: &Self::EItem) -> Result<Cow<[u8]>, heed::BoxedError> {
|
||||
serde_json::to_vec(item).map(Cow::Owned).map_err(Into::into)
|
||||
const COMPRESSION_LEVEL: i32 = 3;
|
||||
}
|
||||
|
||||
impl Bind for ThreadId {
|
||||
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
|
||||
self.to_string().bind(statement, start_index)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> heed::BytesDecode<'a> for SerializedThread {
|
||||
type DItem = SerializedThread;
|
||||
|
||||
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, heed::BoxedError> {
|
||||
// We implement this type manually because we want to call `SerializedThread::from_json`,
|
||||
// instead of the Deserialize trait implementation for `SerializedThread`.
|
||||
SerializedThread::from_json(bytes).map_err(Into::into)
|
||||
impl Column for ThreadId {
|
||||
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
|
||||
let (id_str, next_index) = String::column(statement, start_index)?;
|
||||
Ok((ThreadId::from(id_str.as_str()), next_index))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,8 +938,8 @@ impl ThreadsDatabase {
|
||||
let database_future = executor
|
||||
.spawn({
|
||||
let executor = executor.clone();
|
||||
let database_path = paths::data_dir().join("threads/threads-db.1.mdb");
|
||||
async move { ThreadsDatabase::new(database_path, executor) }
|
||||
let threads_dir = paths::data_dir().join("threads");
|
||||
async move { ThreadsDatabase::new(threads_dir, executor) }
|
||||
})
|
||||
.then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
|
||||
.boxed()
|
||||
@@ -910,41 +948,144 @@ impl ThreadsDatabase {
|
||||
cx.set_global(GlobalThreadsDatabase(database_future));
|
||||
}
|
||||
|
||||
pub fn new(path: PathBuf, executor: BackgroundExecutor) -> Result<Self> {
|
||||
std::fs::create_dir_all(&path)?;
|
||||
pub fn new(threads_dir: PathBuf, executor: BackgroundExecutor) -> Result<Self> {
|
||||
std::fs::create_dir_all(&threads_dir)?;
|
||||
|
||||
let sqlite_path = threads_dir.join("threads.db");
|
||||
let mdb_path = threads_dir.join("threads-db.1.mdb");
|
||||
|
||||
let needs_migration_from_heed = mdb_path.exists();
|
||||
|
||||
let connection = Connection::open_file(&sqlite_path.to_string_lossy());
|
||||
|
||||
connection.exec(indoc! {"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
data_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL
|
||||
)
|
||||
"})?()
|
||||
.map_err(|e| anyhow!("Failed to create threads table: {}", e))?;
|
||||
|
||||
let db = Self {
|
||||
executor: executor.clone(),
|
||||
connection: Arc::new(Mutex::new(connection)),
|
||||
};
|
||||
|
||||
if needs_migration_from_heed {
|
||||
let db_connection = db.connection();
|
||||
let executor_clone = executor.clone();
|
||||
executor
|
||||
.spawn(async move {
|
||||
log::info!("Starting threads.db migration");
|
||||
Self::migrate_from_heed(&mdb_path, db_connection, executor_clone)?;
|
||||
std::fs::remove_dir_all(mdb_path)?;
|
||||
log::info!("threads.db migrated to sqlite");
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
// Remove this migration after 2025-09-01
|
||||
fn migrate_from_heed(
|
||||
mdb_path: &Path,
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
_executor: BackgroundExecutor,
|
||||
) -> Result<()> {
|
||||
use heed::types::SerdeBincode;
|
||||
struct SerializedThreadHeed(SerializedThread);
|
||||
|
||||
impl heed::BytesEncode<'_> for SerializedThreadHeed {
|
||||
type EItem = SerializedThreadHeed;
|
||||
|
||||
fn bytes_encode(
|
||||
item: &Self::EItem,
|
||||
) -> Result<std::borrow::Cow<[u8]>, heed::BoxedError> {
|
||||
serde_json::to_vec(&item.0)
|
||||
.map(std::borrow::Cow::Owned)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> heed::BytesDecode<'a> for SerializedThreadHeed {
|
||||
type DItem = SerializedThreadHeed;
|
||||
|
||||
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, heed::BoxedError> {
|
||||
SerializedThread::from_json(bytes)
|
||||
.map(SerializedThreadHeed)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
const ONE_GB_IN_BYTES: usize = 1024 * 1024 * 1024;
|
||||
|
||||
let env = unsafe {
|
||||
heed::EnvOpenOptions::new()
|
||||
.map_size(ONE_GB_IN_BYTES)
|
||||
.max_dbs(1)
|
||||
.open(path)?
|
||||
.open(mdb_path)?
|
||||
};
|
||||
|
||||
let mut txn = env.write_txn()?;
|
||||
let threads = env.create_database(&mut txn, Some("threads"))?;
|
||||
txn.commit()?;
|
||||
let txn = env.write_txn()?;
|
||||
let threads: heed::Database<SerdeBincode<ThreadId>, SerializedThreadHeed> = env
|
||||
.open_database(&txn, Some("threads"))?
|
||||
.ok_or_else(|| anyhow!("threads database not found"))?;
|
||||
|
||||
Ok(Self {
|
||||
executor,
|
||||
env,
|
||||
threads,
|
||||
})
|
||||
for result in threads.iter(&txn)? {
|
||||
let (thread_id, thread_heed) = result?;
|
||||
Self::save_thread_sync(&connection, thread_id, thread_heed.0)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_thread_sync(
|
||||
connection: &Arc<Mutex<Connection>>,
|
||||
id: ThreadId,
|
||||
thread: SerializedThread,
|
||||
) -> Result<()> {
|
||||
let json_data = serde_json::to_string(&thread)?;
|
||||
let summary = thread.summary.to_string();
|
||||
let updated_at = thread.updated_at.to_rfc3339();
|
||||
|
||||
let connection = connection.lock().unwrap();
|
||||
|
||||
let compressed = zstd::encode_all(json_data.as_bytes(), Self::COMPRESSION_LEVEL)?;
|
||||
let data_type = DataType::Zstd;
|
||||
let data = compressed;
|
||||
|
||||
let mut insert = connection.exec_bound::<(ThreadId, String, String, DataType, Vec<u8>)>(indoc! {"
|
||||
INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)
|
||||
"})?;
|
||||
|
||||
insert((id, summary, updated_at, data_type, data))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_threads(&self) -> Task<Result<Vec<SerializedThreadMetadata>>> {
|
||||
let env = self.env.clone();
|
||||
let threads = self.threads;
|
||||
let connection = self.connection.clone();
|
||||
|
||||
self.executor.spawn(async move {
|
||||
let txn = env.read_txn()?;
|
||||
let mut iter = threads.iter(&txn)?;
|
||||
let connection = connection.lock().unwrap();
|
||||
let mut select =
|
||||
connection.select_bound::<(), (ThreadId, String, String)>(indoc! {"
|
||||
SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC
|
||||
"})?;
|
||||
|
||||
let rows = select(())?;
|
||||
let mut threads = Vec::new();
|
||||
while let Some((key, value)) = iter.next().transpose()? {
|
||||
|
||||
for (id, summary, updated_at) in rows {
|
||||
threads.push(SerializedThreadMetadata {
|
||||
id: key,
|
||||
summary: value.summary,
|
||||
updated_at: value.updated_at,
|
||||
id,
|
||||
summary: summary.into(),
|
||||
updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -953,36 +1094,51 @@ impl ThreadsDatabase {
|
||||
}
|
||||
|
||||
pub fn try_find_thread(&self, id: ThreadId) -> Task<Result<Option<SerializedThread>>> {
|
||||
let env = self.env.clone();
|
||||
let threads = self.threads;
|
||||
let connection = self.connection.clone();
|
||||
|
||||
self.executor.spawn(async move {
|
||||
let txn = env.read_txn()?;
|
||||
let thread = threads.get(&txn, &id)?;
|
||||
Ok(thread)
|
||||
let connection = connection.lock().unwrap();
|
||||
let mut select = connection.select_bound::<ThreadId, (DataType, Vec<u8>)>(indoc! {"
|
||||
SELECT data_type, data FROM threads WHERE id = ? LIMIT 1
|
||||
"})?;
|
||||
|
||||
let rows = select(id)?;
|
||||
if let Some((data_type, data)) = rows.into_iter().next() {
|
||||
let json_data = match data_type {
|
||||
DataType::Zstd => {
|
||||
let decompressed = zstd::decode_all(&data[..])?;
|
||||
String::from_utf8(decompressed)?
|
||||
}
|
||||
DataType::Json => String::from_utf8(data)?,
|
||||
};
|
||||
|
||||
let thread = SerializedThread::from_json(json_data.as_bytes())?;
|
||||
Ok(Some(thread))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_thread(&self, id: ThreadId, thread: SerializedThread) -> Task<Result<()>> {
|
||||
let env = self.env.clone();
|
||||
let threads = self.threads;
|
||||
let connection = self.connection.clone();
|
||||
|
||||
self.executor.spawn(async move {
|
||||
let mut txn = env.write_txn()?;
|
||||
threads.put(&mut txn, &id, &thread)?;
|
||||
txn.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
self.executor
|
||||
.spawn(async move { Self::save_thread_sync(&connection, id, thread) })
|
||||
}
|
||||
|
||||
pub fn delete_thread(&self, id: ThreadId) -> Task<Result<()>> {
|
||||
let env = self.env.clone();
|
||||
let threads = self.threads;
|
||||
let connection = self.connection.clone();
|
||||
|
||||
self.executor.spawn(async move {
|
||||
let mut txn = env.write_txn()?;
|
||||
threads.delete(&mut txn, &id)?;
|
||||
txn.commit()?;
|
||||
let connection = connection.lock().unwrap();
|
||||
|
||||
let mut delete = connection.exec_bound::<ThreadId>(indoc! {"
|
||||
DELETE FROM threads WHERE id = ?
|
||||
"})?;
|
||||
|
||||
delete(id)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -337,6 +337,12 @@ impl ToolUseState {
|
||||
)
|
||||
.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(
|
||||
tool_use.id.clone(),
|
||||
PendingToolUse {
|
||||
@@ -345,6 +351,7 @@ impl ToolUseState {
|
||||
name: tool_use.name.clone(),
|
||||
ui_text: ui_text.clone(),
|
||||
input: tool_use.input,
|
||||
may_perform_edits,
|
||||
status,
|
||||
},
|
||||
);
|
||||
@@ -518,6 +525,7 @@ pub struct PendingToolUse {
|
||||
pub ui_text: Arc<str>,
|
||||
pub input: serde_json::Value,
|
||||
pub status: PendingToolUseStatus,
|
||||
pub may_perform_edits: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -304,7 +304,7 @@ impl AddedContext {
|
||||
AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
|
||||
AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
|
||||
AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
|
||||
AgentContextHandle::Image(handle) => Some(Self::image(handle)),
|
||||
AgentContextHandle::Image(handle) => Some(Self::image(handle, cx)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ impl AddedContext {
|
||||
AgentContext::Thread(context) => Self::attached_thread(context),
|
||||
AgentContext::TextThread(context) => Self::attached_text_thread(context),
|
||||
AgentContext::Rules(context) => Self::attached_rules(context),
|
||||
AgentContext::Image(context) => Self::image(context.clone()),
|
||||
AgentContext::Image(context) => Self::image(context.clone(), cx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,14 +333,8 @@ impl AddedContext {
|
||||
|
||||
fn file(handle: FileContextHandle, full_path: &Path, cx: &App) -> AddedContext {
|
||||
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
|
||||
let name = full_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned().into())
|
||||
.unwrap_or_else(|| full_path_string.clone());
|
||||
let parent = full_path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned().into());
|
||||
let (name, parent) =
|
||||
extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
|
||||
AddedContext {
|
||||
kind: ContextKind::File,
|
||||
name,
|
||||
@@ -370,14 +364,8 @@ impl AddedContext {
|
||||
|
||||
fn directory(handle: DirectoryContextHandle, full_path: &Path) -> AddedContext {
|
||||
let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
|
||||
let name = full_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned().into())
|
||||
.unwrap_or_else(|| full_path_string.clone());
|
||||
let parent = full_path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned().into());
|
||||
let (name, parent) =
|
||||
extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
|
||||
AddedContext {
|
||||
kind: ContextKind::Directory,
|
||||
name,
|
||||
@@ -605,13 +593,23 @@ impl AddedContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn image(context: ImageContext) -> AddedContext {
|
||||
fn image(context: ImageContext, cx: &App) -> AddedContext {
|
||||
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 (name, parent) =
|
||||
extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
|
||||
let icon_path = FileIcons::get_icon(&full_path, cx);
|
||||
(name, parent, icon_path)
|
||||
} else {
|
||||
("Image".into(), None, None)
|
||||
};
|
||||
|
||||
AddedContext {
|
||||
kind: ContextKind::Image,
|
||||
name: "Image".into(),
|
||||
parent: None,
|
||||
name,
|
||||
parent,
|
||||
tooltip: None,
|
||||
icon_path: None,
|
||||
icon_path,
|
||||
status: match context.status() {
|
||||
ImageStatus::Loading => ContextStatus::Loading {
|
||||
message: "Loading…".into(),
|
||||
@@ -639,6 +637,22 @@ impl AddedContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_file_name_and_directory_from_full_path(
|
||||
path: &Path,
|
||||
name_fallback: &SharedString,
|
||||
) -> (SharedString, Option<SharedString>) {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned().into())
|
||||
.unwrap_or_else(|| name_fallback.clone());
|
||||
let parent = path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned().into());
|
||||
|
||||
(name, parent)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ContextFileExcerpt {
|
||||
pub file_name_and_range: SharedString,
|
||||
@@ -765,37 +779,49 @@ impl Component for AddedContext {
|
||||
let mut next_context_id = ContextId::zero();
|
||||
let image_ready = (
|
||||
"Ready",
|
||||
AddedContext::image(ImageContext {
|
||||
context_id: next_context_id.post_inc(),
|
||||
project_path: None,
|
||||
original_image: Arc::new(Image::empty()),
|
||||
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
|
||||
}),
|
||||
AddedContext::image(
|
||||
ImageContext {
|
||||
context_id: next_context_id.post_inc(),
|
||||
project_path: None,
|
||||
full_path: None,
|
||||
original_image: Arc::new(Image::empty()),
|
||||
image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
|
||||
},
|
||||
cx,
|
||||
),
|
||||
);
|
||||
|
||||
let image_loading = (
|
||||
"Loading",
|
||||
AddedContext::image(ImageContext {
|
||||
context_id: next_context_id.post_inc(),
|
||||
project_path: None,
|
||||
original_image: Arc::new(Image::empty()),
|
||||
image_task: cx
|
||||
.background_spawn(async move {
|
||||
smol::Timer::after(Duration::from_secs(60 * 5)).await;
|
||||
Some(LanguageModelImage::empty())
|
||||
})
|
||||
.shared(),
|
||||
}),
|
||||
AddedContext::image(
|
||||
ImageContext {
|
||||
context_id: next_context_id.post_inc(),
|
||||
project_path: None,
|
||||
full_path: None,
|
||||
original_image: Arc::new(Image::empty()),
|
||||
image_task: cx
|
||||
.background_spawn(async move {
|
||||
smol::Timer::after(Duration::from_secs(60 * 5)).await;
|
||||
Some(LanguageModelImage::empty())
|
||||
})
|
||||
.shared(),
|
||||
},
|
||||
cx,
|
||||
),
|
||||
);
|
||||
|
||||
let image_error = (
|
||||
"Error",
|
||||
AddedContext::image(ImageContext {
|
||||
context_id: next_context_id.post_inc(),
|
||||
project_path: None,
|
||||
original_image: Arc::new(Image::empty()),
|
||||
image_task: Task::ready(None).shared(),
|
||||
}),
|
||||
AddedContext::image(
|
||||
ImageContext {
|
||||
context_id: next_context_id.post_inc(),
|
||||
project_path: None,
|
||||
full_path: None,
|
||||
original_image: Arc::new(Image::empty()),
|
||||
image_task: Task::ready(None).shared(),
|
||||
},
|
||||
cx,
|
||||
),
|
||||
);
|
||||
|
||||
Some(
|
||||
|
||||
@@ -372,6 +372,8 @@ impl AgentSettingsContent {
|
||||
None,
|
||||
None,
|
||||
Some(language_model.supports_tools()),
|
||||
Some(language_model.supports_images()),
|
||||
None,
|
||||
)),
|
||||
api_url,
|
||||
});
|
||||
@@ -728,6 +730,7 @@ impl JsonSchema for LanguageModelProviderSetting {
|
||||
"zed.dev".into(),
|
||||
"copilot_chat".into(),
|
||||
"deepseek".into(),
|
||||
"openrouter".into(),
|
||||
"mistral".into(),
|
||||
]),
|
||||
..Default::default()
|
||||
|
||||
@@ -48,7 +48,7 @@ impl SlashCommandCompletionProvider {
|
||||
name_range: Range<Anchor>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Option<Vec<project::Completion>>>> {
|
||||
) -> Task<Result<Vec<project::CompletionResponse>>> {
|
||||
let slash_commands = self.slash_commands.clone();
|
||||
let candidates = slash_commands
|
||||
.command_names(cx)
|
||||
@@ -71,28 +71,27 @@ impl SlashCommandCompletionProvider {
|
||||
.await;
|
||||
|
||||
cx.update(|_, cx| {
|
||||
Some(
|
||||
matches
|
||||
.into_iter()
|
||||
.filter_map(|mat| {
|
||||
let command = slash_commands.command(&mat.string, cx)?;
|
||||
let mut new_text = mat.string.clone();
|
||||
let requires_argument = command.requires_argument();
|
||||
let accepts_arguments = command.accepts_arguments();
|
||||
if requires_argument || accepts_arguments {
|
||||
new_text.push(' ');
|
||||
}
|
||||
let completions = matches
|
||||
.into_iter()
|
||||
.filter_map(|mat| {
|
||||
let command = slash_commands.command(&mat.string, cx)?;
|
||||
let mut new_text = mat.string.clone();
|
||||
let requires_argument = command.requires_argument();
|
||||
let accepts_arguments = command.accepts_arguments();
|
||||
if requires_argument || accepts_arguments {
|
||||
new_text.push(' ');
|
||||
}
|
||||
|
||||
let confirm =
|
||||
editor
|
||||
.clone()
|
||||
.zip(workspace.clone())
|
||||
.map(|(editor, workspace)| {
|
||||
let command_name = mat.string.clone();
|
||||
let command_range = command_range.clone();
|
||||
let editor = editor.clone();
|
||||
let workspace = workspace.clone();
|
||||
Arc::new(
|
||||
let confirm =
|
||||
editor
|
||||
.clone()
|
||||
.zip(workspace.clone())
|
||||
.map(|(editor, workspace)| {
|
||||
let command_name = mat.string.clone();
|
||||
let command_range = command_range.clone();
|
||||
let editor = editor.clone();
|
||||
let workspace = workspace.clone();
|
||||
Arc::new(
|
||||
move |intent: CompletionIntent,
|
||||
window: &mut Window,
|
||||
cx: &mut App| {
|
||||
@@ -118,22 +117,27 @@ impl SlashCommandCompletionProvider {
|
||||
}
|
||||
},
|
||||
) as Arc<_>
|
||||
});
|
||||
Some(project::Completion {
|
||||
replace_range: name_range.clone(),
|
||||
documentation: Some(CompletionDocumentation::SingleLine(
|
||||
command.description().into(),
|
||||
)),
|
||||
new_text,
|
||||
label: command.label(cx),
|
||||
icon_path: None,
|
||||
insert_text_mode: None,
|
||||
confirm,
|
||||
source: CompletionSource::Custom,
|
||||
})
|
||||
});
|
||||
|
||||
Some(project::Completion {
|
||||
replace_range: name_range.clone(),
|
||||
documentation: Some(CompletionDocumentation::SingleLine(
|
||||
command.description().into(),
|
||||
)),
|
||||
new_text,
|
||||
label: command.label(cx),
|
||||
icon_path: None,
|
||||
insert_text_mode: None,
|
||||
confirm,
|
||||
source: CompletionSource::Custom,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
vec![project::CompletionResponse {
|
||||
completions,
|
||||
is_incomplete: false,
|
||||
}]
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -147,7 +151,7 @@ impl SlashCommandCompletionProvider {
|
||||
last_argument_range: Range<Anchor>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Option<Vec<project::Completion>>>> {
|
||||
) -> Task<Result<Vec<project::CompletionResponse>>> {
|
||||
let new_cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
let mut flag = self.cancel_flag.lock();
|
||||
flag.store(true, SeqCst);
|
||||
@@ -165,28 +169,27 @@ impl SlashCommandCompletionProvider {
|
||||
let workspace = self.workspace.clone();
|
||||
let arguments = arguments.to_vec();
|
||||
cx.background_spawn(async move {
|
||||
Ok(Some(
|
||||
completions
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|new_argument| {
|
||||
let confirm =
|
||||
editor
|
||||
.clone()
|
||||
.zip(workspace.clone())
|
||||
.map(|(editor, workspace)| {
|
||||
Arc::new({
|
||||
let mut completed_arguments = arguments.clone();
|
||||
if new_argument.replace_previous_arguments {
|
||||
completed_arguments.clear();
|
||||
} else {
|
||||
completed_arguments.pop();
|
||||
}
|
||||
completed_arguments.push(new_argument.new_text.clone());
|
||||
let completions = completions
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|new_argument| {
|
||||
let confirm =
|
||||
editor
|
||||
.clone()
|
||||
.zip(workspace.clone())
|
||||
.map(|(editor, workspace)| {
|
||||
Arc::new({
|
||||
let mut completed_arguments = arguments.clone();
|
||||
if new_argument.replace_previous_arguments {
|
||||
completed_arguments.clear();
|
||||
} else {
|
||||
completed_arguments.pop();
|
||||
}
|
||||
completed_arguments.push(new_argument.new_text.clone());
|
||||
|
||||
let command_range = command_range.clone();
|
||||
let command_name = command_name.clone();
|
||||
move |intent: CompletionIntent,
|
||||
let command_range = command_range.clone();
|
||||
let command_name = command_name.clone();
|
||||
move |intent: CompletionIntent,
|
||||
window: &mut Window,
|
||||
cx: &mut App| {
|
||||
if new_argument.after_completion.run()
|
||||
@@ -210,34 +213,41 @@ impl SlashCommandCompletionProvider {
|
||||
!new_argument.after_completion.run()
|
||||
}
|
||||
}
|
||||
}) as Arc<_>
|
||||
});
|
||||
}) as Arc<_>
|
||||
});
|
||||
|
||||
let mut new_text = new_argument.new_text.clone();
|
||||
if new_argument.after_completion == AfterCompletion::Continue {
|
||||
new_text.push(' ');
|
||||
}
|
||||
let mut new_text = new_argument.new_text.clone();
|
||||
if new_argument.after_completion == AfterCompletion::Continue {
|
||||
new_text.push(' ');
|
||||
}
|
||||
|
||||
project::Completion {
|
||||
replace_range: if new_argument.replace_previous_arguments {
|
||||
argument_range.clone()
|
||||
} else {
|
||||
last_argument_range.clone()
|
||||
},
|
||||
label: new_argument.label,
|
||||
icon_path: None,
|
||||
new_text,
|
||||
documentation: None,
|
||||
confirm,
|
||||
insert_text_mode: None,
|
||||
source: CompletionSource::Custom,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
project::Completion {
|
||||
replace_range: if new_argument.replace_previous_arguments {
|
||||
argument_range.clone()
|
||||
} else {
|
||||
last_argument_range.clone()
|
||||
},
|
||||
label: new_argument.label,
|
||||
icon_path: None,
|
||||
new_text,
|
||||
documentation: None,
|
||||
confirm,
|
||||
insert_text_mode: None,
|
||||
source: CompletionSource::Custom,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(vec![project::CompletionResponse {
|
||||
completions,
|
||||
is_incomplete: false,
|
||||
}])
|
||||
})
|
||||
} else {
|
||||
Task::ready(Ok(Some(Vec::new())))
|
||||
Task::ready(Ok(vec![project::CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
is_incomplete: false,
|
||||
}]))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +261,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
|
||||
_: editor::CompletionContext,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Option<Vec<project::Completion>>>> {
|
||||
) -> Task<Result<Vec<project::CompletionResponse>>> {
|
||||
let Some((name, arguments, command_range, last_argument_range)) =
|
||||
buffer.update(cx, |buffer, _cx| {
|
||||
let position = buffer_position.to_point(buffer);
|
||||
@@ -295,7 +305,10 @@ impl CompletionProvider for SlashCommandCompletionProvider {
|
||||
Some((name, arguments, command_range, last_argument_range))
|
||||
})
|
||||
else {
|
||||
return Task::ready(Ok(Some(Vec::new())));
|
||||
return Task::ready(Ok(vec![project::CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
is_incomplete: false,
|
||||
}]));
|
||||
};
|
||||
|
||||
if let Some((arguments, argument_range)) = arguments {
|
||||
@@ -329,6 +342,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
|
||||
position: language::Anchor,
|
||||
_text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
let buffer = buffer.read(cx);
|
||||
|
||||
@@ -218,6 +218,9 @@ pub trait Tool: 'static + Send + Sync {
|
||||
/// before having permission to run.
|
||||
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.
|
||||
fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::Value::Object(serde_json::Map::default()))
|
||||
|
||||
@@ -16,11 +16,24 @@ pub fn adapt_schema_to_format(
|
||||
}
|
||||
|
||||
match format {
|
||||
LanguageModelToolSchemaFormat::JsonSchema => Ok(()),
|
||||
LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(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
|
||||
fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
|
||||
if let Value::Object(obj) = json {
|
||||
@@ -237,4 +250,59 @@ mod tests {
|
||||
|
||||
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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ mod tests {
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ impl Tool for CopyPathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./copy_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -33,12 +33,16 @@ impl Tool for CreateDirectoryTool {
|
||||
"create_directory".into()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./create_directory_tool/description.md").into()
|
||||
}
|
||||
|
||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./create_directory_tool/description.md").into()
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
|
||||
@@ -37,6 +37,10 @@ impl Tool for DeletePathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./delete_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ impl Tool for DiagnosticsTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./diagnostics_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ impl Template for EditFilePromptTemplate {
|
||||
pub enum EditAgentOutputEvent {
|
||||
ResolvingEditRange(Range<Anchor>),
|
||||
UnresolvedEditRange,
|
||||
AmbiguousEditRange(Vec<Range<usize>>),
|
||||
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_;
|
||||
|
||||
// If we can't resolve the old text, restart the loop waiting for a
|
||||
// new edit (or for the stream to end).
|
||||
let Some(resolved_old_text) = resolved_old_text else {
|
||||
output_events
|
||||
.unbounded_send(EditAgentOutputEvent::UnresolvedEditRange)
|
||||
.ok();
|
||||
continue;
|
||||
let resolved_old_text = match resolved_old_text.len() {
|
||||
1 => resolved_old_text.pop().unwrap(),
|
||||
0 => {
|
||||
output_events
|
||||
.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
|
||||
@@ -405,7 +419,7 @@ impl EditAgent {
|
||||
mut edit_events: T,
|
||||
cx: &mut AsyncApp,
|
||||
) -> (
|
||||
Task<Result<(T, Option<ResolvedOldText>)>>,
|
||||
Task<Result<(T, Vec<ResolvedOldText>)>>,
|
||||
async_watch::Receiver<Option<Range<usize>>>,
|
||||
)
|
||||
where
|
||||
@@ -425,21 +439,29 @@ impl EditAgent {
|
||||
}
|
||||
}
|
||||
|
||||
let old_range = matcher.finish();
|
||||
old_range_tx.send(old_range.clone())?;
|
||||
if let Some(old_range) = old_range {
|
||||
let line_indent =
|
||||
LineIndent::from_iter(matcher.query_lines().first().unwrap().chars());
|
||||
Ok((
|
||||
edit_events,
|
||||
Some(ResolvedOldText {
|
||||
range: old_range,
|
||||
indent: line_indent,
|
||||
}),
|
||||
))
|
||||
let matches = matcher.finish();
|
||||
|
||||
let old_range = if matches.len() == 1 {
|
||||
matches.first()
|
||||
} 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)
|
||||
@@ -1322,6 +1344,76 @@ mod tests {
|
||||
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(
|
||||
stream: &mut UnboundedReceiver<EditAgentOutputEvent>,
|
||||
) -> Vec<EditAgentOutputEvent> {
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct StreamingFuzzyMatcher {
|
||||
snapshot: TextBufferSnapshot,
|
||||
query_lines: Vec<String>,
|
||||
incomplete_line: String,
|
||||
best_match: Option<Range<usize>>,
|
||||
best_matches: Vec<Range<usize>>,
|
||||
matrix: SearchMatrix,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ impl StreamingFuzzyMatcher {
|
||||
snapshot,
|
||||
query_lines: Vec::new(),
|
||||
incomplete_line: String::new(),
|
||||
best_match: None,
|
||||
best_matches: Vec::new(),
|
||||
matrix: SearchMatrix::new(buffer_line_count + 1),
|
||||
}
|
||||
}
|
||||
@@ -55,31 +55,41 @@ impl StreamingFuzzyMatcher {
|
||||
|
||||
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
|
||||
/// match result.
|
||||
pub fn finish(&mut self) -> Option<Range<usize>> {
|
||||
pub fn finish(&mut self) -> Vec<Range<usize>> {
|
||||
// Process any remaining incomplete line
|
||||
if !self.incomplete_line.is_empty() {
|
||||
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_match.clone()
|
||||
self.best_matches.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 old_query_line_count = self.matrix.rows.saturating_sub(1);
|
||||
if new_query_line_count == old_query_line_count {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
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 mut buffer_row_end = buffer_line_count as u32;
|
||||
let mut best_cost = u32::MAX;
|
||||
let mut matches_with_best_cost = Vec::new();
|
||||
|
||||
for col in 1..=buffer_line_count {
|
||||
let cost = self.matrix.get(new_query_line_count, col).cost;
|
||||
if cost < best_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;
|
||||
let mut query_row = new_query_line_count;
|
||||
let mut buffer_row_start = buffer_row_end;
|
||||
while query_row > 0 && buffer_row_start > 0 {
|
||||
let current = self.matrix.get(query_row, buffer_row_start as usize);
|
||||
match current.direction {
|
||||
SearchDirection::Diagonal => {
|
||||
query_row -= 1;
|
||||
buffer_row_start -= 1;
|
||||
matched_lines += 1;
|
||||
}
|
||||
SearchDirection::Up => {
|
||||
query_row -= 1;
|
||||
}
|
||||
SearchDirection::Left => {
|
||||
buffer_row_start -= 1;
|
||||
// Find ranges for the matches
|
||||
let mut valid_matches = Vec::new();
|
||||
for &buffer_row_end in &matches_with_best_cost {
|
||||
let mut matched_lines = 0;
|
||||
let mut query_row = new_query_line_count;
|
||||
let mut buffer_row_start = buffer_row_end;
|
||||
while query_row > 0 && buffer_row_start > 0 {
|
||||
let current = self.matrix.get(query_row, buffer_row_start as usize);
|
||||
match current.direction {
|
||||
SearchDirection::Diagonal => {
|
||||
query_row -= 1;
|
||||
buffer_row_start -= 1;
|
||||
matched_lines += 1;
|
||||
}
|
||||
SearchDirection::Up => {
|
||||
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;
|
||||
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
|
||||
}
|
||||
valid_matches.into_iter().map(|(_, range)| range).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,28 +656,35 @@ mod tests {
|
||||
matcher.push(chunk);
|
||||
}
|
||||
|
||||
let result = matcher.finish();
|
||||
let actual_ranges = matcher.finish();
|
||||
|
||||
// If no expected ranges, we expect no match
|
||||
if expected_ranges.is_empty() {
|
||||
assert_eq!(
|
||||
result, None,
|
||||
assert!(
|
||||
actual_ranges.is_empty(),
|
||||
"Expected no match for query: {:?}, but found: {:?}",
|
||||
query, result
|
||||
query,
|
||||
actual_ranges
|
||||
);
|
||||
} 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);
|
||||
pretty_assertions::assert_eq!(
|
||||
text_with_actual_range,
|
||||
text_with_expected_range,
|
||||
"Query: {:?}, Chunks: {:?}",
|
||||
indoc! {"
|
||||
Query: {:?}
|
||||
Chunks: {:?}
|
||||
Expected marked text: {}
|
||||
Actual marked text: {}
|
||||
Expected ranges: {:?}
|
||||
Actual ranges: {:?}"
|
||||
},
|
||||
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> {
|
||||
let snapshot = finder.snapshot.clone();
|
||||
finder
|
||||
.finish()
|
||||
.map(|range| snapshot.text_for_range(range).collect::<String>())
|
||||
let matches = finder.finish();
|
||||
if let Some(range) = matches.first() {
|
||||
Some(snapshot.text_for_range(range.clone()).collect::<String>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::{
|
||||
Templates,
|
||||
edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
|
||||
schema::json_schema_for,
|
||||
ui::{COLLAPSED_LINES, ToolOutputPreview},
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use assistant_tool::{
|
||||
@@ -13,7 +14,7 @@ use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
|
||||
TextStyleRefinement, WeakEntity, pulsating_between,
|
||||
TextStyleRefinement, WeakEntity, pulsating_between, px,
|
||||
};
|
||||
use indoc::formatdoc;
|
||||
use language::{
|
||||
@@ -128,6 +129,10 @@ impl Tool for EditFileTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("edit_file_tool/description.md").to_string()
|
||||
}
|
||||
@@ -234,6 +239,7 @@ impl Tool for EditFileTool {
|
||||
};
|
||||
|
||||
let mut hallucinated_old_text = false;
|
||||
let mut ambiguous_ranges = Vec::new();
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
EditAgentOutputEvent::Edited => {
|
||||
@@ -242,6 +248,7 @@ impl Tool for EditFileTool {
|
||||
}
|
||||
}
|
||||
EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
|
||||
EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
|
||||
EditAgentOutputEvent::ResolvingEditRange(range) => {
|
||||
if let Some(card) = card_clone.as_ref() {
|
||||
card.update(cx, |card, cx| card.reveal_range(range, cx))?;
|
||||
@@ -324,6 +331,17 @@ impl Tool for EditFileTool {
|
||||
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 {
|
||||
content: ToolResultContent::Text("No edits were made.".into()),
|
||||
output: serde_json::to_value(output).ok(),
|
||||
@@ -884,30 +902,8 @@ impl ToolCard for EditFileToolCard {
|
||||
(element.into_any_element(), line_height)
|
||||
});
|
||||
|
||||
let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
|
||||
(IconName::ChevronUp, "Collapse Code Block")
|
||||
} else {
|
||||
(IconName::ChevronDown, "Expand Code Block")
|
||||
};
|
||||
|
||||
let gradient_overlay =
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_0()
|
||||
.left_0()
|
||||
.w_full()
|
||||
.h_2_5()
|
||||
.bg(gpui::linear_gradient(
|
||||
0.,
|
||||
gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
|
||||
gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
|
||||
));
|
||||
|
||||
let border_color = cx.theme().colors().border.opacity(0.6);
|
||||
|
||||
const DEFAULT_COLLAPSED_LINES: u32 = 10;
|
||||
let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
|
||||
|
||||
let waiting_for_diff = {
|
||||
let styles = [
|
||||
("w_4_5", (0.1, 0.85), 2000),
|
||||
@@ -992,48 +988,34 @@ impl ToolCard for EditFileToolCard {
|
||||
card.child(waiting_for_diff)
|
||||
})
|
||||
.when(self.preview_expanded && !self.is_loading(), |card| {
|
||||
let editor_view = v_flex()
|
||||
.relative()
|
||||
.h_full()
|
||||
.when(!self.full_height_expanded, |editor_container| {
|
||||
editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
|
||||
})
|
||||
.overflow_hidden()
|
||||
.border_t_1()
|
||||
.border_color(border_color)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(editor);
|
||||
|
||||
card.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.h_full()
|
||||
.when(!self.full_height_expanded, |editor_container| {
|
||||
editor_container
|
||||
.max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
|
||||
})
|
||||
.overflow_hidden()
|
||||
.border_t_1()
|
||||
.border_color(border_color)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(editor)
|
||||
.when(
|
||||
!self.full_height_expanded && is_collapsible,
|
||||
|editor_container| editor_container.child(gradient_overlay),
|
||||
),
|
||||
ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
|
||||
.with_total_lines(self.total_lines.unwrap_or(0) as usize)
|
||||
.toggle_state(self.full_height_expanded)
|
||||
.with_collapsed_fade()
|
||||
.on_toggle({
|
||||
let this = cx.entity().downgrade();
|
||||
move |is_expanded, _window, cx| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(cx, |this, _cx| {
|
||||
this.full_height_expanded = is_expanded;
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.when(is_collapsible, |card| {
|
||||
card.child(
|
||||
h_flex()
|
||||
.id(("expand-button", self.editor.entity_id()))
|
||||
.flex_none()
|
||||
.cursor_pointer()
|
||||
.h_5()
|
||||
.justify_center()
|
||||
.border_t_1()
|
||||
.rounded_b_md()
|
||||
.border_color(border_color)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
|
||||
.child(
|
||||
Icon::new(full_height_icon)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.tooltip(Tooltip::text(full_height_tooltip_label))
|
||||
.on_click(cx.listener(move |this, _event, _window, _cx| {
|
||||
this.full_height_expanded = !this.full_height_expanded;
|
||||
})),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,11 @@ impl Tool for FetchTool {
|
||||
}
|
||||
|
||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||
true
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
|
||||
@@ -59,6 +59,10 @@ impl Tool for FindPathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./find_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ impl Tool for GrepTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./grep_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ impl Tool for ListDirectoryTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./list_directory_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ impl Tool for MovePathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./move_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ impl Tool for NowTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ impl Tool for OpenTool {
|
||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
include_str!("./open_tool/description.md").to_string()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ impl Tool for ReadFileTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./read_file_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::schema::json_schema_for;
|
||||
use crate::{
|
||||
schema::json_schema_for,
|
||||
ui::{COLLAPSED_LINES, ToolOutputPreview},
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
|
||||
use futures::{FutureExt as _, future::Shared};
|
||||
@@ -25,7 +28,7 @@ use terminal_view::TerminalView;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{Disclosure, Tooltip, prelude::*};
|
||||
use util::{
|
||||
get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
|
||||
ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
|
||||
time::duration_alt_display,
|
||||
};
|
||||
use workspace::Workspace;
|
||||
@@ -77,6 +80,10 @@ impl Tool for TerminalTool {
|
||||
true
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./terminal_tool/description.md").to_string()
|
||||
}
|
||||
@@ -254,22 +261,24 @@ impl Tool for TerminalTool {
|
||||
|
||||
let terminal_view = window.update(cx, |_, window, cx| {
|
||||
cx.new(|cx| {
|
||||
TerminalView::new(
|
||||
let mut view = TerminalView::new(
|
||||
terminal.clone(),
|
||||
workspace.downgrade(),
|
||||
None,
|
||||
project.downgrade(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
);
|
||||
view.set_embedded_mode(None, cx);
|
||||
view
|
||||
})
|
||||
})?;
|
||||
|
||||
let _ = card.update(cx, |card, _| {
|
||||
card.update(cx, |card, _| {
|
||||
card.terminal = Some(terminal_view.clone());
|
||||
card.start_instant = Instant::now();
|
||||
});
|
||||
})
|
||||
.log_err();
|
||||
|
||||
let exit_status = terminal
|
||||
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
|
||||
@@ -285,7 +294,7 @@ impl Tool for TerminalTool {
|
||||
exit_status.map(portable_pty::ExitStatus::from),
|
||||
);
|
||||
|
||||
let _ = card.update(cx, |card, _| {
|
||||
card.update(cx, |card, _| {
|
||||
card.command_finished = true;
|
||||
card.exit_status = exit_status;
|
||||
card.was_content_truncated = processed_content.len() < previous_len;
|
||||
@@ -293,7 +302,8 @@ impl Tool for TerminalTool {
|
||||
card.content_line_count = content_line_count;
|
||||
card.finished_with_empty_output = finished_with_empty_output;
|
||||
card.elapsed_time = Some(card.start_instant.elapsed());
|
||||
});
|
||||
})
|
||||
.log_err();
|
||||
|
||||
Ok(processed_content.into())
|
||||
}
|
||||
@@ -473,7 +483,6 @@ impl ToolCard for TerminalToolCard {
|
||||
let time_elapsed = self
|
||||
.elapsed_time
|
||||
.unwrap_or_else(|| self.start_instant.elapsed());
|
||||
let should_hide_terminal = tool_failed || self.finished_with_empty_output;
|
||||
|
||||
let header_bg = cx
|
||||
.theme()
|
||||
@@ -574,7 +583,7 @@ impl ToolCard for TerminalToolCard {
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!should_hide_terminal, |header| {
|
||||
.when(!self.finished_with_empty_output, |header| {
|
||||
header.child(
|
||||
Disclosure::new(
|
||||
("terminal-tool-disclosure", self.entity_id),
|
||||
@@ -618,19 +627,43 @@ impl ToolCard for TerminalToolCard {
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(self.preview_expanded && !should_hide_terminal, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.pt_2()
|
||||
.min_h_72()
|
||||
.border_t_1()
|
||||
.border_color(border_color)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.rounded_b_md()
|
||||
.text_ui_sm(cx)
|
||||
.child(terminal.clone()),
|
||||
)
|
||||
})
|
||||
.when(
|
||||
self.preview_expanded && !self.finished_with_empty_output,
|
||||
|this| {
|
||||
this.child(
|
||||
div()
|
||||
.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(border_color)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.rounded_b_md()
|
||||
.text_ui_sm(cx)
|
||||
.child(
|
||||
ToolOutputPreview::new(
|
||||
terminal.clone().into_any_element(),
|
||||
terminal.entity_id(),
|
||||
)
|
||||
.with_total_lines(self.content_line_count)
|
||||
.toggle_state(!terminal.read(cx).is_content_limited(window))
|
||||
.on_toggle({
|
||||
let terminal = terminal.clone();
|
||||
move |is_expanded, _, cx| {
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.set_embedded_mode(
|
||||
if is_expanded {
|
||||
None
|
||||
} else {
|
||||
Some(COLLAPSED_LINES)
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ impl Tool for ThinkingTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./thinking_tool/description.md").to_string()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod tool_call_card_header;
|
||||
mod tool_output_preview;
|
||||
|
||||
pub use tool_call_card_header::*;
|
||||
pub use tool_output_preview::*;
|
||||
|
||||
115
crates/assistant_tools/src/ui/tool_output_preview.rs
Normal file
115
crates/assistant_tools/src/ui/tool_output_preview.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use gpui::{AnyElement, EntityId, prelude::*};
|
||||
use ui::{Tooltip, prelude::*};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ToolOutputPreview<F>
|
||||
where
|
||||
F: Fn(bool, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
content: AnyElement,
|
||||
entity_id: EntityId,
|
||||
full_height: bool,
|
||||
total_lines: usize,
|
||||
collapsed_fade: bool,
|
||||
on_toggle: Option<F>,
|
||||
}
|
||||
|
||||
pub const COLLAPSED_LINES: usize = 10;
|
||||
|
||||
impl<F> ToolOutputPreview<F>
|
||||
where
|
||||
F: Fn(bool, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
pub fn new(content: AnyElement, entity_id: EntityId) -> Self {
|
||||
Self {
|
||||
content,
|
||||
entity_id,
|
||||
full_height: true,
|
||||
total_lines: 0,
|
||||
collapsed_fade: false,
|
||||
on_toggle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_total_lines(mut self, total_lines: usize) -> Self {
|
||||
self.total_lines = total_lines;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn toggle_state(mut self, full_height: bool) -> Self {
|
||||
self.full_height = full_height;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_collapsed_fade(mut self) -> Self {
|
||||
self.collapsed_fade = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_toggle(mut self, listener: F) -> Self {
|
||||
self.on_toggle = Some(listener);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> RenderOnce for ToolOutputPreview<F>
|
||||
where
|
||||
F: Fn(bool, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
if self.total_lines <= COLLAPSED_LINES {
|
||||
return self.content;
|
||||
}
|
||||
let border_color = cx.theme().colors().border.opacity(0.6);
|
||||
|
||||
let (icon, tooltip_label) = if self.full_height {
|
||||
(IconName::ChevronUp, "Collapse")
|
||||
} else {
|
||||
(IconName::ChevronDown, "Expand")
|
||||
};
|
||||
|
||||
let gradient_overlay =
|
||||
if self.collapsed_fade && !self.full_height {
|
||||
Some(div().absolute().bottom_5().left_0().w_full().h_2_5().bg(
|
||||
gpui::linear_gradient(
|
||||
0.,
|
||||
gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
|
||||
gpui::linear_color_stop(
|
||||
cx.theme().colors().editor_background.opacity(0.),
|
||||
1.,
|
||||
),
|
||||
),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.child(self.content)
|
||||
.children(gradient_overlay)
|
||||
.child(
|
||||
h_flex()
|
||||
.id(("expand-button", self.entity_id))
|
||||
.flex_none()
|
||||
.cursor_pointer()
|
||||
.h_5()
|
||||
.justify_center()
|
||||
.border_t_1()
|
||||
.rounded_b_md()
|
||||
.border_color(border_color)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
|
||||
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
|
||||
.tooltip(Tooltip::text(tooltip_label))
|
||||
.when_some(self.on_toggle, |this, on_toggle| {
|
||||
this.on_click({
|
||||
move |_, window, cx| {
|
||||
on_toggle(!self.full_height, window, cx);
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ impl Tool for WebSearchTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -77,10 +77,16 @@ pub enum Model {
|
||||
MetaLlama318BInstructV1,
|
||||
MetaLlama3170BInstructV1_128k,
|
||||
MetaLlama3170BInstructV1,
|
||||
MetaLlama3211BInstructV1,
|
||||
MetaLlama3290BInstructV1,
|
||||
MetaLlama31405BInstructV1,
|
||||
MetaLlama321BInstructV1,
|
||||
MetaLlama323BInstructV1,
|
||||
MetaLlama3211BInstructV1,
|
||||
MetaLlama3290BInstructV1,
|
||||
MetaLlama3370BInstructV1,
|
||||
#[allow(non_camel_case_types)]
|
||||
MetaLlama4Scout17BInstructV1,
|
||||
#[allow(non_camel_case_types)]
|
||||
MetaLlama4Maverick17BInstructV1,
|
||||
// Mistral models
|
||||
MistralMistral7BInstructV0,
|
||||
MistralMixtral8x7BInstructV0,
|
||||
@@ -125,6 +131,64 @@ impl Model {
|
||||
}
|
||||
|
||||
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 {
|
||||
Model::ClaudeSonnet4 | Model::ClaudeSonnet4Thinking => {
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
@@ -145,7 +209,7 @@ impl Model {
|
||||
Model::AmazonNovaMicro => "amazon.nova-micro-v1:0",
|
||||
Model::AmazonNovaPro => "amazon.nova-pro-v1:0",
|
||||
Model::AmazonNovaPremier => "amazon.nova-premier-v1:0",
|
||||
Model::DeepSeekR1 => "us.deepseek.r1-v1:0",
|
||||
Model::DeepSeekR1 => "deepseek.r1-v1:0",
|
||||
Model::AI21J2GrandeInstruct => "ai21.j2-grande-instruct",
|
||||
Model::AI21J2JumboInstruct => "ai21.j2-jumbo-instruct",
|
||||
Model::AI21J2Mid => "ai21.j2-mid",
|
||||
@@ -162,14 +226,18 @@ impl Model {
|
||||
Model::CohereCommandLightTextV14_4k => "cohere.command-light-text-v14:7:4k",
|
||||
Model::MetaLlama38BInstructV1 => "meta.llama3-8b-instruct-v1:0",
|
||||
Model::MetaLlama370BInstructV1 => "meta.llama3-70b-instruct-v1:0",
|
||||
Model::MetaLlama318BInstructV1_128k => "meta.llama3-1-8b-instruct-v1:0:128k",
|
||||
Model::MetaLlama318BInstructV1_128k => "meta.llama3-1-8b-instruct-v1:0",
|
||||
Model::MetaLlama318BInstructV1 => "meta.llama3-1-8b-instruct-v1:0",
|
||||
Model::MetaLlama3170BInstructV1_128k => "meta.llama3-1-70b-instruct-v1:0:128k",
|
||||
Model::MetaLlama3170BInstructV1_128k => "meta.llama3-1-70b-instruct-v1:0",
|
||||
Model::MetaLlama3170BInstructV1 => "meta.llama3-1-70b-instruct-v1:0",
|
||||
Model::MetaLlama31405BInstructV1 => "meta.llama3-1-405b-instruct-v1:0",
|
||||
Model::MetaLlama3211BInstructV1 => "meta.llama3-2-11b-instruct-v1:0",
|
||||
Model::MetaLlama3290BInstructV1 => "meta.llama3-2-90b-instruct-v1:0",
|
||||
Model::MetaLlama321BInstructV1 => "meta.llama3-2-1b-instruct-v1:0",
|
||||
Model::MetaLlama323BInstructV1 => "meta.llama3-2-3b-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::MistralMixtral8x7BInstructV0 => "mistral.mixtral-8x7b-instruct-v0:1",
|
||||
Model::MistralMistralLarge2402V1 => "mistral.mistral-large-2402-v1:0",
|
||||
@@ -214,16 +282,20 @@ impl Model {
|
||||
Self::CohereCommandRV1 => "Cohere Command R V1",
|
||||
Self::CohereCommandRPlusV1 => "Cohere Command R Plus V1",
|
||||
Self::CohereCommandLightTextV14_4k => "Cohere Command Light Text V14 4K",
|
||||
Self::MetaLlama38BInstructV1 => "Meta Llama 3 8B Instruct V1",
|
||||
Self::MetaLlama370BInstructV1 => "Meta Llama 3 70B Instruct V1",
|
||||
Self::MetaLlama318BInstructV1_128k => "Meta Llama 3 1.8B Instruct V1 128K",
|
||||
Self::MetaLlama318BInstructV1 => "Meta Llama 3 1.8B Instruct V1",
|
||||
Self::MetaLlama3170BInstructV1_128k => "Meta Llama 3 1 70B Instruct V1 128K",
|
||||
Self::MetaLlama3170BInstructV1 => "Meta Llama 3 1 70B Instruct V1",
|
||||
Self::MetaLlama3211BInstructV1 => "Meta Llama 3 2 11B Instruct V1",
|
||||
Self::MetaLlama3290BInstructV1 => "Meta Llama 3 2 90B Instruct V1",
|
||||
Self::MetaLlama321BInstructV1 => "Meta Llama 3 2 1B Instruct V1",
|
||||
Self::MetaLlama323BInstructV1 => "Meta Llama 3 2 3B Instruct V1",
|
||||
Self::MetaLlama38BInstructV1 => "Meta Llama 3 8B Instruct",
|
||||
Self::MetaLlama370BInstructV1 => "Meta Llama 3 70B Instruct",
|
||||
Self::MetaLlama318BInstructV1_128k => "Meta Llama 3.1 8B Instruct 128K",
|
||||
Self::MetaLlama318BInstructV1 => "Meta Llama 3.1 8B Instruct",
|
||||
Self::MetaLlama3170BInstructV1_128k => "Meta Llama 3.1 70B Instruct 128K",
|
||||
Self::MetaLlama3170BInstructV1 => "Meta Llama 3.1 70B Instruct",
|
||||
Self::MetaLlama31405BInstructV1 => "Meta Llama 3.1 405B Instruct",
|
||||
Self::MetaLlama3211BInstructV1 => "Meta Llama 3.2 11B Instruct",
|
||||
Self::MetaLlama3290BInstructV1 => "Meta Llama 3.2 90B Instruct",
|
||||
Self::MetaLlama321BInstructV1 => "Meta Llama 3.2 1B Instruct",
|
||||
Self::MetaLlama323BInstructV1 => "Meta Llama 3.2 3B 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::MistralMixtral8x7BInstructV0 => "Mistral Mixtral 8x7B Instruct V0",
|
||||
Self::MistralMistralLarge2402V1 => "Mistral Large 2402 V1",
|
||||
@@ -245,7 +317,9 @@ impl Model {
|
||||
| Self::Claude3_5Haiku
|
||||
| Self::Claude3_7Sonnet
|
||||
| Self::ClaudeSonnet4
|
||||
| Self::ClaudeOpus4 => 200_000,
|
||||
| Self::ClaudeOpus4
|
||||
| Self::ClaudeSonnet4Thinking
|
||||
| Self::ClaudeOpus4Thinking => 200_000,
|
||||
Self::AmazonNovaPremier => 1_000_000,
|
||||
Self::PalmyraWriterX5 => 1_000_000,
|
||||
Self::PalmyraWriterX4 => 128_000,
|
||||
@@ -354,69 +428,76 @@ impl Model {
|
||||
anyhow::bail!("Unsupported Region {region}");
|
||||
};
|
||||
|
||||
let model_id = self.id();
|
||||
let model_id = self.request_id();
|
||||
|
||||
match (self, region_group) {
|
||||
// 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
|
||||
(Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => {
|
||||
Ok(format!("{}.{}", region_group, model_id))
|
||||
}
|
||||
|
||||
// Models available only in US
|
||||
(Model::Claude3Opus, "us")
|
||||
| (Model::Claude3_5Haiku, "us")
|
||||
| (Model::Claude3_7Sonnet, "us")
|
||||
| (Model::ClaudeSonnet4, "us")
|
||||
| (Model::ClaudeOpus4, "us")
|
||||
| (Model::ClaudeSonnet4Thinking, "us")
|
||||
| (Model::ClaudeOpus4Thinking, "us")
|
||||
| (Model::Claude3_7SonnetThinking, "us")
|
||||
| (Model::AmazonNovaPremier, "us")
|
||||
| (Model::MistralPixtralLarge2502V1, "us") => {
|
||||
// Available everywhere
|
||||
(Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => {
|
||||
Ok(format!("{}.{}", region_group, model_id))
|
||||
}
|
||||
|
||||
// Models available in US, EU, and APAC
|
||||
(Model::Claude3_5SonnetV2, "us")
|
||||
| (Model::Claude3_5SonnetV2, "apac")
|
||||
| (Model::Claude3_5Sonnet, _)
|
||||
| (Model::Claude3Haiku, _)
|
||||
| (Model::Claude3Sonnet, _)
|
||||
| (Model::AmazonNovaLite, _)
|
||||
| (Model::AmazonNovaMicro, _)
|
||||
| (Model::AmazonNovaPro, _) => Ok(format!("{}.{}", region_group, model_id)),
|
||||
// Models in US
|
||||
(
|
||||
Model::AmazonNovaPremier
|
||||
| Model::Claude3_5Haiku
|
||||
| Model::Claude3_5Sonnet
|
||||
| Model::Claude3_5SonnetV2
|
||||
| Model::Claude3_7Sonnet
|
||||
| Model::Claude3_7SonnetThinking
|
||||
| Model::Claude3Haiku
|
||||
| Model::Claude3Opus
|
||||
| Model::Claude3Sonnet
|
||||
| Model::DeepSeekR1
|
||||
| Model::MetaLlama31405BInstructV1
|
||||
| Model::MetaLlama3170BInstructV1_128k
|
||||
| Model::MetaLlama3170BInstructV1
|
||||
| Model::MetaLlama318BInstructV1_128k
|
||||
| Model::MetaLlama318BInstructV1
|
||||
| Model::MetaLlama3211BInstructV1
|
||||
| Model::MetaLlama321BInstructV1
|
||||
| Model::MetaLlama323BInstructV1
|
||||
| Model::MetaLlama3290BInstructV1
|
||||
| Model::MetaLlama3370BInstructV1
|
||||
| Model::MetaLlama4Maverick17BInstructV1
|
||||
| Model::MetaLlama4Scout17BInstructV1
|
||||
| Model::MistralPixtralLarge2502V1
|
||||
| Model::PalmyraWriterX4
|
||||
| Model::PalmyraWriterX5,
|
||||
"us",
|
||||
) => Ok(format!("{}.{}", region_group, model_id)),
|
||||
|
||||
// Models with limited EU availability
|
||||
(Model::MetaLlama321BInstructV1, "us")
|
||||
| (Model::MetaLlama321BInstructV1, "eu")
|
||||
| (Model::MetaLlama323BInstructV1, "us")
|
||||
| (Model::MetaLlama323BInstructV1, "eu") => {
|
||||
Ok(format!("{}.{}", region_group, model_id))
|
||||
}
|
||||
// Models available in EU
|
||||
(
|
||||
Model::Claude3_5Sonnet
|
||||
| Model::Claude3_7Sonnet
|
||||
| Model::Claude3_7SonnetThinking
|
||||
| Model::Claude3Haiku
|
||||
| Model::Claude3Sonnet
|
||||
| Model::MetaLlama321BInstructV1
|
||||
| Model::MetaLlama323BInstructV1
|
||||
| Model::MistralPixtralLarge2502V1,
|
||||
"eu",
|
||||
) => Ok(format!("{}.{}", region_group, model_id)),
|
||||
|
||||
// US-only models (all remaining Meta models)
|
||||
(Model::MetaLlama38BInstructV1, "us")
|
||||
| (Model::MetaLlama370BInstructV1, "us")
|
||||
| (Model::MetaLlama318BInstructV1, "us")
|
||||
| (Model::MetaLlama318BInstructV1_128k, "us")
|
||||
| (Model::MetaLlama3170BInstructV1, "us")
|
||||
| (Model::MetaLlama3170BInstructV1_128k, "us")
|
||||
| (Model::MetaLlama3211BInstructV1, "us")
|
||||
| (Model::MetaLlama3290BInstructV1, "us") => {
|
||||
Ok(format!("{}.{}", region_group, model_id))
|
||||
}
|
||||
|
||||
// Writer models only available in the US
|
||||
(Model::PalmyraWriterX4, "us") | (Model::PalmyraWriterX5, "us") => {
|
||||
// They have some goofiness
|
||||
Ok(format!("{}.{}", region_group, model_id))
|
||||
}
|
||||
// Models available in APAC
|
||||
(
|
||||
Model::Claude3_5Sonnet
|
||||
| Model::Claude3_5SonnetV2
|
||||
| Model::Claude3Haiku
|
||||
| Model::Claude3Sonnet,
|
||||
"apac",
|
||||
) => Ok(format!("{}.{}", region_group, model_id)),
|
||||
|
||||
// Any other combination is not supported
|
||||
_ => Ok(self.id().into()),
|
||||
_ => Ok(self.request_id().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,6 +545,10 @@ mod tests {
|
||||
Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1")?,
|
||||
"apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
);
|
||||
assert_eq!(
|
||||
Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2")?,
|
||||
"apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
);
|
||||
assert_eq!(
|
||||
Model::AmazonNovaLite.cross_region_inference_id("ap-south-1")?,
|
||||
"apac.amazon.nova-lite-v1:0"
|
||||
@@ -490,7 +575,11 @@ mod tests {
|
||||
// Test Meta models
|
||||
assert_eq!(
|
||||
Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?,
|
||||
"us.meta.llama3-70b-instruct-v1:0"
|
||||
"meta.llama3-70b-instruct-v1:0"
|
||||
);
|
||||
assert_eq!(
|
||||
Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1")?,
|
||||
"us.meta.llama3-1-70b-instruct-v1:0"
|
||||
);
|
||||
assert_eq!(
|
||||
Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?,
|
||||
@@ -563,4 +652,39 @@ mod tests {
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ pub struct ChannelBuffer {
|
||||
pub enum ChannelBufferEvent {
|
||||
CollaboratorsChanged,
|
||||
Disconnected,
|
||||
Connected,
|
||||
BufferEdited,
|
||||
ChannelChanged,
|
||||
}
|
||||
@@ -103,6 +104,17 @@ impl ChannelBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connected(&mut self, cx: &mut Context<Self>) {
|
||||
self.connected = true;
|
||||
if self.subscription.is_none() {
|
||||
let Ok(subscription) = self.client.subscribe_to_entity(self.channel_id.0) else {
|
||||
return;
|
||||
};
|
||||
self.subscription = Some(subscription.set_entity(&cx.entity(), &mut cx.to_async()));
|
||||
cx.emit(ChannelBufferEvent::Connected);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remote_id(&self, cx: &App) -> BufferId {
|
||||
self.buffer.read(cx).remote_id()
|
||||
}
|
||||
|
||||
@@ -972,6 +972,7 @@ impl ChannelStore {
|
||||
.log_err();
|
||||
|
||||
if let Some(operations) = operations {
|
||||
channel_buffer.connected(cx);
|
||||
let client = this.client.clone();
|
||||
cx.background_spawn(async move {
|
||||
let operations = operations.await;
|
||||
@@ -1012,8 +1013,8 @@ impl ChannelStore {
|
||||
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(cx, |this, cx| {
|
||||
for (_, buffer) in this.opened_buffers.drain() {
|
||||
if let OpenEntityHandle::Open(buffer) = buffer {
|
||||
for (_, buffer) in &this.opened_buffers {
|
||||
if let OpenEntityHandle::Open(buffer) = &buffer {
|
||||
if let Some(buffer) = buffer.upgrade() {
|
||||
buffer.update(cx, |buffer, cx| buffer.disconnect(cx));
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ We run two instances of collab:
|
||||
|
||||
Both of these run on the Kubernetes cluster hosted in Digital Ocean.
|
||||
|
||||
Deployment is triggered by pushing to the `collab-staging` (or `collab-production`) tag in Github. The best way to do this is:
|
||||
Deployment is triggered by pushing to the `collab-staging` (or `collab-production`) tag in GitHub. The best way to do this is:
|
||||
|
||||
- `./script/deploy-collab staging`
|
||||
- `./script/deploy-collab production`
|
||||
|
||||
@@ -219,12 +219,19 @@ struct BillingSubscriptionJson {
|
||||
id: BillingSubscriptionId,
|
||||
name: String,
|
||||
status: StripeSubscriptionStatus,
|
||||
period: Option<BillingSubscriptionPeriodJson>,
|
||||
trial_end_at: Option<String>,
|
||||
cancel_at: Option<String>,
|
||||
/// Whether this subscription can be canceled.
|
||||
is_cancelable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BillingSubscriptionPeriodJson {
|
||||
start_at: String,
|
||||
end_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListBillingSubscriptionsResponse {
|
||||
subscriptions: Vec<BillingSubscriptionJson>,
|
||||
@@ -254,6 +261,15 @@ async fn list_billing_subscriptions(
|
||||
None => "Zed LLM Usage".to_string(),
|
||||
},
|
||||
status: subscription.stripe_subscription_status,
|
||||
period: maybe!({
|
||||
let start_at = subscription.current_period_start_at()?;
|
||||
let end_at = subscription.current_period_end_at()?;
|
||||
|
||||
Some(BillingSubscriptionPeriodJson {
|
||||
start_at: start_at.to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
end_at: end_at.to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
})
|
||||
}),
|
||||
trial_end_at: if subscription.kind == Some(SubscriptionKind::ZedProTrial) {
|
||||
maybe!({
|
||||
let end_at = subscription.stripe_current_period_end?;
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn get_extensions(
|
||||
params.filter.as_deref(),
|
||||
provides_filter.as_ref(),
|
||||
params.max_schema_version,
|
||||
500,
|
||||
1_000,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -66,6 +66,87 @@ impl Database {
|
||||
.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`.
|
||||
pub async fn delete_stale_servers(
|
||||
&self,
|
||||
@@ -86,7 +167,7 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stale_server_ids(
|
||||
pub async fn stale_server_ids(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
|
||||
@@ -433,6 +433,16 @@ impl Server {
|
||||
tracing::info!("waiting for cleanup timeout");
|
||||
timeout.await;
|
||||
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
|
||||
.db
|
||||
.stale_server_resource_ids(&app_state.config.zed_environment, server_id)
|
||||
@@ -554,6 +564,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
|
||||
.db
|
||||
.delete_stale_servers(&app_state.config.zed_environment, server_id)
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use stripe::{
|
||||
CancellationDetails, CancellationDetailsReason, CheckoutSession, CheckoutSessionMode,
|
||||
CheckoutSessionPaymentMethodCollection, CreateCheckoutSession, CreateCheckoutSessionLineItems,
|
||||
@@ -213,9 +213,18 @@ impl StripeClient for RealStripeClient {
|
||||
}
|
||||
|
||||
async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
|
||||
#[derive(Deserialize)]
|
||||
struct StripeMeterEvent {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
let identifier = params.identifier;
|
||||
match self.client.post_form("/billing/meter_events", params).await {
|
||||
Ok(event) => Ok(event),
|
||||
match self
|
||||
.client
|
||||
.post_form::<StripeMeterEvent, _>("/billing/meter_events", params)
|
||||
.await
|
||||
{
|
||||
Ok(_event) => Ok(()),
|
||||
Err(stripe::StripeError::Stripe(error)) => {
|
||||
if error.http_status == 400
|
||||
&& error
|
||||
@@ -228,7 +237,7 @@ impl StripeClient for RealStripeClient {
|
||||
Err(anyhow!(stripe::StripeError::Stripe(error)))
|
||||
}
|
||||
}
|
||||
Err(error) => Err(anyhow!(error)),
|
||||
Err(error) => Err(anyhow!("failed to create meter event: {error:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -354,6 +354,10 @@ impl ChannelView {
|
||||
editor.set_read_only(true);
|
||||
cx.notify();
|
||||
}),
|
||||
ChannelBufferEvent::Connected => self.editor.update(cx, |editor, cx| {
|
||||
editor.set_read_only(false);
|
||||
cx.notify();
|
||||
}),
|
||||
ChannelBufferEvent::ChannelChanged => {
|
||||
self.editor.update(cx, |_, cx| {
|
||||
cx.emit(editor::EditorEvent::TitleChanged);
|
||||
|
||||
@@ -12,7 +12,7 @@ use language::{
|
||||
Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset,
|
||||
language_settings::SoftWrap,
|
||||
};
|
||||
use project::{Completion, CompletionSource, search::SearchQuery};
|
||||
use project::{Completion, CompletionResponse, CompletionSource, search::SearchQuery};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -64,9 +64,9 @@ impl CompletionProvider for MessageEditorCompletionProvider {
|
||||
_: editor::CompletionContext,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Option<Vec<Completion>>>> {
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
let Some(handle) = self.0.upgrade() else {
|
||||
return Task::ready(Ok(None));
|
||||
return Task::ready(Ok(Vec::new()));
|
||||
};
|
||||
handle.update(cx, |message_editor, cx| {
|
||||
message_editor.completions(buffer, buffer_position, cx)
|
||||
@@ -89,6 +89,7 @@ impl CompletionProvider for MessageEditorCompletionProvider {
|
||||
_position: language::Anchor,
|
||||
text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
_cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
text == "@"
|
||||
@@ -248,22 +249,21 @@ impl MessageEditor {
|
||||
buffer: &Entity<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Option<Vec<Completion>>>> {
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
if let Some((start_anchor, query, candidates)) =
|
||||
self.collect_mention_candidates(buffer, end_anchor, cx)
|
||||
{
|
||||
if !candidates.is_empty() {
|
||||
return cx.spawn(async move |_, cx| {
|
||||
Ok(Some(
|
||||
Self::resolve_completions_for_candidates(
|
||||
&cx,
|
||||
query.as_str(),
|
||||
&candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_mention,
|
||||
)
|
||||
.await,
|
||||
))
|
||||
let completion_response = Self::resolve_completions_for_candidates(
|
||||
&cx,
|
||||
query.as_str(),
|
||||
&candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_mention,
|
||||
)
|
||||
.await;
|
||||
Ok(vec![completion_response])
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -273,21 +273,23 @@ impl MessageEditor {
|
||||
{
|
||||
if !candidates.is_empty() {
|
||||
return cx.spawn(async move |_, cx| {
|
||||
Ok(Some(
|
||||
Self::resolve_completions_for_candidates(
|
||||
&cx,
|
||||
query.as_str(),
|
||||
candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_emoji,
|
||||
)
|
||||
.await,
|
||||
))
|
||||
let completion_response = Self::resolve_completions_for_candidates(
|
||||
&cx,
|
||||
query.as_str(),
|
||||
candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_emoji,
|
||||
)
|
||||
.await;
|
||||
Ok(vec![completion_response])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Task::ready(Ok(Some(Vec::new())))
|
||||
Task::ready(Ok(vec![CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
is_incomplete: false,
|
||||
}]))
|
||||
}
|
||||
|
||||
async fn resolve_completions_for_candidates(
|
||||
@@ -296,18 +298,19 @@ impl MessageEditor {
|
||||
candidates: &[StringMatchCandidate],
|
||||
range: Range<Anchor>,
|
||||
completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
|
||||
) -> Vec<Completion> {
|
||||
) -> CompletionResponse {
|
||||
const LIMIT: usize = 10;
|
||||
let matches = fuzzy::match_strings(
|
||||
candidates,
|
||||
query,
|
||||
true,
|
||||
10,
|
||||
LIMIT,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
matches
|
||||
let completions = matches
|
||||
.into_iter()
|
||||
.map(|mat| {
|
||||
let (new_text, label) = completion_fn(&mat);
|
||||
@@ -322,7 +325,12 @@ impl MessageEditor {
|
||||
source: CompletionSource::Custom,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
CompletionResponse {
|
||||
is_incomplete: completions.len() >= LIMIT,
|
||||
completions,
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
|
||||
|
||||
@@ -333,24 +333,6 @@ pub async fn download_adapter_from_github(
|
||||
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)]
|
||||
pub trait DebugAdapter: 'static + Send + Sync {
|
||||
fn name(&self) -> DebugAdapterName;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::*;
|
||||
use anyhow::Context as _;
|
||||
use dap::adapters::latest_github_release;
|
||||
use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
|
||||
use gpui::{AsyncApp, SharedString};
|
||||
use gpui::{AppContext, AsyncApp, SharedString};
|
||||
use json_dotpath::DotPaths;
|
||||
use language::{LanguageName, Toolchain};
|
||||
use serde_json::Value;
|
||||
@@ -21,12 +22,13 @@ pub(crate) struct PythonDebugAdapter {
|
||||
|
||||
impl PythonDebugAdapter {
|
||||
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_PATH: &'static str = "src/debugpy/adapter";
|
||||
const LANGUAGE_NAME: &'static str = "Python";
|
||||
|
||||
async fn generate_debugpy_arguments(
|
||||
&self,
|
||||
host: &Ipv4Addr,
|
||||
port: u16,
|
||||
user_installed_path: Option<&Path>,
|
||||
@@ -54,7 +56,7 @@ impl PythonDebugAdapter {
|
||||
format!("--port={}", port),
|
||||
])
|
||||
} 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 debugpy_dir =
|
||||
@@ -107,22 +109,21 @@ impl PythonDebugAdapter {
|
||||
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(
|
||||
&self,
|
||||
adapter_name: DebugAdapterName,
|
||||
version: AdapterVersion,
|
||||
delegate: &Arc<dyn DapDelegate>,
|
||||
delegate: Arc<dyn DapDelegate>,
|
||||
) -> Result<()> {
|
||||
let version_path = adapters::download_adapter_from_github(
|
||||
self.name(),
|
||||
adapter_name,
|
||||
version,
|
||||
adapters::DownloadedFileType::Zip,
|
||||
adapters::DownloadedFileType::GzipTar,
|
||||
delegate.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// only needed when you install the latest version for the first time
|
||||
if let Some(debugpy_dir) =
|
||||
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")?;
|
||||
log::debug!("Using Python executable: {}", python_command);
|
||||
|
||||
let arguments = self
|
||||
.generate_debugpy_arguments(
|
||||
&host,
|
||||
port,
|
||||
user_installed_path.as_deref(),
|
||||
installed_in_venv,
|
||||
)
|
||||
.await?;
|
||||
let arguments = Self::generate_debugpy_arguments(
|
||||
&host,
|
||||
port,
|
||||
user_installed_path.as_deref(),
|
||||
installed_in_venv,
|
||||
)
|
||||
.await?;
|
||||
|
||||
log::debug!(
|
||||
"Starting debugpy adapter with command: {} {}",
|
||||
@@ -204,7 +204,7 @@ impl PythonDebugAdapter {
|
||||
#[async_trait(?Send)]
|
||||
impl DebugAdapter for PythonDebugAdapter {
|
||||
fn name(&self) -> DebugAdapterName {
|
||||
DebugAdapterName(Self::ADAPTER_NAME.into())
|
||||
Self::DEBUG_ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn adapter_language_name(&self) -> Option<LanguageName> {
|
||||
@@ -635,7 +635,9 @@ impl DebugAdapter for PythonDebugAdapter {
|
||||
if self.checked.set(()).is_ok() {
|
||||
delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -651,20 +671,18 @@ mod tests {
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_debugpy_install_path_cases() {
|
||||
let adapter = PythonDebugAdapter::default();
|
||||
let host = Ipv4Addr::new(127, 0, 0, 1);
|
||||
let port = 5678;
|
||||
|
||||
// Case 1: User-defined debugpy path (highest precedence)
|
||||
let user_path = PathBuf::from("/custom/path/to/debugpy");
|
||||
let user_args = adapter
|
||||
.generate_debugpy_arguments(&host, port, Some(&user_path), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let user_args =
|
||||
PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
|
||||
let venv_args = adapter
|
||||
.generate_debugpy_arguments(&host, port, None, true)
|
||||
let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -679,9 +697,4 @@ mod tests {
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ project.workspace = true
|
||||
rpc.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# serde_json_lenient.workspace = true
|
||||
settings.workspace = true
|
||||
shlex.workspace = true
|
||||
sysinfo.workspace = true
|
||||
|
||||
@@ -3,11 +3,12 @@ use crate::session::DebugSession;
|
||||
use crate::session::running::RunningState;
|
||||
use crate::{
|
||||
ClearAllBreakpoints, Continue, Detach, FocusBreakpointList, FocusConsole, FocusFrames,
|
||||
FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables, Pause, Restart,
|
||||
ShowStackTrace, StepBack, StepInto, StepOut, StepOver, Stop, ToggleIgnoreBreakpoints,
|
||||
ToggleSessionPicker, ToggleThreadPicker, persistence, spawn_task_or_modal,
|
||||
FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables, NewProcessModal,
|
||||
NewProcessMode, Pause, Restart, ShowStackTrace, StepBack, StepInto, StepOut, StepOver, Stop,
|
||||
ToggleExpandItem, ToggleIgnoreBreakpoints, ToggleSessionPicker, ToggleThreadPicker,
|
||||
persistence, spawn_task_or_modal,
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use anyhow::Result;
|
||||
use command_palette_hooks::CommandPaletteFilter;
|
||||
use dap::StartDebuggingRequestArguments;
|
||||
use dap::adapters::DebugAdapterName;
|
||||
@@ -24,7 +25,7 @@ use gpui::{
|
||||
|
||||
use language::Buffer;
|
||||
use project::debugger::session::{Session, SessionStateEvent};
|
||||
use project::{Fs, ProjectPath, WorktreeId};
|
||||
use project::{Fs, WorktreeId};
|
||||
use project::{Project, debugger::session::ThreadStatus};
|
||||
use rpc::proto::{self};
|
||||
use settings::Settings;
|
||||
@@ -69,6 +70,7 @@ pub struct DebugPanel {
|
||||
pub(crate) thread_picker_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
pub(crate) session_picker_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
fs: Arc<dyn Fs>,
|
||||
is_zoomed: bool,
|
||||
_subscriptions: [Subscription; 1],
|
||||
}
|
||||
|
||||
@@ -103,6 +105,7 @@ impl DebugPanel {
|
||||
fs: workspace.app_state().fs.clone(),
|
||||
thread_picker_menu_handle,
|
||||
session_picker_menu_handle,
|
||||
is_zoomed: false,
|
||||
_subscriptions: [focus_subscription],
|
||||
debug_scenario_scheduled_last: true,
|
||||
}
|
||||
@@ -334,10 +337,17 @@ impl DebugPanel {
|
||||
let Some(task_inventory) = task_store.read(cx).task_inventory() else {
|
||||
return;
|
||||
};
|
||||
let workspace = self.workspace.clone();
|
||||
let Some(scenario) = task_inventory.read(cx).last_scheduled_scenario().cloned() else {
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
NewProcessModal::show(workspace, window, NewProcessMode::Launch, None, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
return;
|
||||
};
|
||||
let workspace = self.workspace.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let task_contexts = workspace
|
||||
@@ -942,68 +952,69 @@ impl DebugPanel {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn save_scenario(
|
||||
&self,
|
||||
scenario: &DebugScenario,
|
||||
worktree_id: WorktreeId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<ProjectPath>> {
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let Some(mut path) = workspace.absolute_path_of_worktree(worktree_id, cx) else {
|
||||
return Task::ready(Err(anyhow!("Couldn't get worktree path")));
|
||||
};
|
||||
// TODO: restore once we have proper comment preserving file edits
|
||||
// pub(crate) fn save_scenario(
|
||||
// &self,
|
||||
// scenario: &DebugScenario,
|
||||
// worktree_id: WorktreeId,
|
||||
// window: &mut Window,
|
||||
// cx: &mut App,
|
||||
// ) -> Task<Result<ProjectPath>> {
|
||||
// self.workspace
|
||||
// .update(cx, |workspace, cx| {
|
||||
// let Some(mut path) = workspace.absolute_path_of_worktree(worktree_id, cx) else {
|
||||
// return Task::ready(Err(anyhow!("Couldn't get worktree path")));
|
||||
// };
|
||||
|
||||
let serialized_scenario = serde_json::to_value(scenario);
|
||||
// let serialized_scenario = serde_json::to_value(scenario);
|
||||
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
let serialized_scenario = serialized_scenario?;
|
||||
let fs =
|
||||
workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?;
|
||||
// cx.spawn_in(window, async move |workspace, cx| {
|
||||
// let serialized_scenario = serialized_scenario?;
|
||||
// let fs =
|
||||
// workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?;
|
||||
|
||||
path.push(paths::local_settings_folder_relative_path());
|
||||
if !fs.is_dir(path.as_path()).await {
|
||||
fs.create_dir(path.as_path()).await?;
|
||||
}
|
||||
path.pop();
|
||||
// path.push(paths::local_settings_folder_relative_path());
|
||||
// if !fs.is_dir(path.as_path()).await {
|
||||
// fs.create_dir(path.as_path()).await?;
|
||||
// }
|
||||
// path.pop();
|
||||
|
||||
path.push(paths::local_debug_file_relative_path());
|
||||
let path = path.as_path();
|
||||
// path.push(paths::local_debug_file_relative_path());
|
||||
// let path = path.as_path();
|
||||
|
||||
if !fs.is_file(path).await {
|
||||
let content =
|
||||
serde_json::to_string_pretty(&serde_json::Value::Array(vec![
|
||||
serialized_scenario,
|
||||
]))?;
|
||||
// if !fs.is_file(path).await {
|
||||
// fs.create_file(path, Default::default()).await?;
|
||||
// fs.write(
|
||||
// path,
|
||||
// initial_local_debug_tasks_content().to_string().as_bytes(),
|
||||
// )
|
||||
// .await?;
|
||||
// }
|
||||
|
||||
fs.create_file(path, Default::default()).await?;
|
||||
fs.save(path, &content.into(), Default::default()).await?;
|
||||
} else {
|
||||
let content = fs.load(path).await?;
|
||||
let mut values = serde_json::from_str::<Vec<serde_json::Value>>(&content)?;
|
||||
values.push(serialized_scenario);
|
||||
fs.save(
|
||||
path,
|
||||
&serde_json::to_string_pretty(&values).map(Into::into)?,
|
||||
Default::default(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// let content = fs.load(path).await?;
|
||||
// let mut values =
|
||||
// serde_json_lenient::from_str::<Vec<serde_json::Value>>(&content)?;
|
||||
// values.push(serialized_scenario);
|
||||
// fs.save(
|
||||
// path,
|
||||
// &serde_json_lenient::to_string_pretty(&values).map(Into::into)?,
|
||||
// Default::default(),
|
||||
// )
|
||||
// .await?;
|
||||
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace
|
||||
.project()
|
||||
.read(cx)
|
||||
.project_path_for_absolute_path(&path, cx)
|
||||
.context(
|
||||
"Couldn't get project path for .zed/debug.json in active worktree",
|
||||
)
|
||||
})?
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|err| Task::ready(Err(err)))
|
||||
}
|
||||
// workspace.update(cx, |workspace, cx| {
|
||||
// workspace
|
||||
// .project()
|
||||
// .read(cx)
|
||||
// .project_path_for_absolute_path(&path, cx)
|
||||
// .context(
|
||||
// "Couldn't get project path for .zed/debug.json in active worktree",
|
||||
// )
|
||||
// })?
|
||||
// })
|
||||
// })
|
||||
// .unwrap_or_else(|err| Task::ready(Err(err)))
|
||||
// }
|
||||
|
||||
pub(crate) fn toggle_thread_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.thread_picker_menu_handle.toggle(window, cx);
|
||||
@@ -1012,6 +1023,22 @@ impl DebugPanel {
|
||||
pub(crate) fn toggle_session_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.session_picker_menu_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
fn toggle_zoom(
|
||||
&mut self,
|
||||
_: &workspace::ToggleZoom,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.is_zoomed {
|
||||
cx.emit(PanelEvent::ZoomOut);
|
||||
} else {
|
||||
if !self.focus_handle(cx).contains_focused(window, cx) {
|
||||
cx.focus_self(window);
|
||||
}
|
||||
cx.emit(PanelEvent::ZoomIn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_session_inner(
|
||||
@@ -1167,6 +1194,15 @@ impl Panel for DebugPanel {
|
||||
}
|
||||
|
||||
fn set_active(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) {}
|
||||
|
||||
fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
|
||||
self.is_zoomed
|
||||
}
|
||||
|
||||
fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.is_zoomed = zoomed;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DebugPanel {
|
||||
@@ -1307,6 +1343,23 @@ impl Render for DebugPanel {
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.on_action(cx.listener(Self::toggle_zoom))
|
||||
.on_action(cx.listener(|panel, _: &ToggleExpandItem, _, cx| {
|
||||
let Some(session) = panel.active_session() else {
|
||||
return;
|
||||
};
|
||||
let active_pane = session
|
||||
.read(cx)
|
||||
.running_state()
|
||||
.read(cx)
|
||||
.active_pane()
|
||||
.clone();
|
||||
active_pane.update(cx, |pane, cx| {
|
||||
let is_zoomed = pane.is_zoomed();
|
||||
pane.set_zoomed(!is_zoomed, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}))
|
||||
.when(self.active_session.is_some(), |this| {
|
||||
this.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
@@ -1410,4 +1463,10 @@ impl workspace::DebuggerProvider for DebuggerProvider {
|
||||
fn debug_scenario_scheduled_last(&self, cx: &App) -> bool {
|
||||
self.0.read(cx).debug_scenario_scheduled_last
|
||||
}
|
||||
|
||||
fn active_thread_state(&self, cx: &App) -> Option<ThreadStatus> {
|
||||
let session = self.0.read(cx).active_session()?;
|
||||
let thread = session.read(cx).running_state().read(cx).thread_id()?;
|
||||
session.read(cx).session(cx).read(cx).thread_state(thread)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use debugger_panel::{DebugPanel, ToggleFocus};
|
||||
use editor::Editor;
|
||||
use feature_flags::{DebuggerFeatureFlag, FeatureFlagViewExt};
|
||||
use gpui::{App, EntityInputHandler, actions};
|
||||
use new_session_modal::{NewSessionModal, NewSessionMode};
|
||||
use new_process_modal::{NewProcessModal, NewProcessMode};
|
||||
use project::debugger::{self, breakpoint_store::SourceBreakpoint};
|
||||
use session::DebugSession;
|
||||
use settings::Settings;
|
||||
@@ -15,7 +15,7 @@ use workspace::{ItemHandle, ShutdownDebugAdapters, Workspace};
|
||||
pub mod attach_modal;
|
||||
pub mod debugger_panel;
|
||||
mod dropdown_menus;
|
||||
mod new_session_modal;
|
||||
mod new_process_modal;
|
||||
mod persistence;
|
||||
pub(crate) mod session;
|
||||
mod stack_trace_view;
|
||||
@@ -49,6 +49,7 @@ actions!(
|
||||
ToggleThreadPicker,
|
||||
ToggleSessionPicker,
|
||||
RerunLastSession,
|
||||
ToggleExpandItem,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -210,7 +211,7 @@ pub fn init(cx: &mut App) {
|
||||
},
|
||||
)
|
||||
.register_action(|workspace: &mut Workspace, _: &Start, window, cx| {
|
||||
NewSessionModal::show(workspace, window, NewSessionMode::Launch, None, cx);
|
||||
NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
|
||||
})
|
||||
.register_action(
|
||||
|workspace: &mut Workspace, _: &RerunLastSession, window, cx| {
|
||||
@@ -352,7 +353,7 @@ fn spawn_task_or_modal(
|
||||
.detach_and_log_err(cx)
|
||||
}
|
||||
Spawn::ViaModal { reveal_target } => {
|
||||
NewSessionModal::show(workspace, window, NewSessionMode::Task, *reveal_target, cx);
|
||||
NewProcessModal::show(workspace, window, NewProcessMode::Task, *reveal_target, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@ pub mod variable_list;
|
||||
use std::{any::Any, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use crate::{
|
||||
new_session_modal::resolve_path,
|
||||
ToggleExpandItem,
|
||||
new_process_modal::resolve_path,
|
||||
persistence::{self, DebuggerPaneItem, SerializedLayout},
|
||||
};
|
||||
|
||||
@@ -285,6 +286,7 @@ pub(crate) fn new_debugger_pane(
|
||||
&new_pane,
|
||||
item_id_to_move,
|
||||
new_pane.read(cx).active_item_index(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -347,6 +349,7 @@ pub(crate) fn new_debugger_pane(
|
||||
false
|
||||
}
|
||||
})));
|
||||
pane.set_can_toggle_zoom(false, cx);
|
||||
pane.display_nav_history_buttons(None);
|
||||
pane.set_custom_drop_handle(cx, custom_drop_handle);
|
||||
pane.set_should_display_tab_bar(|_, _| true);
|
||||
@@ -472,17 +475,19 @@ pub(crate) fn new_debugger_pane(
|
||||
},
|
||||
)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.on_click(cx.listener(move |pane, _, window, cx| {
|
||||
pane.toggle_zoom(&workspace::ToggleZoom, window, cx);
|
||||
.on_click(cx.listener(move |pane, _, _, cx| {
|
||||
let is_zoomed = pane.is_zoomed();
|
||||
pane.set_zoomed(!is_zoomed, cx);
|
||||
cx.notify();
|
||||
}))
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
let zoomed_text =
|
||||
if zoomed { "Zoom Out" } else { "Zoom In" };
|
||||
if zoomed { "Minimize" } else { "Expand" };
|
||||
Tooltip::for_action_in(
|
||||
zoomed_text,
|
||||
&workspace::ToggleZoom,
|
||||
&ToggleExpandItem,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
@@ -566,7 +571,7 @@ impl RunningState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn relativlize_paths(
|
||||
pub(crate) fn relativize_paths(
|
||||
key: Option<&str>,
|
||||
config: &mut serde_json::Value,
|
||||
context: &TaskContext,
|
||||
@@ -574,12 +579,12 @@ impl RunningState {
|
||||
match config {
|
||||
serde_json::Value::Object(obj) => {
|
||||
obj.iter_mut()
|
||||
.for_each(|(key, value)| Self::relativlize_paths(Some(key), value, context));
|
||||
.for_each(|(key, value)| Self::relativize_paths(Some(key), value, context));
|
||||
}
|
||||
serde_json::Value::Array(array) => {
|
||||
array
|
||||
.iter_mut()
|
||||
.for_each(|value| Self::relativlize_paths(None, value, context));
|
||||
.for_each(|value| Self::relativize_paths(None, value, context));
|
||||
}
|
||||
serde_json::Value::String(s) if key == Some("program") || key == Some("cwd") => {
|
||||
// Some built-in zed tasks wrap their arguments in quotes as they might contain spaces.
|
||||
@@ -806,7 +811,7 @@ impl RunningState {
|
||||
mut config,
|
||||
tcp_connection,
|
||||
} = scenario;
|
||||
Self::relativlize_paths(None, &mut config, &task_context);
|
||||
Self::relativize_paths(None, &mut config, &task_context);
|
||||
Self::substitute_variables_in_config(&mut config, &task_context);
|
||||
|
||||
let request_type = dap_registry
|
||||
@@ -897,7 +902,6 @@ impl RunningState {
|
||||
weak_workspace,
|
||||
None,
|
||||
weak_project,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1051,15 +1055,7 @@ impl RunningState {
|
||||
let terminal = terminal_task.await?;
|
||||
|
||||
let terminal_view = cx.new_window_entity(|window, cx| {
|
||||
TerminalView::new(
|
||||
terminal.clone(),
|
||||
workspace,
|
||||
None,
|
||||
weak_project,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
TerminalView::new(terminal.clone(), workspace, None, weak_project, window, cx)
|
||||
})?;
|
||||
|
||||
running.update_in(cx, |running, window, cx| {
|
||||
@@ -1260,18 +1256,6 @@ impl RunningState {
|
||||
Event::Focus => {
|
||||
this.active_pane = source_pane.clone();
|
||||
}
|
||||
Event::ZoomIn => {
|
||||
source_pane.update(cx, |pane, cx| {
|
||||
pane.set_zoomed(true, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
Event::ZoomOut => {
|
||||
source_pane.update(cx, |pane, cx| {
|
||||
pane.set_zoomed(false, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use gpui::{
|
||||
use language::{Buffer, CodeLabel, ToOffset};
|
||||
use menu::Confirm;
|
||||
use project::{
|
||||
Completion,
|
||||
Completion, CompletionResponse,
|
||||
debugger::session::{CompletionsQuery, OutputToken, Session, SessionEvent},
|
||||
};
|
||||
use settings::Settings;
|
||||
@@ -262,9 +262,9 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
|
||||
_trigger: editor::CompletionContext,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Option<Vec<Completion>>>> {
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
let Some(console) = self.0.upgrade() else {
|
||||
return Task::ready(Ok(None));
|
||||
return Task::ready(Ok(Vec::new()));
|
||||
};
|
||||
|
||||
let support_completions = console
|
||||
@@ -309,6 +309,7 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
|
||||
_position: language::Anchor,
|
||||
_text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
_cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
true
|
||||
@@ -322,7 +323,7 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
buffer: &Entity<Buffer>,
|
||||
buffer_position: language::Anchor,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Option<Vec<Completion>>>> {
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
let (variables, string_matches) = console.update(cx, |console, cx| {
|
||||
let mut variables = HashMap::default();
|
||||
let mut string_matches = Vec::default();
|
||||
@@ -354,39 +355,43 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
let query = buffer.read(cx).text();
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
const LIMIT: usize = 10;
|
||||
let matches = fuzzy::match_strings(
|
||||
&string_matches,
|
||||
&query,
|
||||
true,
|
||||
10,
|
||||
LIMIT,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Some(
|
||||
matches
|
||||
.iter()
|
||||
.filter_map(|string_match| {
|
||||
let variable_value = variables.get(&string_match.string)?;
|
||||
let completions = matches
|
||||
.iter()
|
||||
.filter_map(|string_match| {
|
||||
let variable_value = variables.get(&string_match.string)?;
|
||||
|
||||
Some(project::Completion {
|
||||
replace_range: buffer_position..buffer_position,
|
||||
new_text: string_match.string.clone(),
|
||||
label: CodeLabel {
|
||||
filter_range: 0..string_match.string.len(),
|
||||
text: format!("{} {}", string_match.string, variable_value),
|
||||
runs: Vec::new(),
|
||||
},
|
||||
icon_path: None,
|
||||
documentation: None,
|
||||
confirm: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
insert_text_mode: None,
|
||||
})
|
||||
Some(project::Completion {
|
||||
replace_range: buffer_position..buffer_position,
|
||||
new_text: string_match.string.clone(),
|
||||
label: CodeLabel {
|
||||
filter_range: 0..string_match.string.len(),
|
||||
text: format!("{} {}", string_match.string, variable_value),
|
||||
runs: Vec::new(),
|
||||
},
|
||||
icon_path: None,
|
||||
documentation: None,
|
||||
confirm: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
insert_text_mode: None,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(vec![project::CompletionResponse {
|
||||
is_incomplete: completions.len() >= LIMIT,
|
||||
completions,
|
||||
}])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -396,7 +401,7 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
buffer: &Entity<Buffer>,
|
||||
buffer_position: language::Anchor,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Option<Vec<Completion>>>> {
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
let completion_task = console.update(cx, |console, cx| {
|
||||
console.session.update(cx, |state, cx| {
|
||||
let frame_id = console.stack_frame_list.read(cx).opened_stack_frame_id();
|
||||
@@ -411,53 +416,56 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
cx.background_executor().spawn(async move {
|
||||
let completions = completion_task.await?;
|
||||
|
||||
Ok(Some(
|
||||
completions
|
||||
.into_iter()
|
||||
.map(|completion| {
|
||||
let new_text = completion
|
||||
.text
|
||||
.as_ref()
|
||||
.unwrap_or(&completion.label)
|
||||
.to_owned();
|
||||
let buffer_text = snapshot.text();
|
||||
let buffer_bytes = buffer_text.as_bytes();
|
||||
let new_bytes = new_text.as_bytes();
|
||||
let completions = completions
|
||||
.into_iter()
|
||||
.map(|completion| {
|
||||
let new_text = completion
|
||||
.text
|
||||
.as_ref()
|
||||
.unwrap_or(&completion.label)
|
||||
.to_owned();
|
||||
let buffer_text = snapshot.text();
|
||||
let buffer_bytes = buffer_text.as_bytes();
|
||||
let new_bytes = new_text.as_bytes();
|
||||
|
||||
let mut prefix_len = 0;
|
||||
for i in (0..new_bytes.len()).rev() {
|
||||
if buffer_bytes.ends_with(&new_bytes[0..i]) {
|
||||
prefix_len = i;
|
||||
break;
|
||||
}
|
||||
let mut prefix_len = 0;
|
||||
for i in (0..new_bytes.len()).rev() {
|
||||
if buffer_bytes.ends_with(&new_bytes[0..i]) {
|
||||
prefix_len = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let buffer_offset = buffer_position.to_offset(&snapshot);
|
||||
let start = buffer_offset - prefix_len;
|
||||
let start = snapshot.clip_offset(start, Bias::Left);
|
||||
let start = snapshot.anchor_before(start);
|
||||
let replace_range = start..buffer_position;
|
||||
let buffer_offset = buffer_position.to_offset(&snapshot);
|
||||
let start = buffer_offset - prefix_len;
|
||||
let start = snapshot.clip_offset(start, Bias::Left);
|
||||
let start = snapshot.anchor_before(start);
|
||||
let replace_range = start..buffer_position;
|
||||
|
||||
project::Completion {
|
||||
replace_range,
|
||||
new_text,
|
||||
label: CodeLabel {
|
||||
filter_range: 0..completion.label.len(),
|
||||
text: completion.label,
|
||||
runs: Vec::new(),
|
||||
},
|
||||
icon_path: None,
|
||||
documentation: None,
|
||||
confirm: None,
|
||||
source: project::CompletionSource::BufferWord {
|
||||
word_range: buffer_position..language::Anchor::MAX,
|
||||
resolved: false,
|
||||
},
|
||||
insert_text_mode: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
project::Completion {
|
||||
replace_range,
|
||||
new_text,
|
||||
label: CodeLabel {
|
||||
filter_range: 0..completion.label.len(),
|
||||
text: completion.label,
|
||||
runs: Vec::new(),
|
||||
},
|
||||
icon_path: None,
|
||||
documentation: None,
|
||||
confirm: None,
|
||||
source: project::CompletionSource::BufferWord {
|
||||
word_range: buffer_position..language::Anchor::MAX,
|
||||
resolved: false,
|
||||
},
|
||||
insert_text_mode: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(vec![project::CompletionResponse {
|
||||
completions,
|
||||
is_incomplete: false,
|
||||
}])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ mod inline_values;
|
||||
#[cfg(test)]
|
||||
mod module_list;
|
||||
#[cfg(test)]
|
||||
mod new_session_modal;
|
||||
mod new_process_modal;
|
||||
#[cfg(test)]
|
||||
mod persistence;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use dap::DapRegistry;
|
||||
use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
|
||||
use project::{FakeFs, Fs, Project};
|
||||
use project::{FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use task::{DebugRequest, DebugScenario, LaunchRequest, TaskContext, VariableName, ZedDebugConfig};
|
||||
use util::path;
|
||||
|
||||
use crate::new_session_modal::NewSessionMode;
|
||||
// use crate::new_process_modal::NewProcessMode;
|
||||
use crate::tests::{init_test, init_test_workspace};
|
||||
|
||||
#[gpui::test]
|
||||
@@ -152,111 +152,111 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths(
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
// #[gpui::test]
|
||||
// async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
// init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
"main.rs": "fn main() {}"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// let fs = FakeFs::new(executor.clone());
|
||||
// fs.insert_tree(
|
||||
// path!("/project"),
|
||||
// json!({
|
||||
// "main.rs": "fn main() {}"
|
||||
// }),
|
||||
// )
|
||||
// .await;
|
||||
|
||||
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||
let workspace = init_test_workspace(&project, cx).await;
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
|
||||
// let workspace = init_test_workspace(&project, cx).await;
|
||||
// let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
|
||||
workspace
|
||||
.update(cx, |workspace, window, cx| {
|
||||
crate::new_session_modal::NewSessionModal::show(
|
||||
workspace,
|
||||
window,
|
||||
NewSessionMode::Launch,
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
// workspace
|
||||
// .update(cx, |workspace, window, cx| {
|
||||
// crate::new_process_modal::NewProcessModal::show(
|
||||
// workspace,
|
||||
// window,
|
||||
// NewProcessMode::Debug,
|
||||
// None,
|
||||
// cx,
|
||||
// );
|
||||
// })
|
||||
// .unwrap();
|
||||
|
||||
cx.run_until_parked();
|
||||
// cx.run_until_parked();
|
||||
|
||||
let modal = workspace
|
||||
.update(cx, |workspace, _, cx| {
|
||||
workspace.active_modal::<crate::new_session_modal::NewSessionModal>(cx)
|
||||
})
|
||||
.unwrap()
|
||||
.expect("Modal should be active");
|
||||
// let modal = workspace
|
||||
// .update(cx, |workspace, _, cx| {
|
||||
// workspace.active_modal::<crate::new_process_modal::NewProcessModal>(cx)
|
||||
// })
|
||||
// .unwrap()
|
||||
// .expect("Modal should be active");
|
||||
|
||||
modal.update_in(cx, |modal, window, cx| {
|
||||
modal.set_configure("/project/main", "/project", false, window, cx);
|
||||
modal.save_scenario(window, cx);
|
||||
});
|
||||
// modal.update_in(cx, |modal, window, cx| {
|
||||
// modal.set_configure("/project/main", "/project", false, window, cx);
|
||||
// modal.save_scenario(window, cx);
|
||||
// });
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
// cx.executor().run_until_parked();
|
||||
|
||||
let debug_json_content = fs
|
||||
.load(path!("/project/.zed/debug.json").as_ref())
|
||||
.await
|
||||
.expect("debug.json should exist");
|
||||
// let debug_json_content = fs
|
||||
// .load(path!("/project/.zed/debug.json").as_ref())
|
||||
// .await
|
||||
// .expect("debug.json should exist");
|
||||
|
||||
let expected_content = vec![
|
||||
"[",
|
||||
" {",
|
||||
r#" "adapter": "fake-adapter","#,
|
||||
r#" "label": "main (fake-adapter)","#,
|
||||
r#" "request": "launch","#,
|
||||
r#" "program": "/project/main","#,
|
||||
r#" "cwd": "/project","#,
|
||||
r#" "args": [],"#,
|
||||
r#" "env": {}"#,
|
||||
" }",
|
||||
"]",
|
||||
];
|
||||
// let expected_content = vec![
|
||||
// "[",
|
||||
// " {",
|
||||
// r#" "adapter": "fake-adapter","#,
|
||||
// r#" "label": "main (fake-adapter)","#,
|
||||
// r#" "request": "launch","#,
|
||||
// r#" "program": "/project/main","#,
|
||||
// r#" "cwd": "/project","#,
|
||||
// r#" "args": [],"#,
|
||||
// r#" "env": {}"#,
|
||||
// " }",
|
||||
// "]",
|
||||
// ];
|
||||
|
||||
let actual_lines: Vec<&str> = debug_json_content.lines().collect();
|
||||
pretty_assertions::assert_eq!(expected_content, actual_lines);
|
||||
// let actual_lines: Vec<&str> = debug_json_content.lines().collect();
|
||||
// pretty_assertions::assert_eq!(expected_content, actual_lines);
|
||||
|
||||
modal.update_in(cx, |modal, window, cx| {
|
||||
modal.set_configure("/project/other", "/project", true, window, cx);
|
||||
modal.save_scenario(window, cx);
|
||||
});
|
||||
// modal.update_in(cx, |modal, window, cx| {
|
||||
// modal.set_configure("/project/other", "/project", true, window, cx);
|
||||
// modal.save_scenario(window, cx);
|
||||
// });
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
// cx.executor().run_until_parked();
|
||||
|
||||
let debug_json_content = fs
|
||||
.load(path!("/project/.zed/debug.json").as_ref())
|
||||
.await
|
||||
.expect("debug.json should exist after second save");
|
||||
// let debug_json_content = fs
|
||||
// .load(path!("/project/.zed/debug.json").as_ref())
|
||||
// .await
|
||||
// .expect("debug.json should exist after second save");
|
||||
|
||||
let expected_content = vec![
|
||||
"[",
|
||||
" {",
|
||||
r#" "adapter": "fake-adapter","#,
|
||||
r#" "label": "main (fake-adapter)","#,
|
||||
r#" "request": "launch","#,
|
||||
r#" "program": "/project/main","#,
|
||||
r#" "cwd": "/project","#,
|
||||
r#" "args": [],"#,
|
||||
r#" "env": {}"#,
|
||||
" },",
|
||||
" {",
|
||||
r#" "adapter": "fake-adapter","#,
|
||||
r#" "label": "other (fake-adapter)","#,
|
||||
r#" "request": "launch","#,
|
||||
r#" "program": "/project/other","#,
|
||||
r#" "cwd": "/project","#,
|
||||
r#" "args": [],"#,
|
||||
r#" "env": {}"#,
|
||||
" }",
|
||||
"]",
|
||||
];
|
||||
// let expected_content = vec![
|
||||
// "[",
|
||||
// " {",
|
||||
// r#" "adapter": "fake-adapter","#,
|
||||
// r#" "label": "main (fake-adapter)","#,
|
||||
// r#" "request": "launch","#,
|
||||
// r#" "program": "/project/main","#,
|
||||
// r#" "cwd": "/project","#,
|
||||
// r#" "args": [],"#,
|
||||
// r#" "env": {}"#,
|
||||
// " },",
|
||||
// " {",
|
||||
// r#" "adapter": "fake-adapter","#,
|
||||
// r#" "label": "other (fake-adapter)","#,
|
||||
// r#" "request": "launch","#,
|
||||
// r#" "program": "/project/other","#,
|
||||
// r#" "cwd": "/project","#,
|
||||
// r#" "args": [],"#,
|
||||
// r#" "env": {}"#,
|
||||
// " }",
|
||||
// "]",
|
||||
// ];
|
||||
|
||||
let actual_lines: Vec<&str> = debug_json_content.lines().collect();
|
||||
pretty_assertions::assert_eq!(expected_content, actual_lines);
|
||||
}
|
||||
// let actual_lines: Vec<&str> = debug_json_content.lines().collect();
|
||||
// pretty_assertions::assert_eq!(expected_content, actual_lines);
|
||||
// }
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppContext) {
|
||||
@@ -1,9 +1,8 @@
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{
|
||||
AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString,
|
||||
Size, StrikethroughStyle, StyledText, UniformListScrollHandle, div, px, uniform_list,
|
||||
Size, StrikethroughStyle, StyledText, Task, UniformListScrollHandle, div, px, uniform_list,
|
||||
};
|
||||
use gpui::{AsyncWindowContext, WeakEntity};
|
||||
use itertools::Itertools;
|
||||
use language::CodeLabel;
|
||||
use language::{Buffer, LanguageName, LanguageRegistry};
|
||||
@@ -18,6 +17,7 @@ use task::TaskContext;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
cmp::{Reverse, min},
|
||||
@@ -47,15 +47,10 @@ pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
|
||||
// Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
|
||||
// documentation not yet being parsed.
|
||||
//
|
||||
// The size of the cache is set to the number of items fetched around the current selection plus one
|
||||
// for the current selection and another to avoid cases where and adjacent selection exits the
|
||||
// cache. The only current benefit of a larger cache would be doing less markdown parsing when the
|
||||
// selection revisits items.
|
||||
//
|
||||
// One future benefit of a larger cache would be reducing flicker on backspace. This would require
|
||||
// not recreating the menu on every change, by not re-querying the language server when
|
||||
// `is_incomplete = false`.
|
||||
const MARKDOWN_CACHE_MAX_SIZE: usize = MARKDOWN_CACHE_BEFORE_ITEMS + MARKDOWN_CACHE_AFTER_ITEMS + 2;
|
||||
// The size of the cache is set to 16, which is roughly 3 times more than the number of items
|
||||
// fetched around the current selection. This way documentation is more often ready for render when
|
||||
// revisiting previous entries, such as when pressing backspace.
|
||||
const MARKDOWN_CACHE_MAX_SIZE: usize = 16;
|
||||
const MARKDOWN_CACHE_BEFORE_ITEMS: usize = 2;
|
||||
const MARKDOWN_CACHE_AFTER_ITEMS: usize = 2;
|
||||
|
||||
@@ -197,34 +192,64 @@ pub enum ContextMenuOrigin {
|
||||
QuickActionBar,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompletionsMenu {
|
||||
pub id: CompletionId,
|
||||
pub source: CompletionsMenuSource,
|
||||
sort_completions: bool,
|
||||
pub initial_position: Anchor,
|
||||
pub initial_query: Option<Arc<String>>,
|
||||
pub is_incomplete: bool,
|
||||
pub buffer: Entity<Buffer>,
|
||||
pub completions: Rc<RefCell<Box<[Completion]>>>,
|
||||
match_candidates: Rc<[StringMatchCandidate]>,
|
||||
pub entries: Rc<RefCell<Vec<StringMatch>>>,
|
||||
match_candidates: Arc<[StringMatchCandidate]>,
|
||||
pub entries: Rc<RefCell<Box<[StringMatch]>>>,
|
||||
pub selected_item: usize,
|
||||
filter_task: Task<()>,
|
||||
cancel_filter: Arc<AtomicBool>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
resolve_completions: bool,
|
||||
show_completion_documentation: bool,
|
||||
pub(super) ignore_completion_provider: bool,
|
||||
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
|
||||
markdown_cache: Rc<RefCell<VecDeque<(usize, Entity<Markdown>)>>>,
|
||||
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
language: Option<LanguageName>,
|
||||
snippet_sort_order: SnippetSortOrder,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum MarkdownCacheKey {
|
||||
ForCandidate {
|
||||
candidate_id: usize,
|
||||
},
|
||||
ForCompletionMatch {
|
||||
new_text: String,
|
||||
markdown_source: SharedString,
|
||||
},
|
||||
}
|
||||
|
||||
#[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.
|
||||
impl Drop for CompletionsMenu {
|
||||
fn drop(&mut self) {
|
||||
self.cancel_filter.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletionsMenu {
|
||||
pub fn new(
|
||||
id: CompletionId,
|
||||
source: CompletionsMenuSource,
|
||||
sort_completions: bool,
|
||||
show_completion_documentation: bool,
|
||||
ignore_completion_provider: bool,
|
||||
initial_position: Anchor,
|
||||
initial_query: Option<Arc<String>>,
|
||||
is_incomplete: bool,
|
||||
buffer: Entity<Buffer>,
|
||||
completions: Box<[Completion]>,
|
||||
snippet_sort_order: SnippetSortOrder,
|
||||
@@ -240,19 +265,23 @@ impl CompletionsMenu {
|
||||
|
||||
let completions_menu = Self {
|
||||
id,
|
||||
source,
|
||||
sort_completions,
|
||||
initial_position,
|
||||
initial_query,
|
||||
is_incomplete,
|
||||
buffer,
|
||||
show_completion_documentation,
|
||||
ignore_completion_provider,
|
||||
completions: RefCell::new(completions).into(),
|
||||
match_candidates,
|
||||
entries: RefCell::new(Vec::new()).into(),
|
||||
entries: Rc::new(RefCell::new(Box::new([]))),
|
||||
selected_item: 0,
|
||||
filter_task: Task::ready(()),
|
||||
cancel_filter: Arc::new(AtomicBool::new(false)),
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
resolve_completions: true,
|
||||
last_rendered_range: RefCell::new(None).into(),
|
||||
markdown_cache: RefCell::new(VecDeque::with_capacity(MARKDOWN_CACHE_MAX_SIZE)).into(),
|
||||
markdown_cache: RefCell::new(VecDeque::new()).into(),
|
||||
language_registry,
|
||||
language,
|
||||
snippet_sort_order,
|
||||
@@ -303,20 +332,24 @@ impl CompletionsMenu {
|
||||
positions: vec![],
|
||||
string: completion.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.collect();
|
||||
Self {
|
||||
id,
|
||||
source: CompletionsMenuSource::SnippetChoices,
|
||||
sort_completions,
|
||||
initial_position: selection.start,
|
||||
initial_query: None,
|
||||
is_incomplete: false,
|
||||
buffer,
|
||||
completions: RefCell::new(completions).into(),
|
||||
match_candidates,
|
||||
entries: RefCell::new(entries).into(),
|
||||
selected_item: 0,
|
||||
filter_task: Task::ready(()),
|
||||
cancel_filter: Arc::new(AtomicBool::new(false)),
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
resolve_completions: false,
|
||||
show_completion_documentation: false,
|
||||
ignore_completion_provider: false,
|
||||
last_rendered_range: RefCell::new(None).into(),
|
||||
markdown_cache: RefCell::new(VecDeque::new()).into(),
|
||||
language_registry: None,
|
||||
@@ -390,14 +423,7 @@ impl CompletionsMenu {
|
||||
) {
|
||||
if self.selected_item != match_index {
|
||||
self.selected_item = match_index;
|
||||
self.scroll_handle
|
||||
.scroll_to_item(self.selected_item, ScrollStrategy::Top);
|
||||
self.resolve_visible_completions(provider, cx);
|
||||
self.start_markdown_parse_for_nearby_entries(cx);
|
||||
if let Some(provider) = provider {
|
||||
self.handle_selection_changed(provider, window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
self.handle_selection_changed(provider, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,18 +444,25 @@ impl CompletionsMenu {
|
||||
}
|
||||
|
||||
fn handle_selection_changed(
|
||||
&self,
|
||||
provider: &dyn CompletionProvider,
|
||||
&mut self,
|
||||
provider: Option<&dyn CompletionProvider>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
let entries = self.entries.borrow();
|
||||
let entry = if self.selected_item < entries.len() {
|
||||
Some(&entries[self.selected_item])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
provider.selection_changed(entry, window, cx);
|
||||
self.scroll_handle
|
||||
.scroll_to_item(self.selected_item, ScrollStrategy::Top);
|
||||
if let Some(provider) = provider {
|
||||
let entries = self.entries.borrow();
|
||||
let entry = if self.selected_item < entries.len() {
|
||||
Some(&entries[self.selected_item])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
provider.selection_changed(entry, window, cx);
|
||||
}
|
||||
self.resolve_visible_completions(provider, cx);
|
||||
self.start_markdown_parse_for_nearby_entries(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn resolve_visible_completions(
|
||||
@@ -444,6 +477,19 @@ impl CompletionsMenu {
|
||||
return;
|
||||
};
|
||||
|
||||
let entries = self.entries.borrow();
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.selected_item >= entries.len() {
|
||||
log::error!(
|
||||
"bug: completion selected_item >= entries.len(): {} >= {}",
|
||||
self.selected_item,
|
||||
entries.len()
|
||||
);
|
||||
self.selected_item = entries.len() - 1;
|
||||
}
|
||||
|
||||
// Attempt to resolve completions for every item that will be displayed. This matters
|
||||
// because single line documentation may be displayed inline with the completion.
|
||||
//
|
||||
@@ -455,7 +501,6 @@ impl CompletionsMenu {
|
||||
let visible_count = last_rendered_range
|
||||
.clone()
|
||||
.map_or(APPROXIMATE_VISIBLE_COUNT, |range| range.count());
|
||||
let entries = self.entries.borrow();
|
||||
let entry_range = if self.selected_item == 0 {
|
||||
0..min(visible_count, entries.len())
|
||||
} else if self.selected_item == entries.len() - 1 {
|
||||
@@ -508,11 +553,11 @@ impl CompletionsMenu {
|
||||
.update(cx, |editor, cx| {
|
||||
// `resolve_completions` modified state affecting display.
|
||||
cx.notify();
|
||||
editor.with_completions_menu_matching_id(
|
||||
completion_id,
|
||||
|| (),
|
||||
|this| this.start_markdown_parse_for_nearby_entries(cx),
|
||||
);
|
||||
editor.with_completions_menu_matching_id(completion_id, |menu| {
|
||||
if let Some(menu) = menu {
|
||||
menu.start_markdown_parse_for_nearby_entries(cx)
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -548,11 +593,11 @@ impl CompletionsMenu {
|
||||
return None;
|
||||
}
|
||||
let candidate_id = entries[index].candidate_id;
|
||||
match &self.completions.borrow()[candidate_id].documentation {
|
||||
Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => Some(
|
||||
self.get_or_create_markdown(candidate_id, source.clone(), false, cx)
|
||||
.1,
|
||||
),
|
||||
let completions = self.completions.borrow();
|
||||
match &completions[candidate_id].documentation {
|
||||
Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => self
|
||||
.get_or_create_markdown(candidate_id, Some(source), false, &completions, cx)
|
||||
.map(|(_, markdown)| markdown),
|
||||
Some(_) => None,
|
||||
_ => None,
|
||||
}
|
||||
@@ -561,38 +606,75 @@ impl CompletionsMenu {
|
||||
fn get_or_create_markdown(
|
||||
&self,
|
||||
candidate_id: usize,
|
||||
source: SharedString,
|
||||
source: Option<&SharedString>,
|
||||
is_render: bool,
|
||||
completions: &[Completion],
|
||||
cx: &mut Context<Editor>,
|
||||
) -> (bool, Entity<Markdown>) {
|
||||
) -> Option<(bool, Entity<Markdown>)> {
|
||||
let mut markdown_cache = self.markdown_cache.borrow_mut();
|
||||
if let Some((cache_index, (_, markdown))) = markdown_cache
|
||||
.iter()
|
||||
.find_position(|(id, _)| *id == candidate_id)
|
||||
{
|
||||
let markdown = if is_render && cache_index != 0 {
|
||||
|
||||
let mut has_completion_match_cache_entry = false;
|
||||
let mut matching_entry = markdown_cache.iter().find_position(|(key, _)| match key {
|
||||
MarkdownCacheKey::ForCandidate { candidate_id: id } => *id == candidate_id,
|
||||
MarkdownCacheKey::ForCompletionMatch { .. } => {
|
||||
has_completion_match_cache_entry = true;
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_completion_match_cache_entry && matching_entry.is_none() {
|
||||
if let Some(source) = source {
|
||||
matching_entry = markdown_cache.iter().find_position(|(key, _)| {
|
||||
matches!(key, MarkdownCacheKey::ForCompletionMatch { markdown_source, .. }
|
||||
if markdown_source == source)
|
||||
});
|
||||
} else {
|
||||
// Heuristic guess that documentation can be reused when new_text matches. This is
|
||||
// to mitigate documentation flicker while typing. If this is wrong, then resolution
|
||||
// should cause the correct documentation to be displayed soon.
|
||||
let completion = &completions[candidate_id];
|
||||
matching_entry = markdown_cache.iter().find_position(|(key, _)| {
|
||||
matches!(key, MarkdownCacheKey::ForCompletionMatch { new_text, .. }
|
||||
if new_text == &completion.new_text)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((cache_index, (key, markdown))) = matching_entry {
|
||||
let markdown = markdown.clone();
|
||||
|
||||
// Since the markdown source matches, the key can now be ForCandidate.
|
||||
if source.is_some() && matches!(key, MarkdownCacheKey::ForCompletionMatch { .. }) {
|
||||
markdown_cache[cache_index].0 = MarkdownCacheKey::ForCandidate { candidate_id };
|
||||
}
|
||||
|
||||
if is_render && cache_index != 0 {
|
||||
// Move the current selection's cache entry to the front.
|
||||
markdown_cache.rotate_right(1);
|
||||
let cache_len = markdown_cache.len();
|
||||
markdown_cache.swap(0, (cache_index + 1) % cache_len);
|
||||
&markdown_cache[0].1
|
||||
} else {
|
||||
markdown
|
||||
};
|
||||
}
|
||||
|
||||
let is_parsing = markdown.update(cx, |markdown, cx| {
|
||||
// `reset` is called as it's possible for documentation to change due to resolve
|
||||
// requests. It does nothing if `source` is unchanged.
|
||||
markdown.reset(source, cx);
|
||||
if let Some(source) = source {
|
||||
// `reset` is called as it's possible for documentation to change due to resolve
|
||||
// requests. It does nothing if `source` is unchanged.
|
||||
markdown.reset(source.clone(), cx);
|
||||
}
|
||||
markdown.is_parsing()
|
||||
});
|
||||
return (is_parsing, markdown.clone());
|
||||
return Some((is_parsing, markdown));
|
||||
}
|
||||
|
||||
let Some(source) = source else {
|
||||
// Can't create markdown as there is no source.
|
||||
return None;
|
||||
};
|
||||
|
||||
if markdown_cache.len() < MARKDOWN_CACHE_MAX_SIZE {
|
||||
let markdown = cx.new(|cx| {
|
||||
Markdown::new(
|
||||
source,
|
||||
source.clone(),
|
||||
self.language_registry.clone(),
|
||||
self.language.clone(),
|
||||
cx,
|
||||
@@ -601,17 +683,20 @@ impl CompletionsMenu {
|
||||
// Handles redraw when the markdown is done parsing. The current render is for a
|
||||
// deferred draw, and so without this did not redraw when `markdown` notified.
|
||||
cx.observe(&markdown, |_, _, cx| cx.notify()).detach();
|
||||
markdown_cache.push_front((candidate_id, markdown.clone()));
|
||||
(true, markdown)
|
||||
markdown_cache.push_front((
|
||||
MarkdownCacheKey::ForCandidate { candidate_id },
|
||||
markdown.clone(),
|
||||
));
|
||||
Some((true, markdown))
|
||||
} else {
|
||||
debug_assert_eq!(markdown_cache.capacity(), MARKDOWN_CACHE_MAX_SIZE);
|
||||
// Moves the last cache entry to the start. The ring buffer is full, so this does no
|
||||
// copying and just shifts indexes.
|
||||
markdown_cache.rotate_right(1);
|
||||
markdown_cache[0].0 = candidate_id;
|
||||
markdown_cache[0].0 = MarkdownCacheKey::ForCandidate { candidate_id };
|
||||
let markdown = &markdown_cache[0].1;
|
||||
markdown.update(cx, |markdown, cx| markdown.reset(source, cx));
|
||||
(true, markdown.clone())
|
||||
markdown.update(cx, |markdown, cx| markdown.reset(source.clone(), cx));
|
||||
Some((true, markdown.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,37 +859,46 @@ impl CompletionsMenu {
|
||||
}
|
||||
|
||||
let mat = &self.entries.borrow()[self.selected_item];
|
||||
let multiline_docs = match self.completions.borrow_mut()[mat.candidate_id]
|
||||
.documentation
|
||||
.as_ref()?
|
||||
{
|
||||
CompletionDocumentation::MultiLinePlainText(text) => div().child(text.clone()),
|
||||
CompletionDocumentation::SingleLineAndMultiLinePlainText {
|
||||
let completions = self.completions.borrow_mut();
|
||||
let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() {
|
||||
Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()),
|
||||
Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
|
||||
plain_text: Some(text),
|
||||
..
|
||||
} => div().child(text.clone()),
|
||||
CompletionDocumentation::MultiLineMarkdown(source) if !source.is_empty() => {
|
||||
let (is_parsing, markdown) =
|
||||
self.get_or_create_markdown(mat.candidate_id, source.clone(), true, cx);
|
||||
if is_parsing {
|
||||
}) => div().child(text.clone()),
|
||||
Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => {
|
||||
let Some((false, markdown)) = self.get_or_create_markdown(
|
||||
mat.candidate_id,
|
||||
Some(source),
|
||||
true,
|
||||
&completions,
|
||||
cx,
|
||||
) else {
|
||||
return None;
|
||||
}
|
||||
div().child(
|
||||
MarkdownElement::new(markdown, hover_markdown_style(window, cx))
|
||||
.code_block_renderer(markdown::CodeBlockRenderer::Default {
|
||||
copy_button: false,
|
||||
copy_button_on_hover: false,
|
||||
border: false,
|
||||
})
|
||||
.on_url_click(open_markdown_url),
|
||||
)
|
||||
};
|
||||
Self::render_markdown(markdown, window, cx)
|
||||
}
|
||||
CompletionDocumentation::MultiLineMarkdown(_) => return None,
|
||||
CompletionDocumentation::SingleLine(_) => return None,
|
||||
CompletionDocumentation::Undocumented => return None,
|
||||
CompletionDocumentation::SingleLineAndMultiLinePlainText {
|
||||
plain_text: None, ..
|
||||
} => {
|
||||
None => {
|
||||
// Handle the case where documentation hasn't yet been resolved but there's a
|
||||
// `new_text` match in the cache.
|
||||
//
|
||||
// TODO: It's inconsistent that documentation caching based on matching `new_text`
|
||||
// only works for markdown. Consider generally caching the results of resolving
|
||||
// completions.
|
||||
let Some((false, markdown)) =
|
||||
self.get_or_create_markdown(mat.candidate_id, None, true, &completions, cx)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Self::render_markdown(markdown, window, cx)
|
||||
}
|
||||
Some(CompletionDocumentation::MultiLineMarkdown(_)) => return None,
|
||||
Some(CompletionDocumentation::SingleLine(_)) => return None,
|
||||
Some(CompletionDocumentation::Undocumented) => return None,
|
||||
Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
|
||||
plain_text: None,
|
||||
..
|
||||
}) => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -824,6 +918,177 @@ impl CompletionsMenu {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_markdown(
|
||||
markdown: Entity<Markdown>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Div {
|
||||
div().child(
|
||||
MarkdownElement::new(markdown, hover_markdown_style(window, cx))
|
||||
.code_block_renderer(markdown::CodeBlockRenderer::Default {
|
||||
copy_button: false,
|
||||
copy_button_on_hover: false,
|
||||
border: false,
|
||||
})
|
||||
.on_url_click(open_markdown_url),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn filter(
|
||||
&mut self,
|
||||
query: Option<Arc<String>>,
|
||||
provider: Option<Rc<dyn CompletionProvider>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
self.cancel_filter.store(true, Ordering::Relaxed);
|
||||
if let Some(query) = query {
|
||||
self.cancel_filter = Arc::new(AtomicBool::new(false));
|
||||
let matches = self.do_async_filtering(query, cx);
|
||||
let id = self.id;
|
||||
self.filter_task = cx.spawn_in(window, async move |editor, cx| {
|
||||
let matches = matches.await;
|
||||
editor
|
||||
.update_in(cx, |editor, window, cx| {
|
||||
editor.with_completions_menu_matching_id(id, |this| {
|
||||
if let Some(this) = this {
|
||||
this.set_filter_results(matches, provider, window, cx);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
} else {
|
||||
self.filter_task = Task::ready(());
|
||||
let matches = self.unfiltered_matches();
|
||||
self.set_filter_results(matches, provider, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn do_async_filtering(
|
||||
&self,
|
||||
query: Arc<String>,
|
||||
cx: &Context<Editor>,
|
||||
) -> Task<Vec<StringMatch>> {
|
||||
let matches_task = cx.background_spawn({
|
||||
let query = query.clone();
|
||||
let match_candidates = self.match_candidates.clone();
|
||||
let cancel_filter = self.cancel_filter.clone();
|
||||
let background_executor = cx.background_executor().clone();
|
||||
async move {
|
||||
fuzzy::match_strings(
|
||||
&match_candidates,
|
||||
&query,
|
||||
query.chars().any(|c| c.is_uppercase()),
|
||||
100,
|
||||
&cancel_filter,
|
||||
background_executor,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
let completions = self.completions.clone();
|
||||
let sort_completions = self.sort_completions;
|
||||
let snippet_sort_order = self.snippet_sort_order;
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let mut matches = matches_task.await;
|
||||
|
||||
if sort_completions {
|
||||
matches = Self::sort_string_matches(
|
||||
matches,
|
||||
Some(&query),
|
||||
snippet_sort_order,
|
||||
completions.borrow().as_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
matches
|
||||
})
|
||||
}
|
||||
|
||||
/// Like `do_async_filtering` but there is no filter query, so no need to spawn tasks.
|
||||
pub fn unfiltered_matches(&self) -> Vec<StringMatch> {
|
||||
let mut matches = self
|
||||
.match_candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(candidate_id, candidate)| StringMatch {
|
||||
candidate_id,
|
||||
score: Default::default(),
|
||||
positions: Default::default(),
|
||||
string: candidate.string.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if self.sort_completions {
|
||||
matches = Self::sort_string_matches(
|
||||
matches,
|
||||
None,
|
||||
self.snippet_sort_order,
|
||||
self.completions.borrow().as_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
matches
|
||||
}
|
||||
|
||||
pub fn set_filter_results(
|
||||
&mut self,
|
||||
matches: Vec<StringMatch>,
|
||||
provider: Option<Rc<dyn CompletionProvider>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
*self.entries.borrow_mut() = matches.into_boxed_slice();
|
||||
self.selected_item = 0;
|
||||
self.handle_selection_changed(provider.as_deref(), window, cx);
|
||||
}
|
||||
|
||||
fn sort_string_matches(
|
||||
matches: Vec<StringMatch>,
|
||||
query: Option<&str>,
|
||||
snippet_sort_order: SnippetSortOrder,
|
||||
completions: &[Completion],
|
||||
) -> Vec<StringMatch> {
|
||||
let mut sortable_items: Vec<SortableMatch<'_>> = matches
|
||||
.into_iter()
|
||||
.map(|string_match| {
|
||||
let completion = &completions[string_match.candidate_id];
|
||||
|
||||
let is_snippet = matches!(
|
||||
&completion.source,
|
||||
CompletionSource::Lsp { lsp_completion, .. }
|
||||
if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
|
||||
);
|
||||
|
||||
let sort_text =
|
||||
if let CompletionSource::Lsp { lsp_completion, .. } = &completion.source {
|
||||
lsp_completion.sort_text.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (sort_kind, sort_label) = completion.sort_key();
|
||||
|
||||
SortableMatch {
|
||||
string_match,
|
||||
is_snippet,
|
||||
sort_text,
|
||||
sort_kind,
|
||||
sort_label,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::sort_matches(&mut sortable_items, query, snippet_sort_order);
|
||||
|
||||
sortable_items
|
||||
.into_iter()
|
||||
.map(|sortable| sortable.string_match)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sort_matches(
|
||||
matches: &mut Vec<SortableMatch<'_>>,
|
||||
query: Option<&str>,
|
||||
@@ -857,6 +1122,7 @@ impl CompletionsMenu {
|
||||
let fuzzy_bracket_threshold = max_score * (3.0 / 5.0);
|
||||
|
||||
let query_start_lower = query
|
||||
.as_ref()
|
||||
.and_then(|q| q.chars().next())
|
||||
.and_then(|c| c.to_lowercase().next());
|
||||
|
||||
@@ -890,6 +1156,7 @@ impl CompletionsMenu {
|
||||
};
|
||||
let sort_mixed_case_prefix_length = Reverse(
|
||||
query
|
||||
.as_ref()
|
||||
.map(|q| {
|
||||
q.chars()
|
||||
.zip(mat.string_match.string.chars())
|
||||
@@ -920,97 +1187,32 @@ impl CompletionsMenu {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn filter(
|
||||
&mut self,
|
||||
query: Option<&str>,
|
||||
provider: Option<Rc<dyn CompletionProvider>>,
|
||||
editor: WeakEntity<Editor>,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) {
|
||||
let mut matches = if let Some(query) = query {
|
||||
fuzzy::match_strings(
|
||||
&self.match_candidates,
|
||||
query,
|
||||
query.chars().any(|c| c.is_uppercase()),
|
||||
100,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
self.match_candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(candidate_id, candidate)| StringMatch {
|
||||
candidate_id,
|
||||
score: Default::default(),
|
||||
positions: Default::default(),
|
||||
string: candidate.string.clone(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
pub fn preserve_markdown_cache(&mut self, prev_menu: CompletionsMenu) {
|
||||
self.markdown_cache = prev_menu.markdown_cache.clone();
|
||||
|
||||
if self.sort_completions {
|
||||
let completions = self.completions.borrow();
|
||||
|
||||
let mut sortable_items: Vec<SortableMatch<'_>> = matches
|
||||
.into_iter()
|
||||
.map(|string_match| {
|
||||
let completion = &completions[string_match.candidate_id];
|
||||
|
||||
let is_snippet = matches!(
|
||||
&completion.source,
|
||||
CompletionSource::Lsp { lsp_completion, .. }
|
||||
if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
|
||||
);
|
||||
|
||||
let sort_text =
|
||||
if let CompletionSource::Lsp { lsp_completion, .. } = &completion.source {
|
||||
lsp_completion.sort_text.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (sort_kind, sort_label) = completion.sort_key();
|
||||
|
||||
SortableMatch {
|
||||
string_match,
|
||||
is_snippet,
|
||||
sort_text,
|
||||
sort_kind,
|
||||
sort_label,
|
||||
// Convert ForCandidate cache keys to ForCompletionMatch keys.
|
||||
let prev_completions = prev_menu.completions.borrow();
|
||||
self.markdown_cache
|
||||
.borrow_mut()
|
||||
.retain_mut(|(key, _markdown)| match key {
|
||||
MarkdownCacheKey::ForCompletionMatch { .. } => true,
|
||||
MarkdownCacheKey::ForCandidate { candidate_id } => {
|
||||
if let Some(completion) = prev_completions.get(*candidate_id) {
|
||||
match &completion.documentation {
|
||||
Some(CompletionDocumentation::MultiLineMarkdown(source)) => {
|
||||
*key = MarkdownCacheKey::ForCompletionMatch {
|
||||
new_text: completion.new_text.clone(),
|
||||
markdown_source: source.clone(),
|
||||
};
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::sort_matches(&mut sortable_items, query, self.snippet_sort_order);
|
||||
|
||||
matches = sortable_items
|
||||
.into_iter()
|
||||
.map(|sortable| sortable.string_match)
|
||||
.collect();
|
||||
}
|
||||
|
||||
*self.entries.borrow_mut() = matches;
|
||||
self.selected_item = 0;
|
||||
// This keeps the display consistent when y_flipped.
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
|
||||
if let Some(provider) = provider {
|
||||
cx.update(|window, cx| {
|
||||
// Since this is async, it's possible the menu has been closed and possibly even
|
||||
// another opened. `provider.selection_changed` should not be called in this case.
|
||||
let this_menu_still_active = editor
|
||||
.read_with(cx, |editor, _cx| {
|
||||
editor.with_completions_menu_matching_id(self.id, || false, |_| true)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if this_menu_still_active {
|
||||
self.handle_selection_changed(&*provider, window, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -961,7 +961,10 @@ impl DisplaySnapshot {
|
||||
if chunk.is_unnecessary {
|
||||
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);
|
||||
diagnostic_highlight.underline = Some(UnderlineStyle {
|
||||
color: Some(diagnostic_color),
|
||||
@@ -2512,7 +2515,9 @@ pub mod tests {
|
||||
cx.update(|cx| syntax_chunks(DisplayRow(0)..DisplayRow(5), &map, &theme, cx)),
|
||||
[
|
||||
("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),
|
||||
]
|
||||
);
|
||||
@@ -2535,8 +2540,11 @@ pub mod tests {
|
||||
[
|
||||
("out".to_string(), Some(Hsla::blue())),
|
||||
("⋯\n".to_string(), None),
|
||||
(" \nfn ".to_string(), Some(Hsla::red())),
|
||||
("i\n".to_string(), Some(Hsla::blue()))
|
||||
(" ".to_string(), Some(Hsla::red())),
|
||||
("\n".to_string(), None),
|
||||
("fn ".to_string(), Some(Hsla::red())),
|
||||
("i".to_string(), Some(Hsla::blue())),
|
||||
("\n".to_string(), None)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -933,7 +933,7 @@ impl<'a> Iterator for WrapChunks<'a> {
|
||||
self.transforms.next(&());
|
||||
return Some(Chunk {
|
||||
text: &display_text[start_ix..end_ix],
|
||||
..self.input_chunk.clone()
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
JoinLines,
|
||||
code_context_menus::CodeContextMenu,
|
||||
inline_completion_tests::FakeInlineCompletionProvider,
|
||||
linked_editing_ranges::LinkedEditingRanges,
|
||||
scroll::scroll_amount::ScrollAmount,
|
||||
@@ -1911,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);
|
||||
|
||||
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);
|
||||
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);
|
||||
assert_selection_ranges("ˇuse std::str::{foo, ˇbar}\n\n {baz.qux()}", editor, 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);
|
||||
assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx);
|
||||
@@ -1941,7 +1942,7 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
|
||||
|
||||
editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx);
|
||||
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,
|
||||
cx,
|
||||
);
|
||||
@@ -8512,108 +8513,123 @@ async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) {
|
||||
async fn test_snippets(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let (text, insertion_ranges) = marked_text_ranges(
|
||||
indoc! {"
|
||||
a.ˇ b
|
||||
a.ˇ b
|
||||
a.ˇ b
|
||||
"},
|
||||
false,
|
||||
);
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
|
||||
cx.set_state(indoc! {"
|
||||
a.ˇ b
|
||||
a.ˇ b
|
||||
a.ˇ b
|
||||
"});
|
||||
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap();
|
||||
|
||||
let insertion_ranges = editor
|
||||
.selections
|
||||
.all(cx)
|
||||
.iter()
|
||||
.map(|s| s.range().clone())
|
||||
.collect::<Vec<_>>();
|
||||
editor
|
||||
.insert_snippet(&insertion_ranges, snippet, window, cx)
|
||||
.unwrap();
|
||||
|
||||
fn assert(editor: &mut Editor, cx: &mut Context<Editor>, marked_text: &str) {
|
||||
let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false);
|
||||
assert_eq!(editor.text(cx), expected_text);
|
||||
assert_eq!(editor.selections.ranges::<usize>(cx), selection_ranges);
|
||||
}
|
||||
|
||||
assert(
|
||||
editor,
|
||||
cx,
|
||||
indoc! {"
|
||||
a.f(«one», two, «three») b
|
||||
a.f(«one», two, «three») b
|
||||
a.f(«one», two, «three») b
|
||||
"},
|
||||
);
|
||||
|
||||
// Can't move earlier than the first tab stop
|
||||
assert!(!editor.move_to_prev_snippet_tabstop(window, cx));
|
||||
assert(
|
||||
editor,
|
||||
cx,
|
||||
indoc! {"
|
||||
a.f(«one», two, «three») b
|
||||
a.f(«one», two, «three») b
|
||||
a.f(«one», two, «three») b
|
||||
"},
|
||||
);
|
||||
|
||||
assert!(editor.move_to_next_snippet_tabstop(window, cx));
|
||||
assert(
|
||||
editor,
|
||||
cx,
|
||||
indoc! {"
|
||||
a.f(one, «two», three) b
|
||||
a.f(one, «two», three) b
|
||||
a.f(one, «two», three) b
|
||||
"},
|
||||
);
|
||||
|
||||
editor.move_to_prev_snippet_tabstop(window, cx);
|
||||
assert(
|
||||
editor,
|
||||
cx,
|
||||
indoc! {"
|
||||
a.f(«one», two, «three») b
|
||||
a.f(«one», two, «three») b
|
||||
a.f(«one», two, «three») b
|
||||
"},
|
||||
);
|
||||
|
||||
assert!(editor.move_to_next_snippet_tabstop(window, cx));
|
||||
assert(
|
||||
editor,
|
||||
cx,
|
||||
indoc! {"
|
||||
a.f(one, «two», three) b
|
||||
a.f(one, «two», three) b
|
||||
a.f(one, «two», three) b
|
||||
"},
|
||||
);
|
||||
assert!(editor.move_to_next_snippet_tabstop(window, cx));
|
||||
assert(
|
||||
editor,
|
||||
cx,
|
||||
indoc! {"
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
"},
|
||||
);
|
||||
|
||||
// As soon as the last tab stop is reached, snippet state is gone
|
||||
editor.move_to_prev_snippet_tabstop(window, cx);
|
||||
assert(
|
||||
editor,
|
||||
cx,
|
||||
indoc! {"
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
"},
|
||||
);
|
||||
});
|
||||
|
||||
cx.assert_editor_state(indoc! {"
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
"});
|
||||
|
||||
// Can't move earlier than the first tab stop
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
assert!(!editor.move_to_prev_snippet_tabstop(window, cx))
|
||||
});
|
||||
cx.assert_editor_state(indoc! {"
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx)));
|
||||
cx.assert_editor_state(indoc! {"
|
||||
a.f(one, «twoˇ», three) b
|
||||
a.f(one, «twoˇ», three) b
|
||||
a.f(one, «twoˇ», three) b
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, window, cx| assert!(editor.move_to_prev_snippet_tabstop(window, cx)));
|
||||
cx.assert_editor_state(indoc! {"
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
a.f(«oneˇ», two, «threeˇ») b
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx)));
|
||||
cx.assert_editor_state(indoc! {"
|
||||
a.f(one, «twoˇ», three) b
|
||||
a.f(one, «twoˇ», three) b
|
||||
a.f(one, «twoˇ», three) b
|
||||
"});
|
||||
cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx)));
|
||||
cx.assert_editor_state(indoc! {"
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
"});
|
||||
|
||||
// As soon as the last tab stop is reached, snippet state is gone
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
assert!(!editor.move_to_prev_snippet_tabstop(window, cx))
|
||||
});
|
||||
cx.assert_editor_state(indoc! {"
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
a.f(one, two, three)ˇ b
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_snippet_indentation(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
let snippet = Snippet::parse(indoc! {"
|
||||
/*
|
||||
* Multiline comment with leading indentation
|
||||
*
|
||||
* $1
|
||||
*/
|
||||
$0"})
|
||||
.unwrap();
|
||||
let insertion_ranges = editor
|
||||
.selections
|
||||
.all(cx)
|
||||
.iter()
|
||||
.map(|s| s.range().clone())
|
||||
.collect::<Vec<_>>();
|
||||
editor
|
||||
.insert_snippet(&insertion_ranges, snippet, window, cx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
cx.assert_editor_state(indoc! {"
|
||||
/*
|
||||
* Multiline comment with leading indentation
|
||||
*
|
||||
* ˇ
|
||||
*/
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx)));
|
||||
cx.assert_editor_state(indoc! {"
|
||||
/*
|
||||
* Multiline comment with leading indentation
|
||||
*
|
||||
*•
|
||||
*/
|
||||
ˇ"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -11184,14 +11200,15 @@ async fn test_completion(cx: &mut TestAppContext) {
|
||||
"});
|
||||
cx.simulate_keystroke(".");
|
||||
handle_completion_request(
|
||||
&mut cx,
|
||||
indoc! {"
|
||||
one.|<>
|
||||
two
|
||||
three
|
||||
"},
|
||||
vec!["first_completion", "second_completion"],
|
||||
true,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.condition(|editor, _| editor.context_menu_visible())
|
||||
@@ -11291,7 +11308,6 @@ async fn test_completion(cx: &mut TestAppContext) {
|
||||
additional edit
|
||||
"});
|
||||
handle_completion_request(
|
||||
&mut cx,
|
||||
indoc! {"
|
||||
one.second_completion
|
||||
two s
|
||||
@@ -11299,7 +11315,9 @@ async fn test_completion(cx: &mut TestAppContext) {
|
||||
additional edit
|
||||
"},
|
||||
vec!["fourth_completion", "fifth_completion", "sixth_completion"],
|
||||
true,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.condition(|editor, _| editor.context_menu_visible())
|
||||
@@ -11309,7 +11327,6 @@ async fn test_completion(cx: &mut TestAppContext) {
|
||||
cx.simulate_keystroke("i");
|
||||
|
||||
handle_completion_request(
|
||||
&mut cx,
|
||||
indoc! {"
|
||||
one.second_completion
|
||||
two si
|
||||
@@ -11317,7 +11334,9 @@ async fn test_completion(cx: &mut TestAppContext) {
|
||||
additional edit
|
||||
"},
|
||||
vec!["fourth_completion", "fifth_completion", "sixth_completion"],
|
||||
true,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.condition(|editor, _| editor.context_menu_visible())
|
||||
@@ -11351,10 +11370,11 @@ async fn test_completion(cx: &mut TestAppContext) {
|
||||
editor.show_completions(&ShowCompletions { trigger: None }, window, cx);
|
||||
});
|
||||
handle_completion_request(
|
||||
&mut cx,
|
||||
"editor.<clo|>",
|
||||
vec!["close", "clobber"],
|
||||
true,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.condition(|editor, _| editor.context_menu_visible())
|
||||
@@ -11371,6 +11391,128 @@ async fn test_completion(cx: &mut TestAppContext) {
|
||||
apply_additional_edits.await.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_completion_reuse(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
completion_provider: Some(lsp::CompletionOptions {
|
||||
trigger_characters: Some(vec![".".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
cx.set_state("objˇ");
|
||||
cx.simulate_keystroke(".");
|
||||
|
||||
// Initial completion request returns complete results
|
||||
let is_incomplete = false;
|
||||
handle_completion_request(
|
||||
"obj.|<>",
|
||||
vec!["a", "ab", "abc"],
|
||||
is_incomplete,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
|
||||
cx.assert_editor_state("obj.ˇ");
|
||||
check_displayed_completions(vec!["a", "ab", "abc"], &mut cx);
|
||||
|
||||
// Type "a" - filters existing completions
|
||||
cx.simulate_keystroke("a");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
|
||||
cx.assert_editor_state("obj.aˇ");
|
||||
check_displayed_completions(vec!["a", "ab", "abc"], &mut cx);
|
||||
|
||||
// Type "b" - filters existing completions
|
||||
cx.simulate_keystroke("b");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
|
||||
cx.assert_editor_state("obj.abˇ");
|
||||
check_displayed_completions(vec!["ab", "abc"], &mut cx);
|
||||
|
||||
// Type "c" - filters existing completions
|
||||
cx.simulate_keystroke("c");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
|
||||
cx.assert_editor_state("obj.abcˇ");
|
||||
check_displayed_completions(vec!["abc"], &mut cx);
|
||||
|
||||
// Backspace to delete "c" - filters existing completions
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.backspace(&Backspace, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
|
||||
cx.assert_editor_state("obj.abˇ");
|
||||
check_displayed_completions(vec!["ab", "abc"], &mut cx);
|
||||
|
||||
// Moving cursor to the left dismisses menu.
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.move_left(&MoveLeft, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
|
||||
cx.assert_editor_state("obj.aˇb");
|
||||
cx.update_editor(|editor, _, _| {
|
||||
assert_eq!(editor.context_menu_visible(), false);
|
||||
});
|
||||
|
||||
// Type "b" - new request
|
||||
cx.simulate_keystroke("b");
|
||||
let is_incomplete = false;
|
||||
handle_completion_request(
|
||||
"obj.<ab|>a",
|
||||
vec!["ab", "abc"],
|
||||
is_incomplete,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 2);
|
||||
cx.assert_editor_state("obj.abˇb");
|
||||
check_displayed_completions(vec!["ab", "abc"], &mut cx);
|
||||
|
||||
// Backspace to delete "b" - since query was "ab" and is now "a", new request is made.
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.backspace(&Backspace, window, cx);
|
||||
});
|
||||
let is_incomplete = false;
|
||||
handle_completion_request(
|
||||
"obj.<a|>b",
|
||||
vec!["a", "ab", "abc"],
|
||||
is_incomplete,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 3);
|
||||
cx.assert_editor_state("obj.aˇb");
|
||||
check_displayed_completions(vec!["a", "ab", "abc"], &mut cx);
|
||||
|
||||
// Backspace to delete "a" - dismisses menu.
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.backspace(&Backspace, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counter.load(atomic::Ordering::Acquire), 3);
|
||||
cx.assert_editor_state("obj.ˇb");
|
||||
cx.update_editor(|editor, _, _| {
|
||||
assert_eq!(editor.context_menu_visible(), false);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_word_completion(cx: &mut TestAppContext) {
|
||||
let lsp_fetch_timeout_ms = 10;
|
||||
@@ -12051,9 +12193,11 @@ async fn test_no_duplicated_completion_requests(cx: &mut TestAppContext) {
|
||||
let task_completion_item = closure_completion_item.clone();
|
||||
counter_clone.fetch_add(1, atomic::Ordering::Release);
|
||||
async move {
|
||||
Ok(Some(lsp::CompletionResponse::Array(vec![
|
||||
task_completion_item,
|
||||
])))
|
||||
Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList {
|
||||
is_incomplete: true,
|
||||
item_defaults: None,
|
||||
items: vec![task_completion_item],
|
||||
})))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17127,6 +17271,64 @@ async fn test_indent_guide_ends_before_empty_line(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_indent_guide_ignored_only_whitespace_lines(cx: &mut TestAppContext) {
|
||||
let (buffer_id, mut cx) = setup_indent_guides_editor(
|
||||
&"
|
||||
function component() {
|
||||
\treturn (
|
||||
\t\t\t
|
||||
\t\t<div>
|
||||
\t\t\t<abc></abc>
|
||||
\t\t</div>
|
||||
\t)
|
||||
}"
|
||||
.unindent(),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_indent_guides(
|
||||
0..8,
|
||||
vec![
|
||||
indent_guide(buffer_id, 1, 6, 0),
|
||||
indent_guide(buffer_id, 2, 5, 1),
|
||||
indent_guide(buffer_id, 4, 4, 2),
|
||||
],
|
||||
None,
|
||||
&mut cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_indent_guide_fallback_to_next_non_entirely_whitespace_line(cx: &mut TestAppContext) {
|
||||
let (buffer_id, mut cx) = setup_indent_guides_editor(
|
||||
&"
|
||||
function component() {
|
||||
\treturn (
|
||||
\t
|
||||
\t\t<div>
|
||||
\t\t\t<abc></abc>
|
||||
\t\t</div>
|
||||
\t)
|
||||
}"
|
||||
.unindent(),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_indent_guides(
|
||||
0..8,
|
||||
vec![
|
||||
indent_guide(buffer_id, 1, 6, 0),
|
||||
indent_guide(buffer_id, 2, 5, 1),
|
||||
indent_guide(buffer_id, 4, 4, 2),
|
||||
],
|
||||
None,
|
||||
&mut cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_indent_guide_continuing_off_screen(cx: &mut TestAppContext) {
|
||||
let (buffer_id, mut cx) = setup_indent_guides_editor(
|
||||
@@ -21025,6 +21227,7 @@ fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
|
||||
point..point
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Context<Editor>) {
|
||||
let (text, ranges) = marked_text_ranges(marked_text, true);
|
||||
assert_eq!(editor.text(cx), text);
|
||||
@@ -21051,6 +21254,22 @@ pub fn handle_signature_help_request(
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorLspTestContext) {
|
||||
cx.update_editor(|editor, _, _| {
|
||||
if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow().as_ref() {
|
||||
let entries = menu.entries.borrow();
|
||||
let entries = entries
|
||||
.iter()
|
||||
.map(|entry| entry.string.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(entries, expected);
|
||||
} else {
|
||||
panic!("Expected completions menu");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle completion request passing a marked string specifying where the completion
|
||||
/// should be triggered from using '|' character, what range should be replaced, and what completions
|
||||
/// should be returned using '<' and '>' to delimit the range.
|
||||
@@ -21058,10 +21277,11 @@ pub fn handle_signature_help_request(
|
||||
/// Also see `handle_completion_request_with_insert_and_replace`.
|
||||
#[track_caller]
|
||||
pub fn handle_completion_request(
|
||||
cx: &mut EditorLspTestContext,
|
||||
marked_string: &str,
|
||||
completions: Vec<&'static str>,
|
||||
is_incomplete: bool,
|
||||
counter: Arc<AtomicUsize>,
|
||||
cx: &mut EditorLspTestContext,
|
||||
) -> impl Future<Output = ()> {
|
||||
let complete_from_marker: TextRangeMarker = '|'.into();
|
||||
let replace_range_marker: TextRangeMarker = ('<', '>').into();
|
||||
@@ -21085,8 +21305,10 @@ pub fn handle_completion_request(
|
||||
params.text_document_position.position,
|
||||
complete_from_position
|
||||
);
|
||||
Ok(Some(lsp::CompletionResponse::Array(
|
||||
completions
|
||||
Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList {
|
||||
is_incomplete: is_incomplete,
|
||||
item_defaults: None,
|
||||
items: completions
|
||||
.iter()
|
||||
.map(|completion_text| lsp::CompletionItem {
|
||||
label: completion_text.to_string(),
|
||||
@@ -21097,7 +21319,7 @@ pub fn handle_completion_request(
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
)))
|
||||
})))
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -682,7 +682,7 @@ impl EditorElement {
|
||||
editor.select(
|
||||
SelectPhase::BeginColumnar {
|
||||
position,
|
||||
reset: false,
|
||||
reset: true,
|
||||
goal_column: point_for_position.exact_unclipped.column(),
|
||||
},
|
||||
window,
|
||||
|
||||
@@ -1095,14 +1095,15 @@ mod tests {
|
||||
//prompt autocompletion menu
|
||||
cx.simulate_keystroke(".");
|
||||
handle_completion_request(
|
||||
&mut cx,
|
||||
indoc! {"
|
||||
one.|<>
|
||||
two
|
||||
three
|
||||
"},
|
||||
vec!["first_completion", "second_completion"],
|
||||
true,
|
||||
counter.clone(),
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible
|
||||
|
||||
@@ -600,7 +600,7 @@ pub(crate) fn handle_from(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.change_selections_without_showing_completions(None, window, cx, |s| {
|
||||
this.change_selections_without_updating_completions(None, window, cx, |s| {
|
||||
s.select(base_selections);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -264,7 +264,18 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa
|
||||
let raw_point = point.to_point(map);
|
||||
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| {
|
||||
// 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))
|
||||
|| 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 {
|
||||
let raw_point = point.to_point(map);
|
||||
let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
|
||||
|
||||
let mut is_first_iteration = true;
|
||||
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))
|
||||
|| right == '\n'
|
||||
})
|
||||
@@ -782,10 +803,15 @@ mod tests {
|
||||
|
||||
fn assert(marked_text: &str, cx: &mut gpui::App) {
|
||||
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
|
||||
assert_eq!(
|
||||
previous_word_start(&snapshot, display_points[1]),
|
||||
display_points[0]
|
||||
);
|
||||
let actual = previous_word_start(&snapshot, display_points[1]);
|
||||
let expected = 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);
|
||||
@@ -796,12 +822,17 @@ mod tests {
|
||||
assert("\nlorem\nˇ ˇipsum", 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(" ˇdefγˇ", 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]
|
||||
@@ -955,10 +986,15 @@ mod tests {
|
||||
|
||||
fn assert(marked_text: &str, cx: &mut gpui::App) {
|
||||
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
|
||||
assert_eq!(
|
||||
next_word_end(&snapshot, display_points[0]),
|
||||
display_points[1]
|
||||
);
|
||||
let actual = next_word_end(&snapshot, display_points[0]);
|
||||
let expected = 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);
|
||||
@@ -967,11 +1003,18 @@ mod tests {
|
||||
assert(" loremˇ ˇ\nipsum\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(" ˇbcΔˇ", 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]
|
||||
|
||||
@@ -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.
|
||||
#[track_caller]
|
||||
pub fn marked_display_snapshot(
|
||||
text: &str,
|
||||
cx: &mut gpui::App,
|
||||
@@ -83,6 +84,7 @@ pub fn marked_display_snapshot(
|
||||
(snapshot, markers)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn select_ranges(
|
||||
editor: &mut Editor,
|
||||
marked_text: &str,
|
||||
|
||||
@@ -109,6 +109,7 @@ impl EditorTestContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn new_multibuffer<const COUNT: usize>(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
excerpts: [&str; COUNT],
|
||||
@@ -351,6 +352,7 @@ impl EditorTestContext {
|
||||
/// editor state was needed to cause the failure.
|
||||
///
|
||||
/// See the `util::test::marked_text_ranges` function for more information.
|
||||
#[track_caller]
|
||||
pub fn set_state(&mut self, marked_text: &str) -> ContextHandle {
|
||||
let state_context = self.add_assertion_context(format!(
|
||||
"Initial Editor State: \"{}\"",
|
||||
@@ -367,6 +369,7 @@ impl EditorTestContext {
|
||||
}
|
||||
|
||||
/// Only change the editor's selections
|
||||
#[track_caller]
|
||||
pub fn set_selections_state(&mut self, marked_text: &str) -> ContextHandle {
|
||||
let state_context = self.add_assertion_context(format!(
|
||||
"Initial Editor State: \"{}\"",
|
||||
@@ -532,7 +535,9 @@ impl EditorTestContext {
|
||||
#[track_caller]
|
||||
pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
|
||||
let expected_marked_text =
|
||||
generate_marked_text(&self.buffer_text(), &expected_selections, true);
|
||||
generate_marked_text(&self.buffer_text(), &expected_selections, true)
|
||||
.replace(" \n", "•\n");
|
||||
|
||||
self.assert_selections(expected_selections, expected_marked_text)
|
||||
}
|
||||
|
||||
@@ -561,7 +566,8 @@ impl EditorTestContext {
|
||||
) {
|
||||
let actual_selections = self.editor_selections();
|
||||
let actual_marked_text =
|
||||
generate_marked_text(&self.buffer_text(), &actual_selections, true);
|
||||
generate_marked_text(&self.buffer_text(), &actual_selections, true)
|
||||
.replace(" \n", "•\n");
|
||||
if expected_selections != actual_selections {
|
||||
pretty_assertions::assert_eq!(
|
||||
actual_marked_text,
|
||||
|
||||
@@ -246,6 +246,7 @@ impl ExampleContext {
|
||||
| ThreadEvent::StreamedAssistantThinking(_, _)
|
||||
| ThreadEvent::UsePendingTools { .. }
|
||||
| ThreadEvent::CompletionCanceled => {}
|
||||
ThreadEvent::ToolUseLimitReached => {}
|
||||
ThreadEvent::ToolFinished {
|
||||
tool_use_id,
|
||||
pending_tool_use,
|
||||
|
||||
@@ -759,8 +759,8 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.flat_map(|response| response.completions)
|
||||
.map(|c| c.label.text)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
|
||||
@@ -101,7 +101,10 @@ pub fn init(cx: &mut App) {
|
||||
directories: true,
|
||||
multiple: false,
|
||||
},
|
||||
DirectoryLister::Local(workspace.app_state().fs.clone()),
|
||||
DirectoryLister::Local(
|
||||
workspace.project().clone(),
|
||||
workspace.app_state().fs.clone(),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ mod file_finder_tests;
|
||||
mod open_path_prompt_tests;
|
||||
|
||||
pub mod file_finder_settings;
|
||||
mod new_path_prompt;
|
||||
mod open_path_prompt;
|
||||
|
||||
use futures::future::join_all;
|
||||
@@ -20,7 +19,6 @@ use gpui::{
|
||||
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
|
||||
Window, actions,
|
||||
};
|
||||
use new_path_prompt::NewPathPrompt;
|
||||
use open_path_prompt::OpenPathPrompt;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
||||
@@ -38,8 +36,8 @@ use std::{
|
||||
};
|
||||
use text::Point;
|
||||
use ui::{
|
||||
ContextMenu, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, PopoverMenu,
|
||||
PopoverMenuHandle, Tooltip, prelude::*,
|
||||
ButtonLike, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem, ListItemSpacing,
|
||||
PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
|
||||
};
|
||||
use util::{ResultExt, maybe, paths::PathWithPosition, post_inc};
|
||||
use workspace::{
|
||||
@@ -47,7 +45,10 @@ use workspace::{
|
||||
notifications::NotifyResultExt, pane,
|
||||
};
|
||||
|
||||
actions!(file_finder, [SelectPrevious, ToggleMenu]);
|
||||
actions!(
|
||||
file_finder,
|
||||
[SelectPrevious, ToggleFilterMenu, ToggleSplitMenu]
|
||||
);
|
||||
|
||||
impl ModalView for FileFinder {
|
||||
fn on_before_dismiss(
|
||||
@@ -56,7 +57,14 @@ impl ModalView for FileFinder {
|
||||
cx: &mut Context<Self>,
|
||||
) -> workspace::DismissDecision {
|
||||
let submenu_focused = self.picker.update(cx, |picker, cx| {
|
||||
picker.delegate.popover_menu_handle.is_focused(window, cx)
|
||||
picker
|
||||
.delegate
|
||||
.filter_popover_menu_handle
|
||||
.is_focused(window, cx)
|
||||
|| picker
|
||||
.delegate
|
||||
.split_popover_menu_handle
|
||||
.is_focused(window, cx)
|
||||
});
|
||||
workspace::DismissDecision::Dismiss(!submenu_focused)
|
||||
}
|
||||
@@ -75,8 +83,8 @@ pub fn init_settings(cx: &mut App) {
|
||||
pub fn init(cx: &mut App) {
|
||||
init_settings(cx);
|
||||
cx.observe_new(FileFinder::register).detach();
|
||||
cx.observe_new(NewPathPrompt::register).detach();
|
||||
cx.observe_new(OpenPathPrompt::register).detach();
|
||||
cx.observe_new(OpenPathPrompt::register_new_path).detach();
|
||||
}
|
||||
|
||||
impl FileFinder {
|
||||
@@ -212,9 +220,30 @@ impl FileFinder {
|
||||
window.dispatch_action(Box::new(menu::SelectPrevious), cx);
|
||||
}
|
||||
|
||||
fn handle_toggle_menu(&mut self, _: &ToggleMenu, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn handle_filter_toggle_menu(
|
||||
&mut self,
|
||||
_: &ToggleFilterMenu,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.picker.update(cx, |picker, cx| {
|
||||
let menu_handle = &picker.delegate.popover_menu_handle;
|
||||
let menu_handle = &picker.delegate.filter_popover_menu_handle;
|
||||
if menu_handle.is_deployed() {
|
||||
menu_handle.hide(cx);
|
||||
} else {
|
||||
menu_handle.show(window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_split_toggle_menu(
|
||||
&mut self,
|
||||
_: &ToggleSplitMenu,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.picker.update(cx, |picker, cx| {
|
||||
let menu_handle = &picker.delegate.split_popover_menu_handle;
|
||||
if menu_handle.is_deployed() {
|
||||
menu_handle.hide(cx);
|
||||
} else {
|
||||
@@ -301,6 +330,7 @@ impl FileFinder {
|
||||
worktree_id: WorktreeId::from_usize(m.0.worktree_id),
|
||||
path: m.0.path.clone(),
|
||||
},
|
||||
Match::CreateNew(p) => p.clone(),
|
||||
};
|
||||
let open_task = workspace.update(cx, move |workspace, cx| {
|
||||
workspace.split_path_preview(path, false, Some(split_direction), window, cx)
|
||||
@@ -345,7 +375,8 @@ impl Render for FileFinder {
|
||||
.w(modal_max_width)
|
||||
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
|
||||
.on_action(cx.listener(Self::handle_select_prev))
|
||||
.on_action(cx.listener(Self::handle_toggle_menu))
|
||||
.on_action(cx.listener(Self::handle_filter_toggle_menu))
|
||||
.on_action(cx.listener(Self::handle_split_toggle_menu))
|
||||
.on_action(cx.listener(Self::handle_toggle_ignored))
|
||||
.on_action(cx.listener(Self::go_to_file_split_left))
|
||||
.on_action(cx.listener(Self::go_to_file_split_right))
|
||||
@@ -371,7 +402,8 @@ pub struct FileFinderDelegate {
|
||||
history_items: Vec<FoundPath>,
|
||||
separate_history: bool,
|
||||
first_update: bool,
|
||||
popover_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
filter_popover_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
split_popover_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
focus_handle: FocusHandle,
|
||||
include_ignored: Option<bool>,
|
||||
include_ignored_refresh: Task<()>,
|
||||
@@ -423,13 +455,15 @@ enum Match {
|
||||
panel_match: Option<ProjectPanelOrdMatch>,
|
||||
},
|
||||
Search(ProjectPanelOrdMatch),
|
||||
CreateNew(ProjectPath),
|
||||
}
|
||||
|
||||
impl Match {
|
||||
fn path(&self) -> &Arc<Path> {
|
||||
fn path(&self) -> Option<&Arc<Path>> {
|
||||
match self {
|
||||
Match::History { path, .. } => &path.project.path,
|
||||
Match::Search(panel_match) => &panel_match.0.path,
|
||||
Match::History { path, .. } => Some(&path.project.path),
|
||||
Match::Search(panel_match) => Some(&panel_match.0.path),
|
||||
Match::CreateNew(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +471,7 @@ impl Match {
|
||||
match self {
|
||||
Match::History { panel_match, .. } => panel_match.as_ref(),
|
||||
Match::Search(panel_match) => Some(&panel_match),
|
||||
Match::CreateNew(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,7 +501,10 @@ impl Matches {
|
||||
// reason for the matches set to change.
|
||||
self.matches
|
||||
.iter()
|
||||
.position(|m| path.project.path == *m.path())
|
||||
.position(|m| match m.path() {
|
||||
Some(p) => path.project.path == *p,
|
||||
None => false,
|
||||
})
|
||||
.ok_or(0)
|
||||
} else {
|
||||
self.matches.binary_search_by(|m| {
|
||||
@@ -543,6 +581,12 @@ impl Matches {
|
||||
a: &Match,
|
||||
b: &Match,
|
||||
) -> cmp::Ordering {
|
||||
// Handle CreateNew variant - always put it at the end
|
||||
match (a, b) {
|
||||
(Match::CreateNew(_), _) => return cmp::Ordering::Less,
|
||||
(_, Match::CreateNew(_)) => return cmp::Ordering::Greater,
|
||||
_ => {}
|
||||
}
|
||||
debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
|
||||
|
||||
match (&a, &b) {
|
||||
@@ -758,7 +802,8 @@ impl FileFinderDelegate {
|
||||
history_items,
|
||||
separate_history,
|
||||
first_update: true,
|
||||
popover_menu_handle: PopoverMenuHandle::default(),
|
||||
filter_popover_menu_handle: PopoverMenuHandle::default(),
|
||||
split_popover_menu_handle: PopoverMenuHandle::default(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
include_ignored: FileFinderSettings::get_global(cx).include_ignored,
|
||||
include_ignored_refresh: Task::ready(()),
|
||||
@@ -874,6 +919,23 @@ impl FileFinderDelegate {
|
||||
matches.into_iter(),
|
||||
extend_old_matches,
|
||||
);
|
||||
let worktree = self.project.read(cx).visible_worktrees(cx).next();
|
||||
let filename = query.raw_query.to_string();
|
||||
let path = Path::new(&filename);
|
||||
|
||||
// add option of creating new file only if path is relative
|
||||
if let Some(worktree) = worktree {
|
||||
let worktree = worktree.read(cx);
|
||||
if path.is_relative()
|
||||
&& worktree.entry_for_path(&path).is_none()
|
||||
&& !filename.ends_with("/")
|
||||
{
|
||||
self.matches.matches.push(Match::CreateNew(ProjectPath {
|
||||
worktree_id: worktree.id(),
|
||||
path: Arc::from(path),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
self.selected_index = selected_match.map_or_else(
|
||||
|| self.calculate_selected_index(cx),
|
||||
@@ -954,6 +1016,12 @@ impl FileFinderDelegate {
|
||||
}
|
||||
}
|
||||
Match::Search(path_match) => self.labels_for_path_match(&path_match.0),
|
||||
Match::CreateNew(project_path) => (
|
||||
format!("Create file: {}", project_path.path.display()),
|
||||
vec![],
|
||||
String::from(""),
|
||||
vec![],
|
||||
),
|
||||
};
|
||||
|
||||
if file_name_positions.is_empty() {
|
||||
@@ -1137,8 +1205,13 @@ impl FileFinderDelegate {
|
||||
fn key_context(&self, window: &Window, cx: &App) -> KeyContext {
|
||||
let mut key_context = KeyContext::new_with_defaults();
|
||||
key_context.add("FileFinder");
|
||||
if self.popover_menu_handle.is_focused(window, cx) {
|
||||
key_context.add("menu_open");
|
||||
|
||||
if self.filter_popover_menu_handle.is_focused(window, cx) {
|
||||
key_context.add("filter_menu_open");
|
||||
}
|
||||
|
||||
if self.split_popover_menu_handle.is_focused(window, cx) {
|
||||
key_context.add("split_menu_open");
|
||||
}
|
||||
key_context
|
||||
}
|
||||
@@ -1333,6 +1406,29 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
}
|
||||
};
|
||||
match &m {
|
||||
Match::CreateNew(project_path) => {
|
||||
// Create a new file with the given filename
|
||||
if secondary {
|
||||
workspace.split_path_preview(
|
||||
project_path.clone(),
|
||||
false,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
workspace.open_path_preview(
|
||||
project_path.clone(),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Match::History { path, .. } => {
|
||||
let worktree_id = path.project.worktree_id;
|
||||
if workspace
|
||||
@@ -1463,6 +1559,10 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
.flex_none()
|
||||
.size(IconSize::Small.rems())
|
||||
.into_any_element(),
|
||||
Match::CreateNew(_) => Icon::new(IconName::Plus)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::Small)
|
||||
.into_any_element(),
|
||||
};
|
||||
let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix);
|
||||
|
||||
@@ -1470,7 +1570,7 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
if !settings.file_icons {
|
||||
return None;
|
||||
}
|
||||
let file_name = path_match.path().file_name()?;
|
||||
let file_name = path_match.path()?.file_name()?;
|
||||
let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
|
||||
Some(Icon::from_path(icon).color(Color::Muted))
|
||||
});
|
||||
@@ -1492,62 +1592,112 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
|
||||
let context = self.focus_handle.clone();
|
||||
fn render_footer(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.p_1p5()
|
||||
.justify_between()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
IconButton::new("toggle-ignored", IconName::Sliders)
|
||||
.on_click({
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
move |_, window, cx| {
|
||||
focus_handle.dispatch_action(&ToggleIncludeIgnored, window, cx);
|
||||
}
|
||||
PopoverMenu::new("filter-menu-popover")
|
||||
.with_handle(self.filter_popover_menu_handle.clone())
|
||||
.attach(gpui::Corner::BottomRight)
|
||||
.anchor(gpui::Corner::BottomLeft)
|
||||
.offset(gpui::Point {
|
||||
x: px(1.0),
|
||||
y: px(1.0),
|
||||
})
|
||||
.style(ButtonStyle::Subtle)
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(self.include_ignored.unwrap_or(false))
|
||||
.tooltip({
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("filter-trigger", IconName::Sliders)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(self.include_ignored.unwrap_or(false))
|
||||
.when(self.include_ignored.is_some(), |this| {
|
||||
this.indicator(Indicator::dot().color(Color::Info))
|
||||
}),
|
||||
{
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Filter Options",
|
||||
&ToggleFilterMenu,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.menu({
|
||||
let focus_handle = focus_handle.clone();
|
||||
let include_ignored = self.include_ignored;
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Use ignored files",
|
||||
&ToggleIncludeIgnored,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
Some(ContextMenu::build(window, cx, {
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |menu, _, _| {
|
||||
menu.context(focus_handle.clone())
|
||||
.header("Filter Options")
|
||||
.toggleable_entry(
|
||||
"Include Ignored Files",
|
||||
include_ignored.unwrap_or(false),
|
||||
ui::IconPosition::End,
|
||||
Some(ToggleIncludeIgnored.boxed_clone()),
|
||||
move |window, cx| {
|
||||
window.focus(&focus_handle);
|
||||
window.dispatch_action(
|
||||
ToggleIncludeIgnored.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
},
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Button::new("open-selection", "Open").on_click(|_, window, cx| {
|
||||
window.dispatch_action(menu::Confirm.boxed_clone(), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
PopoverMenu::new("menu-popover")
|
||||
.with_handle(self.popover_menu_handle.clone())
|
||||
.attach(gpui::Corner::TopRight)
|
||||
.anchor(gpui::Corner::BottomRight)
|
||||
PopoverMenu::new("split-menu-popover")
|
||||
.with_handle(self.split_popover_menu_handle.clone())
|
||||
.attach(gpui::Corner::BottomRight)
|
||||
.anchor(gpui::Corner::BottomLeft)
|
||||
.offset(gpui::Point {
|
||||
x: px(1.0),
|
||||
y: px(1.0),
|
||||
})
|
||||
.trigger(
|
||||
Button::new("actions-trigger", "Split…")
|
||||
.selected_label_color(Color::Accent),
|
||||
ButtonLike::new("split-trigger")
|
||||
.child(Label::new("Split…"))
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.children(
|
||||
KeyBinding::for_action_in(
|
||||
&ToggleSplitMenu,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
),
|
||||
)
|
||||
.menu({
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, {
|
||||
let context = context.clone();
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |menu, _, _| {
|
||||
menu.context(context)
|
||||
menu.context(focus_handle.clone())
|
||||
.action(
|
||||
"Split Left",
|
||||
pane::SplitLeft.boxed_clone(),
|
||||
@@ -1565,6 +1715,21 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("open-selection", "Open")
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Confirm,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(menu::Confirm.boxed_clone(), cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any(),
|
||||
|
||||
@@ -196,7 +196,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
|
||||
|
||||
cx.simulate_input("bna");
|
||||
picker.update(cx, |picker, _| {
|
||||
assert_eq!(picker.delegate.matches.len(), 2);
|
||||
assert_eq!(picker.delegate.matches.len(), 3);
|
||||
});
|
||||
cx.dispatch_action(SelectNext);
|
||||
cx.dispatch_action(Confirm);
|
||||
@@ -229,7 +229,12 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
|
||||
picker.update(cx, |picker, _| {
|
||||
assert_eq!(
|
||||
picker.delegate.matches.len(),
|
||||
1,
|
||||
// existence of CreateNew option depends on whether path already exists
|
||||
if bandana_query == util::separator!("a/bandana") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
},
|
||||
"Wrong number of matches for bandana query '{bandana_query}'. Matches: {:?}",
|
||||
picker.delegate.matches
|
||||
);
|
||||
@@ -269,9 +274,9 @@ async fn test_unicode_paths(cx: &mut TestAppContext) {
|
||||
|
||||
cx.simulate_input("g");
|
||||
picker.update(cx, |picker, _| {
|
||||
assert_eq!(picker.delegate.matches.len(), 1);
|
||||
assert_eq!(picker.delegate.matches.len(), 2);
|
||||
assert_match_at_position(picker, 1, "g");
|
||||
});
|
||||
cx.dispatch_action(SelectNext);
|
||||
cx.dispatch_action(Confirm);
|
||||
cx.read(|cx| {
|
||||
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
||||
@@ -365,13 +370,12 @@ async fn test_complex_path(cx: &mut TestAppContext) {
|
||||
|
||||
cx.simulate_input("t");
|
||||
picker.update(cx, |picker, _| {
|
||||
assert_eq!(picker.delegate.matches.len(), 1);
|
||||
assert_eq!(picker.delegate.matches.len(), 2);
|
||||
assert_eq!(
|
||||
collect_search_matches(picker).search_paths_only(),
|
||||
vec![PathBuf::from("其他/S数据表格/task.xlsx")],
|
||||
)
|
||||
});
|
||||
cx.dispatch_action(SelectNext);
|
||||
cx.dispatch_action(Confirm);
|
||||
cx.read(|cx| {
|
||||
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
||||
@@ -416,8 +420,9 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_match_at_position(finder, 1, &query_inside_file.to_string());
|
||||
let finder = &finder.delegate;
|
||||
assert_eq!(finder.matches.len(), 1);
|
||||
assert_eq!(finder.matches.len(), 2);
|
||||
let latest_search_query = finder
|
||||
.latest_search_query
|
||||
.as_ref()
|
||||
@@ -431,7 +436,6 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
|
||||
);
|
||||
});
|
||||
|
||||
cx.dispatch_action(SelectNext);
|
||||
cx.dispatch_action(Confirm);
|
||||
|
||||
let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
|
||||
@@ -491,8 +495,9 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_match_at_position(finder, 1, &query_outside_file.to_string());
|
||||
let delegate = &finder.delegate;
|
||||
assert_eq!(delegate.matches.len(), 1);
|
||||
assert_eq!(delegate.matches.len(), 2);
|
||||
let latest_search_query = delegate
|
||||
.latest_search_query
|
||||
.as_ref()
|
||||
@@ -506,7 +511,6 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
|
||||
);
|
||||
});
|
||||
|
||||
cx.dispatch_action(SelectNext);
|
||||
cx.dispatch_action(Confirm);
|
||||
|
||||
let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
|
||||
@@ -561,7 +565,8 @@ async fn test_matching_cancellation(cx: &mut TestAppContext) {
|
||||
.await;
|
||||
|
||||
picker.update(cx, |picker, _cx| {
|
||||
assert_eq!(picker.delegate.matches.len(), 5)
|
||||
// CreateNew option not shown in this case since file already exists
|
||||
assert_eq!(picker.delegate.matches.len(), 5);
|
||||
});
|
||||
|
||||
picker.update_in(cx, |picker, window, cx| {
|
||||
@@ -959,7 +964,8 @@ async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
|
||||
.await;
|
||||
cx.read(|cx| {
|
||||
let finder = picker.read(cx);
|
||||
assert_eq!(finder.delegate.matches.len(), 0);
|
||||
assert_eq!(finder.delegate.matches.len(), 1);
|
||||
assert_match_at_position(finder, 0, "dir");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1518,12 +1524,13 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 5);
|
||||
assert_eq!(finder.delegate.matches.len(), 6);
|
||||
assert_match_at_position(finder, 0, "main.rs");
|
||||
assert_match_selection(finder, 1, "bar.rs");
|
||||
assert_match_at_position(finder, 2, "lib.rs");
|
||||
assert_match_at_position(finder, 3, "moo.rs");
|
||||
assert_match_at_position(finder, 4, "maaa.rs");
|
||||
assert_match_at_position(finder, 5, ".rs");
|
||||
});
|
||||
|
||||
// main.rs is not among matches, select top item
|
||||
@@ -1533,9 +1540,10 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 2);
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_match_at_position(finder, 0, "bar.rs");
|
||||
assert_match_at_position(finder, 1, "lib.rs");
|
||||
assert_match_at_position(finder, 2, "b");
|
||||
});
|
||||
|
||||
// main.rs is back, put it on top and select next item
|
||||
@@ -1545,10 +1553,11 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_eq!(finder.delegate.matches.len(), 4);
|
||||
assert_match_at_position(finder, 0, "main.rs");
|
||||
assert_match_selection(finder, 1, "moo.rs");
|
||||
assert_match_at_position(finder, 2, "maaa.rs");
|
||||
assert_match_at_position(finder, 3, "m");
|
||||
});
|
||||
|
||||
// get back to the initial state
|
||||
@@ -1623,12 +1632,13 @@ async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppC
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 5);
|
||||
assert_eq!(finder.delegate.matches.len(), 6);
|
||||
assert_match_selection(finder, 0, "main.rs");
|
||||
assert_match_at_position(finder, 1, "bar.rs");
|
||||
assert_match_at_position(finder, 2, "lib.rs");
|
||||
assert_match_at_position(finder, 3, "moo.rs");
|
||||
assert_match_at_position(finder, 4, "maaa.rs");
|
||||
assert_match_at_position(finder, 5, ".rs");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1679,12 +1689,13 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 5);
|
||||
assert_eq!(finder.delegate.matches.len(), 6);
|
||||
assert_match_at_position(finder, 0, "main.rs");
|
||||
assert_match_selection(finder, 1, "moo.rs");
|
||||
assert_match_at_position(finder, 2, "bar.rs");
|
||||
assert_match_at_position(finder, 3, "lib.rs");
|
||||
assert_match_at_position(finder, 4, "maaa.rs");
|
||||
assert_match_at_position(finder, 5, ".rs");
|
||||
});
|
||||
|
||||
// main.rs is not among matches, select top item
|
||||
@@ -1694,9 +1705,10 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 2);
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_match_at_position(finder, 0, "bar.rs");
|
||||
assert_match_at_position(finder, 1, "lib.rs");
|
||||
assert_match_at_position(finder, 2, "b");
|
||||
});
|
||||
|
||||
// main.rs is back, put it on top and select next item
|
||||
@@ -1706,10 +1718,11 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
|
||||
})
|
||||
.await;
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_eq!(finder.delegate.matches.len(), 4);
|
||||
assert_match_at_position(finder, 0, "main.rs");
|
||||
assert_match_selection(finder, 1, "moo.rs");
|
||||
assert_match_at_position(finder, 2, "maaa.rs");
|
||||
assert_match_at_position(finder, 3, "m");
|
||||
});
|
||||
|
||||
// get back to the initial state
|
||||
@@ -1965,9 +1978,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
|
||||
let picker = open_file_picker(&workspace, cx);
|
||||
cx.simulate_input("rs");
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 2);
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_match_at_position(finder, 0, "lib.rs");
|
||||
assert_match_at_position(finder, 1, "main.rs");
|
||||
assert_match_at_position(finder, 2, "rs");
|
||||
});
|
||||
|
||||
// Delete main.rs
|
||||
@@ -1980,8 +1994,9 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
|
||||
|
||||
// main.rs is in not among search results anymore
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 1);
|
||||
assert_eq!(finder.delegate.matches.len(), 2);
|
||||
assert_match_at_position(finder, 0, "lib.rs");
|
||||
assert_match_at_position(finder, 1, "rs");
|
||||
});
|
||||
|
||||
// Create util.rs
|
||||
@@ -1994,9 +2009,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
|
||||
|
||||
// util.rs is among search results
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 2);
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_match_at_position(finder, 0, "lib.rs");
|
||||
assert_match_at_position(finder, 1, "util.rs");
|
||||
assert_match_at_position(finder, 2, "rs");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2036,9 +2052,10 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
|
||||
let picker = open_file_picker(&workspace, cx);
|
||||
cx.simulate_input("rs");
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 2);
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_match_at_position(finder, 0, "bar.rs");
|
||||
assert_match_at_position(finder, 1, "lib.rs");
|
||||
assert_match_at_position(finder, 2, "rs");
|
||||
});
|
||||
|
||||
// Add new worktree
|
||||
@@ -2054,10 +2071,11 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
|
||||
|
||||
// main.rs is among search results
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 3);
|
||||
assert_eq!(finder.delegate.matches.len(), 4);
|
||||
assert_match_at_position(finder, 0, "bar.rs");
|
||||
assert_match_at_position(finder, 1, "lib.rs");
|
||||
assert_match_at_position(finder, 2, "main.rs");
|
||||
assert_match_at_position(finder, 3, "rs");
|
||||
});
|
||||
|
||||
// Remove the first worktree
|
||||
@@ -2068,8 +2086,9 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
|
||||
|
||||
// Files from the first worktree are not in the search results anymore
|
||||
picker.update(cx, |finder, _| {
|
||||
assert_eq!(finder.delegate.matches.len(), 1);
|
||||
assert_eq!(finder.delegate.matches.len(), 2);
|
||||
assert_match_at_position(finder, 0, "main.rs");
|
||||
assert_match_at_position(finder, 1, "rs");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2414,7 +2433,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
picker.update(cx, |picker, _| {
|
||||
assert_eq!(picker.delegate.matches.len(), 6);
|
||||
assert_eq!(picker.delegate.matches.len(), 7);
|
||||
assert_eq!(picker.delegate.selected_index, 0);
|
||||
});
|
||||
|
||||
@@ -2426,7 +2445,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
picker.update(cx, |picker, _| {
|
||||
assert_eq!(picker.delegate.matches.len(), 6);
|
||||
assert_eq!(picker.delegate.matches.len(), 7);
|
||||
assert_eq!(picker.delegate.selected_index, 3);
|
||||
});
|
||||
}
|
||||
@@ -2468,7 +2487,7 @@ async fn open_queried_buffer(
|
||||
let history_items = picker.update(cx, |finder, _| {
|
||||
assert_eq!(
|
||||
finder.delegate.matches.len(),
|
||||
expected_matches,
|
||||
expected_matches + 1, // +1 from CreateNew option
|
||||
"Unexpected number of matches found for query `{input}`, matches: {:?}",
|
||||
finder.delegate.matches
|
||||
);
|
||||
@@ -2617,6 +2636,7 @@ fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries
|
||||
.push(Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path));
|
||||
search_entries.search_matches.push(path_match.0.clone());
|
||||
}
|
||||
Match::CreateNew(_) => {}
|
||||
}
|
||||
}
|
||||
search_entries
|
||||
@@ -2650,6 +2670,7 @@ fn assert_match_at_position(
|
||||
let match_file_name = match &match_item {
|
||||
Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(),
|
||||
Match::Search(path_match) => path_match.0.path.file_name(),
|
||||
Match::CreateNew(project_path) => project_path.path.file_name(),
|
||||
}
|
||||
.unwrap()
|
||||
.to_string_lossy();
|
||||
|
||||
@@ -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 futures::channel::oneshot;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{HighlightStyle, StyledText, Task};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{DirectoryItem, DirectoryLister};
|
||||
use settings::Settings;
|
||||
@@ -12,61 +13,136 @@ use std::{
|
||||
atomic::{self, AtomicBool},
|
||||
},
|
||||
};
|
||||
use ui::{Context, ListItem, Window};
|
||||
use ui::{Context, LabelLike, ListItem, Window};
|
||||
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
|
||||
use util::{maybe, paths::compare_paths};
|
||||
use workspace::Workspace;
|
||||
|
||||
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 {
|
||||
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
|
||||
lister: DirectoryLister,
|
||||
selected_index: usize,
|
||||
directory_state: Option<DirectoryState>,
|
||||
matches: Vec<usize>,
|
||||
directory_state: DirectoryState,
|
||||
string_matches: Vec<StringMatch>,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
should_dismiss: bool,
|
||||
replace_prompt: Task<()>,
|
||||
}
|
||||
|
||||
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 {
|
||||
tx: Some(tx),
|
||||
lister,
|
||||
selected_index: 0,
|
||||
directory_state: None,
|
||||
matches: Vec::new(),
|
||||
directory_state: DirectoryState::None {
|
||||
create: creating_path,
|
||||
},
|
||||
string_matches: Vec::new(),
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
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"))]
|
||||
pub fn collect_match_candidates(&self) -> Vec<String> {
|
||||
if let Some(state) = self.directory_state.as_ref() {
|
||||
self.matches
|
||||
match &self.directory_state {
|
||||
DirectoryState::List { entries, .. } => self
|
||||
.string_matches
|
||||
.iter()
|
||||
.filter_map(|&index| {
|
||||
state
|
||||
.match_candidates
|
||||
.get(index)
|
||||
.filter_map(|string_match| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.path.id == string_match.candidate_id)
|
||||
.map(|candidate| candidate.path.string.clone())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
.collect(),
|
||||
DirectoryState::Create {
|
||||
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)]
|
||||
struct DirectoryState {
|
||||
path: String,
|
||||
match_candidates: Vec<CandidateInfo>,
|
||||
error: Option<SharedString>,
|
||||
enum DirectoryState {
|
||||
List {
|
||||
parent_path: String,
|
||||
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)]
|
||||
@@ -83,7 +159,19 @@ impl OpenPathPrompt {
|
||||
) {
|
||||
workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
|
||||
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
|
||||
}));
|
||||
}
|
||||
@@ -91,13 +179,13 @@ impl OpenPathPrompt {
|
||||
fn prompt_for_open_path(
|
||||
workspace: &mut Workspace,
|
||||
lister: DirectoryLister,
|
||||
creating_path: bool,
|
||||
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
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 query = lister.default_query(cx);
|
||||
picker.set_query(query, window, cx);
|
||||
@@ -110,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
type ListItem = ui::ListItem;
|
||||
|
||||
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 {
|
||||
@@ -127,127 +224,196 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> gpui::Task<()> {
|
||||
let lister = self.lister.clone();
|
||||
let query_path = Path::new(&query);
|
||||
let last_item = query_path
|
||||
) -> Task<()> {
|
||||
let lister = &self.lister;
|
||||
let last_item = Path::new(&query)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
|
||||
(dir.to_string(), last_item)
|
||||
.to_string_lossy();
|
||||
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
|
||||
(dir.to_string(), last_item.into_owned())
|
||||
} else {
|
||||
(query, String::new())
|
||||
};
|
||||
|
||||
if dir == "" {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
dir = "/".to_string();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
dir = "C:\\".to_string();
|
||||
}
|
||||
dir = PROMPT_ROOT.to_string();
|
||||
}
|
||||
|
||||
let query = if self
|
||||
.directory_state
|
||||
.as_ref()
|
||||
.map_or(false, |s| s.path == dir)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(lister.list_directory(dir.clone(), cx))
|
||||
let query = match &self.directory_state {
|
||||
DirectoryState::List { parent_path, .. } => {
|
||||
if parent_path == &dir {
|
||||
None
|
||||
} 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));
|
||||
let cancel_flag = self.cancel_flag.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some(query) = query {
|
||||
let paths = query.await;
|
||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
||||
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.update(cx, |this, _| {
|
||||
this.delegate.directory_state = Some(match paths {
|
||||
Ok(mut paths) => {
|
||||
if dir == "/" {
|
||||
paths.push(DirectoryItem {
|
||||
is_dir: true,
|
||||
path: Default::default(),
|
||||
});
|
||||
}
|
||||
if this
|
||||
.update(cx, |this, _| {
|
||||
let new_state = match &this.delegate.directory_state {
|
||||
DirectoryState::None { create: false }
|
||||
| DirectoryState::List { .. } => match paths {
|
||||
Ok(paths) => DirectoryState::List {
|
||||
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 match_candidates = paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, item)| CandidateInfo {
|
||||
path: StringMatchCandidate::new(
|
||||
ix,
|
||||
&item.path.to_string_lossy(),
|
||||
),
|
||||
is_dir: item.is_dir,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
DirectoryState {
|
||||
match_candidates,
|
||||
path: dir,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(err) => DirectoryState {
|
||||
match_candidates: vec![],
|
||||
path: dir,
|
||||
error: Some(err.to_string().into()),
|
||||
},
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
let new_id = new_id.map(|id| id + 1).unwrap_or(0);
|
||||
let user_input = if suffix.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(UserInput {
|
||||
file: StringMatchCandidate::new(new_id, &suffix),
|
||||
exists,
|
||||
is_dir,
|
||||
})
|
||||
};
|
||||
DirectoryState::Create {
|
||||
entries,
|
||||
parent_path: dir.clone(),
|
||||
user_input,
|
||||
}
|
||||
}
|
||||
Err(_) => DirectoryState::Create {
|
||||
entries: Vec::new(),
|
||||
parent_path: dir.clone(),
|
||||
user_input: Some(UserInput {
|
||||
exists: false,
|
||||
is_dir: false,
|
||||
file: StringMatchCandidate::new(0, &suffix),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
this.delegate.directory_state = new_state;
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let match_candidates = this
|
||||
.update(cx, |this, cx| {
|
||||
let directory_state = this.delegate.directory_state.as_ref()?;
|
||||
if directory_state.error.is_some() {
|
||||
this.delegate.matches.clear();
|
||||
this.delegate.selected_index = 0;
|
||||
cx.notify();
|
||||
return None;
|
||||
let Ok(mut new_entries) =
|
||||
this.update(cx, |this, _| match &this.delegate.directory_state {
|
||||
DirectoryState::List {
|
||||
entries,
|
||||
error: None,
|
||||
..
|
||||
}
|
||||
| DirectoryState::Create { entries, .. } => entries.clone(),
|
||||
DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
Some(directory_state.match_candidates.clone())
|
||||
})
|
||||
.unwrap_or(None);
|
||||
|
||||
let Some(mut match_candidates) = match_candidates else {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !suffix.starts_with('.') {
|
||||
match_candidates.retain(|m| !m.path.string.starts_with('.'));
|
||||
new_entries.retain(|entry| !entry.path.string.starts_with('.'));
|
||||
}
|
||||
|
||||
if suffix == "" {
|
||||
if suffix.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.delegate.matches.clear();
|
||||
this.delegate.string_matches.clear();
|
||||
this.delegate
|
||||
.matches
|
||||
.extend(match_candidates.iter().map(|m| m.path.id));
|
||||
|
||||
this.delegate.selected_index = 0;
|
||||
this.delegate.string_matches = new_entries
|
||||
.iter()
|
||||
.map(|m| StringMatch {
|
||||
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();
|
||||
})
|
||||
.ok();
|
||||
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(
|
||||
candidates.as_slice(),
|
||||
&suffix,
|
||||
@@ -257,27 +423,57 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
||||
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.delegate.matches.clear();
|
||||
this.delegate.selected_index = 0;
|
||||
this.delegate.string_matches = matches.clone();
|
||||
this.delegate
|
||||
.matches
|
||||
.extend(matches.into_iter().map(|m| m.candidate_id));
|
||||
this.delegate.matches.sort_by_key(|m| {
|
||||
this.delegate.string_matches.sort_by_key(|m| {
|
||||
(
|
||||
this.delegate.directory_state.as_ref().and_then(|d| {
|
||||
d.match_candidates
|
||||
.get(*m)
|
||||
.map(|c| !c.path.string.starts_with(&suffix))
|
||||
}),
|
||||
*m,
|
||||
new_entries
|
||||
.iter()
|
||||
.find(|entry| entry.path.id == m.candidate_id)
|
||||
.map(|entry| &entry.path)
|
||||
.map(|candidate| !candidate.string.starts_with(&suffix)),
|
||||
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();
|
||||
})
|
||||
.ok();
|
||||
@@ -290,49 +486,107 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
_window: &mut Window,
|
||||
_: &mut Context<Picker<Self>>,
|
||||
) -> Option<String> {
|
||||
let candidate = self.get_entry(self.selected_index)?;
|
||||
Some(
|
||||
maybe!({
|
||||
let m = self.matches.get(self.selected_index)?;
|
||||
let directory_state = self.directory_state.as_ref()?;
|
||||
let candidate = directory_state.match_candidates.get(*m)?;
|
||||
Some(format!(
|
||||
"{}{}{}",
|
||||
directory_state.path,
|
||||
candidate.path.string,
|
||||
if candidate.is_dir {
|
||||
MAIN_SEPARATOR_STR
|
||||
} else {
|
||||
""
|
||||
}
|
||||
))
|
||||
match &self.directory_state {
|
||||
DirectoryState::Create { parent_path, .. } => Some(format!(
|
||||
"{}{}{}",
|
||||
parent_path,
|
||||
candidate.path.string,
|
||||
if candidate.is_dir {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
let Some(m) = self.matches.get(self.selected_index) else {
|
||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
let Some(candidate) = self.get_entry(self.selected_index) else {
|
||||
return;
|
||||
};
|
||||
let Some(directory_state) = self.directory_state.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(candidate) = directory_state.match_candidates.get(*m) else {
|
||||
return;
|
||||
};
|
||||
let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
|
||||
PathBuf::from("/")
|
||||
} else {
|
||||
Path::new(
|
||||
self.lister
|
||||
.resolve_tilde(&directory_state.path, cx)
|
||||
.as_ref(),
|
||||
)
|
||||
.join(&candidate.path.string)
|
||||
};
|
||||
if let Some(tx) = self.tx.take() {
|
||||
tx.send(Some(vec![result])).ok();
|
||||
|
||||
match &self.directory_state {
|
||||
DirectoryState::None { .. } => return,
|
||||
DirectoryState::List { parent_path, .. } => {
|
||||
let confirmed_path =
|
||||
if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
|
||||
PathBuf::from(PROMPT_ROOT)
|
||||
} else {
|
||||
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
|
||||
.join(&candidate.path.string)
|
||||
};
|
||||
if let Some(tx) = self.tx.take() {
|
||||
tx.send(Some(vec![confirmed_path])).ok();
|
||||
}
|
||||
}
|
||||
DirectoryState::Create {
|
||||
parent_path,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -351,19 +605,30 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let settings = FileFinderSettings::get_global(cx);
|
||||
let m = self.matches.get(ix)?;
|
||||
let directory_state = self.directory_state.as_ref()?;
|
||||
let candidate = directory_state.match_candidates.get(*m)?;
|
||||
let highlight_positions = self
|
||||
.string_matches
|
||||
.iter()
|
||||
.find(|string_match| string_match.candidate_id == *m)
|
||||
.map(|string_match| string_match.positions.clone())
|
||||
.unwrap_or_default();
|
||||
let candidate = self.get_entry(ix)?;
|
||||
let match_positions = match &self.directory_state {
|
||||
DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
|
||||
DirectoryState::Create { user_input, .. } => {
|
||||
if let Some(user_input) = user_input {
|
||||
if !user_input.exists || !user_input.is_dir {
|
||||
if ix == 0 {
|
||||
Vec::new()
|
||||
} 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!({
|
||||
if !settings.file_icons {
|
||||
@@ -378,34 +643,128 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
Some(Icon::from_path(icon).color(Color::Muted))
|
||||
});
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.start_slot::<Icon>(file_icon)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.child(HighlightedLabel::new(
|
||||
if directory_state.path == "/" {
|
||||
format!("/{}", candidate.path.string)
|
||||
} else {
|
||||
candidate.path.string.clone()
|
||||
},
|
||||
highlight_positions,
|
||||
)),
|
||||
)
|
||||
match &self.directory_state {
|
||||
DirectoryState::List { parent_path, .. } => Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.start_slot::<Icon>(file_icon)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.child(HighlightedLabel::new(
|
||||
if parent_path == PROMPT_ROOT {
|
||||
format!("{}{}", PROMPT_ROOT, candidate.path.string)
|
||||
} else {
|
||||
candidate.path.string.clone()
|
||||
},
|
||||
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> {
|
||||
let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
|
||||
{
|
||||
error
|
||||
} else {
|
||||
"No such file or directory".into()
|
||||
};
|
||||
Some(text)
|
||||
Some(match &self.directory_state {
|
||||
DirectoryState::Create { .. } => SharedString::from("Type a path…"),
|
||||
DirectoryState::List {
|
||||
error: Some(error), ..
|
||||
} => error.clone(),
|
||||
DirectoryState::List { .. } | DirectoryState::None { .. } => {
|
||||
SharedString::from("No such file or directory")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
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 (picker, cx) = build_open_path_prompt(project, cx);
|
||||
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||
|
||||
let query = path!("/root");
|
||||
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 (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.
|
||||
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 (picker, cx) = build_open_path_prompt(project, cx);
|
||||
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||
|
||||
// Support both forward and backward slashes.
|
||||
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> {
|
||||
cx.update(|cx| {
|
||||
let state = AppState::test(cx);
|
||||
@@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||
|
||||
fn build_open_path_prompt(
|
||||
project: Entity<Project>,
|
||||
creating_path: bool,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
|
||||
let (tx, _) = futures::channel::oneshot::channel();
|
||||
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));
|
||||
(
|
||||
|
||||
@@ -597,7 +597,9 @@ impl Fs for RealFs {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user