Compare commits
47 Commits
vim-syntax
...
fix-task-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a01de2d984 | ||
|
|
e4067b0f51 | ||
|
|
7ddafcdbe4 | ||
|
|
10df7b5eb9 | ||
|
|
55120c4231 | ||
|
|
8227c45a11 | ||
|
|
d23359e19a | ||
|
|
936ad0bf10 | ||
|
|
faa0bb51c9 | ||
|
|
2db2271e3c | ||
|
|
326697922c | ||
|
|
85086550f9 | ||
|
|
79b1dd7db8 | ||
|
|
81f8e2ed4a | ||
|
|
b9256dd469 | ||
|
|
27d3da678c | ||
|
|
03357f3f7b | ||
|
|
4aabba6cf6 | ||
|
|
8c46a4f594 | ||
|
|
eb4a63a9f9 | ||
|
|
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 | ||
|
|
089db88432 | ||
|
|
613be942cd |
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
|
||||
|
||||
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -8864,6 +8864,7 @@ dependencies = [
|
||||
"mistral",
|
||||
"ollama",
|
||||
"open_ai",
|
||||
"open_router",
|
||||
"partial-json-fixer",
|
||||
"project",
|
||||
"proto",
|
||||
@@ -10708,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"
|
||||
@@ -17115,6 +17129,7 @@ dependencies = [
|
||||
"futures-lite 1.13.0",
|
||||
"git2",
|
||||
"globset",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
|
||||
@@ -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 |
@@ -278,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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -315,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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -1500,11 +1497,11 @@
|
||||
}
|
||||
},
|
||||
"LaTeX": {
|
||||
"format_on_save": "on",
|
||||
"formatter": "language_server",
|
||||
"language_servers": ["texlab", "..."],
|
||||
"prettier": {
|
||||
"allowed": false
|
||||
"allowed": true,
|
||||
"plugins": ["prettier-plugin-latex"]
|
||||
}
|
||||
},
|
||||
"Markdown": {
|
||||
@@ -1605,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"
|
||||
},
|
||||
|
||||
@@ -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,11 +1014,11 @@ 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(cx);
|
||||
self.play_notification_sound(window, cx);
|
||||
self.show_notification(
|
||||
"Consecutive tool use limit reached.",
|
||||
IconName::Warning,
|
||||
@@ -1160,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);
|
||||
}
|
||||
}
|
||||
@@ -1586,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)
|
||||
@@ -1610,6 +1612,7 @@ impl ActiveThread {
|
||||
message_id,
|
||||
Role::User,
|
||||
vec![MessageSegment::Text(edited_text)],
|
||||
creases,
|
||||
Some(context.loaded_context),
|
||||
checkpoint.ok(),
|
||||
cx,
|
||||
@@ -3677,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;
|
||||
@@ -3741,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);
|
||||
|
||||
@@ -926,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)?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::agent_model_selector::{AgentModelSelector, ModelType};
|
||||
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,
|
||||
NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker,
|
||||
ToggleProfileSelector, register_agent_preview,
|
||||
};
|
||||
|
||||
#[derive(RegisterComponent)]
|
||||
@@ -459,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>,
|
||||
@@ -494,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();
|
||||
@@ -615,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()
|
||||
@@ -870,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()
|
||||
@@ -888,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"
|
||||
@@ -920,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(
|
||||
@@ -945,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| {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -70,13 +70,15 @@ impl Column for DataType {
|
||||
}
|
||||
}
|
||||
|
||||
const RULES_FILE_NAMES: [&'static str; 6] = [
|
||||
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) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -730,6 +730,7 @@ impl JsonSchema for LanguageModelProviderSetting {
|
||||
"zed.dev".into(),
|
||||
"copilot_chat".into(),
|
||||
"deepseek".into(),
|
||||
"openrouter".into(),
|
||||
"mistral".into(),
|
||||
]),
|
||||
..Default::default()
|
||||
|
||||
@@ -342,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()))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -884,30 +889,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 +975,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()
|
||||
}
|
||||
|
||||
@@ -71,16 +71,20 @@ pub enum Model {
|
||||
// DeepSeek
|
||||
DeepSeekR1,
|
||||
// Meta models
|
||||
MetaLlama38BInstructV1,
|
||||
MetaLlama370BInstructV1,
|
||||
MetaLlama318BInstructV1_128k,
|
||||
MetaLlama318BInstructV1,
|
||||
MetaLlama3170BInstructV1_128k,
|
||||
MetaLlama3170BInstructV1,
|
||||
MetaLlama3211BInstructV1,
|
||||
MetaLlama3290BInstructV1,
|
||||
MetaLlama321BInstructV1,
|
||||
MetaLlama323BInstructV1,
|
||||
MetaLlama3_8BInstruct,
|
||||
MetaLlama3_70BInstruct,
|
||||
MetaLlama31_8BInstruct,
|
||||
MetaLlama31_70BInstruct,
|
||||
MetaLlama31_405BInstruct,
|
||||
MetaLlama32_1BInstruct,
|
||||
MetaLlama32_3BInstruct,
|
||||
MetaLlama32_11BMultiModal,
|
||||
MetaLlama32_90BMultiModal,
|
||||
MetaLlama33_70BInstruct,
|
||||
#[allow(non_camel_case_types)]
|
||||
MetaLlama4Scout_17BInstruct,
|
||||
#[allow(non_camel_case_types)]
|
||||
MetaLlama4Maverick_17BInstruct,
|
||||
// Mistral models
|
||||
MistralMistral7BInstructV0,
|
||||
MistralMixtral8x7BInstructV0,
|
||||
@@ -145,7 +149,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",
|
||||
@@ -160,16 +164,18 @@ impl Model {
|
||||
Model::CohereCommandRV1 => "cohere.command-r-v1:0",
|
||||
Model::CohereCommandRPlusV1 => "cohere.command-r-plus-v1:0",
|
||||
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 => "meta.llama3-1-8b-instruct-v1:0",
|
||||
Model::MetaLlama3170BInstructV1_128k => "meta.llama3-1-70b-instruct-v1:0:128k",
|
||||
Model::MetaLlama3170BInstructV1 => "meta.llama3-1-70b-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::MetaLlama3_8BInstruct => "meta.llama3-8b-instruct-v1:0",
|
||||
Model::MetaLlama3_70BInstruct => "meta.llama3-70b-instruct-v1:0",
|
||||
Model::MetaLlama31_8BInstruct => "meta.llama3-1-8b-instruct-v1:0",
|
||||
Model::MetaLlama31_70BInstruct => "meta.llama3-1-70b-instruct-v1:0",
|
||||
Model::MetaLlama31_405BInstruct => "meta.llama3-1-405b-instruct-v1:0",
|
||||
Model::MetaLlama32_11BMultiModal => "meta.llama3-2-11b-instruct-v1:0",
|
||||
Model::MetaLlama32_90BMultiModal => "meta.llama3-2-90b-instruct-v1:0",
|
||||
Model::MetaLlama32_1BInstruct => "meta.llama3-2-1b-instruct-v1:0",
|
||||
Model::MetaLlama32_3BInstruct => "meta.llama3-2-3b-instruct-v1:0",
|
||||
Model::MetaLlama33_70BInstruct => "meta.llama3-3-70b-instruct-v1:0",
|
||||
Model::MetaLlama4Scout_17BInstruct => "meta.llama4-scout-17b-instruct-v1:0",
|
||||
Model::MetaLlama4Maverick_17BInstruct => "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 +220,18 @@ 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::MetaLlama3_8BInstruct => "Meta Llama 3 8B Instruct",
|
||||
Self::MetaLlama3_70BInstruct => "Meta Llama 3 70B Instruct",
|
||||
Self::MetaLlama31_8BInstruct => "Meta Llama 3.1 8B Instruct",
|
||||
Self::MetaLlama31_70BInstruct => "Meta Llama 3.1 70B Instruct",
|
||||
Self::MetaLlama31_405BInstruct => "Meta Llama 3.1 405B Instruct",
|
||||
Self::MetaLlama32_11BMultiModal => "Meta Llama 3.2 11B Vision Instruct",
|
||||
Self::MetaLlama32_90BMultiModal => "Meta Llama 3.2 90B Vision Instruct",
|
||||
Self::MetaLlama32_1BInstruct => "Meta Llama 3.2 1B Instruct",
|
||||
Self::MetaLlama32_3BInstruct => "Meta Llama 3.2 3B Instruct",
|
||||
Self::MetaLlama33_70BInstruct => "Meta Llama 3.3 70B Instruct",
|
||||
Self::MetaLlama4Scout_17BInstruct => "Meta Llama 4 Scout 17B Instruct",
|
||||
Self::MetaLlama4Maverick_17BInstruct => "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",
|
||||
@@ -365,55 +373,60 @@ impl Model {
|
||||
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::MetaLlama31_405BInstruct
|
||||
| Model::MetaLlama31_70BInstruct
|
||||
| Model::MetaLlama31_8BInstruct
|
||||
| Model::MetaLlama32_11BMultiModal
|
||||
| Model::MetaLlama32_1BInstruct
|
||||
| Model::MetaLlama32_3BInstruct
|
||||
| Model::MetaLlama32_90BMultiModal
|
||||
| Model::MetaLlama33_70BInstruct
|
||||
| Model::MetaLlama4Maverick_17BInstruct
|
||||
| Model::MetaLlama4Scout_17BInstruct
|
||||
| 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::MetaLlama32_1BInstruct
|
||||
| Model::MetaLlama32_3BInstruct
|
||||
| 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()),
|
||||
@@ -464,6 +477,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"
|
||||
@@ -489,11 +506,15 @@ mod tests {
|
||||
fn test_meta_models_inference_ids() -> anyhow::Result<()> {
|
||||
// Test Meta models
|
||||
assert_eq!(
|
||||
Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?,
|
||||
"us.meta.llama3-70b-instruct-v1:0"
|
||||
Model::MetaLlama3_70BInstruct.cross_region_inference_id("us-east-1")?,
|
||||
"meta.llama3-70b-instruct-v1:0"
|
||||
);
|
||||
assert_eq!(
|
||||
Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?,
|
||||
Model::MetaLlama31_70BInstruct.cross_region_inference_id("us-east-1")?,
|
||||
"us.meta.llama3-1-70b-instruct-v1:0"
|
||||
);
|
||||
assert_eq!(
|
||||
Model::MetaLlama32_1BInstruct.cross_region_inference_id("eu-west-1")?,
|
||||
"eu.meta.llama3-2-1b-instruct-v1:0"
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -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:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "@"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,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,
|
||||
);
|
||||
@@ -901,7 +902,6 @@ impl RunningState {
|
||||
weak_workspace,
|
||||
None,
|
||||
weak_project,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1055,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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -194,6 +194,7 @@ pub enum ContextMenuOrigin {
|
||||
|
||||
pub struct CompletionsMenu {
|
||||
pub id: CompletionId,
|
||||
pub source: CompletionsMenuSource,
|
||||
sort_completions: bool,
|
||||
pub initial_position: Anchor,
|
||||
pub initial_query: Option<Arc<String>>,
|
||||
@@ -208,7 +209,6 @@ pub struct CompletionsMenu {
|
||||
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<(MarkdownCacheKey, Entity<Markdown>)>>>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
@@ -227,6 +227,13 @@ enum MarkdownCacheKey {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CompletionsMenuSource {
|
||||
Normal,
|
||||
SnippetChoices,
|
||||
Words,
|
||||
}
|
||||
|
||||
// TODO: There should really be a wrapper around fuzzy match tasks that does this.
|
||||
impl Drop for CompletionsMenu {
|
||||
fn drop(&mut self) {
|
||||
@@ -237,9 +244,9 @@ impl Drop for CompletionsMenu {
|
||||
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,
|
||||
@@ -258,13 +265,13 @@ 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: Rc::new(RefCell::new(Box::new([]))),
|
||||
@@ -328,6 +335,7 @@ impl CompletionsMenu {
|
||||
.collect();
|
||||
Self {
|
||||
id,
|
||||
source: CompletionsMenuSource::SnippetChoices,
|
||||
sort_completions,
|
||||
initial_position: selection.start,
|
||||
initial_query: None,
|
||||
@@ -342,7 +350,6 @@ impl CompletionsMenu {
|
||||
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,
|
||||
|
||||
@@ -2512,7 +2512,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 +2537,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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -211,8 +211,11 @@ use workspace::{
|
||||
searchable::SearchEvent,
|
||||
};
|
||||
|
||||
use crate::hover_links::{find_url, find_url_from_range};
|
||||
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
|
||||
use crate::{
|
||||
code_context_menus::CompletionsMenuSource,
|
||||
hover_links::{find_url, find_url_from_range},
|
||||
};
|
||||
|
||||
pub const FILE_HEADER_HEIGHT: u32 = 2;
|
||||
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
|
||||
@@ -4510,30 +4513,40 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ignore_completion_provider = self
|
||||
let completions_source = self
|
||||
.context_menu
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|menu| match menu {
|
||||
CodeContextMenu::Completions(completions_menu) => {
|
||||
completions_menu.ignore_completion_provider
|
||||
}
|
||||
CodeContextMenu::CodeActions(_) => false,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
.and_then(|menu| match menu {
|
||||
CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source),
|
||||
CodeContextMenu::CodeActions(_) => None,
|
||||
});
|
||||
|
||||
if ignore_completion_provider {
|
||||
self.show_word_completions(&ShowWordCompletions, window, cx);
|
||||
} else if self.is_completion_trigger(text, trigger_in_words, cx) {
|
||||
self.show_completions(
|
||||
&ShowCompletions {
|
||||
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
self.hide_context_menu(window, cx);
|
||||
match completions_source {
|
||||
Some(CompletionsMenuSource::Words) => {
|
||||
self.show_word_completions(&ShowWordCompletions, window, cx)
|
||||
}
|
||||
Some(CompletionsMenuSource::Normal)
|
||||
| Some(CompletionsMenuSource::SnippetChoices)
|
||||
| None
|
||||
if self.is_completion_trigger(
|
||||
text,
|
||||
trigger_in_words,
|
||||
completions_source.is_some(),
|
||||
cx,
|
||||
) =>
|
||||
{
|
||||
self.show_completions(
|
||||
&ShowCompletions {
|
||||
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
self.hide_context_menu(window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4541,6 +4554,7 @@ impl Editor {
|
||||
&self,
|
||||
text: &str,
|
||||
trigger_in_words: bool,
|
||||
menu_is_open: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let position = self.selections.newest_anchor().head();
|
||||
@@ -4558,6 +4572,7 @@ impl Editor {
|
||||
position.text_anchor,
|
||||
text,
|
||||
trigger_in_words,
|
||||
menu_is_open,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
@@ -5008,7 +5023,7 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.open_or_update_completions_menu(true, None, window, cx);
|
||||
self.open_or_update_completions_menu(Some(CompletionsMenuSource::Words), None, window, cx);
|
||||
}
|
||||
|
||||
pub fn show_completions(
|
||||
@@ -5017,12 +5032,12 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.open_or_update_completions_menu(false, options.trigger.as_deref(), window, cx);
|
||||
self.open_or_update_completions_menu(None, options.trigger.as_deref(), window, cx);
|
||||
}
|
||||
|
||||
fn open_or_update_completions_menu(
|
||||
&mut self,
|
||||
ignore_completion_provider: bool,
|
||||
requested_source: Option<CompletionsMenuSource>,
|
||||
trigger: Option<&str>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -5047,10 +5062,13 @@ impl Editor {
|
||||
Self::completion_query(&self.buffer.read(cx).read(cx), position)
|
||||
.map(|query| query.into());
|
||||
|
||||
let provider = if ignore_completion_provider {
|
||||
None
|
||||
} else {
|
||||
self.completion_provider.clone()
|
||||
let provider = match requested_source {
|
||||
Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(),
|
||||
Some(CompletionsMenuSource::Words) => None,
|
||||
Some(CompletionsMenuSource::SnippetChoices) => {
|
||||
log::error!("bug: SnippetChoices requested_source is not handled");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let sort_completions = provider
|
||||
@@ -5106,14 +5124,15 @@ impl Editor {
|
||||
trigger_kind,
|
||||
};
|
||||
|
||||
let (replace_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position);
|
||||
let (replace_range, word_to_exclude) = if word_kind == Some(CharKind::Word) {
|
||||
let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) =
|
||||
buffer_snapshot.surrounding_word(buffer_position)
|
||||
{
|
||||
let word_to_exclude = buffer_snapshot
|
||||
.text_for_range(replace_range.clone())
|
||||
.text_for_range(word_range.clone())
|
||||
.collect::<String>();
|
||||
(
|
||||
buffer_snapshot.anchor_before(replace_range.start)
|
||||
..buffer_snapshot.anchor_after(replace_range.end),
|
||||
buffer_snapshot.anchor_before(word_range.start)
|
||||
..buffer_snapshot.anchor_after(buffer_position),
|
||||
Some(word_to_exclude),
|
||||
)
|
||||
} else {
|
||||
@@ -5221,7 +5240,7 @@ impl Editor {
|
||||
words.remove(&lsp_completion.new_text);
|
||||
}
|
||||
completions.extend(words.into_iter().map(|(word, word_range)| Completion {
|
||||
replace_range: replace_range.clone(),
|
||||
replace_range: word_replace_range.clone(),
|
||||
new_text: word.clone(),
|
||||
label: CodeLabel::plain(word, None),
|
||||
icon_path: None,
|
||||
@@ -5245,9 +5264,9 @@ impl Editor {
|
||||
.map(|workspace| workspace.read(cx).app_state().languages.clone());
|
||||
let menu = CompletionsMenu::new(
|
||||
id,
|
||||
requested_source.unwrap_or(CompletionsMenuSource::Normal),
|
||||
sort_completions,
|
||||
show_completion_documentation,
|
||||
ignore_completion_provider,
|
||||
position,
|
||||
query.clone(),
|
||||
is_incomplete,
|
||||
@@ -5531,14 +5550,12 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
let mut common_prefix_len = 0;
|
||||
for (a, b) in old_text.chars().zip(new_text.chars()) {
|
||||
if a == b {
|
||||
common_prefix_len += a.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let common_prefix_len = old_text
|
||||
.chars()
|
||||
.zip(new_text.chars())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(a, _)| a.len_utf8())
|
||||
.sum::<usize>();
|
||||
|
||||
cx.emit(EditorEvent::InputHandled {
|
||||
utf16_range_to_replace: None,
|
||||
@@ -20294,6 +20311,7 @@ pub trait CompletionProvider {
|
||||
position: language::Anchor,
|
||||
text: &str,
|
||||
trigger_in_words: bool,
|
||||
menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool;
|
||||
|
||||
@@ -20611,6 +20629,7 @@ impl CompletionProvider for Entity<Project> {
|
||||
position: language::Anchor,
|
||||
text: &str,
|
||||
trigger_in_words: bool,
|
||||
menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
let mut chars = text.chars();
|
||||
@@ -20625,7 +20644,7 @@ impl CompletionProvider for Entity<Project> {
|
||||
|
||||
let buffer = buffer.read(cx);
|
||||
let snapshot = buffer.snapshot();
|
||||
if !snapshot.settings_at(position, cx).show_completions_on_input {
|
||||
if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input {
|
||||
return false;
|
||||
}
|
||||
let classifier = snapshot.char_classifier_at(position).for_completion(true);
|
||||
|
||||
@@ -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};
|
||||
@@ -85,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 {
|
||||
@@ -332,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)
|
||||
@@ -456,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,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| {
|
||||
@@ -576,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) {
|
||||
@@ -908,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),
|
||||
@@ -988,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() {
|
||||
@@ -1372,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
|
||||
@@ -1502,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);
|
||||
|
||||
@@ -1509,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))
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::{ops::Range, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use collections::BTreeMap;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use gpui::{App, Global, SharedString};
|
||||
use http_client::HttpClient;
|
||||
@@ -130,7 +129,8 @@ impl Global for GlobalGitHostingProviderRegistry {}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GitHostingProviderRegistryState {
|
||||
providers: BTreeMap<String, Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
|
||||
default_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
|
||||
setting_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -140,6 +140,7 @@ pub struct GitHostingProviderRegistry {
|
||||
|
||||
impl GitHostingProviderRegistry {
|
||||
/// Returns the global [`GitHostingProviderRegistry`].
|
||||
#[track_caller]
|
||||
pub fn global(cx: &App) -> Arc<Self> {
|
||||
cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
|
||||
}
|
||||
@@ -168,7 +169,8 @@ impl GitHostingProviderRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: RwLock::new(GitHostingProviderRegistryState {
|
||||
providers: BTreeMap::default(),
|
||||
setting_providers: Vec::default(),
|
||||
default_providers: Vec::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -177,7 +179,22 @@ impl GitHostingProviderRegistry {
|
||||
pub fn list_hosting_providers(
|
||||
&self,
|
||||
) -> Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>> {
|
||||
self.state.read().providers.values().cloned().collect()
|
||||
let state = self.state.read();
|
||||
state
|
||||
.default_providers
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(state.setting_providers.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn set_setting_providers(
|
||||
&self,
|
||||
providers: impl IntoIterator<Item = Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
|
||||
) {
|
||||
let mut state = self.state.write();
|
||||
state.setting_providers.clear();
|
||||
state.setting_providers.extend(providers);
|
||||
}
|
||||
|
||||
/// Adds the provided [`GitHostingProvider`] to the registry.
|
||||
@@ -185,10 +202,7 @@ impl GitHostingProviderRegistry {
|
||||
&self,
|
||||
provider: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
|
||||
) {
|
||||
self.state
|
||||
.write()
|
||||
.providers
|
||||
.insert(provider.name(), provider);
|
||||
self.state.write().default_providers.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,22 +25,34 @@ fn init_git_hosting_provider_settings(cx: &mut App) {
|
||||
}
|
||||
|
||||
fn update_git_hosting_providers_from_settings(cx: &mut App) {
|
||||
let settings_store = cx.global::<SettingsStore>();
|
||||
let settings = GitHostingProviderSettings::get_global(cx);
|
||||
let provider_registry = GitHostingProviderRegistry::global(cx);
|
||||
|
||||
for provider in settings.git_hosting_providers.iter() {
|
||||
let Some(url) = Url::parse(&provider.base_url).log_err() else {
|
||||
continue;
|
||||
};
|
||||
let local_values: Vec<GitHostingProviderConfig> = settings_store
|
||||
.get_all_locals::<GitHostingProviderSettings>()
|
||||
.into_iter()
|
||||
.flat_map(|(_, _, providers)| providers.git_hosting_providers.clone())
|
||||
.collect();
|
||||
|
||||
let provider = match provider.provider {
|
||||
GitHostingProviderKind::Bitbucket => Arc::new(Bitbucket::new(&provider.name, url)) as _,
|
||||
GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
|
||||
GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
|
||||
};
|
||||
let iter = settings
|
||||
.git_hosting_providers
|
||||
.clone()
|
||||
.into_iter()
|
||||
.chain(local_values)
|
||||
.filter_map(|provider| {
|
||||
let url = Url::parse(&provider.base_url).log_err()?;
|
||||
|
||||
provider_registry.register_hosting_provider(provider);
|
||||
}
|
||||
Some(match provider.provider {
|
||||
GitHostingProviderKind::Bitbucket => {
|
||||
Arc::new(Bitbucket::new(&provider.name, url)) as _
|
||||
}
|
||||
GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
|
||||
GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
|
||||
})
|
||||
});
|
||||
|
||||
provider_registry.set_setting_providers(iter);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -66,7 +78,7 @@ pub struct GitHostingProviderConfig {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct GitHostingProviderSettings {
|
||||
/// The list of custom Git hosting providers.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -1559,6 +1559,11 @@ impl App {
|
||||
self.active_drag.is_some()
|
||||
}
|
||||
|
||||
/// Gets the cursor style of the currently active drag operation.
|
||||
pub fn active_drag_cursor_style(&self) -> Option<CursorStyle> {
|
||||
self.active_drag.as_ref().and_then(|drag| drag.cursor_style)
|
||||
}
|
||||
|
||||
/// Stops active drag and clears any related effects.
|
||||
pub fn stop_active_drag(&mut self, window: &mut Window) -> bool {
|
||||
if self.active_drag.is_some() {
|
||||
@@ -1570,6 +1575,21 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the cursor style for the currently active drag operation.
|
||||
pub fn set_active_drag_cursor_style(
|
||||
&mut self,
|
||||
cursor_style: CursorStyle,
|
||||
window: &mut Window,
|
||||
) -> bool {
|
||||
if let Some(ref mut drag) = self.active_drag {
|
||||
drag.cursor_style = Some(cursor_style);
|
||||
window.refresh();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the prompt renderer for GPUI. This will replace the default or platform specific
|
||||
/// prompts with this custom implementation.
|
||||
pub fn set_prompt_builder(
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum IconName {
|
||||
AiMistral,
|
||||
AiOllama,
|
||||
AiOpenAi,
|
||||
AiOpenRouter,
|
||||
AiZed,
|
||||
ArrowCircle,
|
||||
ArrowDown,
|
||||
@@ -154,6 +155,7 @@ pub enum IconName {
|
||||
LineHeight,
|
||||
Link,
|
||||
ListCollapse,
|
||||
ListTodo,
|
||||
ListTree,
|
||||
ListX,
|
||||
LoadCircle,
|
||||
|
||||
@@ -685,8 +685,9 @@ impl CompletionProvider for RustStyleCompletionProvider {
|
||||
&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 {
|
||||
completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some()
|
||||
|
||||
@@ -3283,8 +3283,8 @@ impl BufferSnapshot {
|
||||
pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
|
||||
let mut start = start.to_offset(self);
|
||||
let mut end = start;
|
||||
let mut next_chars = self.chars_at(start).peekable();
|
||||
let mut prev_chars = self.reversed_chars_at(start).peekable();
|
||||
let mut next_chars = self.chars_at(start).take(128).peekable();
|
||||
let mut prev_chars = self.reversed_chars_at(start).take(128).peekable();
|
||||
|
||||
let classifier = self.char_classifier_at(start);
|
||||
let word_kind = cmp::max(
|
||||
|
||||
@@ -39,6 +39,7 @@ menu.workspace = true
|
||||
mistral = { workspace = true, features = ["schemars"] }
|
||||
ollama = { workspace = true, features = ["schemars"] }
|
||||
open_ai = { workspace = true, features = ["schemars"] }
|
||||
open_router = { workspace = true, features = ["schemars"] }
|
||||
partial-json-fixer.workspace = true
|
||||
project.workspace = true
|
||||
proto.workspace = true
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::provider::lmstudio::LmStudioLanguageModelProvider;
|
||||
use crate::provider::mistral::MistralLanguageModelProvider;
|
||||
use crate::provider::ollama::OllamaLanguageModelProvider;
|
||||
use crate::provider::open_ai::OpenAiLanguageModelProvider;
|
||||
use crate::provider::open_router::OpenRouterLanguageModelProvider;
|
||||
pub use crate::settings::*;
|
||||
|
||||
pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, fs: Arc<dyn Fs>, cx: &mut App) {
|
||||
@@ -72,5 +73,9 @@ fn register_language_model_providers(
|
||||
BedrockLanguageModelProvider::new(client.http_client(), cx),
|
||||
cx,
|
||||
);
|
||||
registry.register_provider(
|
||||
OpenRouterLanguageModelProvider::new(client.http_client(), cx),
|
||||
cx,
|
||||
);
|
||||
registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx);
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod lmstudio;
|
||||
pub mod mistral;
|
||||
pub mod ollama;
|
||||
pub mod open_ai;
|
||||
pub mod open_router;
|
||||
|
||||
@@ -531,13 +531,13 @@ impl LanguageModel for BedrockModel {
|
||||
> {
|
||||
let Ok(region) = cx.read_entity(&self.state, |state, _cx| {
|
||||
// Get region - from credentials or directly from settings
|
||||
let region = state
|
||||
.credentials
|
||||
.as_ref()
|
||||
.map(|s| s.region.clone())
|
||||
.unwrap_or(String::from("us-east-1"));
|
||||
let credentials_region = state.credentials.as_ref().map(|s| s.region.clone());
|
||||
let settings_region = state.settings.as_ref().and_then(|s| s.region.clone());
|
||||
|
||||
region
|
||||
// Use credentials region if available, otherwise use settings region, finally fall back to default
|
||||
credentials_region
|
||||
.or(settings_region)
|
||||
.unwrap_or(String::from("us-east-1"))
|
||||
}) else {
|
||||
return async move {
|
||||
anyhow::bail!("App State Dropped");
|
||||
|
||||
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use ui::{ButtonLike, Indicator, List, prelude::*};
|
||||
use util::ResultExt;
|
||||
|
||||
@@ -201,7 +201,7 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
|
||||
}
|
||||
|
||||
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
|
||||
let mut models: BTreeMap<String, ollama::Model> = BTreeMap::default();
|
||||
let mut models: HashMap<String, ollama::Model> = HashMap::new();
|
||||
|
||||
// Add models from the Ollama API
|
||||
for model in self.state.read(cx).available_models.iter() {
|
||||
@@ -228,7 +228,7 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
|
||||
);
|
||||
}
|
||||
|
||||
models
|
||||
let mut models = models
|
||||
.into_values()
|
||||
.map(|model| {
|
||||
Arc::new(OllamaLanguageModel {
|
||||
@@ -238,7 +238,9 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
|
||||
request_limiter: RateLimiter::new(4),
|
||||
}) as Arc<dyn LanguageModel>
|
||||
})
|
||||
.collect()
|
||||
.collect::<Vec<_>>();
|
||||
models.sort_by_key(|model| model.name());
|
||||
models
|
||||
}
|
||||
|
||||
fn load_model(&self, model: Arc<dyn LanguageModel>, cx: &App) {
|
||||
|
||||
788
crates/language_models/src/provider/open_router.rs
Normal file
788
crates/language_models/src/provider/open_router.rs
Normal file
@@ -0,0 +1,788 @@
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::HashMap;
|
||||
use credentials_provider::CredentialsProvider;
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
|
||||
use gpui::{
|
||||
AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace,
|
||||
};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
||||
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
|
||||
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
|
||||
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
|
||||
RateLimiter, Role, StopReason,
|
||||
};
|
||||
use open_router::{Model, ResponseStreamEvent, list_models, stream_completion};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore};
|
||||
use std::pin::Pin;
|
||||
use std::str::FromStr as _;
|
||||
use std::sync::Arc;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{Icon, IconName, List, Tooltip, prelude::*};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::{AllLanguageModelSettings, ui::InstructionListItem};
|
||||
|
||||
const PROVIDER_ID: &str = "openrouter";
|
||||
const PROVIDER_NAME: &str = "OpenRouter";
|
||||
|
||||
#[derive(Default, Clone, Debug, PartialEq)]
|
||||
pub struct OpenRouterSettings {
|
||||
pub api_url: String,
|
||||
pub available_models: Vec<AvailableModel>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AvailableModel {
|
||||
pub name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub max_tokens: usize,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub max_completion_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct OpenRouterLanguageModelProvider {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
state: gpui::Entity<State>,
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
api_key: Option<String>,
|
||||
api_key_from_env: bool,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
available_models: Vec<open_router::Model>,
|
||||
fetch_models_task: Option<Task<Result<()>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
const OPENROUTER_API_KEY_VAR: &str = "OPENROUTER_API_KEY";
|
||||
|
||||
impl State {
|
||||
fn is_authenticated(&self) -> bool {
|
||||
self.api_key.is_some()
|
||||
}
|
||||
|
||||
fn reset_api_key(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let credentials_provider = <dyn CredentialsProvider>::global(cx);
|
||||
let api_url = AllLanguageModelSettings::get_global(cx)
|
||||
.open_router
|
||||
.api_url
|
||||
.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
credentials_provider
|
||||
.delete_credentials(&api_url, &cx)
|
||||
.await
|
||||
.log_err();
|
||||
this.update(cx, |this, cx| {
|
||||
this.api_key = None;
|
||||
this.api_key_from_env = false;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn set_api_key(&mut self, api_key: String, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let credentials_provider = <dyn CredentialsProvider>::global(cx);
|
||||
let api_url = AllLanguageModelSettings::get_global(cx)
|
||||
.open_router
|
||||
.api_url
|
||||
.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
credentials_provider
|
||||
.write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx)
|
||||
.await
|
||||
.log_err();
|
||||
this.update(cx, |this, cx| {
|
||||
this.api_key = Some(api_key);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(&self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
|
||||
if self.is_authenticated() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
let credentials_provider = <dyn CredentialsProvider>::global(cx);
|
||||
let api_url = AllLanguageModelSettings::get_global(cx)
|
||||
.open_router
|
||||
.api_url
|
||||
.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let (api_key, from_env) = if let Ok(api_key) = std::env::var(OPENROUTER_API_KEY_VAR) {
|
||||
(api_key, true)
|
||||
} else {
|
||||
let (_, api_key) = credentials_provider
|
||||
.read_credentials(&api_url, &cx)
|
||||
.await?
|
||||
.ok_or(AuthenticateError::CredentialsNotFound)?;
|
||||
(
|
||||
String::from_utf8(api_key)
|
||||
.context(format!("invalid {} API key", PROVIDER_NAME))?,
|
||||
false,
|
||||
)
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.api_key = Some(api_key);
|
||||
this.api_key_from_env = from_env;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_models(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let settings = &AllLanguageModelSettings::get_global(cx).open_router;
|
||||
let http_client = self.http_client.clone();
|
||||
let api_url = settings.api_url.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let models = list_models(http_client.as_ref(), &api_url).await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.available_models = models;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.fetch_models(cx);
|
||||
self.fetch_models_task.replace(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenRouterLanguageModelProvider {
|
||||
pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
|
||||
let state = cx.new(|cx| State {
|
||||
api_key: None,
|
||||
api_key_from_env: false,
|
||||
http_client: http_client.clone(),
|
||||
available_models: Vec::new(),
|
||||
fetch_models_task: None,
|
||||
_subscription: cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
|
||||
this.restart_fetch_models_task(cx);
|
||||
cx.notify();
|
||||
}),
|
||||
});
|
||||
|
||||
Self { http_client, state }
|
||||
}
|
||||
|
||||
fn create_language_model(&self, model: open_router::Model) -> Arc<dyn LanguageModel> {
|
||||
Arc::new(OpenRouterLanguageModel {
|
||||
id: LanguageModelId::from(model.id().to_string()),
|
||||
model,
|
||||
state: self.state.clone(),
|
||||
http_client: self.http_client.clone(),
|
||||
request_limiter: RateLimiter::new(4),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModelProviderState for OpenRouterLanguageModelProvider {
|
||||
type ObservableEntity = State;
|
||||
|
||||
fn observable_entity(&self) -> Option<gpui::Entity<Self::ObservableEntity>> {
|
||||
Some(self.state.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModelProvider for OpenRouterLanguageModelProvider {
|
||||
fn id(&self) -> LanguageModelProviderId {
|
||||
LanguageModelProviderId(PROVIDER_ID.into())
|
||||
}
|
||||
|
||||
fn name(&self) -> LanguageModelProviderName {
|
||||
LanguageModelProviderName(PROVIDER_NAME.into())
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::AiOpenRouter
|
||||
}
|
||||
|
||||
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
Some(self.create_language_model(open_router::Model::default()))
|
||||
}
|
||||
|
||||
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
Some(self.create_language_model(open_router::Model::default_fast()))
|
||||
}
|
||||
|
||||
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
|
||||
let mut models_from_api = self.state.read(cx).available_models.clone();
|
||||
let mut settings_models = Vec::new();
|
||||
|
||||
for model in &AllLanguageModelSettings::get_global(cx)
|
||||
.open_router
|
||||
.available_models
|
||||
{
|
||||
settings_models.push(open_router::Model {
|
||||
name: model.name.clone(),
|
||||
display_name: model.display_name.clone(),
|
||||
max_tokens: model.max_tokens,
|
||||
supports_tools: Some(false),
|
||||
});
|
||||
}
|
||||
|
||||
for settings_model in &settings_models {
|
||||
if let Some(pos) = models_from_api
|
||||
.iter()
|
||||
.position(|m| m.name == settings_model.name)
|
||||
{
|
||||
models_from_api[pos] = settings_model.clone();
|
||||
} else {
|
||||
models_from_api.push(settings_model.clone());
|
||||
}
|
||||
}
|
||||
|
||||
models_from_api
|
||||
.into_iter()
|
||||
.map(|model| self.create_language_model(model))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_authenticated(&self, cx: &App) -> bool {
|
||||
self.state.read(cx).is_authenticated()
|
||||
}
|
||||
|
||||
fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
|
||||
self.state.update(cx, |state, cx| state.authenticate(cx))
|
||||
}
|
||||
|
||||
fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView {
|
||||
cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
|
||||
self.state.update(cx, |state, cx| state.reset_api_key(cx))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenRouterLanguageModel {
|
||||
id: LanguageModelId,
|
||||
model: open_router::Model,
|
||||
state: gpui::Entity<State>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
request_limiter: RateLimiter,
|
||||
}
|
||||
|
||||
impl OpenRouterLanguageModel {
|
||||
fn stream_completion(
|
||||
&self,
|
||||
request: open_router::Request,
|
||||
cx: &AsyncApp,
|
||||
) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
|
||||
{
|
||||
let http_client = self.http_client.clone();
|
||||
let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| {
|
||||
let settings = &AllLanguageModelSettings::get_global(cx).open_router;
|
||||
(state.api_key.clone(), settings.api_url.clone())
|
||||
}) else {
|
||||
return futures::future::ready(Err(anyhow!(
|
||||
"App state dropped: Unable to read API key or API URL from the application state"
|
||||
)))
|
||||
.boxed();
|
||||
};
|
||||
|
||||
let future = self.request_limiter.stream(async move {
|
||||
let api_key = api_key.ok_or_else(|| anyhow!("Missing OpenRouter API Key"))?;
|
||||
let request = stream_completion(http_client.as_ref(), &api_url, &api_key, request);
|
||||
let response = request.await?;
|
||||
Ok(response)
|
||||
});
|
||||
|
||||
async move { Ok(future.await?.boxed()) }.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModel for OpenRouterLanguageModel {
|
||||
fn id(&self) -> LanguageModelId {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn name(&self) -> LanguageModelName {
|
||||
LanguageModelName::from(self.model.display_name().to_string())
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> LanguageModelProviderId {
|
||||
LanguageModelProviderId(PROVIDER_ID.into())
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> LanguageModelProviderName {
|
||||
LanguageModelProviderName(PROVIDER_NAME.into())
|
||||
}
|
||||
|
||||
fn supports_tools(&self) -> bool {
|
||||
self.model.supports_tool_calls()
|
||||
}
|
||||
|
||||
fn telemetry_id(&self) -> String {
|
||||
format!("openrouter/{}", self.model.id())
|
||||
}
|
||||
|
||||
fn max_token_count(&self) -> usize {
|
||||
self.model.max_token_count()
|
||||
}
|
||||
|
||||
fn max_output_tokens(&self) -> Option<u32> {
|
||||
self.model.max_output_tokens()
|
||||
}
|
||||
|
||||
fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
|
||||
match choice {
|
||||
LanguageModelToolChoice::Auto => true,
|
||||
LanguageModelToolChoice::Any => true,
|
||||
LanguageModelToolChoice::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_images(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn count_tokens(
|
||||
&self,
|
||||
request: LanguageModelRequest,
|
||||
cx: &App,
|
||||
) -> BoxFuture<'static, Result<usize>> {
|
||||
count_open_router_tokens(request, self.model.clone(), cx)
|
||||
}
|
||||
|
||||
fn stream_completion(
|
||||
&self,
|
||||
request: LanguageModelRequest,
|
||||
cx: &AsyncApp,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<
|
||||
futures::stream::BoxStream<
|
||||
'static,
|
||||
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
|
||||
>,
|
||||
>,
|
||||
> {
|
||||
let request = into_open_router(request, &self.model, self.max_output_tokens());
|
||||
let completions = self.stream_completion(request, cx);
|
||||
async move {
|
||||
let mapper = OpenRouterEventMapper::new();
|
||||
Ok(mapper.map_stream(completions.await?).boxed())
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_open_router(
|
||||
request: LanguageModelRequest,
|
||||
model: &Model,
|
||||
max_output_tokens: Option<u32>,
|
||||
) -> open_router::Request {
|
||||
let mut messages = Vec::new();
|
||||
for req_message in request.messages {
|
||||
for content in req_message.content {
|
||||
match content {
|
||||
MessageContent::Text(text) | MessageContent::Thinking { text, .. } => messages
|
||||
.push(match req_message.role {
|
||||
Role::User => open_router::RequestMessage::User { content: text },
|
||||
Role::Assistant => open_router::RequestMessage::Assistant {
|
||||
content: Some(text),
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
Role::System => open_router::RequestMessage::System { content: text },
|
||||
}),
|
||||
MessageContent::RedactedThinking(_) => {}
|
||||
MessageContent::Image(_) => {}
|
||||
MessageContent::ToolUse(tool_use) => {
|
||||
let tool_call = open_router::ToolCall {
|
||||
id: tool_use.id.to_string(),
|
||||
content: open_router::ToolCallContent::Function {
|
||||
function: open_router::FunctionContent {
|
||||
name: tool_use.name.to_string(),
|
||||
arguments: serde_json::to_string(&tool_use.input)
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(open_router::RequestMessage::Assistant { tool_calls, .. }) =
|
||||
messages.last_mut()
|
||||
{
|
||||
tool_calls.push(tool_call);
|
||||
} else {
|
||||
messages.push(open_router::RequestMessage::Assistant {
|
||||
content: None,
|
||||
tool_calls: vec![tool_call],
|
||||
});
|
||||
}
|
||||
}
|
||||
MessageContent::ToolResult(tool_result) => {
|
||||
let content = match &tool_result.content {
|
||||
LanguageModelToolResultContent::Text(text) => {
|
||||
text.to_string()
|
||||
}
|
||||
LanguageModelToolResultContent::Image(_) => {
|
||||
"[Tool responded with an image, but Zed doesn't support these in Open AI models yet]".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
messages.push(open_router::RequestMessage::Tool {
|
||||
content: content,
|
||||
tool_call_id: tool_result.tool_use_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open_router::Request {
|
||||
model: model.id().into(),
|
||||
messages,
|
||||
stream: true,
|
||||
stop: request.stop,
|
||||
temperature: request.temperature.unwrap_or(0.4),
|
||||
max_tokens: max_output_tokens,
|
||||
parallel_tool_calls: if model.supports_parallel_tool_calls() && !request.tools.is_empty() {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tools: request
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| open_router::ToolDefinition::Function {
|
||||
function: open_router::FunctionDefinition {
|
||||
name: tool.name,
|
||||
description: Some(tool.description),
|
||||
parameters: Some(tool.input_schema),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
tool_choice: request.tool_choice.map(|choice| match choice {
|
||||
LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto,
|
||||
LanguageModelToolChoice::Any => open_router::ToolChoice::Required,
|
||||
LanguageModelToolChoice::None => open_router::ToolChoice::None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenRouterEventMapper {
|
||||
tool_calls_by_index: HashMap<usize, RawToolCall>,
|
||||
}
|
||||
|
||||
impl OpenRouterEventMapper {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool_calls_by_index: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_stream(
|
||||
mut self,
|
||||
events: Pin<Box<dyn Send + Stream<Item = Result<ResponseStreamEvent>>>>,
|
||||
) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
|
||||
{
|
||||
events.flat_map(move |event| {
|
||||
futures::stream::iter(match event {
|
||||
Ok(event) => self.map_event(event),
|
||||
Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_event(
|
||||
&mut self,
|
||||
event: ResponseStreamEvent,
|
||||
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
|
||||
let Some(choice) = event.choices.first() else {
|
||||
return vec![Err(LanguageModelCompletionError::Other(anyhow!(
|
||||
"Response contained no choices"
|
||||
)))];
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
if let Some(content) = choice.delta.content.clone() {
|
||||
events.push(Ok(LanguageModelCompletionEvent::Text(content)));
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
|
||||
for tool_call in tool_calls {
|
||||
let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
|
||||
|
||||
if let Some(tool_id) = tool_call.id.clone() {
|
||||
entry.id = tool_id;
|
||||
}
|
||||
|
||||
if let Some(function) = tool_call.function.as_ref() {
|
||||
if let Some(name) = function.name.clone() {
|
||||
entry.name = name;
|
||||
}
|
||||
|
||||
if let Some(arguments) = function.arguments.clone() {
|
||||
entry.arguments.push_str(&arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match choice.finish_reason.as_deref() {
|
||||
Some("stop") => {
|
||||
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
|
||||
}
|
||||
Some("tool_calls") => {
|
||||
events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
|
||||
match serde_json::Value::from_str(&tool_call.arguments) {
|
||||
Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
|
||||
LanguageModelToolUse {
|
||||
id: tool_call.id.clone().into(),
|
||||
name: tool_call.name.as_str().into(),
|
||||
is_input_complete: true,
|
||||
input,
|
||||
raw_input: tool_call.arguments.clone(),
|
||||
},
|
||||
)),
|
||||
Err(error) => Err(LanguageModelCompletionError::BadInputJson {
|
||||
id: tool_call.id.into(),
|
||||
tool_name: tool_call.name.as_str().into(),
|
||||
raw_input: tool_call.arguments.into(),
|
||||
json_parse_error: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}));
|
||||
|
||||
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
|
||||
}
|
||||
Some(stop_reason) => {
|
||||
log::error!("Unexpected OpenAI stop_reason: {stop_reason:?}",);
|
||||
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RawToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
pub fn count_open_router_tokens(
|
||||
request: LanguageModelRequest,
|
||||
_model: open_router::Model,
|
||||
cx: &App,
|
||||
) -> BoxFuture<'static, Result<usize>> {
|
||||
cx.background_spawn(async move {
|
||||
let messages = request
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|message| tiktoken_rs::ChatCompletionRequestMessage {
|
||||
role: match message.role {
|
||||
Role::User => "user".into(),
|
||||
Role::Assistant => "assistant".into(),
|
||||
Role::System => "system".into(),
|
||||
},
|
||||
content: Some(message.string_contents()),
|
||||
name: None,
|
||||
function_call: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages)
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
struct ConfigurationView {
|
||||
api_key_editor: Entity<Editor>,
|
||||
state: gpui::Entity<State>,
|
||||
load_credentials_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl ConfigurationView {
|
||||
fn new(state: gpui::Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let api_key_editor = cx.new(|cx| {
|
||||
let mut editor = Editor::single_line(window, cx);
|
||||
editor
|
||||
.set_placeholder_text("sk_or_000000000000000000000000000000000000000000000000", cx);
|
||||
editor
|
||||
});
|
||||
|
||||
cx.observe(&state, |_, _, cx| {
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
|
||||
let load_credentials_task = Some(cx.spawn_in(window, {
|
||||
let state = state.clone();
|
||||
async move |this, cx| {
|
||||
if let Some(task) = state
|
||||
.update(cx, |state, cx| state.authenticate(cx))
|
||||
.log_err()
|
||||
{
|
||||
let _ = task.await;
|
||||
}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.load_credentials_task = None;
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
api_key_editor,
|
||||
state,
|
||||
load_credentials_task,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let api_key = self.api_key_editor.read(cx).text(cx);
|
||||
if api_key.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = self.state.clone();
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
state
|
||||
.update(cx, |state, cx| state.set_api_key(api_key, cx))?
|
||||
.await
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.api_key_editor
|
||||
.update(cx, |editor, cx| editor.set_text("", window, cx));
|
||||
|
||||
let state = self.state.clone();
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
state.update(cx, |state, cx| state.reset_api_key(cx))?.await
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_api_key_editor(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let text_style = TextStyle {
|
||||
color: cx.theme().colors().text,
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings.ui_font.features.clone(),
|
||||
font_fallbacks: settings.ui_font.fallbacks.clone(),
|
||||
font_size: rems(0.875).into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
font_style: FontStyle::Normal,
|
||||
line_height: relative(1.3),
|
||||
white_space: WhiteSpace::Normal,
|
||||
..Default::default()
|
||||
};
|
||||
EditorElement::new(
|
||||
&self.api_key_editor,
|
||||
EditorStyle {
|
||||
background: cx.theme().colors().editor_background,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
|
||||
!self.state.read(cx).is_authenticated()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConfigurationView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let env_var_set = self.state.read(cx).api_key_from_env;
|
||||
|
||||
if self.load_credentials_task.is_some() {
|
||||
div().child(Label::new("Loading credentials...")).into_any()
|
||||
} else if self.should_render_editor(cx) {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.on_action(cx.listener(Self::save_api_key))
|
||||
.child(Label::new("To use Zed's assistant with OpenRouter, you need to add an API key. Follow these steps:"))
|
||||
.child(
|
||||
List::new()
|
||||
.child(InstructionListItem::new(
|
||||
"Create an API key by visiting",
|
||||
Some("OpenRouter's console"),
|
||||
Some("https://openrouter.ai/keys"),
|
||||
))
|
||||
.child(InstructionListItem::text_only(
|
||||
"Ensure your OpenRouter account has credits",
|
||||
))
|
||||
.child(InstructionListItem::text_only(
|
||||
"Paste your API key below and hit enter to start using the assistant",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.my_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_sm()
|
||||
.child(self.render_api_key_editor(cx)),
|
||||
)
|
||||
.child(
|
||||
Label::new(
|
||||
format!("You can also assign the {OPENROUTER_API_KEY_VAR} environment variable and restart Zed."),
|
||||
)
|
||||
.size(LabelSize::Small).color(Color::Muted),
|
||||
)
|
||||
.into_any()
|
||||
} else {
|
||||
h_flex()
|
||||
.mt_1()
|
||||
.p_1()
|
||||
.justify_between()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().background)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Icon::new(IconName::Check).color(Color::Success))
|
||||
.child(Label::new(if env_var_set {
|
||||
format!("API key set in {OPENROUTER_API_KEY_VAR} environment variable.")
|
||||
} else {
|
||||
"API key configured.".to_string()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("reset-key", "Reset Key")
|
||||
.label_size(LabelSize::Small)
|
||||
.icon(Some(IconName::Trash))
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_position(IconPosition::Start)
|
||||
.disabled(env_var_set)
|
||||
.when(env_var_set, |this| {
|
||||
this.tooltip(Tooltip::text(format!("To reset your API key, unset the {OPENROUTER_API_KEY_VAR} environment variable.")))
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use crate::provider::{
|
||||
mistral::MistralSettings,
|
||||
ollama::OllamaSettings,
|
||||
open_ai::OpenAiSettings,
|
||||
open_router::OpenRouterSettings,
|
||||
};
|
||||
|
||||
/// Initializes the language model settings.
|
||||
@@ -61,6 +62,7 @@ pub struct AllLanguageModelSettings {
|
||||
pub bedrock: AmazonBedrockSettings,
|
||||
pub ollama: OllamaSettings,
|
||||
pub openai: OpenAiSettings,
|
||||
pub open_router: OpenRouterSettings,
|
||||
pub zed_dot_dev: ZedDotDevSettings,
|
||||
pub google: GoogleSettings,
|
||||
pub copilot_chat: CopilotChatSettings,
|
||||
@@ -76,6 +78,7 @@ pub struct AllLanguageModelSettingsContent {
|
||||
pub ollama: Option<OllamaSettingsContent>,
|
||||
pub lmstudio: Option<LmStudioSettingsContent>,
|
||||
pub openai: Option<OpenAiSettingsContent>,
|
||||
pub open_router: Option<OpenRouterSettingsContent>,
|
||||
#[serde(rename = "zed.dev")]
|
||||
pub zed_dot_dev: Option<ZedDotDevSettingsContent>,
|
||||
pub google: Option<GoogleSettingsContent>,
|
||||
@@ -271,6 +274,12 @@ pub struct ZedDotDevSettingsContent {
|
||||
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
pub struct CopilotChatSettingsContent {}
|
||||
|
||||
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
pub struct OpenRouterSettingsContent {
|
||||
pub api_url: Option<String>,
|
||||
pub available_models: Option<Vec<provider::open_router::AvailableModel>>,
|
||||
}
|
||||
|
||||
impl settings::Settings for AllLanguageModelSettings {
|
||||
const KEY: Option<&'static str> = Some("language_models");
|
||||
|
||||
@@ -409,6 +418,19 @@ impl settings::Settings for AllLanguageModelSettings {
|
||||
&mut settings.mistral.available_models,
|
||||
mistral.as_ref().and_then(|s| s.available_models.clone()),
|
||||
);
|
||||
|
||||
// OpenRouter
|
||||
let open_router = value.open_router.clone();
|
||||
merge(
|
||||
&mut settings.open_router.api_url,
|
||||
open_router.as_ref().and_then(|s| s.api_url.clone()),
|
||||
);
|
||||
merge(
|
||||
&mut settings.open_router.available_models,
|
||||
open_router
|
||||
.as_ref()
|
||||
.and_then(|s| s.available_models.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
|
||||
@@ -49,6 +49,14 @@ mod tests {
|
||||
assert_eq!(buffer.text(), expected);
|
||||
};
|
||||
|
||||
// Do not indent after shebang
|
||||
expect_indents_to(
|
||||
&mut buffer,
|
||||
cx,
|
||||
"#!/usr/bin/env bash\n#",
|
||||
"#!/usr/bin/env bash\n#",
|
||||
);
|
||||
|
||||
// indent function correctly
|
||||
expect_indents_to(
|
||||
&mut buffer,
|
||||
|
||||
@@ -29,6 +29,6 @@ brackets = [
|
||||
### bar
|
||||
### fi
|
||||
### ```
|
||||
increase_indent_pattern = "(\\s*|;)(do|then|in|else|elif)\\b.*$"
|
||||
decrease_indent_pattern = "(\\s*|;)\\b(fi|done|esac|else|elif)\\b.*$"
|
||||
increase_indent_pattern = "(^|\\s+|;)(do|then|in|else|elif)\\b.*$"
|
||||
decrease_indent_pattern = "(^|\\s+|;)(fi|done|esac|else|elif)\\b.*$"
|
||||
# make sure to test each line mode & block mode
|
||||
|
||||
@@ -106,6 +106,24 @@ impl LspAdapter for PythonLspAdapter {
|
||||
Self::SERVER_NAME.clone()
|
||||
}
|
||||
|
||||
async fn initialization_options(
|
||||
self: Arc<Self>,
|
||||
_: &dyn Fs,
|
||||
_: &Arc<dyn LspAdapterDelegate>,
|
||||
) -> Result<Option<Value>> {
|
||||
// Provide minimal initialization options
|
||||
// Virtual environment configuration will be handled through workspace configuration
|
||||
Ok(Some(json!({
|
||||
"python": {
|
||||
"analysis": {
|
||||
"autoSearchPaths": true,
|
||||
"useLibraryCodeForTypes": true,
|
||||
"autoImportCompletions": true
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
async fn check_if_user_installed(
|
||||
&self,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
@@ -128,9 +146,10 @@ impl LspAdapter for PythonLspAdapter {
|
||||
|
||||
let path = node_modules_path.join(NODE_MODULE_RELATIVE_SERVER_PATH);
|
||||
|
||||
let env = delegate.shell_env().await;
|
||||
Some(LanguageServerBinary {
|
||||
path: node,
|
||||
env: None,
|
||||
env: Some(env),
|
||||
arguments: server_binary_arguments(&path),
|
||||
})
|
||||
}
|
||||
@@ -151,7 +170,7 @@ impl LspAdapter for PythonLspAdapter {
|
||||
&self,
|
||||
latest_version: Box<dyn 'static + Send + Any>,
|
||||
container_dir: PathBuf,
|
||||
_: &dyn LspAdapterDelegate,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
) -> Result<LanguageServerBinary> {
|
||||
let latest_version = latest_version.downcast::<String>().unwrap();
|
||||
let server_path = container_dir.join(SERVER_PATH);
|
||||
@@ -163,9 +182,10 @@ impl LspAdapter for PythonLspAdapter {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let env = delegate.shell_env().await;
|
||||
Ok(LanguageServerBinary {
|
||||
path: self.node.binary_path().await?,
|
||||
env: None,
|
||||
env: Some(env),
|
||||
arguments: server_binary_arguments(&server_path),
|
||||
})
|
||||
}
|
||||
@@ -174,7 +194,7 @@ impl LspAdapter for PythonLspAdapter {
|
||||
&self,
|
||||
version: &(dyn 'static + Send + Any),
|
||||
container_dir: &PathBuf,
|
||||
_: &dyn LspAdapterDelegate,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
) -> Option<LanguageServerBinary> {
|
||||
let version = version.downcast_ref::<String>().unwrap();
|
||||
let server_path = container_dir.join(SERVER_PATH);
|
||||
@@ -192,9 +212,10 @@ impl LspAdapter for PythonLspAdapter {
|
||||
if should_install_language_server {
|
||||
None
|
||||
} else {
|
||||
let env = delegate.shell_env().await;
|
||||
Some(LanguageServerBinary {
|
||||
path: self.node.binary_path().await.ok()?,
|
||||
env: None,
|
||||
env: Some(env),
|
||||
arguments: server_binary_arguments(&server_path),
|
||||
})
|
||||
}
|
||||
@@ -203,9 +224,11 @@ impl LspAdapter for PythonLspAdapter {
|
||||
async fn cached_server_binary(
|
||||
&self,
|
||||
container_dir: PathBuf,
|
||||
_: &dyn LspAdapterDelegate,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
) -> Option<LanguageServerBinary> {
|
||||
get_cached_server_binary(container_dir, &self.node).await
|
||||
let mut binary = get_cached_server_binary(container_dir, &self.node).await?;
|
||||
binary.env = Some(delegate.shell_env().await);
|
||||
Some(binary)
|
||||
}
|
||||
|
||||
async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
|
||||
@@ -308,22 +331,64 @@ impl LspAdapter for PythonLspAdapter {
|
||||
.and_then(|s| s.settings.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// If python.pythonPath is not set in user config, do so using our toolchain picker.
|
||||
// If we have a detected toolchain, configure Pyright to use it
|
||||
if let Some(toolchain) = toolchain {
|
||||
if user_settings.is_null() {
|
||||
user_settings = Value::Object(serde_json::Map::default());
|
||||
}
|
||||
let object = user_settings.as_object_mut().unwrap();
|
||||
if let Some(python) = object
|
||||
|
||||
let interpreter_path = toolchain.path.to_string();
|
||||
|
||||
// Detect if this is a virtual environment
|
||||
if let Some(interpreter_dir) = Path::new(&interpreter_path).parent() {
|
||||
if let Some(venv_dir) = interpreter_dir.parent() {
|
||||
// Check if this looks like a virtual environment
|
||||
if venv_dir.join("pyvenv.cfg").exists()
|
||||
|| venv_dir.join("bin/activate").exists()
|
||||
|| venv_dir.join("Scripts/activate.bat").exists()
|
||||
{
|
||||
// Set venvPath and venv at the root level
|
||||
// This matches the format of a pyrightconfig.json file
|
||||
if let Some(parent) = venv_dir.parent() {
|
||||
// Use relative path if the venv is inside the workspace
|
||||
let venv_path = if parent == adapter.worktree_root_path() {
|
||||
".".to_string()
|
||||
} else {
|
||||
parent.to_string_lossy().into_owned()
|
||||
};
|
||||
object.insert("venvPath".to_string(), Value::String(venv_path));
|
||||
}
|
||||
|
||||
if let Some(venv_name) = venv_dir.file_name() {
|
||||
object.insert(
|
||||
"venv".to_owned(),
|
||||
Value::String(venv_name.to_string_lossy().into_owned()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always set the python interpreter path
|
||||
// Get or create the python section
|
||||
let python = object
|
||||
.entry("python")
|
||||
.or_insert(Value::Object(serde_json::Map::default()))
|
||||
.as_object_mut()
|
||||
{
|
||||
python
|
||||
.entry("pythonPath")
|
||||
.or_insert(Value::String(toolchain.path.into()));
|
||||
}
|
||||
.unwrap();
|
||||
|
||||
// Set both pythonPath and defaultInterpreterPath for compatibility
|
||||
python.insert(
|
||||
"pythonPath".to_owned(),
|
||||
Value::String(interpreter_path.clone()),
|
||||
);
|
||||
python.insert(
|
||||
"defaultInterpreterPath".to_owned(),
|
||||
Value::String(interpreter_path),
|
||||
);
|
||||
}
|
||||
|
||||
user_settings
|
||||
})
|
||||
}
|
||||
|
||||
25
crates/open_router/Cargo.toml
Normal file
25
crates/open_router/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "open_router"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/open_router.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
schemars = ["dep:schemars"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
http_client.workspace = true
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
1
crates/open_router/LICENSE-GPL
Symbolic link
1
crates/open_router/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
484
crates/open_router/src/open_router.rs
Normal file
484
crates/open_router/src/open_router.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
|
||||
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
pub const OPEN_ROUTER_API_URL: &str = "https://openrouter.ai/api/v1";
|
||||
|
||||
fn is_none_or_empty<T: AsRef<[U]>, U>(opt: &Option<T>) -> bool {
|
||||
opt.as_ref().map_or(true, |v| v.as_ref().is_empty())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Role {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self> {
|
||||
match value.as_str() {
|
||||
"user" => Ok(Self::User),
|
||||
"assistant" => Ok(Self::Assistant),
|
||||
"system" => Ok(Self::System),
|
||||
"tool" => Ok(Self::Tool),
|
||||
_ => Err(anyhow!("invalid role '{value}'")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Role> for String {
|
||||
fn from(val: Role) -> Self {
|
||||
match val {
|
||||
Role::User => "user".to_owned(),
|
||||
Role::Assistant => "assistant".to_owned(),
|
||||
Role::System => "system".to_owned(),
|
||||
Role::Tool => "tool".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Model {
|
||||
pub name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub max_tokens: usize,
|
||||
pub supports_tools: Option<bool>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn default_fast() -> Self {
|
||||
Self::new(
|
||||
"openrouter/auto",
|
||||
Some("Auto Router"),
|
||||
Some(2000000),
|
||||
Some(true),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn default() -> Self {
|
||||
Self::default_fast()
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
name: &str,
|
||||
display_name: Option<&str>,
|
||||
max_tokens: Option<usize>,
|
||||
supports_tools: Option<bool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
display_name: display_name.map(|s| s.to_owned()),
|
||||
max_tokens: max_tokens.unwrap_or(2000000),
|
||||
supports_tools,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
self.display_name.as_ref().unwrap_or(&self.name)
|
||||
}
|
||||
|
||||
pub fn max_token_count(&self) -> usize {
|
||||
self.max_tokens
|
||||
}
|
||||
|
||||
pub fn max_output_tokens(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn supports_tool_calls(&self) -> bool {
|
||||
self.supports_tools.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn supports_parallel_tool_calls(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub model: String,
|
||||
pub messages: Vec<RequestMessage>,
|
||||
pub stream: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop: Vec<String>,
|
||||
pub temperature: f32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<ToolChoice>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parallel_tool_calls: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ToolChoice {
|
||||
Auto,
|
||||
Required,
|
||||
None,
|
||||
Other(ToolDefinition),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolDefinition {
|
||||
#[allow(dead_code)]
|
||||
Function { function: FunctionDefinition },
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FunctionDefinition {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub parameters: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(tag = "role", rename_all = "lowercase")]
|
||||
pub enum RequestMessage {
|
||||
Assistant {
|
||||
content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
tool_calls: Vec<ToolCall>,
|
||||
},
|
||||
User {
|
||||
content: String,
|
||||
},
|
||||
System {
|
||||
content: String,
|
||||
},
|
||||
Tool {
|
||||
content: String,
|
||||
tool_call_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub content: ToolCallContent,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum ToolCallContent {
|
||||
Function { function: FunctionContent },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct FunctionContent {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct ResponseMessageDelta {
|
||||
pub role: Option<Role>,
|
||||
pub content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty")]
|
||||
pub tool_calls: Option<Vec<ToolCallChunk>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct ToolCallChunk {
|
||||
pub index: usize,
|
||||
pub id: Option<String>,
|
||||
pub function: Option<FunctionChunk>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct FunctionChunk {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ChoiceDelta {
|
||||
pub index: u32,
|
||||
pub delta: ResponseMessageDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ResponseStreamEvent {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub created: u32,
|
||||
pub model: String,
|
||||
pub choices: Vec<ChoiceDelta>,
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Response {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
pub usage: Usage,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Choice {
|
||||
pub index: u32,
|
||||
pub message: RequestMessage,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct ListModelsResponse {
|
||||
pub data: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created: usize,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_length: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub supported_parameters: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
request: Request,
|
||||
) -> Result<Response> {
|
||||
let uri = format!("{api_url}/chat/completions");
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("HTTP-Referer", "https://zed.dev")
|
||||
.header("X-Title", "Zed Editor");
|
||||
|
||||
let mut request_body = request;
|
||||
request_body.stream = false;
|
||||
|
||||
let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request_body)?))?;
|
||||
let mut response = client.send(request).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
let response: Response = serde_json::from_str(&body)?;
|
||||
Ok(response)
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenRouterResponse {
|
||||
error: OpenRouterError,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenRouterError {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
code: String,
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenRouterResponse>(&body) {
|
||||
Ok(response) if !response.error.message.is_empty() => {
|
||||
let error_message = if !response.error.code.is_empty() {
|
||||
format!("{}: {}", response.error.code, response.error.message)
|
||||
} else {
|
||||
response.error.message
|
||||
};
|
||||
|
||||
Err(anyhow!(
|
||||
"Failed to connect to OpenRouter API: {}",
|
||||
error_message
|
||||
))
|
||||
}
|
||||
_ => Err(anyhow!(
|
||||
"Failed to connect to OpenRouter API: {} {}",
|
||||
response.status(),
|
||||
body,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stream_completion(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
request: Request,
|
||||
) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
|
||||
let uri = format!("{api_url}/chat/completions");
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("HTTP-Referer", "https://zed.dev")
|
||||
.header("X-Title", "Zed Editor");
|
||||
|
||||
let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
|
||||
let mut response = client.send(request).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let reader = BufReader::new(response.into_body());
|
||||
Ok(reader
|
||||
.lines()
|
||||
.filter_map(|line| async move {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
if line.starts_with(':') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = line.strip_prefix("data: ")?;
|
||||
if line == "[DONE]" {
|
||||
None
|
||||
} else {
|
||||
match serde_json::from_str::<ResponseStreamEvent>(line) {
|
||||
Ok(response) => Some(Ok(response)),
|
||||
Err(error) => {
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
match serde_json::from_str::<ErrorResponse>(line) {
|
||||
Ok(err_response) => Some(Err(anyhow!(err_response.error))),
|
||||
Err(_) => {
|
||||
if line.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Err(anyhow!(
|
||||
"Failed to parse response: {}. Original content: '{}'",
|
||||
error, line
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => Some(Err(anyhow!(error))),
|
||||
}
|
||||
})
|
||||
.boxed())
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenRouterResponse {
|
||||
error: OpenRouterError,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenRouterError {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
code: String,
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenRouterResponse>(&body) {
|
||||
Ok(response) if !response.error.message.is_empty() => {
|
||||
let error_message = if !response.error.code.is_empty() {
|
||||
format!("{}: {}", response.error.code, response.error.message)
|
||||
} else {
|
||||
response.error.message
|
||||
};
|
||||
|
||||
Err(anyhow!(
|
||||
"Failed to connect to OpenRouter API: {}",
|
||||
error_message
|
||||
))
|
||||
}
|
||||
_ => Err(anyhow!(
|
||||
"Failed to connect to OpenRouter API: {} {}",
|
||||
response.status(),
|
||||
body,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_models(client: &dyn HttpClient, api_url: &str) -> Result<Vec<Model>> {
|
||||
let uri = format!("{api_url}/models");
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.header("Accept", "application/json");
|
||||
|
||||
let request = request_builder.body(AsyncBody::default())?;
|
||||
let mut response = client.send(request).await?;
|
||||
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let response: ListModelsResponse =
|
||||
serde_json::from_str(&body).context("Unable to parse OpenRouter models response")?;
|
||||
|
||||
let models = response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|entry| Model {
|
||||
name: entry.id,
|
||||
// OpenRouter returns display names in the format "provider_name: model_name".
|
||||
// When displayed in the UI, these names can get truncated from the right.
|
||||
// Since users typically already know the provider, we extract just the model name
|
||||
// portion (after the colon) to create a more concise and user-friendly label
|
||||
// for the model dropdown in the agent panel.
|
||||
display_name: Some(
|
||||
entry
|
||||
.name
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&entry.name)
|
||||
.trim()
|
||||
.to_string(),
|
||||
),
|
||||
max_tokens: entry.context_length.unwrap_or(2000000),
|
||||
supports_tools: Some(entry.supported_parameters.contains(&"tools".to_string())),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(models)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Failed to connect to OpenRouter API: {} {}",
|
||||
response.status(),
|
||||
body,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,8 @@ impl Prettier {
|
||||
prettier_plugin_dir.join("plugin.js"),
|
||||
// this one is for @prettier/plugin-php
|
||||
prettier_plugin_dir.join("standalone.js"),
|
||||
// this one is for prettier-plugin-latex
|
||||
prettier_plugin_dir.join("dist").join("prettier-plugin-latex.js"),
|
||||
prettier_plugin_dir,
|
||||
]
|
||||
.into_iter()
|
||||
|
||||
@@ -245,108 +245,39 @@ async fn load_shell_environment(
|
||||
Option<HashMap<String, String>>,
|
||||
Option<EnvironmentErrorMessage>,
|
||||
) {
|
||||
use crate::direnv::{DirenvError, load_direnv_environment};
|
||||
use std::path::PathBuf;
|
||||
use util::parse_env_output;
|
||||
use crate::direnv::load_direnv_environment;
|
||||
use util::shell_env;
|
||||
|
||||
fn message<T>(with: &str) -> (Option<T>, Option<EnvironmentErrorMessage>) {
|
||||
let message = EnvironmentErrorMessage::from_str(with);
|
||||
(None, Some(message))
|
||||
}
|
||||
|
||||
const MARKER: &str = "ZED_SHELL_START";
|
||||
let Some(shell) = std::env::var("SHELL").log_err() else {
|
||||
return message("Failed to get login environment. SHELL environment variable is not set");
|
||||
let dir_ = dir.to_owned();
|
||||
let mut envs = match smol::unblock(move || shell_env::capture(Some(dir_))).await {
|
||||
Ok(envs) => envs,
|
||||
Err(err) => {
|
||||
util::log_err(&err);
|
||||
return (
|
||||
None,
|
||||
Some(EnvironmentErrorMessage::from_str(
|
||||
"Failed to load environment variables. See log for details",
|
||||
)),
|
||||
);
|
||||
}
|
||||
};
|
||||
let shell_path = PathBuf::from(&shell);
|
||||
let shell_name = shell_path.file_name().and_then(|f| f.to_str());
|
||||
|
||||
// What we're doing here is to spawn a shell and then `cd` into
|
||||
// the project directory to get the env in there as if the user
|
||||
// `cd`'d into it. We do that because tools like direnv, asdf, ...
|
||||
// hook into `cd` and only set up the env after that.
|
||||
//
|
||||
// If the user selects `Direct` for direnv, it would set an environment
|
||||
// variable that later uses to know that it should not run the hook.
|
||||
// We would include in `.envs` call so it is okay to run the hook
|
||||
// even if direnv direct mode is enabled.
|
||||
//
|
||||
// In certain shells we need to execute additional_command in order to
|
||||
// trigger the behavior of direnv, etc.
|
||||
|
||||
let command = match shell_name {
|
||||
Some("fish") => format!(
|
||||
"cd '{}'; emit fish_prompt; printf '%s' {MARKER}; /usr/bin/env;",
|
||||
dir.display()
|
||||
),
|
||||
_ => format!(
|
||||
"cd '{}'; printf '%s' {MARKER}; /usr/bin/env;",
|
||||
dir.display()
|
||||
),
|
||||
};
|
||||
|
||||
// csh/tcsh only supports `-l` if it's the only flag. So this won't be a login shell.
|
||||
// Users must rely on vars from `~/.tcshrc` or `~/.cshrc` and not `.login` as a result.
|
||||
let args = match shell_name {
|
||||
Some("tcsh") | Some("csh") => vec!["-i".to_string(), "-c".to_string(), command],
|
||||
_ => vec![
|
||||
"-l".to_string(),
|
||||
"-i".to_string(),
|
||||
"-c".to_string(),
|
||||
command,
|
||||
],
|
||||
};
|
||||
|
||||
let Some(output) = smol::unblock(move || {
|
||||
util::set_pre_exec_to_start_new_session(std::process::Command::new(&shell).args(&args))
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.log_err() else {
|
||||
return message(
|
||||
"Failed to spawn login shell to source login environment variables. See logs for details",
|
||||
);
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
log::error!("login shell exited with {}", output.status);
|
||||
return message("Login shell exited with nonzero exit code. See logs for details");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(env_output_start) = stdout.find(MARKER) else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::error!(
|
||||
"failed to parse output of `env` command in login shell. stdout: {:?}, stderr: {:?}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
return message("Failed to parse stdout of env command. See logs for the output");
|
||||
};
|
||||
|
||||
let mut parsed_env = HashMap::default();
|
||||
let env_output = &stdout[env_output_start + MARKER.len()..];
|
||||
|
||||
parse_env_output(env_output, |key, value| {
|
||||
parsed_env.insert(key, value);
|
||||
});
|
||||
|
||||
let (direnv_environment, direnv_error) = match load_direnv {
|
||||
DirenvSettings::ShellHook => (None, None),
|
||||
DirenvSettings::Direct => match load_direnv_environment(&parsed_env, dir).await {
|
||||
DirenvSettings::Direct => match load_direnv_environment(&envs, dir).await {
|
||||
Ok(env) => (Some(env), None),
|
||||
Err(err) => (
|
||||
None,
|
||||
<Option<EnvironmentErrorMessage> as From<DirenvError>>::from(err),
|
||||
),
|
||||
Err(err) => (None, err.into()),
|
||||
},
|
||||
};
|
||||
|
||||
for (key, value) in direnv_environment.unwrap_or(HashMap::default()) {
|
||||
parsed_env.insert(key, value);
|
||||
if let Some(direnv_environment) = direnv_environment {
|
||||
envs.extend(direnv_environment);
|
||||
}
|
||||
|
||||
(Some(parsed_env), direnv_error)
|
||||
(Some(envs), direnv_error)
|
||||
}
|
||||
|
||||
fn get_directory_env_impl(
|
||||
|
||||
@@ -770,13 +770,26 @@ pub struct DirectoryItem {
|
||||
#[derive(Clone)]
|
||||
pub enum DirectoryLister {
|
||||
Project(Entity<Project>),
|
||||
Local(Arc<dyn Fs>),
|
||||
Local(Entity<Project>, Arc<dyn Fs>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DirectoryLister {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DirectoryLister::Project(project) => {
|
||||
write!(f, "DirectoryLister::Project({project:?})")
|
||||
}
|
||||
DirectoryLister::Local(project, _) => {
|
||||
write!(f, "DirectoryLister::Local({project:?})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectoryLister {
|
||||
pub fn is_local(&self, cx: &App) -> bool {
|
||||
match self {
|
||||
DirectoryLister::Local(_) => true,
|
||||
DirectoryLister::Local(..) => true,
|
||||
DirectoryLister::Project(project) => project.read(cx).is_local(),
|
||||
}
|
||||
}
|
||||
@@ -790,12 +803,28 @@ impl DirectoryLister {
|
||||
}
|
||||
|
||||
pub fn default_query(&self, cx: &mut App) -> String {
|
||||
if let DirectoryLister::Project(project) = self {
|
||||
if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() {
|
||||
return worktree.read(cx).abs_path().to_string_lossy().to_string();
|
||||
let separator = std::path::MAIN_SEPARATOR_STR;
|
||||
match self {
|
||||
DirectoryLister::Project(project) => project,
|
||||
DirectoryLister::Local(project, _) => project,
|
||||
}
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.map(|worktree| worktree.read(cx).abs_path())
|
||||
.map(|dir| dir.to_string_lossy().to_string())
|
||||
.or_else(|| std::env::home_dir().map(|dir| dir.to_string_lossy().to_string()))
|
||||
.map(|mut s| {
|
||||
s.push_str(separator);
|
||||
s
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
format!("C:{separator}")
|
||||
} else {
|
||||
format!("~{separator}")
|
||||
}
|
||||
};
|
||||
format!("~{}", std::path::MAIN_SEPARATOR_STR)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
|
||||
@@ -803,7 +832,7 @@ impl DirectoryLister {
|
||||
DirectoryLister::Project(project) => {
|
||||
project.update(cx, |project, cx| project.list_directory(path, cx))
|
||||
}
|
||||
DirectoryLister::Local(fs) => {
|
||||
DirectoryLister::Local(_, fs) => {
|
||||
let fs = fs.clone();
|
||||
cx.background_spawn(async move {
|
||||
let mut results = vec![];
|
||||
@@ -4049,7 +4078,7 @@ impl Project {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<DirectoryItem>>> {
|
||||
if self.is_local() {
|
||||
DirectoryLister::Local(self.fs.clone()).list_directory(query, cx)
|
||||
DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
|
||||
} else if let Some(session) = self.ssh_client.as_ref() {
|
||||
let path_buf = PathBuf::from(query);
|
||||
let request = proto::ListRemoteDirectory {
|
||||
|
||||
@@ -11,6 +11,7 @@ use buffer_diff::{
|
||||
use fs::FakeFs;
|
||||
use futures::{StreamExt, future};
|
||||
use git::{
|
||||
GitHostingProviderRegistry,
|
||||
repository::RepoPath,
|
||||
status::{StatusCode, TrackedStatus},
|
||||
};
|
||||
@@ -216,6 +217,71 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
GitHostingProviderRegistry::default_global(cx);
|
||||
git_hosting_providers::init(cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let str_path = path!("/dir");
|
||||
let path = Path::new(str_path);
|
||||
|
||||
fs.insert_tree(
|
||||
path!("/dir"),
|
||||
json!({
|
||||
".zed": {
|
||||
"settings.json": r#"{
|
||||
"git_hosting_providers": [
|
||||
{
|
||||
"provider": "gitlab",
|
||||
"base_url": "https://google.com",
|
||||
"name": "foo"
|
||||
}
|
||||
]
|
||||
}"#
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
|
||||
let (_worktree, _) =
|
||||
project.read_with(cx, |project, cx| project.find_worktree(path, cx).unwrap());
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
cx.update(|cx| {
|
||||
let provider = GitHostingProviderRegistry::global(cx);
|
||||
assert!(
|
||||
provider
|
||||
.list_hosting_providers()
|
||||
.into_iter()
|
||||
.any(|provider| provider.name() == "foo")
|
||||
);
|
||||
});
|
||||
|
||||
fs.atomic_write(
|
||||
Path::new(path!("/dir/.zed/settings.json")).to_owned(),
|
||||
"{}".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update(|cx| {
|
||||
let provider = GitHostingProviderRegistry::global(cx);
|
||||
assert!(
|
||||
!provider
|
||||
.list_hosting_providers()
|
||||
.into_iter()
|
||||
.any(|provider| provider.name() == "foo")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -22,7 +22,7 @@ use gpui::{
|
||||
Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
|
||||
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
|
||||
Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
|
||||
anchored, deferred, div, impl_actions, point, px, size, uniform_list,
|
||||
anchored, deferred, div, impl_actions, point, px, size, transparent_white, uniform_list,
|
||||
};
|
||||
use indexmap::IndexMap;
|
||||
use language::DiagnosticSeverity;
|
||||
@@ -85,8 +85,7 @@ pub struct ProjectPanel {
|
||||
ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
|
||||
folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
|
||||
last_worktree_root_id: Option<ProjectEntryId>,
|
||||
last_selection_drag_over_entry: Option<ProjectEntryId>,
|
||||
last_external_paths_drag_over_entry: Option<ProjectEntryId>,
|
||||
drag_target_entry: Option<DragTargetEntry>,
|
||||
expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
|
||||
unfolded_dir_ids: HashSet<ProjectEntryId>,
|
||||
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
|
||||
@@ -112,6 +111,13 @@ pub struct ProjectPanel {
|
||||
hover_expand_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
struct DragTargetEntry {
|
||||
/// The entry currently under the mouse cursor during a drag operation
|
||||
entry_id: ProjectEntryId,
|
||||
/// Highlight this entry along with all of its children
|
||||
highlight_entry_id: Option<ProjectEntryId>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
struct FoldedDirectoryDragTarget {
|
||||
entry_id: ProjectEntryId,
|
||||
@@ -472,9 +478,8 @@ impl ProjectPanel {
|
||||
visible_entries: Default::default(),
|
||||
ancestors: Default::default(),
|
||||
folded_directory_drag_target: None,
|
||||
drag_target_entry: None,
|
||||
last_worktree_root_id: Default::default(),
|
||||
last_external_paths_drag_over_entry: None,
|
||||
last_selection_drag_over_entry: None,
|
||||
expanded_dir_ids: Default::default(),
|
||||
unfolded_dir_ids: Default::default(),
|
||||
selection: None,
|
||||
@@ -3703,6 +3708,67 @@ impl ProjectPanel {
|
||||
(depth, difference)
|
||||
}
|
||||
|
||||
fn highlight_entry_for_external_drag(
|
||||
&self,
|
||||
target_entry: &Entry,
|
||||
target_worktree: &Worktree,
|
||||
) -> Option<ProjectEntryId> {
|
||||
// Always highlight directory or parent directory if it's file
|
||||
if target_entry.is_dir() {
|
||||
Some(target_entry.id)
|
||||
} else if let Some(parent_entry) = target_entry
|
||||
.path
|
||||
.parent()
|
||||
.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
|
||||
{
|
||||
Some(parent_entry.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_entry_for_selection_drag(
|
||||
&self,
|
||||
target_entry: &Entry,
|
||||
target_worktree: &Worktree,
|
||||
dragged_selection: &DraggedSelection,
|
||||
cx: &Context<Self>,
|
||||
) -> Option<ProjectEntryId> {
|
||||
let target_parent_path = target_entry.path.parent();
|
||||
|
||||
// In case of single item drag, we do not highlight existing
|
||||
// directory which item belongs too
|
||||
if dragged_selection.items().count() == 1 {
|
||||
let active_entry_path = self
|
||||
.project
|
||||
.read(cx)
|
||||
.path_for_entry(dragged_selection.active_selection.entry_id, cx)?;
|
||||
|
||||
if let Some(active_parent_path) = active_entry_path.path.parent() {
|
||||
// Do not highlight active entry parent
|
||||
if active_parent_path == target_entry.path.as_ref() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Do not highlight active entry sibling files
|
||||
if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always highlight directory or parent directory if it's file
|
||||
if target_entry.is_dir() {
|
||||
Some(target_entry.id)
|
||||
} else if let Some(parent_entry) =
|
||||
target_parent_path.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
|
||||
{
|
||||
Some(parent_entry.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_entry(
|
||||
&self,
|
||||
entry_id: ProjectEntryId,
|
||||
@@ -3745,6 +3811,8 @@ impl ProjectPanel {
|
||||
.as_ref()
|
||||
.map(|f| f.to_string_lossy().to_string());
|
||||
let path = details.path.clone();
|
||||
let path_for_external_paths = path.clone();
|
||||
let path_for_dragged_selection = path.clone();
|
||||
|
||||
let depth = details.depth;
|
||||
let worktree_id = details.worktree_id;
|
||||
@@ -3802,6 +3870,27 @@ impl ProjectPanel {
|
||||
};
|
||||
|
||||
let folded_directory_drag_target = self.folded_directory_drag_target;
|
||||
let is_highlighted = {
|
||||
if let Some(highlight_entry_id) = self
|
||||
.drag_target_entry
|
||||
.as_ref()
|
||||
.and_then(|drag_target| drag_target.highlight_entry_id)
|
||||
{
|
||||
// Highlight if same entry or it's children
|
||||
if entry_id == highlight_entry_id {
|
||||
true
|
||||
} else {
|
||||
maybe!({
|
||||
let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
|
||||
let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
|
||||
Some(path.starts_with(&highlight_entry.path))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
div()
|
||||
.id(entry_id.to_proto() as usize)
|
||||
@@ -3815,95 +3904,111 @@ impl ProjectPanel {
|
||||
.hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
|
||||
.on_drag_move::<ExternalPaths>(cx.listener(
|
||||
move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
|
||||
if event.bounds.contains(&event.event.position) {
|
||||
if this.last_external_paths_drag_over_entry == Some(entry_id) {
|
||||
return;
|
||||
let is_current_target = this.drag_target_entry.as_ref()
|
||||
.map(|entry| entry.entry_id) == Some(entry_id);
|
||||
|
||||
if !event.bounds.contains(&event.event.position) {
|
||||
// Entry responsible for setting drag target is also responsible to
|
||||
// clear it up after drag is out of bounds
|
||||
if is_current_target {
|
||||
this.drag_target_entry = None;
|
||||
}
|
||||
this.last_external_paths_drag_over_entry = Some(entry_id);
|
||||
this.marked_entries.clear();
|
||||
|
||||
let Some((worktree, path, entry)) = maybe!({
|
||||
let worktree = this
|
||||
.project
|
||||
.read(cx)
|
||||
.worktree_for_id(selection.worktree_id, cx)?;
|
||||
let worktree = worktree.read(cx);
|
||||
let entry = worktree.entry_for_path(&path)?;
|
||||
let path = if entry.is_dir() {
|
||||
path.as_ref()
|
||||
} else {
|
||||
path.parent()?
|
||||
};
|
||||
Some((worktree, path, entry))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.marked_entries.insert(SelectedEntry {
|
||||
entry_id: entry.id,
|
||||
worktree_id: worktree.id(),
|
||||
});
|
||||
|
||||
for entry in worktree.child_entries(path) {
|
||||
this.marked_entries.insert(SelectedEntry {
|
||||
entry_id: entry.id,
|
||||
worktree_id: worktree.id(),
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if is_current_target {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((entry_id, highlight_entry_id)) = maybe!({
|
||||
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
|
||||
let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?;
|
||||
let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree);
|
||||
Some((target_entry.id, highlight_entry_id))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.drag_target_entry = Some(DragTargetEntry {
|
||||
entry_id,
|
||||
highlight_entry_id,
|
||||
});
|
||||
this.marked_entries.clear();
|
||||
},
|
||||
))
|
||||
.on_drop(cx.listener(
|
||||
move |this, external_paths: &ExternalPaths, window, cx| {
|
||||
this.drag_target_entry = None;
|
||||
this.hover_scroll_task.take();
|
||||
this.last_external_paths_drag_over_entry = None;
|
||||
this.marked_entries.clear();
|
||||
this.drop_external_files(external_paths.paths(), entry_id, window, cx);
|
||||
cx.stop_propagation();
|
||||
},
|
||||
))
|
||||
.on_drag_move::<DraggedSelection>(cx.listener(
|
||||
move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
|
||||
if event.bounds.contains(&event.event.position) {
|
||||
if this.last_selection_drag_over_entry == Some(entry_id) {
|
||||
return;
|
||||
}
|
||||
this.last_selection_drag_over_entry = Some(entry_id);
|
||||
this.hover_expand_task.take();
|
||||
let is_current_target = this.drag_target_entry.as_ref()
|
||||
.map(|entry| entry.entry_id) == Some(entry_id);
|
||||
|
||||
if !kind.is_dir()
|
||||
|| this
|
||||
.expanded_dir_ids
|
||||
.get(&details.worktree_id)
|
||||
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
|
||||
{
|
||||
return;
|
||||
if !event.bounds.contains(&event.event.position) {
|
||||
// Entry responsible for setting drag target is also responsible to
|
||||
// clear it up after drag is out of bounds
|
||||
if is_current_target {
|
||||
this.drag_target_entry = None;
|
||||
}
|
||||
|
||||
let bounds = event.bounds;
|
||||
this.hover_expand_task =
|
||||
Some(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(500))
|
||||
.await;
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.hover_expand_task.take();
|
||||
if this.last_selection_drag_over_entry == Some(entry_id)
|
||||
&& bounds.contains(&window.mouse_position())
|
||||
{
|
||||
this.expand_entry(worktree_id, entry_id, cx);
|
||||
this.update_visible_entries(
|
||||
Some((worktree_id, entry_id)),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if is_current_target {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((entry_id, highlight_entry_id)) = maybe!({
|
||||
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
|
||||
let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?;
|
||||
let dragged_selection = event.drag(cx);
|
||||
let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, dragged_selection, cx);
|
||||
Some((target_entry.id, highlight_entry_id))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.drag_target_entry = Some(DragTargetEntry {
|
||||
entry_id,
|
||||
highlight_entry_id,
|
||||
});
|
||||
this.marked_entries.clear();
|
||||
this.hover_expand_task.take();
|
||||
|
||||
if !kind.is_dir()
|
||||
|| this
|
||||
.expanded_dir_ids
|
||||
.get(&details.worktree_id)
|
||||
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let bounds = event.bounds;
|
||||
this.hover_expand_task =
|
||||
Some(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(500))
|
||||
.await;
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.hover_expand_task.take();
|
||||
if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id)
|
||||
&& bounds.contains(&window.mouse_position())
|
||||
{
|
||||
this.expand_entry(worktree_id, entry_id, cx);
|
||||
this.update_visible_entries(
|
||||
Some((worktree_id, entry_id)),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
},
|
||||
))
|
||||
.on_drag(
|
||||
@@ -3917,14 +4022,10 @@ impl ProjectPanel {
|
||||
})
|
||||
},
|
||||
)
|
||||
.drag_over::<DraggedSelection>(move |style, _, _, _| {
|
||||
if folded_directory_drag_target.is_some() {
|
||||
return style;
|
||||
}
|
||||
style.bg(item_colors.drag_over)
|
||||
})
|
||||
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
|
||||
.on_drop(
|
||||
cx.listener(move |this, selections: &DraggedSelection, window, cx| {
|
||||
this.drag_target_entry = None;
|
||||
this.hover_scroll_task.take();
|
||||
this.hover_expand_task.take();
|
||||
if folded_directory_drag_target.is_some() {
|
||||
@@ -4126,6 +4227,7 @@ impl ProjectPanel {
|
||||
div()
|
||||
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
|
||||
this.hover_scroll_task.take();
|
||||
this.drag_target_entry = None;
|
||||
this.folded_directory_drag_target = None;
|
||||
if let Some(target_entry_id) = target_entry_id {
|
||||
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
|
||||
@@ -4208,6 +4310,7 @@ impl ProjectPanel {
|
||||
))
|
||||
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
|
||||
this.hover_scroll_task.take();
|
||||
this.drag_target_entry = None;
|
||||
this.folded_directory_drag_target = None;
|
||||
if let Some(target_entry_id) = target_entry_id {
|
||||
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
|
||||
@@ -4573,13 +4676,14 @@ impl Render for ProjectPanel {
|
||||
.map(|(_, worktree_entries, _)| worktree_entries.len())
|
||||
.sum();
|
||||
|
||||
fn handle_drag_move_scroll<T: 'static>(
|
||||
fn handle_drag_move<T: 'static>(
|
||||
this: &mut ProjectPanel,
|
||||
e: &DragMoveEvent<T>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ProjectPanel>,
|
||||
) {
|
||||
if !e.bounds.contains(&e.event.position) {
|
||||
this.drag_target_entry = None;
|
||||
return;
|
||||
}
|
||||
this.hover_scroll_task.take();
|
||||
@@ -4633,8 +4737,8 @@ impl Render for ProjectPanel {
|
||||
h_flex()
|
||||
.id("project-panel")
|
||||
.group("project-panel")
|
||||
.on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
|
||||
.on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
|
||||
.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
|
||||
.on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
|
||||
.size_full()
|
||||
.relative()
|
||||
.on_hover(cx.listener(|this, hovered, window, cx| {
|
||||
@@ -4890,8 +4994,7 @@ impl Render for ProjectPanel {
|
||||
})
|
||||
.on_drop(cx.listener(
|
||||
move |this, external_paths: &ExternalPaths, window, cx| {
|
||||
this.last_external_paths_drag_over_entry = None;
|
||||
this.marked_entries.clear();
|
||||
this.drag_target_entry = None;
|
||||
this.hover_scroll_task.take();
|
||||
if let Some(task) = this
|
||||
.workspace
|
||||
|
||||
@@ -5098,6 +5098,205 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"dir1": {
|
||||
"file1.txt": "",
|
||||
"dir2": {
|
||||
"file2.txt": ""
|
||||
}
|
||||
},
|
||||
"file3.txt": ""
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
let project = panel.project.read(cx);
|
||||
let worktree = project.visible_worktrees(cx).next().unwrap();
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
// Test 1: Target is a directory, should highlight the directory itself
|
||||
let dir_entry = worktree.entry_for_path("dir1").unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(dir_entry.id),
|
||||
"Should highlight directory itself"
|
||||
);
|
||||
|
||||
// Test 2: Target is nested file, should highlight immediate parent
|
||||
let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap();
|
||||
let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(nested_parent.id),
|
||||
"Should highlight immediate parent"
|
||||
);
|
||||
|
||||
// Test 3: Target is root level file, should highlight root
|
||||
let root_file = worktree.entry_for_path("file3.txt").unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(root_file, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(worktree.root_entry().unwrap().id),
|
||||
"Root level file should return None"
|
||||
);
|
||||
|
||||
// Test 4: Target is root itself, should highlight root
|
||||
let root_entry = worktree.root_entry().unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(root_entry.id),
|
||||
"Root level file should return None"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"parent_dir": {
|
||||
"child_file.txt": "",
|
||||
"sibling_file.txt": "",
|
||||
"child_dir": {
|
||||
"nested_file.txt": ""
|
||||
}
|
||||
},
|
||||
"other_dir": {
|
||||
"other_file.txt": ""
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
let project = panel.project.read(cx);
|
||||
let worktree = project.visible_worktrees(cx).next().unwrap();
|
||||
let worktree_id = worktree.read(cx).id();
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
let parent_dir = worktree.entry_for_path("parent_dir").unwrap();
|
||||
let child_file = worktree
|
||||
.entry_for_path("parent_dir/child_file.txt")
|
||||
.unwrap();
|
||||
let sibling_file = worktree
|
||||
.entry_for_path("parent_dir/sibling_file.txt")
|
||||
.unwrap();
|
||||
let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap();
|
||||
let other_dir = worktree.entry_for_path("other_dir").unwrap();
|
||||
let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap();
|
||||
|
||||
// Test 1: Single item drag, don't highlight parent directory
|
||||
let dragged_selection = DraggedSelection {
|
||||
active_selection: SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
}])),
|
||||
};
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(result, None, "Should not highlight parent of dragged item");
|
||||
|
||||
// Test 2: Single item drag, don't highlight sibling files
|
||||
let result = panel.highlight_entry_for_selection_drag(
|
||||
sibling_file,
|
||||
worktree,
|
||||
&dragged_selection,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(result, None, "Should not highlight sibling files");
|
||||
|
||||
// Test 3: Single item drag, highlight unrelated directory
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(other_dir.id),
|
||||
"Should highlight unrelated directory"
|
||||
);
|
||||
|
||||
// Test 4: Single item drag, highlight sibling directory
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(child_dir.id),
|
||||
"Should highlight sibling directory"
|
||||
);
|
||||
|
||||
// Test 5: Multiple items drag, highlight parent directory
|
||||
let dragged_selection = DraggedSelection {
|
||||
active_selection: SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
marked_selections: Arc::new(BTreeSet::from([
|
||||
SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: sibling_file.id,
|
||||
},
|
||||
])),
|
||||
};
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(parent_dir.id),
|
||||
"Should highlight parent with multiple items"
|
||||
);
|
||||
|
||||
// Test 6: Target is file in different directory, highlight parent
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(other_dir.id),
|
||||
"Should highlight parent of target file"
|
||||
);
|
||||
|
||||
// Test 7: Target is directory, always highlight
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(child_dir.id),
|
||||
"Should always highlight directories"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
|
||||
let path = path.as_ref();
|
||||
panel.update(cx, |panel, cx| {
|
||||
|
||||
@@ -147,7 +147,7 @@ impl ProjectPicker {
|
||||
) -> Entity<Self> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let lister = project::DirectoryLister::Project(project.clone());
|
||||
let delegate = file_finder::OpenPathDelegate::new(tx, lister);
|
||||
let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
|
||||
|
||||
let picker = cx.new(|cx| {
|
||||
let picker = Picker::uniform_list(delegate, window, cx)
|
||||
|
||||
@@ -261,6 +261,7 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
let rule = self.matches.get(ix)?;
|
||||
let default = rule.default;
|
||||
let prompt_id = rule.id;
|
||||
|
||||
let element = ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
@@ -272,9 +273,10 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
.child(Label::new(rule.title.clone().unwrap_or("Untitled".into()))),
|
||||
)
|
||||
.end_slot::<IconButton>(default.then(|| {
|
||||
IconButton::new("toggle-default-rule", IconName::SparkleFilled)
|
||||
IconButton::new("toggle-default-rule", IconName::StarFilled)
|
||||
.toggle_state(true)
|
||||
.icon_color(Color::Accent)
|
||||
.icon_size(IconSize::Small)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::text("Remove from Default Rules"))
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
@@ -283,7 +285,7 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
}))
|
||||
.end_hover_slot(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap_1()
|
||||
.child(if prompt_id.is_built_in() {
|
||||
div()
|
||||
.id("built-in-rule")
|
||||
@@ -299,8 +301,9 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
})
|
||||
.into_any()
|
||||
} else {
|
||||
IconButton::new("delete-rule", IconName::Trash)
|
||||
IconButton::new("delete-rule", IconName::TrashAlt)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_size(IconSize::Small)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::text("Delete Rule"))
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
@@ -309,16 +312,27 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
.into_any_element()
|
||||
})
|
||||
.child(
|
||||
IconButton::new("toggle-default-rule", IconName::Sparkle)
|
||||
IconButton::new("toggle-default-rule", IconName::Star)
|
||||
.toggle_state(default)
|
||||
.selected_icon(IconName::SparkleFilled)
|
||||
.selected_icon(IconName::StarFilled)
|
||||
.icon_color(if default { Color::Accent } else { Color::Muted })
|
||||
.icon_size(IconSize::Small)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::text(if default {
|
||||
"Remove from Default Rules"
|
||||
} else {
|
||||
"Add to Default Rules"
|
||||
}))
|
||||
.map(|this| {
|
||||
if default {
|
||||
this.tooltip(Tooltip::text("Remove from Default Rules"))
|
||||
} else {
|
||||
this.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Add to Default Rules",
|
||||
None,
|
||||
"Always included in every thread.",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
|
||||
})),
|
||||
@@ -1008,216 +1022,180 @@ impl RulesLibrary {
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.pl(DynamicSpacing::Base16.rems(cx))
|
||||
.pt(DynamicSpacing::Base08.rems(cx))
|
||||
.on_click(cx.listener(move |_, _, window, _| {
|
||||
window.focus(&focus_handle);
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.group("active-editor-header")
|
||||
.pr(DynamicSpacing::Base16.rems(cx))
|
||||
.pt(DynamicSpacing::Base02.rems(cx))
|
||||
.pb(DynamicSpacing::Base08.rems(cx))
|
||||
.pt_2()
|
||||
.px_2p5()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex().gap_1().child(
|
||||
div()
|
||||
.max_w_80()
|
||||
.on_action(cx.listener(Self::move_down_from_title))
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
.rounded_sm()
|
||||
.group_hover("active-editor-header", |this| {
|
||||
this.border_color(
|
||||
cx.theme().colors().border_variant,
|
||||
)
|
||||
})
|
||||
.child(EditorElement::new(
|
||||
&rule_editor.title_editor,
|
||||
EditorStyle {
|
||||
background: cx.theme().system().transparent,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: TextStyle {
|
||||
color: cx
|
||||
.theme()
|
||||
.colors()
|
||||
.editor_foreground,
|
||||
font_family: settings
|
||||
.ui_font
|
||||
.family
|
||||
.clone(),
|
||||
font_features: settings
|
||||
.ui_font
|
||||
.features
|
||||
.clone(),
|
||||
font_size: HeadlineSize::Large
|
||||
.rems()
|
||||
.into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
line_height: relative(
|
||||
settings.buffer_line_height.value(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
scrollbar_width: Pixels::ZERO,
|
||||
syntax: cx.theme().syntax().clone(),
|
||||
status: cx.theme().status().clone(),
|
||||
inlay_hints_style:
|
||||
editor::make_inlay_hints_style(cx),
|
||||
inline_completion_styles:
|
||||
editor::make_suggestion_styles(cx),
|
||||
..EditorStyle::default()
|
||||
div()
|
||||
.w_full()
|
||||
.on_action(cx.listener(Self::move_down_from_title))
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
.rounded_sm()
|
||||
.group_hover("active-editor-header", |this| {
|
||||
this.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.child(EditorElement::new(
|
||||
&rule_editor.title_editor,
|
||||
EditorStyle {
|
||||
background: cx.theme().system().transparent,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: TextStyle {
|
||||
color: cx.theme().colors().editor_foreground,
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings
|
||||
.ui_font
|
||||
.features
|
||||
.clone(),
|
||||
font_size: HeadlineSize::Large.rems().into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
line_height: relative(
|
||||
settings.buffer_line_height.value(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
),
|
||||
scrollbar_width: Pixels::ZERO,
|
||||
syntax: cx.theme().syntax().clone(),
|
||||
status: cx.theme().status().clone(),
|
||||
inlay_hints_style: editor::make_inlay_hints_style(
|
||||
cx,
|
||||
),
|
||||
inline_completion_styles:
|
||||
editor::make_suggestion_styles(cx),
|
||||
..EditorStyle::default()
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap(DynamicSpacing::Base16.rems(cx))
|
||||
.child(div()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap(DynamicSpacing::Base16.rems(cx))
|
||||
.children(rule_editor.token_count.map(
|
||||
|token_count| {
|
||||
let token_count: SharedString =
|
||||
token_count.to_string().into();
|
||||
let label_token_count: SharedString =
|
||||
token_count.to_string().into();
|
||||
.flex_shrink_0()
|
||||
.gap(DynamicSpacing::Base04.rems(cx))
|
||||
.children(rule_editor.token_count.map(|token_count| {
|
||||
let token_count: SharedString =
|
||||
token_count.to_string().into();
|
||||
let label_token_count: SharedString =
|
||||
token_count.to_string().into();
|
||||
|
||||
h_flex()
|
||||
.id("token_count")
|
||||
.tooltip(move |window, cx| {
|
||||
let token_count =
|
||||
token_count.clone();
|
||||
|
||||
Tooltip::with_meta(
|
||||
format!(
|
||||
"{} tokens",
|
||||
token_count.clone()
|
||||
),
|
||||
None,
|
||||
format!(
|
||||
"Model: {}",
|
||||
model
|
||||
.as_ref()
|
||||
.map(|model| model
|
||||
.name()
|
||||
.0)
|
||||
.unwrap_or_default()
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Label::new(format!(
|
||||
"{} tokens",
|
||||
label_token_count.clone()
|
||||
))
|
||||
.color(Color::Muted),
|
||||
)
|
||||
},
|
||||
))
|
||||
.child(if prompt_id.is_built_in() {
|
||||
div()
|
||||
.id("built-in-rule")
|
||||
.child(
|
||||
Icon::new(IconName::FileLock)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Built-in rule",
|
||||
None,
|
||||
BUILT_IN_TOOLTIP_TEXT,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any()
|
||||
} else {
|
||||
IconButton::new("delete-rule", IconName::Trash)
|
||||
.size(ButtonSize::Large)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.shape(IconButtonShape::Square)
|
||||
.size(ButtonSize::Large)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Delete Rule",
|
||||
&DeleteRule,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(DeleteRule),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.into_any_element()
|
||||
div()
|
||||
.id("token_count")
|
||||
.mr_1()
|
||||
.flex_shrink_0()
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Token Estimation",
|
||||
None,
|
||||
format!(
|
||||
"Model: {}",
|
||||
model
|
||||
.as_ref()
|
||||
.map(|model| model.name().0)
|
||||
.unwrap_or_default()
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.child(
|
||||
IconButton::new(
|
||||
"duplicate-rule",
|
||||
IconName::BookCopy,
|
||||
)
|
||||
.size(ButtonSize::Large)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.shape(IconButtonShape::Square)
|
||||
.size(ButtonSize::Large)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Duplicate Rule",
|
||||
&DuplicateRule,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(DuplicateRule),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(
|
||||
"toggle-default-rule",
|
||||
IconName::Sparkle,
|
||||
)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.toggle_state(rule_metadata.default)
|
||||
.selected_icon(IconName::SparkleFilled)
|
||||
.icon_color(if rule_metadata.default {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.shape(IconButtonShape::Square)
|
||||
.size(ButtonSize::Large)
|
||||
.tooltip(Tooltip::text(
|
||||
if rule_metadata.default {
|
||||
"Remove from Default Rules"
|
||||
} else {
|
||||
"Add to Default Rules"
|
||||
},
|
||||
Label::new(format!(
|
||||
"{} tokens",
|
||||
label_token_count.clone()
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ToggleDefaultRule),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}))
|
||||
.child(if prompt_id.is_built_in() {
|
||||
div()
|
||||
.id("built-in-rule")
|
||||
.child(
|
||||
Icon::new(IconName::FileLock)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Built-in rule",
|
||||
None,
|
||||
BUILT_IN_TOOLTIP_TEXT,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any()
|
||||
} else {
|
||||
IconButton::new("delete-rule", IconName::TrashAlt)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Delete Rule",
|
||||
&DeleteRule,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window
|
||||
.dispatch_action(Box::new(DeleteRule), cx);
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.child(
|
||||
IconButton::new("duplicate-rule", IconName::BookCopy)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Duplicate Rule",
|
||||
&DuplicateRule,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(DuplicateRule),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("toggle-default-rule", IconName::Star)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(rule_metadata.default)
|
||||
.selected_icon(IconName::StarFilled)
|
||||
.icon_color(if rule_metadata.default {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.map(|this| {
|
||||
if rule_metadata.default {
|
||||
this.tooltip(Tooltip::text(
|
||||
"Remove from Default Rules",
|
||||
))
|
||||
} else {
|
||||
this.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Add to Default Rules",
|
||||
None,
|
||||
"Always included in every thread.",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ToggleDefaultRule),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1228,7 +1206,14 @@ impl RulesLibrary {
|
||||
.on_action(cx.listener(Self::move_up_from_body))
|
||||
.flex_grow()
|
||||
.h_full()
|
||||
.child(rule_editor.body_editor.clone()),
|
||||
.child(
|
||||
h_flex()
|
||||
.py_2()
|
||||
.pl_2p5()
|
||||
.h_full()
|
||||
.flex_1()
|
||||
.child(rule_editor.body_editor.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -250,6 +250,7 @@ trait AnySettingValue: 'static + Send + Sync {
|
||||
cx: &mut App,
|
||||
) -> Result<Box<dyn Any>>;
|
||||
fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any;
|
||||
fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)>;
|
||||
fn set_global_value(&mut self, value: Box<dyn Any>);
|
||||
fn set_local_value(&mut self, root_id: WorktreeId, path: Arc<Path>, value: Box<dyn Any>);
|
||||
fn json_schema(
|
||||
@@ -376,6 +377,24 @@ impl SettingsStore {
|
||||
.expect("no default value for setting type")
|
||||
}
|
||||
|
||||
/// Get all values from project specific settings
|
||||
pub fn get_all_locals<T: Settings>(&self) -> Vec<(WorktreeId, Arc<Path>, &T)> {
|
||||
self.setting_values
|
||||
.get(&TypeId::of::<T>())
|
||||
.unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
|
||||
.all_local_values()
|
||||
.into_iter()
|
||||
.map(|(id, path, any)| {
|
||||
(
|
||||
id,
|
||||
path,
|
||||
any.downcast_ref::<T>()
|
||||
.expect("wrong value type for setting"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Override the global value for a setting.
|
||||
///
|
||||
/// The given value will be overwritten if the user settings file changes.
|
||||
@@ -1235,6 +1254,13 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
|
||||
(key, value)
|
||||
}
|
||||
|
||||
fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)> {
|
||||
self.local_values
|
||||
.iter()
|
||||
.map(|(id, path, value)| (*id, path.clone(), value as _))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any {
|
||||
if let Some(SettingsLocation { worktree_id, path }) = path {
|
||||
for (settings_root_id, settings_path, value) in self.local_values.iter().rev() {
|
||||
|
||||
@@ -237,6 +237,30 @@ impl TaskTemplate {
|
||||
env
|
||||
};
|
||||
|
||||
// We filter out env variables here that aren't set so we don't have extra white space in args
|
||||
let args = self
|
||||
.args
|
||||
.iter()
|
||||
.filter(|arg| {
|
||||
shellexpand::env_with_context(arg, |var| {
|
||||
let colon_position = var.find(':').unwrap_or(var.len());
|
||||
let (variable_name, default) = var.split_at(colon_position);
|
||||
|
||||
if env
|
||||
.get(variable_name)
|
||||
.is_some_and(|arg| !arg.trim().is_empty())
|
||||
|| !default.is_empty()
|
||||
{
|
||||
Ok(Some(""))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Empty argument should be filtered out"))
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Some(ResolvedTask {
|
||||
id: id.clone(),
|
||||
substituted_variables,
|
||||
@@ -256,7 +280,7 @@ impl TaskTemplate {
|
||||
},
|
||||
),
|
||||
command,
|
||||
args: self.args.clone(),
|
||||
args,
|
||||
env,
|
||||
use_new_terminal: self.use_new_terminal,
|
||||
allow_concurrent_runs: self.allow_concurrent_runs,
|
||||
@@ -703,6 +727,7 @@ mod tests {
|
||||
label: "My task".into(),
|
||||
command: "echo".into(),
|
||||
args: vec!["$PATH".into()],
|
||||
env: HashMap::from_iter([("PATH".to_owned(), "non-empty".to_owned())]),
|
||||
..TaskTemplate::default()
|
||||
};
|
||||
let resolved_task = task
|
||||
@@ -715,6 +740,32 @@ mod tests {
|
||||
assert_eq!(resolved.args, task.args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_env_variables_excluded_from_args() {
|
||||
let task = TaskTemplate {
|
||||
label: "My task".into(),
|
||||
command: "echo".into(),
|
||||
args: vec![
|
||||
"$EMPTY_VAR".into(),
|
||||
"hello".into(),
|
||||
"$WHITESPACE_VAR".into(),
|
||||
"$UNDEFINED_VAR".into(),
|
||||
"$WORLD".into(),
|
||||
],
|
||||
env: HashMap::from_iter([
|
||||
("EMPTY_VAR".to_owned(), "".to_owned()),
|
||||
("WHITESPACE_VAR".to_owned(), " ".to_owned()),
|
||||
("WORLD".to_owned(), "non-empty".to_owned()),
|
||||
]),
|
||||
..TaskTemplate::default()
|
||||
};
|
||||
let resolved_task = task
|
||||
.resolve_task(TEST_ID_BASE, &TaskContext::default())
|
||||
.unwrap();
|
||||
let resolved = resolved_task.resolved;
|
||||
assert_eq!(resolved.args, vec!["hello", "$WORLD"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_errors_on_missing_zed_variable() {
|
||||
let task = TaskTemplate {
|
||||
@@ -729,6 +780,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The fish shell doesn't handle white space well so we want to filter out empty environment variables
|
||||
// this test ensures that we maintain this behavior
|
||||
#[test]
|
||||
fn test_mixed_env_variable_formats_in_args() {
|
||||
let task = TaskTemplate {
|
||||
label: "Mixed env test".into(),
|
||||
command: "echo".into(),
|
||||
args: vec![
|
||||
"start".into(),
|
||||
"$DEFINED_VAR".into(),
|
||||
"${ANOTHER_DEFINED}".into(),
|
||||
"$UNDEFINED_VAR".into(),
|
||||
"${UNDEFINED_BRACES}".into(),
|
||||
"${UNDEFINED_BRACES: 5}".into(),
|
||||
"$EMPTY_VAR".into(),
|
||||
"${WHITESPACE_VAR}".into(),
|
||||
"middle".into(),
|
||||
"${ZED_WORKTREE_ROOT}".into(),
|
||||
"${UNDEFINED_VAR}/bin".into(),
|
||||
"$PATH".into(),
|
||||
"end".into(),
|
||||
],
|
||||
env: HashMap::from_iter([
|
||||
("DEFINED_VAR".to_owned(), "value1".to_owned()),
|
||||
("ANOTHER_DEFINED".to_owned(), "value2".to_owned()),
|
||||
("EMPTY_VAR".to_owned(), "".to_owned()),
|
||||
("WHITESPACE_VAR".to_owned(), " ".to_owned()),
|
||||
("PATH".to_owned(), "/usr/bin:/usr/local/bin".to_owned()),
|
||||
]),
|
||||
..TaskTemplate::default()
|
||||
};
|
||||
|
||||
let context = TaskContext {
|
||||
cwd: Some(PathBuf::from("/project")),
|
||||
task_variables: TaskVariables::from_iter([(
|
||||
VariableName::WorktreeRoot,
|
||||
"/project".into(),
|
||||
)]),
|
||||
..TaskContext::default()
|
||||
};
|
||||
|
||||
let resolved_task = task.resolve_task(TEST_ID_BASE, &context).unwrap();
|
||||
let resolved = resolved_task.resolved;
|
||||
|
||||
// Verify that:
|
||||
// - Regular args like "start", "middle", "end" remain
|
||||
// - Defined env vars ($DEFINED_VAR, ${ANOTHER_DEFINED}, $PATH) remain
|
||||
// - Undefined env vars ($UNDEFINED_VAR, ${UNDEFINED_BRACES}) are filtered out
|
||||
// - Empty/whitespace env vars ($EMPTY_VAR, ${WHITESPACE_VAR}) are filtered out
|
||||
// - Zed variables (${ZED_WORKTREE_ROOT}) remain as they're resolved to task variables
|
||||
assert_eq!(
|
||||
resolved.args,
|
||||
vec![
|
||||
"start",
|
||||
"$DEFINED_VAR",
|
||||
"${ANOTHER_DEFINED}",
|
||||
"${UNDEFINED_BRACES: 5}",
|
||||
"middle",
|
||||
"${ZED_WORKTREE_ROOT}",
|
||||
"$PATH",
|
||||
"end"
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(resolved.env.get("DEFINED_VAR"), Some(&"value1".to_owned()));
|
||||
assert_eq!(
|
||||
resolved.env.get("ANOTHER_DEFINED"),
|
||||
Some(&"value2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.env.get("PATH"),
|
||||
Some(&"/usr/bin:/usr/local/bin".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.env.get("ZED_WORKTREE_ROOT"),
|
||||
Some(&"/project".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_symbol_dependent_tasks() {
|
||||
let task_with_all_properties = TaskTemplate {
|
||||
|
||||
@@ -80,7 +80,14 @@ pub fn init(cx: &mut App) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toggle_modal(workspace, None, window, cx).detach();
|
||||
spawn_task_or_modal(
|
||||
workspace,
|
||||
&Spawn::ViaModal {
|
||||
reveal_target: None,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -264,7 +264,6 @@ async fn deserialize_pane_group(
|
||||
workspace.clone(),
|
||||
Some(workspace_id),
|
||||
project.downgrade(),
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
|
||||
use gpui::{
|
||||
AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
|
||||
ElementId, Entity, FocusHandle, Focusable, Font, FontStyle, FontWeight, GlobalElementId,
|
||||
HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement,
|
||||
LayoutId, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
|
||||
ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle,
|
||||
Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId,
|
||||
ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
|
||||
StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection,
|
||||
UnderlineStyle, WeakEntity, WhiteSpace, Window, WindowTextSystem, div, fill, point, px,
|
||||
relative, size,
|
||||
@@ -32,7 +32,7 @@ use workspace::Workspace;
|
||||
use std::mem;
|
||||
use std::{fmt::Debug, ops::RangeInclusive, rc::Rc};
|
||||
|
||||
use crate::{BlockContext, BlockProperties, TerminalView};
|
||||
use crate::{BlockContext, BlockProperties, TerminalMode, TerminalView};
|
||||
|
||||
/// The information generated during layout that is necessary for painting.
|
||||
pub struct LayoutState {
|
||||
@@ -160,7 +160,7 @@ pub struct TerminalElement {
|
||||
focused: bool,
|
||||
cursor_visible: bool,
|
||||
interactivity: Interactivity,
|
||||
embedded: bool,
|
||||
mode: TerminalMode,
|
||||
block_below_cursor: Option<Rc<BlockProperties>>,
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ impl TerminalElement {
|
||||
focused: bool,
|
||||
cursor_visible: bool,
|
||||
block_below_cursor: Option<Rc<BlockProperties>>,
|
||||
embedded: bool,
|
||||
mode: TerminalMode,
|
||||
) -> TerminalElement {
|
||||
TerminalElement {
|
||||
terminal,
|
||||
@@ -191,7 +191,7 @@ impl TerminalElement {
|
||||
focus: focus.clone(),
|
||||
cursor_visible,
|
||||
block_below_cursor,
|
||||
embedded,
|
||||
mode,
|
||||
interactivity: Default::default(),
|
||||
}
|
||||
.track_focus(&focus)
|
||||
@@ -511,21 +511,20 @@ impl TerminalElement {
|
||||
},
|
||||
),
|
||||
);
|
||||
self.interactivity.on_scroll_wheel({
|
||||
let terminal_view = self.terminal_view.downgrade();
|
||||
move |e, window, cx| {
|
||||
terminal_view
|
||||
.update(cx, |terminal_view, cx| {
|
||||
if !terminal_view.embedded
|
||||
|| terminal_view.focus_handle(cx).is_focused(window)
|
||||
{
|
||||
|
||||
if !matches!(self.mode, TerminalMode::Embedded { .. }) {
|
||||
self.interactivity.on_scroll_wheel({
|
||||
let terminal_view = self.terminal_view.downgrade();
|
||||
move |e, _window, cx| {
|
||||
terminal_view
|
||||
.update(cx, |terminal_view, cx| {
|
||||
terminal_view.scroll_wheel(e, cx);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mouse mode handlers:
|
||||
// All mouse modes need the extra click handlers
|
||||
@@ -606,16 +605,6 @@ impl Element for TerminalElement {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
if self.embedded {
|
||||
let scrollable = {
|
||||
let term = self.terminal.read(cx);
|
||||
!term.scrolled_to_top() && !term.scrolled_to_bottom() && self.focused
|
||||
};
|
||||
if scrollable {
|
||||
self.interactivity.occlude_mouse();
|
||||
}
|
||||
}
|
||||
|
||||
let layout_id = self.interactivity.request_layout(
|
||||
global_id,
|
||||
inspector_id,
|
||||
@@ -623,8 +612,29 @@ impl Element for TerminalElement {
|
||||
cx,
|
||||
|mut style, window, cx| {
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
// style.overflow = point(Overflow::Hidden, Overflow::Hidden);
|
||||
|
||||
match &self.mode {
|
||||
TerminalMode::Scrollable => {
|
||||
style.size.height = relative(1.).into();
|
||||
}
|
||||
TerminalMode::Embedded { max_lines } => {
|
||||
let rem_size = window.rem_size();
|
||||
let line_height = window.text_style().font_size.to_pixels(rem_size)
|
||||
* TerminalSettings::get_global(cx)
|
||||
.line_height
|
||||
.value()
|
||||
.to_pixels(rem_size)
|
||||
.0;
|
||||
|
||||
let mut line_count = self.terminal.read(cx).total_lines();
|
||||
if !self.focused {
|
||||
if let Some(max_lines) = max_lines {
|
||||
line_count = line_count.min(*max_lines);
|
||||
}
|
||||
}
|
||||
style.size.height = (line_count * line_height).into();
|
||||
}
|
||||
}
|
||||
|
||||
window.request_layout(style, None, cx)
|
||||
},
|
||||
@@ -679,12 +689,13 @@ impl Element for TerminalElement {
|
||||
|
||||
let line_height = terminal_settings.line_height.value();
|
||||
|
||||
let font_size = if self.embedded {
|
||||
window.text_style().font_size.to_pixels(window.rem_size())
|
||||
} else {
|
||||
terminal_settings
|
||||
let font_size = match &self.mode {
|
||||
TerminalMode::Embedded { .. } => {
|
||||
window.text_style().font_size.to_pixels(window.rem_size())
|
||||
}
|
||||
TerminalMode::Scrollable => terminal_settings
|
||||
.font_size
|
||||
.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx))
|
||||
.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)),
|
||||
};
|
||||
|
||||
let theme = cx.theme().clone();
|
||||
|
||||
@@ -439,7 +439,6 @@ impl TerminalPanel {
|
||||
weak_workspace.clone(),
|
||||
database_id,
|
||||
project.downgrade(),
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -677,7 +676,6 @@ impl TerminalPanel {
|
||||
workspace.weak_handle(),
|
||||
workspace.database_id(),
|
||||
workspace.project().downgrade(),
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -718,7 +716,6 @@ impl TerminalPanel {
|
||||
workspace.weak_handle(),
|
||||
workspace.database_id(),
|
||||
workspace.project().downgrade(),
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1065,6 +1062,7 @@ pub fn new_terminal_pane(
|
||||
&new_pane,
|
||||
item_id_to_move,
|
||||
new_pane.read(cx).active_item_index(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ pub struct TerminalView {
|
||||
context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
|
||||
cursor_shape: CursorShape,
|
||||
blink_state: bool,
|
||||
embedded: bool,
|
||||
mode: TerminalMode,
|
||||
blinking_terminal_enabled: bool,
|
||||
cwd_serialized: bool,
|
||||
blinking_paused: bool,
|
||||
@@ -137,6 +137,15 @@ pub struct TerminalView {
|
||||
_terminal_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub enum TerminalMode {
|
||||
#[default]
|
||||
Scrollable,
|
||||
Embedded {
|
||||
max_lines: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HoverTarget {
|
||||
tooltip: String,
|
||||
@@ -176,7 +185,6 @@ impl TerminalView {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
workspace_id: Option<WorkspaceId>,
|
||||
project: WeakEntity<Project>,
|
||||
embedded: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -215,7 +223,7 @@ impl TerminalView {
|
||||
blink_epoch: 0,
|
||||
hover: None,
|
||||
hover_tooltip_update: Task::ready(()),
|
||||
embedded,
|
||||
mode: TerminalMode::Scrollable,
|
||||
workspace_id,
|
||||
show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
|
||||
block_below_cursor: None,
|
||||
@@ -236,6 +244,21 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
|
||||
pub fn set_embedded_mode(&mut self, max_lines: Option<usize>, cx: &mut Context<Self>) {
|
||||
self.mode = TerminalMode::Embedded { max_lines };
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn is_content_limited(&self, window: &Window) -> bool {
|
||||
match &self.mode {
|
||||
TerminalMode::Scrollable => false,
|
||||
TerminalMode::Embedded { max_lines } => {
|
||||
!self.focus_handle.is_focused(window) && max_lines.is_some()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the marked (pre-edit) text from the IME.
|
||||
pub(crate) fn set_marked_text(
|
||||
&mut self,
|
||||
@@ -820,6 +843,7 @@ impl TerminalView {
|
||||
fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
|
||||
if !Self::should_show_scrollbar(cx)
|
||||
|| !(self.show_scrollbar || self.scrollbar_state.is_dragging())
|
||||
|| matches!(self.mode, TerminalMode::Embedded { .. })
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -1467,7 +1491,7 @@ impl Render for TerminalView {
|
||||
focused,
|
||||
self.should_show_cursor(focused, cx),
|
||||
self.block_below_cursor.clone(),
|
||||
self.embedded,
|
||||
self.mode.clone(),
|
||||
))
|
||||
.when_some(self.render_scrollbar(cx), |div, scrollbar| {
|
||||
div.child(scrollbar)
|
||||
@@ -1593,7 +1617,6 @@ impl Item for TerminalView {
|
||||
self.workspace.clone(),
|
||||
workspace_id,
|
||||
self.project.clone(),
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1751,7 +1774,6 @@ impl SerializableItem for TerminalView {
|
||||
workspace,
|
||||
Some(workspace_id),
|
||||
project.downgrade(),
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -50,5 +50,6 @@ dunce = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
git2.workspace = true
|
||||
indoc.workspace = true
|
||||
rand.workspace = true
|
||||
util_macros.workspace = true
|
||||
|
||||
273
crates/util/src/shell_env.rs
Normal file
273
crates/util/src/shell_env.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use std::borrow::Cow;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Capture all environment variables from the login shell.
|
||||
pub fn capture(change_dir: Option<impl AsRef<Path>>) -> Result<HashMap<String, String>> {
|
||||
let shell_path = std::env::var("SHELL").map(PathBuf::from)?;
|
||||
let shell_name = shell_path.file_name().and_then(OsStr::to_str);
|
||||
|
||||
let mut command_string = String::new();
|
||||
|
||||
// What we're doing here is to spawn a shell and then `cd` into
|
||||
// the project directory to get the env in there as if the user
|
||||
// `cd`'d into it. We do that because tools like direnv, asdf, ...
|
||||
// hook into `cd` and only set up the env after that.
|
||||
if let Some(dir) = change_dir {
|
||||
let dir_str = dir.as_ref().to_string_lossy();
|
||||
command_string.push_str(&format!("cd '{dir_str}';"));
|
||||
}
|
||||
|
||||
// In certain shells we need to execute additional_command in order to
|
||||
// trigger the behavior of direnv, etc.
|
||||
command_string.push_str(match shell_name {
|
||||
Some("fish") => "emit fish_prompt;",
|
||||
_ => "",
|
||||
});
|
||||
|
||||
let mut env_output_file = NamedTempFile::new()?;
|
||||
command_string.push_str(&format!(
|
||||
"sh -c 'export -p' > '{}';",
|
||||
env_output_file.path().to_string_lossy(),
|
||||
));
|
||||
|
||||
let mut command = Command::new(&shell_path);
|
||||
|
||||
// For csh/tcsh, the login shell option is set by passing `-` as
|
||||
// the 0th argument instead of using `-l`.
|
||||
if let Some("tcsh" | "csh") = shell_name {
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::arg0(&mut command, "-");
|
||||
} else {
|
||||
command.arg("-l");
|
||||
}
|
||||
|
||||
command.args(["-i", "-c", &command_string]);
|
||||
|
||||
let process_output = super::set_pre_exec_to_start_new_session(&mut command).output()?;
|
||||
anyhow::ensure!(
|
||||
process_output.status.success(),
|
||||
"login shell exited with {}. stdout: {:?}, stderr: {:?}",
|
||||
process_output.status,
|
||||
String::from_utf8_lossy(&process_output.stdout),
|
||||
String::from_utf8_lossy(&process_output.stderr),
|
||||
);
|
||||
|
||||
let mut env_output = String::new();
|
||||
env_output_file.read_to_string(&mut env_output)?;
|
||||
|
||||
parse(&env_output)
|
||||
.filter_map(|entry| match entry {
|
||||
Ok((name, value)) => Some(Ok((name.into(), value?.into()))),
|
||||
Err(err) => Some(Err(err)),
|
||||
})
|
||||
.collect::<Result<HashMap<String, String>>>()
|
||||
}
|
||||
|
||||
/// Parse the result of calling `sh -c 'export -p'`.
|
||||
///
|
||||
/// https://www.man7.org/linux/man-pages/man1/export.1p.html
|
||||
fn parse(mut input: &str) -> impl Iterator<Item = Result<(Cow<'_, str>, Option<Cow<'_, str>>)>> {
|
||||
std::iter::from_fn(move || {
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match parse_declaration(input) {
|
||||
Ok((entry, rest)) => {
|
||||
input = rest;
|
||||
Some(Ok(entry))
|
||||
}
|
||||
Err(err) => Some(Err(err)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_declaration(input: &str) -> Result<((Cow<'_, str>, Option<Cow<'_, str>>), &str)> {
|
||||
let rest = input
|
||||
.strip_prefix("export ")
|
||||
.context("expected 'export ' prefix")?;
|
||||
|
||||
if let Some((name, rest)) = parse_name_and_terminator(rest, '\n') {
|
||||
Ok(((name, None), rest))
|
||||
} else {
|
||||
let (name, rest) = parse_name_and_terminator(rest, '=').context("invalid name")?;
|
||||
let (value, rest) = parse_literal_and_terminator(rest, '\n').context("invalid value")?;
|
||||
Ok(((name, Some(value)), rest))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_name_and_terminator(input: &str, terminator: char) -> Option<(Cow<'_, str>, &str)> {
|
||||
let (name, rest) = parse_literal_and_terminator(input, terminator)?;
|
||||
(!name.is_empty() && !name.contains('=')).then_some((name, rest))
|
||||
}
|
||||
|
||||
fn parse_literal_and_terminator(input: &str, terminator: char) -> Option<(Cow<'_, str>, &str)> {
|
||||
if let Some((literal, rest)) = parse_literal_single_quoted(input) {
|
||||
let rest = rest.strip_prefix(terminator)?;
|
||||
Some((Cow::Borrowed(literal), rest))
|
||||
} else if let Some((literal, rest)) = parse_literal_double_quoted(input) {
|
||||
let rest = rest.strip_prefix(terminator)?;
|
||||
Some((Cow::Owned(literal), rest))
|
||||
} else {
|
||||
let (literal, rest) = input.split_once(terminator)?;
|
||||
(!literal.contains(|c: char| c.is_ascii_whitespace()))
|
||||
.then_some((Cow::Borrowed(literal), rest))
|
||||
}
|
||||
}
|
||||
|
||||
/// https://www.gnu.org/software/bash/manual/html_node/Single-Quotes.html
|
||||
fn parse_literal_single_quoted(input: &str) -> Option<(&str, &str)> {
|
||||
input.strip_prefix('\'')?.split_once('\'')
|
||||
}
|
||||
|
||||
/// https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
|
||||
fn parse_literal_double_quoted(input: &str) -> Option<(String, &str)> {
|
||||
let rest = input.strip_prefix('"')?;
|
||||
|
||||
let mut char_indices = rest.char_indices();
|
||||
let mut escaping = false;
|
||||
let (literal, rest) = loop {
|
||||
let (index, char) = char_indices.next()?;
|
||||
if char == '"' && !escaping {
|
||||
break (&rest[..index], &rest[index + 1..]);
|
||||
} else {
|
||||
escaping = !escaping && char == '\\';
|
||||
}
|
||||
};
|
||||
|
||||
let literal = literal
|
||||
.replace("\\$", "$")
|
||||
.replace("\\`", "`")
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\\\n", "")
|
||||
.replace("\\\\", "\\");
|
||||
|
||||
Some((literal, rest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse() {
|
||||
let input = indoc::indoc! {r#"
|
||||
export foo
|
||||
export 'foo'
|
||||
export "foo"
|
||||
export foo=
|
||||
export 'foo'=
|
||||
export "foo"=
|
||||
export foo=bar
|
||||
export foo='bar'
|
||||
export foo="bar"
|
||||
export foo='b
|
||||
a
|
||||
z'
|
||||
export foo="b
|
||||
a
|
||||
z"
|
||||
export foo='b\
|
||||
a\
|
||||
z'
|
||||
export foo="b\
|
||||
a\
|
||||
z"
|
||||
export foo='\`Hello\`
|
||||
\"wo\
|
||||
rld\"\n!\\
|
||||
!'
|
||||
export foo="\`Hello\`
|
||||
\"wo\
|
||||
rld\"\n!\\
|
||||
!"
|
||||
"#};
|
||||
|
||||
let expected_values = [
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(""),
|
||||
Some(""),
|
||||
Some(""),
|
||||
Some("bar"),
|
||||
Some("bar"),
|
||||
Some("bar"),
|
||||
Some("b\na\nz"),
|
||||
Some("b\na\nz"),
|
||||
Some("b\\\na\\\nz"),
|
||||
Some("baz"),
|
||||
Some(indoc::indoc! {r#"
|
||||
\`Hello\`
|
||||
\"wo\
|
||||
rld\"\n!\\
|
||||
!"#}),
|
||||
Some(indoc::indoc! {r#"
|
||||
`Hello`
|
||||
"world"\n!\!"#}),
|
||||
];
|
||||
let expected = expected_values
|
||||
.into_iter()
|
||||
.map(|value| ("foo".into(), value.map(Into::into)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let actual = parse(input).collect::<Result<Vec<_>>>().unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_declaration() {
|
||||
let ((name, value), rest) = parse_declaration("export foo\nrest").unwrap();
|
||||
assert_eq!(name, "foo");
|
||||
assert_eq!(value, None);
|
||||
assert_eq!(rest, "rest");
|
||||
|
||||
let ((name, value), rest) = parse_declaration("export foo=bar\nrest").unwrap();
|
||||
assert_eq!(name, "foo");
|
||||
assert_eq!(value.as_deref(), Some("bar"));
|
||||
assert_eq!(rest, "rest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_literal_single_quoted() {
|
||||
let input = indoc::indoc! {r#"
|
||||
'\`Hello\`
|
||||
\"wo\
|
||||
rld\"\n!\\
|
||||
!'
|
||||
rest"#};
|
||||
|
||||
let expected = indoc::indoc! {r#"
|
||||
\`Hello\`
|
||||
\"wo\
|
||||
rld\"\n!\\
|
||||
!"#};
|
||||
|
||||
let (actual, rest) = parse_literal_single_quoted(input).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
assert_eq!(rest, "\nrest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_literal_double_quoted() {
|
||||
let input = indoc::indoc! {r#"
|
||||
"\`Hello\`
|
||||
\"wo\
|
||||
rld\"\n!\\
|
||||
!"
|
||||
rest"#};
|
||||
|
||||
let expected = indoc::indoc! {r#"
|
||||
`Hello`
|
||||
"world"\n!\!"#};
|
||||
|
||||
let (actual, rest) = parse_literal_double_quoted(input).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
assert_eq!(rest, "\nrest");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod fs;
|
||||
pub mod markdown;
|
||||
pub mod paths;
|
||||
pub mod serde;
|
||||
pub mod shell_env;
|
||||
pub mod size;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test;
|
||||
@@ -27,9 +28,6 @@ use std::{
|
||||
};
|
||||
use unicase::UniCase;
|
||||
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context as _;
|
||||
|
||||
pub use take_until::*;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use util_macros::{line_endings, separator, uri};
|
||||
@@ -312,46 +310,21 @@ fn load_shell_from_passwd() -> Result<()> {
|
||||
pub fn load_login_shell_environment() -> Result<()> {
|
||||
load_shell_from_passwd().log_err();
|
||||
|
||||
let marker = "ZED_LOGIN_SHELL_START";
|
||||
let shell = env::var("SHELL").context(
|
||||
"SHELL environment variable is not assigned so we can't source login environment variables",
|
||||
)?;
|
||||
|
||||
// If possible, we want to `cd` in the user's `$HOME` to trigger programs
|
||||
// such as direnv, asdf, mise, ... to adjust the PATH. These tools often hook
|
||||
// into shell's `cd` command (and hooks) to manipulate env.
|
||||
// We do this so that we get the env a user would have when spawning a shell
|
||||
// in home directory.
|
||||
let shell_cmd_prefix = std::env::var_os("HOME")
|
||||
.and_then(|home| home.into_string().ok())
|
||||
.map(|home| format!("cd '{home}';"));
|
||||
|
||||
let shell_cmd = format!(
|
||||
"{}printf '%s' {marker}; /usr/bin/env;",
|
||||
shell_cmd_prefix.as_deref().unwrap_or("")
|
||||
);
|
||||
|
||||
let output = set_pre_exec_to_start_new_session(
|
||||
std::process::Command::new(&shell).args(["-l", "-i", "-c", &shell_cmd]),
|
||||
)
|
||||
.output()
|
||||
.context("failed to spawn login shell to source login environment variables")?;
|
||||
anyhow::ensure!(output.status.success(), "login shell exited with error");
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
if let Some(env_output_start) = stdout.find(marker) {
|
||||
let env_output = &stdout[env_output_start + marker.len()..];
|
||||
|
||||
parse_env_output(env_output, |key, value| unsafe { env::set_var(key, value) });
|
||||
|
||||
log::info!(
|
||||
"set environment variables from shell:{}, path:{}",
|
||||
shell,
|
||||
env::var("PATH").unwrap_or_default(),
|
||||
);
|
||||
for (name, value) in shell_env::capture(Some(paths::home_dir()))? {
|
||||
unsafe { env::set_var(&name, &value) };
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"set environment variables from shell:{}, path:{}",
|
||||
std::env::var("SHELL").unwrap_or_default(),
|
||||
std::env::var("PATH").unwrap_or_default(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -375,32 +348,6 @@ pub fn set_pre_exec_to_start_new_session(
|
||||
command
|
||||
}
|
||||
|
||||
/// Parse the result of calling `usr/bin/env` with no arguments
|
||||
pub fn parse_env_output(env: &str, mut f: impl FnMut(String, String)) {
|
||||
let mut current_key: Option<String> = None;
|
||||
let mut current_value: Option<String> = None;
|
||||
|
||||
for line in env.split_terminator('\n') {
|
||||
if let Some(separator_index) = line.find('=') {
|
||||
if !line[..separator_index].is_empty() {
|
||||
if let Some((key, value)) = Option::zip(current_key.take(), current_value.take()) {
|
||||
f(key, value)
|
||||
}
|
||||
current_key = Some(line[..separator_index].to_string());
|
||||
current_value = Some(line[separator_index + 1..].to_string());
|
||||
continue;
|
||||
};
|
||||
}
|
||||
if let Some(value) = current_value.as_mut() {
|
||||
value.push('\n');
|
||||
value.push_str(line);
|
||||
}
|
||||
}
|
||||
if let Some((key, value)) = Option::zip(current_key.take(), current_value.take()) {
|
||||
f(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_json_lenient_value_into(
|
||||
source: serde_json_lenient::Value,
|
||||
target: &mut serde_json_lenient::Value,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user