Merge branch 'main' into helix-match-fix to fix the tests

This commit is contained in:
fantacell 2025-07-09 18:50:12 +02:00
commit b893f99d4e
174 changed files with 12635 additions and 1996 deletions

View file

@ -19,6 +19,8 @@ rustflags = [
"windows_slim_errors", # This cfg will reduce the size of `windows::core::Error` from 16 bytes to 4 bytes "windows_slim_errors", # This cfg will reduce the size of `windows::core::Error` from 16 bytes to 4 bytes
"-C", "-C",
"target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows "target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows
"-C",
"link-arg=-fuse-ld=lld",
] ]
[env] [env]

View file

@ -33,7 +33,6 @@ workspace-members = [
"zed_emmet", "zed_emmet",
"zed_glsl", "zed_glsl",
"zed_html", "zed_html",
"perplexity",
"zed_proto", "zed_proto",
"zed_ruff", "zed_ruff",
"slash_commands_example", "slash_commands_example",

View file

@ -0,0 +1,64 @@
name: "Trusted Signing on Windows"
description: "Install trusted signing on Windows."
# Modified from https://github.com/Azure/trusted-signing-action
runs:
using: "composite"
steps:
- name: Set variables
id: set-variables
shell: "pwsh"
run: |
$defaultPath = $env:PSModulePath -split ';' | Select-Object -First 1
"PSMODULEPATH=$defaultPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"TRUSTED_SIGNING_MODULE_VERSION=0.5.3" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"BUILD_TOOLS_NUGET_VERSION=10.0.22621.3233" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"TRUSTED_SIGNING_NUGET_VERSION=1.0.53" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"DOTNET_SIGNCLI_NUGET_VERSION=0.9.1-beta.24469.1" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Cache TrustedSigning PowerShell module
id: cache-module
uses: actions/cache@v4
env:
cache-name: cache-module
with:
path: ${{ steps.set-variables.outputs.PSMODULEPATH }}\TrustedSigning\${{ steps.set-variables.outputs.TRUSTED_SIGNING_MODULE_VERSION }}
key: TrustedSigning-${{ steps.set-variables.outputs.TRUSTED_SIGNING_MODULE_VERSION }}
if: ${{ inputs.cache-dependencies == 'true' }}
- name: Cache Microsoft.Windows.SDK.BuildTools NuGet package
id: cache-buildtools
uses: actions/cache@v4
env:
cache-name: cache-buildtools
with:
path: ~\AppData\Local\TrustedSigning\Microsoft.Windows.SDK.BuildTools\Microsoft.Windows.SDK.BuildTools.${{ steps.set-variables.outputs.BUILD_TOOLS_NUGET_VERSION }}
key: Microsoft.Windows.SDK.BuildTools-${{ steps.set-variables.outputs.BUILD_TOOLS_NUGET_VERSION }}
if: ${{ inputs.cache-dependencies == 'true' }}
- name: Cache Microsoft.Trusted.Signing.Client NuGet package
id: cache-tsclient
uses: actions/cache@v4
env:
cache-name: cache-tsclient
with:
path: ~\AppData\Local\TrustedSigning\Microsoft.Trusted.Signing.Client\Microsoft.Trusted.Signing.Client.${{ steps.set-variables.outputs.TRUSTED_SIGNING_NUGET_VERSION }}
key: Microsoft.Trusted.Signing.Client-${{ steps.set-variables.outputs.TRUSTED_SIGNING_NUGET_VERSION }}
if: ${{ inputs.cache-dependencies == 'true' }}
- name: Cache SignCli NuGet package
id: cache-signcli
uses: actions/cache@v4
env:
cache-name: cache-signcli
with:
path: ~\AppData\Local\TrustedSigning\sign\sign.${{ steps.set-variables.outputs.DOTNET_SIGNCLI_NUGET_VERSION }}
key: SignCli-${{ steps.set-variables.outputs.DOTNET_SIGNCLI_NUGET_VERSION }}
if: ${{ inputs.cache-dependencies == 'true' }}
- name: Install Trusted Signing module
shell: "pwsh"
run: |
Install-Module -Name TrustedSigning -RequiredVersion ${{ steps.set-variables.outputs.TRUSTED_SIGNING_MODULE_VERSION }} -Force -Repository PSGallery
if: ${{ inputs.cache-dependencies != 'true' || steps.cache-module.outputs.cache-hit != 'true' }}

View file

@ -411,11 +411,10 @@ jobs:
with: with:
clean: false clean: false
- name: Setup Cargo and Rustup - name: Configure CI
run: | run: |
mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore New-Item -ItemType Directory -Path "./../.cargo" -Force
cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml"
.\script\install-rustup.ps1
- name: cargo clippy - name: cargo clippy
run: | run: |
@ -430,18 +429,9 @@ jobs:
- name: Limit target directory size - name: Limit target directory size
run: ./script/clear-target-dir-if-larger-than.ps1 250 run: ./script/clear-target-dir-if-larger-than.ps1 250
# - name: Check dev drive space
# working-directory: ${{ env.ZED_WORKSPACE }}
# # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive.
# run: ./script/exit-ci-if-dev-drive-is-full.ps1 95
# Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
- name: Clean CI config file - name: Clean CI config file
if: always() if: always()
run: | run: Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue
if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force
}
tests_pass: tests_pass:
name: Tests Pass name: Tests Pass
@ -763,12 +753,67 @@ jobs:
# excludes the final package to only cache dependencies # excludes the final package to only cache dependencies
cachix-filter: "-zed-editor-[0-9.]*-nightly" cachix-filter: "-zed-editor-[0-9.]*-nightly"
bundle-windows-x64:
timeout-minutes: 120
name: Create a Windows installer
runs-on: [self-hosted, Windows, X64]
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
needs: [windows_tests]
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }}
ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }}
ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }}
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
FILE_DIGEST: SHA256
TIMESTAMP_DIGEST: SHA256
TIMESTAMP_SERVER: "http://timestamp.acs.microsoft.com"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
clean: false
- name: Determine version and release channel
working-directory: ${{ env.ZED_WORKSPACE }}
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
# This exports RELEASE_CHANNEL into env (GITHUB_ENV)
script/determine-release-channel.ps1
- name: Install trusted signing
uses: ./.github/actions/install_trusted_signing
- name: Build Zed installer
working-directory: ${{ env.ZED_WORKSPACE }}
run: script/bundle-windows.ps1
- name: Upload installer (x86_64) to Workflow - zed (run-bundling)
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: contains(github.event.pull_request.labels.*.name, 'run-bundling')
with:
name: ZedEditorUserSetup-x64-${{ github.event.pull_request.head.sha || github.sha }}.exe
path: ${{ env.SETUP_PATH }}
- name: Upload Artifacts to release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview
with:
draft: true
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
files: ${{ env.SETUP_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
auto-release-preview: auto-release-preview:
name: Auto release preview name: Auto release preview
if: | if: |
startsWith(github.ref, 'refs/tags/v') startsWith(github.ref, 'refs/tags/v')
&& endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, freebsd] needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64, freebsd]
runs-on: runs-on:
- self-hosted - self-hosted
- bundle - bundle

View file

@ -51,6 +51,32 @@ jobs:
- name: Run tests - name: Run tests
uses: ./.github/actions/run_tests uses: ./.github/actions/run_tests
windows-tests:
timeout-minutes: 60
name: Run tests on Windows
if: github.repository_owner == 'zed-industries'
runs-on: [self-hosted, Windows, X64]
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
clean: false
- name: Configure CI
run: |
New-Item -ItemType Directory -Path "./../.cargo" -Force
Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml"
- name: Run tests
uses: ./.github/actions/run_tests_windows
- name: Limit target directory size
run: ./script/clear-target-dir-if-larger-than.ps1 1024
- name: Clean CI config file
if: always()
run: Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue
bundle-mac: bundle-mac:
timeout-minutes: 60 timeout-minutes: 60
name: Create a macOS bundle name: Create a macOS bundle
@ -213,10 +239,54 @@ jobs:
bundle-nix: bundle-nix:
name: Build and cache Nix package name: Build and cache Nix package
if: false
needs: tests needs: tests
secrets: inherit secrets: inherit
uses: ./.github/workflows/nix.yml uses: ./.github/workflows/nix.yml
bundle-windows-x64:
timeout-minutes: 60
name: Create a Windows installer
if: github.repository_owner == 'zed-industries'
runs-on: [self-hosted, Windows, X64]
needs: windows-tests
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }}
ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }}
ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }}
DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
FILE_DIGEST: SHA256
TIMESTAMP_DIGEST: SHA256
TIMESTAMP_SERVER: "http://timestamp.acs.microsoft.com"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
clean: false
- name: Set release channel to nightly
working-directory: ${{ env.ZED_WORKSPACE }}
run: |
$ErrorActionPreference = "Stop"
$version = git rev-parse --short HEAD
Write-Host "Publishing version: $version on release channel nightly"
"nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL"
- name: Install trusted signing
uses: ./.github/actions/install_trusted_signing
- name: Build Zed installer
working-directory: ${{ env.ZED_WORKSPACE }}
run: script/bundle-windows.ps1
- name: Upload Zed Nightly
working-directory: ${{ env.ZED_WORKSPACE }}
run: script/upload-nightly.ps1 windows
update-nightly-tag: update-nightly-tag:
name: Update nightly tag name: Update nightly tag
if: github.repository_owner == 'zed-industries' if: github.repository_owner == 'zed-industries'
@ -225,6 +295,7 @@ jobs:
- bundle-mac - bundle-mac
- bundle-linux-x86 - bundle-linux-x86
- bundle-linux-arm - bundle-linux-arm
- bundle-windows-x64
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

112
Cargo.lock generated
View file

@ -2,6 +2,33 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "acp"
version = "0.1.0"
dependencies = [
"agent_servers",
"agentic-coding-protocol",
"anyhow",
"async-pipe",
"buffer_diff",
"editor",
"env_logger 0.11.8",
"futures 0.3.31",
"gpui",
"indoc",
"itertools 0.14.0",
"language",
"markdown",
"project",
"serde_json",
"settings",
"smol",
"tempfile",
"ui",
"util",
"workspace-hack",
]
[[package]] [[package]]
name = "activity_indicator" name = "activity_indicator"
version = "0.1.0" version = "0.1.0"
@ -107,6 +134,24 @@ dependencies = [
"zstd", "zstd",
] ]
[[package]]
name = "agent_servers"
version = "0.1.0"
dependencies = [
"anyhow",
"collections",
"futures 0.3.31",
"gpui",
"paths",
"project",
"schemars",
"serde",
"settings",
"util",
"which 6.0.3",
"workspace-hack",
]
[[package]] [[package]]
name = "agent_settings" name = "agent_settings"
version = "0.1.0" version = "0.1.0"
@ -130,8 +175,11 @@ dependencies = [
name = "agent_ui" name = "agent_ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"acp",
"agent", "agent",
"agent_servers",
"agent_settings", "agent_settings",
"agentic-coding-protocol",
"anyhow", "anyhow",
"assistant_context", "assistant_context",
"assistant_slash_command", "assistant_slash_command",
@ -191,6 +239,7 @@ dependencies = [
"settings", "settings",
"smol", "smol",
"streaming_diff", "streaming_diff",
"task",
"telemetry", "telemetry",
"telemetry_events", "telemetry_events",
"terminal", "terminal",
@ -212,6 +261,22 @@ dependencies = [
"zed_llm_client", "zed_llm_client",
] ]
[[package]]
name = "agentic-coding-protocol"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b962eee17ee3924870d9b9d28cc8b6dcb5421e4d4e81cd864226374a122ceed1"
dependencies = [
"anyhow",
"chrono",
"futures 0.3.31",
"log",
"parking_lot",
"schemars",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.7.8" version = "0.7.8"
@ -538,6 +603,8 @@ dependencies = [
"anyhow", "anyhow",
"futures 0.3.31", "futures 0.3.31",
"gpui", "gpui",
"net",
"parking_lot",
"smol", "smol",
"tempfile", "tempfile",
"util", "util",
@ -5189,6 +5256,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "explorer_command_injector"
version = "0.1.0"
dependencies = [
"windows 0.61.1",
"windows-core 0.61.0",
"windows-registry 0.5.1",
"workspace-hack",
]
[[package]] [[package]]
name = "exr" name = "exr"
version = "1.73.0" version = "1.73.0"
@ -8953,6 +9030,7 @@ dependencies = [
"credentials_provider", "credentials_provider",
"deepseek", "deepseek",
"editor", "editor",
"feature_flags",
"fs", "fs",
"futures 0.3.31", "futures 0.3.31",
"google_ai", "google_ai",
@ -10231,6 +10309,18 @@ dependencies = [
"jni-sys", "jni-sys",
] ]
[[package]]
name = "net"
version = "0.1.0"
dependencies = [
"anyhow",
"async-io",
"smol",
"tempfile",
"windows 0.61.1",
"workspace-hack",
]
[[package]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.6" version = "1.0.6"
@ -11336,14 +11426,6 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "perplexity"
version = "0.1.0"
dependencies = [
"serde",
"zed_extension_api 0.6.0",
]
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.8.0" version = "2.8.0"
@ -12535,6 +12617,7 @@ dependencies = [
"prost 0.9.0", "prost 0.9.0",
"prost-build 0.9.0", "prost-build 0.9.0",
"serde", "serde",
"typed-path",
"workspace-hack", "workspace-hack",
] ]
@ -13198,6 +13281,7 @@ dependencies = [
"fs", "fs",
"futures 0.3.31", "futures 0.3.31",
"git", "git",
"git2",
"git_hosting_providers", "git_hosting_providers",
"gpui", "gpui",
"gpui_tokio", "gpui_tokio",
@ -14059,6 +14143,7 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984"
dependencies = [ dependencies = [
"chrono",
"dyn-clone", "dyn-clone",
"indexmap", "indexmap",
"ref-cast", "ref-cast",
@ -17036,6 +17121,12 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "typed-path"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566"
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@ -18282,6 +18373,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"client", "client",
"feature_flags",
"futures 0.3.31", "futures 0.3.31",
"gpui", "gpui",
"http_client", "http_client",
@ -19553,6 +19645,7 @@ dependencies = [
"rustix 1.0.7", "rustix 1.0.7",
"rustls 0.23.26", "rustls 0.23.26",
"rustls-webpki 0.103.1", "rustls-webpki 0.103.1",
"schemars",
"scopeguard", "scopeguard",
"sea-orm", "sea-orm",
"sea-query-binder", "sea-query-binder",
@ -19946,10 +20039,11 @@ dependencies = [
[[package]] [[package]]
name = "zed" name = "zed"
version = "0.195.0" version = "0.196.0"
dependencies = [ dependencies = [
"activity_indicator", "activity_indicator",
"agent", "agent",
"agent_servers",
"agent_settings", "agent_settings",
"agent_ui", "agent_ui",
"anyhow", "anyhow",

View file

@ -2,9 +2,11 @@
resolver = "2" resolver = "2"
members = [ members = [
"crates/activity_indicator", "crates/activity_indicator",
"crates/acp",
"crates/agent_ui", "crates/agent_ui",
"crates/agent", "crates/agent",
"crates/agent_settings", "crates/agent_settings",
"crates/agent_servers",
"crates/anthropic", "crates/anthropic",
"crates/askpass", "crates/askpass",
"crates/assets", "crates/assets",
@ -45,6 +47,7 @@ members = [
"crates/diagnostics", "crates/diagnostics",
"crates/docs_preprocessor", "crates/docs_preprocessor",
"crates/editor", "crates/editor",
"crates/explorer_command_injector",
"crates/eval", "crates/eval",
"crates/extension", "crates/extension",
"crates/extension_api", "crates/extension_api",
@ -99,6 +102,7 @@ members = [
"crates/migrator", "crates/migrator",
"crates/mistral", "crates/mistral",
"crates/multi_buffer", "crates/multi_buffer",
"crates/net",
"crates/node_runtime", "crates/node_runtime",
"crates/notifications", "crates/notifications",
"crates/ollama", "crates/ollama",
@ -188,7 +192,6 @@ members = [
"extensions/emmet", "extensions/emmet",
"extensions/glsl", "extensions/glsl",
"extensions/html", "extensions/html",
"extensions/perplexity",
"extensions/proto", "extensions/proto",
"extensions/ruff", "extensions/ruff",
"extensions/slash-commands-example", "extensions/slash-commands-example",
@ -215,10 +218,12 @@ edition = "2024"
# Workspace member crates # Workspace member crates
# #
activity_indicator = { path = "crates/activity_indicator" } acp = { path = "crates/acp" }
agent = { path = "crates/agent" } agent = { path = "crates/agent" }
activity_indicator = { path = "crates/activity_indicator" }
agent_ui = { path = "crates/agent_ui" } agent_ui = { path = "crates/agent_ui" }
agent_settings = { path = "crates/agent_settings" } agent_settings = { path = "crates/agent_settings" }
agent_servers = { path = "crates/agent_servers" }
ai = { path = "crates/ai" } ai = { path = "crates/ai" }
anthropic = { path = "crates/anthropic" } anthropic = { path = "crates/anthropic" }
askpass = { path = "crates/askpass" } askpass = { path = "crates/askpass" }
@ -311,6 +316,7 @@ menu = { path = "crates/menu" }
migrator = { path = "crates/migrator" } migrator = { path = "crates/migrator" }
mistral = { path = "crates/mistral" } mistral = { path = "crates/mistral" }
multi_buffer = { path = "crates/multi_buffer" } multi_buffer = { path = "crates/multi_buffer" }
net = { path = "crates/net" }
node_runtime = { path = "crates/node_runtime" } node_runtime = { path = "crates/node_runtime" }
notifications = { path = "crates/notifications" } notifications = { path = "crates/notifications" }
ollama = { path = "crates/ollama" } ollama = { path = "crates/ollama" }
@ -398,6 +404,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates # External crates
# #
agentic-coding-protocol = "0.0.5"
aho-corasick = "1.1" aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14" any_vec = "0.14"
@ -624,6 +631,8 @@ wasmtime = { version = "29", default-features = false, features = [
] } ] }
wasmtime-wasi = "29" wasmtime-wasi = "29"
which = "6.0.0" which = "6.0.0"
windows-core = "0.61"
wit-component = "0.221"
workspace-hack = "0.1.0" workspace-hack = "0.1.0"
zed_llm_client = "= 0.8.6" zed_llm_client = "= 0.8.6"
zstd = "0.11" zstd = "0.11"
@ -660,6 +669,7 @@ features = [
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_Graphics_Imaging", "Win32_Graphics_Imaging",
"Win32_Graphics_Imaging_D2D", "Win32_Graphics_Imaging_D2D",
"Win32_Networking_WinSock",
"Win32_Security", "Win32_Security",
"Win32_Security_Credentials", "Win32_Security_Credentials",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",

View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>

After

Width:  |  Height:  |  Size: 402 B

View file

@ -1,3 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.75776 5.50003H8.49988C8.70769 5.50003 8.89518 5.62971 8.95455 5.82346C9.04049 6.01876 8.9858 6.23906 8.82956 6.37656L4.82971 9.87643C4.65315 10.0295 4.39488 10.042 4.20614 9.90455C4.01724 9.76705 3.94849 9.51706 4.04052 9.30301L5.24219 6.49999H3.48601C3.2918 6.49999 3.10524 6.37031 3.03197 6.17657C2.9587 5.98126 3.014 5.76096 3.1708 5.62346L7.17018 2.12375C7.34674 1.97001 7.60454 1.95829 7.7936 2.09547C7.98265 2.23275 8.0514 2.48218 7.95922 2.69695L6.75776 5.50003Z" fill="black"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M6.98749 1.67322C7.08029 1.71878 7.15543 1.79374 7.20121 1.88643C7.24699 1.97912 7.26084 2.08434 7.24061 2.18572L6.72812 4.75007H9.28122C9.37107 4.75006 9.45903 4.77588 9.53463 4.82445C9.61022 4.87302 9.67027 4.94229 9.70761 5.02402C9.74495 5.10574 9.75801 5.19648 9.74524 5.28542C9.73247 5.37437 9.69441 5.45776 9.63559 5.52569L5.57313 10.2131C5.50536 10.2912 5.41366 10.3447 5.31233 10.3653C5.211 10.3858 5.10571 10.3723 5.01285 10.3268C4.92 10.2813 4.8448 10.2064 4.79896 10.1137C4.75311 10.021 4.7392 9.9158 4.75939 9.81439L5.27188 7.25004H2.71878C2.62893 7.25005 2.54097 7.22423 2.46537 7.17566C2.38978 7.12709 2.32973 7.05782 2.29239 6.97609C2.25505 6.89437 2.24199 6.80363 2.25476 6.71469C2.26753 6.62574 2.30559 6.54235 2.36441 6.47443L6.42687 1.78697C6.49466 1.70879 6.58641 1.65524 6.68782 1.63467C6.78923 1.61409 6.89459 1.62765 6.98749 1.67322Z" fill="black"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 601 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.4174 10.2159C10.5454 9.58974 10.4174 9.57261 11.3762 8.46959C11.9337 7.82822 12.335 7.09214 12.335 6.27818C12.335 5.28184 11.9309 4.32631 11.2118 3.62179C10.4926 2.91728 9.5171 2.52148 8.50001 2.52148C7.48291 2.52148 6.50748 2.91728 5.78828 3.62179C5.06909 4.32631 4.66504 5.28184 4.66504 6.27818C4.66504 6.9043 4.79288 7.65565 5.62379 8.46959C6.58253 9.59098 6.45474 9.58974 6.58257 10.2159M10.4174 10.2159L10.4174 12.2989C10.4174 12.9504 9.87836 13.4786 9.21329 13.4786H7.78674C7.12167 13.4786 6.58253 12.9504 6.58253 12.2989L6.58257 10.2159M10.4174 10.2159H8.50001H6.58257" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 776 B

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.4 12.5C12.6917 12.5 12.9715 12.3884 13.1778 12.1899C13.3841 11.9913 13.5 11.722 13.5 11.4412V6.14706C13.5 5.86624 13.3841 5.59693 13.1778 5.39836C12.9715 5.19979 12.6917 5.08824 12.4 5.08824H8.055C7.87103 5.08997 7.68955 5.04726 7.52717 4.96402C7.36478 4.88078 7.22668 4.75967 7.1255 4.61176L6.68 3.97647C6.57984 3.83007 6.44349 3.7099 6.28317 3.62674C6.12286 3.54358 5.94361 3.50003 5.7615 3.5H3.6C3.30826 3.5 3.02847 3.61155 2.82218 3.81012C2.61589 4.00869 2.5 4.27801 2.5 4.55882V11.4412C2.5 11.722 2.61589 11.9913 2.82218 12.1899C3.02847 12.3884 3.30826 12.5 3.6 12.5H12.4Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 778 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 8.5L4.94864 12.6222C4.71647 12.8544 4.40157 12.9848 4.07323 12.9848C3.74488 12.9848 3.42999 12.8544 3.19781 12.6222C2.96564 12.39 2.83521 12.0751 2.83521 11.7468C2.83521 11.4185 2.96564 11.1036 3.19781 10.8714L7.5 6.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.8352 9.98474L13.8352 6.98474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.8352 7.42495L11.7634 6.4298C11.5533 6.23484 11.4353 5.97039 11.4352 5.69462V5.08526L10.1696 3.91022C9.54495 3.33059 8.69961 3.00261 7.81649 2.99722L5.83521 2.98474L6.35041 3.41108C6.71634 3.71233 7.00935 4.08216 7.21013 4.4962C7.4109 4.91024 7.51488 5.35909 7.51521 5.81316L7.5 6.5L9 8.5L9.5 8C9.5 8 9.87337 7.79457 10.0834 7.98959L11.1552 8.98474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 988 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5871 5.40582C12.8514 5.14152 13 4.78304 13 4.40922C13 4.03541 12.8516 3.67688 12.5873 3.41252C12.323 3.14816 11.9645 2.99962 11.5907 2.99957C11.2169 2.99953 10.8584 3.14798 10.594 3.41227L3.92098 10.0869C3.80488 10.2027 3.71903 10.3452 3.67097 10.5019L3.01047 12.678C2.99754 12.7212 2.99657 12.7672 3.00764 12.8109C3.01872 12.8547 3.04143 12.8946 3.07337 12.9265C3.1053 12.9584 3.14528 12.981 3.18905 12.992C3.23282 13.003 3.27875 13.002 3.32197 12.989L5.49849 12.329C5.65508 12.2813 5.79758 12.196 5.91349 12.0805L12.5871 5.40582Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 5L11 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 835 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.57132 13.7143C5.20251 13.7143 5.71418 13.2026 5.71418 12.5714C5.71418 11.9403 5.20251 11.4286 4.57132 11.4286C3.94014 11.4286 3.42847 11.9403 3.42847 12.5714C3.42847 13.2026 3.94014 13.7143 4.57132 13.7143Z" fill="black"/>
<path d="M10.2856 2.85712V5.71426M10.2856 5.71426V8.5714M10.2856 5.71426H13.1428M10.2856 5.71426H7.42847M10.2856 5.71426L12.1904 3.80949M10.2856 5.71426L8.38084 7.61906M10.2856 5.71426L12.1904 7.61906M10.2856 5.71426L8.38084 3.80949" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 13L11 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 12C9.98528 12 12 9.98528 12 7.5C12 5.01472 9.98528 3 7.5 3C5.01472 3 3 5.01472 3 7.5C3 9.98528 5.01472 12 7.5 12Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.99487 8.44023L7.32821 7.10689L5.99487 5.77356" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.33838 10.2264H10.005" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.8889 3H4.11111C3.49746 3 3 3.49746 3 4.11111V11.8889C3 12.5025 3.49746 13 4.11111 13H11.8889C12.5025 13 13 12.5025 13 11.8889V4.11111C13 3.49746 12.5025 3 11.8889 3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

17
assets/icons/tool_web.svg Normal file
View file

@ -0,0 +1,17 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2663_433)">
<mask id="mask0_2663_433" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<path d="M16 0H0V16H16V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_2663_433)">
<path d="M8 13C10.7614 13 13 10.7614 13 7.99999C13 5.23857 10.7614 3 8 3C5.23857 3 3 5.23857 3 7.99999C3 10.7614 5.23857 13 8 13Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 3C6.71611 4.34807 6 6.13836 6 7.99999C6 9.86163 6.71611 11.6519 8 13C9.28387 11.6519 10 9.86163 10 7.99999C10 6.13836 9.28387 4.34807 8 3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8H13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_2663_433">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1,001 B

View file

@ -306,6 +306,15 @@
"enter": "agent::AcceptSuggestedContext" "enter": "agent::AcceptSuggestedContext"
} }
}, },
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"up": "agent::PreviousHistoryMessage",
"down": "agent::NextHistoryMessage"
}
},
{ {
"context": "ThreadHistory", "context": "ThreadHistory",
"bindings": { "bindings": {

View file

@ -357,6 +357,15 @@
"ctrl--": "pane::GoBack" "ctrl--": "pane::GoBack"
} }
}, },
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"up": "agent::PreviousHistoryMessage",
"down": "agent::NextHistoryMessage"
}
},
{ {
"context": "ThreadHistory", "context": "ThreadHistory",
"bindings": { "bindings": {

View file

@ -189,6 +189,8 @@
"z shift-r": "editor::UnfoldAll", "z shift-r": "editor::UnfoldAll",
"z l": "vim::ColumnRight", "z l": "vim::ColumnRight",
"z h": "vim::ColumnLeft", "z h": "vim::ColumnLeft",
"z shift-l": "vim::HalfPageRight",
"z shift-h": "vim::HalfPageLeft",
"shift-z shift-q": ["pane::CloseActiveItem", { "save_intent": "skip" }], "shift-z shift-q": ["pane::CloseActiveItem", { "save_intent": "skip" }],
"shift-z shift-z": ["pane::CloseActiveItem", { "save_intent": "save_all" }], "shift-z shift-z": ["pane::CloseActiveItem", { "save_intent": "save_all" }],
// Count support // Count support
@ -218,35 +220,18 @@
"context": "vim_mode == normal", "context": "vim_mode == normal",
"bindings": { "bindings": {
"ctrl-[": "editor::Cancel", "ctrl-[": "editor::Cancel",
"escape": "editor::Cancel",
":": "command_palette::Toggle", ":": "command_palette::Toggle",
"c": "vim::PushChange", "c": "vim::PushChange",
"shift-c": "vim::ChangeToEndOfLine", "shift-c": "vim::ChangeToEndOfLine",
"d": "vim::PushDelete", "d": "vim::PushDelete",
"delete": "vim::DeleteRight", "delete": "vim::DeleteRight",
"shift-d": "vim::DeleteToEndOfLine",
"shift-j": "vim::JoinLines",
"g shift-j": "vim::JoinLinesNoWhitespace", "g shift-j": "vim::JoinLinesNoWhitespace",
"y": "vim::PushYank", "y": "vim::PushYank",
"shift-y": "vim::YankLine",
"i": "vim::InsertBefore",
"shift-i": "vim::InsertFirstNonWhitespace",
"a": "vim::InsertAfter",
"shift-a": "vim::InsertEndOfLine",
"x": "vim::DeleteRight", "x": "vim::DeleteRight",
"shift-x": "vim::DeleteLeft", "shift-x": "vim::DeleteLeft",
"o": "vim::InsertLineBelow",
"shift-o": "vim::InsertLineAbove",
"~": "vim::ChangeCase",
"ctrl-a": "vim::Increment", "ctrl-a": "vim::Increment",
"ctrl-x": "vim::Decrement", "ctrl-x": "vim::Decrement",
"p": "vim::Paste",
"shift-p": ["vim::Paste", { "before": true }],
"u": "vim::Undo",
"ctrl-r": "vim::Redo", "ctrl-r": "vim::Redo",
"r": "vim::PushReplace",
"s": "vim::Substitute",
"shift-s": "vim::SubstituteLine",
">": "vim::PushIndent", ">": "vim::PushIndent",
"<": "vim::PushOutdent", "<": "vim::PushOutdent",
"=": "vim::PushAutoIndent", "=": "vim::PushAutoIndent",
@ -256,11 +241,8 @@
"g ~": "vim::PushOppositeCase", "g ~": "vim::PushOppositeCase",
"g ?": "vim::PushRot13", "g ?": "vim::PushRot13",
// "g ?": "vim::PushRot47", // "g ?": "vim::PushRot47",
"\"": "vim::PushRegister",
"g w": "vim::PushRewrap", "g w": "vim::PushRewrap",
"g q": "vim::PushRewrap", "g q": "vim::PushRewrap",
"ctrl-pagedown": "pane::ActivateNextItem",
"ctrl-pageup": "pane::ActivatePreviousItem",
"insert": "vim::InsertBefore", "insert": "vim::InsertBefore",
// tree-sitter related commands // tree-sitter related commands
"[ x": "vim::SelectLargerSyntaxNode", "[ x": "vim::SelectLargerSyntaxNode",
@ -364,18 +346,11 @@
} }
}, },
{ {
"context": "vim_mode == helix_normal && !menu", "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu",
"bindings": { "bindings": {
"escape": "editor::Cancel", "escape": "editor::Cancel",
"ctrl-[": "editor::Cancel",
":": "command_palette::Toggle",
"left": "vim::WrappingLeft",
"right": "vim::WrappingRight",
"h": "vim::WrappingLeft",
"l": "vim::WrappingRight",
"shift-d": "vim::DeleteToEndOfLine", "shift-d": "vim::DeleteToEndOfLine",
"shift-j": "vim::JoinLines", "shift-j": "vim::JoinLines",
"y": "editor::Copy",
"shift-y": "vim::YankLine", "shift-y": "vim::YankLine",
"i": "vim::InsertBefore", "i": "vim::InsertBefore",
"shift-i": "vim::InsertFirstNonWhitespace", "shift-i": "vim::InsertFirstNonWhitespace",
@ -390,27 +365,40 @@
"p": "vim::Paste", "p": "vim::Paste",
"shift-p": ["vim::Paste", { "before": true }], "shift-p": ["vim::Paste", { "before": true }],
"u": "vim::Undo", "u": "vim::Undo",
"shift-u": "vim::UndoLastLine",
"r": "vim::PushReplace",
"s": "vim::Substitute",
"shift-s": "vim::SubstituteLine",
"\"": "vim::PushRegister",
"ctrl-pagedown": "pane::ActivateNextItem",
"ctrl-pageup": "pane::ActivatePreviousItem"
}
},
{
"context": "vim_mode == helix_normal && !menu",
"bindings": {
"ctrl-[": "editor::Cancel",
":": "command_palette::Toggle",
"left": "vim::WrappingLeft",
"right": "vim::WrappingRight",
"h": "vim::WrappingLeft",
"l": "vim::WrappingRight",
"y": "editor::Copy",
"alt-;": "vim::OtherEnd",
"ctrl-r": "vim::Redo", "ctrl-r": "vim::Redo",
"f": ["vim::PushFindForward", { "before": false, "multiline": true }], "f": ["vim::PushFindForward", { "before": false, "multiline": true }],
"t": ["vim::PushFindForward", { "before": true, "multiline": true }], "t": ["vim::PushFindForward", { "before": true, "multiline": true }],
"shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }],
"shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }], "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }],
"r": "vim::PushReplace",
"s": "vim::Substitute",
"shift-s": "vim::SubstituteLine",
">": "vim::Indent", ">": "vim::Indent",
"<": "vim::Outdent", "<": "vim::Outdent",
"=": "vim::AutoIndent", "=": "vim::AutoIndent",
"g u": "vim::PushLowercase", "g u": "vim::PushLowercase",
"g shift-u": "vim::PushUppercase", "g shift-u": "vim::PushUppercase",
"g ~": "vim::PushOppositeCase", "g ~": "vim::PushOppositeCase",
"\"": "vim::PushRegister",
"g q": "vim::PushRewrap", "g q": "vim::PushRewrap",
"g w": "vim::PushRewrap", "g w": "vim::PushRewrap",
"ctrl-pagedown": "pane::ActivateNextItem",
"ctrl-pageup": "pane::ActivatePreviousItem",
"insert": "vim::InsertBefore", "insert": "vim::InsertBefore",
".": "vim::Repeat",
"alt-.": "vim::RepeatFind", "alt-.": "vim::RepeatFind",
// tree-sitter related commands // tree-sitter related commands
"[ x": "editor::SelectLargerSyntaxNode", "[ x": "editor::SelectLargerSyntaxNode",
@ -430,7 +418,6 @@
"g h": "vim::StartOfLine", "g h": "vim::StartOfLine",
"g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s"
"g e": "vim::EndOfDocument", "g e": "vim::EndOfDocument",
"g y": "editor::GoToTypeDefinition",
"g r": "editor::FindAllReferences", // zed specific "g r": "editor::FindAllReferences", // zed specific
"g t": "vim::WindowTop", "g t": "vim::WindowTop",
"g c": "vim::WindowMiddle", "g c": "vim::WindowMiddle",

View file

@ -228,7 +228,12 @@
// Whether to show code action button at start of buffer line. // Whether to show code action button at start of buffer line.
"inline_code_actions": true, "inline_code_actions": true,
// Whether to allow drag and drop text selection in buffer. // Whether to allow drag and drop text selection in buffer.
"drag_and_drop_selection": true, "drag_and_drop_selection": {
// When true, enables drag and drop text selection in buffer.
"enabled": true,
// The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created.
"delay": 300
},
// What to do when go to definition yields no results. // What to do when go to definition yields no results.
// //
// 1. Do nothing: `none` // 1. Do nothing: `none`
@ -357,7 +362,9 @@
// Whether to show user picture in the titlebar. // Whether to show user picture in the titlebar.
"show_user_picture": true, "show_user_picture": true,
// Whether to show the sign in button in the titlebar. // Whether to show the sign in button in the titlebar.
"show_sign_in": true "show_sign_in": true,
// Whether to show the menus in the titlebar.
"show_menus": false
}, },
// Scrollbar related settings // Scrollbar related settings
"scrollbar": { "scrollbar": {
@ -861,7 +868,11 @@
/// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. /// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff.
/// ///
/// Default: true /// Default: true
"expand_edit_card": true "expand_edit_card": true,
/// Whether to have terminal cards in the agent panel expanded, showing the whole command output.
///
/// Default: true
"expand_terminal_card": true
}, },
// The settings for slash commands. // The settings for slash commands.
"slash_commands": { "slash_commands": {
@ -1599,6 +1610,9 @@
"use_on_type_format": false, "use_on_type_format": false,
"allow_rewrap": "anywhere", "allow_rewrap": "anywhere",
"soft_wrap": "editor_width", "soft_wrap": "editor_width",
"completions": {
"words": "disabled"
},
"prettier": { "prettier": {
"allowed": true "allowed": true
} }
@ -1612,6 +1626,9 @@
} }
}, },
"Plain Text": { "Plain Text": {
"completions": {
"words": "disabled"
},
"allow_rewrap": "anywhere" "allow_rewrap": "anywhere"
}, },
"Python": { "Python": {
@ -1840,6 +1857,8 @@
"read_ssh_config": true, "read_ssh_config": true,
// Configures context servers for use by the agent. // Configures context servers for use by the agent.
"context_servers": {}, "context_servers": {},
// Configures agent servers available in the agent panel.
"agent_servers": {},
"debugger": { "debugger": {
"stepping_granularity": "line", "stepping_granularity": "line",
"save_breakpoints": true, "save_breakpoints": true,

46
crates/acp/Cargo.toml Normal file
View file

@ -0,0 +1,46 @@
[package]
name = "acp"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/acp.rs"
doctest = false
[features]
test-support = ["gpui/test-support", "project/test-support"]
gemini = []
[dependencies]
agent_servers.workspace = true
agentic-coding-protocol.workspace = true
anyhow.workspace = true
buffer_diff.workspace = true
editor.workspace = true
futures.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true
markdown.workspace = true
project.workspace = true
settings.workspace = true
smol.workspace = true
ui.workspace = true
util.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
async-pipe.workspace = true
env_logger.workspace = true
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
project = { workspace = true, "features" = ["test-support"] }
serde_json.workspace = true
tempfile.workspace = true
util.workspace = true
settings.workspace = true

1
crates/acp/LICENSE-GPL Symbolic link
View file

@ -0,0 +1 @@
../../LICENSE-GPL

1625
crates/acp/src/acp.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
[package]
name = "agent_servers"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/agent_servers.rs"
doctest = false
[dependencies]
anyhow.workspace = true
collections.workspace = true
futures.workspace = true
gpui.workspace = true
paths.workspace = true
project.workspace = true
schemars.workspace = true
serde.workspace = true
settings.workspace = true
util.workspace = true
which.workspace = true
workspace-hack.workspace = true

View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -0,0 +1,231 @@
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{Context as _, Result};
use collections::HashMap;
use gpui::{App, AsyncApp, Entity, SharedString};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources, SettingsStore};
use util::{ResultExt, paths};
pub fn init(cx: &mut App) {
AllAgentServersSettings::register(cx);
}
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AllAgentServersSettings {
gemini: Option<AgentServerSettings>,
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AgentServerSettings {
#[serde(flatten)]
command: AgentServerCommand,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
pub struct AgentServerCommand {
#[serde(rename = "command")]
pub path: PathBuf,
#[serde(default)]
pub args: Vec<String>,
pub env: Option<HashMap<String, String>>,
}
pub struct Gemini;
pub struct AgentServerVersion {
pub current_version: SharedString,
pub supported: bool,
}
pub trait AgentServer: Send {
fn command(
&self,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> impl Future<Output = Result<AgentServerCommand>>;
fn version(
&self,
command: &AgentServerCommand,
) -> impl Future<Output = Result<AgentServerVersion>> + Send;
}
const GEMINI_ACP_ARG: &str = "--acp";
impl AgentServer for Gemini {
async fn command(
&self,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<AgentServerCommand> {
let custom_command = cx.read_global(|settings: &SettingsStore, _| {
let settings = settings.get::<AllAgentServersSettings>(None);
settings
.gemini
.as_ref()
.map(|gemini_settings| AgentServerCommand {
path: gemini_settings.command.path.clone(),
args: gemini_settings
.command
.args
.iter()
.cloned()
.chain(std::iter::once(GEMINI_ACP_ARG.into()))
.collect(),
env: gemini_settings.command.env.clone(),
})
})?;
if let Some(custom_command) = custom_command {
return Ok(custom_command);
}
if let Some(path) = find_bin_in_path("gemini", project, cx).await {
return Ok(AgentServerCommand {
path,
args: vec![GEMINI_ACP_ARG.into()],
env: None,
});
}
let (fs, node_runtime) = project.update(cx, |project, _| {
(project.fs().clone(), project.node_runtime().cloned())
})?;
let node_runtime = node_runtime.context("gemini not found on path")?;
let directory = ::paths::agent_servers_dir().join("gemini");
fs.create_dir(&directory).await?;
node_runtime
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
.await?;
let path = directory.join("node_modules/.bin/gemini");
Ok(AgentServerCommand {
path,
args: vec![GEMINI_ACP_ARG.into()],
env: None,
})
}
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
let version_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--version")
.kill_on_drop(true)
.output();
let help_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--help")
.kill_on_drop(true)
.output();
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
let current_version = String::from_utf8(version_output?.stdout)?.into();
let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG);
Ok(AgentServerVersion {
current_version,
supported,
})
}
}
async fn find_bin_in_path(
bin_name: &'static str,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Option<PathBuf> {
let (env_task, root_dir) = project
.update(cx, |project, cx| {
let worktree = project.visible_worktrees(cx).next();
match worktree {
Some(worktree) => {
let env_task = project.environment().update(cx, |env, cx| {
env.get_worktree_environment(worktree.clone(), cx)
});
let path = worktree.read(cx).abs_path();
(env_task, path)
}
None => {
let path: Arc<Path> = paths::home_dir().as_path().into();
let env_task = project.environment().update(cx, |env, cx| {
env.get_directory_environment(path.clone(), cx)
});
(env_task, path)
}
}
})
.log_err()?;
cx.background_executor()
.spawn(async move {
let which_result = if cfg!(windows) {
which::which(bin_name)
} else {
let env = env_task.await.unwrap_or_default();
let shell_path = env.get("PATH").cloned();
which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref())
};
if let Err(which::Error::CannotFindBinaryPath) = which_result {
return None;
}
which_result.log_err()
})
.await
}
impl std::fmt::Debug for AgentServerCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let filtered_env = self.env.as_ref().map(|env| {
env.iter()
.map(|(k, v)| {
(
k,
if util::redact::should_redact(k) {
"[REDACTED]"
} else {
v
},
)
})
.collect::<Vec<_>>()
});
f.debug_struct("AgentServerCommand")
.field("path", &self.path)
.field("args", &self.args)
.field("env", &filtered_env)
.finish()
}
}
impl settings::Settings for AllAgentServersSettings {
const KEY: Option<&'static str> = Some("agent_servers");
type FileContent = Self;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let mut settings = AllAgentServersSettings::default();
for value in sources.defaults_and_customizations() {
if value.gemini.is_some() {
settings.gemini = value.gemini.clone();
}
}
Ok(settings)
}
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
}

View file

@ -68,6 +68,7 @@ pub struct AgentSettings {
pub preferred_completion_mode: CompletionMode, pub preferred_completion_mode: CompletionMode,
pub enable_feedback: bool, pub enable_feedback: bool,
pub expand_edit_card: bool, pub expand_edit_card: bool,
pub expand_terminal_card: bool,
} }
impl AgentSettings { impl AgentSettings {
@ -296,6 +297,10 @@ pub struct AgentSettingsContent {
/// ///
/// Default: true /// Default: true
expand_edit_card: Option<bool>, expand_edit_card: Option<bool>,
/// Whether to have terminal cards in the agent panel expanded, showing the whole command output.
///
/// Default: true
expand_terminal_card: Option<bool>,
} }
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
@ -447,6 +452,10 @@ impl Settings for AgentSettings {
); );
merge(&mut settings.enable_feedback, value.enable_feedback); merge(&mut settings.enable_feedback, value.enable_feedback);
merge(&mut settings.expand_edit_card, value.expand_edit_card); merge(&mut settings.expand_edit_card, value.expand_edit_card);
merge(
&mut settings.expand_terminal_card,
value.expand_terminal_card,
);
settings settings
.model_parameters .model_parameters

View file

@ -13,14 +13,14 @@ path = "src/agent_ui.rs"
doctest = false doctest = false
[features] [features]
test-support = [ test-support = ["gpui/test-support", "language/test-support"]
"gpui/test-support",
"language/test-support",
]
[dependencies] [dependencies]
acp.workspace = true
agent.workspace = true agent.workspace = true
agentic-coding-protocol.workspace = true
agent_settings.workspace = true agent_settings.workspace = true
agent_servers.workspace = true
anyhow.workspace = true anyhow.workspace = true
assistant_context.workspace = true assistant_context.workspace = true
assistant_slash_command.workspace = true assistant_slash_command.workspace = true
@ -76,6 +76,7 @@ serde_json_lenient.workspace = true
settings.workspace = true settings.workspace = true
smol.workspace = true smol.workspace = true
streaming_diff.workspace = true streaming_diff.workspace = true
task.workspace = true
telemetry.workspace = true telemetry.workspace = true
telemetry_events.workspace = true telemetry_events.workspace = true
terminal.workspace = true terminal.workspace = true

View file

@ -0,0 +1,5 @@
mod completion_provider;
mod message_history;
mod thread_view;
pub use thread_view::AcpThreadView;

View file

@ -0,0 +1,574 @@
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use anyhow::Result;
use collections::HashMap;
use editor::display_map::CreaseId;
use editor::{CompletionProvider, Editor, ExcerptId};
use file_icons::FileIcons;
use gpui::{App, Entity, Task, WeakEntity};
use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext;
use parking_lot::Mutex;
use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, WorktreeId};
use rope::Point;
use text::{Anchor, ToPoint};
use ui::prelude::*;
use workspace::Workspace;
use crate::context_picker::MentionLink;
use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files};
#[derive(Default)]
pub struct MentionSet {
paths_by_crease_id: HashMap<CreaseId, ProjectPath>,
}
impl MentionSet {
pub fn insert(&mut self, crease_id: CreaseId, path: ProjectPath) {
self.paths_by_crease_id.insert(crease_id, path);
}
pub fn path_for_crease_id(&self, crease_id: CreaseId) -> Option<ProjectPath> {
self.paths_by_crease_id.get(&crease_id).cloned()
}
pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> {
self.paths_by_crease_id.drain().map(|(id, _)| id)
}
}
pub struct ContextPickerCompletionProvider {
workspace: WeakEntity<Workspace>,
editor: WeakEntity<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
}
impl ContextPickerCompletionProvider {
pub fn new(
mention_set: Arc<Mutex<MentionSet>>,
workspace: WeakEntity<Workspace>,
editor: WeakEntity<Editor>,
) -> Self {
Self {
mention_set,
workspace,
editor,
}
}
fn completion_for_path(
project_path: ProjectPath,
path_prefix: &str,
is_recent: bool,
is_directory: bool,
excerpt_id: ExcerptId,
source_range: Range<Anchor>,
editor: Entity<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
cx: &App,
) -> Completion {
let (file_name, directory) =
extract_file_name_and_directory(&project_path.path, path_prefix);
let label =
build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
let full_path = if let Some(directory) = directory {
format!("{}{}", directory, file_name)
} else {
file_name.to_string()
};
let crease_icon_path = if is_directory {
FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into())
} else {
FileIcons::get_icon(Path::new(&full_path), cx)
.unwrap_or_else(|| IconName::File.path().into())
};
let completion_icon_path = if is_recent {
IconName::HistoryRerun.path().into()
} else {
crease_icon_path.clone()
};
let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path));
let new_text_len = new_text.len();
Completion {
replace_range: source_range.clone(),
new_text,
label,
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(completion_icon_path),
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
crease_icon_path,
file_name,
project_path,
excerpt_id,
source_range.start,
new_text_len - 1,
editor,
mention_set,
)),
}
}
}
fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
let mut label = CodeLabel::default();
label.push_str(&file_name, None);
label.push_str(" ", None);
if let Some(directory) = directory {
label.push_str(&directory, comment_id);
}
label.filter_range = 0..label.text().len();
label
}
impl CompletionProvider for ContextPickerCompletionProvider {
fn completions(
&self,
excerpt_id: ExcerptId,
buffer: &Entity<Buffer>,
buffer_position: Anchor,
_trigger: CompletionContext,
_window: &mut Window,
cx: &mut Context<Editor>,
) -> Task<Result<Vec<CompletionResponse>>> {
let state = buffer.update(cx, |buffer, _cx| {
let position = buffer_position.to_point(buffer);
let line_start = Point::new(position.row, 0);
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(line, offset_to_line)
});
let Some(state) = state else {
return Task::ready(Ok(Vec::new()));
};
let Some(workspace) = self.workspace.upgrade() else {
return Task::ready(Ok(Vec::new()));
};
let snapshot = buffer.read(cx).snapshot();
let source_range = snapshot.anchor_before(state.source_range.start)
..snapshot.anchor_after(state.source_range.end);
let editor = self.editor.clone();
let mention_set = self.mention_set.clone();
let MentionCompletion { argument, .. } = state;
let query = argument.unwrap_or_else(|| "".to_string());
let search_task = search_files(query.clone(), Arc::<AtomicBool>::default(), &workspace, cx);
cx.spawn(async move |_, cx| {
let matches = search_task.await;
let Some(editor) = editor.upgrade() else {
return Ok(Vec::new());
};
let completions = cx.update(|cx| {
matches
.into_iter()
.map(|mat| {
let path_match = &mat.mat;
let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(path_match.worktree_id),
path: path_match.path.clone(),
};
Self::completion_for_path(
project_path,
&path_match.path_prefix,
mat.is_recent,
path_match.is_dir,
excerpt_id,
source_range.clone(),
editor.clone(),
mention_set.clone(),
cx,
)
})
.collect()
})?;
Ok(vec![CompletionResponse {
completions,
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,
}])
})
}
fn is_completion_trigger(
&self,
buffer: &Entity<language::Buffer>,
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
let buffer = buffer.read(cx);
let position = position.to_point(buffer);
let line_start = Point::new(position.row, 0);
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(line, offset_to_line)
.map(|completion| {
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 {
false
}
}
fn sort_completions(&self) -> bool {
false
}
fn filter_completions(&self) -> bool {
false
}
}
fn confirm_completion_callback(
crease_icon_path: SharedString,
crease_text: SharedString,
project_path: ProjectPath,
excerpt_id: ExcerptId,
start: Anchor,
content_len: usize,
editor: Entity<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
Arc::new(move |_, window, cx| {
let crease_text = crease_text.clone();
let crease_icon_path = crease_icon_path.clone();
let editor = editor.clone();
let project_path = project_path.clone();
let mention_set = mention_set.clone();
window.defer(cx, move |window, cx| {
let crease_id = crate::context_picker::insert_crease_for_mention(
excerpt_id,
start,
content_len,
crease_text.clone(),
crease_icon_path,
editor.clone(),
window,
cx,
);
if let Some(crease_id) = crease_id {
mention_set.lock().insert(crease_id, project_path);
}
});
false
})
}
#[derive(Debug, Default, PartialEq)]
struct MentionCompletion {
source_range: Range<usize>,
argument: Option<String>,
}
impl MentionCompletion {
fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
let last_mention_start = line.rfind('@')?;
if last_mention_start >= line.len() {
return Some(Self::default());
}
if last_mention_start > 0
&& line
.chars()
.nth(last_mention_start - 1)
.map_or(false, |c| !c.is_whitespace())
{
return None;
}
let rest_of_line = &line[last_mention_start + 1..];
let mut argument = None;
let mut parts = rest_of_line.split_whitespace();
let mut end = last_mention_start + 1;
if let Some(argument_text) = parts.next() {
end += argument_text.len();
argument = Some(argument_text.to_string());
}
Some(Self {
source_range: last_mention_start + offset_to_line..end + offset_to_line,
argument,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext};
use project::{Project, ProjectPath};
use serde_json::json;
use settings::SettingsStore;
use std::{ops::Deref, rc::Rc};
use util::path;
use workspace::{AppState, Item};
#[test]
fn test_mention_completion_parse() {
assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
assert_eq!(
MentionCompletion::try_parse("Lorem @", 0),
Some(MentionCompletion {
source_range: 6..7,
argument: None,
})
);
assert_eq!(
MentionCompletion::try_parse("Lorem @main", 0),
Some(MentionCompletion {
source_range: 6..11,
argument: Some("main".to_string()),
})
);
assert_eq!(MentionCompletion::try_parse("test@", 0), None);
}
struct AtMentionEditor(Entity<Editor>);
impl Item for AtMentionEditor {
type Event = ();
fn include_in_nav_history() -> bool {
false
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Test".into()
}
}
impl EventEmitter<()> for AtMentionEditor {}
impl Focusable for AtMentionEditor {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.0.read(cx).focus_handle(cx).clone()
}
}
impl Render for AtMentionEditor {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.0.clone().into_any_element()
}
}
#[gpui::test]
async fn test_context_completion_provider(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);
});
app_state
.fs
.as_fake()
.insert_tree(
path!("/dir"),
json!({
"editor": "",
"a": {
"one.txt": "",
"two.txt": "",
"three.txt": "",
"four.txt": ""
},
"b": {
"five.txt": "",
"six.txt": "",
"seven.txt": "",
"eight.txt": "",
}
}),
)
.await;
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 worktree = project.update(cx, |project, cx| {
let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
assert_eq!(worktrees.len(), 1);
worktrees.pop().unwrap()
});
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
let mut cx = VisualTestContext::from_window(*window.deref(), cx);
let paths = vec![
path!("a/one.txt"),
path!("a/two.txt"),
path!("a/three.txt"),
path!("a/four.txt"),
path!("b/five.txt"),
path!("b/six.txt"),
path!("b/seven.txt"),
path!("b/eight.txt"),
];
let mut opened_editors = Vec::new();
for path in paths {
let buffer = workspace
.update_in(&mut cx, |workspace, window, cx| {
workspace.open_path(
ProjectPath {
worktree_id,
path: Path::new(path).into(),
},
None,
false,
window,
cx,
)
})
.await
.unwrap();
opened_editors.push(buffer);
}
let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let editor = cx.new(|cx| {
Editor::new(
editor::EditorMode::full(),
multi_buffer::MultiBuffer::build_simple("", cx),
None,
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
true,
true,
None,
window,
cx,
);
});
editor
});
let mention_set = Arc::new(Mutex::new(MentionSet::default()));
let editor_entity = editor.downgrade();
editor.update_in(&mut cx, |editor, window, cx| {
window.focus(&editor.focus_handle(cx));
editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
mention_set.clone(),
workspace.downgrade(),
editor_entity,
))));
});
cx.simulate_input("Lorem ");
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem ");
assert!(!editor.has_visible_completions_menu());
});
cx.simulate_input("@");
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels(editor),
&[
"eight.txt dir/b/",
"seven.txt dir/b/",
"six.txt dir/b/",
"five.txt dir/b/",
"four.txt dir/a/",
"three.txt dir/a/",
"two.txt dir/a/",
"one.txt dir/a/",
"dir ",
"a dir/",
"four.txt dir/a/",
"one.txt dir/a/",
"three.txt dir/a/",
"two.txt dir/a/",
"b dir/",
"eight.txt dir/b/",
"five.txt dir/b/",
"seven.txt dir/b/",
"six.txt dir/b/",
"editor dir/"
]
);
});
// Select and confirm "File"
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem [@four.txt](@file:dir/a/four.txt) ");
});
}
fn current_completion_labels(editor: &Editor) -> Vec<String> {
let completions = editor.current_completions().expect("Missing completions");
completions
.into_iter()
.map(|completion| completion.label.text.to_string())
.collect::<Vec<_>>()
}
pub(crate) fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
client::init_settings(cx);
language::init(cx);
Project::init_settings(cx);
workspace::init_settings(cx);
editor::init_settings(cx);
});
}
}

View file

@ -0,0 +1,81 @@
pub struct MessageHistory<T> {
items: Vec<T>,
current: Option<usize>,
}
impl<T> MessageHistory<T> {
pub fn new() -> Self {
MessageHistory {
items: Vec::new(),
current: None,
}
}
pub fn push(&mut self, message: T) {
self.current.take();
self.items.push(message);
}
pub fn prev(&mut self) -> Option<&T> {
if self.items.is_empty() {
return None;
}
let new_ix = self
.current
.get_or_insert(self.items.len())
.saturating_sub(1);
self.current = Some(new_ix);
self.items.get(new_ix)
}
pub fn next(&mut self) -> Option<&T> {
let current = self.current.as_mut()?;
*current += 1;
self.items.get(*current).or_else(|| {
self.current.take();
None
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prev_next() {
let mut history = MessageHistory::new();
// Test empty history
assert_eq!(history.prev(), None);
assert_eq!(history.next(), None);
// Add some messages
history.push("first");
history.push("second");
history.push("third");
// Test prev navigation
assert_eq!(history.prev(), Some(&"third"));
assert_eq!(history.prev(), Some(&"second"));
assert_eq!(history.prev(), Some(&"first"));
assert_eq!(history.prev(), Some(&"first"));
assert_eq!(history.next(), Some(&"second"));
// Test mixed navigation
history.push("fourth");
assert_eq!(history.prev(), Some(&"fourth"));
assert_eq!(history.prev(), Some(&"third"));
assert_eq!(history.next(), Some(&"fourth"));
assert_eq!(history.next(), None);
// Test that push resets navigation
history.prev();
history.prev();
history.push("fifth");
assert_eq!(history.prev(), Some(&"fifth"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -740,7 +740,9 @@ fn wait_for_context_server(
}); });
cx.spawn(async move |_cx| { cx.spawn(async move |_cx| {
let result = rx.await.unwrap(); let result = rx
.await
.map_err(|_| Arc::from("Context server store was dropped"))?;
drop(subscription); drop(subscription);
result result
}) })

View file

@ -7,12 +7,14 @@ use std::time::Duration;
use db::kvp::{Dismissable, KEY_VALUE_STORE}; use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::NewGeminiThread;
use crate::language_model_selector::ToggleModelSelector; use crate::language_model_selector::ToggleModelSelector;
use crate::{ use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
acp::AcpThreadView,
active_thread::{self, ActiveThread, ActiveThreadEvent}, active_thread::{self, ActiveThread, ActiveThreadEvent},
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
agent_diff::AgentDiff, agent_diff::AgentDiff,
@ -38,6 +40,7 @@ use assistant_slash_command::SlashCommandWorkingSet;
use assistant_tool::ToolWorkingSet; use assistant_tool::ToolWorkingSet;
use client::{UserStore, zed_urls}; use client::{UserStore, zed_urls};
use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use feature_flags::{self, FeatureFlagAppExt};
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
@ -109,6 +112,12 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx)); panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx));
} }
}) })
.register_action(|workspace, _: &NewGeminiThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| panel.new_gemini_thread(window, cx));
}
})
.register_action(|workspace, action: &OpenRulesLibrary, window, cx| { .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) { if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx); workspace.focus_panel::<AgentPanel>(window, cx);
@ -125,7 +134,8 @@ pub fn init(cx: &mut App) {
let thread = thread.read(cx).thread().clone(); let thread = thread.read(cx).thread().clone();
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
} }
ActiveView::TextThread { .. } ActiveView::AcpThread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History | ActiveView::History
| ActiveView::Configuration => {} | ActiveView::Configuration => {}
} }
@ -188,6 +198,9 @@ enum ActiveView {
message_editor: Entity<MessageEditor>, message_editor: Entity<MessageEditor>,
_subscriptions: Vec<gpui::Subscription>, _subscriptions: Vec<gpui::Subscription>,
}, },
AcpThread {
thread_view: Entity<AcpThreadView>,
},
TextThread { TextThread {
context_editor: Entity<TextThreadEditor>, context_editor: Entity<TextThreadEditor>,
title_editor: Entity<Editor>, title_editor: Entity<Editor>,
@ -207,7 +220,9 @@ enum WhichFontSize {
impl ActiveView { impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize { pub fn which_font_size_used(&self) -> WhichFontSize {
match self { match self {
ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont, ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => {
WhichFontSize::AgentFont
}
ActiveView::TextThread { .. } => WhichFontSize::BufferFont, ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
ActiveView::Configuration => WhichFontSize::None, ActiveView::Configuration => WhichFontSize::None,
} }
@ -238,6 +253,7 @@ impl ActiveView {
thread.scroll_to_bottom(cx); thread.scroll_to_bottom(cx);
}); });
} }
ActiveView::AcpThread { .. } => {}
ActiveView::TextThread { .. } ActiveView::TextThread { .. }
| ActiveView::History | ActiveView::History
| ActiveView::Configuration => {} | ActiveView::Configuration => {}
@ -653,7 +669,8 @@ impl AgentPanel {
.clone() .clone()
.update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); .update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
} }
ActiveView::TextThread { .. } ActiveView::AcpThread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History | ActiveView::History
| ActiveView::Configuration => {} | ActiveView::Configuration => {}
}, },
@ -733,6 +750,9 @@ impl AgentPanel {
ActiveView::Thread { thread, .. } => { ActiveView::Thread { thread, .. } => {
thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
} }
ActiveView::AcpThread { thread_view, .. } => {
thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx));
}
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
} }
} }
@ -740,7 +760,10 @@ impl AgentPanel {
fn active_message_editor(&self) -> Option<&Entity<MessageEditor>> { fn active_message_editor(&self) -> Option<&Entity<MessageEditor>> {
match &self.active_view { match &self.active_view {
ActiveView::Thread { message_editor, .. } => Some(message_editor), ActiveView::Thread { message_editor, .. } => Some(message_editor),
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, ActiveView::AcpThread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => None,
} }
} }
@ -862,6 +885,21 @@ impl AgentPanel {
context_editor.focus_handle(cx).focus(window); context_editor.focus_handle(cx).focus(window);
} }
fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let workspace = self.workspace.clone();
let project = self.project.clone();
cx.spawn_in(window, async move |this, cx| {
let thread_view = cx.new_window_entity(|window, cx| {
crate::acp::AcpThreadView::new(workspace, project, window, cx)
})?;
this.update_in(cx, |this, window, cx| {
this.set_active_view(ActiveView::AcpThread { thread_view }, window, cx);
})
})
.detach();
}
fn deploy_rules_library( fn deploy_rules_library(
&mut self, &mut self,
action: &OpenRulesLibrary, action: &OpenRulesLibrary,
@ -994,6 +1032,7 @@ impl AgentPanel {
cx, cx,
) )
}); });
let message_editor = cx.new(|cx| { let message_editor = cx.new(|cx| {
MessageEditor::new( MessageEditor::new(
self.fs.clone(), self.fs.clone(),
@ -1025,6 +1064,9 @@ impl AgentPanel {
ActiveView::Thread { message_editor, .. } => { ActiveView::Thread { message_editor, .. } => {
message_editor.focus_handle(cx).focus(window); message_editor.focus_handle(cx).focus(window);
} }
ActiveView::AcpThread { thread_view } => {
thread_view.focus_handle(cx).focus(window);
}
ActiveView::TextThread { context_editor, .. } => { ActiveView::TextThread { context_editor, .. } => {
context_editor.focus_handle(cx).focus(window); context_editor.focus_handle(cx).focus(window);
} }
@ -1144,7 +1186,10 @@ impl AgentPanel {
}) })
.log_err(); .log_err();
} }
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} ActiveView::AcpThread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => {}
} }
} }
@ -1197,6 +1242,13 @@ impl AgentPanel {
) )
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
ActiveView::AcpThread { thread_view } => {
thread_view
.update(cx, |thread_view, cx| {
thread_view.open_thread_as_markdown(workspace, window, cx)
})
.detach_and_log_err(cx);
}
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
} }
} }
@ -1351,7 +1403,8 @@ impl AgentPanel {
} }
}) })
} }
_ => {} ActiveView::AcpThread { .. } => {}
ActiveView::History | ActiveView::Configuration => {}
} }
if current_is_special && !new_is_special { if current_is_special && !new_is_special {
@ -1437,6 +1490,7 @@ impl Focusable for AgentPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle { fn focus_handle(&self, cx: &App) -> FocusHandle {
match &self.active_view { match &self.active_view {
ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx), ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx),
ActiveView::AcpThread { thread_view, .. } => thread_view.focus_handle(cx),
ActiveView::History => self.history.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx),
ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx),
ActiveView::Configuration => { ActiveView::Configuration => {
@ -1593,6 +1647,9 @@ impl AgentPanel {
.into_any_element(), .into_any_element(),
} }
} }
ActiveView::AcpThread { thread_view } => Label::new(thread_view.read(cx).title(cx))
.truncate()
.into_any_element(),
ActiveView::TextThread { ActiveView::TextThread {
title_editor, title_editor,
context_editor, context_editor,
@ -1727,7 +1784,10 @@ impl AgentPanel {
let active_thread = match &self.active_view { let active_thread = match &self.active_view {
ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, ActiveView::AcpThread { .. }
| ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => None,
}; };
let agent_extra_menu = PopoverMenu::new("agent-options-menu") let agent_extra_menu = PopoverMenu::new("agent-options-menu")
@ -1755,6 +1815,9 @@ impl AgentPanel {
menu = menu menu = menu
.action("New Thread", NewThread::default().boxed_clone()) .action("New Thread", NewThread::default().boxed_clone())
.action("New Text Thread", NewTextThread.boxed_clone()) .action("New Text Thread", NewTextThread.boxed_clone())
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
this.action("New Gemini Thread", NewGeminiThread.boxed_clone())
})
.when_some(active_thread, |this, active_thread| { .when_some(active_thread, |this, active_thread| {
let thread = active_thread.read(cx); let thread = active_thread.read(cx);
if !thread.is_empty() { if !thread.is_empty() {
@ -1893,6 +1956,9 @@ impl AgentPanel {
message_editor, message_editor,
.. ..
} => (thread.read(cx), message_editor.read(cx)), } => (thread.read(cx), message_editor.read(cx)),
ActiveView::AcpThread { .. } => {
return None;
}
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
return None; return None;
} }
@ -2031,6 +2097,9 @@ impl AgentPanel {
return false; return false;
} }
} }
ActiveView::AcpThread { .. } => {
return false;
}
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
return false; return false;
} }
@ -2615,6 +2684,9 @@ impl AgentPanel {
) -> Option<AnyElement> { ) -> Option<AnyElement> {
let active_thread = match &self.active_view { let active_thread = match &self.active_view {
ActiveView::Thread { thread, .. } => thread, ActiveView::Thread { thread, .. } => thread,
ActiveView::AcpThread { .. } => {
return None;
}
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
return None; return None;
} }
@ -2961,6 +3033,9 @@ impl AgentPanel {
.detach(); .detach();
}); });
} }
ActiveView::AcpThread { .. } => {
unimplemented!()
}
ActiveView::TextThread { context_editor, .. } => { ActiveView::TextThread { context_editor, .. } => {
context_editor.update(cx, |context_editor, cx| { context_editor.update(cx, |context_editor, cx| {
TextThreadEditor::insert_dragged_files( TextThreadEditor::insert_dragged_files(
@ -3034,6 +3109,7 @@ impl Render for AgentPanel {
}); });
this.continue_conversation(window, cx); this.continue_conversation(window, cx);
} }
ActiveView::AcpThread { .. } => {}
ActiveView::TextThread { .. } ActiveView::TextThread { .. }
| ActiveView::History | ActiveView::History
| ActiveView::Configuration => {} | ActiveView::Configuration => {}
@ -3075,6 +3151,10 @@ impl Render for AgentPanel {
}) })
.child(h_flex().child(message_editor.clone())) .child(h_flex().child(message_editor.clone()))
.child(self.render_drag_target(cx)), .child(self.render_drag_target(cx)),
ActiveView::AcpThread { thread_view, .. } => parent
.relative()
.child(thread_view.clone())
.child(self.render_drag_target(cx)),
ActiveView::History => parent.child(self.history.clone()), ActiveView::History => parent.child(self.history.clone()),
ActiveView::TextThread { ActiveView::TextThread {
context_editor, context_editor,

View file

@ -1,3 +1,4 @@
mod acp;
mod active_thread; mod active_thread;
mod agent_configuration; mod agent_configuration;
mod agent_diff; mod agent_diff;
@ -56,6 +57,8 @@ actions!(
[ [
/// Creates a new text-based conversation thread. /// Creates a new text-based conversation thread.
NewTextThread, NewTextThread,
/// Creates a new Gemini CLI-based conversation thread.
NewGeminiThread,
/// Toggles the context picker interface for adding files, symbols, or other context. /// Toggles the context picker interface for adding files, symbols, or other context.
ToggleContextPicker, ToggleContextPicker,
/// Toggles the navigation menu for switching between threads and views. /// Toggles the navigation menu for switching between threads and views.
@ -76,8 +79,6 @@ actions!(
AddContextServer, AddContextServer,
/// Removes the currently selected thread. /// Removes the currently selected thread.
RemoveSelectedThread, RemoveSelectedThread,
/// Starts a chat conversation with the agent.
Chat,
/// Starts a chat conversation with follow-up enabled. /// Starts a chat conversation with follow-up enabled.
ChatWithFollow, ChatWithFollow,
/// Cycles to the next inline assist suggestion. /// Cycles to the next inline assist suggestion.

View file

@ -1,6 +1,6 @@
mod completion_provider; mod completion_provider;
mod fetch_context_picker; mod fetch_context_picker;
mod file_context_picker; pub(crate) mod file_context_picker;
mod rules_context_picker; mod rules_context_picker;
mod symbol_context_picker; mod symbol_context_picker;
mod thread_context_picker; mod thread_context_picker;

View file

@ -47,13 +47,14 @@ use ui::{
}; };
use util::ResultExt as _; use util::ResultExt as _;
use workspace::{CollaboratorId, Workspace}; use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::Chat;
use zed_llm_client::CompletionIntent; use zed_llm_client::CompletionIntent;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
use crate::profile_selector::ProfileSelector; use crate::profile_selector::ProfileSelector;
use crate::{ use crate::{
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode,
ToggleContextPicker, ToggleProfileSelector, register_agent_preview, ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
}; };

View file

@ -15,6 +15,8 @@ path = "src/askpass.rs"
anyhow.workspace = true anyhow.workspace = true
futures.workspace = true futures.workspace = true
gpui.workspace = true gpui.workspace = true
net.workspace = true
parking_lot.workspace = true
smol.workspace = true smol.workspace = true
tempfile.workspace = true tempfile.workspace = true
util.workspace = true util.workspace = true

View file

@ -1,21 +1,14 @@
use std::path::{Path, PathBuf}; use std::{ffi::OsStr, time::Duration};
use std::time::Duration;
#[cfg(unix)] use anyhow::{Context as _, Result};
use anyhow::Context as _;
use futures::channel::{mpsc, oneshot}; use futures::channel::{mpsc, oneshot};
#[cfg(unix)] use futures::{
use futures::{AsyncBufReadExt as _, io::BufReader}; AsyncBufReadExt as _, AsyncWriteExt as _, FutureExt as _, SinkExt, StreamExt, io::BufReader,
#[cfg(unix)] select_biased,
use futures::{AsyncWriteExt as _, FutureExt as _, select_biased}; };
use futures::{SinkExt, StreamExt};
use gpui::{AsyncApp, BackgroundExecutor, Task}; use gpui::{AsyncApp, BackgroundExecutor, Task};
#[cfg(unix)]
use smol::fs; use smol::fs;
#[cfg(unix)] use util::ResultExt as _;
use smol::net::unix::UnixListener;
#[cfg(unix)]
use util::{ResultExt as _, fs::make_file_executable, get_shell_safe_zed_path};
#[derive(PartialEq, Eq)] #[derive(PartialEq, Eq)]
pub enum AskPassResult { pub enum AskPassResult {
@ -42,41 +35,56 @@ impl AskPassDelegate {
Self { tx, _task: task } Self { tx, _task: task }
} }
pub async fn ask_password(&mut self, prompt: String) -> anyhow::Result<String> { pub async fn ask_password(&mut self, prompt: String) -> Result<String> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
self.tx.send((prompt, tx)).await?; self.tx.send((prompt, tx)).await?;
Ok(rx.await?) Ok(rx.await?)
} }
} }
#[cfg(unix)]
pub struct AskPassSession { pub struct AskPassSession {
script_path: PathBuf, #[cfg(not(target_os = "windows"))]
script_path: std::path::PathBuf,
#[cfg(target_os = "windows")]
askpass_helper: String,
#[cfg(target_os = "windows")]
secret: std::sync::Arc<parking_lot::Mutex<String>>,
_askpass_task: Task<()>, _askpass_task: Task<()>,
askpass_opened_rx: Option<oneshot::Receiver<()>>, askpass_opened_rx: Option<oneshot::Receiver<()>>,
askpass_kill_master_rx: Option<oneshot::Receiver<()>>, askpass_kill_master_rx: Option<oneshot::Receiver<()>>,
} }
#[cfg(unix)] #[cfg(not(target_os = "windows"))]
const ASKPASS_SCRIPT_NAME: &str = "askpass.sh";
#[cfg(target_os = "windows")]
const ASKPASS_SCRIPT_NAME: &str = "askpass.ps1";
impl AskPassSession { impl AskPassSession {
/// This will create a new AskPassSession. /// This will create a new AskPassSession.
/// You must retain this session until the master process exits. /// You must retain this session until the master process exits.
#[must_use] #[must_use]
pub async fn new( pub async fn new(executor: &BackgroundExecutor, mut delegate: AskPassDelegate) -> Result<Self> {
executor: &BackgroundExecutor, use net::async_net::UnixListener;
mut delegate: AskPassDelegate, use util::fs::make_file_executable;
) -> anyhow::Result<Self> {
#[cfg(target_os = "windows")]
let secret = std::sync::Arc::new(parking_lot::Mutex::new(String::new()));
let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?; let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?;
let askpass_socket = temp_dir.path().join("askpass.sock"); let askpass_socket = temp_dir.path().join("askpass.sock");
let askpass_script_path = temp_dir.path().join("askpass.sh"); let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME);
let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>(); let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
let listener = let listener = UnixListener::bind(&askpass_socket).context("creating askpass socket")?;
UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?; #[cfg(not(target_os = "windows"))]
let zed_path = get_shell_safe_zed_path()?; let zed_path = util::get_shell_safe_zed_path()?;
#[cfg(target_os = "windows")]
let zed_path = std::env::current_exe()
.context("finding current executable path for use in askpass")?;
let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>(); let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>();
let mut kill_tx = Some(askpass_kill_master_tx); let mut kill_tx = Some(askpass_kill_master_tx);
#[cfg(target_os = "windows")]
let askpass_secret = secret.clone();
let askpass_task = executor.spawn(async move { let askpass_task = executor.spawn(async move {
let mut askpass_opened_tx = Some(askpass_opened_tx); let mut askpass_opened_tx = Some(askpass_opened_tx);
@ -93,10 +101,14 @@ impl AskPassSession {
if let Some(password) = delegate if let Some(password) = delegate
.ask_password(prompt.to_string()) .ask_password(prompt.to_string())
.await .await
.context("failed to get askpass password") .context("getting askpass password")
.log_err() .log_err()
{ {
stream.write_all(password.as_bytes()).await.log_err(); stream.write_all(password.as_bytes()).await.log_err();
#[cfg(target_os = "windows")]
{
*askpass_secret.lock() = password;
}
} else { } else {
if let Some(kill_tx) = kill_tx.take() { if let Some(kill_tx) = kill_tx.take() {
kill_tx.send(()).log_err(); kill_tx.send(()).log_err();
@ -112,34 +124,49 @@ impl AskPassSession {
}); });
// Create an askpass script that communicates back to this process. // Create an askpass script that communicates back to this process.
let askpass_script = format!( let askpass_script = generate_askpass_script(&zed_path, &askpass_socket);
"{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n", fs::write(&askpass_script_path, askpass_script)
zed_exe = zed_path, .await
askpass_socket = askpass_socket.display(), .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?;
print_args = "printf '%s\\0' \"$@\"",
shebang = "#!/bin/sh",
);
fs::write(&askpass_script_path, askpass_script).await?;
make_file_executable(&askpass_script_path).await?; make_file_executable(&askpass_script_path).await?;
#[cfg(target_os = "windows")]
let askpass_helper = format!(
"powershell.exe -ExecutionPolicy Bypass -File {}",
askpass_script_path.display()
);
Ok(Self { Ok(Self {
#[cfg(not(target_os = "windows"))]
script_path: askpass_script_path, script_path: askpass_script_path,
#[cfg(target_os = "windows")]
secret,
#[cfg(target_os = "windows")]
askpass_helper,
_askpass_task: askpass_task, _askpass_task: askpass_task,
askpass_kill_master_rx: Some(askpass_kill_master_rx), askpass_kill_master_rx: Some(askpass_kill_master_rx),
askpass_opened_rx: Some(askpass_opened_rx), askpass_opened_rx: Some(askpass_opened_rx),
}) })
} }
pub fn script_path(&self) -> &Path { #[cfg(not(target_os = "windows"))]
pub fn script_path(&self) -> impl AsRef<OsStr> {
&self.script_path &self.script_path
} }
#[cfg(target_os = "windows")]
pub fn script_path(&self) -> impl AsRef<OsStr> {
&self.askpass_helper
}
// This will run the askpass task forever, resolving as many authentication requests as needed. // This will run the askpass task forever, resolving as many authentication requests as needed.
// The caller is responsible for examining the result of their own commands and cancelling this // The caller is responsible for examining the result of their own commands and cancelling this
// future when this is no longer needed. Note that this can only be called once, but due to the // future when this is no longer needed. Note that this can only be called once, but due to the
// drop order this takes an &mut, so you can `drop()` it after you're done with the master process. // drop order this takes an &mut, so you can `drop()` it after you're done with the master process.
pub async fn run(&mut self) -> AskPassResult { pub async fn run(&mut self) -> AskPassResult {
let connection_timeout = Duration::from_secs(10); // This is the default timeout setting used by VSCode.
let connection_timeout = Duration::from_secs(17);
let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once"); let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once");
let askpass_kill_master_rx = self let askpass_kill_master_rx = self
.askpass_kill_master_rx .askpass_kill_master_rx
@ -158,14 +185,19 @@ impl AskPassSession {
} }
} }
} }
/// This will return the password that was last set by the askpass script.
#[cfg(target_os = "windows")]
pub fn get_password(&self) -> String {
self.secret.lock().clone()
}
} }
/// The main function for when Zed is running in netcat mode for use in askpass. /// The main function for when Zed is running in netcat mode for use in askpass.
/// Called from both the remote server binary and the zed binary in their respective main functions. /// Called from both the remote server binary and the zed binary in their respective main functions.
#[cfg(unix)]
pub fn main(socket: &str) { pub fn main(socket: &str) {
use net::UnixStream;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use std::os::unix::net::UnixStream;
use std::process::exit; use std::process::exit;
let mut stream = match UnixStream::connect(socket) { let mut stream = match UnixStream::connect(socket) {
@ -182,6 +214,10 @@ pub fn main(socket: &str) {
exit(1); exit(1);
} }
#[cfg(target_os = "windows")]
while buffer.last().map_or(false, |&b| b == b'\n' || b == b'\r') {
buffer.pop();
}
if buffer.last() != Some(&b'\0') { if buffer.last() != Some(&b'\0') {
buffer.push(b'\0'); buffer.push(b'\0');
} }
@ -202,28 +238,28 @@ pub fn main(socket: &str) {
exit(1); exit(1);
} }
} }
#[cfg(not(unix))]
pub fn main(_socket: &str) {}
#[cfg(not(unix))] #[inline]
pub struct AskPassSession { #[cfg(not(target_os = "windows"))]
path: PathBuf, fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) -> String {
format!(
"{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n",
zed_exe = zed_path,
askpass_socket = askpass_socket.display(),
print_args = "printf '%s\\0' \"$@\"",
shebang = "#!/bin/sh",
)
} }
#[cfg(not(unix))] #[inline]
impl AskPassSession { #[cfg(target_os = "windows")]
pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result<Self> { fn generate_askpass_script(zed_path: &std::path::Path, askpass_socket: &std::path::Path) -> String {
Ok(Self { format!(
path: PathBuf::new(), r#"
}) $ErrorActionPreference = 'Stop';
} ($args -join [char]0) | & "{zed_exe}" --askpass={askpass_socket} 2> $null
"#,
pub fn script_path(&self) -> &Path { zed_exe = zed_path.display(),
&self.path askpass_socket = askpass_socket.display(),
} )
pub async fn run(&mut self) -> AskPassResult {
futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))).await;
AskPassResult::Timedout
}
} }

View file

@ -2,12 +2,13 @@ use crate::{
schema::json_schema_for, schema::json_schema_for,
ui::{COLLAPSED_LINES, ToolOutputPreview}, ui::{COLLAPSED_LINES, ToolOutputPreview},
}; };
use agent_settings;
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus}; use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
use futures::{FutureExt as _, future::Shared}; use futures::{FutureExt as _, future::Shared};
use gpui::{ use gpui::{
AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement, Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
WeakEntity, Window, TextStyleRefinement, Transformation, WeakEntity, Window, percentage,
}; };
use language::LineEnding; use language::LineEnding;
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@ -247,6 +248,7 @@ impl Tool for TerminalTool {
command_markdown.clone(), command_markdown.clone(),
working_dir.clone(), working_dir.clone(),
cx.entity_id(), cx.entity_id(),
cx,
) )
}); });
@ -441,7 +443,10 @@ impl TerminalToolCard {
input_command: Entity<Markdown>, input_command: Entity<Markdown>,
working_dir: Option<PathBuf>, working_dir: Option<PathBuf>,
entity_id: EntityId, entity_id: EntityId,
cx: &mut Context<Self>,
) -> Self { ) -> Self {
let expand_terminal_card =
agent_settings::AgentSettings::get_global(cx).expand_terminal_card;
Self { Self {
input_command, input_command,
working_dir, working_dir,
@ -453,7 +458,7 @@ impl TerminalToolCard {
finished_with_empty_output: false, finished_with_empty_output: false,
original_content_len: 0, original_content_len: 0,
content_line_count: 0, content_line_count: 0,
preview_expanded: true, preview_expanded: expand_terminal_card,
start_instant: Instant::now(), start_instant: Instant::now(),
elapsed_time: None, elapsed_time: None,
} }
@ -518,6 +523,46 @@ impl ToolCard for TerminalToolCard {
.color(Color::Muted), .color(Color::Muted),
), ),
) )
.when(!self.command_finished, |header| {
header.child(
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))),
),
)
})
.when(tool_failed || command_failed, |header| {
header.child(
div()
.id(("terminal-tool-error-code-indicator", self.entity_id))
.child(
Icon::new(IconName::Close)
.size(IconSize::Small)
.color(Color::Error),
)
.when(command_failed && self.exit_status.is_some(), |this| {
this.tooltip(Tooltip::text(format!(
"Exited with code {}",
self.exit_status
.and_then(|status| status.code())
.unwrap_or(-1),
)))
})
.when(
!command_failed && tool_failed && status.error().is_some(),
|this| {
this.tooltip(Tooltip::text(format!(
"Error: {}",
status.error().unwrap(),
)))
},
),
)
})
.when(self.was_content_truncated, |header| { .when(self.was_content_truncated, |header| {
let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
"Output exceeded terminal max lines and was \ "Output exceeded terminal max lines and was \
@ -555,34 +600,6 @@ impl ToolCard for TerminalToolCard {
.size(LabelSize::Small), .size(LabelSize::Small),
) )
}) })
.when(tool_failed || command_failed, |header| {
header.child(
div()
.id(("terminal-tool-error-code-indicator", self.entity_id))
.child(
Icon::new(IconName::Close)
.size(IconSize::Small)
.color(Color::Error),
)
.when(command_failed && self.exit_status.is_some(), |this| {
this.tooltip(Tooltip::text(format!(
"Exited with code {}",
self.exit_status
.and_then(|status| status.code())
.unwrap_or(-1),
)))
})
.when(
!command_failed && tool_failed && status.error().is_some(),
|this| {
this.tooltip(Tooltip::text(format!(
"Error: {}",
status.error().unwrap(),
)))
},
),
)
})
.when(!self.finished_with_empty_output, |header| { .when(!self.finished_with_empty_output, |header| {
header.child( header.child(
Disclosure::new( Disclosure::new(
@ -634,6 +651,7 @@ impl ToolCard for TerminalToolCard {
div() div()
.pt_2() .pt_2()
.border_t_1() .border_t_1()
.when(tool_failed || command_failed, |card| card.border_dashed())
.border_color(border_color) .border_color(border_color)
.bg(cx.theme().colors().editor_background) .bg(cx.theme().colors().editor_background)
.rounded_b_md() .rounded_b_md()

View file

@ -638,7 +638,7 @@ impl AutoUpdater {
let filename = match OS { let filename = match OS {
"macos" => anyhow::Ok("Zed.dmg"), "macos" => anyhow::Ok("Zed.dmg"),
"linux" => Ok("zed.tar.gz"), "linux" => Ok("zed.tar.gz"),
"windows" => Ok("ZedUpdateInstaller.exe"), "windows" => Ok("zed_editor_installer.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?; }?;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 577 KiB

Before After
Before After

View file

@ -130,6 +130,13 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
} }
fn main() -> Result<()> { fn main() -> Result<()> {
#[cfg(all(not(debug_assertions), target_os = "windows"))]
unsafe {
use ::windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole};
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
}
#[cfg(unix)] #[cfg(unix)]
util::prevent_root_execution(); util::prevent_root_execution();

View file

@ -26,7 +26,7 @@ CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"
CREATE TABLE "access_tokens" ( CREATE TABLE "access_tokens" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER REFERENCES users (id), "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
"impersonated_user_id" INTEGER REFERENCES users (id), "impersonated_user_id" INTEGER REFERENCES users (id),
"hash" VARCHAR(128) "hash" VARCHAR(128)
); );

View file

@ -0,0 +1,3 @@
ALTER TABLE access_tokens DROP CONSTRAINT access_tokens_user_id_fkey;
ALTER TABLE access_tokens ADD CONSTRAINT access_tokens_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;

View file

@ -44,3 +44,53 @@ async fn test_accepted_tos(db: &Arc<Database>) {
let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
assert!(user.accepted_tos_at.is_none()); assert!(user.accepted_tos_at.is_none());
} }
test_both_dbs!(
test_destroy_user_cascade_deletes_access_tokens,
test_destroy_user_cascade_deletes_access_tokens_postgres,
test_destroy_user_cascade_deletes_access_tokens_sqlite
);
async fn test_destroy_user_cascade_deletes_access_tokens(db: &Arc<Database>) {
let user_id = db
.create_user(
"user1@example.com",
Some("user1"),
false,
NewUserParams {
github_login: "user1".to_string(),
github_user_id: 12345,
},
)
.await
.unwrap()
.user_id;
let user = db.get_user_by_id(user_id).await.unwrap();
assert!(user.is_some());
let token_1_id = db
.create_access_token(user_id, None, "token-1", 10)
.await
.unwrap();
let token_2_id = db
.create_access_token(user_id, None, "token-2", 10)
.await
.unwrap();
let token_1 = db.get_access_token(token_1_id).await;
let token_2 = db.get_access_token(token_2_id).await;
assert!(token_1.is_ok());
assert!(token_2.is_ok());
db.destroy_user(user_id).await.unwrap();
let user = db.get_user_by_id(user_id).await.unwrap();
assert!(user.is_none());
let token_1 = db.get_access_token(token_1_id).await;
let token_2 = db.get_access_token(token_2_id).await;
assert!(token_1.is_err());
assert!(token_2.is_err());
}

View file

@ -1066,7 +1066,7 @@ impl DisplaySnapshot {
} }
let font_size = editor_style.text.font_size.to_pixels(*rem_size); let font_size = editor_style.text.font_size.to_pixels(*rem_size);
text_system.layout_line(&line, font_size, &runs) text_system.layout_line(&line, font_size, &runs, None)
} }
pub fn x_for_display_point( pub fn x_for_display_point(

View file

@ -865,9 +865,19 @@ pub trait Addon: 'static {
} }
} }
struct ChangeLocation {
current: Option<Vec<Anchor>>,
original: Vec<Anchor>,
}
impl ChangeLocation {
fn locations(&self) -> &[Anchor] {
self.current.as_ref().unwrap_or(&self.original)
}
}
/// A set of caret positions, registered when the editor was edited. /// A set of caret positions, registered when the editor was edited.
pub struct ChangeList { pub struct ChangeList {
changes: Vec<Vec<Anchor>>, changes: Vec<ChangeLocation>,
/// Currently "selected" change. /// Currently "selected" change.
position: Option<usize>, position: Option<usize>,
} }
@ -894,20 +904,38 @@ impl ChangeList {
(prev + count).min(self.changes.len() - 1) (prev + count).min(self.changes.len() - 1)
}; };
self.position = Some(next); self.position = Some(next);
self.changes.get(next).map(|anchors| anchors.as_slice()) self.changes.get(next).map(|change| change.locations())
} }
/// Adds a new change to the list, resetting the change list position. /// Adds a new change to the list, resetting the change list position.
pub fn push_to_change_list(&mut self, pop_state: bool, new_positions: Vec<Anchor>) { pub fn push_to_change_list(&mut self, group: bool, new_positions: Vec<Anchor>) {
self.position.take(); self.position.take();
if pop_state { if let Some(last) = self.changes.last_mut()
self.changes.pop(); && group
{
last.current = Some(new_positions)
} else {
self.changes.push(ChangeLocation {
original: new_positions,
current: None,
});
} }
self.changes.push(new_positions.clone());
} }
pub fn last(&self) -> Option<&[Anchor]> { pub fn last(&self) -> Option<&[Anchor]> {
self.changes.last().map(|anchors| anchors.as_slice()) self.changes.last().map(|change| change.locations())
}
pub fn last_before_grouping(&self) -> Option<&[Anchor]> {
self.changes.last().map(|change| change.original.as_slice())
}
pub fn invert_last_group(&mut self) {
if let Some(last) = self.changes.last_mut() {
if let Some(current) = last.current.as_mut() {
mem::swap(&mut last.original, current);
}
}
} }
} }
@ -1142,7 +1170,6 @@ pub struct Editor {
pub change_list: ChangeList, pub change_list: ChangeList,
inline_value_cache: InlineValueCache, inline_value_cache: InlineValueCache,
selection_drag_state: SelectionDragState, selection_drag_state: SelectionDragState,
drag_and_drop_selection_enabled: bool,
next_color_inlay_id: usize, next_color_inlay_id: usize,
colors: Option<LspColorData>, colors: Option<LspColorData>,
folding_newlines: Task<()>, folding_newlines: Task<()>,
@ -2174,7 +2201,6 @@ impl Editor {
change_list: ChangeList::new(), change_list: ChangeList::new(),
mode, mode,
selection_drag_state: SelectionDragState::None, selection_drag_state: SelectionDragState::None,
drag_and_drop_selection_enabled: EditorSettings::get_global(cx).drag_and_drop_selection,
folding_newlines: Task::ready(()), folding_newlines: Task::ready(()),
}; };
if let Some(breakpoints) = editor.breakpoint_store.as_ref() { if let Some(breakpoints) = editor.breakpoint_store.as_ref() {
@ -19871,7 +19897,6 @@ impl Editor {
self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs; self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default(); self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default();
self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default(); self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default();
self.drag_and_drop_selection_enabled = editor_settings.drag_and_drop_selection;
} }
if old_cursor_shape != self.cursor_shape { if old_cursor_shape != self.cursor_shape {

View file

@ -52,7 +52,7 @@ pub struct EditorSettings {
#[serde(default)] #[serde(default)]
pub diagnostics_max_severity: Option<DiagnosticSeverity>, pub diagnostics_max_severity: Option<DiagnosticSeverity>,
pub inline_code_actions: bool, pub inline_code_actions: bool,
pub drag_and_drop_selection: bool, pub drag_and_drop_selection: DragAndDropSelection,
pub lsp_document_colors: DocumentColorsRenderMode, pub lsp_document_colors: DocumentColorsRenderMode,
} }
@ -275,6 +275,26 @@ pub struct ScrollbarAxes {
pub vertical: bool, pub vertical: bool,
} }
/// Whether to allow drag and drop text selection in buffer.
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct DragAndDropSelection {
/// When true, enables drag and drop text selection in buffer.
///
/// Default: true
#[serde(default = "default_true")]
pub enabled: bool,
/// The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created.
///
/// Default: 300
#[serde(default = "default_drag_and_drop_selection_delay_ms")]
pub delay: u64,
}
fn default_drag_and_drop_selection_delay_ms() -> u64 {
300
}
/// Which diagnostic indicators to show in the scrollbar. /// Which diagnostic indicators to show in the scrollbar.
/// ///
/// Default: all /// Default: all
@ -536,10 +556,8 @@ pub struct EditorSettingsContent {
/// Default: true /// Default: true
pub inline_code_actions: Option<bool>, pub inline_code_actions: Option<bool>,
/// Whether to allow drag and drop text selection in buffer. /// Drag and drop related settings
/// pub drag_and_drop_selection: Option<DragAndDropSelection>,
/// Default: true
pub drag_and_drop_selection: Option<bool>,
/// How to render LSP `textDocument/documentColor` colors in the editor. /// How to render LSP `textDocument/documentColor` colors in the editor.
/// ///

View file

@ -87,7 +87,6 @@ use util::{RangeExt, ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt}; use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt};
const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.; const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
const SELECTION_DRAG_DELAY: Duration = Duration::from_millis(300);
/// Determines what kinds of highlights should be applied to a lines background. /// Determines what kinds of highlights should be applied to a lines background.
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
@ -644,7 +643,11 @@ impl EditorElement {
return; return;
} }
if editor.drag_and_drop_selection_enabled && click_count == 1 { if EditorSettings::get_global(cx)
.drag_and_drop_selection
.enabled
&& click_count == 1
{
let newest_anchor = editor.selections.newest_anchor(); let newest_anchor = editor.selections.newest_anchor();
let snapshot = editor.snapshot(window, cx); let snapshot = editor.snapshot(window, cx);
let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot)); let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot));
@ -1022,7 +1025,10 @@ impl EditorElement {
ref click_position, ref click_position,
ref mouse_down_time, ref mouse_down_time,
} => { } => {
if mouse_down_time.elapsed() >= SELECTION_DRAG_DELAY { let drag_and_drop_delay = Duration::from_millis(
EditorSettings::get_global(cx).drag_and_drop_selection.delay,
);
if mouse_down_time.elapsed() >= drag_and_drop_delay {
let drop_cursor = Selection { let drop_cursor = Selection {
id: post_inc(&mut editor.selections.next_selection_id), id: post_inc(&mut editor.selections.next_selection_id),
start: drop_anchor, start: drop_anchor,
@ -1611,6 +1617,7 @@ impl EditorElement {
strikethrough: None, strikethrough: None,
underline: None, underline: None,
}], }],
None,
) )
}) })
} else { } else {
@ -3263,10 +3270,12 @@ impl EditorElement {
underline: None, underline: None,
strikethrough: None, strikethrough: None,
}; };
let line = let line = window.text_system().shape_line(
window line.to_string().into(),
.text_system() font_size,
.shape_line(line.to_string().into(), font_size, &[run]); &[run],
None,
);
LineWithInvisibles { LineWithInvisibles {
width: line.width, width: line.width,
len: line.len, len: line.len,
@ -5707,6 +5716,19 @@ impl EditorElement {
let editor = self.editor.read(cx); let editor = self.editor.read(cx);
if editor.mouse_cursor_hidden { if editor.mouse_cursor_hidden {
window.set_window_cursor_style(CursorStyle::None); window.set_window_cursor_style(CursorStyle::None);
} else if let SelectionDragState::ReadyToDrag {
mouse_down_time, ..
} = &editor.selection_drag_state
{
let drag_and_drop_delay = Duration::from_millis(
EditorSettings::get_global(cx).drag_and_drop_selection.delay,
);
if mouse_down_time.elapsed() >= drag_and_drop_delay {
window.set_cursor_style(
CursorStyle::DragCopy,
&layout.position_map.text_hitbox,
);
}
} else if matches!( } else if matches!(
editor.selection_drag_state, editor.selection_drag_state,
SelectionDragState::Dragging { .. } SelectionDragState::Dragging { .. }
@ -6888,6 +6910,7 @@ impl EditorElement {
underline: None, underline: None,
strikethrough: None, strikethrough: None,
}], }],
None,
); );
layout.width layout.width
@ -6916,6 +6939,7 @@ impl EditorElement {
text, text,
self.style.text.font_size.to_pixels(window.rem_size()), self.style.text.font_size.to_pixels(window.rem_size()),
&[run], &[run],
None,
) )
} }
@ -7184,10 +7208,12 @@ impl LineWithInvisibles {
}]) { }]) {
if let Some(replacement) = highlighted_chunk.replacement { if let Some(replacement) = highlighted_chunk.replacement {
if !line.is_empty() { if !line.is_empty() {
let shaped_line = let shaped_line = window.text_system().shape_line(
window line.clone().into(),
.text_system() font_size,
.shape_line(line.clone().into(), font_size, &styles); &styles,
None,
);
width += shaped_line.width; width += shaped_line.width;
len += shaped_line.len; len += shaped_line.len;
fragments.push(LineFragment::Text(shaped_line)); fragments.push(LineFragment::Text(shaped_line));
@ -7207,6 +7233,7 @@ impl LineWithInvisibles {
chunk, chunk,
font_size, font_size,
&[text_style.to_run(highlighted_chunk.text.len())], &[text_style.to_run(highlighted_chunk.text.len())],
None,
); );
AvailableSpace::Definite(shaped_line.width) AvailableSpace::Definite(shaped_line.width)
} else { } else {
@ -7251,7 +7278,7 @@ impl LineWithInvisibles {
}; };
let line_layout = window let line_layout = window
.text_system() .text_system()
.shape_line(x, font_size, &[run]) .shape_line(x, font_size, &[run], None)
.with_len(highlighted_chunk.text.len()); .with_len(highlighted_chunk.text.len());
width += line_layout.width; width += line_layout.width;
@ -7266,6 +7293,7 @@ impl LineWithInvisibles {
line.clone().into(), line.clone().into(),
font_size, font_size,
&styles, &styles,
None,
); );
width += shaped_line.width; width += shaped_line.width;
len += shaped_line.len; len += shaped_line.len;
@ -7935,6 +7963,7 @@ impl Element for EditorElement {
editor.last_bounds = Some(bounds); editor.last_bounds = Some(bounds);
editor.gutter_dimensions = gutter_dimensions; editor.gutter_dimensions = gutter_dimensions;
editor.set_visible_line_count(bounds.size.height / line_height, window, cx); editor.set_visible_line_count(bounds.size.height / line_height, window, cx);
editor.set_visible_column_count(editor_content_width / em_advance);
if matches!( if matches!(
editor.mode, editor.mode,
@ -8440,6 +8469,7 @@ impl Element for EditorElement {
scroll_width, scroll_width,
em_advance, em_advance,
&line_layouts, &line_layouts,
window,
cx, cx,
) )
} else { } else {
@ -8594,6 +8624,7 @@ impl Element for EditorElement {
scroll_width, scroll_width,
em_advance, em_advance,
&line_layouts, &line_layouts,
window,
cx, cx,
) )
} else { } else {
@ -8831,6 +8862,7 @@ impl Element for EditorElement {
underline: None, underline: None,
strikethrough: None, strikethrough: None,
}], }],
None
); );
let space_invisible = window.text_system().shape_line( let space_invisible = window.text_system().shape_line(
"".into(), "".into(),
@ -8843,6 +8875,7 @@ impl Element for EditorElement {
underline: None, underline: None,
strikethrough: None, strikethrough: None,
}], }],
None
); );
let mode = snapshot.mode.clone(); let mode = snapshot.mode.clone();

View file

@ -381,10 +381,14 @@ fn show_hover(
.anchor_after(local_diagnostic.range.end), .anchor_after(local_diagnostic.range.end),
}; };
let scroll_handle = ScrollHandle::new();
Some(DiagnosticPopover { Some(DiagnosticPopover {
local_diagnostic, local_diagnostic,
markdown, markdown,
border_color, border_color,
scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
scroll_handle,
background_color, background_color,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor, anchor,
@ -955,6 +959,8 @@ pub struct DiagnosticPopover {
pub keyboard_grace: Rc<RefCell<bool>>, pub keyboard_grace: Rc<RefCell<bool>>,
pub anchor: Anchor, pub anchor: Anchor,
_subscription: Subscription, _subscription: Subscription,
pub scroll_handle: ScrollHandle,
pub scrollbar_state: ScrollbarState,
} }
impl DiagnosticPopover { impl DiagnosticPopover {
@ -968,10 +974,7 @@ impl DiagnosticPopover {
let this = cx.entity().downgrade(); let this = cx.entity().downgrade();
div() div()
.id("diagnostic") .id("diagnostic")
.block() .occlude()
.max_h(max_size.height)
.overflow_y_scroll()
.max_w(max_size.width)
.elevation_2_borderless(cx) .elevation_2_borderless(cx)
// Don't draw the background color if the theme // Don't draw the background color if the theme
// allows transparent surfaces. // allows transparent surfaces.
@ -992,27 +995,72 @@ impl DiagnosticPopover {
div() div()
.py_1() .py_1()
.px_2() .px_2()
.child(
MarkdownElement::new(
self.markdown.clone(),
diagnostics_markdown_style(window, cx),
)
.on_url_click(move |link, window, cx| {
if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) {
this.update(cx, |this, cx| {
renderer.as_ref().open_link(this, link, window, cx);
})
.ok();
}
}),
)
.bg(self.background_color) .bg(self.background_color)
.border_1() .border_1()
.border_color(self.border_color) .border_color(self.border_color)
.rounded_lg(), .rounded_lg()
.child(
div()
.id("diagnostic-content-container")
.overflow_y_scroll()
.max_w(max_size.width)
.max_h(max_size.height)
.track_scroll(&self.scroll_handle)
.child(
MarkdownElement::new(
self.markdown.clone(),
diagnostics_markdown_style(window, cx),
)
.on_url_click(
move |link, window, cx| {
if let Some(renderer) = GlobalDiagnosticRenderer::global(cx)
{
this.update(cx, |this, cx| {
renderer.as_ref().open_link(this, link, window, cx);
})
.ok();
}
},
),
),
)
.child(self.render_vertical_scrollbar(cx)),
) )
.into_any_element() .into_any_element()
} }
fn render_vertical_scrollbar(&self, cx: &mut Context<Editor>) -> Stateful<Div> {
div()
.occlude()
.id("diagnostic-popover-vertical-scroll")
.on_mouse_move(cx.listener(|_, _, _, cx| {
cx.notify();
cx.stop_propagation()
}))
.on_hover(|_, _, cx| {
cx.stop_propagation();
})
.on_any_mouse_down(|_, _, cx| {
cx.stop_propagation();
})
.on_mouse_up(
MouseButton::Left,
cx.listener(|_, _, _, cx| {
cx.stop_propagation();
}),
)
.on_scroll_wheel(cx.listener(|_, _, _, cx| {
cx.notify();
}))
.h_full()
.absolute()
.right_1()
.top_1()
.bottom_0()
.w(px(12.))
.cursor_default()
.children(Scrollbar::vertical(self.scrollbar_state.clone()))
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -1607,24 +1607,10 @@ impl SearchableItem for Editor {
let text = self.buffer.read(cx); let text = self.buffer.read(cx);
let text = text.snapshot(cx); let text = text.snapshot(cx);
let mut edits = vec![]; let mut edits = vec![];
let mut last_point: Option<Point> = None;
for m in matches { for m in matches {
let point = m.start.to_point(&text);
let text = text.text_for_range(m.clone()).collect::<Vec<_>>(); let text = text.text_for_range(m.clone()).collect::<Vec<_>>();
// Check if the row for the current match is different from the last
// match. If that's not the case and we're still replacing matches
// in the same row/line, skip this match if the `one_match_per_line`
// option is enabled.
if last_point.is_none() {
last_point = Some(point);
} else if last_point.is_some() && point.row != last_point.unwrap().row {
last_point = Some(point);
} else if query.one_match_per_line().is_some_and(|enabled| enabled) {
continue;
}
let text: Cow<_> = if text.len() == 1 { let text: Cow<_> = if text.len() == 1 {
text.first().cloned().unwrap().into() text.first().cloned().unwrap().into()
} else { } else {

View file

@ -13,6 +13,7 @@ use crate::{
pub use autoscroll::{Autoscroll, AutoscrollStrategy}; pub use autoscroll::{Autoscroll, AutoscrollStrategy};
use core::fmt::Debug; use core::fmt::Debug;
use gpui::{App, Axis, Context, Global, Pixels, Task, Window, point, px}; use gpui::{App, Axis, Context, Global, Pixels, Task, Window, point, px};
use language::language_settings::{AllLanguageSettings, SoftWrap};
use language::{Bias, Point}; use language::{Bias, Point};
pub use scroll_amount::ScrollAmount; pub use scroll_amount::ScrollAmount;
use settings::Settings; use settings::Settings;
@ -151,12 +152,16 @@ pub struct ScrollManager {
pub(crate) vertical_scroll_margin: f32, pub(crate) vertical_scroll_margin: f32,
anchor: ScrollAnchor, anchor: ScrollAnchor,
ongoing: OngoingScroll, ongoing: OngoingScroll,
/// The second element indicates whether the autoscroll request is local
/// (true) or remote (false). Local requests are initiated by user actions,
/// while remote requests come from external sources.
autoscroll_request: Option<(Autoscroll, bool)>, autoscroll_request: Option<(Autoscroll, bool)>,
last_autoscroll: Option<(gpui::Point<f32>, f32, f32, AutoscrollStrategy)>, last_autoscroll: Option<(gpui::Point<f32>, f32, f32, AutoscrollStrategy)>,
show_scrollbars: bool, show_scrollbars: bool,
hide_scrollbar_task: Option<Task<()>>, hide_scrollbar_task: Option<Task<()>>,
active_scrollbar: Option<ActiveScrollbarState>, active_scrollbar: Option<ActiveScrollbarState>,
visible_line_count: Option<f32>, visible_line_count: Option<f32>,
visible_column_count: Option<f32>,
forbid_vertical_scroll: bool, forbid_vertical_scroll: bool,
minimap_thumb_state: Option<ScrollbarThumbState>, minimap_thumb_state: Option<ScrollbarThumbState>,
} }
@ -173,6 +178,7 @@ impl ScrollManager {
active_scrollbar: None, active_scrollbar: None,
last_autoscroll: None, last_autoscroll: None,
visible_line_count: None, visible_line_count: None,
visible_column_count: None,
forbid_vertical_scroll: false, forbid_vertical_scroll: false,
minimap_thumb_state: None, minimap_thumb_state: None,
} }
@ -210,7 +216,7 @@ impl ScrollManager {
window: &mut Window, window: &mut Window,
cx: &mut Context<Editor>, cx: &mut Context<Editor>,
) { ) {
let (new_anchor, top_row) = if scroll_position.y <= 0. { let (new_anchor, top_row) = if scroll_position.y <= 0. && scroll_position.x <= 0. {
( (
ScrollAnchor { ScrollAnchor {
anchor: Anchor::min(), anchor: Anchor::min(),
@ -218,6 +224,22 @@ impl ScrollManager {
}, },
0, 0,
) )
} else if scroll_position.y <= 0. {
let buffer_point = map
.clip_point(
DisplayPoint::new(DisplayRow(0), scroll_position.x as u32),
Bias::Left,
)
.to_point(map);
let anchor = map.buffer_snapshot.anchor_at(buffer_point, Bias::Right);
(
ScrollAnchor {
anchor: anchor,
offset: scroll_position.max(&gpui::Point::default()),
},
0,
)
} else { } else {
let scroll_top = scroll_position.y; let scroll_top = scroll_position.y;
let scroll_top = match EditorSettings::get_global(cx).scroll_beyond_last_line { let scroll_top = match EditorSettings::get_global(cx).scroll_beyond_last_line {
@ -242,8 +264,13 @@ impl ScrollManager {
} }
}; };
let scroll_top_buffer_point = let scroll_top_row = DisplayRow(scroll_top as u32);
DisplayPoint::new(DisplayRow(scroll_top as u32), 0).to_point(map); let scroll_top_buffer_point = map
.clip_point(
DisplayPoint::new(scroll_top_row, scroll_position.x as u32),
Bias::Left,
)
.to_point(map);
let top_anchor = map let top_anchor = map
.buffer_snapshot .buffer_snapshot
.anchor_at(scroll_top_buffer_point, Bias::Right); .anchor_at(scroll_top_buffer_point, Bias::Right);
@ -476,6 +503,10 @@ impl Editor {
.map(|line_count| line_count as u32 - 1) .map(|line_count| line_count as u32 - 1)
} }
pub fn visible_column_count(&self) -> Option<f32> {
self.scroll_manager.visible_column_count
}
pub(crate) fn set_visible_line_count( pub(crate) fn set_visible_line_count(
&mut self, &mut self,
lines: f32, lines: f32,
@ -497,6 +528,10 @@ impl Editor {
} }
} }
pub(crate) fn set_visible_column_count(&mut self, columns: f32) {
self.scroll_manager.visible_column_count = Some(columns);
}
pub fn apply_scroll_delta( pub fn apply_scroll_delta(
&mut self, &mut self,
scroll_delta: gpui::Point<f32>, scroll_delta: gpui::Point<f32>,
@ -675,25 +710,48 @@ impl Editor {
let Some(visible_line_count) = self.visible_line_count() else { let Some(visible_line_count) = self.visible_line_count() else {
return; return;
}; };
let Some(mut visible_column_count) = self.visible_column_count() else {
return;
};
// If the user has a preferred line length, and has the editor
// configured to wrap at the preferred line length, or bounded to it,
// use that value over the visible column count. This was mostly done so
// that tests could actually be written for vim's `z l`, `z h`, `z
// shift-l` and `z shift-h` commands, as there wasn't a good way to
// configure the editor to only display a certain number of columns. If
// that ever happens, this could probably be removed.
let settings = AllLanguageSettings::get_global(cx);
if matches!(
settings.defaults.soft_wrap,
SoftWrap::PreferredLineLength | SoftWrap::Bounded
) {
if (settings.defaults.preferred_line_length as f32) < visible_column_count {
visible_column_count = settings.defaults.preferred_line_length as f32;
}
}
// If the scroll position is currently at the left edge of the document // If the scroll position is currently at the left edge of the document
// (x == 0.0) and the intent is to scroll right, the gutter's margin // (x == 0.0) and the intent is to scroll right, the gutter's margin
// should first be added to the current position, otherwise the cursor // should first be added to the current position, otherwise the cursor
// will end at the column position minus the margin, which looks off. // will end at the column position minus the margin, which looks off.
if current_position.x == 0.0 && amount.columns() > 0. { if current_position.x == 0.0 && amount.columns(visible_column_count) > 0. {
if let Some(last_position_map) = &self.last_position_map { if let Some(last_position_map) = &self.last_position_map {
current_position.x += self.gutter_dimensions.margin / last_position_map.em_advance; current_position.x += self.gutter_dimensions.margin / last_position_map.em_advance;
} }
} }
let new_position = let new_position = current_position
current_position + point(amount.columns(), amount.lines(visible_line_count)); + point(
amount.columns(visible_column_count),
amount.lines(visible_line_count),
);
self.set_scroll_position(new_position, window, cx); self.set_scroll_position(new_position, window, cx);
} }
/// Returns an ordering. The newest selection is: /// Returns an ordering. The newest selection is:
/// Ordering::Equal => on screen /// Ordering::Equal => on screen
/// Ordering::Less => above the screen /// Ordering::Less => above or to the left of the screen
/// Ordering::Greater => below the screen /// Ordering::Greater => below or to the right of the screen
pub fn newest_selection_on_screen(&self, cx: &mut App) -> Ordering { pub fn newest_selection_on_screen(&self, cx: &mut App) -> Ordering {
let snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let newest_head = self let newest_head = self
@ -711,8 +769,12 @@ impl Editor {
return Ordering::Less; return Ordering::Less;
} }
if let Some(visible_lines) = self.visible_line_count() { if let (Some(visible_lines), Some(visible_columns)) =
if newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32) { (self.visible_line_count(), self.visible_column_count())
{
if newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32)
&& newest_head.column() <= screen_top.column() + visible_columns as u32
{
return Ordering::Equal; return Ordering::Equal;
} }
} }

View file

@ -274,12 +274,14 @@ impl Editor {
start_row: DisplayRow, start_row: DisplayRow,
viewport_width: Pixels, viewport_width: Pixels,
scroll_width: Pixels, scroll_width: Pixels,
max_glyph_width: Pixels, em_advance: Pixels,
layouts: &[LineWithInvisibles], layouts: &[LineWithInvisibles],
window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> bool { ) -> bool {
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let selections = self.selections.all::<Point>(cx); let selections = self.selections.all::<Point>(cx);
let mut scroll_position = self.scroll_manager.scroll_position(&display_map);
let mut target_left; let mut target_left;
let mut target_right; let mut target_right;
@ -295,16 +297,17 @@ impl Editor {
if head.row() >= start_row if head.row() >= start_row
&& head.row() < DisplayRow(start_row.0 + layouts.len() as u32) && head.row() < DisplayRow(start_row.0 + layouts.len() as u32)
{ {
let start_column = head.column().saturating_sub(3); let start_column = head.column();
let end_column = cmp::min(display_map.line_len(head.row()), head.column() + 3); let end_column = cmp::min(display_map.line_len(head.row()), head.column());
target_left = target_left.min( target_left = target_left.min(
layouts[head.row().minus(start_row) as usize] layouts[head.row().minus(start_row) as usize]
.x_for_index(start_column as usize), .x_for_index(start_column as usize)
+ self.gutter_dimensions.margin,
); );
target_right = target_right.max( target_right = target_right.max(
layouts[head.row().minus(start_row) as usize] layouts[head.row().minus(start_row) as usize]
.x_for_index(end_column as usize) .x_for_index(end_column as usize)
+ max_glyph_width, + em_advance,
); );
} }
} }
@ -319,14 +322,16 @@ impl Editor {
return false; return false;
} }
let scroll_left = self.scroll_manager.anchor.offset.x * max_glyph_width; let scroll_left = self.scroll_manager.anchor.offset.x * em_advance;
let scroll_right = scroll_left + viewport_width; let scroll_right = scroll_left + viewport_width;
if target_left < scroll_left { if target_left < scroll_left {
self.scroll_manager.anchor.offset.x = target_left / max_glyph_width; scroll_position.x = target_left / em_advance;
self.set_scroll_position_internal(scroll_position, true, true, window, cx);
true true
} else if target_right > scroll_right { } else if target_right > scroll_right {
self.scroll_manager.anchor.offset.x = (target_right - viewport_width) / max_glyph_width; scroll_position.x = (target_right - viewport_width) / em_advance;
self.set_scroll_position_internal(scroll_position, true, true, window, cx);
true true
} else { } else {
false false

View file

@ -23,6 +23,8 @@ pub enum ScrollAmount {
Page(f32), Page(f32),
// Scroll N columns (positive is towards the right of the document) // Scroll N columns (positive is towards the right of the document)
Column(f32), Column(f32),
// Scroll N page width (positive is towards the right of the document)
PageWidth(f32),
} }
impl ScrollAmount { impl ScrollAmount {
@ -37,14 +39,16 @@ impl ScrollAmount {
(visible_line_count * count).trunc() (visible_line_count * count).trunc()
} }
Self::Column(_count) => 0.0, Self::Column(_count) => 0.0,
Self::PageWidth(_count) => 0.0,
} }
} }
pub fn columns(&self) -> f32 { pub fn columns(&self, visible_column_count: f32) -> f32 {
match self { match self {
Self::Line(_count) => 0.0, Self::Line(_count) => 0.0,
Self::Page(_count) => 0.0, Self::Page(_count) => 0.0,
Self::Column(count) => *count, Self::Column(count) => *count,
Self::PageWidth(count) => (visible_column_count * count).trunc(),
} }
} }
@ -58,6 +62,7 @@ impl ScrollAmount {
// so I'm leaving this at 0.0 for now to try and make it clear that // so I'm leaving this at 0.0 for now to try and make it clear that
// this should not have an impact on that? // this should not have an impact on that?
ScrollAmount::Column(_) => px(0.0), ScrollAmount::Column(_) => px(0.0),
ScrollAmount::PageWidth(_) => px(0.0),
} }
} }

View file

@ -324,20 +324,8 @@
<body> <body>
<h1 id="current-filename">Thread Explorer</h1> <h1 id="current-filename">Thread Explorer</h1>
<div class="view-switcher"> <div class="view-switcher">
<button <button id="full-view" class="view-button active" onclick="switchView('full')">Full View</button>
id="full-view" <button id="compact-view" class="view-button" onclick="switchView('compact')">Compact View</button>
class="view-button active"
onclick="switchView('full')"
>
Full View
</button>
<button
id="compact-view"
class="view-button"
onclick="switchView('compact')"
>
Compact View
</button>
<button <button
id="export-button" id="export-button"
class="view-button" class="view-button"
@ -347,11 +335,7 @@
Export Export
</button> </button>
<div class="theme-switcher"> <div class="theme-switcher">
<button <button id="theme-toggle" class="theme-button" onclick="toggleTheme()">
id="theme-toggle"
class="theme-button"
onclick="toggleTheme()"
>
<span id="theme-icon" class="theme-icon">☀️</span> <span id="theme-icon" class="theme-icon">☀️</span>
<span id="theme-text">Light</span> <span id="theme-text">Light</span>
</button> </button>
@ -368,8 +352,7 @@
&larr; Previous &larr; Previous
</button> </button>
<div class="thread-indicator"> <div class="thread-indicator">
Thread <span id="current-thread-index">1</span> of Thread <span id="current-thread-index">1</span> of <span id="total-threads">1</span>:
<span id="total-threads">1</span>:
<span id="thread-id">Default Thread</span> <span id="thread-id">Default Thread</span>
</div> </div>
<button <button
@ -423,9 +406,7 @@
function toggleTheme() { function toggleTheme() {
// If currently system or light, switch to dark // If currently system or light, switch to dark
if (themeMode === "system") { if (themeMode === "system") {
const systemDark = window.matchMedia( const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
"(prefers-color-scheme: dark)",
).matches;
themeMode = systemDark ? "light" : "dark"; themeMode = systemDark ? "light" : "dark";
} else { } else {
themeMode = themeMode === "light" ? "dark" : "light"; themeMode = themeMode === "light" ? "dark" : "light";
@ -442,19 +423,15 @@
function initTheme() { function initTheme() {
if (themeMode === "system") { if (themeMode === "system") {
// Use system preference // Use system preference
const systemDark = window.matchMedia( const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
"(prefers-color-scheme: dark)",
).matches;
applyTheme(systemDark ? "dark" : "light"); applyTheme(systemDark ? "dark" : "light");
// Listen for system theme changes // Listen for system theme changes
window window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
.matchMedia("(prefers-color-scheme: dark)") if (themeMode === "system") {
.addEventListener("change", (e) => { applyTheme(e.matches ? "dark" : "light");
if (themeMode === "system") { }
applyTheme(e.matches ? "dark" : "light"); });
}
});
} else { } else {
// Use saved preference // Use saved preference
applyTheme(themeMode); applyTheme(themeMode);
@ -466,49 +443,38 @@
viewMode = mode; viewMode = mode;
// Update button states // Update button states
document document.getElementById("full-view").classList.toggle("active", mode === "full");
.getElementById("full-view") document.getElementById("compact-view").classList.toggle("active", mode === "compact");
.classList.toggle("active", mode === "full");
document
.getElementById("compact-view")
.classList.toggle("active", mode === "compact");
// Add or remove compact-mode class on the body // Add or remove compact-mode class on the body
document.body.classList.toggle( document.body.classList.toggle("compact-mode", mode === "compact");
"compact-mode",
mode === "compact",
);
// Re-render the thread with the new view mode // Re-render the thread with the new view mode
renderThread(); renderThread();
} }
// Function to export the current thread as a JSON file // Function to export the current thread as a JSON file
function exportThreadAsJson() { function exportThreadAsJson() {
// Clone the thread to avoid modifying the original // Clone the thread to avoid modifying the original
const threadToExport = JSON.parse(JSON.stringify(thread)); const threadToExport = JSON.parse(JSON.stringify(thread));
// Create a Blob with the JSON data // Create a Blob with the JSON data
const blob = new Blob( const blob = new Blob([JSON.stringify(threadToExport, null, 2)], { type: "application/json" });
[JSON.stringify(threadToExport, null, 2)],
{ type: "application/json" }
);
// Create a download link // Create a download link
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
// Generate filename based on thread ID or index // Generate filename based on thread ID or index
const filename = threadToExport.thread_id || const filename =
threadToExport.filename || threadToExport.thread_id || threadToExport.filename || `thread-${currentThreadIndex + 1}.json`;
`thread-${currentThreadIndex + 1}.json`;
a.download = filename.endsWith(".json") ? filename : `${filename}.json`; a.download = filename.endsWith(".json") ? filename : `${filename}.json`;
// Trigger the download // Trigger the download
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
// Clean up // Clean up
setTimeout(() => { setTimeout(() => {
document.body.removeChild(a); document.body.removeChild(a);
@ -524,9 +490,7 @@
}, },
{ {
role: "user", role: "user",
content: [ content: [{ Text: "Fix the bug: kwargs not passed..." }],
{ Text: "Fix the bug: kwargs not passed..." },
],
}, },
{ {
role: "assistant", role: "assistant",
@ -593,12 +557,9 @@
name: "edit_file", name: "edit_file",
input: { input: {
path: "fastmcp/core.py", path: "fastmcp/core.py",
old_string: old_string: "def start_server(app):\n anyio.run(app)",
"def start_server(app):\n anyio.run(app)", new_string: "def start_server(app, **kwargs):\n anyio.run(app, **kwargs)",
new_string: display_description: "Fix kwargs passing to anyio.run",
"def start_server(app, **kwargs):\n anyio.run(app, **kwargs)",
display_description:
"Fix kwargs passing to anyio.run",
}, },
is_input_complete: true, is_input_complete: true,
}, },
@ -681,14 +642,10 @@
// Function to update the navigation buttons state // Function to update the navigation buttons state
function updateNavigationButtons() { function updateNavigationButtons() {
document.getElementById("prev-thread").disabled = document.getElementById("prev-thread").disabled = currentThreadIndex <= 0;
currentThreadIndex <= 0; document.getElementById("next-thread").disabled = currentThreadIndex >= threads.length - 1;
document.getElementById("next-thread").disabled = document.getElementById("current-thread-index").textContent = currentThreadIndex + 1;
currentThreadIndex >= threads.length - 1; document.getElementById("total-threads").textContent = threads.length;
document.getElementById("current-thread-index").textContent =
currentThreadIndex + 1;
document.getElementById("total-threads").textContent =
threads.length;
} }
function renderThread() { function renderThread() {
@ -696,20 +653,15 @@
tbody.innerHTML = ""; // Clear existing content tbody.innerHTML = ""; // Clear existing content
// Set thread name if available // Set thread name if available
const threadId = const threadId = thread.thread_id || `Thread ${currentThreadIndex + 1}`;
thread.thread_id || `Thread ${currentThreadIndex + 1}`;
document.getElementById("thread-id").textContent = threadId; document.getElementById("thread-id").textContent = threadId;
// Set filename in the header if available // Set filename in the header if available
const filename = const filename = thread.filename || `Thread ${currentThreadIndex + 1}`;
thread.filename || `Thread ${currentThreadIndex + 1}`; document.getElementById("current-filename").textContent = filename;
document.getElementById("current-filename").textContent =
filename;
// Skip system message // Skip system message
const nonSystemMessages = thread.messages.filter( const nonSystemMessages = thread.messages.filter((msg) => msg.role !== "system");
(msg) => msg.role !== "system",
);
let turnNumber = 0; let turnNumber = 0;
processMessages(nonSystemMessages, tbody, turnNumber); processMessages(nonSystemMessages, tbody, turnNumber);
@ -737,9 +689,7 @@
for (const content of msg.content) { for (const content of msg.content) {
if (content.hasOwnProperty("Text")) { if (content.hasOwnProperty("Text")) {
if (assistantText) { if (assistantText) {
assistantText += assistantText += "<br><br>" + formatContent(content.Text);
"<br><br>" +
formatContent(content.Text);
} else { } else {
assistantText = formatContent(content.Text); assistantText = formatContent(content.Text);
} }
@ -763,49 +713,33 @@
tbody.appendChild(row); tbody.appendChild(row);
// Add all tool calls to the tools cell // Add all tool calls to the tools cell
const toolsCell = document.getElementById( const toolsCell = document.getElementById(`tools-${turnNumber}`);
`tools-${turnNumber}`, const resultsCell = document.getElementById(`results-${turnNumber}`);
);
const resultsCell = document.getElementById(
`results-${turnNumber}`,
);
// Process all tools and their results // Process all tools and their results
for (let j = 0; j < toolUses.length; j++) { for (let j = 0; j < toolUses.length; j++) {
const toolUse = toolUses[j]; const toolUse = toolUses[j];
const toolCall = formatToolCall( const toolCall = formatToolCall(toolUse.name, toolUse.input);
toolUse.name,
toolUse.input,
);
// Add the tool call to the tools cell // Add the tool call to the tools cell
if (j > 0) toolsCell.innerHTML += "<hr>"; if (j > 0) toolsCell.innerHTML += "<hr>";
toolsCell.innerHTML += toolCall; toolsCell.innerHTML += toolCall;
// Look for corresponding tool result // Look for corresponding tool result
if ( if (hasMatchingToolResult(messages, i, toolUse.name)) {
hasMatchingToolResult(messages, i, toolUse.name)
) {
const resultMsg = messages[i + 1]; const resultMsg = messages[i + 1];
const toolResult = findToolResult( const toolResult = findToolResult(resultMsg, toolUse.name);
resultMsg,
toolUse.name,
);
if (toolResult) { if (toolResult) {
// Add the result to the results cell // Add the result to the results cell
if (j > 0) resultsCell.innerHTML += "<hr>"; if (j > 0) resultsCell.innerHTML += "<hr>";
// Create a container for the result // Create a container for the result
const resultDiv = const resultDiv = document.createElement("div");
document.createElement("div");
resultDiv.className = "tool-result"; resultDiv.className = "tool-result";
// Format and display the tool result // Format and display the tool result
formatToolResultInline( formatToolResultInline(toolResult.content.Text, resultDiv);
toolResult.content,
resultDiv,
);
resultsCell.appendChild(resultDiv); resultsCell.appendChild(resultDiv);
// Skip the result message in the next iteration // Skip the result message in the next iteration
@ -815,10 +749,7 @@
} }
} }
} }
} else if ( } else if (msg.role === "user" && msg.content.some((c) => c.hasOwnProperty("ToolResult"))) {
msg.role === "user" &&
msg.content.some((c) => c.hasOwnProperty("ToolResult"))
) {
// Skip tool result messages as they are handled with their corresponding tool use // Skip tool result messages as they are handled with their corresponding tool use
continue; continue;
} }
@ -826,10 +757,7 @@
} }
function isUserQuery(message) { function isUserQuery(message) {
return ( return message.role === "user" && !message.content.some((c) => c.hasOwnProperty("ToolResult"));
message.role === "user" &&
!message.content.some((c) => c.hasOwnProperty("ToolResult"))
);
} }
function renderUserMessage(message, turnNumber, tbody) { function renderUserMessage(message, turnNumber, tbody) {
@ -848,18 +776,14 @@
currentIndex + 1 < messages.length && currentIndex + 1 < messages.length &&
messages[currentIndex + 1].role === "user" && messages[currentIndex + 1].role === "user" &&
messages[currentIndex + 1].content.some( messages[currentIndex + 1].content.some(
(c) => (c) => c.hasOwnProperty("ToolResult") && c.ToolResult.tool_name === toolName,
c.hasOwnProperty("ToolResult") &&
c.ToolResult.tool_name === toolName,
) )
); );
} }
function findToolResult(resultMessage, toolName) { function findToolResult(resultMessage, toolName) {
const toolResultContent = resultMessage.content.find( const toolResultContent = resultMessage.content.find(
(c) => (c) => c.hasOwnProperty("ToolResult") && c.ToolResult.tool_name === toolName,
c.hasOwnProperty("ToolResult") &&
c.ToolResult.tool_name === toolName,
); );
return toolResultContent ? toolResultContent.ToolResult : null; return toolResultContent ? toolResultContent.ToolResult : null;
@ -874,18 +798,12 @@
for (const [key, value] of Object.entries(input)) { for (const [key, value] of Object.entries(input)) {
if (value !== null && value !== undefined) { if (value !== null && value !== undefined) {
// Store full parameter for expanded view // Store full parameter for expanded view
let fullValue = let fullValue = typeof value === "string" ? `"${value}"` : value;
typeof value === "string"
? `"${value}"`
: value;
fullParams.push([key, fullValue]); fullParams.push([key, fullValue]);
// Abbreviated value for compact view // Abbreviated value for compact view
let displayValue = fullValue; let displayValue = fullValue;
if ( if (typeof value === "string" && value.length > 30) {
typeof value === "string" &&
value.length > 30
) {
displayValue = `"${value.substring(0, 30)}..."`; displayValue = `"${value.substring(0, 30)}..."`;
} }
params.push(`${key}=${displayValue}`); params.push(`${key}=${displayValue}`);
@ -903,10 +821,7 @@
// For the full view, use the original untruncated values // For the full view, use the original untruncated values
let result = `<span class="tool-name">${name}</span>(`; let result = `<span class="tool-name">${name}</span>(`;
const formattedParams = fullParams const formattedParams = fullParams
.map( .map((p) => `&nbsp;&nbsp;&nbsp;&nbsp;${p[0]}=${p[1]}`)
(p) =>
`&nbsp;&nbsp;&nbsp;&nbsp;${p[0]}=${p[1]}`,
)
.join(",<br/>"); .join(",<br/>");
const fullView = `${result}<br/>${formattedParams}<br/>)`; const fullView = `${result}<br/>${formattedParams}<br/>)`;
@ -925,8 +840,7 @@
for (const [key, value] of Object.entries(input)) { for (const [key, value] of Object.entries(input)) {
if (value !== null && value !== undefined) { if (value !== null && value !== undefined) {
// Format different types of values // Format different types of values
let formattedValue = let formattedValue = typeof value === "string" ? `"${value}"` : value;
typeof value === "string" ? `"${value}"` : value;
params.push([key, formattedValue]); params.push([key, formattedValue]);
} }
} }
@ -938,9 +852,7 @@
return `${result}${params[0][1]})`; return `${result}${params[0][1]})`;
} else { } else {
// Format parameters // Format parameters
const formattedParams = params const formattedParams = params.map((p) => `&nbsp;&nbsp;&nbsp;&nbsp;${p[0]}=${p[1]}`).join(",<br/>");
.map((p) => `&nbsp;&nbsp;&nbsp;&nbsp;${p[0]}=${p[1]}`)
.join(",<br/>");
return `${result}<br/>${formattedParams}<br/>)`; return `${result}<br/>${formattedParams}<br/>)`;
} }
} }
@ -1013,21 +925,13 @@
// Keyboard navigation handler // Keyboard navigation handler
document.addEventListener("keydown", function (event) { document.addEventListener("keydown", function (event) {
// previous thread // previous thread
if ( if ((event.ctrlKey && event.key === "ArrowLeft") || event.key === "h" || event.key === "k") {
(event.ctrlKey && event.key === "ArrowLeft") ||
event.key === "h" ||
event.key === "k"
) {
if (!document.getElementById("prev-thread").disabled) { if (!document.getElementById("prev-thread").disabled) {
previousThread(); previousThread();
} }
} }
// next thread // next thread
else if ( else if ((event.ctrlKey && event.key === "ArrowRight") || event.key === "j" || event.key === "l") {
(event.ctrlKey && event.key === "ArrowRight") ||
event.key === "j" ||
event.key === "l"
) {
if (!document.getElementById("next-thread").disabled) { if (!document.getElementById("next-thread").disabled) {
nextThread(); nextThread();
} }

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap2="http://schemas.microsoft.com/appx/manifest/uap/windows10/2"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
IgnorableNamespaces="uap uap2 uap3 rescap desktop desktop4 desktop5 desktop6 uap10 com">
<!-- TODO: Use Zed's signature here. -->
<Identity
Name="ZedIndustries.Zed.Nightly"
Publisher="CN=Zed Industries Inc, O=Zed Industries Inc, L=Denver, S=Colorado, C=US"
Version="1.0.0.0" />
<Properties>
<DisplayName>Zed Editor Nightly</DisplayName>
<PublisherDisplayName>Zed Industries</PublisherDisplayName>
<!-- TODO: Use actual icon here. -->
<Logo>resources\logo_150x150.png</Logo>
<uap10:AllowExternalContent>true</uap10:AllowExternalContent>
<desktop6:RegistryWriteVirtualization>disabled</desktop6:RegistryWriteVirtualization>
<desktop6:FileSystemWriteVirtualization>disabled</desktop6:FileSystemWriteVirtualization>
</Properties>
<Resources>
<Resource Language="en-us" />
<Resource Language="zh-cn" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.22000.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources"/>
</Capabilities>
<Applications>
<Application Id="ZedNightly"
Executable="Zed.exe"
uap10:TrustLevel="mediumIL"
uap10:RuntimeBehavior="win32App">
<!-- TODO: Use actual icon here. -->
<uap:VisualElements
AppListEntry="none"
DisplayName="Zed Editor Nightly"
Description="Zed Editor Nightly explorer command injector"
BackgroundColor="transparent"
Square150x150Logo="resources\logo_150x150.png"
Square44x44Logo="resources\logo_70x70.png">
</uap:VisualElements>
<Extensions>
<desktop4:Extension Category="windows.fileExplorerContextMenus">
<desktop4:FileExplorerContextMenus>
<desktop5:ItemType Type="Directory">
<desktop5:Verb Id="OpenWithZedNightly" Clsid="266f2cfe-1653-42af-b55c-fe3590c83871" />
</desktop5:ItemType>
<desktop5:ItemType Type="Directory\Background">
<desktop5:Verb Id="OpenWithZedNightly" Clsid="266f2cfe-1653-42af-b55c-fe3590c83871" />
</desktop5:ItemType>
<desktop5:ItemType Type="*">
<desktop5:Verb Id="OpenWithZedNightly" Clsid="266f2cfe-1653-42af-b55c-fe3590c83871" />
</desktop5:ItemType>
</desktop4:FileExplorerContextMenus>
</desktop4:Extension>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:SurrogateServer DisplayName="Zed Editor Nightly">
<com:Class Id="266f2cfe-1653-42af-b55c-fe3590c83871" Path="zed_explorer_command_injector.dll" ThreadingModel="STA"/>
</com:SurrogateServer>
</com:ComServer>
</com:Extension>
</Extensions>
</Application>
</Applications>
</Package>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap2="http://schemas.microsoft.com/appx/manifest/uap/windows10/2"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
IgnorableNamespaces="uap uap2 uap3 rescap desktop desktop4 desktop5 desktop6 uap10 com">
<!-- TODO: Use Zed's signature here. -->
<Identity
Name="ZedIndustries.Zed.Preview"
Publisher="CN=Zed Industries Inc, O=Zed Industries Inc, L=Denver, S=Colorado, C=US"
Version="1.0.0.0" />
<Properties>
<DisplayName>Zed Editor Preview</DisplayName>
<PublisherDisplayName>Zed Industries</PublisherDisplayName>
<!-- TODO: Use actual icon here. -->
<Logo>resources\logo_150x150.png</Logo>
<uap10:AllowExternalContent>true</uap10:AllowExternalContent>
<desktop6:RegistryWriteVirtualization>disabled</desktop6:RegistryWriteVirtualization>
<desktop6:FileSystemWriteVirtualization>disabled</desktop6:FileSystemWriteVirtualization>
</Properties>
<Resources>
<Resource Language="en-us" />
<Resource Language="zh-cn" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.22000.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources"/>
</Capabilities>
<Applications>
<Application Id="ZedPreview"
Executable="Zed.exe"
uap10:TrustLevel="mediumIL"
uap10:RuntimeBehavior="win32App">
<!-- TODO: Use actual icon here. -->
<uap:VisualElements
AppListEntry="none"
DisplayName="Zed Editor Preview"
Description="Zed Editor Preview explorer command injector"
BackgroundColor="transparent"
Square150x150Logo="resources\logo_150x150.png"
Square44x44Logo="resources\logo_70x70.png">
</uap:VisualElements>
<Extensions>
<desktop4:Extension Category="windows.fileExplorerContextMenus">
<desktop4:FileExplorerContextMenus>
<desktop5:ItemType Type="Directory">
<desktop5:Verb Id="OpenWithZedPreview" Clsid="af8e85ea-fb20-4db2-93cf-56513c1ec697" />
</desktop5:ItemType>
<desktop5:ItemType Type="Directory\Background">
<desktop5:Verb Id="OpenWithZedPreview" Clsid="af8e85ea-fb20-4db2-93cf-56513c1ec697" />
</desktop5:ItemType>
<desktop5:ItemType Type="*">
<desktop5:Verb Id="OpenWithZedPreview" Clsid="af8e85ea-fb20-4db2-93cf-56513c1ec697" />
</desktop5:ItemType>
</desktop4:FileExplorerContextMenus>
</desktop4:Extension>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:SurrogateServer DisplayName="Zed Editor Preview">
<com:Class Id="af8e85ea-fb20-4db2-93cf-56513c1ec697" Path="zed_explorer_command_injector.dll" ThreadingModel="STA"/>
</com:SurrogateServer>
</com:ComServer>
</com:Extension>
</Extensions>
</Application>
</Applications>
</Package>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap2="http://schemas.microsoft.com/appx/manifest/uap/windows10/2"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
IgnorableNamespaces="uap uap2 uap3 rescap desktop desktop4 desktop5 desktop6 uap10 com">
<!-- TODO: Use Zed's signature here. -->
<Identity
Name="ZedIndustries.Zed"
Publisher="CN=Zed Industries Inc, O=Zed Industries Inc, L=Denver, S=Colorado, C=US"
Version="1.0.0.0" />
<Properties>
<DisplayName>Zed Editor</DisplayName>
<PublisherDisplayName>Zed Industries</PublisherDisplayName>
<!-- TODO: Use actual icon here. -->
<Logo>resources\logo_150x150.png</Logo>
<uap10:AllowExternalContent>true</uap10:AllowExternalContent>
<desktop6:RegistryWriteVirtualization>disabled</desktop6:RegistryWriteVirtualization>
<desktop6:FileSystemWriteVirtualization>disabled</desktop6:FileSystemWriteVirtualization>
</Properties>
<Resources>
<Resource Language="en-us" />
<Resource Language="zh-cn" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.22000.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources"/>
</Capabilities>
<Applications>
<Application Id="Zed"
Executable="Zed.exe"
uap10:TrustLevel="mediumIL"
uap10:RuntimeBehavior="win32App">
<!-- TODO: Use actual icon here. -->
<uap:VisualElements
AppListEntry="none"
DisplayName="Zed Editor"
Description="Zed Editor explorer command injector"
BackgroundColor="transparent"
Square150x150Logo="resources\logo_150x150.png"
Square44x44Logo="resources\logo_70x70.png">
</uap:VisualElements>
<Extensions>
<desktop4:Extension Category="windows.fileExplorerContextMenus">
<desktop4:FileExplorerContextMenus>
<desktop5:ItemType Type="Directory">
<desktop5:Verb Id="OpenWithZed" Clsid="6a1f6b13-3b82-48a1-9e06-7bb0a6d0bffd" />
</desktop5:ItemType>
<desktop5:ItemType Type="Directory\Background">
<desktop5:Verb Id="OpenWithZed" Clsid="6a1f6b13-3b82-48a1-9e06-7bb0a6d0bffd" />
</desktop5:ItemType>
<desktop5:ItemType Type="*">
<desktop5:Verb Id="OpenWithZed" Clsid="6a1f6b13-3b82-48a1-9e06-7bb0a6d0bffd" />
</desktop5:ItemType>
</desktop4:FileExplorerContextMenus>
</desktop4:Extension>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:SurrogateServer DisplayName="Zed Editor">
<com:Class Id="6a1f6b13-3b82-48a1-9e06-7bb0a6d0bffd" Path="zed_explorer_command_injector.dll" ThreadingModel="STA"/>
</com:SurrogateServer>
</com:ComServer>
</com:Extension>
</Extensions>
</Application>
</Applications>
</Package>

View file

@ -0,0 +1,28 @@
[package]
name = "explorer_command_injector"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
crate-type = ["cdylib"]
path = "src/explorer_command_injector.rs"
doctest = false
[features]
default = ["nightly"]
stable = []
preview = []
nightly = []
[target.'cfg(target_os = "windows")'.dependencies]
windows.workspace = true
windows-core.workspace = true
windows-registry = "0.5"
[dependencies]
workspace-hack.workspace = true

View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -0,0 +1,201 @@
#![cfg(target_os = "windows")]
use std::{os::windows::ffi::OsStringExt, path::PathBuf};
use windows::{
Win32::{
Foundation::{
CLASS_E_CLASSNOTAVAILABLE, E_FAIL, E_INVALIDARG, E_NOTIMPL, ERROR_INSUFFICIENT_BUFFER,
GetLastError, HINSTANCE, MAX_PATH,
},
Globalization::u_strlen,
System::{
Com::{IBindCtx, IClassFactory, IClassFactory_Impl},
LibraryLoader::GetModuleFileNameW,
SystemServices::DLL_PROCESS_ATTACH,
},
UI::Shell::{
ECF_DEFAULT, ECS_ENABLED, IEnumExplorerCommand, IExplorerCommand,
IExplorerCommand_Impl, IShellItemArray, SHStrDupW, SIGDN_FILESYSPATH,
},
},
core::{BOOL, GUID, HRESULT, HSTRING, Interface, Ref, Result, implement},
};
static mut DLL_INSTANCE: HINSTANCE = HINSTANCE(std::ptr::null_mut());
#[unsafe(no_mangle)]
extern "system" fn DllMain(
hinstdll: HINSTANCE,
fdwreason: u32,
_lpvreserved: *mut core::ffi::c_void,
) -> bool {
if fdwreason == DLL_PROCESS_ATTACH {
unsafe { DLL_INSTANCE = hinstdll };
}
true
}
#[implement(IExplorerCommand)]
struct ExplorerCommandInjector;
#[allow(non_snake_case)]
impl IExplorerCommand_Impl for ExplorerCommandInjector_Impl {
fn GetTitle(&self, _: Ref<IShellItemArray>) -> Result<windows_core::PWSTR> {
let command_description =
retrieve_command_description().unwrap_or(HSTRING::from("Open with Zed"));
unsafe { SHStrDupW(&command_description) }
}
fn GetIcon(&self, _: Ref<IShellItemArray>) -> Result<windows_core::PWSTR> {
let Some(zed_exe) = get_zed_exe_path() else {
return Err(E_FAIL.into());
};
unsafe { SHStrDupW(&HSTRING::from(zed_exe)) }
}
fn GetToolTip(&self, _: Ref<IShellItemArray>) -> Result<windows_core::PWSTR> {
Err(E_NOTIMPL.into())
}
fn GetCanonicalName(&self) -> Result<windows_core::GUID> {
Ok(GUID::zeroed())
}
fn GetState(&self, _: Ref<IShellItemArray>, _: BOOL) -> Result<u32> {
Ok(ECS_ENABLED.0 as _)
}
fn Invoke(&self, psiitemarray: Ref<IShellItemArray>, _: Ref<IBindCtx>) -> Result<()> {
let items = psiitemarray.ok()?;
let Some(zed_exe) = get_zed_exe_path() else {
return Ok(());
};
let count = unsafe { items.GetCount()? };
for idx in 0..count {
let item = unsafe { items.GetItemAt(idx)? };
let item_path = unsafe { item.GetDisplayName(SIGDN_FILESYSPATH)?.to_string()? };
std::process::Command::new(&zed_exe)
.arg(&item_path)
.spawn()
.map_err(|_| E_INVALIDARG)?;
}
Ok(())
}
fn GetFlags(&self) -> Result<u32> {
Ok(ECF_DEFAULT.0 as _)
}
fn EnumSubCommands(&self) -> Result<IEnumExplorerCommand> {
Err(E_NOTIMPL.into())
}
}
#[implement(IClassFactory)]
struct ExplorerCommandInjectorFactory;
impl IClassFactory_Impl for ExplorerCommandInjectorFactory_Impl {
fn CreateInstance(
&self,
punkouter: Ref<windows_core::IUnknown>,
riid: *const windows_core::GUID,
ppvobject: *mut *mut core::ffi::c_void,
) -> Result<()> {
unsafe {
*ppvobject = std::ptr::null_mut();
}
if punkouter.is_none() {
let factory: IExplorerCommand = ExplorerCommandInjector {}.into();
let ret = unsafe { factory.query(riid, ppvobject).ok() };
if ret.is_ok() {
unsafe {
*ppvobject = factory.into_raw();
}
}
ret
} else {
Err(E_INVALIDARG.into())
}
}
fn LockServer(&self, _: BOOL) -> Result<()> {
Ok(())
}
}
#[cfg(all(feature = "stable", not(feature = "preview"), not(feature = "nightly")))]
const MODULE_ID: GUID = GUID::from_u128(0x6a1f6b13_3b82_48a1_9e06_7bb0a6d0bffd);
#[cfg(all(feature = "preview", not(feature = "stable"), not(feature = "nightly")))]
const MODULE_ID: GUID = GUID::from_u128(0xaf8e85ea_fb20_4db2_93cf_56513c1ec697);
#[cfg(all(feature = "nightly", not(feature = "stable"), not(feature = "preview")))]
const MODULE_ID: GUID = GUID::from_u128(0x266f2cfe_1653_42af_b55c_fe3590c83871);
// Make cargo clippy happy
#[cfg(all(feature = "nightly", feature = "stable", feature = "preview"))]
const MODULE_ID: GUID = GUID::from_u128(0x685f4d49_6718_4c55_b271_ebb5c6a48d6f);
#[unsafe(no_mangle)]
extern "system" fn DllGetClassObject(
class_id: *const GUID,
iid: *const GUID,
out: *mut *mut std::ffi::c_void,
) -> HRESULT {
unsafe {
*out = std::ptr::null_mut();
}
let class_id = unsafe { *class_id };
if class_id == MODULE_ID {
let instance: IClassFactory = ExplorerCommandInjectorFactory {}.into();
let ret = unsafe { instance.query(iid, out) };
if ret.is_ok() {
unsafe {
*out = instance.into_raw();
}
}
ret
} else {
CLASS_E_CLASSNOTAVAILABLE
}
}
fn get_zed_install_folder() -> Option<PathBuf> {
let mut buf = vec![0u16; MAX_PATH as usize];
unsafe { GetModuleFileNameW(Some(DLL_INSTANCE.into()), &mut buf) };
while unsafe { GetLastError() } == ERROR_INSUFFICIENT_BUFFER {
buf = vec![0u16; buf.len() * 2];
unsafe { GetModuleFileNameW(Some(DLL_INSTANCE.into()), &mut buf) };
}
let len = unsafe { u_strlen(buf.as_ptr()) };
let path: PathBuf = std::ffi::OsString::from_wide(&buf[..len as usize])
.into_string()
.ok()?
.into();
Some(path.parent()?.parent()?.to_path_buf())
}
#[inline]
fn get_zed_exe_path() -> Option<String> {
get_zed_install_folder().map(|path| path.join("Zed.exe").to_string_lossy().to_string())
}
#[inline]
fn retrieve_command_description() -> Result<HSTRING> {
#[cfg(all(feature = "stable", not(feature = "preview"), not(feature = "nightly")))]
const REG_PATH: &str = "Software\\Classes\\ZedEditorContextMenu";
#[cfg(all(feature = "preview", not(feature = "stable"), not(feature = "nightly")))]
const REG_PATH: &str = "Software\\Classes\\ZedEditorPreviewContextMenu";
#[cfg(all(feature = "nightly", not(feature = "stable"), not(feature = "preview")))]
const REG_PATH: &str = "Software\\Classes\\ZedEditorNightlyContextMenu";
// Make cargo clippy happy
#[cfg(all(feature = "nightly", feature = "stable", feature = "preview"))]
const REG_PATH: &str = "Software\\Classes\\ZedEditorClippyContextMenu";
let key = windows_registry::CURRENT_USER.open(REG_PATH)?;
key.get_hstring("Title")
}

View file

@ -54,7 +54,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use url::Url; use url::Url;
use util::ResultExt; use util::{ResultExt, paths::RemotePathBuf};
use wasm_host::{ use wasm_host::{
WasmExtension, WasmHost, WasmExtension, WasmHost,
wit::{is_supported_wasm_api_version, wasm_api_version_range}, wit::{is_supported_wasm_api_version, wasm_api_version_range},
@ -1689,6 +1689,7 @@ impl ExtensionStore {
.request(proto::SyncExtensions { extensions }) .request(proto::SyncExtensions { extensions })
})? })?
.await?; .await?;
let path_style = client.read_with(cx, |client, _| client.path_style())?;
for missing_extension in response.missing_extensions.into_iter() { for missing_extension in response.missing_extensions.into_iter() {
let tmp_dir = tempfile::tempdir()?; let tmp_dir = tempfile::tempdir()?;
@ -1701,7 +1702,10 @@ impl ExtensionStore {
) )
})? })?
.await?; .await?;
let dest_dir = PathBuf::from(&response.tmp_dir).join(missing_extension.clone().id); let dest_dir = RemotePathBuf::new(
PathBuf::from(&response.tmp_dir).join(missing_extension.clone().id),
path_style,
);
log::info!("Uploading extension {}", missing_extension.clone().id); log::info!("Uploading extension {}", missing_extension.clone().id);
client client
@ -1718,7 +1722,7 @@ impl ExtensionStore {
client client
.update(cx, |client, _cx| { .update(cx, |client, _cx| {
client.proto_client().request(proto::InstallExtension { client.proto_client().request(proto::InstallExtension {
tmp_dir: dest_dir.to_string_lossy().to_string(), tmp_dir: dest_dir.to_proto(),
extension: Some(missing_extension), extension: Some(missing_extension),
}) })
})? })?

View file

@ -1,7 +1,10 @@
use std::{path::PathBuf, sync::Arc}; use std::{path::PathBuf, sync::Arc};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use client::{TypedEnvelope, proto}; use client::{
TypedEnvelope,
proto::{self, FromProto},
};
use collections::{HashMap, HashSet}; use collections::{HashMap, HashSet};
use extension::{ use extension::{
Extension, ExtensionDebugAdapterProviderProxy, ExtensionHostProxy, ExtensionLanguageProxy, Extension, ExtensionDebugAdapterProviderProxy, ExtensionHostProxy, ExtensionLanguageProxy,
@ -328,7 +331,7 @@ impl HeadlessExtensionStore {
version: extension.version, version: extension.version,
dev: extension.dev, dev: extension.dev,
}, },
PathBuf::from(envelope.payload.tmp_dir), PathBuf::from_proto(envelope.payload.tmp_dir),
cx, cx,
) )
})? })?

View file

@ -92,6 +92,23 @@ impl FeatureFlag for JjUiFeatureFlag {
const NAME: &'static str = "jj-ui"; const NAME: &'static str = "jj-ui";
} }
pub struct AcpFeatureFlag;
impl FeatureFlag for AcpFeatureFlag {
const NAME: &'static str = "acp";
}
pub struct ZedCloudFeatureFlag {}
impl FeatureFlag for ZedCloudFeatureFlag {
const NAME: &'static str = "zed-cloud";
fn enabled_for_staff() -> bool {
// Require individual opt-in, for now.
false
}
}
pub trait FeatureFlagViewExt<V: 'static> { pub trait FeatureFlagViewExt<V: 'static> {
fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
where where

View file

@ -15,16 +15,14 @@ use std::{
}; };
use ui::{Context, LabelLike, ListItem, Window}; use ui::{Context, LabelLike, ListItem, Window};
use ui::{HighlightedLabel, ListItemSpacing, prelude::*}; use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
use util::{maybe, paths::compare_paths}; use util::{
maybe,
paths::{PathStyle, compare_paths},
};
use workspace::Workspace; use workspace::Workspace;
pub(crate) struct OpenPathPrompt; pub(crate) struct OpenPathPrompt;
#[cfg(target_os = "windows")]
const PROMPT_ROOT: &str = "C:\\";
#[cfg(not(target_os = "windows"))]
const PROMPT_ROOT: &str = "/";
#[derive(Debug)] #[derive(Debug)]
pub struct OpenPathDelegate { pub struct OpenPathDelegate {
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>, tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
@ -34,6 +32,8 @@ pub struct OpenPathDelegate {
string_matches: Vec<StringMatch>, string_matches: Vec<StringMatch>,
cancel_flag: Arc<AtomicBool>, cancel_flag: Arc<AtomicBool>,
should_dismiss: bool, should_dismiss: bool,
prompt_root: String,
path_style: PathStyle,
replace_prompt: Task<()>, replace_prompt: Task<()>,
} }
@ -42,6 +42,7 @@ impl OpenPathDelegate {
tx: oneshot::Sender<Option<Vec<PathBuf>>>, tx: oneshot::Sender<Option<Vec<PathBuf>>>,
lister: DirectoryLister, lister: DirectoryLister,
creating_path: bool, creating_path: bool,
path_style: PathStyle,
) -> Self { ) -> Self {
Self { Self {
tx: Some(tx), tx: Some(tx),
@ -53,6 +54,11 @@ impl OpenPathDelegate {
string_matches: Vec::new(), string_matches: Vec::new(),
cancel_flag: Arc::new(AtomicBool::new(false)), cancel_flag: Arc::new(AtomicBool::new(false)),
should_dismiss: true, should_dismiss: true,
prompt_root: match path_style {
PathStyle::Posix => "/".to_string(),
PathStyle::Windows => "C:\\".to_string(),
},
path_style,
replace_prompt: Task::ready(()), replace_prompt: Task::ready(()),
} }
} }
@ -185,7 +191,8 @@ impl OpenPathPrompt {
cx: &mut Context<Workspace>, cx: &mut Context<Workspace>,
) { ) {
workspace.toggle_modal(window, cx, |window, cx| { workspace.toggle_modal(window, cx, |window, cx| {
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path); let delegate =
OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::current());
let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
let query = lister.default_query(cx); let query = lister.default_query(cx);
picker.set_query(query, window, cx); picker.set_query(query, window, cx);
@ -226,18 +233,7 @@ impl PickerDelegate for OpenPathDelegate {
cx: &mut Context<Picker<Self>>, cx: &mut Context<Picker<Self>>,
) -> Task<()> { ) -> Task<()> {
let lister = &self.lister; let lister = &self.lister;
let last_item = Path::new(&query) let (dir, suffix) = get_dir_and_suffix(query, self.path_style);
.file_name()
.unwrap_or_default()
.to_string_lossy();
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
(dir.to_string(), last_item.into_owned())
} else {
(query, String::new())
};
if dir == "" {
dir = PROMPT_ROOT.to_string();
}
let query = match &self.directory_state { let query = match &self.directory_state {
DirectoryState::List { parent_path, .. } => { DirectoryState::List { parent_path, .. } => {
@ -266,6 +262,7 @@ impl PickerDelegate for OpenPathDelegate {
self.cancel_flag = Arc::new(AtomicBool::new(false)); self.cancel_flag = Arc::new(AtomicBool::new(false));
let cancel_flag = self.cancel_flag.clone(); let cancel_flag = self.cancel_flag.clone();
let parent_path_is_root = self.prompt_root == dir;
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
if let Some(query) = query { if let Some(query) = query {
let paths = query.await; let paths = query.await;
@ -279,7 +276,7 @@ impl PickerDelegate for OpenPathDelegate {
DirectoryState::None { create: false } DirectoryState::None { create: false }
| DirectoryState::List { .. } => match paths { | DirectoryState::List { .. } => match paths {
Ok(paths) => DirectoryState::List { Ok(paths) => DirectoryState::List {
entries: path_candidates(&dir, paths), entries: path_candidates(parent_path_is_root, paths),
parent_path: dir.clone(), parent_path: dir.clone(),
error: None, error: None,
}, },
@ -292,7 +289,7 @@ impl PickerDelegate for OpenPathDelegate {
DirectoryState::None { create: true } DirectoryState::None { create: true }
| DirectoryState::Create { .. } => match paths { | DirectoryState::Create { .. } => match paths {
Ok(paths) => { Ok(paths) => {
let mut entries = path_candidates(&dir, paths); let mut entries = path_candidates(parent_path_is_root, paths);
let mut exists = false; let mut exists = false;
let mut is_dir = false; let mut is_dir = false;
let mut new_id = None; let mut new_id = None;
@ -488,6 +485,7 @@ impl PickerDelegate for OpenPathDelegate {
_: &mut Context<Picker<Self>>, _: &mut Context<Picker<Self>>,
) -> Option<String> { ) -> Option<String> {
let candidate = self.get_entry(self.selected_index)?; let candidate = self.get_entry(self.selected_index)?;
let path_style = self.path_style;
Some( Some(
maybe!({ maybe!({
match &self.directory_state { match &self.directory_state {
@ -496,7 +494,7 @@ impl PickerDelegate for OpenPathDelegate {
parent_path, parent_path,
candidate.path.string, candidate.path.string,
if candidate.is_dir { if candidate.is_dir {
MAIN_SEPARATOR_STR path_style.separator()
} else { } else {
"" ""
} }
@ -506,7 +504,7 @@ impl PickerDelegate for OpenPathDelegate {
parent_path, parent_path,
candidate.path.string, candidate.path.string,
if candidate.is_dir { if candidate.is_dir {
MAIN_SEPARATOR_STR path_style.separator()
} else { } else {
"" ""
} }
@ -527,8 +525,8 @@ impl PickerDelegate for OpenPathDelegate {
DirectoryState::None { .. } => return, DirectoryState::None { .. } => return,
DirectoryState::List { parent_path, .. } => { DirectoryState::List { parent_path, .. } => {
let confirmed_path = let confirmed_path =
if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() { if parent_path == &self.prompt_root && candidate.path.string.is_empty() {
PathBuf::from(PROMPT_ROOT) PathBuf::from(&self.prompt_root)
} else { } else {
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref()) Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
.join(&candidate.path.string) .join(&candidate.path.string)
@ -548,8 +546,8 @@ impl PickerDelegate for OpenPathDelegate {
return; return;
} }
let prompted_path = let prompted_path =
if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() { if parent_path == &self.prompt_root && user_input.file.string.is_empty() {
PathBuf::from(PROMPT_ROOT) PathBuf::from(&self.prompt_root)
} else { } else {
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref()) Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
.join(&user_input.file.string) .join(&user_input.file.string)
@ -652,8 +650,8 @@ impl PickerDelegate for OpenPathDelegate {
.inset(true) .inset(true)
.toggle_state(selected) .toggle_state(selected)
.child(HighlightedLabel::new( .child(HighlightedLabel::new(
if parent_path == PROMPT_ROOT { if parent_path == &self.prompt_root {
format!("{}{}", PROMPT_ROOT, candidate.path.string) format!("{}{}", self.prompt_root, candidate.path.string)
} else { } else {
candidate.path.string.clone() candidate.path.string.clone()
}, },
@ -665,10 +663,10 @@ impl PickerDelegate for OpenPathDelegate {
user_input, user_input,
.. ..
} => { } => {
let (label, delta) = if parent_path == PROMPT_ROOT { let (label, delta) = if parent_path == &self.prompt_root {
( (
format!("{}{}", PROMPT_ROOT, candidate.path.string), format!("{}{}", self.prompt_root, candidate.path.string),
PROMPT_ROOT.len(), self.prompt_root.len(),
) )
} else { } else {
(candidate.path.string.clone(), 0) (candidate.path.string.clone(), 0)
@ -751,8 +749,11 @@ impl PickerDelegate for OpenPathDelegate {
} }
} }
fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> { fn path_candidates(
if *parent_path == PROMPT_ROOT { parent_path_is_root: bool,
mut children: Vec<DirectoryItem>,
) -> Vec<CandidateInfo> {
if parent_path_is_root {
children.push(DirectoryItem { children.push(DirectoryItem {
is_dir: true, is_dir: true,
path: PathBuf::default(), path: PathBuf::default(),
@ -769,3 +770,128 @@ fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Ve
}) })
.collect() .collect()
} }
#[cfg(target_os = "windows")]
fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) {
let last_item = Path::new(&query)
.file_name()
.unwrap_or_default()
.to_string_lossy();
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
(dir.to_string(), last_item.into_owned())
} else {
(query.to_string(), String::new())
};
match path_style {
PathStyle::Posix => {
if dir.is_empty() {
dir = "/".to_string();
}
}
PathStyle::Windows => {
if dir.len() < 3 {
dir = "C:\\".to_string();
}
}
}
(dir, suffix)
}
#[cfg(not(target_os = "windows"))]
fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) {
match path_style {
PathStyle::Posix => {
let (mut dir, suffix) = if let Some(index) = query.rfind('/') {
(query[..index].to_string(), query[index + 1..].to_string())
} else {
(query, String::new())
};
if !dir.ends_with('/') {
dir.push('/');
}
(dir, suffix)
}
PathStyle::Windows => {
let (mut dir, suffix) = if let Some(index) = query.rfind('\\') {
(query[..index].to_string(), query[index + 1..].to_string())
} else {
(query, String::new())
};
if dir.len() < 3 {
dir = "C:\\".to_string();
}
if !dir.ends_with('\\') {
dir.push('\\');
}
(dir, suffix)
}
}
}
#[cfg(test)]
mod tests {
use util::paths::PathStyle;
use crate::open_path_prompt::get_dir_and_suffix;
#[test]
fn test_get_dir_and_suffix_with_windows_style() {
let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("C:".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("C:\\".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("C:\\Use".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "Use");
let (dir, suffix) =
get_dir_and_suffix("C:\\Users\\Junkui\\Docum".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\Users\\Junkui\\");
assert_eq!(suffix, "Docum");
let (dir, suffix) =
get_dir_and_suffix("C:\\Users\\Junkui\\Documents".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\Users\\Junkui\\");
assert_eq!(suffix, "Documents");
let (dir, suffix) =
get_dir_and_suffix("C:\\Users\\Junkui\\Documents\\".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\Users\\Junkui\\Documents\\");
assert_eq!(suffix, "");
}
#[test]
fn test_get_dir_and_suffix_with_posix_style() {
let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Posix);
assert_eq!(dir, "/");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Posix);
assert_eq!(dir, "/");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Posix);
assert_eq!(dir, "/");
assert_eq!(suffix, "Use");
let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Posix);
assert_eq!(dir, "/Users/Junkui/");
assert_eq!(suffix, "Docum");
let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Posix);
assert_eq!(dir, "/Users/Junkui/");
assert_eq!(suffix, "Documents");
let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Posix);
assert_eq!(dir, "/Users/Junkui/Documents/");
assert_eq!(suffix, "");
}
}

View file

@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate};
use project::Project; use project::Project;
use serde_json::json; use serde_json::json;
use ui::rems; use ui::rems;
use util::path; use util::{path, paths::PathStyle};
use workspace::{AppState, Workspace}; use workspace::{AppState, Workspace};
use crate::OpenPathDelegate; use crate::OpenPathDelegate;
@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, cx); let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
let query = path!("/root"); let query = path!("/root");
insert_query(query, &picker, cx).await; insert_query(query, &picker, cx).await;
@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, cx); let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
// Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
let query = path!("/root"); let query = path!("/root");
@ -186,7 +186,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
} }
#[gpui::test] #[gpui::test]
#[cfg(target_os = "windows")] #[cfg_attr(not(target_os = "windows"), ignore)]
async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
let app_state = init_test(cx); let app_state = init_test(cx);
app_state app_state
@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, cx); let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
// Support both forward and backward slashes. // Support both forward and backward slashes.
let query = "C:/root/"; let query = "C:/root/";
@ -251,6 +251,47 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
); );
} }
#[gpui::test]
#[cfg_attr(not(target_os = "windows"), ignore)]
async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
"/root",
json!({
"a": "A",
"dir1": {},
"dir2": {}
}),
)
.await;
let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx);
let query = "/root/";
insert_query(query, &picker, cx).await;
assert_eq!(
collect_match_candidates(&picker, cx),
vec!["a", "dir1", "dir2"]
);
assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/a");
// Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash.
let query = "/root/d";
insert_query(query, &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]);
assert_eq!(confirm_completion(query, 1, &picker, cx), "/root/dir2/");
let query = "/root/d";
insert_query(query, &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]);
assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/dir1/");
}
#[gpui::test] #[gpui::test]
async fn test_new_path_prompt(cx: &mut TestAppContext) { async fn test_new_path_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx); let app_state = init_test(cx);
@ -278,7 +319,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, true, cx); let (picker, cx) = build_open_path_prompt(project, true, PathStyle::current(), cx);
insert_query(path!("/root"), &picker, cx).await; insert_query(path!("/root"), &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
@ -315,11 +356,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
fn build_open_path_prompt( fn build_open_path_prompt(
project: Entity<Project>, project: Entity<Project>,
creating_path: bool, creating_path: bool,
path_style: PathStyle,
cx: &mut TestAppContext, cx: &mut TestAppContext,
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) { ) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
let (tx, _) = futures::channel::oneshot::channel(); let (tx, _) = futures::channel::oneshot::channel();
let lister = project::DirectoryLister::Project(project.clone()); let lister = project::DirectoryLister::Project(project.clone());
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path); let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style);
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
( (

View file

@ -2844,7 +2844,7 @@ impl GitPanel {
PopoverMenu::new(id.into()) PopoverMenu::new(id.into())
.trigger( .trigger(
IconButton::new("overflow-menu-trigger", IconName::EllipsisVertical) IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.icon_color(Color::Muted), .icon_color(Color::Muted),
) )
@ -2965,15 +2965,20 @@ impl GitPanel {
&self, &self,
id: impl Into<ElementId>, id: impl Into<ElementId>,
keybinding_target: Option<FocusHandle>, keybinding_target: Option<FocusHandle>,
cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
PopoverMenu::new(id.into()) PopoverMenu::new(id.into())
.trigger( .trigger(
ui::ButtonLike::new_rounded_right("commit-split-button-right") ui::ButtonLike::new_rounded_right("commit-split-button-right")
.layer(ui::ElevationIndex::ModalSurface) .layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::None) .size(ButtonSize::None)
.child( .child(
div() h_flex()
.px_1() .px_1()
.h_full()
.justify_center()
.border_l_1()
.border_color(cx.theme().colors().border)
.child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
), ),
) )
@ -3066,6 +3071,7 @@ impl GitPanel {
Some( Some(
self.panel_header_container(window, cx) self.panel_header_container(window, cx)
.px_2() .px_2()
.justify_between()
.child( .child(
panel_button(change_string) panel_button(change_string)
.color(Color::Muted) .color(Color::Muted)
@ -3080,23 +3086,25 @@ impl GitPanel {
}) })
}), }),
) )
.child(div().flex_grow()) // spacer
.child(self.render_overflow_menu("overflow_menu"))
.child(div().w_2()) // another spacer
.child( .child(
panel_filled_button(text) h_flex()
.tooltip(Tooltip::for_action_title_in( .gap_1()
tooltip, .child(self.render_overflow_menu("overflow_menu"))
action.as_ref(), .child(
&self.focus_handle, panel_filled_button(text)
)) .tooltip(Tooltip::for_action_title_in(
.disabled(self.entry_count == 0) tooltip,
.on_click(move |_, _, cx| { action.as_ref(),
let action = action.boxed_clone(); &self.focus_handle,
cx.defer(move |cx| { ))
cx.dispatch_action(action.as_ref()); .disabled(self.entry_count == 0)
}) .on_click(move |_, _, cx| {
}), let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
})
}),
),
), ),
) )
} }
@ -3174,7 +3182,7 @@ impl GitPanel {
.w_full() .w_full()
.h(max_height + footer_size) .h(max_height + footer_size)
.border_t_1() .border_t_1()
.border_color(cx.theme().colors().border_variant) .border_color(cx.theme().colors().border)
.cursor_text() .cursor_text()
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
window.focus(&this.commit_editor.focus_handle(cx)); window.focus(&this.commit_editor.focus_handle(cx));
@ -3259,6 +3267,7 @@ impl GitPanel {
let (can_commit, tooltip) = self.configure_commit_button(cx); let (can_commit, tooltip) = self.configure_commit_button(cx);
let title = self.commit_button_title(); let title = self.commit_button_title();
let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx); let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
div() div()
.id("commit-wrapper") .id("commit-wrapper")
.on_hover(cx.listener(move |this, hovered, _, cx| { .on_hover(cx.listener(move |this, hovered, _, cx| {
@ -3371,6 +3380,7 @@ impl GitPanel {
self.render_git_commit_menu( self.render_git_commit_menu(
ElementId::Name(format!("split-button-right-{}", title).into()), ElementId::Name(format!("split-button-right-{}", title).into()),
Some(commit_tooltip_focus_handle.clone()), Some(commit_tooltip_focus_handle.clone()),
cx,
) )
.into_any_element(), .into_any_element(),
)) ))
@ -3415,8 +3425,8 @@ impl GitPanel {
fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
div() div()
.py_2() .p_2()
.px(px(8.)) .border_t_1()
.border_color(cx.theme().colors().border) .border_color(cx.theme().colors().border)
.child( .child(
Label::new( Label::new(
@ -3431,22 +3441,21 @@ impl GitPanel {
let branch = active_repository.read(cx).branch.as_ref()?; let branch = active_repository.read(cx).branch.as_ref()?;
let commit = branch.most_recent_commit.as_ref()?.clone(); let commit = branch.most_recent_commit.as_ref()?.clone();
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
let this = cx.entity(); let this = cx.entity();
Some( Some(
h_flex() h_flex()
.items_center() .py_1p5()
.py_2() .px_2()
.px(px(8.))
.border_color(cx.theme().colors().border)
.gap_1p5() .gap_1p5()
.justify_between()
.border_t_1()
.border_color(cx.theme().colors().border.opacity(0.8))
.child( .child(
div() div()
.flex_grow() .flex_grow()
.overflow_hidden() .overflow_hidden()
.items_center()
.max_w(relative(0.85)) .max_w(relative(0.85))
.h_full()
.child( .child(
Label::new(commit.subject.clone()) Label::new(commit.subject.clone())
.size(LabelSize::Small) .size(LabelSize::Small)
@ -3480,12 +3489,11 @@ impl GitPanel {
} }
}), }),
) )
.child(div().flex_1())
.when(commit.has_parent, |this| { .when(commit.has_parent, |this| {
let has_unstaged = self.has_unstaged_changes(); let has_unstaged = self.has_unstaged_changes();
this.child( this.child(
panel_icon_button("undo", IconName::Undo) panel_icon_button("undo", IconName::Undo)
.icon_size(IconSize::Small) .icon_size(IconSize::XSmall)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.tooltip(move |window, cx| { .tooltip(move |window, cx| {
Tooltip::with_meta( Tooltip::with_meta(
@ -3507,43 +3515,38 @@ impl GitPanel {
} }
fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
h_flex() h_flex().h_full().flex_grow().justify_center().child(
.h_full() v_flex()
.flex_grow() .gap_2()
.justify_center() .child(h_flex().w_full().justify_around().child(
.items_center() if self.active_repository.is_some() {
.child( "No changes to commit"
v_flex() } else {
.gap_2() "No Git repositories"
.child(h_flex().w_full().justify_around().child( },
if self.active_repository.is_some() { ))
"No changes to commit" .children({
} else { let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
"No Git repositories" (worktree_count > 0 && self.active_repository.is_none()).then(|| {
}, h_flex().w_full().justify_around().child(
)) panel_filled_button("Initialize Repository")
.children({ .tooltip(Tooltip::for_action_title_in(
let worktree_count = self.project.read(cx).visible_worktrees(cx).count(); "git init",
(worktree_count > 0 && self.active_repository.is_none()).then(|| { &git::Init,
h_flex().w_full().justify_around().child( &self.focus_handle,
panel_filled_button("Initialize Repository") ))
.tooltip(Tooltip::for_action_title_in( .on_click(move |_, _, cx| {
"git init", cx.defer(move |cx| {
&git::Init, cx.dispatch_action(&git::Init);
&self.focus_handle, })
)) }),
.on_click(move |_, _, cx| { )
cx.defer(move |cx| {
cx.dispatch_action(&git::Init);
})
}),
)
})
}) })
.text_ui_sm(cx) })
.mx_auto() .text_ui_sm(cx)
.text_color(Color::Placeholder.color(cx)), .mx_auto()
) .text_color(Color::Placeholder.color(cx)),
)
} }
fn render_vertical_scrollbar( fn render_vertical_scrollbar(
@ -4621,7 +4624,7 @@ impl RenderOnce for PanelRepoFooter {
}) })
.trigger_with_tooltip( .trigger_with_tooltip(
repo_selector_trigger.disabled(single_repo).truncate(true), repo_selector_trigger.disabled(single_repo).truncate(true),
Tooltip::text("Switch active repository"), Tooltip::text("Switch Active Repository"),
) )
.anchor(Corner::BottomLeft) .anchor(Corner::BottomLeft)
.into_any_element(); .into_any_element();

View file

@ -220,7 +220,7 @@ blade-macros.workspace = true
flume = "0.11" flume = "0.11"
rand.workspace = true rand.workspace = true
windows.workspace = true windows.workspace = true
windows-core = "0.61" windows-core.workspace = true
windows-numerics = "0.2" windows-numerics = "0.2"
windows-registry = "0.5" windows-registry = "0.5"

View file

@ -487,7 +487,7 @@ impl Element for TextElement {
let font_size = style.font_size.to_pixels(window.rem_size()); let font_size = style.font_size.to_pixels(window.rem_size());
let line = window let line = window
.text_system() .text_system()
.shape_line(display_text, font_size, &runs); .shape_line(display_text, font_size, &runs, None);
let cursor_pos = line.x_for_index(cursor); let cursor_pos = line.x_for_index(cursor);
let (selection, cursor) = if selected_range.is_empty() { let (selection, cursor) = if selected_range.is_empty() {

View file

@ -506,35 +506,6 @@ pub trait UniformListDecoration {
) -> AnyElement; ) -> AnyElement;
} }
/// A trait for implementing top slots in a [`UniformList`].
/// Top slots are elements that appear at the top of the list and can adjust
/// the visible range of list items.
pub trait UniformListTopSlot {
/// Returns elements to render at the top slot for the given visible range.
fn compute(
&mut self,
visible_range: Range<usize>,
window: &mut Window,
cx: &mut App,
) -> SmallVec<[AnyElement; 8]>;
/// Layout and prepaint the top slot elements.
fn prepaint(
&self,
elements: &mut SmallVec<[AnyElement; 8]>,
bounds: Bounds<Pixels>,
item_height: Pixels,
scroll_offset: Point<Pixels>,
padding: crate::Edges<Pixels>,
can_scroll_horizontally: bool,
window: &mut Window,
cx: &mut App,
);
/// Paint the top slot elements.
fn paint(&self, elements: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App);
}
impl UniformList { impl UniformList {
/// Selects a specific list item for measurement. /// Selects a specific list item for measurement.
pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self { pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {

View file

@ -7,7 +7,7 @@ use super::{
use crate::{ use crate::{
Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
MacDisplay, MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, MacDisplay, MacWindow, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay,
PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, SemanticVersion, Task, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, SemanticVersion, Task,
WindowAppearance, WindowParams, hash, WindowAppearance, WindowParams, hash,
}; };
@ -170,6 +170,7 @@ pub(crate) struct MacPlatformState {
open_urls: Option<Box<dyn FnMut(Vec<String>)>>, open_urls: Option<Box<dyn FnMut(Vec<String>)>>,
finish_launching: Option<Box<dyn FnOnce()>>, finish_launching: Option<Box<dyn FnOnce()>>,
dock_menu: Option<id>, dock_menu: Option<id>,
menus: Option<Vec<OwnedMenu>>,
} }
impl Default for MacPlatform { impl Default for MacPlatform {
@ -207,6 +208,7 @@ impl MacPlatform {
finish_launching: None, finish_launching: None,
dock_menu: None, dock_menu: None,
on_keyboard_layout_change: None, on_keyboard_layout_change: None,
menus: None,
})) }))
} }
@ -226,7 +228,7 @@ impl MacPlatform {
unsafe fn create_menu_bar( unsafe fn create_menu_bar(
&self, &self,
menus: Vec<Menu>, menus: &Vec<Menu>,
delegate: id, delegate: id,
actions: &mut Vec<Box<dyn Action>>, actions: &mut Vec<Box<dyn Action>>,
keymap: &Keymap, keymap: &Keymap,
@ -241,7 +243,7 @@ impl MacPlatform {
menu.setTitle_(menu_title); menu.setTitle_(menu_title);
menu.setDelegate_(delegate); menu.setDelegate_(delegate);
for item_config in menu_config.items { for item_config in &menu_config.items {
menu.addItem_(Self::create_menu_item( menu.addItem_(Self::create_menu_item(
item_config, item_config,
delegate, delegate,
@ -277,7 +279,7 @@ impl MacPlatform {
dock_menu.setDelegate_(delegate); dock_menu.setDelegate_(delegate);
for item_config in menu_items { for item_config in menu_items {
dock_menu.addItem_(Self::create_menu_item( dock_menu.addItem_(Self::create_menu_item(
item_config, &item_config,
delegate, delegate,
actions, actions,
keymap, keymap,
@ -289,7 +291,7 @@ impl MacPlatform {
} }
unsafe fn create_menu_item( unsafe fn create_menu_item(
item: MenuItem, item: &MenuItem,
delegate: id, delegate: id,
actions: &mut Vec<Box<dyn Action>>, actions: &mut Vec<Box<dyn Action>>,
keymap: &Keymap, keymap: &Keymap,
@ -399,7 +401,7 @@ impl MacPlatform {
let tag = actions.len() as NSInteger; let tag = actions.len() as NSInteger;
let _: () = msg_send![item, setTag: tag]; let _: () = msg_send![item, setTag: tag];
actions.push(action); actions.push(action.boxed_clone());
item item
} }
MenuItem::Submenu(Menu { name, items }) => { MenuItem::Submenu(Menu { name, items }) => {
@ -865,10 +867,15 @@ impl Platform for MacPlatform {
let app: id = msg_send![APP_CLASS, sharedApplication]; let app: id = msg_send![APP_CLASS, sharedApplication];
let mut state = self.0.lock(); let mut state = self.0.lock();
let actions = &mut state.menu_actions; let actions = &mut state.menu_actions;
let menu = self.create_menu_bar(menus, NSWindow::delegate(app), actions, keymap); let menu = self.create_menu_bar(&menus, NSWindow::delegate(app), actions, keymap);
drop(state); drop(state);
app.setMainMenu_(menu); app.setMainMenu_(menu);
} }
self.0.lock().menus = Some(menus.into_iter().map(|menu| menu.owned()).collect());
}
fn get_menus(&self) -> Option<Vec<OwnedMenu>> {
self.0.lock().menus.clone()
} }
fn set_dock_menu(&self, menu: Vec<MenuItem>, keymap: &Keymap) { fn set_dock_menu(&self, menu: Vec<MenuItem>, keymap: &Keymap) {

View file

@ -357,6 +357,7 @@ impl WindowTextSystem {
text: SharedString, text: SharedString,
font_size: Pixels, font_size: Pixels,
runs: &[TextRun], runs: &[TextRun],
force_width: Option<Pixels>,
) -> ShapedLine { ) -> ShapedLine {
debug_assert!( debug_assert!(
text.find('\n').is_none(), text.find('\n').is_none(),
@ -384,7 +385,7 @@ impl WindowTextSystem {
}); });
} }
let layout = self.layout_line(&text, font_size, runs); let layout = self.layout_line(&text, font_size, runs, force_width);
ShapedLine { ShapedLine {
layout, layout,
@ -524,6 +525,7 @@ impl WindowTextSystem {
text: Text, text: Text,
font_size: Pixels, font_size: Pixels,
runs: &[TextRun], runs: &[TextRun],
force_width: Option<Pixels>,
) -> Arc<LineLayout> ) -> Arc<LineLayout>
where where
Text: AsRef<str>, Text: AsRef<str>,
@ -544,9 +546,9 @@ impl WindowTextSystem {
}); });
} }
let layout = self let layout =
.line_layout_cache self.line_layout_cache
.layout_line(text, font_size, &font_runs); .layout_line_internal(text, font_size, &font_runs, force_width);
font_runs.clear(); font_runs.clear();
self.font_runs_pool.lock().push(font_runs); self.font_runs_pool.lock().push(font_runs);

View file

@ -482,6 +482,7 @@ impl LineLayoutCache {
font_size, font_size,
runs, runs,
wrap_width, wrap_width,
force_width: None,
} as &dyn AsCacheKeyRef; } as &dyn AsCacheKeyRef;
let current_frame = self.current_frame.upgradable_read(); let current_frame = self.current_frame.upgradable_read();
@ -516,6 +517,7 @@ impl LineLayoutCache {
font_size, font_size,
runs: SmallVec::from(runs), runs: SmallVec::from(runs),
wrap_width, wrap_width,
force_width: None,
}); });
let mut current_frame = self.current_frame.write(); let mut current_frame = self.current_frame.write();
@ -534,6 +536,20 @@ impl LineLayoutCache {
font_size: Pixels, font_size: Pixels,
runs: &[FontRun], runs: &[FontRun],
) -> Arc<LineLayout> ) -> Arc<LineLayout>
where
Text: AsRef<str>,
SharedString: From<Text>,
{
self.layout_line_internal(text, font_size, runs, None)
}
pub fn layout_line_internal<Text>(
&self,
text: Text,
font_size: Pixels,
runs: &[FontRun],
force_width: Option<Pixels>,
) -> Arc<LineLayout>
where where
Text: AsRef<str>, Text: AsRef<str>,
SharedString: From<Text>, SharedString: From<Text>,
@ -543,6 +559,7 @@ impl LineLayoutCache {
font_size, font_size,
runs, runs,
wrap_width: None, wrap_width: None,
force_width,
} as &dyn AsCacheKeyRef; } as &dyn AsCacheKeyRef;
let current_frame = self.current_frame.upgradable_read(); let current_frame = self.current_frame.upgradable_read();
@ -557,16 +574,30 @@ impl LineLayoutCache {
layout layout
} else { } else {
let text = SharedString::from(text); let text = SharedString::from(text);
let layout = Arc::new( let mut layout = self
self.platform_text_system .platform_text_system
.layout_line(&text, font_size, runs), .layout_line(&text, font_size, runs);
);
if let Some(force_width) = force_width {
let mut glyph_pos = 0;
for run in layout.runs.iter_mut() {
for glyph in run.glyphs.iter_mut() {
if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) {
glyph.position.x = glyph_pos * force_width;
}
glyph_pos += 1;
}
}
}
let key = Arc::new(CacheKey { let key = Arc::new(CacheKey {
text, text,
font_size, font_size,
runs: SmallVec::from(runs), runs: SmallVec::from(runs),
wrap_width: None, wrap_width: None,
force_width,
}); });
let layout = Arc::new(layout);
current_frame.lines.insert(key.clone(), layout.clone()); current_frame.lines.insert(key.clone(), layout.clone());
current_frame.used_lines.push(key); current_frame.used_lines.push(key);
layout layout
@ -591,6 +622,7 @@ struct CacheKey {
font_size: Pixels, font_size: Pixels,
runs: SmallVec<[FontRun; 1]>, runs: SmallVec<[FontRun; 1]>,
wrap_width: Option<Pixels>, wrap_width: Option<Pixels>,
force_width: Option<Pixels>,
} }
#[derive(Copy, Clone, PartialEq, Eq, Hash)] #[derive(Copy, Clone, PartialEq, Eq, Hash)]
@ -599,6 +631,7 @@ struct CacheKeyRef<'a> {
font_size: Pixels, font_size: Pixels,
runs: &'a [FontRun], runs: &'a [FontRun],
wrap_width: Option<Pixels>, wrap_width: Option<Pixels>,
force_width: Option<Pixels>,
} }
impl PartialEq for (dyn AsCacheKeyRef + '_) { impl PartialEq for (dyn AsCacheKeyRef + '_) {
@ -622,6 +655,7 @@ impl AsCacheKeyRef for CacheKey {
font_size: self.font_size, font_size: self.font_size,
runs: self.runs.as_slice(), runs: self.runs.as_slice(),
wrap_width: self.wrap_width, wrap_width: self.wrap_width,
force_width: self.force_width,
} }
} }
} }

View file

@ -226,10 +226,21 @@ impl HttpClientWithUrl {
} }
/// Builds a Zed LLM URL using the given path. /// Builds a Zed LLM URL using the given path.
pub fn build_zed_llm_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> { pub fn build_zed_llm_url(
&self,
path: &str,
query: &[(&str, &str)],
use_cloud: bool,
) -> Result<Url> {
let base_url = self.base_url(); let base_url = self.base_url();
let base_api_url = match base_url.as_ref() { let base_api_url = match base_url.as_ref() {
"https://zed.dev" => "https://llm.zed.dev", "https://zed.dev" => {
if use_cloud {
"https://cloud.zed.dev"
} else {
"https://llm.zed.dev"
}
}
"https://staging.zed.dev" => "https://llm-staging.zed.dev", "https://staging.zed.dev" => "https://llm-staging.zed.dev",
"http://localhost:3000" => "http://localhost:8787", "http://localhost:3000" => "http://localhost:8787",
other => other, other => other,

View file

@ -13,6 +13,7 @@ pub enum IconName {
AiBedrock, AiBedrock,
AiDeepSeek, AiDeepSeek,
AiEdit, AiEdit,
AiGemini,
AiGoogle, AiGoogle,
AiLmStudio, AiLmStudio,
AiMistral, AiMistral,
@ -252,6 +253,14 @@ pub enum IconName {
TextSnippet, TextSnippet,
ThumbsDown, ThumbsDown,
ThumbsUp, ThumbsUp,
ToolBulb,
ToolFolder,
ToolHammer,
ToolPencil,
ToolRegex,
ToolSearch,
ToolTerminal,
ToolWeb,
Trash, Trash,
TrashAlt, TrashAlt,
Triangle, Triangle,

View file

@ -28,6 +28,7 @@ credentials_provider.workspace = true
copilot.workspace = true copilot.workspace = true
deepseek = { workspace = true, features = ["schemars"] } deepseek = { workspace = true, features = ["schemars"] }
editor.workspace = true editor.workspace = true
feature_flags.workspace = true
fs.workspace = true fs.workspace = true
futures.workspace = true futures.workspace = true
google_ai = { workspace = true, features = ["schemars"] } google_ai = { workspace = true, features = ["schemars"] }

View file

@ -2,6 +2,7 @@ use anthropic::AnthropicModelMode;
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use client::{Client, ModelRequestUsage, UserStore, zed_urls}; use client::{Client, ModelRequestUsage, UserStore, zed_urls};
use feature_flags::{FeatureFlagAppExt as _, ZedCloudFeatureFlag};
use futures::{ use futures::{
AsyncBufReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream, AsyncBufReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream,
}; };
@ -136,6 +137,7 @@ impl State {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
let use_cloud = cx.has_flag::<ZedCloudFeatureFlag>();
Self { Self {
client: client.clone(), client: client.clone(),
@ -163,7 +165,7 @@ impl State {
.await; .await;
} }
let response = Self::fetch_models(client, llm_api_token).await?; let response = Self::fetch_models(client, llm_api_token, use_cloud).await?;
cx.update(|cx| { cx.update(|cx| {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let mut models = Vec::new(); let mut models = Vec::new();
@ -265,13 +267,18 @@ impl State {
async fn fetch_models( async fn fetch_models(
client: Arc<Client>, client: Arc<Client>,
llm_api_token: LlmApiToken, llm_api_token: LlmApiToken,
use_cloud: bool,
) -> Result<ListModelsResponse> { ) -> Result<ListModelsResponse> {
let http_client = &client.http_client(); let http_client = &client.http_client();
let token = llm_api_token.acquire(&client).await?; let token = llm_api_token.acquire(&client).await?;
let request = http_client::Request::builder() let request = http_client::Request::builder()
.method(Method::GET) .method(Method::GET)
.uri(http_client.build_zed_llm_url("/models", &[])?.as_ref()) .uri(
http_client
.build_zed_llm_url("/models", &[], use_cloud)?
.as_ref(),
)
.header("Authorization", format!("Bearer {token}")) .header("Authorization", format!("Bearer {token}"))
.body(AsyncBody::empty())?; .body(AsyncBody::empty())?;
let mut response = http_client let mut response = http_client
@ -535,6 +542,7 @@ impl CloudLanguageModel {
llm_api_token: LlmApiToken, llm_api_token: LlmApiToken,
app_version: Option<SemanticVersion>, app_version: Option<SemanticVersion>,
body: CompletionBody, body: CompletionBody,
use_cloud: bool,
) -> Result<PerformLlmCompletionResponse> { ) -> Result<PerformLlmCompletionResponse> {
let http_client = &client.http_client(); let http_client = &client.http_client();
@ -542,9 +550,11 @@ impl CloudLanguageModel {
let mut refreshed_token = false; let mut refreshed_token = false;
loop { loop {
let request_builder = http_client::Request::builder() let request_builder = http_client::Request::builder().method(Method::POST).uri(
.method(Method::POST) http_client
.uri(http_client.build_zed_llm_url("/completions", &[])?.as_ref()); .build_zed_llm_url("/completions", &[], use_cloud)?
.as_ref(),
);
let request_builder = if let Some(app_version) = app_version { let request_builder = if let Some(app_version) = app_version {
request_builder.header(ZED_VERSION_HEADER_NAME, app_version.to_string()) request_builder.header(ZED_VERSION_HEADER_NAME, app_version.to_string())
} else { } else {
@ -771,6 +781,7 @@ impl LanguageModel for CloudLanguageModel {
let model_id = self.model.id.to_string(); let model_id = self.model.id.to_string();
let generate_content_request = let generate_content_request =
into_google(request, model_id.clone(), GoogleModelMode::Default); into_google(request, model_id.clone(), GoogleModelMode::Default);
let use_cloud = cx.has_flag::<ZedCloudFeatureFlag>();
async move { async move {
let http_client = &client.http_client(); let http_client = &client.http_client();
let token = llm_api_token.acquire(&client).await?; let token = llm_api_token.acquire(&client).await?;
@ -786,7 +797,7 @@ impl LanguageModel for CloudLanguageModel {
.method(Method::POST) .method(Method::POST)
.uri( .uri(
http_client http_client
.build_zed_llm_url("/count_tokens", &[])? .build_zed_llm_url("/count_tokens", &[], use_cloud)?
.as_ref(), .as_ref(),
) )
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
@ -835,6 +846,9 @@ impl LanguageModel for CloudLanguageModel {
let intent = request.intent; let intent = request.intent;
let mode = request.mode; let mode = request.mode;
let app_version = cx.update(|cx| AppVersion::global(cx)).ok(); let app_version = cx.update(|cx| AppVersion::global(cx)).ok();
let use_cloud = cx
.update(|cx| cx.has_flag::<ZedCloudFeatureFlag>())
.unwrap_or(false);
match self.model.provider { match self.model.provider {
zed_llm_client::LanguageModelProvider::Anthropic => { zed_llm_client::LanguageModelProvider::Anthropic => {
let request = into_anthropic( let request = into_anthropic(
@ -872,6 +886,7 @@ impl LanguageModel for CloudLanguageModel {
provider_request: serde_json::to_value(&request) provider_request: serde_json::to_value(&request)
.map_err(|e| anyhow!(e))?, .map_err(|e| anyhow!(e))?,
}, },
use_cloud,
) )
.await .await
.map_err(|err| match err.downcast::<ApiError>() { .map_err(|err| match err.downcast::<ApiError>() {
@ -924,6 +939,7 @@ impl LanguageModel for CloudLanguageModel {
provider_request: serde_json::to_value(&request) provider_request: serde_json::to_value(&request)
.map_err(|e| anyhow!(e))?, .map_err(|e| anyhow!(e))?,
}, },
use_cloud,
) )
.await?; .await?;
@ -964,6 +980,7 @@ impl LanguageModel for CloudLanguageModel {
provider_request: serde_json::to_value(&request) provider_request: serde_json::to_value(&request)
.map_err(|e| anyhow!(e))?, .map_err(|e| anyhow!(e))?,
}, },
use_cloud,
) )
.await?; .await?;

View file

@ -185,15 +185,18 @@ impl LanguageServerState {
menu = menu.separator().item(button); menu = menu.separator().item(button);
continue; continue;
}; };
let Some(server_info) = item.server_info() else { let Some(server_info) = item.server_info() else {
continue; continue;
}; };
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
let server_selector = server_info.server_selector(); let server_selector = server_info.server_selector();
// TODO currently, Zed remote does not work well with the LSP logs // TODO currently, Zed remote does not work well with the LSP logs
// https://github.com/zed-industries/zed/issues/28557 // https://github.com/zed-industries/zed/issues/28557
let has_logs = lsp_store.read(cx).as_local().is_some() let has_logs = lsp_store.read(cx).as_local().is_some()
&& lsp_logs.read(cx).has_server_logs(&server_selector); && lsp_logs.read(cx).has_server_logs(&server_selector);
let status_color = server_info let status_color = server_info
.binary_status .binary_status
.and_then(|binary_status| match binary_status.status { .and_then(|binary_status| match binary_status.status {
@ -218,16 +221,40 @@ impl LanguageServerState {
.other_servers_start_index .other_servers_start_index
.is_some_and(|index| index == i) .is_some_and(|index| index == i)
{ {
menu = menu.separator(); menu = menu.separator().header("Other Buffers");
} }
if i == 0 && self.other_servers_start_index.is_some() {
menu = menu.header("Current Buffer");
}
menu = menu.item(ContextMenuItem::custom_entry( menu = menu.item(ContextMenuItem::custom_entry(
move |_, _| { move |_, _| {
h_flex() h_flex()
.gap_1() .group("menu_item")
.w_full() .w_full()
.child(Indicator::dot().color(status_color)) .gap_2()
.child(Label::new(server_info.name.0.clone())) .justify_between()
.when(!has_logs, |div| div.cursor_default()) .child(
h_flex()
.gap_2()
.child(Indicator::dot().color(status_color))
.child(Label::new(server_info.name.0.clone())),
)
.child(
h_flex()
.visible_on_hover("menu_item")
.child(
Label::new("View Logs")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Icon::new(IconName::ChevronRight)
.size(IconSize::Small)
.color(Color::Muted),
),
)
.into_any_element() .into_any_element()
}, },
{ {
@ -708,8 +735,6 @@ impl LspTool {
state.update(cx, |state, cx| state.fill_menu(menu, cx)) state.update(cx, |state, cx| state.fill_menu(menu, cx))
}); });
lsp_tool.lsp_menu = Some(menu.clone()); lsp_tool.lsp_menu = Some(menu.clone());
// TODO kb will this work?
// what about the selections?
lsp_tool.popover_menu_handle.refresh_menu( lsp_tool.popover_menu_handle.refresh_menu(
window, window,
cx, cx,
@ -836,17 +861,27 @@ impl Render for LspTool {
} }
} }
let indicator = if has_errors { let (indicator, description) = if has_errors {
Some(Indicator::dot().color(Color::Error)) (
Some(Indicator::dot().color(Color::Error)),
"Server with errors",
)
} else if has_warnings { } else if has_warnings {
Some(Indicator::dot().color(Color::Warning)) (
Some(Indicator::dot().color(Color::Warning)),
"Server with warnings",
)
} else if has_other_notifications { } else if has_other_notifications {
Some(Indicator::dot().color(Color::Modified)) (
Some(Indicator::dot().color(Color::Modified)),
"Server with notifications",
)
} else { } else {
None (None, "All Servers Operational")
}; };
let lsp_tool = cx.entity().clone(); let lsp_tool = cx.entity().clone();
div().child( div().child(
PopoverMenu::new("lsp-tool") PopoverMenu::new("lsp-tool")
.menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone()) .menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone())
@ -858,7 +893,13 @@ impl Render for LspTool {
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.indicator_border_color(Some(cx.theme().colors().status_bar_background)), .indicator_border_color(Some(cx.theme().colors().status_bar_background)),
move |window, cx| { move |window, cx| {
Tooltip::for_action("Language Servers", &ToggleMenu, window, cx) Tooltip::with_meta(
"Language Servers",
Some(&ToggleMenu),
description,
window,
cx,
)
}, },
), ),
) )

View file

@ -93,3 +93,9 @@ pub(crate) mod m_2025_06_27 {
pub(crate) use settings::SETTINGS_PATTERNS; pub(crate) use settings::SETTINGS_PATTERNS;
} }
pub(crate) mod m_2025_07_08 {
mod settings;
pub(crate) use settings::SETTINGS_PATTERNS;
}

View file

@ -0,0 +1,37 @@
use std::ops::Range;
use tree_sitter::{Query, QueryMatch};
use crate::MigrationPatterns;
use crate::patterns::SETTINGS_ROOT_KEY_VALUE_PATTERN;
pub const SETTINGS_PATTERNS: MigrationPatterns = &[(
SETTINGS_ROOT_KEY_VALUE_PATTERN,
migrate_drag_and_drop_selection,
)];
fn migrate_drag_and_drop_selection(
contents: &str,
mat: &QueryMatch,
query: &Query,
) -> Option<(Range<usize>, String)> {
let name_ix = query.capture_index_for_name("name")?;
let name_range = mat.nodes_for_capture_index(name_ix).next()?.byte_range();
let name = contents.get(name_range)?;
if name != "drag_and_drop_selection" {
return None;
}
let value_ix = query.capture_index_for_name("value")?;
let value_node = mat.nodes_for_capture_index(value_ix).next()?;
let value_range = value_node.byte_range();
let value = contents.get(value_range.clone())?;
match value {
"true" | "false" => {
let replacement = format!("{{\n \"enabled\": {}\n }}", value);
Some((value_range, replacement))
}
_ => None,
}
}

View file

@ -160,6 +160,10 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
migrations::m_2025_06_27::SETTINGS_PATTERNS, migrations::m_2025_06_27::SETTINGS_PATTERNS,
&SETTINGS_QUERY_2025_06_27, &SETTINGS_QUERY_2025_06_27,
), ),
(
migrations::m_2025_07_08::SETTINGS_PATTERNS,
&SETTINGS_QUERY_2025_07_08,
),
]; ];
run_migrations(text, migrations) run_migrations(text, migrations)
} }
@ -270,6 +274,10 @@ define_query!(
SETTINGS_QUERY_2025_06_27, SETTINGS_QUERY_2025_06_27,
migrations::m_2025_06_27::SETTINGS_PATTERNS migrations::m_2025_06_27::SETTINGS_PATTERNS
); );
define_query!(
SETTINGS_QUERY_2025_07_08,
migrations::m_2025_07_08::SETTINGS_PATTERNS
);
// custom query // custom query
static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| { static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {

25
crates/net/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "net"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/net.rs"
doctest = false
[dependencies]
smol.workspace = true
workspace-hack.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
anyhow.workspace = true
async-io = "2.4"
windows.workspace = true
[dev-dependencies]
tempfile.workspace = true

1
crates/net/LICENSE-GPL Symbolic link
View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -0,0 +1,69 @@
#[cfg(not(target_os = "windows"))]
pub use smol::net::unix::{UnixListener, UnixStream};
#[cfg(target_os = "windows")]
pub use windows::{UnixListener, UnixStream};
#[cfg(target_os = "windows")]
pub mod windows {
use std::{
io::Result,
path::Path,
pin::Pin,
task::{Context, Poll},
};
use smol::{
Async,
io::{AsyncRead, AsyncWrite},
};
pub struct UnixListener(Async<crate::UnixListener>);
impl UnixListener {
pub fn bind<P: AsRef<Path>>(path: P) -> Result<Self> {
Ok(UnixListener(Async::new(crate::UnixListener::bind(path)?)?))
}
pub async fn accept(&self) -> Result<(UnixStream, ())> {
let (sock, _) = self.0.read_with(|listener| listener.accept()).await?;
Ok((UnixStream(Async::new(sock)?), ()))
}
}
pub struct UnixStream(Async<crate::UnixStream>);
impl UnixStream {
pub async fn connect<P: AsRef<Path>>(path: P) -> Result<Self> {
Ok(UnixStream(Async::new(crate::UnixStream::connect(path)?)?))
}
}
impl AsyncRead for UnixStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut [u8],
) -> Poll<Result<usize>> {
Pin::new(&mut self.0).poll_read(cx, buf)
}
}
impl AsyncWrite for UnixStream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize>> {
Pin::new(&mut self.0).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<()>> {
Pin::new(&mut self.0).poll_flush(cx)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<()>> {
Pin::new(&mut self.0).poll_close(cx)
}
}
}

View file

@ -0,0 +1,45 @@
use std::{
io::Result,
os::windows::io::{AsSocket, BorrowedSocket},
path::Path,
};
use windows::Win32::Networking::WinSock::{SOCKADDR_UN, SOMAXCONN, bind, listen};
use crate::{
socket::UnixSocket,
stream::UnixStream,
util::{init, map_ret, sockaddr_un},
};
pub struct UnixListener(UnixSocket);
impl UnixListener {
pub fn bind<P: AsRef<Path>>(path: P) -> Result<Self> {
init();
let socket = UnixSocket::new()?;
let (addr, len) = sockaddr_un(path)?;
unsafe {
map_ret(bind(
socket.as_raw(),
&addr as *const _ as *const _,
len as i32,
))?;
map_ret(listen(socket.as_raw(), SOMAXCONN as _))?;
}
Ok(Self(socket))
}
pub fn accept(&self) -> Result<(UnixStream, ())> {
let mut storage = SOCKADDR_UN::default();
let mut len = std::mem::size_of_val(&storage) as i32;
let raw = self.0.accept(&mut storage as *mut _ as *mut _, &mut len)?;
Ok((UnixStream::new(raw), ()))
}
}
impl AsSocket for UnixListener {
fn as_socket(&self) -> BorrowedSocket<'_> {
unsafe { BorrowedSocket::borrow_raw(self.0.as_raw().0 as _) }
}
}

107
crates/net/src/net.rs Normal file
View file

@ -0,0 +1,107 @@
pub mod async_net;
#[cfg(target_os = "windows")]
pub mod listener;
#[cfg(target_os = "windows")]
pub mod socket;
#[cfg(target_os = "windows")]
pub mod stream;
#[cfg(target_os = "windows")]
mod util;
#[cfg(target_os = "windows")]
pub use listener::*;
#[cfg(target_os = "windows")]
pub use socket::*;
#[cfg(not(target_os = "windows"))]
pub use std::os::unix::net::{UnixListener, UnixStream};
#[cfg(target_os = "windows")]
pub use stream::*;
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use smol::io::{AsyncReadExt, AsyncWriteExt};
const SERVER_MESSAGE: &str = "Connection closed";
const CLIENT_MESSAGE: &str = "Hello, server!";
const BUFFER_SIZE: usize = 32;
#[test]
fn test_windows_listener() -> std::io::Result<()> {
use crate::{UnixListener, UnixStream};
let temp = tempfile::tempdir()?;
let socket = temp.path().join("socket.sock");
let listener = UnixListener::bind(&socket)?;
// Server
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
// Read data from the client
let mut buffer = [0; BUFFER_SIZE];
let bytes_read = stream.read(&mut buffer).unwrap();
let string = String::from_utf8_lossy(&buffer[..bytes_read]);
assert_eq!(string, CLIENT_MESSAGE);
// Send a message back to the client
stream.write_all(SERVER_MESSAGE.as_bytes()).unwrap();
});
// Client
let mut client = UnixStream::connect(&socket)?;
// Send data to the server
client.write_all(CLIENT_MESSAGE.as_bytes())?;
let mut buffer = [0; BUFFER_SIZE];
// Read the response from the server
let bytes_read = client.read(&mut buffer)?;
let string = String::from_utf8_lossy(&buffer[..bytes_read]);
assert_eq!(string, SERVER_MESSAGE);
client.flush()?;
server.join().unwrap();
Ok(())
}
#[test]
fn test_unix_listener() -> std::io::Result<()> {
use crate::async_net::{UnixListener, UnixStream};
smol::block_on(async {
let temp = tempfile::tempdir()?;
let socket = temp.path().join("socket.sock");
let listener = UnixListener::bind(&socket)?;
// Server
let server = smol::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
// Read data from the client
let mut buffer = [0; BUFFER_SIZE];
let bytes_read = stream.read(&mut buffer).await.unwrap();
let string = String::from_utf8_lossy(&buffer[..bytes_read]);
assert_eq!(string, CLIENT_MESSAGE);
// Send a message back to the client
stream.write_all(SERVER_MESSAGE.as_bytes()).await.unwrap();
});
// Client
let mut client = UnixStream::connect(&socket).await?;
client.write_all(CLIENT_MESSAGE.as_bytes()).await?;
// Read the response from the server
let mut buffer = [0; BUFFER_SIZE];
let bytes_read = client.read(&mut buffer).await?;
let string = String::from_utf8_lossy(&buffer[..bytes_read]);
assert_eq!(string, "Connection closed");
client.flush().await?;
server.await;
Ok(())
})
}
}

59
crates/net/src/socket.rs Normal file
View file

@ -0,0 +1,59 @@
use std::io::{Error, ErrorKind, Result};
use windows::Win32::{
Foundation::{HANDLE, HANDLE_FLAG_INHERIT, HANDLE_FLAGS, SetHandleInformation},
Networking::WinSock::{
AF_UNIX, SEND_RECV_FLAGS, SOCK_STREAM, SOCKADDR, SOCKET, WSA_FLAG_OVERLAPPED,
WSAEWOULDBLOCK, WSASocketW, accept, closesocket, recv, send,
},
};
use crate::util::map_ret;
pub struct UnixSocket(SOCKET);
impl UnixSocket {
pub fn new() -> Result<Self> {
unsafe {
let raw = WSASocketW(AF_UNIX as _, SOCK_STREAM.0, 0, None, 0, WSA_FLAG_OVERLAPPED)?;
SetHandleInformation(
HANDLE(raw.0 as _),
HANDLE_FLAG_INHERIT.0,
HANDLE_FLAGS::default(),
)?;
Ok(Self(raw))
}
}
pub(crate) fn as_raw(&self) -> SOCKET {
self.0
}
pub fn accept(&self, storage: *mut SOCKADDR, len: &mut i32) -> Result<Self> {
match unsafe { accept(self.0, Some(storage), Some(len)) } {
Ok(sock) => Ok(Self(sock)),
Err(err) => {
let wsa_err = unsafe { windows::Win32::Networking::WinSock::WSAGetLastError().0 };
if wsa_err == WSAEWOULDBLOCK.0 {
Err(Error::new(ErrorKind::WouldBlock, "accept would block"))
} else {
Err(err.into())
}
}
}
}
pub(crate) fn recv(&self, buf: &mut [u8]) -> Result<usize> {
map_ret(unsafe { recv(self.0, buf, SEND_RECV_FLAGS::default()) })
}
pub(crate) fn send(&self, buf: &[u8]) -> Result<usize> {
map_ret(unsafe { send(self.0, buf, SEND_RECV_FLAGS::default()) })
}
}
impl Drop for UnixSocket {
fn drop(&mut self) {
unsafe { closesocket(self.0) };
}
}

60
crates/net/src/stream.rs Normal file
View file

@ -0,0 +1,60 @@
use std::{
io::{Read, Result, Write},
os::windows::io::{AsSocket, BorrowedSocket},
path::Path,
};
use async_io::IoSafe;
use windows::Win32::Networking::WinSock::connect;
use crate::{
socket::UnixSocket,
util::{init, map_ret, sockaddr_un},
};
pub struct UnixStream(UnixSocket);
unsafe impl IoSafe for UnixStream {}
impl UnixStream {
pub fn new(socket: UnixSocket) -> Self {
Self(socket)
}
pub fn connect<P: AsRef<Path>>(path: P) -> Result<Self> {
init();
unsafe {
let inner = UnixSocket::new()?;
let (addr, len) = sockaddr_un(path)?;
map_ret(connect(
inner.as_raw(),
&addr as *const _ as *const _,
len as i32,
))?;
Ok(Self(inner))
}
}
}
impl Read for UnixStream {
fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
self.0.recv(buf)
}
}
impl Write for UnixStream {
fn write(&mut self, buf: &[u8]) -> Result<usize> {
self.0.send(buf)
}
fn flush(&mut self) -> Result<()> {
Ok(())
}
}
impl AsSocket for UnixStream {
fn as_socket(&self) -> BorrowedSocket<'_> {
unsafe { BorrowedSocket::borrow_raw(self.0.as_raw().0 as _) }
}
}

76
crates/net/src/util.rs Normal file
View file

@ -0,0 +1,76 @@
use std::{
io::{Error, ErrorKind, Result},
path::Path,
sync::Once,
};
use windows::Win32::Networking::WinSock::{
ADDRESS_FAMILY, AF_UNIX, SOCKADDR_UN, SOCKET_ERROR, WSAGetLastError, WSAStartup,
};
pub(crate) fn init() {
static ONCE: Once = Once::new();
ONCE.call_once(|| unsafe {
let mut wsa_data = std::mem::zeroed();
let result = WSAStartup(0x202, &mut wsa_data);
if result != 0 {
panic!("WSAStartup failed: {}", result);
}
});
}
// https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/
pub(crate) fn sockaddr_un<P: AsRef<Path>>(path: P) -> Result<(SOCKADDR_UN, usize)> {
let mut addr = SOCKADDR_UN::default();
addr.sun_family = ADDRESS_FAMILY(AF_UNIX);
let bytes = path
.as_ref()
.to_str()
.map(|s| s.as_bytes())
.ok_or(ErrorKind::InvalidInput)?;
if bytes.contains(&0) {
return Err(Error::new(
ErrorKind::InvalidInput,
"paths may not contain interior null bytes",
));
}
if bytes.len() >= addr.sun_path.len() {
return Err(Error::new(
ErrorKind::InvalidInput,
"path must be shorter than SUN_LEN",
));
}
unsafe {
std::ptr::copy_nonoverlapping(
bytes.as_ptr(),
addr.sun_path.as_mut_ptr().cast(),
bytes.len(),
);
}
let mut len = sun_path_offset(&addr) + bytes.len();
match bytes.first() {
Some(&0) | None => {}
Some(_) => len += 1,
}
Ok((addr, len))
}
pub(crate) fn map_ret(ret: i32) -> Result<usize> {
if ret == SOCKET_ERROR {
Err(Error::from_raw_os_error(unsafe { WSAGetLastError().0 }))
} else {
Ok(ret as usize)
}
}
fn sun_path_offset(addr: &SOCKADDR_UN) -> usize {
// Work with an actual instance of the type since using a null pointer is UB
let base = addr as *const _ as usize;
let path = &addr.sun_path as *const _ as usize;
path - base
}

View file

@ -4584,53 +4584,52 @@ impl OutlinePanel {
.track_scroll(self.scroll_handle.clone()) .track_scroll(self.scroll_handle.clone())
.when(show_indent_guides, |list| { .when(show_indent_guides, |list| {
list.with_decoration( list.with_decoration(
ui::indent_guides( ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
cx.entity().clone(), .with_compute_indents_fn(
px(indent_size), cx.entity().clone(),
IndentGuideColors::panel(cx), |outline_panel, range, _, _| {
|outline_panel, range, _, _| { let entries = outline_panel.cached_entries.get(range);
let entries = outline_panel.cached_entries.get(range); if let Some(entries) = entries {
if let Some(entries) = entries { entries.into_iter().map(|item| item.depth).collect()
entries.into_iter().map(|item| item.depth).collect() } else {
} else { smallvec::SmallVec::new()
smallvec::SmallVec::new() }
} },
}, )
) .with_render_fn(
.with_render_fn( cx.entity().clone(),
cx.entity().clone(), move |outline_panel, params, _, _| {
move |outline_panel, params, _, _| { const LEFT_OFFSET: Pixels = px(14.);
const LEFT_OFFSET: Pixels = px(14.);
let indent_size = params.indent_size; let indent_size = params.indent_size;
let item_height = params.item_height; let item_height = params.item_height;
let active_indent_guide_ix = find_active_indent_guide_ix( let active_indent_guide_ix = find_active_indent_guide_ix(
outline_panel, outline_panel,
&params.indent_guides, &params.indent_guides,
); );
params params
.indent_guides .indent_guides
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(ix, layout)| { .map(|(ix, layout)| {
let bounds = Bounds::new( let bounds = Bounds::new(
point( point(
layout.offset.x * indent_size + LEFT_OFFSET, layout.offset.x * indent_size + LEFT_OFFSET,
layout.offset.y * item_height, layout.offset.y * item_height,
), ),
size(px(1.), layout.length * item_height), size(px(1.), layout.length * item_height),
); );
ui::RenderedIndentGuide { ui::RenderedIndentGuide {
bounds, bounds,
layout, layout,
is_active: active_indent_guide_ix == Some(ix), is_active: active_indent_guide_ix == Some(ix),
hitbox: None, hitbox: None,
} }
}) })
.collect() .collect()
}, },
), ),
) )
}) })
}; };

View file

@ -67,10 +67,10 @@ pub fn panel_filled_button(label: impl Into<SharedString>) -> ui::Button {
pub fn panel_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton { pub fn panel_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton {
let id = ElementId::Name(id.into()); let id = ElementId::Name(id.into());
ui::IconButton::new(id, icon)
IconButton::new(id, icon)
// TODO: Change this once we use on_surface_bg in button_like // TODO: Change this once we use on_surface_bg in button_like
.layer(ui::ElevationIndex::ModalSurface) .layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::Compact)
} }
pub fn panel_filled_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton { pub fn panel_filled_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton {

View file

@ -352,6 +352,14 @@ pub fn debug_adapters_dir() -> &'static PathBuf {
DEBUG_ADAPTERS_DIR.get_or_init(|| data_dir().join("debug_adapters")) DEBUG_ADAPTERS_DIR.get_or_init(|| data_dir().join("debug_adapters"))
} }
/// Returns the path to the agent servers directory
///
/// This is where agent servers are downloaded to
pub fn agent_servers_dir() -> &'static PathBuf {
static AGENT_SERVERS_DIR: OnceLock<PathBuf> = OnceLock::new();
AGENT_SERVERS_DIR.get_or_init(|| data_dir().join("agent_servers"))
}
/// Returns the path to the Copilot directory. /// Returns the path to the Copilot directory.
pub fn copilot_dir() -> &'static PathBuf { pub fn copilot_dir() -> &'static PathBuf {
static COPILOT_DIR: OnceLock<PathBuf> = OnceLock::new(); static COPILOT_DIR: OnceLock<PathBuf> = OnceLock::new();

View file

@ -33,7 +33,7 @@ use http_client::HttpClient;
use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind}; use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind};
use node_runtime::NodeRuntime; use node_runtime::NodeRuntime;
use remote::SshRemoteClient; use remote::{SshRemoteClient, ssh_session::SshArgs};
use rpc::{ use rpc::{
AnyProtoClient, TypedEnvelope, AnyProtoClient, TypedEnvelope,
proto::{self}, proto::{self},
@ -253,11 +253,16 @@ impl DapStore {
cx.spawn(async move |_, cx| { cx.spawn(async move |_, cx| {
let response = request.await?; let response = request.await?;
let binary = DebugAdapterBinary::from_proto(response)?; let binary = DebugAdapterBinary::from_proto(response)?;
let mut ssh_command = ssh_client.read_with(cx, |ssh, _| { let (mut ssh_command, envs, path_style) =
anyhow::Ok(SshCommand { ssh_client.read_with(cx, |ssh, _| {
arguments: ssh.ssh_args().context("SSH arguments not found")?, let (SshArgs { arguments, envs }, path_style) =
}) ssh.ssh_info().context("SSH arguments not found")?;
})??; anyhow::Ok((
SshCommand { arguments },
envs.unwrap_or_default(),
path_style,
))
})??;
let mut connection = None; let mut connection = None;
if let Some(c) = binary.connection { if let Some(c) = binary.connection {
@ -282,12 +287,13 @@ impl DapStore {
binary.cwd.as_deref(), binary.cwd.as_deref(),
binary.envs, binary.envs,
None, None,
path_style,
); );
Ok(DebugAdapterBinary { Ok(DebugAdapterBinary {
command: Some(program), command: Some(program),
arguments: args, arguments: args,
envs: HashMap::default(), envs,
cwd: None, cwd: None,
connection, connection,
request_args: binary.request_args, request_args: binary.request_args,

View file

@ -84,7 +84,7 @@ impl ProjectEnvironment {
self.get_worktree_environment(worktree, cx) self.get_worktree_environment(worktree, cx)
} }
pub(crate) fn get_worktree_environment( pub fn get_worktree_environment(
&mut self, &mut self,
worktree: Entity<Worktree>, worktree: Entity<Worktree>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@ -118,7 +118,7 @@ impl ProjectEnvironment {
/// If the project was opened from the CLI, then the inherited CLI environment is returned. /// If the project was opened from the CLI, then the inherited CLI environment is returned.
/// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in /// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in
/// that directory, to get environment variables as if the user has `cd`'d there. /// that directory, to get environment variables as if the user has `cd`'d there.
pub(crate) fn get_directory_environment( pub fn get_directory_environment(
&mut self, &mut self,
abs_path: Arc<Path>, abs_path: Arc<Path>,
cx: &mut Context<Self>, cx: &mut Context<Self>,

View file

@ -117,7 +117,7 @@ use text::{Anchor, BufferId, Point};
use toolchain_store::EmptyToolchainStore; use toolchain_store::EmptyToolchainStore;
use util::{ use util::{
ResultExt as _, ResultExt as _,
paths::{SanitizedPath, compare_paths}, paths::{PathStyle, RemotePathBuf, SanitizedPath, compare_paths},
}; };
use worktree::{CreatedEntry, Snapshot, Traversal}; use worktree::{CreatedEntry, Snapshot, Traversal};
pub use worktree::{ pub use worktree::{
@ -1159,9 +1159,11 @@ impl Project {
let snippets = let snippets =
SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx); SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx);
let ssh_proto = ssh.read(cx).proto_client(); let (ssh_proto, path_style) =
let worktree_store = ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style()));
cx.new(|_| WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID)); let worktree_store = cx.new(|_| {
WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style)
});
cx.subscribe(&worktree_store, Self::on_worktree_store_event) cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach(); .detach();
@ -1410,8 +1412,15 @@ impl Project {
let remote_id = response.payload.project_id; let remote_id = response.payload.project_id;
let role = response.payload.role(); let role = response.payload.role();
// todo(zjk)
// Set the proper path style based on the remote
let worktree_store = cx.new(|_| { let worktree_store = cx.new(|_| {
WorktreeStore::remote(true, client.clone().into(), response.payload.project_id) WorktreeStore::remote(
true,
client.clone().into(),
response.payload.project_id,
PathStyle::Posix,
)
})?; })?;
let buffer_store = cx.new(|cx| { let buffer_store = cx.new(|cx| {
BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
@ -4039,7 +4048,8 @@ impl Project {
}) })
}) })
} else if let Some(ssh_client) = self.ssh_client.as_ref() { } else if let Some(ssh_client) = self.ssh_client.as_ref() {
let request_path = Path::new(path); let path_style = ssh_client.read(cx).path_style();
let request_path = RemotePathBuf::from_str(path, path_style);
let request = ssh_client let request = ssh_client
.read(cx) .read(cx)
.proto_client() .proto_client()

View file

@ -404,6 +404,9 @@ impl SearchQuery {
let start = line_offset + mat.start(); let start = line_offset + mat.start();
let end = line_offset + mat.end(); let end = line_offset + mat.end();
matches.push(start..end); matches.push(start..end);
if self.one_match_per_line() == Some(true) {
break;
}
} }
line_offset += line.len() + 1; line_offset += line.len() + 1;

View file

@ -4,6 +4,7 @@ use collections::HashMap;
use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, Task, WeakEntity}; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, Task, WeakEntity};
use itertools::Itertools; use itertools::Itertools;
use language::LanguageName; use language::LanguageName;
use remote::ssh_session::SshArgs;
use settings::{Settings, SettingsLocation}; use settings::{Settings, SettingsLocation};
use smol::channel::bounded; use smol::channel::bounded;
use std::{ use std::{
@ -17,7 +18,10 @@ use terminal::{
TaskState, TaskStatus, Terminal, TerminalBuilder, TaskState, TaskStatus, Terminal, TerminalBuilder,
terminal_settings::{self, TerminalSettings, VenvSettings}, terminal_settings::{self, TerminalSettings, VenvSettings},
}; };
use util::ResultExt; use util::{
ResultExt,
paths::{PathStyle, RemotePathBuf},
};
pub struct Terminals { pub struct Terminals {
pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>, pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
@ -47,6 +51,13 @@ impl SshCommand {
} }
} }
pub struct SshDetails {
pub host: String,
pub ssh_command: SshCommand,
pub envs: Option<HashMap<String, String>>,
pub path_style: PathStyle,
}
impl Project { impl Project {
pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> { pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
let worktree = self let worktree = self
@ -68,14 +79,16 @@ impl Project {
} }
} }
pub fn ssh_details(&self, cx: &App) -> Option<(String, SshCommand)> { pub fn ssh_details(&self, cx: &App) -> Option<SshDetails> {
if let Some(ssh_client) = &self.ssh_client { if let Some(ssh_client) = &self.ssh_client {
let ssh_client = ssh_client.read(cx); let ssh_client = ssh_client.read(cx);
if let Some(args) = ssh_client.ssh_args() { if let Some((SshArgs { arguments, envs }, path_style)) = ssh_client.ssh_info() {
return Some(( return Some(SshDetails {
ssh_client.connection_options().host.clone(), host: ssh_client.connection_options().host.clone(),
SshCommand { arguments: args }, ssh_command: SshCommand { arguments },
)); envs,
path_style,
});
} }
} }
@ -158,17 +171,26 @@ impl Project {
.unwrap_or_default(); .unwrap_or_default();
env.extend(settings.env.clone()); env.extend(settings.env.clone());
match &self.ssh_details(cx) { match self.ssh_details(cx) {
Some((_, ssh_command)) => { Some(SshDetails {
ssh_command,
envs,
path_style,
..
}) => {
let (command, args) = wrap_for_ssh( let (command, args) = wrap_for_ssh(
ssh_command, &ssh_command,
Some((&command, &args)), Some((&command, &args)),
path.as_deref(), path.as_deref(),
env, env,
None, None,
path_style,
); );
let mut command = std::process::Command::new(command); let mut command = std::process::Command::new(command);
command.args(args); command.args(args);
if let Some(envs) = envs {
command.envs(envs);
}
command command
} }
None => { None => {
@ -202,6 +224,7 @@ impl Project {
} }
}; };
let ssh_details = this.ssh_details(cx); let ssh_details = this.ssh_details(cx);
let is_ssh_terminal = ssh_details.is_some();
let mut settings_location = None; let mut settings_location = None;
if let Some(path) = path.as_ref() { if let Some(path) = path.as_ref() {
@ -226,11 +249,7 @@ impl Project {
// precedence. // precedence.
env.extend(settings.env.clone()); env.extend(settings.env.clone());
let local_path = if ssh_details.is_none() { let local_path = if is_ssh_terminal { None } else { path.clone() };
path.clone()
} else {
None
};
let mut python_venv_activate_command = None; let mut python_venv_activate_command = None;
@ -241,8 +260,13 @@ impl Project {
this.python_activate_command(python_venv_directory, &settings.detect_venv); this.python_activate_command(python_venv_directory, &settings.detect_venv);
} }
match &ssh_details { match ssh_details {
Some((host, ssh_command)) => { Some(SshDetails {
host,
ssh_command,
envs,
path_style,
}) => {
log::debug!("Connecting to a remote server: {ssh_command:?}"); log::debug!("Connecting to a remote server: {ssh_command:?}");
// Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
@ -252,9 +276,18 @@ impl Project {
env.entry("TERM".to_string()) env.entry("TERM".to_string())
.or_insert_with(|| "xterm-256color".to_string()); .or_insert_with(|| "xterm-256color".to_string());
let (program, args) = let (program, args) = wrap_for_ssh(
wrap_for_ssh(&ssh_command, None, path.as_deref(), env, None); &ssh_command,
None,
path.as_deref(),
env,
None,
path_style,
);
env = HashMap::default(); env = HashMap::default();
if let Some(envs) = envs {
env.extend(envs);
}
( (
Option::<TaskState>::None, Option::<TaskState>::None,
Shell::WithArguments { Shell::WithArguments {
@ -290,8 +323,13 @@ impl Project {
); );
} }
match &ssh_details { match ssh_details {
Some((host, ssh_command)) => { Some(SshDetails {
host,
ssh_command,
envs,
path_style,
}) => {
log::debug!("Connecting to a remote server: {ssh_command:?}"); log::debug!("Connecting to a remote server: {ssh_command:?}");
env.entry("TERM".to_string()) env.entry("TERM".to_string())
.or_insert_with(|| "xterm-256color".to_string()); .or_insert_with(|| "xterm-256color".to_string());
@ -304,8 +342,12 @@ impl Project {
path.as_deref(), path.as_deref(),
env, env,
python_venv_directory.as_deref(), python_venv_directory.as_deref(),
path_style,
); );
env = HashMap::default(); env = HashMap::default();
if let Some(envs) = envs {
env.extend(envs);
}
( (
task_state, task_state,
Shell::WithArguments { Shell::WithArguments {
@ -343,7 +385,7 @@ impl Project {
settings.cursor_shape.unwrap_or_default(), settings.cursor_shape.unwrap_or_default(),
settings.alternate_scroll, settings.alternate_scroll,
settings.max_scroll_history_lines, settings.max_scroll_history_lines,
ssh_details.is_some(), is_ssh_terminal,
window, window,
completion_tx, completion_tx,
cx, cx,
@ -533,6 +575,7 @@ pub fn wrap_for_ssh(
path: Option<&Path>, path: Option<&Path>,
env: HashMap<String, String>, env: HashMap<String, String>,
venv_directory: Option<&Path>, venv_directory: Option<&Path>,
path_style: PathStyle,
) -> (String, Vec<String>) { ) -> (String, Vec<String>) {
let to_run = if let Some((command, args)) = command { let to_run = if let Some((command, args)) = command {
// DEFAULT_REMOTE_SHELL is '"${SHELL:-sh}"' so must not be escaped // DEFAULT_REMOTE_SHELL is '"${SHELL:-sh}"' so must not be escaped
@ -555,24 +598,25 @@ pub fn wrap_for_ssh(
} }
if let Some(venv_directory) = venv_directory { if let Some(venv_directory) = venv_directory {
if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) { if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) {
env_changes.push_str(&format!("PATH={}:$PATH ", str)); let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string();
env_changes.push_str(&format!("PATH={}:$PATH ", path));
} }
} }
let commands = if let Some(path) = path { let commands = if let Some(path) = path {
let path_string = path.to_string_lossy().to_string(); let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string();
// shlex will wrap the command in single quotes (''), disabling ~ expansion, // shlex will wrap the command in single quotes (''), disabling ~ expansion,
// replace ith with something that works // replace ith with something that works
let tilde_prefix = "~/"; let tilde_prefix = "~/";
if path.starts_with(tilde_prefix) { if path.starts_with(tilde_prefix) {
let trimmed_path = path_string let trimmed_path = path
.trim_start_matches("/") .trim_start_matches("/")
.trim_start_matches("~") .trim_start_matches("~")
.trim_start_matches("/"); .trim_start_matches("/");
format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
} else { } else {
format!("cd {path:?}; {env_changes} {to_run}") format!("cd {path}; {env_changes} {to_run}")
} }
} else { } else {
format!("cd; {env_changes} {to_run}") format!("cd; {env_changes} {to_run}")

View file

@ -25,7 +25,10 @@ use smol::{
stream::StreamExt, stream::StreamExt,
}; };
use text::ReplicaId; use text::ReplicaId;
use util::{ResultExt, paths::SanitizedPath}; use util::{
ResultExt,
paths::{PathStyle, RemotePathBuf, SanitizedPath},
};
use worktree::{ use worktree::{
Entry, ProjectEntryId, UpdatedEntriesSet, UpdatedGitRepositoriesSet, Worktree, WorktreeId, Entry, ProjectEntryId, UpdatedEntriesSet, UpdatedGitRepositoriesSet, Worktree, WorktreeId,
WorktreeSettings, WorktreeSettings,
@ -46,6 +49,7 @@ enum WorktreeStoreState {
Remote { Remote {
upstream_client: AnyProtoClient, upstream_client: AnyProtoClient,
upstream_project_id: u64, upstream_project_id: u64,
path_style: PathStyle,
}, },
} }
@ -100,6 +104,7 @@ impl WorktreeStore {
retain_worktrees: bool, retain_worktrees: bool,
upstream_client: AnyProtoClient, upstream_client: AnyProtoClient,
upstream_project_id: u64, upstream_project_id: u64,
path_style: PathStyle,
) -> Self { ) -> Self {
Self { Self {
next_entry_id: Default::default(), next_entry_id: Default::default(),
@ -111,6 +116,7 @@ impl WorktreeStore {
state: WorktreeStoreState::Remote { state: WorktreeStoreState::Remote {
upstream_client, upstream_client,
upstream_project_id, upstream_project_id,
path_style,
}, },
} }
} }
@ -214,17 +220,16 @@ impl WorktreeStore {
if !self.loading_worktrees.contains_key(&abs_path) { if !self.loading_worktrees.contains_key(&abs_path) {
let task = match &self.state { let task = match &self.state {
WorktreeStoreState::Remote { WorktreeStoreState::Remote {
upstream_client, .. upstream_client,
path_style,
..
} => { } => {
if upstream_client.is_via_collab() { if upstream_client.is_via_collab() {
Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab")))) Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab"))))
} else { } else {
self.create_ssh_worktree( let abs_path =
upstream_client.clone(), RemotePathBuf::new(abs_path.as_path().to_path_buf(), *path_style);
abs_path.clone(), self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx)
visible,
cx,
)
} }
} }
WorktreeStoreState::Local { fs } => { WorktreeStoreState::Local { fs } => {
@ -250,11 +255,12 @@ impl WorktreeStore {
fn create_ssh_worktree( fn create_ssh_worktree(
&mut self, &mut self,
client: AnyProtoClient, client: AnyProtoClient,
abs_path: impl Into<SanitizedPath>, abs_path: RemotePathBuf,
visible: bool, visible: bool,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<Entity<Worktree>, Arc<anyhow::Error>>> { ) -> Task<Result<Entity<Worktree>, Arc<anyhow::Error>>> {
let mut abs_path = Into::<SanitizedPath>::into(abs_path).to_string(); let path_style = abs_path.path_style();
let mut abs_path = abs_path.to_string();
// If we start with `/~` that means the ssh path was something like `ssh://user@host/~/home-dir-folder/` // If we start with `/~` that means the ssh path was something like `ssh://user@host/~/home-dir-folder/`
// in which case want to strip the leading the `/`. // in which case want to strip the leading the `/`.
// On the host-side, the `~` will get expanded. // On the host-side, the `~` will get expanded.
@ -265,10 +271,11 @@ impl WorktreeStore {
if abs_path.is_empty() { if abs_path.is_empty() {
abs_path = "~/".to_string(); abs_path = "~/".to_string();
} }
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let this = this.upgrade().context("Dropped worktree store")?; let this = this.upgrade().context("Dropped worktree store")?;
let path = Path::new(abs_path.as_str()); let path = RemotePathBuf::new(abs_path.into(), path_style);
let response = client let response = client
.request(proto::AddWorktree { .request(proto::AddWorktree {
project_id: SSH_PROJECT_ID, project_id: SSH_PROJECT_ID,

Some files were not shown because too many files have changed in this diff Show more