Compare commits
95 Commits
scheduler
...
branch-dif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
323511f3c2 | ||
|
|
eb0436e84e | ||
|
|
ddb467de90 | ||
|
|
6dc68e02a7 | ||
|
|
bf1ae1d196 | ||
|
|
3b7dbb87b0 | ||
|
|
bb13228ad5 | ||
|
|
ec1528b890 | ||
|
|
2aa0114b40 | ||
|
|
bb2d833373 | ||
|
|
eedfc5be5a | ||
|
|
0e76cc8036 | ||
|
|
6bd5251882 | ||
|
|
13de400a2a | ||
|
|
c3480c3d6f | ||
|
|
0cbacb8500 | ||
|
|
7327ef662b | ||
|
|
c1ca7303a8 | ||
|
|
92283285ae | ||
|
|
d80f9dda75 | ||
|
|
ebc22c290b | ||
|
|
7633bbf55a | ||
|
|
91cbb2ec25 | ||
|
|
40199266b6 | ||
|
|
9a8c5053c2 | ||
|
|
c446662862 | ||
|
|
6feae92616 | ||
|
|
ae840c6ef3 | ||
|
|
d7fd5910d7 | ||
|
|
8d5861322b | ||
|
|
5a9e18603d | ||
|
|
2a7761fe17 | ||
|
|
f23096034b | ||
|
|
1ed17fdd94 | ||
|
|
7ea7f4e767 | ||
|
|
035d7ddcf8 | ||
|
|
9d67276090 | ||
|
|
161d128d45 | ||
|
|
e1b0a98c34 | ||
|
|
ae0ee70abd | ||
|
|
893eb92f91 | ||
|
|
45fa6d81ac | ||
|
|
60ad82cc94 | ||
|
|
564ded71c1 | ||
|
|
63b3839a83 | ||
|
|
9f749881b3 | ||
|
|
946efb03df | ||
|
|
4b96ad3fba | ||
|
|
4368c1b56b | ||
|
|
e5a968b709 | ||
|
|
7aecab8e14 | ||
|
|
e4df866664 | ||
|
|
8770fcc841 | ||
|
|
6dcae2711d | ||
|
|
5e01fb8f1c | ||
|
|
88a79750cc | ||
|
|
4c411b9fc8 | ||
|
|
5ac6ae501f | ||
|
|
c01f12b15d | ||
|
|
dfa066dfe8 | ||
|
|
ac8c653ae6 | ||
|
|
d2318be8d9 | ||
|
|
a026163746 | ||
|
|
ad3ddd381d | ||
|
|
7e3fbeb59d | ||
|
|
8e7caa429d | ||
|
|
c894351544 | ||
|
|
a96015b3c5 | ||
|
|
2eb7ac97e0 | ||
|
|
f06c18765f | ||
|
|
2f279c5de4 | ||
|
|
60b95d9253 | ||
|
|
47ad1b2143 | ||
|
|
35c0d02c7c | ||
|
|
374a8bc4cb | ||
|
|
f06be6f3ec | ||
|
|
970242480a | ||
|
|
54cec5b484 | ||
|
|
60d17cccd3 | ||
|
|
8a8a9a4f07 | ||
|
|
634a1343dd | ||
|
|
2ba25b5c94 | ||
|
|
965dbc988f | ||
|
|
5b73b40df8 | ||
|
|
d910feac1d | ||
|
|
61175ab9cd | ||
|
|
2790eb604a | ||
|
|
acff65ed3f | ||
|
|
3315fd94d2 | ||
|
|
62083fe796 | ||
|
|
a852bcc094 | ||
|
|
f290daf7ea | ||
|
|
129bff8358 | ||
|
|
c833f8905b | ||
|
|
d74384f6e2 |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,2 +1,5 @@
|
||||
# Prevent GitHub from displaying comments within JSON files as errors.
|
||||
*.json linguist-language=JSON-with-Comments
|
||||
|
||||
# Ensure the WSL script always has LF line endings, even on Windows
|
||||
crates/zed/resources/windows/zed-wsl text eol=lf
|
||||
|
||||
159
.github/actions/run_tests_windows/action.yml
vendored
159
.github/actions/run_tests_windows/action.yml
vendored
@@ -20,167 +20,8 @@ runs:
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Configure crash dumps
|
||||
shell: powershell
|
||||
run: |
|
||||
# Record the start time for this CI run
|
||||
$runStartTime = Get-Date
|
||||
$runStartTimeStr = $runStartTime.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
Write-Host "CI run started at: $runStartTimeStr"
|
||||
|
||||
# Save the timestamp for later use
|
||||
echo "CI_RUN_START_TIME=$($runStartTime.Ticks)" >> $env:GITHUB_ENV
|
||||
|
||||
# Create crash dump directory in workspace (non-persistent)
|
||||
$dumpPath = "$env:GITHUB_WORKSPACE\crash_dumps"
|
||||
New-Item -ItemType Directory -Force -Path $dumpPath | Out-Null
|
||||
|
||||
Write-Host "Setting up crash dump detection..."
|
||||
Write-Host "Workspace dump path: $dumpPath"
|
||||
|
||||
# Note: We're NOT modifying registry on stateful runners
|
||||
# Instead, we'll check default Windows crash locations after tests
|
||||
|
||||
- name: Run tests
|
||||
shell: powershell
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
$env:RUST_BACKTRACE = "full"
|
||||
|
||||
# Enable Windows debugging features
|
||||
$env:_NT_SYMBOL_PATH = "srv*https://msdl.microsoft.com/download/symbols"
|
||||
|
||||
# .NET crash dump environment variables (ephemeral)
|
||||
$env:COMPlus_DbgEnableMiniDump = "1"
|
||||
$env:COMPlus_DbgMiniDumpType = "4"
|
||||
$env:COMPlus_CreateDumpDiagnostics = "1"
|
||||
|
||||
cargo nextest run --workspace --no-fail-fast
|
||||
|
||||
- name: Analyze crash dumps
|
||||
if: always()
|
||||
shell: powershell
|
||||
run: |
|
||||
Write-Host "Checking for crash dumps..."
|
||||
|
||||
# Get the CI run start time from the environment
|
||||
$runStartTime = [DateTime]::new([long]$env:CI_RUN_START_TIME)
|
||||
Write-Host "Only analyzing dumps created after: $($runStartTime.ToString('yyyy-MM-dd HH:mm:ss'))"
|
||||
|
||||
# Check all possible crash dump locations
|
||||
$searchPaths = @(
|
||||
"$env:GITHUB_WORKSPACE\crash_dumps",
|
||||
"$env:LOCALAPPDATA\CrashDumps",
|
||||
"$env:TEMP",
|
||||
"$env:GITHUB_WORKSPACE",
|
||||
"$env:USERPROFILE\AppData\Local\CrashDumps",
|
||||
"C:\Windows\System32\config\systemprofile\AppData\Local\CrashDumps"
|
||||
)
|
||||
|
||||
$dumps = @()
|
||||
foreach ($path in $searchPaths) {
|
||||
if (Test-Path $path) {
|
||||
Write-Host "Searching in: $path"
|
||||
$found = Get-ChildItem "$path\*.dmp" -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.CreationTime -gt $runStartTime
|
||||
}
|
||||
if ($found) {
|
||||
$dumps += $found
|
||||
Write-Host " Found $($found.Count) dump(s) from this CI run"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dumps) {
|
||||
Write-Host "Found $($dumps.Count) crash dump(s)"
|
||||
|
||||
# Install debugging tools if not present
|
||||
$cdbPath = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe"
|
||||
if (-not (Test-Path $cdbPath)) {
|
||||
Write-Host "Installing Windows Debugging Tools..."
|
||||
$url = "https://go.microsoft.com/fwlink/?linkid=2237387"
|
||||
Invoke-WebRequest -Uri $url -OutFile winsdksetup.exe
|
||||
Start-Process -Wait winsdksetup.exe -ArgumentList "/features OptionId.WindowsDesktopDebuggers /quiet"
|
||||
}
|
||||
|
||||
foreach ($dump in $dumps) {
|
||||
Write-Host "`n=================================="
|
||||
Write-Host "Analyzing crash dump: $($dump.Name)"
|
||||
Write-Host "Size: $([math]::Round($dump.Length / 1MB, 2)) MB"
|
||||
Write-Host "Time: $($dump.CreationTime)"
|
||||
Write-Host "=================================="
|
||||
|
||||
# Set symbol path
|
||||
$env:_NT_SYMBOL_PATH = "srv*C:\symbols*https://msdl.microsoft.com/download/symbols"
|
||||
|
||||
# Run analysis
|
||||
$analysisOutput = & $cdbPath -z $dump.FullName -c "!analyze -v; ~*k; lm; q" 2>&1 | Out-String
|
||||
|
||||
# Extract key information
|
||||
if ($analysisOutput -match "ExceptionCode:\s*([\w]+)") {
|
||||
Write-Host "Exception Code: $($Matches[1])"
|
||||
if ($Matches[1] -eq "c0000005") {
|
||||
Write-Host "Exception Type: ACCESS VIOLATION"
|
||||
}
|
||||
}
|
||||
|
||||
if ($analysisOutput -match "EXCEPTION_RECORD:\s*(.+)") {
|
||||
Write-Host "Exception Record: $($Matches[1])"
|
||||
}
|
||||
|
||||
if ($analysisOutput -match "FAULTING_IP:\s*\n(.+)") {
|
||||
Write-Host "Faulting Instruction: $($Matches[1])"
|
||||
}
|
||||
|
||||
# Save full analysis
|
||||
$analysisFile = "$($dump.FullName).analysis.txt"
|
||||
$analysisOutput | Out-File -FilePath $analysisFile
|
||||
Write-Host "`nFull analysis saved to: $analysisFile"
|
||||
|
||||
# Print stack trace section
|
||||
Write-Host "`n--- Stack Trace Preview ---"
|
||||
$stackSection = $analysisOutput -split "STACK_TEXT:" | Select-Object -Last 1
|
||||
$stackLines = $stackSection -split "`n" | Select-Object -First 20
|
||||
$stackLines | ForEach-Object { Write-Host $_ }
|
||||
Write-Host "--- End Stack Trace Preview ---"
|
||||
}
|
||||
|
||||
Write-Host "`n⚠️ Crash dumps detected! Download the 'crash-dumps' artifact for detailed analysis."
|
||||
|
||||
# Copy dumps to workspace for artifact upload
|
||||
$artifactPath = "$env:GITHUB_WORKSPACE\crash_dumps_collected"
|
||||
New-Item -ItemType Directory -Force -Path $artifactPath | Out-Null
|
||||
|
||||
foreach ($dump in $dumps) {
|
||||
$destName = "$($dump.Directory.Name)_$($dump.Name)"
|
||||
Copy-Item $dump.FullName -Destination "$artifactPath\$destName"
|
||||
if (Test-Path "$($dump.FullName).analysis.txt") {
|
||||
Copy-Item "$($dump.FullName).analysis.txt" -Destination "$artifactPath\$destName.analysis.txt"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Copied $($dumps.Count) dump(s) to artifact directory"
|
||||
} else {
|
||||
Write-Host "No crash dumps from this CI run found"
|
||||
}
|
||||
|
||||
- name: Upload crash dumps
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: crash-dumps-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
crash_dumps_collected/*.dmp
|
||||
crash_dumps_collected/*.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Check test results
|
||||
shell: powershell
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
# Re-check test results to fail the job if tests failed
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Tests failed with exit code: $LASTEXITCODE"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -81,6 +81,7 @@ jobs:
|
||||
echo "run_license=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \
|
||||
echo "$GITHUB_REF_NAME" | grep -qvP '^v[0-9]+\.[0-9]+\.[0-9x](-pre)?$' && \
|
||||
echo "run_nix=true" >> "$GITHUB_OUTPUT" || \
|
||||
echo "run_nix=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ If you would like to add a new icon to the Zed icon theme, [open a Discussion](h
|
||||
|
||||
## Bird's-eye view of Zed
|
||||
|
||||
We suggest you keep the [zed glossary](docs/src/development/GLOSSARY.md) at your side when starting out. It lists and explains some of the structures and terms you will see throughout the codebase.
|
||||
|
||||
Zed is made up of several smaller crates - let's go over those you're most likely to interact with:
|
||||
|
||||
- [`gpui`](/crates/gpui) is a GPU-accelerated UI framework which provides all of the building blocks for Zed. **We recommend familiarizing yourself with the root level GPUI documentation.**
|
||||
|
||||
33
Cargo.lock
generated
33
Cargo.lock
generated
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"language_model",
|
||||
"markdown",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"project",
|
||||
"prompt_store",
|
||||
"rand 0.8.5",
|
||||
@@ -30,6 +31,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
"task",
|
||||
"tempfile",
|
||||
"terminal",
|
||||
"ui",
|
||||
@@ -37,6 +39,7 @@ dependencies = [
|
||||
"util",
|
||||
"uuid",
|
||||
"watch",
|
||||
"which 6.0.3",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
@@ -192,9 +195,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0-alpha.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b91e5ec3ce05e8effb2a7a3b7b1a587daa6699b9f98bbde6a35e44b8c6c773a"
|
||||
checksum = "603941db1d130ee275840c465b73a2312727d4acef97449550ccf033de71301f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-broadcast",
|
||||
@@ -248,7 +251,6 @@ dependencies = [
|
||||
"open",
|
||||
"parking_lot",
|
||||
"paths",
|
||||
"portable-pty",
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"prompt_store",
|
||||
@@ -274,7 +276,6 @@ dependencies = [
|
||||
"uuid",
|
||||
"watch",
|
||||
"web_search",
|
||||
"which 6.0.3",
|
||||
"workspace-hack",
|
||||
"worktree",
|
||||
"zlog",
|
||||
@@ -9146,6 +9147,7 @@ dependencies = [
|
||||
"icons",
|
||||
"image",
|
||||
"log",
|
||||
"open_router",
|
||||
"parking_lot",
|
||||
"proto",
|
||||
"schemars",
|
||||
@@ -11221,6 +11223,8 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum 0.27.1",
|
||||
"thiserror 2.0.12",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
@@ -13744,7 +13748,6 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest 0.12.15 (git+https://github.com/zed-industries/reqwest.git?rev=951c770a32f1998d6e999cef3e59e0013e6c4415)",
|
||||
"serde",
|
||||
"smol",
|
||||
"tokio",
|
||||
"workspace-hack",
|
||||
]
|
||||
@@ -14349,18 +14352,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scheduler"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-task",
|
||||
"futures 0.3.31",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schema_generator"
|
||||
version = "0.1.0"
|
||||
@@ -14915,6 +14906,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"command_palette_hooks",
|
||||
"debugger_ui",
|
||||
"editor",
|
||||
"feature_flags",
|
||||
"gpui",
|
||||
@@ -14932,6 +14924,7 @@ dependencies = [
|
||||
name = "settings_ui_macros"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
@@ -20147,9 +20140,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xcb"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1e2f212bb1a92cd8caac8051b829a6582ede155ccb60b5d5908b81b100952be"
|
||||
checksum = "f07c123b796139bfe0603e654eaf08e132e52387ba95b252c78bad3640ba37ea"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
@@ -20406,7 +20399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.203.0"
|
||||
version = "0.204.0"
|
||||
dependencies = [
|
||||
"acp_tools",
|
||||
"activity_indicator",
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -140,7 +140,6 @@ members = [
|
||||
"crates/rpc",
|
||||
"crates/rules_library",
|
||||
"crates/schema_generator",
|
||||
"crates/scheduler",
|
||||
"crates/search",
|
||||
"crates/semantic_index",
|
||||
"crates/semantic_version",
|
||||
@@ -300,9 +299,7 @@ git_hosting_providers = { path = "crates/git_hosting_providers" }
|
||||
git_ui = { path = "crates/git_ui" }
|
||||
go_to_line = { path = "crates/go_to_line" }
|
||||
google_ai = { path = "crates/google_ai" }
|
||||
gpui = { path = "crates/gpui", default-features = false, features = [
|
||||
"http_client",
|
||||
] }
|
||||
gpui = { path = "crates/gpui", default-features = false }
|
||||
gpui_macros = { path = "crates/gpui_macros" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
html_to_markdown = { path = "crates/html_to_markdown" }
|
||||
@@ -431,7 +428,7 @@ zlog_settings = { path = "crates/zlog_settings" }
|
||||
# External crates
|
||||
#
|
||||
|
||||
agent-client-protocol = "0.1"
|
||||
agent-client-protocol = { version = "0.2.0-alpha.4", features = ["unstable"]}
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
|
||||
any_vec = "0.14"
|
||||
@@ -697,6 +694,7 @@ features = [
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Graphics_Imaging",
|
||||
"Win32_Graphics_Hlsl",
|
||||
"Win32_Networking_WinSock",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Credentials",
|
||||
@@ -848,6 +846,9 @@ too_many_arguments = "allow"
|
||||
# We often have large enum variants yet we rarely actually bother with splitting them up.
|
||||
large_enum_variant = "allow"
|
||||
|
||||
# Boolean expressions can be hard to read, requiring only the minimal form gets in the way
|
||||
nonminimal_bool = "allow"
|
||||
|
||||
[workspace.metadata.cargo-machete]
|
||||
ignored = [
|
||||
"bindgen",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 29 KiB |
@@ -63,8 +63,8 @@
|
||||
"ctrl-k": "editor::CutToEndOfLine",
|
||||
"ctrl-k ctrl-q": "editor::Rewrap",
|
||||
"ctrl-k q": "editor::Rewrap",
|
||||
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-delete": "editor::DeleteToNextWordEnd",
|
||||
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"cut": "editor::Cut",
|
||||
"shift-delete": "editor::Cut",
|
||||
"ctrl-x": "editor::Cut",
|
||||
|
||||
@@ -70,9 +70,9 @@
|
||||
"cmd-k q": "editor::Rewrap",
|
||||
"cmd-backspace": "editor::DeleteToBeginningOfLine",
|
||||
"cmd-delete": "editor::DeleteToEndOfLine",
|
||||
"alt-backspace": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-w": "editor::DeleteToPreviousWordStart",
|
||||
"alt-delete": "editor::DeleteToNextWordEnd",
|
||||
"alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"cmd-x": "editor::Cut",
|
||||
"cmd-c": "editor::Copy",
|
||||
"cmd-v": "editor::Paste",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"ctrl-k": "editor::CutToEndOfLine",
|
||||
"ctrl-k ctrl-q": "editor::Rewrap",
|
||||
"ctrl-k q": "editor::Rewrap",
|
||||
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-delete": "editor::DeleteToNextWordEnd",
|
||||
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"cut": "editor::Cut",
|
||||
"shift-delete": "editor::Cut",
|
||||
"ctrl-x": "editor::Cut",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"alt-,": "pane::GoBack", // xref-pop-marker-stack
|
||||
"ctrl-x h": "editor::SelectAll", // mark-whole-buffer
|
||||
"ctrl-d": "editor::Delete", // delete-char
|
||||
"alt-d": "editor::DeleteToNextWordEnd", // kill-word
|
||||
"alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word
|
||||
"ctrl-k": "editor::KillRingCut", // kill-line
|
||||
"ctrl-w": "editor::Cut", // kill-region
|
||||
"alt-w": "editor::Copy", // kill-ring-save
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"ctrl-k ctrl-u": "editor::ConvertToUpperCase",
|
||||
"ctrl-k ctrl-l": "editor::ConvertToLowerCase",
|
||||
"shift-alt-m": "markdown::OpenPreviewToTheSide",
|
||||
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-delete": "editor::DeleteToNextWordEnd",
|
||||
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"alt-right": "editor::MoveToNextSubwordEnd",
|
||||
"alt-left": "editor::MoveToPreviousSubwordStart",
|
||||
"alt-shift-right": "editor::SelectToNextSubwordEnd",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"alt-,": "pane::GoBack", // xref-pop-marker-stack
|
||||
"ctrl-x h": "editor::SelectAll", // mark-whole-buffer
|
||||
"ctrl-d": "editor::Delete", // delete-char
|
||||
"alt-d": "editor::DeleteToNextWordEnd", // kill-word
|
||||
"alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word
|
||||
"ctrl-k": "editor::KillRingCut", // kill-line
|
||||
"ctrl-w": "editor::Cut", // kill-region
|
||||
"alt-w": "editor::Copy", // kill-ring-save
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
"cmd-k cmd-l": "editor::ConvertToLowerCase",
|
||||
"cmd-shift-j": "editor::JoinLines",
|
||||
"shift-alt-m": "markdown::OpenPreviewToTheSide",
|
||||
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-delete": "editor::DeleteToNextWordEnd",
|
||||
"ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-right": "editor::MoveToNextSubwordEnd",
|
||||
"ctrl-left": "editor::MoveToPreviousSubwordStart",
|
||||
"ctrl-shift-right": "editor::SelectToNextSubwordEnd",
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
{
|
||||
"context": "Editor",
|
||||
"bindings": {
|
||||
"alt-backspace": "editor::DeleteToPreviousWordStart",
|
||||
"alt-shift-backspace": "editor::DeleteToNextWordEnd",
|
||||
"alt-delete": "editor::DeleteToNextWordEnd",
|
||||
"alt-shift-delete": "editor::DeleteToNextWordEnd",
|
||||
"alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"alt-shift-backspace": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"alt-shift-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-backspace": "editor::DeleteToPreviousSubwordStart",
|
||||
"ctrl-delete": "editor::DeleteToNextSubwordEnd",
|
||||
"alt-left": ["editor::MoveToPreviousWordStart", { "stop_at_soft_wraps": true }],
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
"ctrl-x ctrl-z": "editor::Cancel",
|
||||
"ctrl-x ctrl-e": "vim::LineDown",
|
||||
"ctrl-x ctrl-y": "vim::LineUp",
|
||||
"ctrl-w": "editor::DeleteToPreviousWordStart",
|
||||
"ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }],
|
||||
"ctrl-u": "editor::DeleteToBeginningOfLine",
|
||||
"ctrl-t": "vim::Indent",
|
||||
"ctrl-d": "vim::Outdent",
|
||||
|
||||
@@ -172,7 +172,7 @@ The user has specified the following rules that should be applied:
|
||||
Rules title: {{title}}
|
||||
{{/if}}
|
||||
``````
|
||||
{{contents}}}
|
||||
{{contents}}
|
||||
``````
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
@@ -188,8 +188,8 @@
|
||||
// 4. A box drawn around the following character
|
||||
// "hollow"
|
||||
//
|
||||
// Default: not set, defaults to "bar"
|
||||
"cursor_shape": null,
|
||||
// Default: "bar"
|
||||
"cursor_shape": "bar",
|
||||
// Determines when the mouse cursor should be hidden in an editor or input box.
|
||||
//
|
||||
// 1. Never hide the mouse cursor:
|
||||
@@ -223,6 +223,8 @@
|
||||
"current_line_highlight": "all",
|
||||
// Whether to highlight all occurrences of the selected text in an editor.
|
||||
"selection_highlight": true,
|
||||
// Whether the text selection should have rounded corners.
|
||||
"rounded_selection": true,
|
||||
// The debounce delay before querying highlights from the language
|
||||
// server based on the current cursor location.
|
||||
"lsp_highlight_debounce": 75,
|
||||
@@ -280,8 +282,8 @@
|
||||
// - "warning"
|
||||
// - "info"
|
||||
// - "hint"
|
||||
// - null — allow all diagnostics (default)
|
||||
"diagnostics_max_severity": null,
|
||||
// - "all" — allow all diagnostics (default)
|
||||
"diagnostics_max_severity": "all",
|
||||
// Whether to show wrap guides (vertical rulers) in the editor.
|
||||
// Setting this to true will show a guide at the 'preferred_line_length' value
|
||||
// if 'soft_wrap' is set to 'preferred_line_length', and will show any
|
||||
@@ -1774,7 +1776,7 @@
|
||||
"api_url": "http://localhost:1234/api/v0"
|
||||
},
|
||||
"deepseek": {
|
||||
"api_url": "https://api.deepseek.com"
|
||||
"api_url": "https://api.deepseek.com/v1"
|
||||
},
|
||||
"mistral": {
|
||||
"api_url": "https://api.mistral.ai/v1"
|
||||
|
||||
@@ -31,18 +31,21 @@ language.workspace = true
|
||||
language_model.workspace = true
|
||||
markdown.workspace = true
|
||||
parking_lot = { workspace = true, optional = true }
|
||||
portable-pty.workspace = true
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
task.workspace = true
|
||||
terminal.workspace = true
|
||||
ui.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
watch.workspace = true
|
||||
which.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -7,6 +7,7 @@ use agent_settings::AgentSettings;
|
||||
use collections::HashSet;
|
||||
pub use connection::*;
|
||||
pub use diff::*;
|
||||
use futures::future::Shared;
|
||||
use language::language_settings::FormatOnSave;
|
||||
pub use mention::*;
|
||||
use project::lsp_store::{FormatTrigger, LspFormatTarget};
|
||||
@@ -15,7 +16,7 @@ use settings::Settings as _;
|
||||
pub use terminal::*;
|
||||
|
||||
use action_log::ActionLog;
|
||||
use agent_client_protocol as acp;
|
||||
use agent_client_protocol::{self as acp};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use editor::Bias;
|
||||
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
|
||||
@@ -33,7 +34,8 @@ use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{fmt::Display, mem, path::PathBuf, sync::Arc};
|
||||
use ui::App;
|
||||
use util::ResultExt;
|
||||
use util::{ResultExt, get_system_shell};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserMessage {
|
||||
@@ -183,37 +185,46 @@ impl ToolCall {
|
||||
tool_call: acp::ToolCall,
|
||||
status: ToolCallStatus,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
) -> Result<Self> {
|
||||
let title = if let Some((first_line, _)) = tool_call.title.split_once("\n") {
|
||||
first_line.to_owned() + "…"
|
||||
} else {
|
||||
tool_call.title
|
||||
};
|
||||
Self {
|
||||
let mut content = Vec::with_capacity(tool_call.content.len());
|
||||
for item in tool_call.content {
|
||||
content.push(ToolCallContent::from_acp(
|
||||
item,
|
||||
language_registry.clone(),
|
||||
terminals,
|
||||
cx,
|
||||
)?);
|
||||
}
|
||||
|
||||
let result = Self {
|
||||
id: tool_call.id,
|
||||
label: cx
|
||||
.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)),
|
||||
kind: tool_call.kind,
|
||||
content: tool_call
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|content| ToolCallContent::from_acp(content, language_registry.clone(), cx))
|
||||
.collect(),
|
||||
content,
|
||||
locations: tool_call.locations,
|
||||
resolved_locations: Vec::default(),
|
||||
status,
|
||||
raw_input: tool_call.raw_input,
|
||||
raw_output: tool_call.raw_output,
|
||||
}
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn update_fields(
|
||||
&mut self,
|
||||
fields: acp::ToolCallUpdateFields,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
) -> Result<()> {
|
||||
let acp::ToolCallUpdateFields {
|
||||
kind,
|
||||
status,
|
||||
@@ -248,14 +259,15 @@ impl ToolCall {
|
||||
|
||||
// Reuse existing content if we can
|
||||
for (old, new) in self.content.iter_mut().zip(content.by_ref()) {
|
||||
old.update_from_acp(new, language_registry.clone(), cx);
|
||||
old.update_from_acp(new, language_registry.clone(), terminals, cx)?;
|
||||
}
|
||||
for new in content {
|
||||
self.content.push(ToolCallContent::from_acp(
|
||||
new,
|
||||
language_registry.clone(),
|
||||
terminals,
|
||||
cx,
|
||||
))
|
||||
)?)
|
||||
}
|
||||
self.content.truncate(new_content_len);
|
||||
}
|
||||
@@ -279,6 +291,7 @@ impl ToolCall {
|
||||
}
|
||||
self.raw_output = Some(raw_output);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
|
||||
@@ -549,13 +562,16 @@ impl ToolCallContent {
|
||||
pub fn from_acp(
|
||||
content: acp::ToolCallContent,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
) -> Result<Self> {
|
||||
match content {
|
||||
acp::ToolCallContent::Content { content } => {
|
||||
Self::ContentBlock(ContentBlock::new(content, &language_registry, cx))
|
||||
}
|
||||
acp::ToolCallContent::Diff { diff } => Self::Diff(cx.new(|cx| {
|
||||
acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new(
|
||||
content,
|
||||
&language_registry,
|
||||
cx,
|
||||
))),
|
||||
acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| {
|
||||
Diff::finalized(
|
||||
diff.path,
|
||||
diff.old_text,
|
||||
@@ -563,7 +579,12 @@ impl ToolCallContent {
|
||||
language_registry,
|
||||
cx,
|
||||
)
|
||||
})),
|
||||
}))),
|
||||
acp::ToolCallContent::Terminal { terminal_id } => terminals
|
||||
.get(&terminal_id)
|
||||
.cloned()
|
||||
.map(Self::Terminal)
|
||||
.ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,8 +592,9 @@ impl ToolCallContent {
|
||||
&mut self,
|
||||
new: acp::ToolCallContent,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
) -> Result<()> {
|
||||
let needs_update = match (&self, &new) {
|
||||
(Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => {
|
||||
old_diff.read(cx).needs_update(
|
||||
@@ -585,8 +607,9 @@ impl ToolCallContent {
|
||||
};
|
||||
|
||||
if needs_update {
|
||||
*self = Self::from_acp(new, language_registry, cx);
|
||||
*self = Self::from_acp(new, language_registry, terminals, cx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_markdown(&self, cx: &App) -> String {
|
||||
@@ -762,7 +785,10 @@ pub struct AcpThread {
|
||||
session_id: acp::SessionId,
|
||||
token_usage: Option<TokenUsage>,
|
||||
prompt_capabilities: acp::PromptCapabilities,
|
||||
available_commands: Vec<acp::AvailableCommand>,
|
||||
_observe_prompt_capabilities: Task<anyhow::Result<()>>,
|
||||
determine_shell: Shared<Task<String>>,
|
||||
terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -778,6 +804,7 @@ pub enum AcpThreadEvent {
|
||||
Error,
|
||||
LoadError(LoadError),
|
||||
PromptCapabilitiesUpdated,
|
||||
Refusal,
|
||||
}
|
||||
|
||||
impl EventEmitter<AcpThreadEvent> for AcpThread {}
|
||||
@@ -833,6 +860,7 @@ impl AcpThread {
|
||||
action_log: Entity<ActionLog>,
|
||||
session_id: acp::SessionId,
|
||||
mut prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
|
||||
available_commands: Vec<acp::AvailableCommand>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let prompt_capabilities = *prompt_capabilities_rx.borrow();
|
||||
@@ -846,6 +874,20 @@ impl AcpThread {
|
||||
}
|
||||
});
|
||||
|
||||
let determine_shell = cx
|
||||
.background_spawn(async move {
|
||||
if cfg!(windows) {
|
||||
return get_system_shell();
|
||||
}
|
||||
|
||||
if which::which("bash").is_ok() {
|
||||
"bash".into()
|
||||
} else {
|
||||
get_system_shell()
|
||||
}
|
||||
})
|
||||
.shared();
|
||||
|
||||
Self {
|
||||
action_log,
|
||||
shared_buffers: Default::default(),
|
||||
@@ -858,7 +900,10 @@ impl AcpThread {
|
||||
session_id,
|
||||
token_usage: None,
|
||||
prompt_capabilities,
|
||||
available_commands,
|
||||
_observe_prompt_capabilities: task,
|
||||
terminals: HashMap::default(),
|
||||
determine_shell,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,6 +911,10 @@ impl AcpThread {
|
||||
self.prompt_capabilities
|
||||
}
|
||||
|
||||
pub fn available_commands(&self) -> Vec<acp::AvailableCommand> {
|
||||
self.available_commands.clone()
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> &Rc<dyn AgentConnection> {
|
||||
&self.connection
|
||||
}
|
||||
@@ -1082,27 +1131,28 @@ impl AcpThread {
|
||||
let update = update.into();
|
||||
let languages = self.project.read(cx).languages().clone();
|
||||
|
||||
let (ix, current_call) = self
|
||||
.tool_call_mut(update.id())
|
||||
let ix = self
|
||||
.index_for_tool_call(update.id())
|
||||
.context("Tool call not found")?;
|
||||
let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
match update {
|
||||
ToolCallUpdate::UpdateFields(update) => {
|
||||
let location_updated = update.fields.locations.is_some();
|
||||
current_call.update_fields(update.fields, languages, cx);
|
||||
call.update_fields(update.fields, languages, &self.terminals, cx)?;
|
||||
if location_updated {
|
||||
self.resolve_locations(update.id, cx);
|
||||
}
|
||||
}
|
||||
ToolCallUpdate::UpdateDiff(update) => {
|
||||
current_call.content.clear();
|
||||
current_call
|
||||
.content
|
||||
.push(ToolCallContent::Diff(update.diff));
|
||||
call.content.clear();
|
||||
call.content.push(ToolCallContent::Diff(update.diff));
|
||||
}
|
||||
ToolCallUpdate::UpdateTerminal(update) => {
|
||||
current_call.content.clear();
|
||||
current_call
|
||||
.content
|
||||
call.content.clear();
|
||||
call.content
|
||||
.push(ToolCallContent::Terminal(update.terminal));
|
||||
}
|
||||
}
|
||||
@@ -1125,21 +1175,30 @@ impl AcpThread {
|
||||
/// Fails if id does not match an existing entry.
|
||||
pub fn upsert_tool_call_inner(
|
||||
&mut self,
|
||||
tool_call_update: acp::ToolCallUpdate,
|
||||
update: acp::ToolCallUpdate,
|
||||
status: ToolCallStatus,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), acp::Error> {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
let id = tool_call_update.id.clone();
|
||||
let id = update.id.clone();
|
||||
|
||||
if let Some((ix, current_call)) = self.tool_call_mut(&id) {
|
||||
current_call.update_fields(tool_call_update.fields, language_registry, cx);
|
||||
current_call.status = status;
|
||||
if let Some(ix) = self.index_for_tool_call(&id) {
|
||||
let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
call.update_fields(update.fields, language_registry, &self.terminals, cx)?;
|
||||
call.status = status;
|
||||
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(ix));
|
||||
} else {
|
||||
let call =
|
||||
ToolCall::from_acp(tool_call_update.try_into()?, status, language_registry, cx);
|
||||
let call = ToolCall::from_acp(
|
||||
update.try_into()?,
|
||||
status,
|
||||
language_registry,
|
||||
&self.terminals,
|
||||
cx,
|
||||
)?;
|
||||
self.push_entry(AgentThreadEntry::ToolCall(call), cx);
|
||||
};
|
||||
|
||||
@@ -1147,6 +1206,22 @@ impl AcpThread {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn index_for_tool_call(&self, id: &acp::ToolCallId) -> Option<usize> {
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(index, entry)| {
|
||||
if let AgentThreadEntry::ToolCall(tool_call) = entry
|
||||
&& &tool_call.id == id
|
||||
{
|
||||
Some(index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_call_mut(&mut self, id: &acp::ToolCallId) -> Option<(usize, &mut ToolCall)> {
|
||||
// The tool call we are looking for is typically the last one, or very close to the end.
|
||||
// At the moment, it doesn't seem like a hashmap would be a good fit for this use case.
|
||||
@@ -1495,15 +1570,42 @@ impl AcpThread {
|
||||
this.send_task.take();
|
||||
}
|
||||
|
||||
// Truncate entries if the last prompt was refused.
|
||||
// Handle refusal - distinguish between user prompt and tool call refusals
|
||||
if let Ok(Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::Refusal,
|
||||
})) = result
|
||||
&& let Some((ix, _)) = this.last_user_message()
|
||||
{
|
||||
let range = ix..this.entries.len();
|
||||
this.entries.truncate(ix);
|
||||
cx.emit(AcpThreadEvent::EntriesRemoved(range));
|
||||
if let Some((user_msg_ix, _)) = this.last_user_message() {
|
||||
// Check if there's a completed tool call with results after the last user message
|
||||
// This indicates the refusal is in response to tool output, not the user's prompt
|
||||
let has_completed_tool_call_after_user_msg =
|
||||
this.entries.iter().skip(user_msg_ix + 1).any(|entry| {
|
||||
if let AgentThreadEntry::ToolCall(tool_call) = entry {
|
||||
// Check if the tool call has completed and has output
|
||||
matches!(tool_call.status, ToolCallStatus::Completed)
|
||||
&& tool_call.raw_output.is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_completed_tool_call_after_user_msg {
|
||||
// Refusal is due to tool output - don't truncate, just notify
|
||||
// The model refused based on what the tool returned
|
||||
cx.emit(AcpThreadEvent::Refusal);
|
||||
} else {
|
||||
// User prompt was refused - truncate back to before the user message
|
||||
let range = user_msg_ix..this.entries.len();
|
||||
if range.start < range.end {
|
||||
this.entries.truncate(user_msg_ix);
|
||||
cx.emit(AcpThreadEvent::EntriesRemoved(range));
|
||||
}
|
||||
cx.emit(AcpThreadEvent::Refusal);
|
||||
}
|
||||
} else {
|
||||
// No user message found, treat as general refusal
|
||||
cx.emit(AcpThreadEvent::Refusal);
|
||||
}
|
||||
}
|
||||
|
||||
cx.emit(AcpThreadEvent::Stopped);
|
||||
@@ -1829,6 +1931,133 @@ impl AcpThread {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_terminal(
|
||||
&self,
|
||||
mut command: String,
|
||||
args: Vec<String>,
|
||||
extra_env: Vec<acp::EnvVariable>,
|
||||
cwd: Option<PathBuf>,
|
||||
output_byte_limit: Option<u64>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Entity<Terminal>>> {
|
||||
for arg in args {
|
||||
command.push(' ');
|
||||
command.push_str(&arg);
|
||||
}
|
||||
|
||||
let shell_command = if cfg!(windows) {
|
||||
format!("$null | & {{{}}}", command.replace("\"", "'"))
|
||||
} else if let Some(cwd) = cwd.as_ref().and_then(|cwd| cwd.as_os_str().to_str()) {
|
||||
// Make sure once we're *inside* the shell, we cd into `cwd`
|
||||
format!("(cd {cwd}; {}) </dev/null", command)
|
||||
} else {
|
||||
format!("({}) </dev/null", command)
|
||||
};
|
||||
let args = vec!["-c".into(), shell_command];
|
||||
|
||||
let env = match &cwd {
|
||||
Some(dir) => self.project.update(cx, |project, cx| {
|
||||
project.directory_environment(dir.as_path().into(), cx)
|
||||
}),
|
||||
None => Task::ready(None).shared(),
|
||||
};
|
||||
|
||||
let env = cx.spawn(async move |_, _| {
|
||||
let mut env = env.await.unwrap_or_default();
|
||||
if cfg!(unix) {
|
||||
env.insert("PAGER".into(), "cat".into());
|
||||
}
|
||||
for var in extra_env {
|
||||
env.insert(var.name, var.value);
|
||||
}
|
||||
env
|
||||
});
|
||||
|
||||
let project = self.project.clone();
|
||||
let language_registry = project.read(cx).languages().clone();
|
||||
let determine_shell = self.determine_shell.clone();
|
||||
|
||||
let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into());
|
||||
let terminal_task = cx.spawn({
|
||||
let terminal_id = terminal_id.clone();
|
||||
async move |_this, cx| {
|
||||
let program = determine_shell.await;
|
||||
let env = env.await;
|
||||
let terminal = project
|
||||
.update(cx, |project, cx| {
|
||||
project.create_terminal_task(
|
||||
task::SpawnInTerminal {
|
||||
command: Some(program),
|
||||
args,
|
||||
cwd: cwd.clone(),
|
||||
env,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
cx.new(|cx| {
|
||||
Terminal::new(
|
||||
terminal_id,
|
||||
command,
|
||||
cwd,
|
||||
output_byte_limit.map(|l| l as usize),
|
||||
terminal,
|
||||
language_registry,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let terminal = terminal_task.await?;
|
||||
this.update(cx, |this, _cx| {
|
||||
this.terminals.insert(terminal_id, terminal.clone());
|
||||
terminal
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kill_terminal(
|
||||
&mut self,
|
||||
terminal_id: acp::TerminalId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<()> {
|
||||
self.terminals
|
||||
.get(&terminal_id)
|
||||
.context("Terminal not found")?
|
||||
.update(cx, |terminal, cx| {
|
||||
terminal.kill(cx);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn release_terminal(
|
||||
&mut self,
|
||||
terminal_id: acp::TerminalId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<()> {
|
||||
self.terminals
|
||||
.remove(&terminal_id)
|
||||
.context("Terminal not found")?
|
||||
.update(cx, |terminal, cx| {
|
||||
terminal.kill(cx);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn terminal(&self, terminal_id: acp::TerminalId) -> Result<Entity<Terminal>> {
|
||||
self.terminals
|
||||
.get(&terminal_id)
|
||||
.context("Terminal not found")
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn to_markdown(&self, cx: &App) -> String {
|
||||
self.entries.iter().map(|e| e.to_markdown(cx)).collect()
|
||||
}
|
||||
@@ -2480,6 +2709,187 @@ mod tests {
|
||||
assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tool_result_refusal(cx: &mut TestAppContext) {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, None, cx).await;
|
||||
|
||||
// Create a connection that simulates refusal after tool result
|
||||
let prompt_count = Arc::new(AtomicUsize::new(0));
|
||||
let connection = Rc::new(FakeAgentConnection::new().on_user_message({
|
||||
let prompt_count = prompt_count.clone();
|
||||
move |_request, thread, mut cx| {
|
||||
let count = prompt_count.fetch_add(1, SeqCst);
|
||||
async move {
|
||||
if count == 0 {
|
||||
// First prompt: Generate a tool call with result
|
||||
thread.update(&mut cx, |thread, cx| {
|
||||
thread
|
||||
.handle_session_update(
|
||||
acp::SessionUpdate::ToolCall(acp::ToolCall {
|
||||
id: acp::ToolCallId("tool1".into()),
|
||||
title: "Test Tool".into(),
|
||||
kind: acp::ToolKind::Fetch,
|
||||
status: acp::ToolCallStatus::Completed,
|
||||
content: vec![],
|
||||
locations: vec![],
|
||||
raw_input: Some(serde_json::json!({"query": "test"})),
|
||||
raw_output: Some(
|
||||
serde_json::json!({"result": "inappropriate content"}),
|
||||
),
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
})?;
|
||||
|
||||
// Now return refusal because of the tool result
|
||||
Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::Refusal,
|
||||
})
|
||||
} else {
|
||||
Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::EndTurn,
|
||||
})
|
||||
}
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_thread(project, Path::new("/test"), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Track if we see a Refusal event
|
||||
let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
|
||||
let saw_refusal_event_captured = saw_refusal_event.clone();
|
||||
thread.update(cx, |_thread, cx| {
|
||||
cx.subscribe(
|
||||
&thread,
|
||||
move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
|
||||
if matches!(event, AcpThreadEvent::Refusal) {
|
||||
*saw_refusal_event_captured.lock().unwrap() = true;
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
});
|
||||
|
||||
// Send a user message - this will trigger tool call and then refusal
|
||||
let send_task = thread.update(cx, |thread, cx| {
|
||||
thread.send(
|
||||
vec![acp::ContentBlock::Text(acp::TextContent {
|
||||
text: "Hello".into(),
|
||||
annotations: None,
|
||||
})],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.background_executor.spawn(send_task).detach();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify that:
|
||||
// 1. A Refusal event WAS emitted (because it's a tool result refusal, not user prompt)
|
||||
// 2. The user message was NOT truncated
|
||||
assert!(
|
||||
*saw_refusal_event.lock().unwrap(),
|
||||
"Refusal event should be emitted for tool result refusals"
|
||||
);
|
||||
|
||||
thread.read_with(cx, |thread, _| {
|
||||
let entries = thread.entries();
|
||||
assert!(entries.len() >= 2, "Should have user message and tool call");
|
||||
|
||||
// Verify user message is still there
|
||||
assert!(
|
||||
matches!(entries[0], AgentThreadEntry::UserMessage(_)),
|
||||
"User message should not be truncated"
|
||||
);
|
||||
|
||||
// Verify tool call is there with result
|
||||
if let AgentThreadEntry::ToolCall(tool_call) = &entries[1] {
|
||||
assert!(
|
||||
tool_call.raw_output.is_some(),
|
||||
"Tool call should have output"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected tool call at index 1");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, None, cx).await;
|
||||
|
||||
let refuse_next = Arc::new(AtomicBool::new(false));
|
||||
let connection = Rc::new(FakeAgentConnection::new().on_user_message({
|
||||
let refuse_next = refuse_next.clone();
|
||||
move |_request, _thread, _cx| {
|
||||
if refuse_next.load(SeqCst) {
|
||||
async move {
|
||||
Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::Refusal,
|
||||
})
|
||||
}
|
||||
.boxed_local()
|
||||
} else {
|
||||
async move {
|
||||
Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::EndTurn,
|
||||
})
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Track if we see a Refusal event
|
||||
let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
|
||||
let saw_refusal_event_captured = saw_refusal_event.clone();
|
||||
thread.update(cx, |_thread, cx| {
|
||||
cx.subscribe(
|
||||
&thread,
|
||||
move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
|
||||
if matches!(event, AcpThreadEvent::Refusal) {
|
||||
*saw_refusal_event_captured.lock().unwrap() = true;
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
});
|
||||
|
||||
// Send a message that will be refused
|
||||
refuse_next.store(true, SeqCst);
|
||||
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify that a Refusal event WAS emitted for user prompt refusal
|
||||
assert!(
|
||||
*saw_refusal_event.lock().unwrap(),
|
||||
"Refusal event should be emitted for user prompt refusals"
|
||||
);
|
||||
|
||||
// Verify the message was truncated (user prompt refusal)
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(thread.to_markdown(cx), "");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_refusal(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -2543,8 +2953,8 @@ mod tests {
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate refusing the second message, ensuring the conversation gets
|
||||
// truncated to before sending it.
|
||||
// Simulate refusing the second message. The message should be truncated
|
||||
// when a user prompt is refused.
|
||||
refuse_next.store(true, SeqCst);
|
||||
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx)))
|
||||
.await
|
||||
@@ -2670,6 +3080,7 @@ mod tests {
|
||||
audio: true,
|
||||
embedded_context: true,
|
||||
}),
|
||||
vec![],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -75,7 +75,6 @@ pub trait AgentConnection {
|
||||
fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
|
||||
}
|
||||
|
||||
@@ -339,6 +338,7 @@ mod test_support {
|
||||
audio: true,
|
||||
embedded_context: true,
|
||||
}),
|
||||
vec![],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
use gpui::{App, AppContext, Context, Entity};
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use futures::{FutureExt as _, future::Shared};
|
||||
use gpui::{App, AppContext, Context, Entity, Task};
|
||||
use language::LanguageRegistry;
|
||||
use markdown::Markdown;
|
||||
use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant};
|
||||
|
||||
pub struct Terminal {
|
||||
id: acp::TerminalId,
|
||||
command: Entity<Markdown>,
|
||||
working_dir: Option<PathBuf>,
|
||||
terminal: Entity<terminal::Terminal>,
|
||||
started_at: Instant,
|
||||
output: Option<TerminalOutput>,
|
||||
output_byte_limit: Option<usize>,
|
||||
_output_task: Shared<Task<acp::TerminalExitStatus>>,
|
||||
}
|
||||
|
||||
pub struct TerminalOutput {
|
||||
pub ended_at: Instant,
|
||||
pub exit_status: Option<ExitStatus>,
|
||||
pub was_content_truncated: bool,
|
||||
pub content: String,
|
||||
pub original_content_len: usize,
|
||||
pub content_line_count: usize,
|
||||
pub finished_with_empty_output: bool,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
pub fn new(
|
||||
id: acp::TerminalId,
|
||||
command: String,
|
||||
working_dir: Option<PathBuf>,
|
||||
output_byte_limit: Option<usize>,
|
||||
terminal: Entity<terminal::Terminal>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let command_task = terminal.read(cx).wait_for_completed_task(cx);
|
||||
Self {
|
||||
id,
|
||||
command: cx.new(|cx| {
|
||||
Markdown::new(
|
||||
format!("```\n{}\n```", command).into(),
|
||||
@@ -41,27 +50,93 @@ impl Terminal {
|
||||
terminal,
|
||||
started_at: Instant::now(),
|
||||
output: None,
|
||||
output_byte_limit,
|
||||
_output_task: cx
|
||||
.spawn(async move |this, cx| {
|
||||
let exit_status = command_task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let (content, original_content_len) = this.truncated_output(cx);
|
||||
let content_line_count = this.terminal.read(cx).total_lines();
|
||||
|
||||
this.output = Some(TerminalOutput {
|
||||
ended_at: Instant::now(),
|
||||
exit_status,
|
||||
content,
|
||||
original_content_len,
|
||||
content_line_count,
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
let exit_status = exit_status.map(portable_pty::ExitStatus::from);
|
||||
|
||||
acp::TerminalExitStatus {
|
||||
exit_code: exit_status.as_ref().map(|e| e.exit_code()),
|
||||
signal: exit_status.and_then(|e| e.signal().map(Into::into)),
|
||||
}
|
||||
})
|
||||
.shared(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
exit_status: Option<ExitStatus>,
|
||||
original_content_len: usize,
|
||||
truncated_content_len: usize,
|
||||
content_line_count: usize,
|
||||
finished_with_empty_output: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.output = Some(TerminalOutput {
|
||||
ended_at: Instant::now(),
|
||||
exit_status,
|
||||
was_content_truncated: truncated_content_len < original_content_len,
|
||||
original_content_len,
|
||||
content_line_count,
|
||||
finished_with_empty_output,
|
||||
pub fn id(&self) -> &acp::TerminalId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
|
||||
self._output_task.clone()
|
||||
}
|
||||
|
||||
pub fn kill(&mut self, cx: &mut App) {
|
||||
self.terminal.update(cx, |terminal, _cx| {
|
||||
terminal.kill_active_task();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
|
||||
if let Some(output) = self.output.as_ref() {
|
||||
let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
|
||||
|
||||
acp::TerminalOutputResponse {
|
||||
output: output.content.clone(),
|
||||
truncated: output.original_content_len > output.content.len(),
|
||||
exit_status: Some(acp::TerminalExitStatus {
|
||||
exit_code: exit_status.as_ref().map(|e| e.exit_code()),
|
||||
signal: exit_status.and_then(|e| e.signal().map(Into::into)),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
let (current_content, original_len) = self.truncated_output(cx);
|
||||
|
||||
acp::TerminalOutputResponse {
|
||||
truncated: current_content.len() < original_len,
|
||||
output: current_content,
|
||||
exit_status: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncated_output(&self, cx: &App) -> (String, usize) {
|
||||
let terminal = self.terminal.read(cx);
|
||||
let mut content = terminal.get_content();
|
||||
|
||||
let original_content_len = content.len();
|
||||
|
||||
if let Some(limit) = self.output_byte_limit
|
||||
&& content.len() > limit
|
||||
{
|
||||
let mut end_ix = limit.min(content.len());
|
||||
while !content.is_char_boundary(end_ix) {
|
||||
end_ix -= 1;
|
||||
}
|
||||
// Don't truncate mid-line, clear the remainder of the last line
|
||||
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
|
||||
content.truncate(end_ix);
|
||||
}
|
||||
|
||||
(content, original_content_len)
|
||||
}
|
||||
|
||||
pub fn command(&self) -> &Entity<Markdown> {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage, VersionCheckType};
|
||||
use editor::Editor;
|
||||
use extension_host::ExtensionStore;
|
||||
use extension_host::{ExtensionOperation, ExtensionStore};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, App, Context, CursorStyle, Entity, EventEmitter,
|
||||
InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement,
|
||||
Styled, Transformation, Window, actions, percentage,
|
||||
App, Context, CursorStyle, Entity, EventEmitter, InteractiveElement as _, ParentElement as _,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Window, actions,
|
||||
};
|
||||
use language::{
|
||||
BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName,
|
||||
@@ -25,7 +24,10 @@ use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use ui::{ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
|
||||
use ui::{
|
||||
ButtonLike, CommonAnimationExt, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip,
|
||||
prelude::*,
|
||||
};
|
||||
use util::truncate_and_trailoff;
|
||||
use workspace::{StatusItemView, Workspace, item::ItemHandle};
|
||||
|
||||
@@ -405,13 +407,7 @@ impl ActivityIndicator {
|
||||
icon: Some(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(percentage(delta)))
|
||||
},
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
),
|
||||
message,
|
||||
@@ -433,11 +429,7 @@ impl ActivityIndicator {
|
||||
icon: Some(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
),
|
||||
message: format!("Debug: {}", session.read(cx).adapter()),
|
||||
@@ -460,11 +452,7 @@ impl ActivityIndicator {
|
||||
icon: Some(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
),
|
||||
message: job_info.message.into(),
|
||||
@@ -671,8 +659,9 @@ impl ActivityIndicator {
|
||||
}
|
||||
|
||||
// Show any application auto-update info.
|
||||
if let Some(updater) = &self.auto_updater {
|
||||
return match &updater.read(cx).status() {
|
||||
self.auto_updater
|
||||
.as_ref()
|
||||
.and_then(|updater| match &updater.read(cx).status() {
|
||||
AutoUpdateStatus::Checking => Some(Content {
|
||||
icon: Some(
|
||||
Icon::new(IconName::Download)
|
||||
@@ -728,28 +717,49 @@ impl ActivityIndicator {
|
||||
tooltip_message: None,
|
||||
}),
|
||||
AutoUpdateStatus::Idle => None,
|
||||
};
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
if let Some(extension_store) =
|
||||
ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
|
||||
&& let Some((extension_id, operation)) =
|
||||
extension_store.outstanding_operations().iter().next()
|
||||
{
|
||||
let (message, icon, rotate) = match operation {
|
||||
ExtensionOperation::Install => (
|
||||
format!("Installing {extension_id} extension…"),
|
||||
IconName::LoadCircle,
|
||||
true,
|
||||
),
|
||||
ExtensionOperation::Upgrade => (
|
||||
format!("Updating {extension_id} extension…"),
|
||||
IconName::Download,
|
||||
false,
|
||||
),
|
||||
ExtensionOperation::Remove => (
|
||||
format!("Removing {extension_id} extension…"),
|
||||
IconName::LoadCircle,
|
||||
true,
|
||||
),
|
||||
};
|
||||
|
||||
if let Some(extension_store) =
|
||||
ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
|
||||
&& let Some(extension_id) = extension_store.outstanding_operations().keys().next()
|
||||
{
|
||||
return Some(Content {
|
||||
icon: Some(
|
||||
Icon::new(IconName::Download)
|
||||
.size(IconSize::Small)
|
||||
.into_any_element(),
|
||||
),
|
||||
message: format!("Updating {extension_id} extension…"),
|
||||
on_click: Some(Arc::new(|this, window, cx| {
|
||||
this.dismiss_error_message(&DismissErrorMessage, window, cx)
|
||||
})),
|
||||
tooltip_message: None,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
Some(Content {
|
||||
icon: Some(Icon::new(icon).size(IconSize::Small).map(|this| {
|
||||
if rotate {
|
||||
this.with_rotate_animation(3).into_any_element()
|
||||
} else {
|
||||
this.into_any_element()
|
||||
}
|
||||
})),
|
||||
message,
|
||||
on_click: Some(Arc::new(|this, window, cx| {
|
||||
this.dismiss_error_message(&Default::default(), window, cx)
|
||||
})),
|
||||
tooltip_message: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn version_tooltip_message(version: &VersionCheckType) -> String {
|
||||
|
||||
@@ -48,7 +48,6 @@ log.workspace = true
|
||||
open.workspace = true
|
||||
parking_lot.workspace = true
|
||||
paths.workspace = true
|
||||
portable-pty.workspace = true
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
rust-embed.workspace = true
|
||||
@@ -68,7 +67,6 @@ util.workspace = true
|
||||
uuid.workspace = true
|
||||
watch.workspace = true
|
||||
web_search.workspace = true
|
||||
which.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
zstd.workspace = true
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
ContextServerRegistry, Thread, ThreadEvent, ThreadsDatabase, ToolCallAuthorization,
|
||||
UserMessageContent, templates::Templates,
|
||||
};
|
||||
use crate::{HistoryStore, TitleUpdated, TokenUsageUpdated};
|
||||
use crate::{HistoryStore, TerminalHandle, ThreadEnvironment, TitleUpdated, TokenUsageUpdated};
|
||||
use acp_thread::{AcpThread, AgentModelSelector};
|
||||
use action_log::ActionLog;
|
||||
use agent_client_protocol as acp;
|
||||
@@ -10,7 +10,8 @@ use agent_settings::AgentSettings;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::{HashSet, IndexMap};
|
||||
use fs::Fs;
|
||||
use futures::channel::mpsc;
|
||||
use futures::channel::{mpsc, oneshot};
|
||||
use futures::future::Shared;
|
||||
use futures::{StreamExt, future};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
|
||||
@@ -23,7 +24,7 @@ use prompt_store::{
|
||||
use settings::update_settings_file;
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use util::ResultExt;
|
||||
@@ -276,13 +277,6 @@ impl NativeAgent {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<AcpThread> {
|
||||
let connection = Rc::new(NativeAgentConnection(cx.entity()));
|
||||
let registry = LanguageModelRegistry::read_global(cx);
|
||||
let summarization_model = registry.thread_summary_model().map(|c| c.model);
|
||||
|
||||
thread_handle.update(cx, |thread, cx| {
|
||||
thread.set_summarization_model(summarization_model, cx);
|
||||
thread.add_default_tools(cx)
|
||||
});
|
||||
|
||||
let thread = thread_handle.read(cx);
|
||||
let session_id = thread.id().clone();
|
||||
@@ -298,9 +292,24 @@ impl NativeAgent {
|
||||
action_log.clone(),
|
||||
session_id.clone(),
|
||||
prompt_capabilities_rx,
|
||||
vec![],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let registry = LanguageModelRegistry::read_global(cx);
|
||||
let summarization_model = registry.thread_summary_model().map(|c| c.model);
|
||||
|
||||
thread_handle.update(cx, |thread, cx| {
|
||||
thread.set_summarization_model(summarization_model, cx);
|
||||
thread.add_default_tools(
|
||||
Rc::new(AcpThreadEnvironment {
|
||||
acp_thread: acp_thread.downgrade(),
|
||||
}) as _,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let subscriptions = vec![
|
||||
cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
|
||||
this.sessions.remove(acp_thread.session_id());
|
||||
@@ -1001,7 +1010,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
|
||||
self.0.read_with(cx, |agent, _cx| {
|
||||
agent.sessions.get(session_id).map(|session| {
|
||||
Rc::new(NativeAgentSessionEditor {
|
||||
Rc::new(NativeAgentSessionTruncate {
|
||||
thread: session.thread.clone(),
|
||||
acp_thread: session.acp_thread.clone(),
|
||||
}) as _
|
||||
@@ -1050,12 +1059,12 @@ impl acp_thread::AgentTelemetry for NativeAgentConnection {
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeAgentSessionEditor {
|
||||
struct NativeAgentSessionTruncate {
|
||||
thread: Entity<Thread>,
|
||||
acp_thread: WeakEntity<AcpThread>,
|
||||
}
|
||||
|
||||
impl acp_thread::AgentSessionTruncate for NativeAgentSessionEditor {
|
||||
impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate {
|
||||
fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task<Result<()>> {
|
||||
match self.thread.update(cx, |thread, cx| {
|
||||
thread.truncate(message_id.clone(), cx)?;
|
||||
@@ -1104,6 +1113,66 @@ impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AcpThreadEnvironment {
|
||||
acp_thread: WeakEntity<AcpThread>,
|
||||
}
|
||||
|
||||
impl ThreadEnvironment for AcpThreadEnvironment {
|
||||
fn create_terminal(
|
||||
&self,
|
||||
command: String,
|
||||
cwd: Option<PathBuf>,
|
||||
output_byte_limit: Option<u64>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<Rc<dyn TerminalHandle>>> {
|
||||
let task = self.acp_thread.update(cx, |thread, cx| {
|
||||
thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx)
|
||||
});
|
||||
|
||||
let acp_thread = self.acp_thread.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
let terminal = task?.await?;
|
||||
|
||||
let (drop_tx, drop_rx) = oneshot::channel();
|
||||
let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone())?;
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
drop_rx.await.ok();
|
||||
acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx))
|
||||
})
|
||||
.detach();
|
||||
|
||||
let handle = AcpTerminalHandle {
|
||||
terminal,
|
||||
_drop_tx: Some(drop_tx),
|
||||
};
|
||||
|
||||
Ok(Rc::new(handle) as _)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AcpTerminalHandle {
|
||||
terminal: Entity<acp_thread::Terminal>,
|
||||
_drop_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl TerminalHandle for AcpTerminalHandle {
|
||||
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId> {
|
||||
self.terminal.read_with(cx, |term, _cx| term.id().clone())
|
||||
}
|
||||
|
||||
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
|
||||
self.terminal
|
||||
.read_with(cx, |term, _cx| term.wait_for_exit())
|
||||
}
|
||||
|
||||
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
|
||||
self.terminal
|
||||
.read_with(cx, |term, cx| term.current_output(cx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::HistoryEntryId;
|
||||
|
||||
@@ -72,7 +72,6 @@ async fn test_echo(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
|
||||
async fn test_thinking(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
@@ -1349,7 +1348,6 @@ async fn test_cancellation(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
|
||||
async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
@@ -1688,7 +1686,6 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
|
||||
async fn test_title_generation(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
@@ -2353,15 +2350,20 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
|
||||
settings::init(cx);
|
||||
Project::init_settings(cx);
|
||||
agent_settings::init(cx);
|
||||
gpui_tokio::init(cx);
|
||||
let http_client = ReqwestClient::user_agent("agent tests").unwrap();
|
||||
cx.set_http_client(Arc::new(http_client));
|
||||
|
||||
client::init_settings(cx);
|
||||
let client = Client::production(cx);
|
||||
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
|
||||
language_model::init(client.clone(), cx);
|
||||
language_models::init(user_store, client.clone(), cx);
|
||||
match model {
|
||||
TestModel::Fake => {}
|
||||
TestModel::Sonnet4 => {
|
||||
gpui_tokio::init(cx);
|
||||
let http_client = ReqwestClient::user_agent("agent tests").unwrap();
|
||||
cx.set_http_client(Arc::new(http_client));
|
||||
client::init_settings(cx);
|
||||
let client = Client::production(cx);
|
||||
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
|
||||
language_model::init(client.clone(), cx);
|
||||
language_models::init(user_store, client.clone(), cx);
|
||||
}
|
||||
};
|
||||
|
||||
watch_settings(fs.clone(), cx);
|
||||
});
|
||||
@@ -2475,6 +2477,7 @@ fn setup_context_server(
|
||||
path: "somebinary".into(),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
timeout: None,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -45,14 +45,15 @@ use schemars::{JsonSchema, Schema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, update_settings_file};
|
||||
use smol::stream::StreamExt;
|
||||
use std::fmt::Write;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ops::RangeInclusive,
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -523,6 +524,22 @@ pub enum AgentMessageContent {
|
||||
ToolUse(LanguageModelToolUse),
|
||||
}
|
||||
|
||||
pub trait TerminalHandle {
|
||||
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId>;
|
||||
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse>;
|
||||
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>>;
|
||||
}
|
||||
|
||||
pub trait ThreadEnvironment {
|
||||
fn create_terminal(
|
||||
&self,
|
||||
command: String,
|
||||
cwd: Option<PathBuf>,
|
||||
output_byte_limit: Option<u64>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<Rc<dyn TerminalHandle>>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ThreadEvent {
|
||||
UserMessage(UserMessage),
|
||||
@@ -535,6 +552,14 @@ pub enum ThreadEvent {
|
||||
Stop(acp::StopReason),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewTerminal {
|
||||
pub command: String,
|
||||
pub output_byte_limit: Option<u64>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub response: oneshot::Sender<Result<Entity<acp_thread::Terminal>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolCallAuthorization {
|
||||
pub tool_call: acp::ToolCallUpdate,
|
||||
@@ -1024,7 +1049,11 @@ impl Thread {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_default_tools(&mut self, cx: &mut Context<Self>) {
|
||||
pub fn add_default_tools(
|
||||
&mut self,
|
||||
environment: Rc<dyn ThreadEnvironment>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
self.add_tool(CopyPathTool::new(self.project.clone()));
|
||||
self.add_tool(CreateDirectoryTool::new(self.project.clone()));
|
||||
@@ -1045,7 +1074,7 @@ impl Thread {
|
||||
self.project.clone(),
|
||||
self.action_log.clone(),
|
||||
));
|
||||
self.add_tool(TerminalTool::new(self.project.clone(), cx));
|
||||
self.add_tool(TerminalTool::new(self.project.clone(), environment));
|
||||
self.add_tool(ThinkingTool);
|
||||
self.add_tool(WebSearchTool);
|
||||
}
|
||||
@@ -2389,19 +2418,6 @@ impl ToolCallEventStream {
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_terminal(&self, terminal: Entity<acp_thread::Terminal>) {
|
||||
self.stream
|
||||
.0
|
||||
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
|
||||
acp_thread::ToolCallUpdateTerminal {
|
||||
id: acp::ToolCallId(self.tool_use_id.to_string().into()),
|
||||
terminal,
|
||||
}
|
||||
.into(),
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn authorize(&self, title: impl Into<String>, cx: &mut App) -> Task<Result<()>> {
|
||||
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
|
||||
return Task::ready(Ok(()));
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use futures::{FutureExt as _, future::Shared};
|
||||
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||
use gpui::{App, Entity, SharedString, Task};
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode};
|
||||
use util::markdown::MarkdownInlineCode;
|
||||
|
||||
use crate::{AgentTool, ToolCallEventStream};
|
||||
use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream};
|
||||
|
||||
const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
|
||||
const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
|
||||
|
||||
/// Executes a shell one-liner and returns the combined output.
|
||||
///
|
||||
@@ -36,25 +36,14 @@ pub struct TerminalToolInput {
|
||||
|
||||
pub struct TerminalTool {
|
||||
project: Entity<Project>,
|
||||
determine_shell: Shared<Task<String>>,
|
||||
environment: Rc<dyn ThreadEnvironment>,
|
||||
}
|
||||
|
||||
impl TerminalTool {
|
||||
pub fn new(project: Entity<Project>, cx: &mut App) -> Self {
|
||||
let determine_shell = cx.background_spawn(async move {
|
||||
if cfg!(windows) {
|
||||
return get_system_shell();
|
||||
}
|
||||
|
||||
if which::which("bash").is_ok() {
|
||||
"bash".into()
|
||||
} else {
|
||||
get_system_shell()
|
||||
}
|
||||
});
|
||||
pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
|
||||
Self {
|
||||
project,
|
||||
determine_shell: determine_shell.shared(),
|
||||
environment,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,128 +88,49 @@ impl AgentTool for TerminalTool {
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Self::Output>> {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
let working_dir = match working_dir(&input, &self.project, cx) {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => return Task::ready(Err(err)),
|
||||
};
|
||||
let program = self.determine_shell.clone();
|
||||
let command = if cfg!(windows) {
|
||||
format!("$null | & {{{}}}", input.command.replace("\"", "'"))
|
||||
} else if let Some(cwd) = working_dir
|
||||
.as_ref()
|
||||
.and_then(|cwd| cwd.as_os_str().to_str())
|
||||
{
|
||||
// Make sure once we're *inside* the shell, we cd into `cwd`
|
||||
format!("(cd {cwd}; {}) </dev/null", input.command)
|
||||
} else {
|
||||
format!("({}) </dev/null", input.command)
|
||||
};
|
||||
let args = vec!["-c".into(), command];
|
||||
|
||||
let env = match &working_dir {
|
||||
Some(dir) => self.project.update(cx, |project, cx| {
|
||||
project.directory_environment(dir.as_path().into(), cx)
|
||||
}),
|
||||
None => Task::ready(None).shared(),
|
||||
};
|
||||
|
||||
let env = cx.spawn(async move |_| {
|
||||
let mut env = env.await.unwrap_or_default();
|
||||
if cfg!(unix) {
|
||||
env.insert("PAGER".into(), "cat".into());
|
||||
}
|
||||
env
|
||||
});
|
||||
|
||||
let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx);
|
||||
cx.spawn(async move |cx| {
|
||||
authorize.await?;
|
||||
|
||||
cx.spawn({
|
||||
async move |cx| {
|
||||
authorize.await?;
|
||||
let terminal = self
|
||||
.environment
|
||||
.create_terminal(
|
||||
input.command.clone(),
|
||||
working_dir,
|
||||
Some(COMMAND_OUTPUT_LIMIT),
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let program = program.await;
|
||||
let env = env.await;
|
||||
let terminal = self
|
||||
.project
|
||||
.update(cx, |project, cx| {
|
||||
project.create_terminal_task(
|
||||
task::SpawnInTerminal {
|
||||
command: Some(program),
|
||||
args,
|
||||
cwd: working_dir.clone(),
|
||||
env,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
let acp_terminal = cx.new(|cx| {
|
||||
acp_thread::Terminal::new(
|
||||
input.command.clone(),
|
||||
working_dir.clone(),
|
||||
terminal.clone(),
|
||||
language_registry,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
event_stream.update_terminal(acp_terminal.clone());
|
||||
let terminal_id = terminal.id(cx)?;
|
||||
event_stream.update_fields(acp::ToolCallUpdateFields {
|
||||
content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let exit_status = terminal
|
||||
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
|
||||
.await;
|
||||
let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
|
||||
(terminal.get_content(), terminal.total_lines())
|
||||
})?;
|
||||
let exit_status = terminal.wait_for_exit(cx)?.await;
|
||||
let output = terminal.current_output(cx)?;
|
||||
|
||||
let (processed_content, finished_with_empty_output) = process_content(
|
||||
&content,
|
||||
&input.command,
|
||||
exit_status.map(portable_pty::ExitStatus::from),
|
||||
);
|
||||
|
||||
acp_terminal
|
||||
.update(cx, |terminal, cx| {
|
||||
terminal.finish(
|
||||
exit_status,
|
||||
content.len(),
|
||||
processed_content.len(),
|
||||
content_line_count,
|
||||
finished_with_empty_output,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.log_err();
|
||||
|
||||
Ok(processed_content)
|
||||
}
|
||||
Ok(process_content(output, &input.command, exit_status))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn process_content(
|
||||
content: &str,
|
||||
output: acp::TerminalOutputResponse,
|
||||
command: &str,
|
||||
exit_status: Option<portable_pty::ExitStatus>,
|
||||
) -> (String, bool) {
|
||||
let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
|
||||
|
||||
let content = if should_truncate {
|
||||
let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
|
||||
while !content.is_char_boundary(end_ix) {
|
||||
end_ix -= 1;
|
||||
}
|
||||
// Don't truncate mid-line, clear the remainder of the last line
|
||||
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
|
||||
&content[..end_ix]
|
||||
} else {
|
||||
content
|
||||
};
|
||||
let content = content.trim();
|
||||
exit_status: acp::TerminalExitStatus,
|
||||
) -> String {
|
||||
let content = output.output.trim();
|
||||
let is_empty = content.is_empty();
|
||||
|
||||
let content = format!("```\n{content}\n```");
|
||||
let content = if should_truncate {
|
||||
let content = if output.truncated {
|
||||
format!(
|
||||
"Command output too long. The first {} bytes:\n\n{content}",
|
||||
content.len(),
|
||||
@@ -229,24 +139,21 @@ fn process_content(
|
||||
content
|
||||
};
|
||||
|
||||
let content = match exit_status {
|
||||
Some(exit_status) if exit_status.success() => {
|
||||
let content = match exit_status.exit_code {
|
||||
Some(0) => {
|
||||
if is_empty {
|
||||
"Command executed successfully.".to_string()
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
Some(exit_status) => {
|
||||
Some(exit_code) => {
|
||||
if is_empty {
|
||||
format!(
|
||||
"Command \"{command}\" failed with exit code {}.",
|
||||
exit_status.exit_code()
|
||||
)
|
||||
format!("Command \"{command}\" failed with exit code {}.", exit_code)
|
||||
} else {
|
||||
format!(
|
||||
"Command \"{command}\" failed with exit code {}.\n\n{content}",
|
||||
exit_status.exit_code()
|
||||
exit_code
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -257,7 +164,7 @@ fn process_content(
|
||||
)
|
||||
}
|
||||
};
|
||||
(content, is_empty)
|
||||
content
|
||||
}
|
||||
|
||||
fn working_dir(
|
||||
@@ -300,169 +207,3 @@ fn working_dir(
|
||||
anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use agent_settings::AgentSettings;
|
||||
use editor::EditorSettings;
|
||||
use fs::RealFs;
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use terminal::terminal_settings::TerminalSettings;
|
||||
use theme::ThemeSettings;
|
||||
use util::test::TempTree;
|
||||
|
||||
use crate::ThreadEvent;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
zlog::init_test();
|
||||
|
||||
executor.allow_parking();
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
language::init(cx);
|
||||
Project::init_settings(cx);
|
||||
ThemeSettings::register(cx);
|
||||
TerminalSettings::register(cx);
|
||||
EditorSettings::register(cx);
|
||||
AgentSettings::register(cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
if cfg!(windows) {
|
||||
return;
|
||||
}
|
||||
|
||||
init_test(&executor, cx);
|
||||
|
||||
let fs = Arc::new(RealFs::new(None, executor));
|
||||
let tree = TempTree::new(json!({
|
||||
"project": {},
|
||||
}));
|
||||
let project: Entity<Project> =
|
||||
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
|
||||
|
||||
let input = TerminalToolInput {
|
||||
command: "cat".to_owned(),
|
||||
cd: tree
|
||||
.path()
|
||||
.join("project")
|
||||
.as_path()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
};
|
||||
let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
|
||||
let result = cx
|
||||
.update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
|
||||
|
||||
let auth = event_stream_rx.expect_authorization().await;
|
||||
auth.response.send(auth.options[0].id.clone()).unwrap();
|
||||
event_stream_rx.expect_terminal().await;
|
||||
assert_eq!(result.await.unwrap(), "Command executed successfully.");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
if cfg!(windows) {
|
||||
return;
|
||||
}
|
||||
|
||||
init_test(&executor, cx);
|
||||
|
||||
let fs = Arc::new(RealFs::new(None, executor));
|
||||
let tree = TempTree::new(json!({
|
||||
"project": {},
|
||||
"other-project": {},
|
||||
}));
|
||||
let project: Entity<Project> =
|
||||
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
|
||||
|
||||
let check = |input, expected, cx: &mut TestAppContext| {
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
let result = cx.update(|cx| {
|
||||
Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let event = stream_rx.try_next();
|
||||
if let Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(auth)))) = event {
|
||||
auth.response.send(auth.options[0].id.clone()).unwrap();
|
||||
}
|
||||
|
||||
cx.spawn(async move |_| {
|
||||
let output = result.await;
|
||||
assert_eq!(output.ok(), expected);
|
||||
})
|
||||
};
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: ".".into(),
|
||||
},
|
||||
Some(format!(
|
||||
"```\n{}\n```",
|
||||
tree.path().join("project").display()
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: "other-project".into(),
|
||||
},
|
||||
None, // other-project is a dir, but *not* a worktree (yet)
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Absolute path above the worktree root
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: tree.path().to_string_lossy().into(),
|
||||
},
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
project
|
||||
.update(cx, |project, cx| {
|
||||
project.create_worktree(tree.path().join("other-project"), true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: "other-project".into(),
|
||||
},
|
||||
Some(format!(
|
||||
"```\n{}\n```",
|
||||
tree.path().join("other-project").display()
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: ".".into(),
|
||||
},
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct AcpConnection {
|
||||
connection: Rc<acp::ClientSideConnection>,
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
auth_methods: Vec<acp::AuthMethod>,
|
||||
prompt_capabilities: acp::PromptCapabilities,
|
||||
agent_capabilities: acp::AgentCapabilities,
|
||||
_io_task: Task<Result<()>>,
|
||||
_wait_task: Task<Result<()>>,
|
||||
_stderr_task: Task<Result<()>>,
|
||||
@@ -134,6 +134,7 @@ impl AcpConnection {
|
||||
read_text_file: true,
|
||||
write_text_file: true,
|
||||
},
|
||||
terminal: true,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
@@ -147,7 +148,7 @@ impl AcpConnection {
|
||||
connection,
|
||||
server_name,
|
||||
sessions,
|
||||
prompt_capabilities: response.agent_capabilities.prompt_capabilities,
|
||||
agent_capabilities: response.agent_capabilities,
|
||||
_io_task: io_task,
|
||||
_wait_task: wait_task,
|
||||
_stderr_task: stderr_task,
|
||||
@@ -155,7 +156,7 @@ impl AcpConnection {
|
||||
}
|
||||
|
||||
pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities {
|
||||
&self.prompt_capabilities
|
||||
&self.agent_capabilities.prompt_capabilities
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +223,8 @@ impl AgentConnection for AcpConnection {
|
||||
action_log,
|
||||
session_id.clone(),
|
||||
// ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically.
|
||||
watch::Receiver::constant(self.prompt_capabilities),
|
||||
watch::Receiver::constant(self.agent_capabilities.prompt_capabilities),
|
||||
response.available_commands,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
@@ -344,11 +346,7 @@ impl acp::Client for ClientDelegate {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
let task = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?
|
||||
.thread
|
||||
.session_thread(&arguments.session_id)?
|
||||
.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
|
||||
})??;
|
||||
@@ -364,11 +362,7 @@ impl acp::Client for ClientDelegate {
|
||||
) -> Result<(), acp::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let task = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?
|
||||
.thread
|
||||
.session_thread(&arguments.session_id)?
|
||||
.update(cx, |thread, cx| {
|
||||
thread.write_text_file(arguments.path, arguments.content, cx)
|
||||
})?;
|
||||
@@ -382,16 +376,12 @@ impl acp::Client for ClientDelegate {
|
||||
&self,
|
||||
arguments: acp::ReadTextFileRequest,
|
||||
) -> Result<acp::ReadTextFileResponse, acp::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let task = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?
|
||||
.thread
|
||||
.update(cx, |thread, cx| {
|
||||
let task = self.session_thread(&arguments.session_id)?.update(
|
||||
&mut self.cx.clone(),
|
||||
|thread, cx| {
|
||||
thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx)
|
||||
})?;
|
||||
},
|
||||
)?;
|
||||
|
||||
let content = task.await?;
|
||||
|
||||
@@ -402,16 +392,92 @@ impl acp::Client for ClientDelegate {
|
||||
&self,
|
||||
notification: acp::SessionNotification,
|
||||
) -> Result<(), acp::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let sessions = self.sessions.borrow();
|
||||
let session = sessions
|
||||
.get(¬ification.session_id)
|
||||
.context("Failed to get session")?;
|
||||
|
||||
session.thread.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(notification.update, cx)
|
||||
})??;
|
||||
self.session_thread(¬ification.session_id)?
|
||||
.update(&mut self.cx.clone(), |thread, cx| {
|
||||
thread.handle_session_update(notification.update, cx)
|
||||
})??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_terminal(
|
||||
&self,
|
||||
args: acp::CreateTerminalRequest,
|
||||
) -> Result<acp::CreateTerminalResponse, acp::Error> {
|
||||
let terminal = self
|
||||
.session_thread(&args.session_id)?
|
||||
.update(&mut self.cx.clone(), |thread, cx| {
|
||||
thread.create_terminal(
|
||||
args.command,
|
||||
args.args,
|
||||
args.env,
|
||||
args.cwd,
|
||||
args.output_byte_limit,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
Ok(
|
||||
terminal.read_with(&self.cx, |terminal, _| acp::CreateTerminalResponse {
|
||||
terminal_id: terminal.id().clone(),
|
||||
})?,
|
||||
)
|
||||
}
|
||||
|
||||
async fn kill_terminal(&self, args: acp::KillTerminalRequest) -> Result<(), acp::Error> {
|
||||
self.session_thread(&args.session_id)?
|
||||
.update(&mut self.cx.clone(), |thread, cx| {
|
||||
thread.kill_terminal(args.terminal_id, cx)
|
||||
})??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn release_terminal(&self, args: acp::ReleaseTerminalRequest) -> Result<(), acp::Error> {
|
||||
self.session_thread(&args.session_id)?
|
||||
.update(&mut self.cx.clone(), |thread, cx| {
|
||||
thread.release_terminal(args.terminal_id, cx)
|
||||
})??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn terminal_output(
|
||||
&self,
|
||||
args: acp::TerminalOutputRequest,
|
||||
) -> Result<acp::TerminalOutputResponse, acp::Error> {
|
||||
self.session_thread(&args.session_id)?
|
||||
.read_with(&mut self.cx.clone(), |thread, cx| {
|
||||
let out = thread
|
||||
.terminal(args.terminal_id)?
|
||||
.read(cx)
|
||||
.current_output(cx);
|
||||
|
||||
Ok(out)
|
||||
})?
|
||||
}
|
||||
|
||||
async fn wait_for_terminal_exit(
|
||||
&self,
|
||||
args: acp::WaitForTerminalExitRequest,
|
||||
) -> Result<acp::WaitForTerminalExitResponse, acp::Error> {
|
||||
let exit_status = self
|
||||
.session_thread(&args.session_id)?
|
||||
.update(&mut self.cx.clone(), |thread, cx| {
|
||||
anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit())
|
||||
})??
|
||||
.await;
|
||||
|
||||
Ok(acp::WaitForTerminalExitResponse { exit_status })
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientDelegate {
|
||||
fn session_thread(&self, session_id: &acp::SessionId) -> Result<WeakEntity<AcpThread>> {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(session_id)
|
||||
.context("Failed to get session")
|
||||
.map(|session| session.thread.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,20 @@ pub fn init(cx: &mut App) {
|
||||
pub struct AgentServerDelegate {
|
||||
project: Entity<Project>,
|
||||
status_tx: Option<watch::Sender<SharedString>>,
|
||||
new_version_available: Option<watch::Sender<Option<String>>>,
|
||||
}
|
||||
|
||||
impl AgentServerDelegate {
|
||||
pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
|
||||
Self { project, status_tx }
|
||||
pub fn new(
|
||||
project: Entity<Project>,
|
||||
status_tx: Option<watch::Sender<SharedString>>,
|
||||
new_version_tx: Option<watch::Sender<Option<String>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
project,
|
||||
status_tx,
|
||||
new_version_available: new_version_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project(&self) -> &Entity<Project> {
|
||||
@@ -73,6 +82,7 @@ impl AgentServerDelegate {
|
||||
)));
|
||||
};
|
||||
let status_tx = self.status_tx;
|
||||
let new_version_available = self.new_version_available;
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if !ignore_system_version {
|
||||
@@ -101,9 +111,11 @@ impl AgentServerDelegate {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(version) = file_name
|
||||
.to_str()
|
||||
.and_then(|name| semver::Version::from_str(&name).ok())
|
||||
if let Some(name) = file_name.to_str()
|
||||
&& let Some(version) = semver::Version::from_str(name).ok()
|
||||
&& fs
|
||||
.is_file(&dir.join(file_name).join(&entrypoint_path))
|
||||
.await
|
||||
{
|
||||
versions.push((version, file_name.to_owned()));
|
||||
} else {
|
||||
@@ -146,6 +158,7 @@ impl AgentServerDelegate {
|
||||
cx.background_spawn({
|
||||
let file_name = file_name.clone();
|
||||
let dir = dir.clone();
|
||||
let fs = fs.clone();
|
||||
async move {
|
||||
let latest_version =
|
||||
node_runtime.npm_package_latest_version(&package_name).await;
|
||||
@@ -160,6 +173,9 @@ impl AgentServerDelegate {
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
if let Some(mut new_version_available) = new_version_available {
|
||||
new_version_available.send(Some(latest_version)).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -171,7 +187,7 @@ impl AgentServerDelegate {
|
||||
}
|
||||
let dir = dir.clone();
|
||||
cx.background_spawn(Self::download_latest_version(
|
||||
fs,
|
||||
fs.clone(),
|
||||
dir.clone(),
|
||||
node_runtime,
|
||||
package_name,
|
||||
@@ -179,14 +195,18 @@ impl AgentServerDelegate {
|
||||
.await?
|
||||
.into()
|
||||
};
|
||||
|
||||
let agent_server_path = dir.join(version).join(entrypoint_path);
|
||||
let agent_server_path_exists = fs.is_file(&agent_server_path).await;
|
||||
anyhow::ensure!(
|
||||
agent_server_path_exists,
|
||||
"Missing entrypoint path {} after installation",
|
||||
agent_server_path.to_string_lossy()
|
||||
);
|
||||
|
||||
anyhow::Ok(AgentServerCommand {
|
||||
path: node_path,
|
||||
args: vec![
|
||||
dir.join(version)
|
||||
.join(entrypoint_path)
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
],
|
||||
args: vec![agent_server_path.to_string_lossy().to_string()],
|
||||
env: Default::default(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,7 @@ impl AgentServer for ClaudeCode {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Rc<dyn AgentConnection>>> {
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let fs = delegate.project().read(cx).fs().clone();
|
||||
let server_name = self.name();
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).claude.clone()
|
||||
@@ -109,6 +110,13 @@ impl AgentServer for ClaudeCode {
|
||||
.insert("ANTHROPIC_API_KEY".to_owned(), api_key.key);
|
||||
}
|
||||
|
||||
let root_dir_exists = fs.is_dir(&root_dir).await;
|
||||
anyhow::ensure!(
|
||||
root_dir_exists,
|
||||
"Session root {} does not exist or is not a directory",
|
||||
root_dir.to_string_lossy()
|
||||
);
|
||||
|
||||
crate::acp::connect(server_name, command.clone(), &root_dir, cx).await
|
||||
})
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ pub async fn new_test_thread(
|
||||
current_dir: impl AsRef<Path>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Entity<AcpThread> {
|
||||
let delegate = AgentServerDelegate::new(project.clone(), None);
|
||||
let delegate = AgentServerDelegate::new(project.clone(), None, None);
|
||||
|
||||
let connection = cx
|
||||
.update(|cx| server.connect(current_dir.as_ref(), delegate, cx))
|
||||
|
||||
@@ -36,6 +36,7 @@ impl AgentServer for Gemini {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Rc<dyn AgentConnection>>> {
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let fs = delegate.project().read(cx).fs().clone();
|
||||
let server_name = self.name();
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).gemini.clone()
|
||||
@@ -74,6 +75,13 @@ impl AgentServer for Gemini {
|
||||
.insert("GEMINI_API_KEY".to_owned(), api_key.key);
|
||||
}
|
||||
|
||||
let root_dir_exists = fs.is_dir(&root_dir).await;
|
||||
anyhow::ensure!(
|
||||
root_dir_exists,
|
||||
"Session root {} does not exist or is not a directory",
|
||||
root_dir.to_string_lossy()
|
||||
);
|
||||
|
||||
let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
|
||||
match &result {
|
||||
Ok(connection) => {
|
||||
@@ -92,7 +100,7 @@ impl AgentServer for Gemini {
|
||||
log::error!("connected to gemini, but missing prompt_capabilities.image (version is {current_version})");
|
||||
return Err(LoadError::Unsupported {
|
||||
current_version: current_version.into(),
|
||||
command: command.path.to_string_lossy().to_string().into(),
|
||||
command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
|
||||
minimum_version: Self::MINIMUM_VERSION.into(),
|
||||
}
|
||||
.into());
|
||||
@@ -129,7 +137,7 @@ impl AgentServer for Gemini {
|
||||
if !supported {
|
||||
return Err(LoadError::Unsupported {
|
||||
current_version: current_version.into(),
|
||||
command: command.path.to_string_lossy().to_string().into(),
|
||||
command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
|
||||
minimum_version: Self::MINIMUM_VERSION.into(),
|
||||
}
|
||||
.into());
|
||||
|
||||
@@ -48,7 +48,7 @@ pub enum NotifyWhenAgentWaiting {
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug, SettingsUi)]
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct AgentSettings {
|
||||
pub enabled: bool,
|
||||
pub button: bool,
|
||||
@@ -223,7 +223,7 @@ impl AgentSettingsContent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default)]
|
||||
#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default, SettingsUi)]
|
||||
pub struct AgentSettingsContent {
|
||||
/// Whether the Agent is enabled.
|
||||
///
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -13,8 +13,10 @@ use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{App, Entity, Task, WeakEntity};
|
||||
use language::{Buffer, CodeLabel, HighlightId};
|
||||
use lsp::CompletionContext;
|
||||
use project::lsp_store::CompletionDocumentation;
|
||||
use project::{
|
||||
Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId,
|
||||
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project,
|
||||
ProjectPath, Symbol, WorktreeId,
|
||||
};
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
@@ -23,7 +25,7 @@ use ui::prelude::*;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::AgentPanel;
|
||||
use crate::acp::message_editor::MessageEditor;
|
||||
use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
|
||||
use crate::context_picker::file_context_picker::{FileMatch, search_files};
|
||||
use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
|
||||
use crate::context_picker::symbol_context_picker::SymbolMatch;
|
||||
@@ -67,6 +69,7 @@ pub struct ContextPickerCompletionProvider {
|
||||
history_store: Entity<HistoryStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
}
|
||||
|
||||
impl ContextPickerCompletionProvider {
|
||||
@@ -76,6 +79,7 @@ impl ContextPickerCompletionProvider {
|
||||
history_store: Entity<HistoryStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
message_editor,
|
||||
@@ -83,6 +87,7 @@ impl ContextPickerCompletionProvider {
|
||||
history_store,
|
||||
prompt_store,
|
||||
prompt_capabilities,
|
||||
available_commands,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +374,42 @@ impl ContextPickerCompletionProvider {
|
||||
})
|
||||
}
|
||||
|
||||
fn search(
|
||||
fn search_slash_commands(
|
||||
&self,
|
||||
query: String,
|
||||
cx: &mut App,
|
||||
) -> Task<Vec<acp::AvailableCommand>> {
|
||||
let commands = self.available_commands.borrow().clone();
|
||||
if commands.is_empty() {
|
||||
return Task::ready(Vec::new());
|
||||
}
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let candidates = commands
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, command)| StringMatchCandidate::new(id, &command.name))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let matches = fuzzy::match_strings(
|
||||
&candidates,
|
||||
&query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
&Arc::new(AtomicBool::default()),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|mat| commands[mat.candidate_id].clone())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn search_mentions(
|
||||
&self,
|
||||
mode: Option<ContextPickerMode>,
|
||||
query: String,
|
||||
@@ -651,10 +691,10 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
let offset_to_line = buffer.point_to_offset(line_start);
|
||||
let mut lines = buffer.text_for_range(line_start..position).lines();
|
||||
let line = lines.next()?;
|
||||
MentionCompletion::try_parse(
|
||||
self.prompt_capabilities.get().embedded_context,
|
||||
ContextCompletion::try_parse(
|
||||
line,
|
||||
offset_to_line,
|
||||
self.prompt_capabilities.get().embedded_context,
|
||||
)
|
||||
});
|
||||
let Some(state) = state else {
|
||||
@@ -667,97 +707,175 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
|
||||
let project = workspace.read(cx).project().clone();
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let source_range = snapshot.anchor_before(state.source_range.start)
|
||||
..snapshot.anchor_after(state.source_range.end);
|
||||
let source_range = snapshot.anchor_before(state.source_range().start)
|
||||
..snapshot.anchor_after(state.source_range().end);
|
||||
|
||||
let editor = self.message_editor.clone();
|
||||
|
||||
let MentionCompletion { mode, argument, .. } = state;
|
||||
let query = argument.unwrap_or_else(|| "".to_string());
|
||||
|
||||
let search_task = self.search(mode, query, Arc::<AtomicBool>::default(), cx);
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
let matches = search_task.await;
|
||||
|
||||
let completions = cx.update(|cx| {
|
||||
matches
|
||||
.into_iter()
|
||||
.filter_map(|mat| match mat {
|
||||
Match::File(FileMatch { mat, is_recent }) => {
|
||||
let project_path = ProjectPath {
|
||||
worktree_id: WorktreeId::from_usize(mat.worktree_id),
|
||||
path: mat.path.clone(),
|
||||
match state {
|
||||
ContextCompletion::SlashCommand(SlashCommandCompletion {
|
||||
command, argument, ..
|
||||
}) => {
|
||||
let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
|
||||
cx.background_spawn(async move {
|
||||
let completions = search_task
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|command| {
|
||||
let new_text = if let Some(argument) = argument.as_ref() {
|
||||
format!("/{} {}", command.name, argument)
|
||||
} else {
|
||||
format!("/{} ", command.name)
|
||||
};
|
||||
|
||||
Self::completion_for_path(
|
||||
project_path,
|
||||
&mat.path_prefix,
|
||||
is_recent,
|
||||
mat.is_dir,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
project.clone(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
let is_missing_argument = argument.is_none() && command.input.is_some();
|
||||
Completion {
|
||||
replace_range: source_range.clone(),
|
||||
new_text,
|
||||
label: CodeLabel::plain(command.name.to_string(), None),
|
||||
documentation: Some(CompletionDocumentation::MultiLinePlainText(
|
||||
command.description.into(),
|
||||
)),
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(Arc::new({
|
||||
let editor = editor.clone();
|
||||
move |intent, _window, cx| {
|
||||
if !is_missing_argument {
|
||||
cx.defer({
|
||||
let editor = editor.clone();
|
||||
move |cx| {
|
||||
editor
|
||||
.update(cx, |_editor, cx| {
|
||||
match intent {
|
||||
CompletionIntent::Complete
|
||||
| CompletionIntent::CompleteWithInsert
|
||||
| CompletionIntent::CompleteWithReplace => {
|
||||
if !is_missing_argument {
|
||||
cx.emit(MessageEditorEvent::Send);
|
||||
}
|
||||
}
|
||||
CompletionIntent::Compose => {}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
is_missing_argument
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
|
||||
symbol,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
workspace.clone(),
|
||||
cx,
|
||||
),
|
||||
Ok(vec![CompletionResponse {
|
||||
completions,
|
||||
display_options: CompletionDisplayOptions {
|
||||
dynamic_width: true,
|
||||
},
|
||||
// Since this does its own filtering (see `filter_completions()` returns false),
|
||||
// there is no benefit to computing whether this set of completions is incomplete.
|
||||
is_incomplete: true,
|
||||
}])
|
||||
})
|
||||
}
|
||||
ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
|
||||
let query = argument.unwrap_or_default();
|
||||
let search_task =
|
||||
self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
|
||||
|
||||
Match::Thread(thread) => Some(Self::completion_for_thread(
|
||||
thread,
|
||||
source_range.clone(),
|
||||
false,
|
||||
editor.clone(),
|
||||
cx,
|
||||
)),
|
||||
cx.spawn(async move |_, cx| {
|
||||
let matches = search_task.await;
|
||||
|
||||
Match::RecentThread(thread) => Some(Self::completion_for_thread(
|
||||
thread,
|
||||
source_range.clone(),
|
||||
true,
|
||||
editor.clone(),
|
||||
cx,
|
||||
)),
|
||||
let completions = cx.update(|cx| {
|
||||
matches
|
||||
.into_iter()
|
||||
.filter_map(|mat| match mat {
|
||||
Match::File(FileMatch { mat, is_recent }) => {
|
||||
let project_path = ProjectPath {
|
||||
worktree_id: WorktreeId::from_usize(mat.worktree_id),
|
||||
path: mat.path.clone(),
|
||||
};
|
||||
|
||||
Match::Rules(user_rules) => Some(Self::completion_for_rules(
|
||||
user_rules,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
cx,
|
||||
)),
|
||||
Self::completion_for_path(
|
||||
project_path,
|
||||
&mat.path_prefix,
|
||||
is_recent,
|
||||
mat.is_dir,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
project.clone(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
Match::Fetch(url) => Self::completion_for_fetch(
|
||||
source_range.clone(),
|
||||
url,
|
||||
editor.clone(),
|
||||
cx,
|
||||
),
|
||||
Match::Symbol(SymbolMatch { symbol, .. }) => {
|
||||
Self::completion_for_symbol(
|
||||
symbol,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
workspace.clone(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
|
||||
entry,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
&workspace,
|
||||
cx,
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
})?;
|
||||
Match::Thread(thread) => Some(Self::completion_for_thread(
|
||||
thread,
|
||||
source_range.clone(),
|
||||
false,
|
||||
editor.clone(),
|
||||
cx,
|
||||
)),
|
||||
|
||||
Ok(vec![CompletionResponse {
|
||||
completions,
|
||||
// Since this does its own filtering (see `filter_completions()` returns false),
|
||||
// there is no benefit to computing whether this set of completions is incomplete.
|
||||
is_incomplete: true,
|
||||
}])
|
||||
})
|
||||
Match::RecentThread(thread) => Some(Self::completion_for_thread(
|
||||
thread,
|
||||
source_range.clone(),
|
||||
true,
|
||||
editor.clone(),
|
||||
cx,
|
||||
)),
|
||||
|
||||
Match::Rules(user_rules) => Some(Self::completion_for_rules(
|
||||
user_rules,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
cx,
|
||||
)),
|
||||
|
||||
Match::Fetch(url) => Self::completion_for_fetch(
|
||||
source_range.clone(),
|
||||
url,
|
||||
editor.clone(),
|
||||
cx,
|
||||
),
|
||||
|
||||
Match::Entry(EntryMatch { entry, .. }) => {
|
||||
Self::completion_for_entry(
|
||||
entry,
|
||||
source_range.clone(),
|
||||
editor.clone(),
|
||||
&workspace,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})?;
|
||||
|
||||
Ok(vec![CompletionResponse {
|
||||
completions,
|
||||
display_options: CompletionDisplayOptions {
|
||||
dynamic_width: true,
|
||||
},
|
||||
// Since this does its own filtering (see `filter_completions()` returns false),
|
||||
// there is no benefit to computing whether this set of completions is incomplete.
|
||||
is_incomplete: true,
|
||||
}])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_completion_trigger(
|
||||
@@ -775,14 +893,14 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
let offset_to_line = buffer.point_to_offset(line_start);
|
||||
let mut lines = buffer.text_for_range(line_start..position).lines();
|
||||
if let Some(line) = lines.next() {
|
||||
MentionCompletion::try_parse(
|
||||
self.prompt_capabilities.get().embedded_context,
|
||||
ContextCompletion::try_parse(
|
||||
line,
|
||||
offset_to_line,
|
||||
self.prompt_capabilities.get().embedded_context,
|
||||
)
|
||||
.map(|completion| {
|
||||
completion.source_range.start <= offset_to_line + position.column as usize
|
||||
&& completion.source_range.end >= offset_to_line + position.column as usize
|
||||
completion.source_range().start <= offset_to_line + position.column as usize
|
||||
&& completion.source_range().end >= offset_to_line + position.column as usize
|
||||
})
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
@@ -851,7 +969,7 @@ fn confirm_completion_callback(
|
||||
.clone()
|
||||
.update(cx, |message_editor, cx| {
|
||||
message_editor
|
||||
.confirm_completion(
|
||||
.confirm_mention_completion(
|
||||
crease_text,
|
||||
start,
|
||||
content_len,
|
||||
@@ -867,6 +985,89 @@ fn confirm_completion_callback(
|
||||
})
|
||||
}
|
||||
|
||||
enum ContextCompletion {
|
||||
SlashCommand(SlashCommandCompletion),
|
||||
Mention(MentionCompletion),
|
||||
}
|
||||
|
||||
impl ContextCompletion {
|
||||
fn source_range(&self) -> Range<usize> {
|
||||
match self {
|
||||
Self::SlashCommand(completion) => completion.source_range.clone(),
|
||||
Self::Mention(completion) => completion.source_range.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option<Self> {
|
||||
if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) {
|
||||
Some(Self::SlashCommand(command))
|
||||
} else if let Some(mention) =
|
||||
MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line)
|
||||
{
|
||||
Some(Self::Mention(mention))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct SlashCommandCompletion {
|
||||
pub source_range: Range<usize>,
|
||||
pub command: Option<String>,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
|
||||
impl SlashCommandCompletion {
|
||||
pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
|
||||
// If we decide to support commands that are not at the beginning of the prompt, we can remove this check
|
||||
if !line.starts_with('/') || offset_to_line != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let last_command_start = line.rfind('/')?;
|
||||
if last_command_start >= line.len() {
|
||||
return Some(Self::default());
|
||||
}
|
||||
if last_command_start > 0
|
||||
&& line
|
||||
.chars()
|
||||
.nth(last_command_start - 1)
|
||||
.is_some_and(|c| !c.is_whitespace())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let rest_of_line = &line[last_command_start + 1..];
|
||||
|
||||
let mut command = None;
|
||||
let mut argument = None;
|
||||
let mut end = last_command_start + 1;
|
||||
|
||||
if let Some(command_text) = rest_of_line.split_whitespace().next() {
|
||||
command = Some(command_text.to_string());
|
||||
end += command_text.len();
|
||||
|
||||
// Find the start of arguments after the command
|
||||
if let Some(args_start) =
|
||||
rest_of_line[command_text.len()..].find(|c: char| !c.is_whitespace())
|
||||
{
|
||||
let args = &rest_of_line[command_text.len() + args_start..].trim_end();
|
||||
if !args.is_empty() {
|
||||
argument = Some(args.to_string());
|
||||
end += args.len() + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
source_range: last_command_start + offset_to_line..end + offset_to_line,
|
||||
command,
|
||||
argument,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct MentionCompletion {
|
||||
source_range: Range<usize>,
|
||||
@@ -932,6 +1133,62 @@ impl MentionCompletion {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_slash_command_completion_parse() {
|
||||
assert_eq!(
|
||||
SlashCommandCompletion::try_parse("/", 0),
|
||||
Some(SlashCommandCompletion {
|
||||
source_range: 0..1,
|
||||
command: None,
|
||||
argument: None,
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
SlashCommandCompletion::try_parse("/help", 0),
|
||||
Some(SlashCommandCompletion {
|
||||
source_range: 0..5,
|
||||
command: Some("help".to_string()),
|
||||
argument: None,
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
SlashCommandCompletion::try_parse("/help ", 0),
|
||||
Some(SlashCommandCompletion {
|
||||
source_range: 0..5,
|
||||
command: Some("help".to_string()),
|
||||
argument: None,
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
SlashCommandCompletion::try_parse("/help arg1", 0),
|
||||
Some(SlashCommandCompletion {
|
||||
source_range: 0..10,
|
||||
command: Some("help".to_string()),
|
||||
argument: Some("arg1".to_string()),
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
|
||||
Some(SlashCommandCompletion {
|
||||
source_range: 0..15,
|
||||
command: Some("help".to_string()),
|
||||
argument: Some("arg1 arg2".to_string()),
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
|
||||
|
||||
assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
|
||||
|
||||
assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
|
||||
|
||||
assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mention_completion_parse() {
|
||||
assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use std::{cell::Cell, ops::Range, rc::Rc};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
ops::Range,
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use acp_thread::{AcpThread, AgentThreadEntry};
|
||||
use agent_client_protocol::{PromptCapabilities, ToolCallId};
|
||||
use agent_client_protocol::{self as acp, ToolCallId};
|
||||
use agent2::HistoryStore;
|
||||
use collections::HashMap;
|
||||
use editor::{Editor, EditorMode, MinimapVisibility};
|
||||
use gpui::{
|
||||
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
|
||||
ScrollHandle, TextStyleRefinement, WeakEntity, Window,
|
||||
ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window,
|
||||
};
|
||||
use language::language_settings::SoftWrap;
|
||||
use project::Project;
|
||||
@@ -26,8 +30,9 @@ pub struct EntryViewState {
|
||||
history_store: Entity<HistoryStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
entries: Vec<Entry>,
|
||||
prevent_slash_commands: bool,
|
||||
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
|
||||
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
}
|
||||
|
||||
impl EntryViewState {
|
||||
@@ -36,8 +41,9 @@ impl EntryViewState {
|
||||
project: Entity<Project>,
|
||||
history_store: Entity<HistoryStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
|
||||
prevent_slash_commands: bool,
|
||||
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace,
|
||||
@@ -45,8 +51,9 @@ impl EntryViewState {
|
||||
history_store,
|
||||
prompt_store,
|
||||
entries: Vec::new(),
|
||||
prevent_slash_commands,
|
||||
prompt_capabilities,
|
||||
available_commands,
|
||||
agent_name,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +92,9 @@ impl EntryViewState {
|
||||
self.history_store.clone(),
|
||||
self.prompt_store.clone(),
|
||||
self.prompt_capabilities.clone(),
|
||||
self.available_commands.clone(),
|
||||
self.agent_name.clone(),
|
||||
"Edit message - @ to include context",
|
||||
self.prevent_slash_commands,
|
||||
editor::EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
max_lines: None,
|
||||
@@ -125,22 +133,35 @@ impl EntryViewState {
|
||||
views
|
||||
};
|
||||
|
||||
let is_tool_call_completed =
|
||||
matches!(tool_call.status, acp_thread::ToolCallStatus::Completed);
|
||||
|
||||
for terminal in terminals {
|
||||
views.entry(terminal.entity_id()).or_insert_with(|| {
|
||||
let element = create_terminal(
|
||||
self.workspace.clone(),
|
||||
self.project.clone(),
|
||||
terminal.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any();
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index: index,
|
||||
view_event: ViewEvent::NewTerminal(id.clone()),
|
||||
});
|
||||
element
|
||||
});
|
||||
match views.entry(terminal.entity_id()) {
|
||||
collections::hash_map::Entry::Vacant(entry) => {
|
||||
let element = create_terminal(
|
||||
self.workspace.clone(),
|
||||
self.project.clone(),
|
||||
terminal.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any();
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index: index,
|
||||
view_event: ViewEvent::NewTerminal(id.clone()),
|
||||
});
|
||||
entry.insert(element);
|
||||
}
|
||||
collections::hash_map::Entry::Occupied(_entry) => {
|
||||
if is_tool_call_completed && terminal.read(cx).output().is_none() {
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index: index,
|
||||
view_event: ViewEvent::TerminalMovedToBackground(id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for diff in diffs {
|
||||
@@ -217,6 +238,7 @@ pub struct EntryViewEvent {
|
||||
pub enum ViewEvent {
|
||||
NewDiff(ToolCallId),
|
||||
NewTerminal(ToolCallId),
|
||||
TerminalMovedToBackground(ToolCallId),
|
||||
MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
|
||||
}
|
||||
|
||||
@@ -457,7 +479,8 @@ mod tests {
|
||||
history_store,
|
||||
None,
|
||||
Default::default(),
|
||||
false,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
acp::completion_provider::ContextPickerCompletionProvider,
|
||||
acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion},
|
||||
context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content},
|
||||
};
|
||||
use acp_thread::{MentionUri, selection_name};
|
||||
@@ -11,10 +11,10 @@ use assistant_slash_commands::codeblock_fence_for_path;
|
||||
use collections::{HashMap, HashSet};
|
||||
use editor::{
|
||||
Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
|
||||
EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer,
|
||||
SemanticsProvider, ToOffset,
|
||||
EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, InlayId,
|
||||
MultiBuffer, ToOffset,
|
||||
actions::Paste,
|
||||
display_map::{Crease, CreaseId, FoldId},
|
||||
display_map::{Crease, CreaseId, FoldId, Inlay},
|
||||
};
|
||||
use futures::{
|
||||
FutureExt as _,
|
||||
@@ -22,18 +22,20 @@ use futures::{
|
||||
};
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
|
||||
EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext,
|
||||
Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between,
|
||||
EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, SharedString,
|
||||
Subscription, Task, TextStyle, WeakEntity, pulsating_between,
|
||||
};
|
||||
use language::{Buffer, Language};
|
||||
use language::{Buffer, Language, language_settings::InlayHintKind};
|
||||
use language_model::LanguageModelImage;
|
||||
use postage::stream::Stream as _;
|
||||
use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree};
|
||||
use project::{
|
||||
CompletionIntent, InlayHint, InlayHintLabel, Project, ProjectItem, ProjectPath, Worktree,
|
||||
};
|
||||
use prompt_store::{PromptId, PromptStore};
|
||||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
cell::{Cell, RefCell},
|
||||
ffi::OsStr,
|
||||
fmt::Write,
|
||||
ops::{Range, RangeInclusive},
|
||||
@@ -42,20 +44,18 @@ use std::{
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use text::{OffsetRangeExt, ToOffset as _};
|
||||
use text::OffsetRangeExt;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _,
|
||||
FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
|
||||
LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled,
|
||||
TextSize, TintColor, Toggleable, Window, div, h_flex, px,
|
||||
LabelCommon, LabelSize, ParentElement, Render, SelectableButton, Styled, TextSize, TintColor,
|
||||
Toggleable, Window, div, h_flex,
|
||||
};
|
||||
use util::{ResultExt, debug_panic};
|
||||
use workspace::{Workspace, notifications::NotifyResultExt as _};
|
||||
use zed_actions::agent::Chat;
|
||||
|
||||
const PARSE_SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(50);
|
||||
|
||||
pub struct MessageEditor {
|
||||
mention_set: MentionSet,
|
||||
editor: Entity<Editor>,
|
||||
@@ -63,8 +63,9 @@ pub struct MessageEditor {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
history_store: Entity<HistoryStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
prevent_slash_commands: bool,
|
||||
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_parse_slash_command_task: Task<()>,
|
||||
}
|
||||
@@ -79,6 +80,8 @@ pub enum MessageEditorEvent {
|
||||
|
||||
impl EventEmitter<MessageEditorEvent> for MessageEditor {}
|
||||
|
||||
const COMMAND_HINT_INLAY_ID: usize = 0;
|
||||
|
||||
impl MessageEditor {
|
||||
pub fn new(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
@@ -86,8 +89,9 @@ impl MessageEditor {
|
||||
history_store: Entity<HistoryStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
agent_name: SharedString,
|
||||
placeholder: impl Into<Arc<str>>,
|
||||
prevent_slash_commands: bool,
|
||||
mode: EditorMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -99,16 +103,14 @@ impl MessageEditor {
|
||||
},
|
||||
None,
|
||||
);
|
||||
let completion_provider = ContextPickerCompletionProvider::new(
|
||||
let completion_provider = Rc::new(ContextPickerCompletionProvider::new(
|
||||
cx.weak_entity(),
|
||||
workspace.clone(),
|
||||
history_store.clone(),
|
||||
prompt_store.clone(),
|
||||
prompt_capabilities.clone(),
|
||||
);
|
||||
let semantics_provider = Rc::new(SlashCommandSemanticsProvider {
|
||||
range: Cell::new(None),
|
||||
});
|
||||
available_commands.clone(),
|
||||
));
|
||||
let mention_set = MentionSet::default();
|
||||
let editor = cx.new(|cx| {
|
||||
let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
|
||||
@@ -119,15 +121,12 @@ impl MessageEditor {
|
||||
editor.set_show_indent_guides(false, cx);
|
||||
editor.set_soft_wrap();
|
||||
editor.set_use_modal_editing(true);
|
||||
editor.set_completion_provider(Some(Rc::new(completion_provider)));
|
||||
editor.set_completion_provider(Some(completion_provider.clone()));
|
||||
editor.set_context_menu_options(ContextMenuOptions {
|
||||
min_entries_visible: 12,
|
||||
max_entries_visible: 12,
|
||||
placement: Some(ContextMenuPlacement::Above),
|
||||
});
|
||||
if prevent_slash_commands {
|
||||
editor.set_semantics_provider(Some(semantics_provider.clone()));
|
||||
}
|
||||
editor.register_addon(MessageEditorAddon::new());
|
||||
editor
|
||||
});
|
||||
@@ -141,21 +140,33 @@ impl MessageEditor {
|
||||
})
|
||||
.detach();
|
||||
|
||||
let mut has_hint = false;
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
subscriptions.push(cx.subscribe_in(&editor, window, {
|
||||
let semantics_provider = semantics_provider.clone();
|
||||
move |this, editor, event, window, cx| {
|
||||
if let EditorEvent::Edited { .. } = event {
|
||||
if prevent_slash_commands {
|
||||
this.highlight_slash_command(
|
||||
semantics_provider.clone(),
|
||||
editor.clone(),
|
||||
window,
|
||||
let snapshot = editor.update(cx, |editor, cx| {
|
||||
let new_hints = this
|
||||
.command_hint(editor.buffer(), cx)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let has_new_hint = !new_hints.is_empty();
|
||||
editor.splice_inlays(
|
||||
if has_hint {
|
||||
&[InlayId::Hint(COMMAND_HINT_INLAY_ID)]
|
||||
} else {
|
||||
&[]
|
||||
},
|
||||
new_hints,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
|
||||
has_hint = has_new_hint;
|
||||
|
||||
editor.snapshot(window, cx)
|
||||
});
|
||||
this.mention_set.remove_invalid(snapshot);
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -168,13 +179,57 @@ impl MessageEditor {
|
||||
workspace,
|
||||
history_store,
|
||||
prompt_store,
|
||||
prevent_slash_commands,
|
||||
prompt_capabilities,
|
||||
available_commands,
|
||||
agent_name,
|
||||
_subscriptions: subscriptions,
|
||||
_parse_slash_command_task: Task::ready(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_hint(&self, buffer: &Entity<MultiBuffer>, cx: &App) -> Option<Inlay> {
|
||||
let available_commands = self.available_commands.borrow();
|
||||
if available_commands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let snapshot = buffer.read(cx).snapshot(cx);
|
||||
let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
|
||||
if parsed_command.argument.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command_name = parsed_command.command?;
|
||||
let available_command = available_commands
|
||||
.iter()
|
||||
.find(|command| command.name == command_name)?;
|
||||
|
||||
let acp::AvailableCommandInput::Unstructured { mut hint } =
|
||||
available_command.input.clone()?;
|
||||
|
||||
let mut hint_pos = parsed_command.source_range.end + 1;
|
||||
if hint_pos > snapshot.len() {
|
||||
hint_pos = snapshot.len();
|
||||
hint.insert(0, ' ');
|
||||
}
|
||||
|
||||
let hint_pos = snapshot.anchor_after(hint_pos);
|
||||
|
||||
Some(Inlay::hint(
|
||||
COMMAND_HINT_INLAY_ID,
|
||||
hint_pos,
|
||||
&InlayHint {
|
||||
position: hint_pos.text_anchor,
|
||||
label: InlayHintLabel::String(hint),
|
||||
kind: Some(InlayHintKind::Parameter),
|
||||
padding_left: false,
|
||||
padding_right: false,
|
||||
tooltip: None,
|
||||
resolve_state: project::ResolveState::Resolved,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn insert_thread_summary(
|
||||
&mut self,
|
||||
thread: agent2::DbThreadMetadata,
|
||||
@@ -191,7 +246,7 @@ impl MessageEditor {
|
||||
.text_anchor
|
||||
});
|
||||
|
||||
self.confirm_completion(
|
||||
self.confirm_mention_completion(
|
||||
thread.title.clone(),
|
||||
start,
|
||||
thread.title.len(),
|
||||
@@ -227,7 +282,7 @@ impl MessageEditor {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn confirm_completion(
|
||||
pub fn confirm_mention_completion(
|
||||
&mut self,
|
||||
crease_text: SharedString,
|
||||
start: text::Anchor,
|
||||
@@ -645,7 +700,7 @@ impl MessageEditor {
|
||||
self.project.read(cx).fs().clone(),
|
||||
self.history_store.clone(),
|
||||
));
|
||||
let delegate = AgentServerDelegate::new(self.project.clone(), None);
|
||||
let delegate = AgentServerDelegate::new(self.project.clone(), None, None);
|
||||
let connection = server.connect(Path::new(""), delegate, cx);
|
||||
cx.spawn(async move |_, cx| {
|
||||
let agent = connection.await?;
|
||||
@@ -679,21 +734,62 @@ impl MessageEditor {
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_slash_commands(
|
||||
text: &str,
|
||||
available_commands: &[acp::AvailableCommand],
|
||||
agent_name: &str,
|
||||
) -> Result<()> {
|
||||
if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
|
||||
if let Some(command_name) = parsed_command.command {
|
||||
// Check if this command is in the list of available commands from the server
|
||||
let is_supported = available_commands
|
||||
.iter()
|
||||
.any(|cmd| cmd.name == command_name);
|
||||
|
||||
if !is_supported {
|
||||
return Err(anyhow!(
|
||||
"The /{} command is not supported by {}.\n\nAvailable commands: {}",
|
||||
command_name,
|
||||
agent_name,
|
||||
if available_commands.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
available_commands
|
||||
.iter()
|
||||
.map(|cmd| format!("/{}", cmd.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn contents(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
|
||||
// Check for unsupported slash commands before spawning async task
|
||||
let text = self.editor.read(cx).text(cx);
|
||||
let available_commands = self.available_commands.borrow().clone();
|
||||
if let Err(err) =
|
||||
Self::validate_slash_commands(&text, &available_commands, &self.agent_name)
|
||||
{
|
||||
return Task::ready(Err(err));
|
||||
}
|
||||
|
||||
let contents = self
|
||||
.mention_set
|
||||
.contents(&self.prompt_capabilities.get(), cx);
|
||||
let editor = self.editor.clone();
|
||||
let prevent_slash_commands = self.prevent_slash_commands;
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
let contents = contents.await?;
|
||||
let mut all_tracked_buffers = Vec::new();
|
||||
|
||||
editor.update(cx, |editor, cx| {
|
||||
let result = editor.update(cx, |editor, cx| {
|
||||
let mut ix = 0;
|
||||
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
|
||||
let text = editor.text(cx);
|
||||
@@ -706,14 +802,16 @@ impl MessageEditor {
|
||||
|
||||
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
|
||||
if crease_range.start > ix {
|
||||
let chunk = if prevent_slash_commands
|
||||
&& ix == 0
|
||||
&& parse_slash_command(&text[ix..]).is_some()
|
||||
{
|
||||
format!(" {}", &text[ix..crease_range.start]).into()
|
||||
} else {
|
||||
text[ix..crease_range.start].into()
|
||||
};
|
||||
//todo(): Custom slash command ContentBlock?
|
||||
// let chunk = if prevent_slash_commands
|
||||
// && ix == 0
|
||||
// && parse_slash_command(&text[ix..]).is_some()
|
||||
// {
|
||||
// format!(" {}", &text[ix..crease_range.start]).into()
|
||||
// } else {
|
||||
// text[ix..crease_range.start].into()
|
||||
// };
|
||||
let chunk = text[ix..crease_range.start].into();
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let chunk = match mention {
|
||||
@@ -769,22 +867,24 @@ impl MessageEditor {
|
||||
}
|
||||
|
||||
if ix < text.len() {
|
||||
let last_chunk = if prevent_slash_commands
|
||||
&& ix == 0
|
||||
&& parse_slash_command(&text[ix..]).is_some()
|
||||
{
|
||||
format!(" {}", text[ix..].trim_end())
|
||||
} else {
|
||||
text[ix..].trim_end().to_owned()
|
||||
};
|
||||
//todo(): Custom slash command ContentBlock?
|
||||
// let last_chunk = if prevent_slash_commands
|
||||
// && ix == 0
|
||||
// && parse_slash_command(&text[ix..]).is_some()
|
||||
// {
|
||||
// format!(" {}", text[ix..].trim_end())
|
||||
// } else {
|
||||
// text[ix..].trim_end().to_owned()
|
||||
// };
|
||||
let last_chunk = text[ix..].trim_end().to_owned();
|
||||
if !last_chunk.is_empty() {
|
||||
chunks.push(last_chunk.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(chunks, all_tracked_buffers)
|
||||
})
|
||||
Ok((chunks, all_tracked_buffers))
|
||||
})?;
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -971,7 +1071,14 @@ impl MessageEditor {
|
||||
cx,
|
||||
);
|
||||
});
|
||||
tasks.push(self.confirm_completion(file_name, anchor, content_len, uri, window, cx));
|
||||
tasks.push(self.confirm_mention_completion(
|
||||
file_name,
|
||||
anchor,
|
||||
content_len,
|
||||
uri,
|
||||
window,
|
||||
cx,
|
||||
));
|
||||
}
|
||||
cx.spawn(async move |_, _| {
|
||||
join_all(tasks).await;
|
||||
@@ -1133,48 +1240,6 @@ impl MessageEditor {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn highlight_slash_command(
|
||||
&mut self,
|
||||
semantics_provider: Rc<SlashCommandSemanticsProvider>,
|
||||
editor: Entity<Editor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
struct InvalidSlashCommand;
|
||||
|
||||
self._parse_slash_command_task = cx.spawn_in(window, async move |_, cx| {
|
||||
cx.background_executor()
|
||||
.timer(PARSE_SLASH_COMMAND_DEBOUNCE)
|
||||
.await;
|
||||
editor
|
||||
.update_in(cx, |editor, window, cx| {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
let range = parse_slash_command(&editor.text(cx));
|
||||
semantics_provider.range.set(range);
|
||||
if let Some((start, end)) = range {
|
||||
editor.highlight_text::<InvalidSlashCommand>(
|
||||
vec![
|
||||
snapshot.buffer_snapshot.anchor_after(start)
|
||||
..snapshot.buffer_snapshot.anchor_before(end),
|
||||
],
|
||||
HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: Some(gpui::red()),
|
||||
wavy: true,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
editor.clear_highlights::<InvalidSlashCommand>(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self, cx: &App) -> String {
|
||||
self.editor.read(cx).text(cx)
|
||||
}
|
||||
@@ -1234,6 +1299,7 @@ impl Render for MessageEditor {
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
syntax: cx.theme().syntax().clone(),
|
||||
inlay_hints_style: editor::make_inlay_hints_style(cx),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -1264,7 +1330,7 @@ pub(crate) fn insert_crease_for_mention(
|
||||
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
|
||||
|
||||
let placeholder = FoldPlaceholder {
|
||||
render: render_fold_icon_button(
|
||||
render: render_mention_fold_button(
|
||||
crease_label,
|
||||
crease_icon,
|
||||
start..end,
|
||||
@@ -1294,7 +1360,7 @@ pub(crate) fn insert_crease_for_mention(
|
||||
Some((crease_id, tx))
|
||||
}
|
||||
|
||||
fn render_fold_icon_button(
|
||||
fn render_mention_fold_button(
|
||||
label: SharedString,
|
||||
icon: SharedString,
|
||||
range: Range<Anchor>,
|
||||
@@ -1471,118 +1537,6 @@ impl MentionSet {
|
||||
}
|
||||
}
|
||||
|
||||
struct SlashCommandSemanticsProvider {
|
||||
range: Cell<Option<(usize, usize)>>,
|
||||
}
|
||||
|
||||
impl SemanticsProvider for SlashCommandSemanticsProvider {
|
||||
fn hover(
|
||||
&self,
|
||||
buffer: &Entity<Buffer>,
|
||||
position: text::Anchor,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<Option<Vec<project::Hover>>>> {
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let offset = position.to_offset(&snapshot);
|
||||
let (start, end) = self.range.get()?;
|
||||
if !(start..end).contains(&offset) {
|
||||
return None;
|
||||
}
|
||||
let range = snapshot.anchor_after(start)..snapshot.anchor_after(end);
|
||||
Some(Task::ready(Some(vec![project::Hover {
|
||||
contents: vec![project::HoverBlock {
|
||||
text: "Slash commands are not supported".into(),
|
||||
kind: project::HoverBlockKind::PlainText,
|
||||
}],
|
||||
range: Some(range),
|
||||
language: None,
|
||||
}])))
|
||||
}
|
||||
|
||||
fn inline_values(
|
||||
&self,
|
||||
_buffer_handle: Entity<Buffer>,
|
||||
_range: Range<text::Anchor>,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn inlay_hints(
|
||||
&self,
|
||||
_buffer_handle: Entity<Buffer>,
|
||||
_range: Range<text::Anchor>,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_inlay_hint(
|
||||
&self,
|
||||
_hint: project::InlayHint,
|
||||
_buffer_handle: Entity<Buffer>,
|
||||
_server_id: lsp::LanguageServerId,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<project::InlayHint>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn supports_inlay_hints(&self, _buffer: &Entity<Buffer>, _cx: &mut App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn document_highlights(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<Vec<project::DocumentHighlight>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn definitions(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_kind: editor::GotoDefinitionKind,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<Option<Vec<project::LocationLink>>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn range_for_rename(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<Option<Range<text::Anchor>>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn perform_rename(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_new_name: String,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<project::ProjectTransaction>>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_slash_command(text: &str) -> Option<(usize, usize)> {
|
||||
if let Some(remainder) = text.strip_prefix('/') {
|
||||
let pos = remainder
|
||||
.find(char::is_whitespace)
|
||||
.unwrap_or(remainder.len());
|
||||
let command = &remainder[..pos];
|
||||
if !command.is_empty() && command.chars().all(char::is_alphanumeric) {
|
||||
return Some((0, 1 + command.len()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct MessageEditorAddon {}
|
||||
|
||||
impl MessageEditorAddon {
|
||||
@@ -1610,7 +1564,13 @@ impl Addon for MessageEditorAddon {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{cell::Cell, ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
ops::Range,
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use acp_thread::MentionUri;
|
||||
use agent_client_protocol as acp;
|
||||
@@ -1657,8 +1617,9 @@ mod tests {
|
||||
history_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
false,
|
||||
EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
max_lines: None,
|
||||
@@ -1735,6 +1696,140 @@ mod tests {
|
||||
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/test",
|
||||
json!({
|
||||
".zed": {
|
||||
"tasks.json": r#"[{"label": "test", "command": "echo"}]"#
|
||||
},
|
||||
"src": {
|
||||
"main.rs": "fn main() {}",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
|
||||
let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
|
||||
let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
|
||||
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
|
||||
// Start with no available commands - simulating Claude which doesn't support slash commands
|
||||
let available_commands = Rc::new(RefCell::new(vec![]));
|
||||
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let workspace_handle = workspace.downgrade();
|
||||
let message_editor = workspace.update_in(cx, |_, window, cx| {
|
||||
cx.new(|cx| {
|
||||
MessageEditor::new(
|
||||
workspace_handle.clone(),
|
||||
project.clone(),
|
||||
history_store.clone(),
|
||||
None,
|
||||
prompt_capabilities.clone(),
|
||||
available_commands.clone(),
|
||||
"Claude Code".into(),
|
||||
"Test",
|
||||
EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
max_lines: None,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
|
||||
|
||||
// Test that slash commands fail when no available_commands are set (empty list means no commands supported)
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("/file test.txt", window, cx);
|
||||
});
|
||||
|
||||
let contents_result = message_editor
|
||||
.update(cx, |message_editor, cx| message_editor.contents(cx))
|
||||
.await;
|
||||
|
||||
// Should fail because available_commands is empty (no commands supported)
|
||||
assert!(contents_result.is_err());
|
||||
let error_message = contents_result.unwrap_err().to_string();
|
||||
assert!(error_message.contains("not supported by Claude Code"));
|
||||
assert!(error_message.contains("Available commands: none"));
|
||||
|
||||
// Now simulate Claude providing its list of available commands (which doesn't include file)
|
||||
available_commands.replace(vec![acp::AvailableCommand {
|
||||
name: "help".to_string(),
|
||||
description: "Get help".to_string(),
|
||||
input: None,
|
||||
}]);
|
||||
|
||||
// Test that unsupported slash commands trigger an error when we have a list of available commands
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("/file test.txt", window, cx);
|
||||
});
|
||||
|
||||
let contents_result = message_editor
|
||||
.update(cx, |message_editor, cx| message_editor.contents(cx))
|
||||
.await;
|
||||
|
||||
assert!(contents_result.is_err());
|
||||
let error_message = contents_result.unwrap_err().to_string();
|
||||
assert!(error_message.contains("not supported by Claude Code"));
|
||||
assert!(error_message.contains("/file"));
|
||||
assert!(error_message.contains("Available commands: /help"));
|
||||
|
||||
// Test that supported commands work fine
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("/help", window, cx);
|
||||
});
|
||||
|
||||
let contents_result = message_editor
|
||||
.update(cx, |message_editor, cx| message_editor.contents(cx))
|
||||
.await;
|
||||
|
||||
// Should succeed because /help is in available_commands
|
||||
assert!(contents_result.is_ok());
|
||||
|
||||
// Test that regular text works fine
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("Hello Claude!", window, cx);
|
||||
});
|
||||
|
||||
let (content, _) = message_editor
|
||||
.update(cx, |message_editor, cx| message_editor.contents(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(content.len(), 1);
|
||||
if let acp::ContentBlock::Text(text) = &content[0] {
|
||||
assert_eq!(text.text, "Hello Claude!");
|
||||
} else {
|
||||
panic!("Expected ContentBlock::Text");
|
||||
}
|
||||
|
||||
// Test that @ mentions still work
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("Check this @", window, cx);
|
||||
});
|
||||
|
||||
// The @ mention functionality should not be affected
|
||||
let (content, _) = message_editor
|
||||
.update(cx, |message_editor, cx| message_editor.contents(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(content.len(), 1);
|
||||
if let acp::ContentBlock::Text(text) = &content[0] {
|
||||
assert_eq!(text.text, "Check this @");
|
||||
} else {
|
||||
panic!("Expected ContentBlock::Text");
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageEditorItem(Entity<MessageEditor>);
|
||||
|
||||
impl Item for MessageEditorItem {
|
||||
@@ -1764,7 +1859,192 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_context_completion_provider(cx: &mut TestAppContext) {
|
||||
async fn test_completion_provider_commands(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let app_state = cx.update(AppState::test);
|
||||
|
||||
cx.update(|cx| {
|
||||
language::init(cx);
|
||||
editor::init(cx);
|
||||
workspace::init(app_state.clone(), cx);
|
||||
Project::init_settings(cx);
|
||||
});
|
||||
|
||||
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
|
||||
let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let workspace = window.root(cx).unwrap();
|
||||
|
||||
let mut cx = VisualTestContext::from_window(*window, cx);
|
||||
|
||||
let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
|
||||
let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
|
||||
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
|
||||
let available_commands = Rc::new(RefCell::new(vec![
|
||||
acp::AvailableCommand {
|
||||
name: "quick-math".to_string(),
|
||||
description: "2 + 2 = 4 - 1 = 3".to_string(),
|
||||
input: None,
|
||||
},
|
||||
acp::AvailableCommand {
|
||||
name: "say-hello".to_string(),
|
||||
description: "Say hello to whoever you want".to_string(),
|
||||
input: Some(acp::AvailableCommandInput::Unstructured {
|
||||
hint: "<name>".to_string(),
|
||||
}),
|
||||
},
|
||||
]));
|
||||
|
||||
let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let workspace_handle = cx.weak_entity();
|
||||
let message_editor = cx.new(|cx| {
|
||||
MessageEditor::new(
|
||||
workspace_handle,
|
||||
project.clone(),
|
||||
history_store.clone(),
|
||||
None,
|
||||
prompt_capabilities.clone(),
|
||||
available_commands.clone(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
EditorMode::AutoHeight {
|
||||
max_lines: None,
|
||||
min_lines: 1,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace.active_pane().update(cx, |pane, cx| {
|
||||
pane.add_item(
|
||||
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
message_editor.read(cx).focus_handle(cx).focus(window);
|
||||
message_editor.read(cx).editor().clone()
|
||||
});
|
||||
|
||||
cx.simulate_input("/");
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert_eq!(editor.text(cx), "/");
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
|
||||
assert_eq!(
|
||||
current_completion_labels_with_documentation(editor),
|
||||
&[
|
||||
("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
|
||||
("say-hello".into(), "Say hello to whoever you want".into())
|
||||
]
|
||||
);
|
||||
editor.set_text("", window, cx);
|
||||
});
|
||||
|
||||
cx.simulate_input("/qui");
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert_eq!(editor.text(cx), "/qui");
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
|
||||
assert_eq!(
|
||||
current_completion_labels_with_documentation(editor),
|
||||
&[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
|
||||
);
|
||||
editor.set_text("", window, cx);
|
||||
});
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert_eq!(editor.display_text(cx), "/quick-math ");
|
||||
assert!(!editor.has_visible_completions_menu());
|
||||
editor.set_text("", window, cx);
|
||||
});
|
||||
|
||||
cx.simulate_input("/say");
|
||||
|
||||
editor.update_in(&mut cx, |editor, _window, cx| {
|
||||
assert_eq!(editor.display_text(cx), "/say");
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
|
||||
assert_eq!(
|
||||
current_completion_labels_with_documentation(editor),
|
||||
&[("say-hello".into(), "Say hello to whoever you want".into())]
|
||||
);
|
||||
});
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
editor.update_in(&mut cx, |editor, _window, cx| {
|
||||
assert_eq!(editor.text(cx), "/say-hello ");
|
||||
assert_eq!(editor.display_text(cx), "/say-hello <name>");
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
|
||||
assert_eq!(
|
||||
current_completion_labels_with_documentation(editor),
|
||||
&[("say-hello".into(), "Say hello to whoever you want".into())]
|
||||
);
|
||||
});
|
||||
|
||||
cx.simulate_input("GPT5");
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert_eq!(editor.text(cx), "/say-hello GPT5");
|
||||
assert_eq!(editor.display_text(cx), "/say-hello GPT5");
|
||||
assert!(!editor.has_visible_completions_menu());
|
||||
|
||||
// Delete argument
|
||||
for _ in 0..4 {
|
||||
editor.backspace(&editor::actions::Backspace, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
editor.update_in(&mut cx, |editor, window, cx| {
|
||||
assert_eq!(editor.text(cx), "/say-hello ");
|
||||
// Hint is visible because argument was deleted
|
||||
assert_eq!(editor.display_text(cx), "/say-hello <name>");
|
||||
|
||||
// Delete last command letter
|
||||
editor.backspace(&editor::actions::Backspace, window, cx);
|
||||
editor.backspace(&editor::actions::Backspace, window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
editor.update_in(&mut cx, |editor, _window, cx| {
|
||||
// Hint goes away once command no longer matches an available one
|
||||
assert_eq!(editor.text(cx), "/say-hell");
|
||||
assert_eq!(editor.display_text(cx), "/say-hell");
|
||||
assert!(!editor.has_visible_completions_menu());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let app_state = cx.update(AppState::test);
|
||||
@@ -1857,8 +2137,9 @@ mod tests {
|
||||
history_store.clone(),
|
||||
None,
|
||||
prompt_capabilities.clone(),
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
false,
|
||||
EditorMode::AutoHeight {
|
||||
max_lines: None,
|
||||
min_lines: 1,
|
||||
@@ -1888,7 +2169,6 @@ mod tests {
|
||||
assert_eq!(editor.text(cx), "Lorem @");
|
||||
assert!(editor.has_visible_completions_menu());
|
||||
|
||||
// Only files since we have default capabilities
|
||||
assert_eq!(
|
||||
current_completion_labels(editor),
|
||||
&[
|
||||
@@ -2284,4 +2564,20 @@ mod tests {
|
||||
.map(|completion| completion.label.text)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
|
||||
let completions = editor.current_completions().expect("Missing completions");
|
||||
completions
|
||||
.into_iter()
|
||||
.map(|completion| {
|
||||
(
|
||||
completion.label.text,
|
||||
completion
|
||||
.documentation
|
||||
.map(|d| d.text().to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ impl AcpModelSelectorPopover {
|
||||
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.menu_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
pub fn active_model_name(&self, cx: &App) -> Option<SharedString> {
|
||||
self.selector
|
||||
.read(cx)
|
||||
.delegate
|
||||
.active_model()
|
||||
.map(|model| model.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AcpModelSelectorPopover {
|
||||
|
||||
@@ -8,7 +8,7 @@ use action_log::ActionLog;
|
||||
use agent_client_protocol::{self as acp, PromptCapabilities};
|
||||
use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
|
||||
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
|
||||
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
|
||||
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use audio::{Audio, Sound};
|
||||
use buffer_diff::BufferDiff;
|
||||
@@ -23,9 +23,9 @@ use gpui::{
|
||||
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
|
||||
CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
|
||||
ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement,
|
||||
Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
|
||||
Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage,
|
||||
point, prelude::*, pulsating_between,
|
||||
Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,
|
||||
WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, point, prelude::*,
|
||||
pulsating_between,
|
||||
};
|
||||
use language::Buffer;
|
||||
|
||||
@@ -35,7 +35,7 @@ use project::{Project, ProjectEntryId};
|
||||
use prompt_store::{PromptId, PromptStore};
|
||||
use rope::Point;
|
||||
use settings::{Settings as _, SettingsStore};
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -45,8 +45,8 @@ use terminal_view::terminal_panel::TerminalPanel;
|
||||
use text::Anchor;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
|
||||
Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
|
||||
Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
|
||||
PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*,
|
||||
};
|
||||
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
|
||||
use workspace::{CollaboratorId, Workspace};
|
||||
@@ -78,10 +78,12 @@ enum ThreadFeedback {
|
||||
Negative,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ThreadError {
|
||||
PaymentRequired,
|
||||
ModelRequestLimitReached(cloud_llm_client::Plan),
|
||||
ToolUseLimitReached,
|
||||
Refusal,
|
||||
AuthenticationRequired(SharedString),
|
||||
Other(SharedString),
|
||||
}
|
||||
@@ -284,7 +286,9 @@ pub struct AcpThreadView {
|
||||
should_be_following: bool,
|
||||
editing_message: Option<usize>,
|
||||
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
|
||||
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
|
||||
is_loading_contents: bool,
|
||||
new_server_version_available: Option<SharedString>,
|
||||
_cancel_task: Option<Task<()>>,
|
||||
_subscriptions: [Subscription; 3],
|
||||
}
|
||||
@@ -325,10 +329,15 @@ impl AcpThreadView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
|
||||
let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().is_some();
|
||||
let available_commands = Rc::new(RefCell::new(vec![]));
|
||||
|
||||
let placeholder = if agent.name() == "Zed Agent" {
|
||||
format!("Message the {} — @ to include context", agent.name())
|
||||
} else if agent.name() == "Claude Code" || !available_commands.borrow().is_empty() {
|
||||
format!(
|
||||
"Message {} — @ to include context, / for commands",
|
||||
agent.name()
|
||||
)
|
||||
} else {
|
||||
format!("Message {} — @ to include context", agent.name())
|
||||
};
|
||||
@@ -340,8 +349,9 @@ impl AcpThreadView {
|
||||
history_store.clone(),
|
||||
prompt_store.clone(),
|
||||
prompt_capabilities.clone(),
|
||||
available_commands.clone(),
|
||||
agent.name(),
|
||||
placeholder,
|
||||
prevent_slash_commands,
|
||||
editor::EditorMode::AutoHeight {
|
||||
min_lines: MIN_EDITOR_LINES,
|
||||
max_lines: Some(MAX_EDITOR_LINES),
|
||||
@@ -364,7 +374,8 @@ impl AcpThreadView {
|
||||
history_store.clone(),
|
||||
prompt_store.clone(),
|
||||
prompt_capabilities.clone(),
|
||||
prevent_slash_commands,
|
||||
available_commands.clone(),
|
||||
agent.name(),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -396,18 +407,33 @@ impl AcpThreadView {
|
||||
editing_message: None,
|
||||
edits_expanded: false,
|
||||
plan_expanded: false,
|
||||
prompt_capabilities,
|
||||
available_commands,
|
||||
editor_expanded: false,
|
||||
should_be_following: false,
|
||||
history_store,
|
||||
hovered_recent_history_item: None,
|
||||
prompt_capabilities,
|
||||
is_loading_contents: false,
|
||||
_subscriptions: subscriptions,
|
||||
_cancel_task: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
new_server_version_available: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.thread_state = Self::initial_state(
|
||||
self.agent.clone(),
|
||||
None,
|
||||
self.workspace.clone(),
|
||||
self.project.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self.new_server_version_available.take();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn initial_state(
|
||||
agent: Rc<dyn AgentServer>,
|
||||
resume_thread: Option<DbThreadMetadata>,
|
||||
@@ -416,14 +442,37 @@ impl AcpThreadView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ThreadState {
|
||||
let root_dir = project
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
if !project.read(cx).is_local() && agent.clone().downcast::<NativeAgentServer>().is_none() {
|
||||
return ThreadState::LoadError(LoadError::Other(
|
||||
"External agents are not yet supported for remote projects.".into(),
|
||||
));
|
||||
}
|
||||
let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
|
||||
// Pick the first non-single-file worktree for the root directory if there are any,
|
||||
// and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
|
||||
worktrees.sort_by(|l, r| {
|
||||
l.read(cx)
|
||||
.is_single_file()
|
||||
.cmp(&r.read(cx).is_single_file())
|
||||
});
|
||||
let root_dir = worktrees
|
||||
.into_iter()
|
||||
.filter_map(|worktree| {
|
||||
if worktree.read(cx).is_single_file() {
|
||||
Some(worktree.read(cx).abs_path().parent()?.into())
|
||||
} else {
|
||||
Some(worktree.read(cx).abs_path())
|
||||
}
|
||||
})
|
||||
.next()
|
||||
.map(|worktree| worktree.read(cx).abs_path())
|
||||
.unwrap_or_else(|| paths::home_dir().as_path().into());
|
||||
let (tx, mut rx) = watch::channel("Loading…".into());
|
||||
let delegate = AgentServerDelegate::new(project.clone(), Some(tx));
|
||||
let (status_tx, mut status_rx) = watch::channel("Loading…".into());
|
||||
let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
|
||||
let delegate = AgentServerDelegate::new(
|
||||
project.clone(),
|
||||
Some(status_tx),
|
||||
Some(new_version_available_tx),
|
||||
);
|
||||
|
||||
let connect_task = agent.connect(&root_dir, delegate, cx);
|
||||
let load_task = cx.spawn_in(window, async move |this, cx| {
|
||||
@@ -486,6 +535,26 @@ impl AcpThreadView {
|
||||
Ok(thread) => {
|
||||
let action_log = thread.read(cx).action_log().clone();
|
||||
|
||||
let mut available_commands = thread.read(cx).available_commands();
|
||||
|
||||
if connection
|
||||
.auth_methods()
|
||||
.iter()
|
||||
.any(|method| method.id.0.as_ref() == "claude-login")
|
||||
{
|
||||
available_commands.push(acp::AvailableCommand {
|
||||
name: "login".to_owned(),
|
||||
description: "Authenticate".to_owned(),
|
||||
input: None,
|
||||
});
|
||||
available_commands.push(acp::AvailableCommand {
|
||||
name: "logout".to_owned(),
|
||||
description: "Authenticate".to_owned(),
|
||||
input: None,
|
||||
});
|
||||
}
|
||||
this.available_commands.replace(available_commands);
|
||||
|
||||
this.prompt_capabilities
|
||||
.set(thread.read(cx).prompt_capabilities());
|
||||
|
||||
@@ -578,10 +647,23 @@ impl AcpThreadView {
|
||||
.log_err();
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(new_version) = new_version_available_rx.recv().await {
|
||||
if let Some(new_version) = new_version {
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_server_version_available = Some(new_version.into());
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let loading_view = cx.new(|cx| {
|
||||
let update_title_task = cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
let status = rx.recv().await?;
|
||||
let status = status_rx.recv().await?;
|
||||
this.update(cx, |this: &mut LoadingView, cx| {
|
||||
this.title = status;
|
||||
cx.notify();
|
||||
@@ -617,17 +699,13 @@ impl AcpThreadView {
|
||||
move |_, ev, window, cx| {
|
||||
if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
|
||||
&& &provider_id == updated_provider_id
|
||||
&& LanguageModelRegistry::global(cx)
|
||||
.read(cx)
|
||||
.provider(&provider_id)
|
||||
.map_or(false, |provider| provider.is_authenticated(cx))
|
||||
{
|
||||
this.update(cx, |this, cx| {
|
||||
this.thread_state = Self::initial_state(
|
||||
agent.clone(),
|
||||
None,
|
||||
this.workspace.clone(),
|
||||
this.project.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
this.reset(window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -827,6 +905,9 @@ impl AcpThreadView {
|
||||
self.expanded_tool_calls.insert(tool_call_id.clone());
|
||||
}
|
||||
}
|
||||
ViewEvent::TerminalMovedToBackground(tool_call_id) => {
|
||||
self.expanded_tool_calls.remove(tool_call_id);
|
||||
}
|
||||
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
|
||||
if let Some(thread) = self.thread()
|
||||
&& let Some(AgentThreadEntry::UserMessage(user_message)) =
|
||||
@@ -899,6 +980,40 @@ impl AcpThreadView {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = self.message_editor.read(cx).text(cx);
|
||||
let text = text.trim();
|
||||
if text == "/login" || text == "/logout" {
|
||||
let ThreadState::Ready { thread, .. } = &self.thread_state else {
|
||||
return;
|
||||
};
|
||||
|
||||
let connection = thread.read(cx).connection().clone();
|
||||
if !connection
|
||||
.auth_methods()
|
||||
.iter()
|
||||
.any(|method| method.id.0.as_ref() == "claude-login")
|
||||
{
|
||||
return;
|
||||
};
|
||||
let this = cx.weak_entity();
|
||||
let agent = self.agent.clone();
|
||||
window.defer(cx, |window, cx| {
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired {
|
||||
description: None,
|
||||
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
|
||||
},
|
||||
agent,
|
||||
connection,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let contents = self
|
||||
.message_editor
|
||||
.update(cx, |message_editor, cx| message_editor.contents(cx));
|
||||
@@ -1189,6 +1304,14 @@ impl AcpThreadView {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
AcpThreadEvent::Refusal => {
|
||||
self.thread_retry_status.take();
|
||||
self.thread_error = Some(ThreadError::Refusal);
|
||||
let model_or_agent_name = self.get_current_model_name(cx);
|
||||
let notification_message =
|
||||
format!("{} refused to respond to this request", model_or_agent_name);
|
||||
self.notify_with_sound(¬ification_message, IconName::Warning, window, cx);
|
||||
}
|
||||
AcpThreadEvent::Error => {
|
||||
self.thread_retry_status.take();
|
||||
self.notify_with_sound(
|
||||
@@ -1271,11 +1394,11 @@ impl AcpThreadView {
|
||||
.read(cx)
|
||||
.provider(&language_model::ANTHROPIC_PROVIDER_ID)
|
||||
.unwrap();
|
||||
if !provider.is_authenticated(cx) {
|
||||
let this = cx.weak_entity();
|
||||
let agent = self.agent.clone();
|
||||
let connection = connection.clone();
|
||||
window.defer(cx, |window, cx| {
|
||||
let this = cx.weak_entity();
|
||||
let agent = self.agent.clone();
|
||||
let connection = connection.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
if !provider.is_authenticated(cx) {
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired {
|
||||
@@ -1287,9 +1410,21 @@ impl AcpThreadView {
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.thread_state = Self::initial_state(
|
||||
agent,
|
||||
None,
|
||||
this.workspace.clone(),
|
||||
this.project.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if method.0.as_ref() == "vertex-ai"
|
||||
&& std::env::var("GOOGLE_API_KEY").is_err()
|
||||
&& (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
|
||||
@@ -1333,7 +1468,6 @@ impl AcpThreadView {
|
||||
cx.notify();
|
||||
self.auth_task =
|
||||
Some(cx.spawn_in(window, {
|
||||
let project = self.project.clone();
|
||||
let agent = self.agent.clone();
|
||||
async move |this, cx| {
|
||||
let result = authenticate.await;
|
||||
@@ -1362,14 +1496,7 @@ impl AcpThreadView {
|
||||
}
|
||||
this.handle_thread_error(err, cx);
|
||||
} else {
|
||||
this.thread_state = Self::initial_state(
|
||||
agent,
|
||||
None,
|
||||
this.workspace.clone(),
|
||||
project.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
this.reset(window, cx);
|
||||
}
|
||||
this.auth_task.take()
|
||||
})
|
||||
@@ -1391,7 +1518,7 @@ impl AcpThreadView {
|
||||
let cwd = project.first_project_directory(cx);
|
||||
let shell = project.terminal_settings(&cwd, cx).shell.clone();
|
||||
|
||||
let delegate = AgentServerDelegate::new(project_entity.clone(), None);
|
||||
let delegate = AgentServerDelegate::new(project_entity.clone(), None, None);
|
||||
let command = ClaudeCode::login_command(delegate, cx);
|
||||
|
||||
window.spawn(cx, async move |cx| {
|
||||
@@ -2418,7 +2545,8 @@ impl AcpThreadView {
|
||||
|
||||
let output = terminal_data.output();
|
||||
let command_finished = output.is_some();
|
||||
let truncated_output = output.is_some_and(|output| output.was_content_truncated);
|
||||
let truncated_output =
|
||||
output.is_some_and(|output| output.original_content_len > output.content.len());
|
||||
let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
|
||||
|
||||
let command_failed = command_finished
|
||||
@@ -2506,48 +2634,20 @@ impl AcpThreadView {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Info)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(percentage(delta)))
|
||||
},
|
||||
),
|
||||
.with_rotate_animation(2)
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Disclosure::new(
|
||||
SharedString::from(format!(
|
||||
"terminal-tool-disclosure-{}",
|
||||
terminal.entity_id()
|
||||
)),
|
||||
is_expanded,
|
||||
)
|
||||
.opened_icon(IconName::ChevronUp)
|
||||
.closed_icon(IconName::ChevronDown)
|
||||
.visible_on_hover(&header_group)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call.id.clone();
|
||||
move |this, _event, _window, _cx| {
|
||||
if is_expanded {
|
||||
this.expanded_tool_calls.remove(&id);
|
||||
} else {
|
||||
this.expanded_tool_calls.insert(id.clone());
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.when(truncated_output, |header| {
|
||||
let tooltip = if let Some(output) = output {
|
||||
if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
|
||||
"Output exceeded terminal max lines and was \
|
||||
truncated, the model received the first 16 KB."
|
||||
.to_string()
|
||||
format!("Output exceeded terminal max lines and was \
|
||||
truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
|
||||
} else {
|
||||
format!(
|
||||
"Output is {} long, and to avoid unexpected token usage, \
|
||||
only 16 KB was sent back to the model.",
|
||||
only {} was sent back to the agent.",
|
||||
format_file_size(output.original_content_len as u64, true),
|
||||
format_file_size(output.content.len() as u64, true)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -2595,7 +2695,29 @@ impl AcpThreadView {
|
||||
)))
|
||||
}),
|
||||
)
|
||||
});
|
||||
})
|
||||
.child(
|
||||
Disclosure::new(
|
||||
SharedString::from(format!(
|
||||
"terminal-tool-disclosure-{}",
|
||||
terminal.entity_id()
|
||||
)),
|
||||
is_expanded,
|
||||
)
|
||||
.opened_icon(IconName::ChevronUp)
|
||||
.closed_icon(IconName::ChevronDown)
|
||||
.visible_on_hover(&header_group)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call.id.clone();
|
||||
move |this, _event, _window, _cx| {
|
||||
if is_expanded {
|
||||
this.expanded_tool_calls.remove(&id);
|
||||
} else {
|
||||
this.expanded_tool_calls.insert(id.clone());
|
||||
}
|
||||
}
|
||||
})),
|
||||
);
|
||||
|
||||
let terminal_view = self
|
||||
.entry_view_state
|
||||
@@ -2646,7 +2768,18 @@ impl AcpThreadView {
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.rounded_b_md()
|
||||
.text_ui_sm(cx)
|
||||
.children(terminal_view.clone()),
|
||||
.h_full()
|
||||
.children(terminal_view.map(|terminal_view| {
|
||||
if terminal_view
|
||||
.read(cx)
|
||||
.content_mode(window, cx)
|
||||
.is_scrollable()
|
||||
{
|
||||
div().h_72().child(terminal_view).into_any_element()
|
||||
} else {
|
||||
terminal_view.into_any_element()
|
||||
}
|
||||
})),
|
||||
)
|
||||
})
|
||||
.into_any()
|
||||
@@ -2928,16 +3061,7 @@ impl AcpThreadView {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(percentage(
|
||||
delta,
|
||||
)))
|
||||
},
|
||||
)
|
||||
.into_any_element(),
|
||||
.with_rotate_animation(2)
|
||||
)
|
||||
.child(Label::new("Authenticating…").size(LabelSize::Small)),
|
||||
)
|
||||
@@ -3250,13 +3374,7 @@ impl AcpThreadView {
|
||||
acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Accent)
|
||||
.with_animation(
|
||||
"running",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(percentage(delta)))
|
||||
},
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
|
||||
.size(IconSize::Small)
|
||||
@@ -4683,6 +4801,7 @@ impl AcpThreadView {
|
||||
fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
|
||||
let content = match self.thread_error.as_ref()? {
|
||||
ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
|
||||
ThreadError::Refusal => self.render_refusal_error(cx),
|
||||
ThreadError::AuthenticationRequired(error) => {
|
||||
self.render_authentication_required_error(error.clone(), cx)
|
||||
}
|
||||
@@ -4698,6 +4817,75 @@ impl AcpThreadView {
|
||||
Some(div().child(content))
|
||||
}
|
||||
|
||||
fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
|
||||
v_flex().w_full().justify_end().child(
|
||||
h_flex()
|
||||
.p_2()
|
||||
.pr_3()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().element_background)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(IconName::Download)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
.child(Label::new("New version available").size(LabelSize::Small)),
|
||||
)
|
||||
.child(
|
||||
Button::new("update-button", format!("Update to v{}", version))
|
||||
.label_size(LabelSize::Small)
|
||||
.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.reset(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_current_model_name(&self, cx: &App) -> SharedString {
|
||||
// For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
|
||||
// For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
|
||||
// This provides better clarity about what refused the request
|
||||
if self
|
||||
.agent
|
||||
.clone()
|
||||
.downcast::<agent2::NativeAgentServer>()
|
||||
.is_some()
|
||||
{
|
||||
// Native agent - use the model name
|
||||
self.model_selector
|
||||
.as_ref()
|
||||
.and_then(|selector| selector.read(cx).active_model_name(cx))
|
||||
.unwrap_or_else(|| SharedString::from("The model"))
|
||||
} else {
|
||||
// ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
|
||||
self.agent.name()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
|
||||
let model_or_agent_name = self.get_current_model_name(cx);
|
||||
let refusal_message = format!(
|
||||
"{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
|
||||
model_or_agent_name
|
||||
);
|
||||
|
||||
Callout::new()
|
||||
.severity(Severity::Error)
|
||||
.title("Request Refused")
|
||||
.icon(IconName::XCircle)
|
||||
.description(refusal_message.clone())
|
||||
.actions_slot(self.create_copy_button(&refusal_message))
|
||||
.dismiss_action(self.dismiss_error_button(cx))
|
||||
}
|
||||
|
||||
fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
|
||||
let can_resume = self
|
||||
.thread()
|
||||
@@ -4980,11 +5168,7 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement {
|
||||
Icon::new(IconName::LoadCircle)
|
||||
.size(size)
|
||||
.color(Color::Accent)
|
||||
.with_animation(
|
||||
"load_context_circle",
|
||||
Animation::new(Duration::from_secs(3)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.with_rotate_animation(3)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -5075,6 +5259,12 @@ impl Render for AcpThreadView {
|
||||
})
|
||||
.children(self.render_thread_retry_status_callout(window, cx))
|
||||
.children(self.render_thread_error(window, cx))
|
||||
.when_some(
|
||||
self.new_server_version_available.as_ref().filter(|_| {
|
||||
!has_messages || !matches!(self.thread_state, ThreadState::Ready { .. })
|
||||
}),
|
||||
|this, version| this.child(self.render_new_version_callout(&version, cx)),
|
||||
)
|
||||
.children(
|
||||
if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
|
||||
Some(usage_callout.into_any_element())
|
||||
@@ -5329,6 +5519,33 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_refusal_handling(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let (thread_view, cx) =
|
||||
setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
|
||||
|
||||
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
|
||||
message_editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("Do something harmful", window, cx);
|
||||
});
|
||||
|
||||
thread_view.update_in(cx, |thread_view, window, cx| {
|
||||
thread_view.send(window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Check that the refusal error is set
|
||||
thread_view.read_with(cx, |thread_view, _cx| {
|
||||
assert!(
|
||||
matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
|
||||
"Expected refusal error to be set"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -5528,6 +5745,7 @@ pub(crate) mod tests {
|
||||
audio: true,
|
||||
embedded_context: true,
|
||||
}),
|
||||
vec![],
|
||||
cx,
|
||||
)
|
||||
})))
|
||||
@@ -5563,6 +5781,68 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulates a model which always returns a refusal response
|
||||
#[derive(Clone)]
|
||||
struct RefusalAgentConnection;
|
||||
|
||||
impl AgentConnection for RefusalAgentConnection {
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
cx: &mut gpui::App,
|
||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||
Task::ready(Ok(cx.new(|cx| {
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
AcpThread::new(
|
||||
"RefusalAgentConnection",
|
||||
self,
|
||||
project,
|
||||
action_log,
|
||||
SessionId("test".into()),
|
||||
watch::Receiver::constant(acp::PromptCapabilities {
|
||||
image: true,
|
||||
audio: true,
|
||||
embedded_context: true,
|
||||
}),
|
||||
Vec::new(),
|
||||
cx,
|
||||
)
|
||||
})))
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn authenticate(
|
||||
&self,
|
||||
_method_id: acp::AuthMethodId,
|
||||
_cx: &mut App,
|
||||
) -> Task<gpui::Result<()>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn prompt(
|
||||
&self,
|
||||
_id: Option<acp_thread::UserMessageId>,
|
||||
_params: acp::PromptRequest,
|
||||
_cx: &mut App,
|
||||
) -> Task<gpui::Result<acp::PromptResponse>> {
|
||||
Task::ready(Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::Refusal,
|
||||
}))
|
||||
}
|
||||
|
||||
fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
|
||||
@@ -23,9 +23,8 @@ use gpui::{
|
||||
AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardEntry,
|
||||
ClipboardItem, DefiniteLength, EdgesRefinement, Empty, Entity, EventEmitter, Focusable, Hsla,
|
||||
ListAlignment, ListOffset, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful,
|
||||
StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation,
|
||||
UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage,
|
||||
pulsating_between,
|
||||
StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle,
|
||||
WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, pulsating_between,
|
||||
};
|
||||
use language::{Buffer, Language, LanguageRegistry};
|
||||
use language_model::{
|
||||
@@ -46,8 +45,8 @@ use std::time::Duration;
|
||||
use text::ToPoint;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
Banner, Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize,
|
||||
Tooltip, prelude::*,
|
||||
Banner, CommonAnimationExt, Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar,
|
||||
ScrollbarState, TextSize, Tooltip, prelude::*,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
use util::markdown::MarkdownCodeBlock;
|
||||
@@ -1002,8 +1001,22 @@ impl ActiveThread {
|
||||
// Don't notify for intermediate tool use
|
||||
}
|
||||
Ok(StopReason::Refusal) => {
|
||||
let model_name = self
|
||||
.thread
|
||||
.read(cx)
|
||||
.configured_model()
|
||||
.map(|configured| configured.model.name().0.to_string())
|
||||
.unwrap_or_else(|| "The model".to_string());
|
||||
let refusal_message = format!(
|
||||
"{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
|
||||
model_name
|
||||
);
|
||||
self.last_error = Some(ThreadError::Message {
|
||||
header: SharedString::from("Request Refused"),
|
||||
message: SharedString::from(refusal_message),
|
||||
});
|
||||
self.notify_with_sound(
|
||||
"Language model refused to respond",
|
||||
format!("{} refused to respond", model_name),
|
||||
IconName::Warning,
|
||||
window,
|
||||
cx,
|
||||
@@ -2647,15 +2660,7 @@ impl ActiveThread {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(
|
||||
percentage(delta),
|
||||
))
|
||||
},
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -2831,17 +2836,11 @@ impl ActiveThread {
|
||||
}
|
||||
ToolUseStatus::Pending
|
||||
| ToolUseStatus::InputStillStreaming
|
||||
| ToolUseStatus::Running => {
|
||||
let icon = Icon::new(IconName::ArrowCircle)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small);
|
||||
icon.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
| ToolUseStatus::Running => Icon::new(IconName::ArrowCircle)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
ToolUseStatus::Finished(_) => div().w_0().into_any_element(),
|
||||
ToolUseStatus::Error(_) => {
|
||||
let icon = Icon::new(IconName::Close)
|
||||
@@ -2930,15 +2929,7 @@ impl ActiveThread {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Accent)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(percentage(
|
||||
delta,
|
||||
)))
|
||||
},
|
||||
),
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(
|
||||
Label::new("Running…")
|
||||
|
||||
@@ -3,7 +3,7 @@ mod configure_context_server_modal;
|
||||
mod manage_profiles_modal;
|
||||
mod tool_picker;
|
||||
|
||||
use std::{ops::Range, sync::Arc, time::Duration};
|
||||
use std::{ops::Range, sync::Arc};
|
||||
|
||||
use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
|
||||
use agent_settings::AgentSettings;
|
||||
@@ -17,9 +17,8 @@ use extension::ExtensionManifest;
|
||||
use extension_host::ExtensionStore;
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
Action, Animation, AnimationExt as _, AnyView, App, AsyncWindowContext, Corner, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation,
|
||||
WeakEntity, percentage,
|
||||
Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
Hsla, ScrollHandle, Subscription, Task, WeakEntity,
|
||||
};
|
||||
use language::LanguageRegistry;
|
||||
use language_model::{
|
||||
@@ -32,8 +31,9 @@ use project::{
|
||||
};
|
||||
use settings::{Settings, SettingsStore, update_settings_file};
|
||||
use ui::{
|
||||
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
|
||||
Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
|
||||
Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex,
|
||||
Indicator, PopoverMenu, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip,
|
||||
prelude::*,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
use workspace::{Workspace, create_and_open_local_file};
|
||||
@@ -670,10 +670,9 @@ impl AgentConfiguration {
|
||||
Icon::new(IconName::LoadCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Accent)
|
||||
.with_animation(
|
||||
SharedString::from(format!("{}-starting", context_server_id.0,)),
|
||||
Animation::new(Duration::from_secs(3)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
.with_keyed_rotate_animation(
|
||||
SharedString::from(format!("{}-starting", context_server_id.0)),
|
||||
3,
|
||||
)
|
||||
.into_any_element(),
|
||||
"Server is starting.",
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use context_server::{ContextServerCommand, ContextServerId};
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AsyncWindowContext, DismissEvent, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle,
|
||||
WeakEntity, percentage, prelude::*,
|
||||
AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task,
|
||||
TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
|
||||
};
|
||||
use language::{Language, LanguageRegistry};
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
|
||||
@@ -24,7 +22,9 @@ use project::{
|
||||
};
|
||||
use settings::{Settings as _, update_settings_file};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
|
||||
use ui::{
|
||||
CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
use workspace::{ModalView, Workspace};
|
||||
|
||||
@@ -638,11 +638,7 @@ impl ConfigureContextServerModal {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Info)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -14,9 +14,8 @@ use editor::{
|
||||
scroll::Autoscroll,
|
||||
};
|
||||
use gpui::{
|
||||
Action, Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Empty, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, Global, SharedString, Subscription, Task, Transformation,
|
||||
WeakEntity, Window, percentage, prelude::*,
|
||||
Action, AnyElement, AnyView, App, AppContext, Empty, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*,
|
||||
};
|
||||
|
||||
use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point};
|
||||
@@ -29,9 +28,8 @@ use std::{
|
||||
collections::hash_map::Entry,
|
||||
ops::Range,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use ui::{IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider};
|
||||
use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider};
|
||||
use util::ResultExt;
|
||||
use workspace::{
|
||||
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
|
||||
@@ -1084,11 +1082,7 @@ impl Render for AgentDiffToolbar {
|
||||
Icon::new(IconName::LoadCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Accent)
|
||||
.with_animation(
|
||||
"load_circle",
|
||||
Animation::new(Duration::from_secs(3)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
),
|
||||
.with_rotate_animation(3),
|
||||
)
|
||||
.into_any();
|
||||
|
||||
@@ -1523,7 +1517,10 @@ impl AgentDiff {
|
||||
self.update_reviewing_editors(workspace, window, cx);
|
||||
}
|
||||
}
|
||||
AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) => {
|
||||
AcpThreadEvent::Stopped
|
||||
| AcpThreadEvent::Error
|
||||
| AcpThreadEvent::LoadError(_)
|
||||
| AcpThreadEvent::Refusal => {
|
||||
self.update_reviewing_editors(workspace, window, cx);
|
||||
}
|
||||
AcpThreadEvent::TitleUpdated
|
||||
|
||||
@@ -10,11 +10,11 @@ use agent2::{DbThreadMetadata, HistoryEntry};
|
||||
use db::kvp::{Dismissable, KEY_VALUE_STORE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zed_actions::OpenBrowser;
|
||||
use zed_actions::agent::ReauthenticateAgent;
|
||||
use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
|
||||
|
||||
use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
|
||||
use crate::agent_diff::AgentDiffThread;
|
||||
use crate::ui::AcpOnboardingModal;
|
||||
use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
|
||||
use crate::{
|
||||
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
|
||||
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
|
||||
@@ -207,6 +207,9 @@ pub fn init(cx: &mut App) {
|
||||
.register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
|
||||
AcpOnboardingModal::toggle(workspace, window, cx)
|
||||
})
|
||||
.register_action(|workspace, _: &OpenClaudeCodeOnboardingModal, window, cx| {
|
||||
ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
|
||||
})
|
||||
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
|
||||
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
|
||||
window.refresh();
|
||||
@@ -1091,6 +1094,7 @@ impl AgentPanel {
|
||||
let workspace = self.workspace.clone();
|
||||
let project = self.project.clone();
|
||||
let fs = self.fs.clone();
|
||||
let is_not_local = !self.project.read(cx).is_local();
|
||||
|
||||
const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
|
||||
|
||||
@@ -1122,17 +1126,21 @@ impl AgentPanel {
|
||||
agent
|
||||
}
|
||||
None => {
|
||||
cx.background_spawn(async move {
|
||||
KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
|
||||
})
|
||||
.await
|
||||
.log_err()
|
||||
.flatten()
|
||||
.and_then(|value| {
|
||||
serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.agent
|
||||
if is_not_local {
|
||||
ExternalAgent::NativeAgent
|
||||
} else {
|
||||
cx.background_spawn(async move {
|
||||
KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
|
||||
})
|
||||
.await
|
||||
.log_err()
|
||||
.flatten()
|
||||
.and_then(|value| {
|
||||
serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.agent
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1911,13 +1919,17 @@ impl AgentPanel {
|
||||
AgentType::Gemini => {
|
||||
self.external_thread(Some(crate::ExternalAgent::Gemini), None, None, window, cx)
|
||||
}
|
||||
AgentType::ClaudeCode => self.external_thread(
|
||||
Some(crate::ExternalAgent::ClaudeCode),
|
||||
None,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
AgentType::ClaudeCode => {
|
||||
self.selected_agent = AgentType::ClaudeCode;
|
||||
self.serialize(cx);
|
||||
self.external_thread(
|
||||
Some(crate::ExternalAgent::ClaudeCode),
|
||||
None,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
AgentType::Custom { name, command } => self.external_thread(
|
||||
Some(crate::ExternalAgent::Custom { name, command }),
|
||||
None,
|
||||
@@ -2523,6 +2535,9 @@ impl AgentPanel {
|
||||
.with_handle(self.new_thread_menu_handle.clone())
|
||||
.menu({
|
||||
let workspace = self.workspace.clone();
|
||||
let is_not_local = workspace
|
||||
.update(cx, |workspace, cx| !workspace.project().read(cx).is_local())
|
||||
.unwrap_or_default();
|
||||
|
||||
move |window, cx| {
|
||||
telemetry::event!("New Thread Clicked");
|
||||
@@ -2613,6 +2628,7 @@ impl AgentPanel {
|
||||
ContextMenuEntry::new("New Gemini CLI Thread")
|
||||
.icon(IconName::AiGemini)
|
||||
.icon_color(Color::Muted)
|
||||
.disabled(is_not_local)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
move |window, cx| {
|
||||
@@ -2639,6 +2655,7 @@ impl AgentPanel {
|
||||
menu.item(
|
||||
ContextMenuEntry::new("New Claude Code Thread")
|
||||
.icon(IconName::AiClaude)
|
||||
.disabled(is_not_local)
|
||||
.icon_color(Color::Muted)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
@@ -2671,6 +2688,7 @@ impl AgentPanel {
|
||||
ContextMenuEntry::new(format!("New {} Thread", agent_name))
|
||||
.icon(IconName::Terminal)
|
||||
.icon_color(Color::Muted)
|
||||
.disabled(is_not_local)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
let agent_name = agent_name.clone();
|
||||
@@ -2949,6 +2967,20 @@ impl AgentPanel {
|
||||
return false;
|
||||
}
|
||||
|
||||
let user_store = self.user_store.read(cx);
|
||||
|
||||
if user_store
|
||||
.plan()
|
||||
.is_some_and(|plan| matches!(plan, Plan::ZedPro))
|
||||
&& user_store
|
||||
.subscription_period()
|
||||
.and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
|
||||
.is_some_and(|date| date < chrono::Utc::now())
|
||||
{
|
||||
OnboardingUpsell::set_dismissed(true, cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
match &self.active_view {
|
||||
ActiveView::History | ActiveView::Configuration => false,
|
||||
ActiveView::ExternalAgentThread { thread_view, .. }
|
||||
@@ -3517,6 +3549,11 @@ impl AgentPanel {
|
||||
) -> AnyElement {
|
||||
let message_with_header = format!("{}\n{}", header, message);
|
||||
|
||||
// Don't show Retry button for refusals
|
||||
let is_refusal = header == "Request Refused";
|
||||
let retry_button = self.render_retry_button(thread);
|
||||
let copy_button = self.create_copy_button(message_with_header);
|
||||
|
||||
Callout::new()
|
||||
.severity(Severity::Error)
|
||||
.icon(IconName::XCircle)
|
||||
@@ -3525,8 +3562,8 @@ impl AgentPanel {
|
||||
.actions_slot(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(self.render_retry_button(thread))
|
||||
.child(self.create_copy_button(message_with_header)),
|
||||
.when(!is_refusal, |this| this.child(retry_button))
|
||||
.child(copy_button),
|
||||
)
|
||||
.dismiss_action(self.dismiss_error_button(thread, cx))
|
||||
.into_any_element()
|
||||
|
||||
@@ -13,7 +13,10 @@ use http_client::HttpClientWithUrl;
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, CodeLabel, HighlightId};
|
||||
use lsp::CompletionContext;
|
||||
use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId};
|
||||
use project::{
|
||||
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, ProjectPath,
|
||||
Symbol, WorktreeId,
|
||||
};
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
use text::{Anchor, OffsetRangeExt, ToPoint};
|
||||
@@ -897,6 +900,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
|
||||
Ok(vec![CompletionResponse {
|
||||
completions,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
// Since this does its own filtering (see `filter_completions()` returns false),
|
||||
// there is no benefit to computing whether this set of completions is incomplete.
|
||||
is_incomplete: true,
|
||||
|
||||
@@ -144,7 +144,8 @@ impl InlineAssistant {
|
||||
let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
let enabled = AgentSettings::get_global(cx).enabled;
|
||||
let enabled = !DisableAiSettings::get_global(cx).disable_ai
|
||||
&& AgentSettings::get_global(cx).enabled;
|
||||
terminal_panel.update(cx, |terminal_panel, cx| {
|
||||
terminal_panel.set_assistant_enabled(enabled, cx)
|
||||
});
|
||||
|
||||
@@ -93,8 +93,8 @@ impl<T: 'static> Render for PromptEditor<T> {
|
||||
};
|
||||
|
||||
let bottom_padding = match &self.mode {
|
||||
PromptEditorMode::Buffer { .. } => Pixels::from(0.),
|
||||
PromptEditorMode::Terminal { .. } => Pixels::from(8.0),
|
||||
PromptEditorMode::Buffer { .. } => rems_from_px(2.0),
|
||||
PromptEditorMode::Terminal { .. } => rems_from_px(8.0),
|
||||
};
|
||||
|
||||
buttons.extend(self.render_buttons(window, cx));
|
||||
@@ -762,20 +762,22 @@ impl<T: 'static> PromptEditor<T> {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let font_size = TextSize::Default.rems(cx);
|
||||
let line_height = font_size.to_pixels(window.rem_size()) * 1.3;
|
||||
fn render_editor(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let colors = cx.theme().colors();
|
||||
|
||||
div()
|
||||
.key_context("InlineAssistEditor")
|
||||
.size_full()
|
||||
.p_2()
|
||||
.pl_1()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.bg(colors.editor_background)
|
||||
.child({
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let font_size = settings.buffer_font_size(cx);
|
||||
let line_height = font_size * 1.2;
|
||||
|
||||
let text_style = TextStyle {
|
||||
color: cx.theme().colors().editor_foreground,
|
||||
color: colors.editor_foreground,
|
||||
font_family: settings.buffer_font.family.clone(),
|
||||
font_features: settings.buffer_font.features.clone(),
|
||||
font_size: font_size.into(),
|
||||
@@ -786,7 +788,7 @@ impl<T: 'static> PromptEditor<T> {
|
||||
EditorElement::new(
|
||||
&self.editor,
|
||||
EditorStyle {
|
||||
background: cx.theme().colors().editor_background,
|
||||
background: colors.editor_background,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
..Default::default()
|
||||
|
||||
@@ -7,7 +7,10 @@ use fuzzy::{StringMatchCandidate, match_strings};
|
||||
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window};
|
||||
use language::{Anchor, Buffer, ToPoint};
|
||||
use parking_lot::Mutex;
|
||||
use project::{CompletionIntent, CompletionSource, lsp_store::CompletionDocumentation};
|
||||
use project::{
|
||||
CompletionDisplayOptions, CompletionIntent, CompletionSource,
|
||||
lsp_store::CompletionDocumentation,
|
||||
};
|
||||
use rope::Point;
|
||||
use std::{
|
||||
ops::Range,
|
||||
@@ -133,6 +136,7 @@ impl SlashCommandCompletionProvider {
|
||||
|
||||
vec![project::CompletionResponse {
|
||||
completions,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: false,
|
||||
}]
|
||||
})
|
||||
@@ -237,6 +241,7 @@ impl SlashCommandCompletionProvider {
|
||||
|
||||
Ok(vec![project::CompletionResponse {
|
||||
completions,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
// TODO: Could have slash commands indicate whether their completions are incomplete.
|
||||
is_incomplete: true,
|
||||
}])
|
||||
@@ -244,6 +249,7 @@ impl SlashCommandCompletionProvider {
|
||||
} else {
|
||||
Task::ready(Ok(vec![project::CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: true,
|
||||
}]))
|
||||
}
|
||||
@@ -305,6 +311,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
|
||||
else {
|
||||
return Task::ready(Ok(vec![project::CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: false,
|
||||
}]));
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ use gpui::{
|
||||
Action, Animation, AnimationExt, AnyElement, AnyView, App, ClipboardEntry, ClipboardItem,
|
||||
Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement,
|
||||
IntoElement, ParentElement, Pixels, Render, RenderImage, SharedString, Size,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, Transformation, WeakEntity, actions,
|
||||
div, img, percentage, point, prelude::*, pulsating_between, size,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, actions, div, img, point,
|
||||
prelude::*, pulsating_between, size,
|
||||
};
|
||||
use language::{
|
||||
BufferSnapshot, LspAdapterDelegate, ToOffset,
|
||||
@@ -53,8 +53,8 @@ use std::{
|
||||
};
|
||||
use text::SelectionGoal;
|
||||
use ui::{
|
||||
ButtonLike, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, TintColor, Tooltip,
|
||||
prelude::*,
|
||||
ButtonLike, CommonAnimationExt, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle,
|
||||
TintColor, Tooltip, prelude::*,
|
||||
};
|
||||
use util::{ResultExt, maybe};
|
||||
use workspace::{
|
||||
@@ -1061,15 +1061,7 @@ impl TextThreadEditor {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Info)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(
|
||||
percentage(delta),
|
||||
))
|
||||
},
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
);
|
||||
note = Some(Self::esc_kbd(cx).into_any_element());
|
||||
@@ -2790,11 +2782,7 @@ fn invoked_slash_command_fold_placeholder(
|
||||
.child(Label::new(format!("/{}", command.name)))
|
||||
.map(|parent| match &command.status {
|
||||
InvokedSlashCommandStatus::Running(_) => {
|
||||
parent.child(Icon::new(IconName::ArrowCircle).with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(4)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
))
|
||||
parent.child(Icon::new(IconName::ArrowCircle).with_rotate_animation(4))
|
||||
}
|
||||
InvokedSlashCommandStatus::Error(message) => parent.child(
|
||||
Label::new(format!("error: {message}"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod acp_onboarding_modal;
|
||||
mod agent_notification;
|
||||
mod burn_mode_tooltip;
|
||||
mod claude_code_onboarding_modal;
|
||||
mod context_pill;
|
||||
mod end_trial_upsell;
|
||||
mod onboarding_modal;
|
||||
@@ -10,6 +11,7 @@ mod unavailable_editing_tooltip;
|
||||
pub use acp_onboarding_modal::*;
|
||||
pub use agent_notification::*;
|
||||
pub use burn_mode_tooltip::*;
|
||||
pub use claude_code_onboarding_modal::*;
|
||||
pub use context_pill::*;
|
||||
pub use end_trial_upsell::*;
|
||||
pub use onboarding_modal::*;
|
||||
|
||||
@@ -141,20 +141,12 @@ impl Render for AcpOnboardingModal {
|
||||
.bg(gpui::black().opacity(0.15)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.))
|
||||
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
|
||||
)
|
||||
.child(
|
||||
Vector::new(
|
||||
VectorName::AcpLogoSerif,
|
||||
rems_from_px(111.),
|
||||
rems_from_px(41.),
|
||||
)
|
||||
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
|
||||
),
|
||||
Vector::new(
|
||||
VectorName::AcpLogoSerif,
|
||||
rems_from_px(257.),
|
||||
rems_from_px(47.),
|
||||
)
|
||||
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
|
||||
254
crates/agent_ui/src/ui/claude_code_onboarding_modal.rs
Normal file
254
crates/agent_ui/src/ui/claude_code_onboarding_modal.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use client::zed_urls;
|
||||
use gpui::{
|
||||
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
|
||||
linear_color_stop, linear_gradient,
|
||||
};
|
||||
use ui::{TintColor, Vector, VectorName, prelude::*};
|
||||
use workspace::{ModalView, Workspace};
|
||||
|
||||
use crate::agent_panel::{AgentPanel, AgentType};
|
||||
|
||||
macro_rules! claude_code_onboarding_event {
|
||||
($name:expr) => {
|
||||
telemetry::event!($name, source = "ACP Claude Code Onboarding");
|
||||
};
|
||||
($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
|
||||
telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+);
|
||||
};
|
||||
}
|
||||
|
||||
pub struct ClaudeCodeOnboardingModal {
|
||||
focus_handle: FocusHandle,
|
||||
workspace: Entity<Workspace>,
|
||||
}
|
||||
|
||||
impl ClaudeCodeOnboardingModal {
|
||||
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
|
||||
let workspace_entity = cx.entity();
|
||||
workspace.toggle_modal(window, cx, |_window, cx| Self {
|
||||
workspace: workspace_entity,
|
||||
focus_handle: cx.focus_handle(),
|
||||
});
|
||||
}
|
||||
|
||||
fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.workspace.update(cx, |workspace, cx| {
|
||||
workspace.focus_panel::<AgentPanel>(window, cx);
|
||||
|
||||
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(AgentType::ClaudeCode, window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
|
||||
claude_code_onboarding_event!("Open Panel Clicked");
|
||||
}
|
||||
|
||||
fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.open_url(&zed_urls::external_agents_docs(cx));
|
||||
cx.notify();
|
||||
|
||||
claude_code_onboarding_event!("Documentation Link Clicked");
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for ClaudeCodeOnboardingModal {}
|
||||
|
||||
impl Focusable for ClaudeCodeOnboardingModal {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for ClaudeCodeOnboardingModal {}
|
||||
|
||||
impl Render for ClaudeCodeOnboardingModal {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let illustration_element = |icon: IconName, label: Option<SharedString>, opacity: f32| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.gap_1()
|
||||
.rounded_sm()
|
||||
.bg(cx.theme().colors().element_active.opacity(0.05))
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_dashed()
|
||||
.child(
|
||||
Icon::new(icon)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
|
||||
)
|
||||
.map(|this| {
|
||||
if let Some(label_text) = label {
|
||||
this.child(
|
||||
Label::new(label_text)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div().w_16().h_1().rounded_full().bg(cx
|
||||
.theme()
|
||||
.colors()
|
||||
.element_active
|
||||
.opacity(0.6)),
|
||||
)
|
||||
}
|
||||
})
|
||||
.opacity(opacity)
|
||||
};
|
||||
|
||||
let illustration = h_flex()
|
||||
.relative()
|
||||
.h(rems_from_px(126.))
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.justify_center()
|
||||
.gap_8()
|
||||
.rounded_t_md()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
|
||||
Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
|
||||
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
|
||||
),
|
||||
)
|
||||
.child(div().absolute().inset_0().size_full().bg(linear_gradient(
|
||||
0.,
|
||||
linear_color_stop(
|
||||
cx.theme().colors().elevated_surface_background.opacity(0.1),
|
||||
0.9,
|
||||
),
|
||||
linear_color_stop(
|
||||
cx.theme().colors().elevated_surface_background.opacity(0.),
|
||||
0.,
|
||||
),
|
||||
)))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.size_full()
|
||||
.bg(gpui::black().opacity(0.15)),
|
||||
)
|
||||
.child(
|
||||
Vector::new(
|
||||
VectorName::AcpLogoSerif,
|
||||
rems_from_px(257.),
|
||||
rems_from_px(47.),
|
||||
)
|
||||
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(illustration_element(IconName::Stop, None, 0.15))
|
||||
.child(illustration_element(
|
||||
IconName::AiGemini,
|
||||
Some("New Gemini CLI Thread".into()),
|
||||
0.3,
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.pl_1()
|
||||
.pr_2()
|
||||
.py_0p5()
|
||||
.gap_1()
|
||||
.rounded_sm()
|
||||
.bg(cx.theme().colors().element_active.opacity(0.2))
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(
|
||||
Icon::new(IconName::AiClaude)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new("New Claude Code Thread").size(LabelSize::Small)),
|
||||
)
|
||||
.child(illustration_element(
|
||||
IconName::Stop,
|
||||
Some("Your Agent Here".into()),
|
||||
0.3,
|
||||
))
|
||||
.child(illustration_element(IconName::Stop, None, 0.15)),
|
||||
);
|
||||
|
||||
let heading = v_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new("Beta Release")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Headline::new("Claude Code: Natively in Zed").size(HeadlineSize::Large));
|
||||
|
||||
let copy = "Powered by the Agent Client Protocol, you can now run Claude Code as\na first-class citizen in Zed's agent panel.";
|
||||
|
||||
let open_panel_button = Button::new("open-panel", "Start with Claude Code")
|
||||
.icon_size(IconSize::Indicator)
|
||||
.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.full_width()
|
||||
.on_click(cx.listener(Self::open_panel));
|
||||
|
||||
let docs_button = Button::new("add-other-agents", "Add Other Agents")
|
||||
.icon(IconName::ArrowUpRight)
|
||||
.icon_size(IconSize::Indicator)
|
||||
.icon_color(Color::Muted)
|
||||
.full_width()
|
||||
.on_click(cx.listener(Self::view_docs));
|
||||
|
||||
let close_button = h_flex().absolute().top_2().right_2().child(
|
||||
IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|
||||
|_, _: &ClickEvent, _window, cx| {
|
||||
claude_code_onboarding_event!("Canceled", trigger = "X click");
|
||||
cx.emit(DismissEvent);
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
||||
v_flex()
|
||||
.id("acp-onboarding")
|
||||
.key_context("AcpOnboardingModal")
|
||||
.relative()
|
||||
.w(rems(34.))
|
||||
.h_full()
|
||||
.elevation_3(cx)
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.overflow_hidden()
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
|
||||
claude_code_onboarding_event!("Canceled", trigger = "Action");
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
|
||||
this.focus_handle.focus(window);
|
||||
}))
|
||||
.child(illustration)
|
||||
.child(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.child(heading)
|
||||
.child(Label::new(copy).color(Color::Muted))
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.child(open_panel_button)
|
||||
.child(docs_button),
|
||||
),
|
||||
)
|
||||
.child(close_button)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use cloud_llm_client::Plan;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, Entity, IntoElement, RenderOnce, Transformation,
|
||||
Window, percentage,
|
||||
};
|
||||
use ui::{Divider, Vector, VectorName, prelude::*};
|
||||
use gpui::{AnyElement, App, Entity, IntoElement, RenderOnce, Window};
|
||||
use ui::{CommonAnimationExt, Divider, Vector, VectorName, prelude::*};
|
||||
|
||||
use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions};
|
||||
|
||||
@@ -147,11 +144,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
rems_from_px(72.),
|
||||
)
|
||||
.color(Color::Custom(cx.theme().colors().text_accent.alpha(0.3)))
|
||||
.with_animation(
|
||||
"loading_stamp",
|
||||
Animation::new(Duration::from_secs(10)).repeat(),
|
||||
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
|
||||
),
|
||||
.with_rotate_animation(10),
|
||||
);
|
||||
|
||||
let pro_trial_stamp = div()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore};
|
||||
use cloud_llm_client::Plan;
|
||||
use gpui::{Entity, IntoElement, ParentElement};
|
||||
use ui::prelude::*;
|
||||
|
||||
@@ -35,6 +36,8 @@ impl EditPredictionOnboarding {
|
||||
|
||||
impl Render for EditPredictionOnboarding {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let is_free_plan = self.user_store.read(cx).plan() == Some(Plan::ZedFree);
|
||||
|
||||
let github_copilot = v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new(if self.copilot_is_configured {
|
||||
@@ -67,7 +70,8 @@ impl Render for EditPredictionOnboarding {
|
||||
self.continue_with_zed_ai.clone(),
|
||||
cx,
|
||||
))
|
||||
.child(ui::Divider::horizontal())
|
||||
.child(github_copilot)
|
||||
.when(is_free_plan, |this| {
|
||||
this.child(ui::Divider::horizontal()).child(github_copilot)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ pub struct YoungAccountBanner;
|
||||
|
||||
impl RenderOnce for YoungAccountBanner {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing-support@zed.dev.";
|
||||
const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, GitHub accounts created fewer than 30 days ago are not eligible for free plan usage or Pro plan free trial. To request an exception, reach out to billing-support@zed.dev.";
|
||||
|
||||
let label = div()
|
||||
.w_full()
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Tool for DeletePathTool {
|
||||
}
|
||||
|
||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
|
||||
false
|
||||
true
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
|
||||
@@ -17,7 +17,7 @@ use editor::{
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
|
||||
TextStyleRefinement, Transformation, WeakEntity, percentage, pulsating_between, px,
|
||||
TextStyleRefinement, WeakEntity, pulsating_between, px,
|
||||
};
|
||||
use indoc::formatdoc;
|
||||
use language::{
|
||||
@@ -44,7 +44,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{Disclosure, Tooltip, prelude::*};
|
||||
use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
|
||||
use util::ResultExt;
|
||||
use workspace::Workspace;
|
||||
|
||||
@@ -636,8 +636,11 @@ impl EditFileToolCard {
|
||||
// Create a buffer diff with the current text as the base
|
||||
let buffer_diff = cx.new(|cx| {
|
||||
let mut diff = BufferDiff::new(&text_snapshot, cx);
|
||||
let base_text = buffer_snapshot.text();
|
||||
let language = buffer_snapshot.language().cloned();
|
||||
let _ = diff.set_base_text(
|
||||
buffer_snapshot.clone(),
|
||||
Some(Arc::new(base_text)),
|
||||
language,
|
||||
language_registry,
|
||||
text_snapshot,
|
||||
cx,
|
||||
@@ -939,11 +942,7 @@ impl ToolCard for EditFileToolCard {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Info)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
),
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
})
|
||||
.when_some(error_message, |header, error_message| {
|
||||
|
||||
@@ -8,8 +8,8 @@ use anyhow::{Context as _, Result, anyhow};
|
||||
use assistant_tool::{Tool, ToolCard, ToolResult, ToolUseStatus};
|
||||
use futures::{FutureExt as _, future::Shared};
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
|
||||
TextStyleRefinement, Transformation, WeakEntity, Window, percentage,
|
||||
AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement,
|
||||
WeakEntity, Window,
|
||||
};
|
||||
use language::LineEnding;
|
||||
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
|
||||
@@ -28,7 +28,7 @@ use std::{
|
||||
};
|
||||
use terminal_view::TerminalView;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{Disclosure, Tooltip, prelude::*};
|
||||
use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
|
||||
use util::{
|
||||
ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
|
||||
time::duration_alt_display,
|
||||
@@ -522,11 +522,7 @@ impl ToolCard for TerminalToolCard {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Info)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
),
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
})
|
||||
.when(tool_failed || command_failed, |header| {
|
||||
|
||||
@@ -4,7 +4,7 @@ use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources, SettingsUi};
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
pub struct AudioSettings {
|
||||
/// Opt into the new audio system.
|
||||
#[serde(rename = "experimental.rodio_audio", default)]
|
||||
@@ -12,7 +12,7 @@ pub struct AudioSettings {
|
||||
}
|
||||
|
||||
/// Configuration of audio in Zed.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
#[serde(default)]
|
||||
pub struct AudioSettingsContent {
|
||||
/// Whether to use the experimental audio system
|
||||
|
||||
@@ -113,20 +113,19 @@ impl Drop for MacOsUnmounter {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(SettingsUi)]
|
||||
struct AutoUpdateSetting(bool);
|
||||
|
||||
/// Whether or not to automatically check for updates.
|
||||
///
|
||||
/// Default: true
|
||||
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)]
|
||||
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize, SettingsUi)]
|
||||
#[serde(transparent)]
|
||||
struct AutoUpdateSettingContent(bool);
|
||||
|
||||
impl Settings for AutoUpdateSetting {
|
||||
const KEY: Option<&'static str> = Some("auto_update");
|
||||
|
||||
type FileContent = Option<AutoUpdateSettingContent>;
|
||||
type FileContent = AutoUpdateSettingContent;
|
||||
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
|
||||
let auto_update = [
|
||||
@@ -136,17 +135,19 @@ impl Settings for AutoUpdateSetting {
|
||||
sources.user,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|value| value.copied().flatten())
|
||||
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
|
||||
.find_map(|value| value.copied())
|
||||
.unwrap_or(*sources.default);
|
||||
|
||||
Ok(Self(auto_update.0))
|
||||
}
|
||||
|
||||
fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
|
||||
vscode.enum_setting("update.mode", current, |s| match s {
|
||||
let mut cur = &mut Some(*current);
|
||||
vscode.enum_setting("update.mode", &mut cur, |s| match s {
|
||||
"none" | "manual" => Some(AutoUpdateSettingContent(false)),
|
||||
_ => Some(AutoUpdateSettingContent(true)),
|
||||
});
|
||||
*current = cur.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1158,34 +1158,22 @@ impl BufferDiff {
|
||||
self.hunks_intersecting_range(start..end, buffer, cx)
|
||||
}
|
||||
|
||||
pub fn set_base_text_buffer(
|
||||
&mut self,
|
||||
base_buffer: Entity<language::Buffer>,
|
||||
buffer: text::BufferSnapshot,
|
||||
cx: &mut Context<Self>,
|
||||
) -> oneshot::Receiver<()> {
|
||||
let base_buffer = base_buffer.read(cx);
|
||||
let language_registry = base_buffer.language_registry();
|
||||
let base_buffer = base_buffer.snapshot();
|
||||
self.set_base_text(base_buffer, language_registry, buffer, cx)
|
||||
}
|
||||
|
||||
/// Used in cases where the change set isn't derived from git.
|
||||
pub fn set_base_text(
|
||||
&mut self,
|
||||
base_buffer: language::BufferSnapshot,
|
||||
base_text: Option<Arc<String>>,
|
||||
language: Option<Arc<Language>>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
buffer: text::BufferSnapshot,
|
||||
cx: &mut Context<Self>,
|
||||
) -> oneshot::Receiver<()> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let this = cx.weak_entity();
|
||||
let base_text = Arc::new(base_buffer.text());
|
||||
|
||||
let snapshot = BufferDiffSnapshot::new_with_base_text(
|
||||
buffer.clone(),
|
||||
Some(base_text),
|
||||
base_buffer.language().cloned(),
|
||||
base_text,
|
||||
language,
|
||||
language_registry,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -4,14 +4,14 @@ use schemars::JsonSchema;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources, SettingsUi};
|
||||
|
||||
#[derive(Deserialize, Debug, SettingsUi)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CallSettings {
|
||||
pub mute_on_join: bool,
|
||||
pub share_on_join: bool,
|
||||
}
|
||||
|
||||
/// Configuration of voice calls in Zed.
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
pub struct CallSettingsContent {
|
||||
/// Whether the microphone should be muted when joining a channel or a call.
|
||||
///
|
||||
|
||||
@@ -84,13 +84,16 @@ struct Args {
|
||||
/// Run zed in dev-server mode
|
||||
#[arg(long)]
|
||||
dev_server_token: Option<String>,
|
||||
/// The username and WSL distribution to use when opening paths. ,If not specified,
|
||||
/// The username and WSL distribution to use when opening paths. If not specified,
|
||||
/// Zed will attempt to open the paths directly.
|
||||
///
|
||||
/// The username is optional, and if not specified, the default user for the distribution
|
||||
/// will be used.
|
||||
///
|
||||
/// Example: `me@Ubuntu` or `Ubuntu` for default distribution.
|
||||
/// Example: `me@Ubuntu` or `Ubuntu`.
|
||||
///
|
||||
/// WARN: You should not fill in this field by hand.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[arg(long, value_name = "USER@DISTRO")]
|
||||
wsl: Option<String>,
|
||||
/// Not supported in Zed CLI, only supported on Zed binary
|
||||
@@ -301,6 +304,11 @@ fn main() -> Result<()> {
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let wsl = args.wsl.as_ref();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let wsl = None;
|
||||
|
||||
for path in args.paths_with_position.iter() {
|
||||
if path.starts_with("zed://")
|
||||
|| path.starts_with("http://")
|
||||
@@ -319,7 +327,7 @@ fn main() -> Result<()> {
|
||||
paths.push(tmp_file.path().to_string_lossy().to_string());
|
||||
let (tmp_file, _) = tmp_file.keep()?;
|
||||
anonymous_fd_tmp_files.push((file, tmp_file));
|
||||
} else if let Some(wsl) = &args.wsl {
|
||||
} else if let Some(wsl) = wsl {
|
||||
urls.push(format!("file://{}", parse_path_in_wsl(path, wsl)?));
|
||||
} else {
|
||||
paths.push(parse_path_with_position(path)?);
|
||||
@@ -338,11 +346,16 @@ fn main() -> Result<()> {
|
||||
let (_, handshake) = server.accept().context("Handshake after Zed spawn")?;
|
||||
let (tx, rx) = (handshake.requests, handshake.responses);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let wsl = args.wsl;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let wsl = None;
|
||||
|
||||
tx.send(CliRequest::Open {
|
||||
paths,
|
||||
urls,
|
||||
diff_paths,
|
||||
wsl: args.wsl,
|
||||
wsl,
|
||||
wait: args.wait,
|
||||
open_new_workspace,
|
||||
env,
|
||||
|
||||
@@ -96,12 +96,12 @@ actions!(
|
||||
]
|
||||
);
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, SettingsUi)]
|
||||
pub struct ClientSettingsContent {
|
||||
server_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, SettingsUi)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClientSettings {
|
||||
pub server_url: String,
|
||||
}
|
||||
@@ -122,12 +122,12 @@ impl Settings for ClientSettings {
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema, SettingsUi)]
|
||||
pub struct ProxySettingsContent {
|
||||
proxy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, SettingsUi)]
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ProxySettings {
|
||||
pub proxy: Option<String>,
|
||||
}
|
||||
@@ -520,14 +520,14 @@ impl<T: 'static> Drop for PendingEntitySubscription<T> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Deserialize, Debug, SettingsUi)]
|
||||
#[derive(Copy, Clone, Deserialize, Debug)]
|
||||
pub struct TelemetrySettings {
|
||||
pub diagnostics: bool,
|
||||
pub metrics: bool,
|
||||
}
|
||||
|
||||
/// Control what info is collected by Zed.
|
||||
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
pub struct TelemetrySettingsContent {
|
||||
/// Send debug info like crash reports.
|
||||
///
|
||||
|
||||
@@ -3425,16 +3425,16 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
Some(blame_entry("1b1b1b", 0..1)),
|
||||
Some(blame_entry("0d0d0d", 1..2)),
|
||||
Some(blame_entry("3a3a3a", 2..3)),
|
||||
Some(blame_entry("4c4c4c", 3..4)),
|
||||
Some((buffer_id_b, blame_entry("1b1b1b", 0..1))),
|
||||
Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
|
||||
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
|
||||
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
|
||||
]
|
||||
);
|
||||
|
||||
blame.update(cx, |blame, _| {
|
||||
for (idx, entry) in entries.iter().flatten().enumerate() {
|
||||
let details = blame.details_for_entry(entry).unwrap();
|
||||
for (idx, (buffer, entry)) in entries.iter().flatten().enumerate() {
|
||||
let details = blame.details_for_entry(*buffer, entry).unwrap();
|
||||
assert_eq!(details.message, format!("message for idx-{}", idx));
|
||||
assert_eq!(
|
||||
details.permalink.unwrap().to_string(),
|
||||
@@ -3474,9 +3474,9 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
entries,
|
||||
vec![
|
||||
None,
|
||||
Some(blame_entry("0d0d0d", 1..2)),
|
||||
Some(blame_entry("3a3a3a", 2..3)),
|
||||
Some(blame_entry("4c4c4c", 3..4)),
|
||||
Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
|
||||
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
|
||||
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -3511,8 +3511,8 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
vec![
|
||||
None,
|
||||
None,
|
||||
Some(blame_entry("3a3a3a", 2..3)),
|
||||
Some(blame_entry("4c4c4c", 3..4)),
|
||||
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
|
||||
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ use language::{
|
||||
Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset,
|
||||
language_settings::SoftWrap,
|
||||
};
|
||||
use project::{Completion, CompletionResponse, CompletionSource, search::SearchQuery};
|
||||
use project::{
|
||||
Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, search::SearchQuery,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
ops::Range,
|
||||
@@ -275,6 +277,7 @@ impl MessageEditor {
|
||||
|
||||
Task::ready(Ok(vec![CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: false,
|
||||
}]))
|
||||
}
|
||||
@@ -317,6 +320,7 @@ impl MessageEditor {
|
||||
|
||||
CompletionResponse {
|
||||
is_incomplete: completions.len() >= LIMIT,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
completions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources, SettingsUi};
|
||||
use workspace::dock::DockPosition;
|
||||
|
||||
#[derive(Deserialize, Debug, SettingsUi)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CollaborationPanelSettings {
|
||||
pub button: bool,
|
||||
pub dock: DockPosition,
|
||||
@@ -20,14 +20,14 @@ pub enum ChatPanelButton {
|
||||
WhenInCall,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, SettingsUi)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ChatPanelSettings {
|
||||
pub button: ChatPanelButton,
|
||||
pub dock: DockPosition,
|
||||
pub default_width: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
pub struct ChatPanelSettingsContent {
|
||||
/// When to show the panel button in the status bar.
|
||||
///
|
||||
@@ -43,14 +43,14 @@ pub struct ChatPanelSettingsContent {
|
||||
pub default_width: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, SettingsUi)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct NotificationPanelSettings {
|
||||
pub button: bool,
|
||||
pub dock: DockPosition,
|
||||
pub default_width: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
pub struct PanelSettingsContent {
|
||||
/// Whether to show the panel button in the status bar.
|
||||
///
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
};
|
||||
|
||||
const JSON_RPC_VERSION: &str = "2.0";
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
// Standard JSON-RPC error codes
|
||||
pub const PARSE_ERROR: i32 = -32700;
|
||||
@@ -60,6 +60,7 @@ pub(crate) struct Client {
|
||||
executor: BackgroundExecutor,
|
||||
#[allow(dead_code)]
|
||||
transport: Arc<dyn Transport>,
|
||||
request_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -143,6 +144,7 @@ pub struct ModelContextServerBinary {
|
||||
pub executable: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
@@ -169,8 +171,9 @@ impl Client {
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(String::new);
|
||||
|
||||
let timeout = binary.timeout.map(Duration::from_millis);
|
||||
let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?);
|
||||
Self::new(server_id, server_name.into(), transport, cx)
|
||||
Self::new(server_id, server_name.into(), transport, timeout, cx)
|
||||
}
|
||||
|
||||
/// Creates a new Client instance for a context server.
|
||||
@@ -178,6 +181,7 @@ impl Client {
|
||||
server_id: ContextServerId,
|
||||
server_name: Arc<str>,
|
||||
transport: Arc<dyn Transport>,
|
||||
request_timeout: Option<Duration>,
|
||||
cx: AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let (outbound_tx, outbound_rx) = channel::unbounded::<String>();
|
||||
@@ -237,6 +241,7 @@ impl Client {
|
||||
io_tasks: Mutex::new(Some((input_task, output_task))),
|
||||
output_done_rx: Mutex::new(Some(output_done_rx)),
|
||||
transport,
|
||||
request_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -327,8 +332,13 @@ impl Client {
|
||||
method: &str,
|
||||
params: impl Serialize,
|
||||
) -> Result<T> {
|
||||
self.request_with(method, params, None, Some(REQUEST_TIMEOUT))
|
||||
.await
|
||||
self.request_with(
|
||||
method,
|
||||
params,
|
||||
None,
|
||||
self.request_timeout.or(Some(DEFAULT_REQUEST_TIMEOUT)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn request_with<T: DeserializeOwned>(
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct ContextServerCommand {
|
||||
pub path: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
/// Timeout for tool calls in milliseconds. Defaults to 60000 (60 seconds) if not specified.
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ContextServerCommand {
|
||||
@@ -123,6 +125,7 @@ impl ContextServer {
|
||||
executable: Path::new(&command.path).to_path_buf(),
|
||||
args: command.args.clone(),
|
||||
env: command.env.clone(),
|
||||
timeout: command.timeout,
|
||||
},
|
||||
working_directory,
|
||||
cx.clone(),
|
||||
@@ -131,6 +134,7 @@ impl ContextServer {
|
||||
client::ContextServerId(self.id.0.clone()),
|
||||
self.id().0,
|
||||
transport.clone(),
|
||||
None,
|
||||
cx.clone(),
|
||||
)?,
|
||||
})
|
||||
|
||||
@@ -164,6 +164,8 @@ pub enum ModelVendor {
|
||||
OpenAI,
|
||||
Google,
|
||||
Anthropic,
|
||||
#[serde(rename = "xAI")]
|
||||
XAI,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum DebugPanelDockPosition {
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, SettingsUi)]
|
||||
#[serde(default)]
|
||||
// todo(settings_ui) @ben: I'm pretty sure not having the fields be optional here is a bug,
|
||||
// it means the defaults will override previously set values if a single key is missing
|
||||
#[settings_ui(group = "Debugger", path = "debugger")]
|
||||
pub struct DebuggerSettings {
|
||||
/// Determines the stepping granularity.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
use std::rc::Rc;
|
||||
|
||||
use collections::HashMap;
|
||||
use gpui::{Animation, AnimationExt as _, Entity, Transformation, WeakEntity, percentage};
|
||||
use gpui::{Entity, WeakEntity};
|
||||
use project::debugger::session::{ThreadId, ThreadStatus};
|
||||
use ui::{ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
|
||||
use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
|
||||
use util::{maybe, truncate_and_trailoff};
|
||||
|
||||
use crate::{
|
||||
@@ -152,11 +152,7 @@ impl DebugPanel {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element()
|
||||
} else {
|
||||
match running_state.thread_status(cx).unwrap_or_default() {
|
||||
|
||||
@@ -15,7 +15,7 @@ use gpui::{
|
||||
use language::{Anchor, Buffer, CodeLabel, TextBufferSnapshot, ToOffset};
|
||||
use menu::{Confirm, SelectNext, SelectPrevious};
|
||||
use project::{
|
||||
Completion, CompletionResponse,
|
||||
Completion, CompletionDisplayOptions, CompletionResponse,
|
||||
debugger::session::{CompletionsQuery, OutputToken, Session},
|
||||
lsp_store::CompletionDocumentation,
|
||||
search_history::{SearchHistory, SearchHistoryCursor},
|
||||
@@ -685,6 +685,7 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
|
||||
Ok(vec![project::CompletionResponse {
|
||||
is_incomplete: completions.len() >= LIMIT,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
completions,
|
||||
}])
|
||||
})
|
||||
@@ -797,6 +798,7 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
|
||||
Ok(vec![project::CompletionResponse {
|
||||
completions,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: false,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
pub const DEEPSEEK_API_URL: &str = "https://api.deepseek.com";
|
||||
pub const DEEPSEEK_API_URL: &str = "https://api.deepseek.com/v1";
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -263,7 +263,7 @@ pub async fn stream_completion(
|
||||
api_key: &str,
|
||||
request: Request,
|
||||
) -> Result<BoxStream<'static, Result<StreamResponse>>> {
|
||||
let uri = format!("{api_url}/v1/chat/completions");
|
||||
let uri = format!("{api_url}/chat/completions");
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
|
||||
@@ -228,21 +228,29 @@ pub struct ShowCompletions {
|
||||
pub struct HandleInput(pub String);
|
||||
|
||||
/// Deletes from the cursor to the end of the next word.
|
||||
/// Stops before the end of the next word, if whitespace sequences of length >= 2 are encountered.
|
||||
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
|
||||
#[action(namespace = editor)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteToNextWordEnd {
|
||||
#[serde(default)]
|
||||
pub ignore_newlines: bool,
|
||||
// Whether to stop before the end of the next word, if language-defined bracket is encountered.
|
||||
#[serde(default)]
|
||||
pub ignore_brackets: bool,
|
||||
}
|
||||
|
||||
/// Deletes from the cursor to the start of the previous word.
|
||||
/// Stops before the start of the previous word, if whitespace sequences of length >= 2 are encountered.
|
||||
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
|
||||
#[action(namespace = editor)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteToPreviousWordStart {
|
||||
#[serde(default)]
|
||||
pub ignore_newlines: bool,
|
||||
// Whether to stop before the start of the previous word, if language-defined bracket is encountered.
|
||||
#[serde(default)]
|
||||
pub ignore_brackets: bool,
|
||||
}
|
||||
|
||||
/// Folds all code blocks at the specified indentation level.
|
||||
|
||||
@@ -11,9 +11,9 @@ use language::{Buffer, LanguageName, LanguageRegistry};
|
||||
use markdown::{Markdown, MarkdownElement};
|
||||
use multi_buffer::{Anchor, ExcerptId};
|
||||
use ordered_float::OrderedFloat;
|
||||
use project::CompletionSource;
|
||||
use project::lsp_store::CompletionDocumentation;
|
||||
use project::{CodeAction, Completion, TaskSourceKind};
|
||||
use project::{CompletionDisplayOptions, CompletionSource};
|
||||
use task::DebugScenario;
|
||||
use task::TaskContext;
|
||||
|
||||
@@ -232,6 +232,7 @@ pub struct CompletionsMenu {
|
||||
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
language: Option<LanguageName>,
|
||||
display_options: CompletionDisplayOptions,
|
||||
snippet_sort_order: SnippetSortOrder,
|
||||
}
|
||||
|
||||
@@ -271,6 +272,7 @@ impl CompletionsMenu {
|
||||
is_incomplete: bool,
|
||||
buffer: Entity<Buffer>,
|
||||
completions: Box<[Completion]>,
|
||||
display_options: CompletionDisplayOptions,
|
||||
snippet_sort_order: SnippetSortOrder,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
language: Option<LanguageName>,
|
||||
@@ -304,6 +306,7 @@ impl CompletionsMenu {
|
||||
markdown_cache: RefCell::new(VecDeque::new()).into(),
|
||||
language_registry,
|
||||
language,
|
||||
display_options,
|
||||
snippet_sort_order,
|
||||
};
|
||||
|
||||
@@ -375,6 +378,7 @@ impl CompletionsMenu {
|
||||
markdown_cache: RefCell::new(VecDeque::new()).into(),
|
||||
language_registry: None,
|
||||
language: None,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
snippet_sort_order,
|
||||
}
|
||||
}
|
||||
@@ -737,6 +741,33 @@ impl CompletionsMenu {
|
||||
cx: &mut Context<Editor>,
|
||||
) -> AnyElement {
|
||||
let show_completion_documentation = self.show_completion_documentation;
|
||||
let widest_completion_ix = if self.display_options.dynamic_width {
|
||||
let completions = self.completions.borrow();
|
||||
let widest_completion_ix = self
|
||||
.entries
|
||||
.borrow()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, mat)| {
|
||||
let completion = &completions[mat.candidate_id];
|
||||
let documentation = &completion.documentation;
|
||||
|
||||
let mut len = completion.label.text.chars().count();
|
||||
if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
|
||||
if show_completion_documentation {
|
||||
len += text.chars().count();
|
||||
}
|
||||
}
|
||||
|
||||
len
|
||||
})
|
||||
.map(|(ix, _)| ix);
|
||||
drop(completions);
|
||||
widest_completion_ix
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let selected_item = self.selected_item;
|
||||
let completions = self.completions.clone();
|
||||
let entries = self.entries.clone();
|
||||
@@ -863,7 +894,13 @@ impl CompletionsMenu {
|
||||
.max_h(max_height_in_lines as f32 * window.line_height())
|
||||
.track_scroll(self.scroll_handle.clone())
|
||||
.with_sizing_behavior(ListSizingBehavior::Infer)
|
||||
.w(rems(34.));
|
||||
.map(|this| {
|
||||
if self.display_options.dynamic_width {
|
||||
this.with_width_from_item(widest_completion_ix)
|
||||
} else {
|
||||
this.w(rems(34.))
|
||||
}
|
||||
});
|
||||
|
||||
Popover::new().child(list).into_any_element()
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ mod lsp_ext;
|
||||
mod mouse_context_menu;
|
||||
pub mod movement;
|
||||
mod persistence;
|
||||
mod proposed_changes_editor;
|
||||
mod rust_analyzer_ext;
|
||||
pub mod scroll;
|
||||
mod selections_collection;
|
||||
@@ -70,9 +69,7 @@ pub use multi_buffer::{
|
||||
Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey,
|
||||
RowInfo, ToOffset, ToPoint,
|
||||
};
|
||||
pub use proposed_changes_editor::{
|
||||
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
|
||||
};
|
||||
|
||||
pub use text::Bias;
|
||||
|
||||
use ::git::{
|
||||
@@ -147,21 +144,22 @@ use multi_buffer::{
|
||||
use parking_lot::Mutex;
|
||||
use persistence::DB;
|
||||
use project::{
|
||||
BreakpointWithPosition, CodeAction, Completion, CompletionIntent, CompletionResponse,
|
||||
CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, Location, LocationLink,
|
||||
PrepareRenameResponse, Project, ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
|
||||
debugger::breakpoint_store::Breakpoint,
|
||||
BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
|
||||
CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint,
|
||||
Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, ProjectPath,
|
||||
ProjectTransaction, TaskSourceKind,
|
||||
debugger::{
|
||||
breakpoint_store::{
|
||||
BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore,
|
||||
BreakpointStoreEvent,
|
||||
Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
|
||||
BreakpointStore, BreakpointStoreEvent,
|
||||
},
|
||||
session::{Session, SessionEvent},
|
||||
},
|
||||
git_store::{GitStoreEvent, RepositoryEvent},
|
||||
lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
|
||||
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter},
|
||||
project_settings::{GitGutterSetting, ProjectSettings},
|
||||
project_settings::{
|
||||
DiagnosticSeverity, GitGutterSetting, GoToDiagnosticSeverityFilter, ProjectSettings,
|
||||
},
|
||||
};
|
||||
use rand::{seq::SliceRandom, thread_rng};
|
||||
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
|
||||
@@ -189,7 +187,6 @@ use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use sum_tree::TreeMap;
|
||||
use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
|
||||
use text::{BufferId, FromAnchor, OffsetUtf16, Rope};
|
||||
use theme::{
|
||||
@@ -226,7 +223,7 @@ const MAX_SELECTION_HISTORY_LEN: usize = 1024;
|
||||
pub(crate) const CURSORS_VISIBLE_FOR: Duration = Duration::from_millis(2000);
|
||||
#[doc(hidden)]
|
||||
pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
pub const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -1059,8 +1056,8 @@ pub struct Editor {
|
||||
placeholder_text: Option<Arc<str>>,
|
||||
highlight_order: usize,
|
||||
highlighted_rows: HashMap<TypeId, Vec<RowHighlight>>,
|
||||
background_highlights: TreeMap<HighlightKey, BackgroundHighlight>,
|
||||
gutter_highlights: TreeMap<TypeId, GutterHighlight>,
|
||||
background_highlights: HashMap<HighlightKey, BackgroundHighlight>,
|
||||
gutter_highlights: HashMap<TypeId, GutterHighlight>,
|
||||
scrollbar_marker_state: ScrollbarMarkerState,
|
||||
active_indent_guides_state: ActiveIndentGuidesState,
|
||||
nav_history: Option<ItemNavHistory>,
|
||||
@@ -2111,8 +2108,8 @@ impl Editor {
|
||||
placeholder_text: None,
|
||||
highlight_order: 0,
|
||||
highlighted_rows: HashMap::default(),
|
||||
background_highlights: TreeMap::default(),
|
||||
gutter_highlights: TreeMap::default(),
|
||||
background_highlights: HashMap::default(),
|
||||
gutter_highlights: HashMap::default(),
|
||||
scrollbar_marker_state: ScrollbarMarkerState::default(),
|
||||
active_indent_guides_state: ActiveIndentGuidesState::default(),
|
||||
nav_history: None,
|
||||
@@ -5635,17 +5632,25 @@ impl Editor {
|
||||
// that having one source with `is_incomplete: true` doesn't cause all to be re-queried.
|
||||
let mut completions = Vec::new();
|
||||
let mut is_incomplete = false;
|
||||
let mut display_options: Option<CompletionDisplayOptions> = None;
|
||||
if let Some(provider_responses) = provider_responses.await.log_err()
|
||||
&& !provider_responses.is_empty()
|
||||
{
|
||||
for response in provider_responses {
|
||||
completions.extend(response.completions);
|
||||
is_incomplete = is_incomplete || response.is_incomplete;
|
||||
match display_options.as_mut() {
|
||||
None => {
|
||||
display_options = Some(response.display_options);
|
||||
}
|
||||
Some(options) => options.merge(&response.display_options),
|
||||
}
|
||||
}
|
||||
if completion_settings.words == WordsCompletionMode::Fallback {
|
||||
words = Task::ready(BTreeMap::default());
|
||||
}
|
||||
}
|
||||
let display_options = display_options.unwrap_or_default();
|
||||
|
||||
let mut words = words.await;
|
||||
if let Some(word_to_exclude) = &word_to_exclude {
|
||||
@@ -5687,6 +5692,7 @@ impl Editor {
|
||||
is_incomplete,
|
||||
buffer.clone(),
|
||||
completions.into(),
|
||||
display_options,
|
||||
snippet_sort_order,
|
||||
languages,
|
||||
language,
|
||||
@@ -6620,7 +6626,7 @@ impl Editor {
|
||||
buffer_row: Some(point.row),
|
||||
..Default::default()
|
||||
};
|
||||
let Some(blame_entry) = blame
|
||||
let Some((buffer, blame_entry)) = blame
|
||||
.update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next())
|
||||
.flatten()
|
||||
else {
|
||||
@@ -6630,12 +6636,19 @@ impl Editor {
|
||||
let anchor = self.selections.newest_anchor().head();
|
||||
let position = self.to_pixel_point(anchor, &snapshot, window);
|
||||
if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) {
|
||||
self.show_blame_popover(&blame_entry, position + last_bounds.origin, true, cx);
|
||||
self.show_blame_popover(
|
||||
buffer,
|
||||
&blame_entry,
|
||||
position + last_bounds.origin,
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
fn show_blame_popover(
|
||||
&mut self,
|
||||
buffer: BufferId,
|
||||
blame_entry: &BlameEntry,
|
||||
position: gpui::Point<Pixels>,
|
||||
ignore_timeout: bool,
|
||||
@@ -6659,7 +6672,7 @@ impl Editor {
|
||||
return;
|
||||
};
|
||||
let blame = blame.read(cx);
|
||||
let details = blame.details_for_entry(&blame_entry);
|
||||
let details = blame.details_for_entry(buffer, &blame_entry);
|
||||
let markdown = cx.new(|cx| {
|
||||
Markdown::new(
|
||||
details
|
||||
@@ -7744,6 +7757,11 @@ impl Editor {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.ime_transaction.is_some() {
|
||||
self.discard_edit_prediction(false, cx);
|
||||
return None;
|
||||
}
|
||||
|
||||
let selection = self.selections.newest_anchor();
|
||||
let cursor = selection.head();
|
||||
let multibuffer = self.buffer.read(cx).snapshot(cx);
|
||||
@@ -11816,6 +11834,18 @@ impl Editor {
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let selections = self.selections.all::<Point>(cx);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum CommentFormat {
|
||||
/// single line comment, with prefix for line
|
||||
Line(String),
|
||||
/// single line within a block comment, with prefix for line
|
||||
BlockLine(String),
|
||||
/// a single line of a block comment that includes the initial delimiter
|
||||
BlockCommentWithStart(BlockCommentConfig),
|
||||
/// a single line of a block comment that includes the ending delimiter
|
||||
BlockCommentWithEnd(BlockCommentConfig),
|
||||
}
|
||||
|
||||
// Split selections to respect paragraph, indent, and comment prefix boundaries.
|
||||
let wrap_ranges = selections.into_iter().flat_map(|selection| {
|
||||
let mut non_blank_rows_iter = (selection.start.row..=selection.end.row)
|
||||
@@ -11832,37 +11862,75 @@ impl Editor {
|
||||
let language_scope = buffer.language_scope_at(selection.head());
|
||||
|
||||
let indent_and_prefix_for_row =
|
||||
|row: u32| -> (IndentSize, Option<String>, Option<String>) {
|
||||
|row: u32| -> (IndentSize, Option<CommentFormat>, Option<String>) {
|
||||
let indent = buffer.indent_size_for_line(MultiBufferRow(row));
|
||||
let (comment_prefix, rewrap_prefix) =
|
||||
if let Some(language_scope) = &language_scope {
|
||||
let indent_end = Point::new(row, indent.len);
|
||||
let comment_prefix = language_scope
|
||||
let (comment_prefix, rewrap_prefix) = if let Some(language_scope) =
|
||||
&language_scope
|
||||
{
|
||||
let indent_end = Point::new(row, indent.len);
|
||||
let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
|
||||
let line_text_after_indent = buffer
|
||||
.text_for_range(indent_end..line_end)
|
||||
.collect::<String>();
|
||||
|
||||
let is_within_comment_override = buffer
|
||||
.language_scope_at(indent_end)
|
||||
.is_some_and(|scope| scope.override_name() == Some("comment"));
|
||||
let comment_delimiters = if is_within_comment_override {
|
||||
// we are within a comment syntax node, but we don't
|
||||
// yet know what kind of comment: block, doc or line
|
||||
match (
|
||||
language_scope.documentation_comment(),
|
||||
language_scope.block_comment(),
|
||||
) {
|
||||
(Some(config), _) | (_, Some(config))
|
||||
if buffer.contains_str_at(indent_end, &config.start) =>
|
||||
{
|
||||
Some(CommentFormat::BlockCommentWithStart(config.clone()))
|
||||
}
|
||||
(Some(config), _) | (_, Some(config))
|
||||
if line_text_after_indent.ends_with(config.end.as_ref()) =>
|
||||
{
|
||||
Some(CommentFormat::BlockCommentWithEnd(config.clone()))
|
||||
}
|
||||
(Some(config), _) | (_, Some(config))
|
||||
if buffer.contains_str_at(indent_end, &config.prefix) =>
|
||||
{
|
||||
Some(CommentFormat::BlockLine(config.prefix.to_string()))
|
||||
}
|
||||
(_, _) => language_scope
|
||||
.line_comment_prefixes()
|
||||
.iter()
|
||||
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
|
||||
.map(|prefix| CommentFormat::Line(prefix.to_string())),
|
||||
}
|
||||
} else {
|
||||
// we not in an overridden comment node, but we may
|
||||
// be within a non-overridden line comment node
|
||||
language_scope
|
||||
.line_comment_prefixes()
|
||||
.iter()
|
||||
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
|
||||
.map(|prefix| prefix.to_string());
|
||||
let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
|
||||
let line_text_after_indent = buffer
|
||||
.text_for_range(indent_end..line_end)
|
||||
.collect::<String>();
|
||||
let rewrap_prefix = language_scope
|
||||
.rewrap_prefixes()
|
||||
.iter()
|
||||
.find_map(|prefix_regex| {
|
||||
prefix_regex.find(&line_text_after_indent).map(|mat| {
|
||||
if mat.start() == 0 {
|
||||
Some(mat.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
(comment_prefix, rewrap_prefix)
|
||||
} else {
|
||||
(None, None)
|
||||
.map(|prefix| CommentFormat::Line(prefix.to_string()))
|
||||
};
|
||||
|
||||
let rewrap_prefix = language_scope
|
||||
.rewrap_prefixes()
|
||||
.iter()
|
||||
.find_map(|prefix_regex| {
|
||||
prefix_regex.find(&line_text_after_indent).map(|mat| {
|
||||
if mat.start() == 0 {
|
||||
Some(mat.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
(comment_delimiters, rewrap_prefix)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
(indent, comment_prefix, rewrap_prefix)
|
||||
};
|
||||
|
||||
@@ -11873,22 +11941,22 @@ impl Editor {
|
||||
let mut prev_row = first_row;
|
||||
let (
|
||||
mut current_range_indent,
|
||||
mut current_range_comment_prefix,
|
||||
mut current_range_comment_delimiters,
|
||||
mut current_range_rewrap_prefix,
|
||||
) = indent_and_prefix_for_row(first_row);
|
||||
|
||||
for row in non_blank_rows_iter.skip(1) {
|
||||
let has_paragraph_break = row > prev_row + 1;
|
||||
|
||||
let (row_indent, row_comment_prefix, row_rewrap_prefix) =
|
||||
let (row_indent, row_comment_delimiters, row_rewrap_prefix) =
|
||||
indent_and_prefix_for_row(row);
|
||||
|
||||
let has_indent_change = row_indent != current_range_indent;
|
||||
let has_comment_change = row_comment_prefix != current_range_comment_prefix;
|
||||
let has_comment_change = row_comment_delimiters != current_range_comment_delimiters;
|
||||
|
||||
let has_boundary_change = has_comment_change
|
||||
|| row_rewrap_prefix.is_some()
|
||||
|| (has_indent_change && current_range_comment_prefix.is_some());
|
||||
|| (has_indent_change && current_range_comment_delimiters.is_some());
|
||||
|
||||
if has_paragraph_break || has_boundary_change {
|
||||
ranges.push((
|
||||
@@ -11896,13 +11964,13 @@ impl Editor {
|
||||
Point::new(current_range_start, 0)
|
||||
..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))),
|
||||
current_range_indent,
|
||||
current_range_comment_prefix.clone(),
|
||||
current_range_comment_delimiters.clone(),
|
||||
current_range_rewrap_prefix.clone(),
|
||||
from_empty_selection,
|
||||
));
|
||||
current_range_start = row;
|
||||
current_range_indent = row_indent;
|
||||
current_range_comment_prefix = row_comment_prefix;
|
||||
current_range_comment_delimiters = row_comment_delimiters;
|
||||
current_range_rewrap_prefix = row_rewrap_prefix;
|
||||
}
|
||||
prev_row = row;
|
||||
@@ -11913,7 +11981,7 @@ impl Editor {
|
||||
Point::new(current_range_start, 0)
|
||||
..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))),
|
||||
current_range_indent,
|
||||
current_range_comment_prefix,
|
||||
current_range_comment_delimiters,
|
||||
current_range_rewrap_prefix,
|
||||
from_empty_selection,
|
||||
));
|
||||
@@ -11927,7 +11995,7 @@ impl Editor {
|
||||
for (
|
||||
language_settings,
|
||||
wrap_range,
|
||||
indent_size,
|
||||
mut indent_size,
|
||||
comment_prefix,
|
||||
rewrap_prefix,
|
||||
from_empty_selection,
|
||||
@@ -11947,16 +12015,26 @@ impl Editor {
|
||||
|
||||
let tab_size = language_settings.tab_size;
|
||||
|
||||
let (line_prefix, inside_comment) = match &comment_prefix {
|
||||
Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => {
|
||||
(Some(prefix.as_str()), true)
|
||||
}
|
||||
Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { prefix, .. })) => {
|
||||
(Some(prefix.as_ref()), true)
|
||||
}
|
||||
Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig {
|
||||
start: _,
|
||||
end: _,
|
||||
prefix,
|
||||
tab_size,
|
||||
})) => {
|
||||
indent_size.len += tab_size;
|
||||
(Some(prefix.as_ref()), true)
|
||||
}
|
||||
None => (None, false),
|
||||
};
|
||||
let indent_prefix = indent_size.chars().collect::<String>();
|
||||
let mut line_prefix = indent_prefix.clone();
|
||||
let mut inside_comment = false;
|
||||
if let Some(prefix) = &comment_prefix {
|
||||
line_prefix.push_str(prefix);
|
||||
inside_comment = true;
|
||||
}
|
||||
if let Some(prefix) = &rewrap_prefix {
|
||||
line_prefix.push_str(prefix);
|
||||
}
|
||||
let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or(""));
|
||||
|
||||
let allow_rewrap_based_on_language = match language_settings.allow_rewrap {
|
||||
RewrapBehavior::InComments => inside_comment,
|
||||
@@ -12001,6 +12079,8 @@ impl Editor {
|
||||
let start_offset = start.to_offset(&buffer);
|
||||
let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row)));
|
||||
let selection_text = buffer.text_for_range(start..end).collect::<String>();
|
||||
let mut first_line_delimiter = None;
|
||||
let mut last_line_delimiter = None;
|
||||
let Some(lines_without_prefixes) = selection_text
|
||||
.lines()
|
||||
.enumerate()
|
||||
@@ -12008,6 +12088,46 @@ impl Editor {
|
||||
let line_trimmed = line.trim_start();
|
||||
if rewrap_prefix.is_some() && ix > 0 {
|
||||
Ok(line_trimmed)
|
||||
} else if let Some(
|
||||
CommentFormat::BlockCommentWithStart(BlockCommentConfig {
|
||||
start,
|
||||
prefix,
|
||||
end,
|
||||
tab_size,
|
||||
})
|
||||
| CommentFormat::BlockCommentWithEnd(BlockCommentConfig {
|
||||
start,
|
||||
prefix,
|
||||
end,
|
||||
tab_size,
|
||||
}),
|
||||
) = &comment_prefix
|
||||
{
|
||||
let line_trimmed = line_trimmed
|
||||
.strip_prefix(start.as_ref())
|
||||
.map(|s| {
|
||||
let mut indent_size = indent_size;
|
||||
indent_size.len -= tab_size;
|
||||
let indent_prefix: String = indent_size.chars().collect();
|
||||
first_line_delimiter = Some((indent_prefix, start));
|
||||
s.trim_start()
|
||||
})
|
||||
.unwrap_or(line_trimmed);
|
||||
let line_trimmed = line_trimmed
|
||||
.strip_suffix(end.as_ref())
|
||||
.map(|s| {
|
||||
last_line_delimiter = Some(end);
|
||||
s.trim_end()
|
||||
})
|
||||
.unwrap_or(line_trimmed);
|
||||
let line_trimmed = line_trimmed
|
||||
.strip_prefix(prefix.as_ref())
|
||||
.unwrap_or(line_trimmed);
|
||||
Ok(line_trimmed)
|
||||
} else if let Some(CommentFormat::BlockLine(prefix)) = &comment_prefix {
|
||||
line_trimmed.strip_prefix(prefix).with_context(|| {
|
||||
format!("line did not start with prefix {prefix:?}: {line:?}")
|
||||
})
|
||||
} else {
|
||||
line_trimmed
|
||||
.strip_prefix(&line_prefix.trim_start())
|
||||
@@ -12034,14 +12154,25 @@ impl Editor {
|
||||
line_prefix.clone()
|
||||
};
|
||||
|
||||
let wrapped_text = wrap_with_prefix(
|
||||
line_prefix,
|
||||
subsequent_lines_prefix,
|
||||
lines_without_prefixes.join("\n"),
|
||||
wrap_column,
|
||||
tab_size,
|
||||
options.preserve_existing_whitespace,
|
||||
);
|
||||
let wrapped_text = {
|
||||
let mut wrapped_text = wrap_with_prefix(
|
||||
line_prefix,
|
||||
subsequent_lines_prefix,
|
||||
lines_without_prefixes.join("\n"),
|
||||
wrap_column,
|
||||
tab_size,
|
||||
options.preserve_existing_whitespace,
|
||||
);
|
||||
|
||||
if let Some((indent, delimiter)) = first_line_delimiter {
|
||||
wrapped_text = format!("{indent}{delimiter}\n{wrapped_text}");
|
||||
}
|
||||
if let Some(last_line) = last_line_delimiter {
|
||||
wrapped_text = format!("{wrapped_text}\n{indent_prefix}{last_line}");
|
||||
}
|
||||
|
||||
wrapped_text
|
||||
};
|
||||
|
||||
// TODO: should always use char-based diff while still supporting cursor behavior that
|
||||
// matches vim.
|
||||
@@ -13019,11 +13150,17 @@ impl Editor {
|
||||
this.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if selection.is_empty() {
|
||||
let cursor = if action.ignore_newlines {
|
||||
let mut cursor = if action.ignore_newlines {
|
||||
movement::previous_word_start(map, selection.head())
|
||||
} else {
|
||||
movement::previous_word_start_or_newline(map, selection.head())
|
||||
};
|
||||
cursor = movement::adjust_greedy_deletion(
|
||||
map,
|
||||
selection.head(),
|
||||
cursor,
|
||||
action.ignore_brackets,
|
||||
);
|
||||
selection.set_head(cursor, SelectionGoal::None);
|
||||
}
|
||||
});
|
||||
@@ -13044,7 +13181,9 @@ impl Editor {
|
||||
this.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if selection.is_empty() {
|
||||
let cursor = movement::previous_subword_start(map, selection.head());
|
||||
let mut cursor = movement::previous_subword_start(map, selection.head());
|
||||
cursor =
|
||||
movement::adjust_greedy_deletion(map, selection.head(), cursor, false);
|
||||
selection.set_head(cursor, SelectionGoal::None);
|
||||
}
|
||||
});
|
||||
@@ -13120,11 +13259,17 @@ impl Editor {
|
||||
this.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if selection.is_empty() {
|
||||
let cursor = if action.ignore_newlines {
|
||||
let mut cursor = if action.ignore_newlines {
|
||||
movement::next_word_end(map, selection.head())
|
||||
} else {
|
||||
movement::next_word_end_or_newline(map, selection.head())
|
||||
};
|
||||
cursor = movement::adjust_greedy_deletion(
|
||||
map,
|
||||
selection.head(),
|
||||
cursor,
|
||||
action.ignore_brackets,
|
||||
);
|
||||
selection.set_head(cursor, SelectionGoal::None);
|
||||
}
|
||||
});
|
||||
@@ -13144,7 +13289,9 @@ impl Editor {
|
||||
this.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if selection.is_empty() {
|
||||
let cursor = movement::next_subword_end(map, selection.head());
|
||||
let mut cursor = movement::next_subword_end(map, selection.head());
|
||||
cursor =
|
||||
movement::adjust_greedy_deletion(map, selection.head(), cursor, false);
|
||||
selection.set_head(cursor, SelectionGoal::None);
|
||||
}
|
||||
});
|
||||
@@ -18943,7 +19090,7 @@ impl Editor {
|
||||
let snapshot = self.snapshot(window, cx);
|
||||
let cursor = self.selections.newest::<Point>(cx).head();
|
||||
let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?;
|
||||
let blame_entry = blame
|
||||
let (_, blame_entry) = blame
|
||||
.update(cx, |blame, cx| {
|
||||
blame
|
||||
.blame_for_rows(
|
||||
@@ -18958,7 +19105,7 @@ impl Editor {
|
||||
})
|
||||
.flatten()?;
|
||||
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
|
||||
let repo = blame.read(cx).repository(cx)?;
|
||||
let repo = blame.read(cx).repository(cx, buffer.remote_id())?;
|
||||
let workspace = self.workspace()?.downgrade();
|
||||
renderer.open_blame_commit(blame_entry, repo, workspace, window, cx);
|
||||
None
|
||||
@@ -18994,18 +19141,17 @@ impl Editor {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(project) = self.project() {
|
||||
let Some(buffer) = self.buffer().read(cx).as_singleton() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if buffer.read(cx).file().is_none() {
|
||||
if let Some(buffer) = self.buffer().read(cx).as_singleton()
|
||||
&& buffer.read(cx).file().is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let focused = self.focus_handle(cx).contains_focused(window, cx);
|
||||
|
||||
let project = project.clone();
|
||||
let blame = cx.new(|cx| GitBlame::new(buffer, project, user_triggered, focused, cx));
|
||||
let blame = cx
|
||||
.new(|cx| GitBlame::new(self.buffer.clone(), project, user_triggered, focused, cx));
|
||||
self.blame_subscription =
|
||||
Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify()));
|
||||
self.blame = Some(blame);
|
||||
@@ -19655,7 +19801,24 @@ impl Editor {
|
||||
let buffer = &snapshot.buffer_snapshot;
|
||||
let start = buffer.anchor_before(0);
|
||||
let end = buffer.anchor_after(buffer.len());
|
||||
self.background_highlights_in_range(start..end, &snapshot, cx.theme())
|
||||
self.sorted_background_highlights_in_range(start..end, &snapshot, cx.theme())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn sorted_background_highlights_in_range(
|
||||
&self,
|
||||
search_range: Range<Anchor>,
|
||||
display_snapshot: &DisplaySnapshot,
|
||||
theme: &Theme,
|
||||
) -> Vec<(Range<DisplayPoint>, Hsla)> {
|
||||
let mut res = self.background_highlights_in_range(search_range, display_snapshot, theme);
|
||||
res.sort_by(|a, b| {
|
||||
a.0.start
|
||||
.cmp(&b.0.start)
|
||||
.then_with(|| a.0.end.cmp(&b.0.end))
|
||||
.then_with(|| a.1.cmp(&b.1))
|
||||
});
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
@@ -19720,6 +19883,9 @@ impl Editor {
|
||||
.is_some_and(|(_, highlights)| !highlights.is_empty())
|
||||
}
|
||||
|
||||
/// Returns all background highlights for a given range.
|
||||
///
|
||||
/// The order of highlights is not deterministic, do sort the ranges if needed for the logic.
|
||||
pub fn background_highlights_in_range(
|
||||
&self,
|
||||
search_range: Range<Anchor>,
|
||||
@@ -19758,84 +19924,6 @@ impl Editor {
|
||||
results
|
||||
}
|
||||
|
||||
pub fn background_highlight_row_ranges<T: 'static>(
|
||||
&self,
|
||||
search_range: Range<Anchor>,
|
||||
display_snapshot: &DisplaySnapshot,
|
||||
count: usize,
|
||||
) -> Vec<RangeInclusive<DisplayPoint>> {
|
||||
let mut results = Vec::new();
|
||||
let Some((_, ranges)) = self
|
||||
.background_highlights
|
||||
.get(&HighlightKey::Type(TypeId::of::<T>()))
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let start_ix = match ranges.binary_search_by(|probe| {
|
||||
let cmp = probe
|
||||
.end
|
||||
.cmp(&search_range.start, &display_snapshot.buffer_snapshot);
|
||||
if cmp.is_gt() {
|
||||
Ordering::Greater
|
||||
} else {
|
||||
Ordering::Less
|
||||
}
|
||||
}) {
|
||||
Ok(i) | Err(i) => i,
|
||||
};
|
||||
let mut push_region = |start: Option<Point>, end: Option<Point>| {
|
||||
if let (Some(start_display), Some(end_display)) = (start, end) {
|
||||
results.push(
|
||||
start_display.to_display_point(display_snapshot)
|
||||
..=end_display.to_display_point(display_snapshot),
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut start_row: Option<Point> = None;
|
||||
let mut end_row: Option<Point> = None;
|
||||
if ranges.len() > count {
|
||||
return Vec::new();
|
||||
}
|
||||
for range in &ranges[start_ix..] {
|
||||
if range
|
||||
.start
|
||||
.cmp(&search_range.end, &display_snapshot.buffer_snapshot)
|
||||
.is_ge()
|
||||
{
|
||||
break;
|
||||
}
|
||||
let end = range.end.to_point(&display_snapshot.buffer_snapshot);
|
||||
if let Some(current_row) = &end_row
|
||||
&& end.row == current_row.row
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let start = range.start.to_point(&display_snapshot.buffer_snapshot);
|
||||
if start_row.is_none() {
|
||||
assert_eq!(end_row, None);
|
||||
start_row = Some(start);
|
||||
end_row = Some(end);
|
||||
continue;
|
||||
}
|
||||
if let Some(current_end) = end_row.as_mut() {
|
||||
if start.row > current_end.row + 1 {
|
||||
push_region(start_row, end_row);
|
||||
start_row = Some(start);
|
||||
end_row = Some(end);
|
||||
} else {
|
||||
// Merge two hunks.
|
||||
*current_end = end;
|
||||
}
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
}
|
||||
// We might still have a hunk that was not rendered (if there was a search hit on the last line)
|
||||
push_region(start_row, end_row);
|
||||
results
|
||||
}
|
||||
|
||||
pub fn gutter_highlights_in_range(
|
||||
&self,
|
||||
search_range: Range<Anchor>,
|
||||
@@ -20381,65 +20469,6 @@ impl Editor {
|
||||
self.searchable
|
||||
}
|
||||
|
||||
fn open_proposed_changes_editor(
|
||||
&mut self,
|
||||
_: &OpenProposedChangesEditor,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(workspace) = self.workspace() else {
|
||||
cx.propagate();
|
||||
return;
|
||||
};
|
||||
|
||||
let selections = self.selections.all::<usize>(cx);
|
||||
let multi_buffer = self.buffer.read(cx);
|
||||
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
|
||||
let mut new_selections_by_buffer = HashMap::default();
|
||||
for selection in selections {
|
||||
for (buffer, range, _) in
|
||||
multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end)
|
||||
{
|
||||
let mut range = range.to_point(buffer);
|
||||
range.start.column = 0;
|
||||
range.end.column = buffer.line_len(range.end.row);
|
||||
new_selections_by_buffer
|
||||
.entry(multi_buffer.buffer(buffer.remote_id()).unwrap())
|
||||
.or_insert(Vec::new())
|
||||
.push(range)
|
||||
}
|
||||
}
|
||||
|
||||
let proposed_changes_buffers = new_selections_by_buffer
|
||||
.into_iter()
|
||||
.map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges })
|
||||
.collect::<Vec<_>>();
|
||||
let proposed_changes_editor = cx.new(|cx| {
|
||||
ProposedChangesEditor::new(
|
||||
"Proposed changes",
|
||||
proposed_changes_buffers,
|
||||
self.project.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.active_pane().update(cx, |pane, cx| {
|
||||
pane.add_item(
|
||||
Box::new(proposed_changes_editor),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn open_excerpts_in_split(
|
||||
&mut self,
|
||||
_: &OpenExcerptsSplit,
|
||||
@@ -22142,6 +22171,7 @@ fn snippet_completions(
|
||||
if scopes.is_empty() {
|
||||
return Task::ready(Ok(CompletionResponse {
|
||||
completions: vec![],
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: false,
|
||||
}));
|
||||
}
|
||||
@@ -22166,6 +22196,7 @@ fn snippet_completions(
|
||||
if last_word.is_empty() {
|
||||
return Ok(CompletionResponse {
|
||||
completions: vec![],
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: true,
|
||||
});
|
||||
}
|
||||
@@ -22287,6 +22318,7 @@ fn snippet_completions(
|
||||
|
||||
Ok(CompletionResponse {
|
||||
completions,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,12 +11,13 @@ use util::serde::default_true;
|
||||
|
||||
/// Imports from the VSCode settings at
|
||||
/// https://code.visualstudio.com/docs/reference/default-settings
|
||||
#[derive(Deserialize, Clone, SettingsUi)]
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct EditorSettings {
|
||||
pub cursor_blink: bool,
|
||||
pub cursor_shape: Option<CursorShape>,
|
||||
pub current_line_highlight: CurrentLineHighlight,
|
||||
pub selection_highlight: bool,
|
||||
pub rounded_selection: bool,
|
||||
pub lsp_highlight_debounce: u64,
|
||||
pub hover_popover_enabled: bool,
|
||||
pub hover_popover_delay: u64,
|
||||
@@ -60,7 +61,9 @@ pub struct EditorSettings {
|
||||
}
|
||||
|
||||
/// How to render LSP `textDocument/documentColor` colors in the editor.
|
||||
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DocumentColorsRenderMode {
|
||||
/// Do not query and render document colors.
|
||||
@@ -74,7 +77,7 @@ pub enum DocumentColorsRenderMode {
|
||||
Background,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CurrentLineHighlight {
|
||||
// Don't highlight the current line.
|
||||
@@ -88,7 +91,7 @@ pub enum CurrentLineHighlight {
|
||||
}
|
||||
|
||||
/// When to populate a new search's query based on the text under the cursor.
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SeedQuerySetting {
|
||||
/// Always populate the search query with the word under the cursor.
|
||||
@@ -100,7 +103,9 @@ pub enum SeedQuerySetting {
|
||||
}
|
||||
|
||||
/// What to do when multibuffer is double clicked in some of its excerpts (parts of singleton buffers).
|
||||
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(
|
||||
Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DoubleClickInMultibuffer {
|
||||
/// Behave as a regular buffer and select the whole word.
|
||||
@@ -119,7 +124,9 @@ pub struct Jupyter {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(
|
||||
Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct JupyterContent {
|
||||
/// Whether the Jupyter feature is enabled.
|
||||
@@ -291,7 +298,9 @@ pub struct ScrollbarAxes {
|
||||
}
|
||||
|
||||
/// Whether to allow drag and drop text selection in buffer.
|
||||
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(
|
||||
Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi,
|
||||
)]
|
||||
pub struct DragAndDropSelection {
|
||||
/// When true, enables drag and drop text selection in buffer.
|
||||
///
|
||||
@@ -331,7 +340,7 @@ pub enum ScrollbarDiagnostics {
|
||||
/// The key to use for adding multiple cursors
|
||||
///
|
||||
/// Default: alt
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MultiCursorModifier {
|
||||
Alt,
|
||||
@@ -342,7 +351,7 @@ pub enum MultiCursorModifier {
|
||||
/// Whether the editor will scroll beyond the last line.
|
||||
///
|
||||
/// Default: one_page
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScrollBeyondLastLine {
|
||||
/// The editor will not scroll beyond the last line.
|
||||
@@ -356,7 +365,9 @@ pub enum ScrollBeyondLastLine {
|
||||
}
|
||||
|
||||
/// Default options for buffer and project search items.
|
||||
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(
|
||||
Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi,
|
||||
)]
|
||||
pub struct SearchSettings {
|
||||
/// Whether to show the project search button in the status bar.
|
||||
#[serde(default = "default_true")]
|
||||
@@ -372,7 +383,9 @@ pub struct SearchSettings {
|
||||
}
|
||||
|
||||
/// What to do when go to definition yields no results.
|
||||
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoToDefinitionFallback {
|
||||
/// Disables the fallback.
|
||||
@@ -385,7 +398,9 @@ pub enum GoToDefinitionFallback {
|
||||
/// Determines when the mouse cursor should be hidden in an editor or input box.
|
||||
///
|
||||
/// Default: on_typing_and_movement
|
||||
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HideMouseMode {
|
||||
/// Never hide the mouse cursor
|
||||
@@ -400,7 +415,9 @@ pub enum HideMouseMode {
|
||||
/// Determines how snippets are sorted relative to other completion items.
|
||||
///
|
||||
/// Default: inline
|
||||
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SnippetSortOrder {
|
||||
/// Place snippets at the top of the completion list
|
||||
@@ -414,7 +431,8 @@ pub enum SnippetSortOrder {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, SettingsUi)]
|
||||
#[settings_ui(group = "Editor")]
|
||||
pub struct EditorSettingsContent {
|
||||
/// Whether the cursor blinks in the editor.
|
||||
///
|
||||
@@ -423,7 +441,7 @@ pub struct EditorSettingsContent {
|
||||
/// Cursor shape for the default editor.
|
||||
/// Can be "bar", "block", "underline", or "hollow".
|
||||
///
|
||||
/// Default: None
|
||||
/// Default: bar
|
||||
pub cursor_shape: Option<CursorShape>,
|
||||
/// Determines when the mouse cursor should be hidden in an editor or input box.
|
||||
///
|
||||
@@ -441,6 +459,10 @@ pub struct EditorSettingsContent {
|
||||
///
|
||||
/// Default: true
|
||||
pub selection_highlight: Option<bool>,
|
||||
/// Whether the text selection should have rounded corners.
|
||||
///
|
||||
/// Default: true
|
||||
pub rounded_selection: Option<bool>,
|
||||
/// The debounce delay before querying highlights from the language
|
||||
/// server based on the current cursor location.
|
||||
///
|
||||
@@ -596,7 +618,7 @@ pub struct EditorSettingsContent {
|
||||
}
|
||||
|
||||
// Status bar related settings
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
|
||||
pub struct StatusBarContent {
|
||||
/// Whether to display the active language button in the status bar.
|
||||
///
|
||||
@@ -609,7 +631,7 @@ pub struct StatusBarContent {
|
||||
}
|
||||
|
||||
// Toolbar related settings
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
|
||||
pub struct ToolbarContent {
|
||||
/// Whether to display breadcrumbs in the editor toolbar.
|
||||
///
|
||||
@@ -635,7 +657,9 @@ pub struct ToolbarContent {
|
||||
}
|
||||
|
||||
/// Scrollbar related settings
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default, SettingsUi,
|
||||
)]
|
||||
pub struct ScrollbarContent {
|
||||
/// When to show the scrollbar in the editor.
|
||||
///
|
||||
@@ -670,7 +694,9 @@ pub struct ScrollbarContent {
|
||||
}
|
||||
|
||||
/// Minimap related settings
|
||||
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
#[derive(
|
||||
Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, SettingsUi,
|
||||
)]
|
||||
pub struct MinimapContent {
|
||||
/// When to show the minimap in the editor.
|
||||
///
|
||||
@@ -718,7 +744,9 @@ pub struct ScrollbarAxesContent {
|
||||
}
|
||||
|
||||
/// Gutter related settings
|
||||
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi,
|
||||
)]
|
||||
pub struct GutterContent {
|
||||
/// Whether to show line numbers in the gutter.
|
||||
///
|
||||
@@ -794,6 +822,7 @@ impl Settings for EditorSettings {
|
||||
"editor.selectionHighlight",
|
||||
&mut current.selection_highlight,
|
||||
);
|
||||
vscode.bool_setting("editor.roundedSelection", &mut current.rounded_selection);
|
||||
vscode.bool_setting("editor.hover.enabled", &mut current.hover_popover_enabled);
|
||||
vscode.u64_setting("editor.hover.delay", &mut current.hover_popover_delay);
|
||||
|
||||
|
||||
@@ -2476,51 +2476,379 @@ async fn test_delete_to_beginning_of_line(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
|
||||
async fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let editor = cx.add_window(|window, cx| {
|
||||
let buffer = MultiBuffer::build_simple("one two three four", cx);
|
||||
build_editor(buffer, window, cx)
|
||||
});
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
_ = editor.update(cx, |editor, window, cx| {
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
// an empty selection - the preceding word fragment is deleted
|
||||
DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2),
|
||||
// characters selected - they are deleted
|
||||
DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 12),
|
||||
])
|
||||
});
|
||||
// For an empty selection, the preceding word fragment is deleted.
|
||||
// For non-empty selections, only selected characters are deleted.
|
||||
cx.set_state("onˇe two t«hreˇ»e four");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(editor.buffer.read(cx).read(cx).text(), "e two te four");
|
||||
});
|
||||
cx.assert_editor_state("ˇe two tˇe four");
|
||||
|
||||
_ = editor.update(cx, |editor, window, cx| {
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
// an empty selection - the following word fragment is deleted
|
||||
DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3),
|
||||
// characters selected - they are deleted
|
||||
DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 10),
|
||||
])
|
||||
});
|
||||
cx.set_state("e tˇwo te «fˇ»our");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(editor.buffer.read(cx).read(cx).text(), "e t te our");
|
||||
});
|
||||
cx.assert_editor_state("e tˇ te ˇour");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_delete_whitespaces(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
cx.set_state("here is some text ˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: true,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Continuous whitespace sequences are removed entirely, words behind them are not affected by the deletion action.
|
||||
cx.assert_editor_state("here is some textˇwith a space");
|
||||
|
||||
cx.set_state("here is some text ˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("here is some textˇwith a space");
|
||||
|
||||
cx.set_state("here is some textˇ with a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: true,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Same happens in the other direction.
|
||||
cx.assert_editor_state("here is some textˇwith a space");
|
||||
|
||||
cx.set_state("here is some textˇ with a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("here is some textˇwith a space");
|
||||
|
||||
cx.set_state("here is some textˇ with a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("here is some textˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("here is some ˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Single whitespaces are removed with the word behind them.
|
||||
cx.assert_editor_state("here is ˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("here ˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("ˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("ˇwith a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Same happens in the other direction.
|
||||
cx.assert_editor_state("ˇ a space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("ˇ space");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("ˇ");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("ˇ");
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state("ˇ");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_delete_to_bracket(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let language = Arc::new(
|
||||
Language::new(
|
||||
LanguageConfig {
|
||||
brackets: BracketPairConfig {
|
||||
pairs: vec![
|
||||
BracketPair {
|
||||
start: "\"".to_string(),
|
||||
end: "\"".to_string(),
|
||||
close: true,
|
||||
surround: true,
|
||||
newline: false,
|
||||
},
|
||||
BracketPair {
|
||||
start: "(".to_string(),
|
||||
end: ")".to_string(),
|
||||
close: true,
|
||||
surround: true,
|
||||
newline: true,
|
||||
},
|
||||
],
|
||||
..BracketPairConfig::default()
|
||||
},
|
||||
..LanguageConfig::default()
|
||||
},
|
||||
Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
)
|
||||
.with_brackets_query(
|
||||
r#"
|
||||
("(" @open ")" @close)
|
||||
("\"" @open "\"" @close)
|
||||
"#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
|
||||
|
||||
cx.set_state(r#"macro!("// ˇCOMMENT");"#);
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Deletion stops before brackets if asked to not ignore them.
|
||||
cx.assert_editor_state(r#"macro!("ˇCOMMENT");"#);
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Deletion has to remove a single bracket and then stop again.
|
||||
cx.assert_editor_state(r#"macro!(ˇCOMMENT");"#);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state(r#"macro!ˇCOMMENT");"#);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state(r#"ˇCOMMENT");"#);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state(r#"ˇCOMMENT");"#);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Brackets on the right are not paired anymore, hence deletion does not stop at them
|
||||
cx.assert_editor_state(r#"ˇ");"#);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state(r#"ˇ"#);
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_next_word_end(
|
||||
&DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state(r#"ˇ"#);
|
||||
|
||||
cx.set_state(r#"macro!("// ˇCOMMENT");"#);
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.delete_to_previous_word_start(
|
||||
&DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: true,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.assert_editor_state(r#"macroˇCOMMENT");"#);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -2533,9 +2861,11 @@ fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) {
|
||||
});
|
||||
let del_to_prev_word_start = DeleteToPreviousWordStart {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: false,
|
||||
};
|
||||
let del_to_prev_word_start_ignore_newlines = DeleteToPreviousWordStart {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
};
|
||||
|
||||
_ = editor.update(cx, |editor, window, cx| {
|
||||
@@ -2569,9 +2899,11 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
|
||||
});
|
||||
let del_to_next_word_end = DeleteToNextWordEnd {
|
||||
ignore_newlines: false,
|
||||
ignore_brackets: false,
|
||||
};
|
||||
let del_to_next_word_end_ignore_newlines = DeleteToNextWordEnd {
|
||||
ignore_newlines: true,
|
||||
ignore_brackets: false,
|
||||
};
|
||||
|
||||
_ = editor.update(cx, |editor, window, cx| {
|
||||
@@ -2600,6 +2932,8 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
|
||||
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
|
||||
assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n four");
|
||||
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
|
||||
assert_eq!(editor.buffer.read(cx).read(cx).text(), "four");
|
||||
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
|
||||
assert_eq!(editor.buffer.read(cx).read(cx).text(), "");
|
||||
});
|
||||
}
|
||||
@@ -5561,14 +5895,18 @@ async fn test_rewrap(cx: &mut TestAppContext) {
|
||||
},
|
||||
None,
|
||||
));
|
||||
let rust_language = Arc::new(Language::new(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
line_comments: vec!["// ".into(), "/// ".into()],
|
||||
..LanguageConfig::default()
|
||||
},
|
||||
Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
));
|
||||
let rust_language = Arc::new(
|
||||
Language::new(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
line_comments: vec!["// ".into(), "/// ".into()],
|
||||
..LanguageConfig::default()
|
||||
},
|
||||
Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
)
|
||||
.with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let plaintext_language = Arc::new(Language::new(
|
||||
LanguageConfig {
|
||||
@@ -5884,6 +6222,411 @@ async fn test_rewrap(cx: &mut TestAppContext) {
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rewrap_block_comments(cx: &mut TestAppContext) {
|
||||
init_test(cx, |settings| {
|
||||
settings.languages.0.extend([(
|
||||
"Rust".into(),
|
||||
LanguageSettingsContent {
|
||||
allow_rewrap: Some(language_settings::RewrapBehavior::InComments),
|
||||
preferred_line_length: Some(40),
|
||||
..Default::default()
|
||||
},
|
||||
)])
|
||||
});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
let rust_lang = Arc::new(
|
||||
Language::new(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
line_comments: vec!["// ".into()],
|
||||
block_comment: Some(BlockCommentConfig {
|
||||
start: "/*".into(),
|
||||
end: "*/".into(),
|
||||
prefix: "* ".into(),
|
||||
tab_size: 1,
|
||||
}),
|
||||
documentation_comment: Some(BlockCommentConfig {
|
||||
start: "/**".into(),
|
||||
end: "*/".into(),
|
||||
prefix: "* ".into(),
|
||||
tab_size: 1,
|
||||
}),
|
||||
|
||||
..LanguageConfig::default()
|
||||
},
|
||||
Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
)
|
||||
.with_override_query("[(line_comment) (block_comment)] @comment.inclusive")
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// regular block comment
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*/
|
||||
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
"},
|
||||
indoc! {"
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// indent is respected
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
{}
|
||||
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
"},
|
||||
indoc! {"
|
||||
{}
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// short block comments with inline delimiters
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
"},
|
||||
indoc! {"
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// multiline block comment with inline start/end delimiters
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit. */
|
||||
"},
|
||||
indoc! {"
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// block comment rewrap still respects paragraph bounds
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*
|
||||
* Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
indoc! {"
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*
|
||||
* Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// documentation comments
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/**ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
/**
|
||||
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
indoc! {"
|
||||
/**
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
/**
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// different, adjacent comments
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/**
|
||||
*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*/
|
||||
/*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
//ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
"},
|
||||
indoc! {"
|
||||
/**
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
//ˇ Lorem ipsum dolor sit amet,
|
||||
// consectetur adipiscing elit.
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// selection w/ single short block comment
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
«/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
|
||||
"},
|
||||
indoc! {"
|
||||
«/*
|
||||
* Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/ˇ»
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// rewrapping a single comment w/ abutting comments
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/* ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
"},
|
||||
indoc! {"
|
||||
/*
|
||||
* ˇLorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// selection w/ non-abutting short block comments
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
«/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
|
||||
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
|
||||
"},
|
||||
indoc! {"
|
||||
«/*
|
||||
* Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/ˇ»
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// selection of multiline block comments
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
«/* Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit. */ˇ»
|
||||
"},
|
||||
indoc! {"
|
||||
«/*
|
||||
* Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/ˇ»
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// partial selection of multiline block comments
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
«/* Lorem ipsum dolor sit amet,ˇ»
|
||||
* consectetur adipiscing elit. */
|
||||
/* Lorem ipsum dolor sit amet,
|
||||
«* consectetur adipiscing elit. */ˇ»
|
||||
"},
|
||||
indoc! {"
|
||||
«/*
|
||||
* Lorem ipsum dolor sit amet,ˇ»
|
||||
* consectetur adipiscing elit. */
|
||||
/* Lorem ipsum dolor sit amet,
|
||||
«* consectetur adipiscing elit.
|
||||
*/ˇ»
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// selection w/ abutting short block comments
|
||||
// TODO: should not be combined; should rewrap as 2 comments
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
«/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
|
||||
"},
|
||||
// desired behavior:
|
||||
// indoc! {"
|
||||
// «/*
|
||||
// * Lorem ipsum dolor sit amet,
|
||||
// * consectetur adipiscing elit.
|
||||
// */
|
||||
// /*
|
||||
// * Lorem ipsum dolor sit amet,
|
||||
// * consectetur adipiscing elit.
|
||||
// */ˇ»
|
||||
// "},
|
||||
// actual behaviour:
|
||||
indoc! {"
|
||||
«/*
|
||||
* Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit. Lorem
|
||||
* ipsum dolor sit amet, consectetur
|
||||
* adipiscing elit.
|
||||
*/ˇ»
|
||||
"},
|
||||
rust_lang.clone(),
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
// TODO: same as above, but with delimiters on separate line
|
||||
// assert_rewrap(
|
||||
// indoc! {"
|
||||
// «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
// */
|
||||
// /*
|
||||
// * Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ»
|
||||
// "},
|
||||
// // desired:
|
||||
// // indoc! {"
|
||||
// // «/*
|
||||
// // * Lorem ipsum dolor sit amet,
|
||||
// // * consectetur adipiscing elit.
|
||||
// // */
|
||||
// // /*
|
||||
// // * Lorem ipsum dolor sit amet,
|
||||
// // * consectetur adipiscing elit.
|
||||
// // */ˇ»
|
||||
// // "},
|
||||
// // actual: (but with trailing w/s on the empty lines)
|
||||
// indoc! {"
|
||||
// «/*
|
||||
// * Lorem ipsum dolor sit amet,
|
||||
// * consectetur adipiscing elit.
|
||||
// *
|
||||
// */
|
||||
// /*
|
||||
// *
|
||||
// * Lorem ipsum dolor sit amet,
|
||||
// * consectetur adipiscing elit.
|
||||
// */ˇ»
|
||||
// "},
|
||||
// rust_lang.clone(),
|
||||
// &mut cx,
|
||||
// );
|
||||
|
||||
// TODO these are unhandled edge cases; not correct, just documenting known issues
|
||||
assert_rewrap(
|
||||
indoc! {"
|
||||
/*
|
||||
//ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
//ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */
|
||||
/*ˇ Lorem ipsum dolor sit amet */ /* consectetur adipiscing elit. */
|
||||
"},
|
||||
// desired:
|
||||
// indoc! {"
|
||||
// /*
|
||||
// *ˇ Lorem ipsum dolor sit amet,
|
||||
// * consectetur adipiscing elit.
|
||||
// */
|
||||
// /*
|
||||
// *ˇ Lorem ipsum dolor sit amet,
|
||||
// * consectetur adipiscing elit.
|
||||
// */
|
||||
// /*
|
||||
// *ˇ Lorem ipsum dolor sit amet
|
||||
// */ /* consectetur adipiscing elit. */
|
||||
// "},
|
||||
// actual:
|
||||
indoc! {"
|
||||
/*
|
||||
//ˇ Lorem ipsum dolor sit amet,
|
||||
// consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
* //ˇ Lorem ipsum dolor sit amet,
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
/*
|
||||
*ˇ Lorem ipsum dolor sit amet */ /*
|
||||
* consectetur adipiscing elit.
|
||||
*/
|
||||
"},
|
||||
rust_lang,
|
||||
&mut cx,
|
||||
);
|
||||
|
||||
#[track_caller]
|
||||
fn assert_rewrap(
|
||||
unwrapped_text: &str,
|
||||
wrapped_text: &str,
|
||||
language: Arc<Language>,
|
||||
cx: &mut EditorTestContext,
|
||||
) {
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
|
||||
cx.set_state(unwrapped_text);
|
||||
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx));
|
||||
cx.assert_editor_state(wrapped_text);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_hard_wrap(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -15044,37 +15787,34 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) {
|
||||
);
|
||||
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
let mut highlighted_ranges = editor.background_highlights_in_range(
|
||||
let highlighted_ranges = editor.sorted_background_highlights_in_range(
|
||||
anchor_range(Point::new(3, 4)..Point::new(7, 4)),
|
||||
&snapshot,
|
||||
cx.theme(),
|
||||
);
|
||||
// Enforce a consistent ordering based on color without relying on the ordering of the
|
||||
// highlight's `TypeId` which is non-executor.
|
||||
highlighted_ranges.sort_unstable_by_key(|(_, color)| *color);
|
||||
assert_eq!(
|
||||
highlighted_ranges,
|
||||
&[
|
||||
(
|
||||
DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4),
|
||||
Hsla::red(),
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5),
|
||||
Hsla::red(),
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5),
|
||||
Hsla::green(),
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4),
|
||||
Hsla::red(),
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6),
|
||||
Hsla::green(),
|
||||
),
|
||||
(
|
||||
DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5),
|
||||
Hsla::red(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
editor.background_highlights_in_range(
|
||||
editor.sorted_background_highlights_in_range(
|
||||
anchor_range(Point::new(5, 6)..Point::new(6, 4)),
|
||||
&snapshot,
|
||||
cx.theme(),
|
||||
|
||||
@@ -117,6 +117,7 @@ struct SelectionLayout {
|
||||
struct InlineBlameLayout {
|
||||
element: AnyElement,
|
||||
bounds: Bounds<Pixels>,
|
||||
buffer_id: BufferId,
|
||||
entry: BlameEntry,
|
||||
}
|
||||
|
||||
@@ -439,7 +440,6 @@ impl EditorElement {
|
||||
register_action(editor, window, Editor::toggle_code_actions);
|
||||
register_action(editor, window, Editor::open_excerpts);
|
||||
register_action(editor, window, Editor::open_excerpts_in_split);
|
||||
register_action(editor, window, Editor::open_proposed_changes_editor);
|
||||
register_action(editor, window, Editor::toggle_soft_wrap);
|
||||
register_action(editor, window, Editor::toggle_tab_bar);
|
||||
register_action(editor, window, Editor::toggle_line_numbers);
|
||||
@@ -1157,7 +1157,7 @@ impl EditorElement {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
if let Some((bounds, blame_entry)) = &position_map.inline_blame_bounds {
|
||||
if let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds {
|
||||
let mouse_over_inline_blame = bounds.contains(&event.position);
|
||||
let mouse_over_popover = editor
|
||||
.inline_blame_popover
|
||||
@@ -1170,7 +1170,7 @@ impl EditorElement {
|
||||
.is_some_and(|state| state.keyboard_grace);
|
||||
|
||||
if mouse_over_inline_blame || mouse_over_popover {
|
||||
editor.show_blame_popover(blame_entry, event.position, false, cx);
|
||||
editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx);
|
||||
} else if !keyboard_grace {
|
||||
editor.hide_blame_popover(cx);
|
||||
}
|
||||
@@ -2454,7 +2454,7 @@ impl EditorElement {
|
||||
padding * em_width
|
||||
};
|
||||
|
||||
let entry = blame
|
||||
let (buffer_id, entry) = blame
|
||||
.update(cx, |blame, cx| {
|
||||
blame.blame_for_rows(&[*row_info], cx).next()
|
||||
})
|
||||
@@ -2489,13 +2489,22 @@ impl EditorElement {
|
||||
let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
|
||||
let bounds = Bounds::new(absolute_offset, size);
|
||||
|
||||
self.layout_blame_entry_popover(entry.clone(), blame, line_height, text_hitbox, window, cx);
|
||||
self.layout_blame_entry_popover(
|
||||
entry.clone(),
|
||||
blame,
|
||||
line_height,
|
||||
text_hitbox,
|
||||
row_info.buffer_id?,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx);
|
||||
|
||||
Some(InlineBlameLayout {
|
||||
element,
|
||||
bounds,
|
||||
buffer_id,
|
||||
entry,
|
||||
})
|
||||
}
|
||||
@@ -2506,6 +2515,7 @@ impl EditorElement {
|
||||
blame: Entity<GitBlame>,
|
||||
line_height: Pixels,
|
||||
text_hitbox: &Hitbox,
|
||||
buffer: BufferId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
@@ -2530,6 +2540,7 @@ impl EditorElement {
|
||||
popover_state.markdown,
|
||||
workspace,
|
||||
&blame,
|
||||
buffer,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -2604,14 +2615,16 @@ impl EditorElement {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ix, blame_entry)| {
|
||||
let (buffer_id, blame_entry) = blame_entry?;
|
||||
let mut element = render_blame_entry(
|
||||
ix,
|
||||
&blame,
|
||||
blame_entry?,
|
||||
blame_entry,
|
||||
&self.style,
|
||||
&mut last_used_color,
|
||||
self.editor.clone(),
|
||||
workspace.clone(),
|
||||
buffer_id,
|
||||
blame_renderer.clone(),
|
||||
cx,
|
||||
)?;
|
||||
@@ -3270,6 +3283,10 @@ impl EditorElement {
|
||||
if rows.start >= rows.end {
|
||||
return Vec::new();
|
||||
}
|
||||
if !base_background.is_opaque() {
|
||||
// We don't actually know what color is behind this editor.
|
||||
return Vec::new();
|
||||
}
|
||||
let highlight_iter = highlight_ranges.iter().cloned();
|
||||
let selection_iter = selections.iter().flat_map(|(player_color, layouts)| {
|
||||
let color = player_color.selection;
|
||||
@@ -6063,7 +6080,7 @@ impl EditorElement {
|
||||
};
|
||||
|
||||
self.paint_lines_background(layout, window, cx);
|
||||
let invisible_display_ranges = self.paint_highlights(layout, window);
|
||||
let invisible_display_ranges = self.paint_highlights(layout, window, cx);
|
||||
self.paint_document_colors(layout, window);
|
||||
self.paint_lines(&invisible_display_ranges, layout, window, cx);
|
||||
self.paint_redactions(layout, window);
|
||||
@@ -6085,6 +6102,7 @@ impl EditorElement {
|
||||
&mut self,
|
||||
layout: &mut EditorLayout,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> SmallVec<[Range<DisplayPoint>; 32]> {
|
||||
window.paint_layer(layout.position_map.text_hitbox.bounds, |window| {
|
||||
let mut invisible_display_ranges = SmallVec::<[Range<DisplayPoint>; 32]>::new();
|
||||
@@ -6101,7 +6119,11 @@ impl EditorElement {
|
||||
);
|
||||
}
|
||||
|
||||
let corner_radius = 0.15 * layout.position_map.line_height;
|
||||
let corner_radius = if EditorSettings::get_global(cx).rounded_selection {
|
||||
0.15 * layout.position_map.line_height
|
||||
} else {
|
||||
Pixels::ZERO
|
||||
};
|
||||
|
||||
for (player_color, selections) in &layout.selections {
|
||||
for selection in selections.iter() {
|
||||
@@ -7389,12 +7411,13 @@ fn render_blame_entry_popover(
|
||||
markdown: Entity<Markdown>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
blame: &Entity<GitBlame>,
|
||||
buffer: BufferId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
|
||||
let blame = blame.read(cx);
|
||||
let repository = blame.repository(cx)?;
|
||||
let repository = blame.repository(cx, buffer)?;
|
||||
renderer.render_blame_entry_popover(
|
||||
blame_entry,
|
||||
scroll_handle,
|
||||
@@ -7415,6 +7438,7 @@ fn render_blame_entry(
|
||||
last_used_color: &mut Option<(PlayerColor, Oid)>,
|
||||
editor: Entity<Editor>,
|
||||
workspace: Entity<Workspace>,
|
||||
buffer: BufferId,
|
||||
renderer: Arc<dyn BlameRenderer>,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
@@ -7435,8 +7459,8 @@ fn render_blame_entry(
|
||||
last_used_color.replace((sha_color, blame_entry.sha));
|
||||
|
||||
let blame = blame.read(cx);
|
||||
let details = blame.details_for_entry(&blame_entry);
|
||||
let repository = blame.repository(cx)?;
|
||||
let details = blame.details_for_entry(buffer, &blame_entry);
|
||||
let repository = blame.repository(cx, buffer)?;
|
||||
renderer.render_blame_entry(
|
||||
&style.text,
|
||||
blame_entry,
|
||||
@@ -8739,7 +8763,7 @@ impl Element for EditorElement {
|
||||
return None;
|
||||
}
|
||||
let blame = editor.blame.as_ref()?;
|
||||
let blame_entry = blame
|
||||
let (_, blame_entry) = blame
|
||||
.update(cx, |blame, cx| {
|
||||
let row_infos =
|
||||
snapshot.row_infos(snapshot.longest_row()).next()?;
|
||||
@@ -9289,7 +9313,7 @@ impl Element for EditorElement {
|
||||
text_hitbox: text_hitbox.clone(),
|
||||
inline_blame_bounds: inline_blame_layout
|
||||
.as_ref()
|
||||
.map(|layout| (layout.bounds, layout.entry.clone())),
|
||||
.map(|layout| (layout.bounds, layout.buffer_id, layout.entry.clone())),
|
||||
display_hunks: display_hunks.clone(),
|
||||
diff_hunk_control_bounds,
|
||||
});
|
||||
@@ -9949,7 +9973,7 @@ pub(crate) struct PositionMap {
|
||||
pub snapshot: EditorSnapshot,
|
||||
pub text_hitbox: Hitbox,
|
||||
pub gutter_hitbox: Hitbox,
|
||||
pub inline_blame_bounds: Option<(Bounds<Pixels>, BlameEntry)>,
|
||||
pub inline_blame_bounds: Option<(Bounds<Pixels>, BufferId, BlameEntry)>,
|
||||
pub display_hunks: Vec<(DisplayDiffHunk, Option<Hitbox>)>,
|
||||
pub diff_hunk_control_bounds: Vec<(DisplayRow, Bounds<Pixels>)>,
|
||||
}
|
||||
@@ -10969,7 +10993,7 @@ mod tests {
|
||||
|
||||
#[gpui::test]
|
||||
fn test_merge_overlapping_ranges() {
|
||||
let base_bg = Hsla::default();
|
||||
let base_bg = Hsla::white();
|
||||
let color1 = Hsla {
|
||||
h: 0.0,
|
||||
s: 0.5,
|
||||
@@ -11039,7 +11063,7 @@ mod tests {
|
||||
|
||||
#[gpui::test]
|
||||
fn test_bg_segments_per_row() {
|
||||
let base_bg = Hsla::default();
|
||||
let base_bg = Hsla::white();
|
||||
|
||||
// Case A: selection spans three display rows: row 1 [5, end), full row 2, row 3 [0, 7)
|
||||
{
|
||||
|
||||
@@ -10,16 +10,18 @@ use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
|
||||
TextStyle, WeakEntity, Window,
|
||||
};
|
||||
use language::{Bias, Buffer, BufferSnapshot, Edit};
|
||||
use itertools::Itertools;
|
||||
use language::{Bias, BufferSnapshot, Edit};
|
||||
use markdown::Markdown;
|
||||
use multi_buffer::RowInfo;
|
||||
use multi_buffer::{MultiBuffer, RowInfo};
|
||||
use project::{
|
||||
Project, ProjectItem,
|
||||
Project, ProjectItem as _,
|
||||
git_store::{GitStoreEvent, Repository, RepositoryEvent},
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use sum_tree::SumTree;
|
||||
use text::BufferId;
|
||||
use workspace::Workspace;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -63,16 +65,19 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitBlame {
|
||||
project: Entity<Project>,
|
||||
buffer: Entity<Buffer>,
|
||||
struct GitBlameBuffer {
|
||||
entries: SumTree<GitBlameEntry>,
|
||||
commit_details: HashMap<Oid, ParsedCommitMessage>,
|
||||
buffer_snapshot: BufferSnapshot,
|
||||
buffer_edits: text::Subscription,
|
||||
commit_details: HashMap<Oid, ParsedCommitMessage>,
|
||||
}
|
||||
|
||||
pub struct GitBlame {
|
||||
project: Entity<Project>,
|
||||
multi_buffer: WeakEntity<MultiBuffer>,
|
||||
buffers: HashMap<BufferId, GitBlameBuffer>,
|
||||
task: Task<Result<()>>,
|
||||
focused: bool,
|
||||
generated: bool,
|
||||
changed_while_blurred: bool,
|
||||
user_triggered: bool,
|
||||
regenerate_on_edit_task: Task<Result<()>>,
|
||||
@@ -184,44 +189,44 @@ impl gpui::Global for GlobalBlameRenderer {}
|
||||
|
||||
impl GitBlame {
|
||||
pub fn new(
|
||||
buffer: Entity<Buffer>,
|
||||
multi_buffer: Entity<MultiBuffer>,
|
||||
project: Entity<Project>,
|
||||
user_triggered: bool,
|
||||
focused: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let entries = SumTree::from_item(
|
||||
GitBlameEntry {
|
||||
rows: buffer.read(cx).max_point().row + 1,
|
||||
blame: None,
|
||||
let multi_buffer_subscription = cx.subscribe(
|
||||
&multi_buffer,
|
||||
|git_blame, multi_buffer, event, cx| match event {
|
||||
multi_buffer::Event::DirtyChanged => {
|
||||
if !multi_buffer.read(cx).is_dirty(cx) {
|
||||
git_blame.generate(cx);
|
||||
}
|
||||
}
|
||||
multi_buffer::Event::ExcerptsAdded { .. }
|
||||
| multi_buffer::Event::ExcerptsEdited { .. } => git_blame.regenerate_on_edit(cx),
|
||||
_ => {}
|
||||
},
|
||||
&(),
|
||||
);
|
||||
|
||||
let buffer_subscriptions = cx.subscribe(&buffer, |this, buffer, event, cx| match event {
|
||||
language::BufferEvent::DirtyChanged => {
|
||||
if !buffer.read(cx).is_dirty() {
|
||||
this.generate(cx);
|
||||
}
|
||||
}
|
||||
language::BufferEvent::Edited => {
|
||||
this.regenerate_on_edit(cx);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let project_subscription = cx.subscribe(&project, {
|
||||
let buffer = buffer.clone();
|
||||
let multi_buffer = multi_buffer.downgrade();
|
||||
|
||||
move |this, _, event, cx| {
|
||||
move |git_blame, _, event, cx| {
|
||||
if let project::Event::WorktreeUpdatedEntries(_, updated) = event {
|
||||
let project_entry_id = buffer.read(cx).entry_id(cx);
|
||||
let Some(multi_buffer) = multi_buffer.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let project_entry_id = multi_buffer
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.and_then(|it| it.read(cx).entry_id(cx));
|
||||
if updated
|
||||
.iter()
|
||||
.any(|(_, entry_id, _)| project_entry_id == Some(*entry_id))
|
||||
{
|
||||
log::debug!("Updated buffers. Regenerating blame data...",);
|
||||
this.generate(cx);
|
||||
git_blame.generate(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,24 +244,17 @@ impl GitBlame {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let buffer_snapshot = buffer.read(cx).snapshot();
|
||||
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
|
||||
let mut this = Self {
|
||||
project,
|
||||
buffer,
|
||||
buffer_snapshot,
|
||||
entries,
|
||||
buffer_edits,
|
||||
multi_buffer: multi_buffer.downgrade(),
|
||||
buffers: HashMap::default(),
|
||||
user_triggered,
|
||||
focused,
|
||||
changed_while_blurred: false,
|
||||
commit_details: HashMap::default(),
|
||||
task: Task::ready(Ok(())),
|
||||
generated: false,
|
||||
regenerate_on_edit_task: Task::ready(Ok(())),
|
||||
_regenerate_subscriptions: vec![
|
||||
buffer_subscriptions,
|
||||
multi_buffer_subscription,
|
||||
project_subscription,
|
||||
git_store_subscription,
|
||||
],
|
||||
@@ -265,56 +263,63 @@ impl GitBlame {
|
||||
this
|
||||
}
|
||||
|
||||
pub fn repository(&self, cx: &App) -> Option<Entity<Repository>> {
|
||||
pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
|
||||
self.project
|
||||
.read(cx)
|
||||
.git_store()
|
||||
.read(cx)
|
||||
.repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx)
|
||||
.repository_and_path_for_buffer_id(id, cx)
|
||||
.map(|(repo, _)| repo)
|
||||
}
|
||||
|
||||
pub fn has_generated_entries(&self) -> bool {
|
||||
self.generated
|
||||
!self.buffers.is_empty()
|
||||
}
|
||||
|
||||
pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<ParsedCommitMessage> {
|
||||
self.commit_details.get(&entry.sha).cloned()
|
||||
pub fn details_for_entry(
|
||||
&self,
|
||||
buffer: BufferId,
|
||||
entry: &BlameEntry,
|
||||
) -> Option<ParsedCommitMessage> {
|
||||
self.buffers
|
||||
.get(&buffer)?
|
||||
.commit_details
|
||||
.get(&entry.sha)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn blame_for_rows<'a>(
|
||||
&'a mut self,
|
||||
rows: &'a [RowInfo],
|
||||
cx: &App,
|
||||
) -> impl 'a + Iterator<Item = Option<BlameEntry>> {
|
||||
self.sync(cx);
|
||||
|
||||
let buffer_id = self.buffer_snapshot.remote_id();
|
||||
let mut cursor = self.entries.cursor::<u32>(&());
|
||||
cx: &'a mut App,
|
||||
) -> impl Iterator<Item = Option<(BufferId, BlameEntry)>> + use<'a> {
|
||||
rows.iter().map(move |info| {
|
||||
let row = info
|
||||
.buffer_row
|
||||
.filter(|_| info.buffer_id == Some(buffer_id))?;
|
||||
cursor.seek_forward(&row, Bias::Right);
|
||||
cursor.item()?.blame.clone()
|
||||
let buffer_id = info.buffer_id?;
|
||||
self.sync(cx, buffer_id);
|
||||
|
||||
let buffer_row = info.buffer_row?;
|
||||
let mut cursor = self.buffers.get(&buffer_id)?.entries.cursor::<u32>(&());
|
||||
cursor.seek_forward(&buffer_row, Bias::Right);
|
||||
Some((buffer_id, cursor.item()?.blame.clone()?))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn max_author_length(&mut self, cx: &App) -> usize {
|
||||
self.sync(cx);
|
||||
|
||||
pub fn max_author_length(&mut self, cx: &mut App) -> usize {
|
||||
let mut max_author_length = 0;
|
||||
self.sync_all(cx);
|
||||
|
||||
for entry in self.entries.iter() {
|
||||
let author_len = entry
|
||||
.blame
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.author.as_ref())
|
||||
.map(|author| author.len());
|
||||
if let Some(author_len) = author_len
|
||||
&& author_len > max_author_length
|
||||
{
|
||||
max_author_length = author_len;
|
||||
for buffer in self.buffers.values() {
|
||||
for entry in buffer.entries.iter() {
|
||||
let author_len = entry
|
||||
.blame
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.author.as_ref())
|
||||
.map(|author| author.len());
|
||||
if let Some(author_len) = author_len
|
||||
&& author_len > max_author_length
|
||||
{
|
||||
max_author_length = author_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,22 +341,48 @@ impl GitBlame {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(&mut self, cx: &App) {
|
||||
let edits = self.buffer_edits.consume();
|
||||
let new_snapshot = self.buffer.read(cx).snapshot();
|
||||
fn sync_all(&mut self, cx: &mut App) {
|
||||
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
|
||||
return;
|
||||
};
|
||||
multi_buffer
|
||||
.read(cx)
|
||||
.excerpt_buffer_ids()
|
||||
.into_iter()
|
||||
.for_each(|id| self.sync(cx, id));
|
||||
}
|
||||
|
||||
fn sync(&mut self, cx: &mut App, buffer_id: BufferId) {
|
||||
let Some(blame_buffer) = self.buffers.get_mut(&buffer_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(buffer) = self
|
||||
.multi_buffer
|
||||
.upgrade()
|
||||
.and_then(|multi_buffer| multi_buffer.read(cx).buffer(buffer_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let edits = blame_buffer.buffer_edits.consume();
|
||||
let new_snapshot = buffer.read(cx).snapshot();
|
||||
|
||||
let mut row_edits = edits
|
||||
.into_iter()
|
||||
.map(|edit| {
|
||||
let old_point_range = self.buffer_snapshot.offset_to_point(edit.old.start)
|
||||
..self.buffer_snapshot.offset_to_point(edit.old.end);
|
||||
let old_point_range = blame_buffer.buffer_snapshot.offset_to_point(edit.old.start)
|
||||
..blame_buffer.buffer_snapshot.offset_to_point(edit.old.end);
|
||||
let new_point_range = new_snapshot.offset_to_point(edit.new.start)
|
||||
..new_snapshot.offset_to_point(edit.new.end);
|
||||
|
||||
if old_point_range.start.column
|
||||
== self.buffer_snapshot.line_len(old_point_range.start.row)
|
||||
== blame_buffer
|
||||
.buffer_snapshot
|
||||
.line_len(old_point_range.start.row)
|
||||
&& (new_snapshot.chars_at(edit.new.start).next() == Some('\n')
|
||||
|| self.buffer_snapshot.line_len(old_point_range.end.row) == 0)
|
||||
|| blame_buffer
|
||||
.buffer_snapshot
|
||||
.line_len(old_point_range.end.row)
|
||||
== 0)
|
||||
{
|
||||
Edit {
|
||||
old: old_point_range.start.row + 1..old_point_range.end.row + 1,
|
||||
@@ -375,7 +406,7 @@ impl GitBlame {
|
||||
.peekable();
|
||||
|
||||
let mut new_entries = SumTree::default();
|
||||
let mut cursor = self.entries.cursor::<u32>(&());
|
||||
let mut cursor = blame_buffer.entries.cursor::<u32>(&());
|
||||
|
||||
while let Some(mut edit) = row_edits.next() {
|
||||
while let Some(next_edit) = row_edits.peek() {
|
||||
@@ -433,17 +464,28 @@ impl GitBlame {
|
||||
new_entries.append(cursor.suffix(), &());
|
||||
drop(cursor);
|
||||
|
||||
self.buffer_snapshot = new_snapshot;
|
||||
self.entries = new_entries;
|
||||
blame_buffer.buffer_snapshot = new_snapshot;
|
||||
blame_buffer.entries = new_entries;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn check_invariants(&mut self, cx: &mut Context<Self>) {
|
||||
self.sync(cx);
|
||||
assert_eq!(
|
||||
self.entries.summary().rows,
|
||||
self.buffer.read(cx).max_point().row + 1
|
||||
);
|
||||
self.sync_all(cx);
|
||||
for (&id, buffer) in &self.buffers {
|
||||
assert_eq!(
|
||||
buffer.entries.summary().rows,
|
||||
self.multi_buffer
|
||||
.upgrade()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.buffer(id)
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.max_point()
|
||||
.row
|
||||
+ 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn generate(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -451,62 +493,105 @@ impl GitBlame {
|
||||
self.changed_while_blurred = true;
|
||||
return;
|
||||
}
|
||||
let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
let snapshot = self.buffer.read(cx).snapshot();
|
||||
let blame = self.project.update(cx, |project, cx| {
|
||||
project.blame_buffer(&self.buffer, None, cx)
|
||||
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
|
||||
return Vec::new();
|
||||
};
|
||||
multi_buffer
|
||||
.read(cx)
|
||||
.all_buffer_ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let buffer = multi_buffer.read(cx).buffer(id)?;
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
|
||||
let blame_buffer = project.blame_buffer(&buffer, None, cx);
|
||||
Some((id, snapshot, buffer_edits, blame_buffer))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let provider_registry = GitHostingProviderRegistry::default_global(cx);
|
||||
|
||||
self.task = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
let (result, errors) = cx
|
||||
.background_spawn({
|
||||
let snapshot = snapshot.clone();
|
||||
async move {
|
||||
let Some(Blame {
|
||||
entries,
|
||||
messages,
|
||||
remote_url,
|
||||
}) = blame.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut res = vec![];
|
||||
let mut errors = vec![];
|
||||
for (id, snapshot, buffer_edits, blame) in blame {
|
||||
match blame.await {
|
||||
Ok(Some(Blame {
|
||||
entries,
|
||||
messages,
|
||||
remote_url,
|
||||
})) => {
|
||||
let entries = build_blame_entry_sum_tree(
|
||||
entries,
|
||||
snapshot.max_point().row,
|
||||
);
|
||||
let commit_details = parse_commit_messages(
|
||||
messages,
|
||||
remote_url,
|
||||
provider_registry.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
|
||||
let commit_details =
|
||||
parse_commit_messages(messages, remote_url, provider_registry).await;
|
||||
|
||||
anyhow::Ok(Some((entries, commit_details)))
|
||||
res.push((
|
||||
id,
|
||||
snapshot,
|
||||
buffer_edits,
|
||||
Some(entries),
|
||||
commit_details,
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
res.push((id, snapshot, buffer_edits, None, Default::default()))
|
||||
}
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
(res, errors)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| match result {
|
||||
Ok(None) => {
|
||||
// Nothing to do, e.g. no repository found
|
||||
this.update(cx, |this, cx| {
|
||||
this.buffers.clear();
|
||||
for (id, snapshot, buffer_edits, entries, commit_details) in result {
|
||||
let Some(entries) = entries else {
|
||||
continue;
|
||||
};
|
||||
this.buffers.insert(
|
||||
id,
|
||||
GitBlameBuffer {
|
||||
buffer_edits,
|
||||
buffer_snapshot: snapshot,
|
||||
entries,
|
||||
commit_details,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(Some((entries, commit_details))) => {
|
||||
this.buffer_edits = buffer_edits;
|
||||
this.buffer_snapshot = snapshot;
|
||||
this.entries = entries;
|
||||
this.commit_details = commit_details;
|
||||
this.generated = true;
|
||||
cx.notify();
|
||||
cx.notify();
|
||||
if !errors.is_empty() {
|
||||
this.project.update(cx, |_, cx| {
|
||||
if this.user_triggered {
|
||||
log::error!("failed to get git blame data: {errors:?}");
|
||||
let notification = errors
|
||||
.into_iter()
|
||||
.format_with(",", |e, f| f(&format_args!("{:#}", e)))
|
||||
.to_string();
|
||||
cx.emit(project::Event::Toast {
|
||||
notification_id: "git-blame".into(),
|
||||
message: notification,
|
||||
});
|
||||
} else {
|
||||
// If we weren't triggered by a user, we just log errors in the background, instead of sending
|
||||
// notifications.
|
||||
log::debug!("failed to get git blame data: {errors:?}");
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(error) => this.project.update(cx, |_, cx| {
|
||||
if this.user_triggered {
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
let notification = format!("{:#}", error).trim().to_string();
|
||||
cx.emit(project::Event::Toast {
|
||||
notification_id: "git-blame".into(),
|
||||
message: notification,
|
||||
});
|
||||
} else {
|
||||
// If we weren't triggered by a user, we just log errors in the background, instead of sending
|
||||
// notifications.
|
||||
log::debug!("failed to get git blame data: {error:?}");
|
||||
}
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -520,7 +605,7 @@ impl GitBlame {
|
||||
this.update(cx, |this, cx| {
|
||||
this.generate(cx);
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +744,9 @@ mod tests {
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
.into_iter()
|
||||
.map(|it| Some((buffer_id, it?)))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -705,6 +793,7 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
|
||||
let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx));
|
||||
|
||||
@@ -785,6 +874,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
|
||||
@@ -806,14 +896,14 @@ mod tests {
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
Some(blame_entry("1b1b1b", 0..1)),
|
||||
Some(blame_entry("0d0d0d", 1..2)),
|
||||
Some(blame_entry("3a3a3a", 2..3)),
|
||||
Some((buffer_id, blame_entry("1b1b1b", 0..1))),
|
||||
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
|
||||
None,
|
||||
None,
|
||||
Some(blame_entry("3a3a3a", 5..6)),
|
||||
Some(blame_entry("0d0d0d", 6..7)),
|
||||
Some(blame_entry("3a3a3a", 7..8)),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 5..6))),
|
||||
Some((buffer_id, blame_entry("0d0d0d", 6..7))),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 7..8))),
|
||||
]
|
||||
);
|
||||
// Subset of lines
|
||||
@@ -831,8 +921,8 @@ mod tests {
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
Some(blame_entry("0d0d0d", 1..2)),
|
||||
Some(blame_entry("3a3a3a", 2..3)),
|
||||
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
|
||||
None
|
||||
]
|
||||
);
|
||||
@@ -852,7 +942,7 @@ mod tests {
|
||||
cx
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(blame_entry("0d0d0d", 1..2)), None, None]
|
||||
vec![Some((buffer_id, blame_entry("0d0d0d", 1..2))), None, None]
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -895,6 +985,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
|
||||
@@ -1061,8 +1152,9 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mbuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
let git_blame = cx.new(|cx| GitBlame::new(mbuffer.clone(), project, false, true, cx));
|
||||
cx.executor().run_until_parked();
|
||||
git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
|
||||
|
||||
|
||||
@@ -289,12 +289,114 @@ pub fn previous_word_start_or_newline(map: &DisplaySnapshot, point: DisplayPoint
|
||||
let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
|
||||
|
||||
find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
|
||||
(classifier.kind(left) != classifier.kind(right) && !right.is_whitespace())
|
||||
(classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right))
|
||||
|| left == '\n'
|
||||
|| right == '\n'
|
||||
})
|
||||
}
|
||||
|
||||
/// Text movements are too greedy, making deletions too greedy too.
|
||||
/// Makes deletions more ergonomic by potentially reducing the deletion range based on its text contents:
|
||||
/// * whitespace sequences with length >= 2 stop the deletion after removal (despite movement jumping over the word behind the whitespaces)
|
||||
/// * brackets stop the deletion after removal (despite movement currently not accounting for these and jumping over)
|
||||
pub fn adjust_greedy_deletion(
|
||||
map: &DisplaySnapshot,
|
||||
delete_from: DisplayPoint,
|
||||
delete_until: DisplayPoint,
|
||||
ignore_brackets: bool,
|
||||
) -> DisplayPoint {
|
||||
if delete_from == delete_until {
|
||||
return delete_until;
|
||||
}
|
||||
let is_backward = delete_from > delete_until;
|
||||
let delete_range = if is_backward {
|
||||
map.display_point_to_point(delete_until, Bias::Left)
|
||||
.to_offset(&map.buffer_snapshot)
|
||||
..map
|
||||
.display_point_to_point(delete_from, Bias::Right)
|
||||
.to_offset(&map.buffer_snapshot)
|
||||
} else {
|
||||
map.display_point_to_point(delete_from, Bias::Left)
|
||||
.to_offset(&map.buffer_snapshot)
|
||||
..map
|
||||
.display_point_to_point(delete_until, Bias::Right)
|
||||
.to_offset(&map.buffer_snapshot)
|
||||
};
|
||||
|
||||
let trimmed_delete_range = if ignore_brackets {
|
||||
delete_range
|
||||
} else {
|
||||
let brackets_in_delete_range = map
|
||||
.buffer_snapshot
|
||||
.bracket_ranges(delete_range.clone())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|(left_bracket, right_bracket)| {
|
||||
[
|
||||
left_bracket.start,
|
||||
left_bracket.end,
|
||||
right_bracket.start,
|
||||
right_bracket.end,
|
||||
]
|
||||
})
|
||||
.filter(|&bracket| delete_range.start < bracket && bracket < delete_range.end);
|
||||
let closest_bracket = if is_backward {
|
||||
brackets_in_delete_range.max()
|
||||
} else {
|
||||
brackets_in_delete_range.min()
|
||||
};
|
||||
|
||||
if is_backward {
|
||||
closest_bracket.unwrap_or(delete_range.start)..delete_range.end
|
||||
} else {
|
||||
delete_range.start..closest_bracket.unwrap_or(delete_range.end)
|
||||
}
|
||||
};
|
||||
|
||||
let mut whitespace_sequences = Vec::new();
|
||||
let mut current_offset = trimmed_delete_range.start;
|
||||
let mut whitespace_sequence_length = 0;
|
||||
let mut whitespace_sequence_start = 0;
|
||||
for ch in map
|
||||
.buffer_snapshot
|
||||
.text_for_range(trimmed_delete_range.clone())
|
||||
.flat_map(str::chars)
|
||||
{
|
||||
if ch.is_whitespace() {
|
||||
if whitespace_sequence_length == 0 {
|
||||
whitespace_sequence_start = current_offset;
|
||||
}
|
||||
whitespace_sequence_length += 1;
|
||||
} else {
|
||||
if whitespace_sequence_length >= 2 {
|
||||
whitespace_sequences.push((whitespace_sequence_start, current_offset));
|
||||
}
|
||||
whitespace_sequence_start = 0;
|
||||
whitespace_sequence_length = 0;
|
||||
}
|
||||
current_offset += ch.len_utf8();
|
||||
}
|
||||
if whitespace_sequence_length >= 2 {
|
||||
whitespace_sequences.push((whitespace_sequence_start, current_offset));
|
||||
}
|
||||
|
||||
let closest_whitespace_end = if is_backward {
|
||||
whitespace_sequences.last().map(|&(start, _)| start)
|
||||
} else {
|
||||
whitespace_sequences.first().map(|&(_, end)| end)
|
||||
};
|
||||
|
||||
closest_whitespace_end
|
||||
.unwrap_or_else(|| {
|
||||
if is_backward {
|
||||
trimmed_delete_range.start
|
||||
} else {
|
||||
trimmed_delete_range.end
|
||||
}
|
||||
})
|
||||
.to_display_point(map)
|
||||
}
|
||||
|
||||
/// Returns a position of the previous subword boundary, where a subword is defined as a run of
|
||||
/// word characters of the same "subkind" - where subcharacter kinds are '_' character,
|
||||
/// lowerspace characters and uppercase characters.
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SelectionEffects, SemanticsProvider};
|
||||
use buffer_diff::BufferDiff;
|
||||
use collections::HashSet;
|
||||
use futures::{channel::mpsc, future::join_all};
|
||||
use gpui::{App, Entity, EventEmitter, Focusable, Render, Subscription, Task};
|
||||
use language::{Buffer, BufferEvent, Capability};
|
||||
use multi_buffer::{ExcerptRange, MultiBuffer};
|
||||
use project::Project;
|
||||
use smol::stream::StreamExt;
|
||||
use std::{any::TypeId, ops::Range, rc::Rc, time::Duration};
|
||||
use text::ToOffset;
|
||||
use ui::{ButtonLike, KeyBinding, prelude::*};
|
||||
use workspace::{
|
||||
Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
|
||||
item::SaveOptions, searchable::SearchableItemHandle,
|
||||
};
|
||||
|
||||
pub struct ProposedChangesEditor {
|
||||
editor: Entity<Editor>,
|
||||
multibuffer: Entity<MultiBuffer>,
|
||||
title: SharedString,
|
||||
buffer_entries: Vec<BufferEntry>,
|
||||
_recalculate_diffs_task: Task<Option<()>>,
|
||||
recalculate_diffs_tx: mpsc::UnboundedSender<RecalculateDiff>,
|
||||
}
|
||||
|
||||
pub struct ProposedChangeLocation<T> {
|
||||
pub buffer: Entity<Buffer>,
|
||||
pub ranges: Vec<Range<T>>,
|
||||
}
|
||||
|
||||
struct BufferEntry {
|
||||
base: Entity<Buffer>,
|
||||
branch: Entity<Buffer>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
pub struct ProposedChangesEditorToolbar {
|
||||
current_editor: Option<Entity<ProposedChangesEditor>>,
|
||||
}
|
||||
|
||||
struct RecalculateDiff {
|
||||
buffer: Entity<Buffer>,
|
||||
debounce: bool,
|
||||
}
|
||||
|
||||
/// A provider of code semantics for branch buffers.
|
||||
///
|
||||
/// Requests in edited regions will return nothing, but requests in unchanged
|
||||
/// regions will be translated into the base buffer's coordinates.
|
||||
struct BranchBufferSemanticsProvider(Rc<dyn SemanticsProvider>);
|
||||
|
||||
impl ProposedChangesEditor {
|
||||
pub fn new<T: Clone + ToOffset>(
|
||||
title: impl Into<SharedString>,
|
||||
locations: Vec<ProposedChangeLocation<T>>,
|
||||
project: Option<Entity<Project>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
|
||||
let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
|
||||
let mut this = Self {
|
||||
editor: cx.new(|cx| {
|
||||
let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, window, cx);
|
||||
editor.set_expand_all_diff_hunks(cx);
|
||||
editor.set_completion_provider(None);
|
||||
editor.clear_code_action_providers();
|
||||
editor.set_semantics_provider(
|
||||
editor
|
||||
.semantics_provider()
|
||||
.map(|provider| Rc::new(BranchBufferSemanticsProvider(provider)) as _),
|
||||
);
|
||||
editor
|
||||
}),
|
||||
multibuffer,
|
||||
title: title.into(),
|
||||
buffer_entries: Vec::new(),
|
||||
recalculate_diffs_tx,
|
||||
_recalculate_diffs_task: cx.spawn_in(window, async move |this, cx| {
|
||||
let mut buffers_to_diff = HashSet::default();
|
||||
while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await {
|
||||
buffers_to_diff.insert(recalculate_diff.buffer);
|
||||
|
||||
while recalculate_diff.debounce {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(50))
|
||||
.await;
|
||||
let mut had_further_changes = false;
|
||||
while let Ok(next_recalculate_diff) = recalculate_diffs_rx.try_next() {
|
||||
let next_recalculate_diff = next_recalculate_diff?;
|
||||
recalculate_diff.debounce &= next_recalculate_diff.debounce;
|
||||
buffers_to_diff.insert(next_recalculate_diff.buffer);
|
||||
had_further_changes = true;
|
||||
}
|
||||
if !had_further_changes {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let recalculate_diff_futures = this
|
||||
.update(cx, |this, cx| {
|
||||
buffers_to_diff
|
||||
.drain()
|
||||
.filter_map(|buffer| {
|
||||
let buffer = buffer.read(cx);
|
||||
let base_buffer = buffer.base_buffer()?;
|
||||
let buffer = buffer.text_snapshot();
|
||||
let diff =
|
||||
this.multibuffer.read(cx).diff_for(buffer.remote_id())?;
|
||||
Some(diff.update(cx, |diff, cx| {
|
||||
diff.set_base_text_buffer(base_buffer.clone(), buffer, cx)
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
join_all(recalculate_diff_futures).await;
|
||||
}
|
||||
None
|
||||
}),
|
||||
};
|
||||
this.reset_locations(locations, window, cx);
|
||||
this
|
||||
}
|
||||
|
||||
pub fn branch_buffer_for_base(&self, base_buffer: &Entity<Buffer>) -> Option<Entity<Buffer>> {
|
||||
self.buffer_entries.iter().find_map(|entry| {
|
||||
if &entry.base == base_buffer {
|
||||
Some(entry.branch.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
|
||||
self.title = title;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn reset_locations<T: Clone + ToOffset>(
|
||||
&mut self,
|
||||
locations: Vec<ProposedChangeLocation<T>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Undo all branch changes
|
||||
for entry in &self.buffer_entries {
|
||||
let base_version = entry.base.read(cx).version();
|
||||
entry.branch.update(cx, |buffer, cx| {
|
||||
let undo_counts = buffer
|
||||
.operations()
|
||||
.iter()
|
||||
.filter_map(|(timestamp, _)| {
|
||||
if !base_version.observed(*timestamp) {
|
||||
Some((*timestamp, u32::MAX))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
buffer.undo_operations(undo_counts, cx);
|
||||
});
|
||||
}
|
||||
|
||||
self.multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.clear(cx);
|
||||
});
|
||||
|
||||
let mut buffer_entries = Vec::new();
|
||||
let mut new_diffs = Vec::new();
|
||||
for location in locations {
|
||||
let branch_buffer;
|
||||
if let Some(ix) = self
|
||||
.buffer_entries
|
||||
.iter()
|
||||
.position(|entry| entry.base == location.buffer)
|
||||
{
|
||||
let entry = self.buffer_entries.remove(ix);
|
||||
branch_buffer = entry.branch.clone();
|
||||
buffer_entries.push(entry);
|
||||
} else {
|
||||
branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
|
||||
new_diffs.push(cx.new(|cx| {
|
||||
let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx);
|
||||
let _ = diff.set_base_text_buffer(
|
||||
location.buffer.clone(),
|
||||
branch_buffer.read(cx).text_snapshot(),
|
||||
cx,
|
||||
);
|
||||
diff
|
||||
}));
|
||||
buffer_entries.push(BufferEntry {
|
||||
branch: branch_buffer.clone(),
|
||||
base: location.buffer.clone(),
|
||||
_subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event),
|
||||
});
|
||||
}
|
||||
|
||||
self.multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.push_excerpts(
|
||||
branch_buffer,
|
||||
location
|
||||
.ranges
|
||||
.into_iter()
|
||||
.map(|range| ExcerptRange::new(range)),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
self.buffer_entries = buffer_entries;
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
|
||||
selections.refresh()
|
||||
});
|
||||
editor.buffer.update(cx, |buffer, cx| {
|
||||
for diff in new_diffs {
|
||||
buffer.add_diff(diff, cx)
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub fn recalculate_all_buffer_diffs(&self) {
|
||||
for (ix, entry) in self.buffer_entries.iter().enumerate().rev() {
|
||||
self.recalculate_diffs_tx
|
||||
.unbounded_send(RecalculateDiff {
|
||||
buffer: entry.branch.clone(),
|
||||
debounce: ix > 0,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_buffer_event(
|
||||
&mut self,
|
||||
buffer: Entity<Buffer>,
|
||||
event: &BufferEvent,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
if let BufferEvent::Operation { .. } = event {
|
||||
self.recalculate_diffs_tx
|
||||
.unbounded_send(RecalculateDiff {
|
||||
buffer,
|
||||
debounce: true,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProposedChangesEditor {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.key_context("ProposedChangesEditor")
|
||||
.child(self.editor.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for ProposedChangesEditor {
|
||||
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
||||
self.editor.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<EditorEvent> for ProposedChangesEditor {}
|
||||
|
||||
impl Item for ProposedChangesEditor {
|
||||
type Event = EditorEvent;
|
||||
|
||||
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
|
||||
Some(Icon::new(IconName::Diff))
|
||||
}
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
|
||||
Some(Box::new(self.editor.clone()))
|
||||
}
|
||||
|
||||
fn act_as_type<'a>(
|
||||
&'a self,
|
||||
type_id: TypeId,
|
||||
self_handle: &'a Entity<Self>,
|
||||
_: &'a App,
|
||||
) -> Option<gpui::AnyView> {
|
||||
if type_id == TypeId::of::<Self>() {
|
||||
Some(self_handle.to_any())
|
||||
} else if type_id == TypeId::of::<Editor>() {
|
||||
Some(self.editor.to_any())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn added_to_workspace(
|
||||
&mut self,
|
||||
workspace: &mut Workspace,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
Item::added_to_workspace(editor, workspace, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.deactivated(window, cx));
|
||||
}
|
||||
|
||||
fn navigate(
|
||||
&mut self,
|
||||
data: Box<dyn std::any::Any>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
self.editor
|
||||
.update(cx, |editor, cx| Item::navigate(editor, data, window, cx))
|
||||
}
|
||||
|
||||
fn set_nav_history(
|
||||
&mut self,
|
||||
nav_history: workspace::ItemNavHistory,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
Item::set_nav_history(editor, nav_history, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn can_save(&self, cx: &App) -> bool {
|
||||
self.editor.read(cx).can_save(cx)
|
||||
}
|
||||
|
||||
fn save(
|
||||
&mut self,
|
||||
options: SaveOptions,
|
||||
project: Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
Item::save(editor, options, project, window, cx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProposedChangesEditorToolbar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_editor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_toolbar_item_location(&self) -> ToolbarItemLocation {
|
||||
if self.current_editor.is_some() {
|
||||
ToolbarItemLocation::PrimaryRight
|
||||
} else {
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProposedChangesEditorToolbar {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All"));
|
||||
|
||||
match &self.current_editor {
|
||||
Some(editor) => {
|
||||
let focus_handle = editor.focus_handle(cx);
|
||||
let keybinding =
|
||||
KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, window, cx)
|
||||
.map(|binding| binding.into_any_element());
|
||||
|
||||
button_like.children(keybinding).on_click({
|
||||
move |_event, window, cx| {
|
||||
focus_handle.dispatch_action(&ApplyAllDiffHunks, window, cx)
|
||||
}
|
||||
})
|
||||
}
|
||||
None => button_like.disabled(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for ProposedChangesEditorToolbar {}
|
||||
|
||||
impl ToolbarItemView for ProposedChangesEditorToolbar {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn workspace::ItemHandle>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> workspace::ToolbarItemLocation {
|
||||
self.current_editor =
|
||||
active_pane_item.and_then(|item| item.downcast::<ProposedChangesEditor>());
|
||||
self.get_toolbar_item_location()
|
||||
}
|
||||
}
|
||||
|
||||
impl BranchBufferSemanticsProvider {
|
||||
fn to_base(
|
||||
&self,
|
||||
buffer: &Entity<Buffer>,
|
||||
positions: &[text::Anchor],
|
||||
cx: &App,
|
||||
) -> Option<Entity<Buffer>> {
|
||||
let base_buffer = buffer.read(cx).base_buffer()?;
|
||||
let version = base_buffer.read(cx).version();
|
||||
if positions
|
||||
.iter()
|
||||
.any(|position| !version.observed(position.timestamp))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(base_buffer)
|
||||
}
|
||||
}
|
||||
|
||||
impl SemanticsProvider for BranchBufferSemanticsProvider {
|
||||
fn hover(
|
||||
&self,
|
||||
buffer: &Entity<Buffer>,
|
||||
position: text::Anchor,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<Option<Vec<project::Hover>>>> {
|
||||
let buffer = self.to_base(buffer, &[position], cx)?;
|
||||
self.0.hover(&buffer, position, cx)
|
||||
}
|
||||
|
||||
fn inlay_hints(
|
||||
&self,
|
||||
buffer: Entity<Buffer>,
|
||||
range: Range<text::Anchor>,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
|
||||
let buffer = self.to_base(&buffer, &[range.start, range.end], cx)?;
|
||||
self.0.inlay_hints(buffer, range, cx)
|
||||
}
|
||||
|
||||
fn inline_values(
|
||||
&self,
|
||||
_: Entity<Buffer>,
|
||||
_: Range<text::Anchor>,
|
||||
_: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_inlay_hint(
|
||||
&self,
|
||||
hint: project::InlayHint,
|
||||
buffer: Entity<Buffer>,
|
||||
server_id: lsp::LanguageServerId,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<project::InlayHint>>> {
|
||||
let buffer = self.to_base(&buffer, &[], cx)?;
|
||||
self.0.resolve_inlay_hint(hint, buffer, server_id, cx)
|
||||
}
|
||||
|
||||
fn supports_inlay_hints(&self, buffer: &Entity<Buffer>, cx: &mut App) -> bool {
|
||||
if let Some(buffer) = self.to_base(buffer, &[], cx) {
|
||||
self.0.supports_inlay_hints(&buffer, cx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn document_highlights(
|
||||
&self,
|
||||
buffer: &Entity<Buffer>,
|
||||
position: text::Anchor,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Vec<project::DocumentHighlight>>>> {
|
||||
let buffer = self.to_base(buffer, &[position], cx)?;
|
||||
self.0.document_highlights(&buffer, position, cx)
|
||||
}
|
||||
|
||||
fn definitions(
|
||||
&self,
|
||||
buffer: &Entity<Buffer>,
|
||||
position: text::Anchor,
|
||||
kind: crate::GotoDefinitionKind,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Option<Vec<project::LocationLink>>>>> {
|
||||
let buffer = self.to_base(buffer, &[position], cx)?;
|
||||
self.0.definitions(&buffer, position, kind, cx)
|
||||
}
|
||||
|
||||
fn range_for_rename(
|
||||
&self,
|
||||
_: &Entity<Buffer>,
|
||||
_: text::Anchor,
|
||||
_: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Option<Range<text::Anchor>>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn perform_rename(
|
||||
&self,
|
||||
_: &Entity<Buffer>,
|
||||
_: text::Anchor,
|
||||
_: String,
|
||||
_: &mut App,
|
||||
) -> Option<Task<anyhow::Result<project::ProjectTransaction>>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,10 @@ pub struct ClaudeCodeFeatureFlag;
|
||||
|
||||
impl FeatureFlag for ClaudeCodeFeatureFlag {
|
||||
const NAME: &'static str = "claude-code";
|
||||
|
||||
fn enabled_for_all() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub trait FeatureFlagViewExt<V: 'static> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use schemars::JsonSchema;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources, SettingsUi};
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, SettingsUi)]
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FileFinderSettings {
|
||||
pub file_icons: bool,
|
||||
pub modal_max_width: Option<FileFinderWidth>,
|
||||
@@ -11,7 +11,7 @@ pub struct FileFinderSettings {
|
||||
pub include_ignored: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
pub struct FileFinderSettingsContent {
|
||||
/// Whether to show file icons in the file finder.
|
||||
///
|
||||
|
||||
@@ -107,6 +107,18 @@ impl GitRepository for FakeGitRepository {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn diff_to_commit(
|
||||
&self,
|
||||
_commit: String,
|
||||
_cx: AsyncApp,
|
||||
) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn merge_base(&self, _commit_a: String, _commit_b: String) -> BoxFuture<'_, Option<String>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn load_commit(
|
||||
&self,
|
||||
_commit: String,
|
||||
|
||||
@@ -309,6 +309,8 @@ pub trait GitRepository: Send + Sync {
|
||||
/// Also returns `None` for symlinks.
|
||||
fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
|
||||
|
||||
fn merge_base(&self, commit_a: String, commit_b: String) -> BoxFuture<'_, Option<String>>;
|
||||
|
||||
fn set_index_text(
|
||||
&self,
|
||||
path: RepoPath,
|
||||
@@ -360,6 +362,7 @@ pub trait GitRepository: Send + Sync {
|
||||
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
|
||||
|
||||
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
|
||||
fn diff_to_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
|
||||
fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
|
||||
|
||||
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
|
||||
@@ -614,6 +617,115 @@ impl GitRepository for RealGitRepository {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn merge_base(&self, commit_a: String, commit_b: String) -> BoxFuture<'_, Option<String>> {
|
||||
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
|
||||
else {
|
||||
return future::ready(None).boxed();
|
||||
};
|
||||
let git = GitBinary::new(
|
||||
self.git_binary_path.clone(),
|
||||
working_directory,
|
||||
self.executor.clone(),
|
||||
);
|
||||
async move {
|
||||
let merge_base = git
|
||||
.run(&["merge-base", &commit_a, &commit_b])
|
||||
.await
|
||||
.log_err()?;
|
||||
Some(merge_base.to_string())
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn diff_to_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
|
||||
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
|
||||
else {
|
||||
return future::ready(Err(anyhow!("no working directory"))).boxed();
|
||||
};
|
||||
cx.background_spawn(async move {
|
||||
let diff_output = util::command::new_std_command("git")
|
||||
.current_dir(&working_directory)
|
||||
.args([
|
||||
"--no-optional-locks",
|
||||
"diff",
|
||||
"--format=%P",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
"--name-status",
|
||||
])
|
||||
.arg(&commit)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("starting git show process")?;
|
||||
|
||||
let diff_stdout = String::from_utf8_lossy(&diff_output.stdout);
|
||||
dbg!(&diff_stdout);
|
||||
let changes = parse_git_diff_name_status(&diff_stdout);
|
||||
|
||||
let mut cat_file_process = util::command::new_std_command("git")
|
||||
.current_dir(&working_directory)
|
||||
.args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("starting git cat-file process")?;
|
||||
|
||||
use std::io::Write as _;
|
||||
let mut files = Vec::<CommitFile>::new();
|
||||
let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
|
||||
let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
|
||||
let mut info_line = String::new();
|
||||
let mut newline = [b'\0'];
|
||||
for (path, status_code) in changes {
|
||||
match status_code {
|
||||
StatusCode::Modified => {
|
||||
writeln!(&mut stdin, "{commit}:{}", path.display())?;
|
||||
}
|
||||
StatusCode::Added => {
|
||||
files.push(CommitFile {
|
||||
path: path.into(),
|
||||
old_text: None,
|
||||
new_text: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
StatusCode::Deleted => {
|
||||
writeln!(&mut stdin, "{commit}:{}", path.display())?;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
stdin.flush()?;
|
||||
|
||||
info_line.clear();
|
||||
stdout.read_line(&mut info_line)?;
|
||||
|
||||
dbg!(&info_line);
|
||||
|
||||
let len = info_line.trim_end().parse().with_context(|| {
|
||||
format!("invalid object size output from cat-file {info_line}")
|
||||
})?;
|
||||
let mut text = vec![0; len];
|
||||
stdout.read_exact(&mut text)?;
|
||||
stdout.read_exact(&mut newline)?;
|
||||
|
||||
let text = String::from_utf8_lossy(&text).to_string();
|
||||
dbg!(&text);
|
||||
|
||||
files.push(CommitFile {
|
||||
path: path.into(),
|
||||
old_text: Some(text),
|
||||
new_text: None,
|
||||
})
|
||||
}
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
|
||||
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
|
||||
else {
|
||||
|
||||
@@ -341,7 +341,6 @@ impl PickerDelegate for BranchListDelegate {
|
||||
};
|
||||
picker
|
||||
.update(cx, |picker, _| {
|
||||
#[allow(clippy::nonminimal_bool)]
|
||||
if !query.is_empty()
|
||||
&& !matches
|
||||
.first()
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::commit_modal::CommitModal;
|
||||
use crate::commit_tooltip::CommitTooltip;
|
||||
use crate::commit_view::CommitView;
|
||||
use crate::git_panel_settings::StatusStyle;
|
||||
use crate::project_diff::{self, Diff, ProjectDiff};
|
||||
use crate::project_diff::{self, Diff, DiffBaseKind, ProjectDiff};
|
||||
use crate::remote_output::{self, RemoteAction, SuccessMessage};
|
||||
use crate::{branch_picker, picker_prompt, render_remote_button};
|
||||
use crate::{
|
||||
@@ -31,11 +31,11 @@ use git::{
|
||||
UnstageAll,
|
||||
};
|
||||
use gpui::{
|
||||
Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
|
||||
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
|
||||
ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point,
|
||||
PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
|
||||
WeakEntity, actions, anchored, deferred, percentage, uniform_list,
|
||||
Action, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, DismissEvent, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
|
||||
ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
|
||||
Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred,
|
||||
uniform_list,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, File};
|
||||
@@ -63,8 +63,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize};
|
||||
use strum::{IntoEnumIterator, VariantNames};
|
||||
use time::OffsetDateTime;
|
||||
use ui::{
|
||||
Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
|
||||
ScrollbarState, SplitButton, Tooltip, prelude::*,
|
||||
Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize,
|
||||
PopoverMenu, Scrollbar, ScrollbarState, SplitButton, Tooltip, prelude::*,
|
||||
};
|
||||
use util::{ResultExt, TryFutureExt, maybe};
|
||||
use workspace::SERIALIZATION_THROTTLE_TIME;
|
||||
@@ -937,7 +937,13 @@ impl GitPanel {
|
||||
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
|
||||
ProjectDiff::deploy_at(
|
||||
workspace,
|
||||
DiffBaseKind::Head,
|
||||
Some(entry.clone()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
self.focus_handle.focus(window);
|
||||
@@ -3088,13 +3094,7 @@ impl GitPanel {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Info)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(Transformation::rotate(percentage(delta)))
|
||||
},
|
||||
),
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(
|
||||
Label::new("Generating Commit...")
|
||||
|
||||
@@ -36,7 +36,7 @@ pub enum StatusStyle {
|
||||
LabelColor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
|
||||
pub struct GitPanelSettingsContent {
|
||||
/// Whether to show the panel button in the status bar.
|
||||
///
|
||||
@@ -77,7 +77,7 @@ pub struct GitPanelSettingsContent {
|
||||
pub collapse_untracked_diff: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, SettingsUi)]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct GitPanelSettings {
|
||||
pub button: bool,
|
||||
pub dock: DockPosition,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
remote_button::{render_publish_button, render_push_button},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
|
||||
use buffer_diff::{BufferDiff, BufferDiffSnapshot, DiffHunkSecondaryStatus};
|
||||
use collections::HashSet;
|
||||
use editor::{
|
||||
Editor, EditorEvent, SelectionEffects,
|
||||
@@ -17,7 +17,7 @@ use futures::StreamExt;
|
||||
use git::{
|
||||
Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
|
||||
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
|
||||
status::FileStatus,
|
||||
status::{FileStatus, TrackedStatus},
|
||||
};
|
||||
use gpui::{
|
||||
Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
|
||||
@@ -30,8 +30,11 @@ use project::{
|
||||
git_store::{GitStore, GitStoreEvent, RepositoryEvent},
|
||||
};
|
||||
use settings::{Settings, SettingsStore};
|
||||
use std::any::{Any, TypeId};
|
||||
use std::ops::Range;
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
sync::Arc,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
|
||||
use util::ResultExt as _;
|
||||
@@ -48,7 +51,9 @@ actions!(
|
||||
/// Shows the diff between the working directory and the index.
|
||||
Diff,
|
||||
/// Adds files to the git staging area.
|
||||
Add
|
||||
Add,
|
||||
/// Shows the diff between the working directory and the default branch.
|
||||
DiffToDefaultBranch,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -61,10 +66,17 @@ pub struct ProjectDiff {
|
||||
focus_handle: FocusHandle,
|
||||
update_needed: postage::watch::Sender<()>,
|
||||
pending_scroll: Option<PathKey>,
|
||||
diff_base_kind: DiffBaseKind,
|
||||
_task: Task<Result<()>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub enum DiffBaseKind {
|
||||
Head,
|
||||
MergeBaseOfDefaultBranch,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DiffBuffer {
|
||||
path_key: PathKey,
|
||||
@@ -80,6 +92,7 @@ const NEW_NAMESPACE: u32 = 3;
|
||||
impl ProjectDiff {
|
||||
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
|
||||
workspace.register_action(Self::deploy);
|
||||
workspace.register_action(Self::diff_to_default_branch);
|
||||
workspace.register_action(|workspace, _: &Add, window, cx| {
|
||||
Self::deploy(workspace, &Diff, window, cx);
|
||||
});
|
||||
@@ -92,39 +105,66 @@ impl ProjectDiff {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
Self::deploy_at(workspace, None, window, cx)
|
||||
Self::deploy_at(workspace, DiffBaseKind::Head, None, window, cx)
|
||||
}
|
||||
|
||||
fn diff_to_default_branch(
|
||||
workspace: &mut Workspace,
|
||||
_: &DiffToDefaultBranch,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
Self::deploy_at(
|
||||
workspace,
|
||||
DiffBaseKind::MergeBaseOfDefaultBranch,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn deploy_at(
|
||||
workspace: &mut Workspace,
|
||||
diff_base_kind: DiffBaseKind,
|
||||
entry: Option<GitStatusEntry>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
telemetry::event!(
|
||||
"Git Diff Opened",
|
||||
match diff_base_kind {
|
||||
DiffBaseKind::MergeBaseOfDefaultBranch => "Git Branch Diff Opened",
|
||||
DiffBaseKind::Head => "Git Diff Opened",
|
||||
},
|
||||
source = if entry.is_some() {
|
||||
"Git Panel"
|
||||
} else {
|
||||
"Action"
|
||||
}
|
||||
);
|
||||
let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
existing
|
||||
} else {
|
||||
let workspace_handle = cx.entity();
|
||||
let project_diff =
|
||||
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(project_diff.clone()),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
project_diff
|
||||
};
|
||||
let project_diff =
|
||||
if let Some(existing) = Self::existing_project_diff(workspace, diff_base_kind, cx) {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
existing
|
||||
} else {
|
||||
let workspace_handle = cx.entity();
|
||||
let project_diff = cx.new(|cx| {
|
||||
Self::new(
|
||||
workspace.project().clone(),
|
||||
workspace_handle,
|
||||
diff_base_kind,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(project_diff.clone()),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
project_diff
|
||||
};
|
||||
if let Some(entry) = entry {
|
||||
project_diff.update(cx, |project_diff, cx| {
|
||||
project_diff.move_to_entry(entry, window, cx);
|
||||
@@ -132,6 +172,16 @@ impl ProjectDiff {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn existing_project_diff(
|
||||
workspace: &mut Workspace,
|
||||
diff_base_kind: DiffBaseKind,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Option<Entity<Self>> {
|
||||
workspace
|
||||
.items_of_type::<Self>(cx)
|
||||
.find(|item| item.read(cx).diff_base_kind == diff_base_kind)
|
||||
}
|
||||
|
||||
pub fn autoscroll(&self, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.request_autoscroll(Autoscroll::fit(), cx);
|
||||
@@ -141,6 +191,7 @@ impl ProjectDiff {
|
||||
fn new(
|
||||
project: Entity<Project>,
|
||||
workspace: Entity<Workspace>,
|
||||
diff_base_kind: DiffBaseKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -152,9 +203,21 @@ impl ProjectDiff {
|
||||
Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
|
||||
diff_display_editor.disable_diagnostics(cx);
|
||||
diff_display_editor.set_expand_all_diff_hunks(cx);
|
||||
diff_display_editor.register_addon(GitPanelAddon {
|
||||
workspace: workspace.downgrade(),
|
||||
});
|
||||
match diff_base_kind {
|
||||
DiffBaseKind::Head => {
|
||||
diff_display_editor.register_addon(GitPanelAddon {
|
||||
workspace: workspace.downgrade(),
|
||||
});
|
||||
}
|
||||
DiffBaseKind::MergeBaseOfDefaultBranch => {
|
||||
diff_display_editor.start_temporary_diff_override();
|
||||
diff_display_editor.set_render_diff_hunk_controls(
|
||||
Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
|
||||
cx,
|
||||
);
|
||||
//
|
||||
}
|
||||
}
|
||||
diff_display_editor
|
||||
});
|
||||
window.defer(cx, {
|
||||
@@ -205,7 +268,7 @@ impl ProjectDiff {
|
||||
let (mut send, recv) = postage::watch::channel::<()>();
|
||||
let worker = window.spawn(cx, {
|
||||
let this = cx.weak_entity();
|
||||
async |cx| Self::handle_status_updates(this, recv, cx).await
|
||||
async move |cx| Self::handle_status_updates(this, diff_base_kind, recv, cx).await
|
||||
});
|
||||
// Kick off a refresh immediately
|
||||
*send.borrow_mut() = ();
|
||||
@@ -214,6 +277,7 @@ impl ProjectDiff {
|
||||
project,
|
||||
git_store: git_store.clone(),
|
||||
workspace: workspace.downgrade(),
|
||||
diff_base_kind,
|
||||
focus_handle,
|
||||
editor,
|
||||
multibuffer,
|
||||
@@ -351,6 +415,9 @@ impl ProjectDiff {
|
||||
let Some(project_path) = self.active_path(cx) else {
|
||||
return;
|
||||
};
|
||||
if self.diff_base_kind != DiffBaseKind::Head {
|
||||
return;
|
||||
}
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
|
||||
@@ -506,21 +573,149 @@ impl ProjectDiff {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_merge_base_of_default_branch(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let project = self.project.clone();
|
||||
let language_registry = project.read(cx).languages().clone();
|
||||
let Some(repo) = self.git_store.read(cx).active_repository() else {
|
||||
self.multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.clear(cx);
|
||||
});
|
||||
return Task::ready(Ok(()));
|
||||
};
|
||||
let default_branch = repo.update(cx, |repo, _| repo.default_branch());
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let Some(default_branch) = default_branch.await?? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(merge_base) = repo
|
||||
.update(cx, |repo, _| {
|
||||
repo.merge_base("HEAD".to_string(), default_branch.into())
|
||||
})?
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let diff = repo
|
||||
.update(cx, |repo, _| repo.diff_to_commit(merge_base))?
|
||||
.await??;
|
||||
|
||||
for file in diff.files {
|
||||
let Some(path) = repo.update(cx, |repo, cx| {
|
||||
repo.repo_path_to_project_path(&file.path, cx)
|
||||
})?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let open_buffer = project
|
||||
.update(cx, |project, cx| project.open_buffer(path.clone(), cx))?
|
||||
.await;
|
||||
|
||||
let mut status = FileStatus::Tracked(TrackedStatus {
|
||||
index_status: git::status::StatusCode::Unmodified,
|
||||
worktree_status: git::status::StatusCode::Modified,
|
||||
});
|
||||
let buffer = match open_buffer {
|
||||
Ok(buffer) => buffer,
|
||||
Err(err) => {
|
||||
let exists = project.read_with(cx, |project, cx| {
|
||||
project.entry_for_path(&path, cx).is_some()
|
||||
})?;
|
||||
if exists {
|
||||
return Err(err);
|
||||
}
|
||||
status = FileStatus::Tracked(TrackedStatus {
|
||||
index_status: git::status::StatusCode::Unmodified,
|
||||
worktree_status: git::status::StatusCode::Deleted,
|
||||
});
|
||||
cx.new(|cx| Buffer::local("", cx))?
|
||||
}
|
||||
};
|
||||
|
||||
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
|
||||
let namespace = if file.old_text.is_none() {
|
||||
NEW_NAMESPACE
|
||||
} else {
|
||||
TRACKED_NAMESPACE
|
||||
};
|
||||
|
||||
let buffer_diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx))?;
|
||||
buffer_diff
|
||||
.update(cx, |buffer_diff, cx| {
|
||||
buffer_diff.set_base_text(
|
||||
file.old_text.map(Arc::new),
|
||||
buffer_snapshot.language().cloned(),
|
||||
Some(language_registry.clone()),
|
||||
buffer_snapshot.text,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
this.read_with(cx, |this, cx| {
|
||||
BufferDiffSnapshot::new_with_base_buffer(
|
||||
buffer.clone(),
|
||||
base_text,
|
||||
this.base_text().clone(),
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.add_diff(buffer_diff.clone(), cx);
|
||||
});
|
||||
this.register_buffer(
|
||||
DiffBuffer {
|
||||
path_key: PathKey::namespaced(namespace, file.path.0),
|
||||
buffer,
|
||||
diff: buffer_diff,
|
||||
file_status: status,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle_status_updates(
|
||||
this: WeakEntity<Self>,
|
||||
diff_base_kind: DiffBaseKind,
|
||||
mut recv: postage::watch::Receiver<()>,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) -> Result<()> {
|
||||
while (recv.next().await).is_some() {
|
||||
let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
|
||||
for buffer_to_load in buffers_to_load {
|
||||
if let Some(buffer) = buffer_to_load.await.log_err() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
|
||||
.ok();
|
||||
})?;
|
||||
match diff_base_kind {
|
||||
DiffBaseKind::Head => {
|
||||
let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
|
||||
for buffer_to_load in buffers_to_load {
|
||||
if let Some(buffer) = buffer_to_load.await.log_err() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.register_buffer(buffer, window, cx)
|
||||
})
|
||||
.ok();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DiffBaseKind::MergeBaseOfDefaultBranch => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.refresh_merge_base_of_default_branch(window, cx)
|
||||
})?
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.pending_scroll.take();
|
||||
cx.notify();
|
||||
@@ -637,7 +832,15 @@ impl Item for ProjectDiff {
|
||||
Self: Sized,
|
||||
{
|
||||
let workspace = self.workspace.upgrade()?;
|
||||
Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
|
||||
Some(cx.new(|cx| {
|
||||
ProjectDiff::new(
|
||||
self.project.clone(),
|
||||
workspace,
|
||||
self.diff_base_kind,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_dirty(&self, cx: &App) -> bool {
|
||||
@@ -805,7 +1008,16 @@ impl SerializableItem for ProjectDiff {
|
||||
window.spawn(cx, async move |cx| {
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
let workspace_handle = cx.entity();
|
||||
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
|
||||
// todo!()
|
||||
cx.new(|cx| {
|
||||
Self::new(
|
||||
workspace.project().clone(),
|
||||
workspace_handle,
|
||||
DiffBaseKind::Head,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1399,7 +1611,7 @@ mod tests {
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let diff = cx.new_window_entity(|window, cx| {
|
||||
ProjectDiff::new(project.clone(), workspace, window, cx)
|
||||
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
@@ -1454,7 +1666,7 @@ mod tests {
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let diff = cx.new_window_entity(|window, cx| {
|
||||
ProjectDiff::new(project.clone(), workspace, window, cx)
|
||||
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
@@ -1536,7 +1748,7 @@ mod tests {
|
||||
Editor::for_buffer(buffer, Some(project.clone()), window, cx)
|
||||
});
|
||||
let diff = cx.new_window_entity(|window, cx| {
|
||||
ProjectDiff::new(project.clone(), workspace, window, cx)
|
||||
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
@@ -1827,7 +2039,7 @@ mod tests {
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let diff = cx.new_window_entity(|window, cx| {
|
||||
ProjectDiff::new(project.clone(), workspace, window, cx)
|
||||
ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ impl StatusItemView for CursorPosition {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize, SettingsUi)]
|
||||
#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum LineIndicatorFormat {
|
||||
Short,
|
||||
@@ -301,14 +301,14 @@ pub(crate) enum LineIndicatorFormat {
|
||||
Long,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)]
|
||||
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize, SettingsUi)]
|
||||
#[serde(transparent)]
|
||||
pub(crate) struct LineIndicatorFormatContent(LineIndicatorFormat);
|
||||
|
||||
impl Settings for LineIndicatorFormat {
|
||||
const KEY: Option<&'static str> = Some("line_indicator_format");
|
||||
|
||||
type FileContent = Option<LineIndicatorFormatContent>;
|
||||
type FileContent = LineIndicatorFormatContent;
|
||||
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
|
||||
let format = [
|
||||
@@ -317,8 +317,8 @@ impl Settings for LineIndicatorFormat {
|
||||
sources.user,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|value| value.copied().flatten())
|
||||
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
|
||||
.find_map(|value| value.copied())
|
||||
.unwrap_or(*sources.default);
|
||||
|
||||
Ok(format.0)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ license = "Apache-2.0"
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["http_client", "font-kit", "wayland", "x11", "windows-manifest"]
|
||||
default = ["font-kit", "wayland", "x11", "windows-manifest"]
|
||||
test-support = [
|
||||
"leak-detection",
|
||||
"collections/test-support",
|
||||
"rand",
|
||||
"util/test-support",
|
||||
"http_client?/test-support",
|
||||
"http_client/test-support",
|
||||
"wayland",
|
||||
"x11",
|
||||
]
|
||||
@@ -91,7 +91,7 @@ derive_more.workspace = true
|
||||
etagere = "0.2"
|
||||
futures.workspace = true
|
||||
gpui_macros.workspace = true
|
||||
http_client = { optional = true, workspace = true }
|
||||
http_client.workspace = true
|
||||
image.workspace = true
|
||||
inventory.workspace = true
|
||||
itertools.workspace = true
|
||||
|
||||
@@ -23,7 +23,7 @@ On macOS, GPUI uses Metal for rendering. In order to use Metal, you need to do t
|
||||
|
||||
- Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store, or from the [Apple Developer](https://developer.apple.com/download/all/) website. Note this requires a developer account.
|
||||
|
||||
> Ensure you launch XCode after installing, and install the macOS components, which is the default option.
|
||||
> Ensure you launch Xcode after installing, and install the macOS components, which is the default option. If you are on macOS 26 (Tahoe) you will need to use `--features gpui/runtime_shaders` or add the feature in the root `Cargo.toml`
|
||||
|
||||
- Install [Xcode command line tools](https://developer.apple.com/xcode/resources/)
|
||||
|
||||
|
||||
@@ -2313,7 +2313,7 @@ pub struct AnyDrag {
|
||||
}
|
||||
|
||||
/// Contains state associated with a tooltip. You'll only need this struct if you're implementing
|
||||
/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip].
|
||||
/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip](crate::Interactivity::tooltip).
|
||||
#[derive(Clone)]
|
||||
pub struct AnyTooltip {
|
||||
/// The view used to display the tooltip
|
||||
|
||||
@@ -218,7 +218,7 @@ impl AsyncApp {
|
||||
Some(read(app.try_global()?, &app))
|
||||
}
|
||||
|
||||
/// A convenience method for [App::update_global]
|
||||
/// A convenience method for [`App::update_global`](BorrowAppContext::update_global)
|
||||
/// for updating the global state of the specified type.
|
||||
pub fn update_global<G: Global, R>(
|
||||
&self,
|
||||
@@ -293,7 +293,7 @@ impl AsyncWindowContext {
|
||||
.update(self, |_, window, cx| read(cx.global(), window, cx))
|
||||
}
|
||||
|
||||
/// A convenience method for [`App::update_global`].
|
||||
/// A convenience method for [`App::update_global`](BorrowAppContext::update_global).
|
||||
/// for updating the global state of the specified type.
|
||||
pub fn update_global<G, R>(
|
||||
&mut self,
|
||||
|
||||
@@ -473,6 +473,11 @@ impl Hsla {
|
||||
self.a == 0.0
|
||||
}
|
||||
|
||||
/// Returns true if the HSLA color is fully opaque, false otherwise.
|
||||
pub fn is_opaque(&self) -> bool {
|
||||
self.a == 1.0
|
||||
}
|
||||
|
||||
/// Blends `other` on top of `self` based on `other`'s alpha value. The resulting color is a combination of `self`'s and `other`'s colors.
|
||||
///
|
||||
/// If `other`'s alpha value is 1.0 or greater, `other` color is fully opaque, thus `other` is returned as the output color.
|
||||
|
||||
@@ -88,9 +88,9 @@ impl Deref for GlobalColors {
|
||||
|
||||
impl Global for GlobalColors {}
|
||||
|
||||
/// Implement this trait to allow global [Color] access via `cx.default_colors()`.
|
||||
/// Implement this trait to allow global [Colors] access via `cx.default_colors()`.
|
||||
pub trait DefaultColors {
|
||||
/// Returns the default [`gpui::Colors`]
|
||||
/// Returns the default [`Colors`]
|
||||
fn default_colors(&self) -> &Arc<Colors>;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
//! tree and any callbacks they have registered with GPUI are dropped and the process repeats.
|
||||
//!
|
||||
//! But some state is too simple and voluminous to store in every view that needs it, e.g.
|
||||
//! whether a hover has been started or not. For this, GPUI provides the [`Element::State`], associated type.
|
||||
//! whether a hover has been started or not. For this, GPUI provides the [`Element::PrepaintState`], associated type.
|
||||
//!
|
||||
//! # Implementing your own elements
|
||||
//!
|
||||
//! Elements are intended to be the low level, imperative API to GPUI. They are responsible for upholding,
|
||||
//! or breaking, GPUI's features as they deem necessary. As an example, most GPUI elements are expected
|
||||
//! to stay in the bounds that their parent element gives them. But with [`WindowContext::break_content_mask`],
|
||||
//! to stay in the bounds that their parent element gives them. But with [`Window::with_content_mask`],
|
||||
//! you can ignore this restriction and paint anywhere inside of the window's bounds. This is useful for overlays
|
||||
//! and popups and anything else that shows up 'on top' of other elements.
|
||||
//! With great power, comes great responsibility.
|
||||
|
||||
@@ -87,7 +87,7 @@ pub trait AnimationExt {
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> AnimationExt for E {}
|
||||
impl<E: IntoElement + 'static> AnimationExt for E {}
|
||||
|
||||
/// A GPUI element that applies an animation to another element
|
||||
pub struct AnimationElement<E> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user