diff --git a/.cargo/bundle-config.toml b/.cargo/bundle-config.toml index 66c1f2ec08..0fc6a68ff9 100644 --- a/.cargo/bundle-config.toml +++ b/.cargo/bundle-config.toml @@ -1,5 +1,8 @@ -[env] -RUSTC_BOOTSTRAP = "1" +[target.'cfg(target_os = "linux")'] +rustflags = ["-C", "link-args=-Wl,--disable-new-dtags,-rpath,$ORIGIN/../lib"] -[build] -rustflags = ["-Z", "share-generics=y"] +[target.'cfg(all(target_os = "linux", target_env = "musl"))'] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.aarch64-unknown-linux-musl] +rustflags = ["-C", "link-arg=-fuse-ld=lld"] diff --git a/.cargo/config.toml b/.cargo/config.toml index a9bf1f9cc9..8ed5893b99 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,4 @@ -[build] +[target.'cfg(all())'] # v0 mangling scheme provides more detailed backtraces around closures rustflags = ["-C", "symbol-mangling-version=v0", "--cfg", "tokio_unstable"] diff --git a/livekit.yaml b/.config/livekit.yaml similarity index 100% rename from livekit.yaml rename to .config/livekit.yaml diff --git a/lychee.toml b/.config/lychee.toml similarity index 100% rename from lychee.toml rename to .config/lychee.toml diff --git a/typos.toml b/.config/typos.toml similarity index 99% rename from typos.toml rename to .config/typos.toml index 9e3bb541df..4c1a01055c 100644 --- a/typos.toml +++ b/.config/typos.toml @@ -116,5 +116,6 @@ extend-ignore-re = [ check-filename = true [default.extend-words] +flate = "flate" scap = "scap" writeable = "writeable" diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold deleted file mode 100644 index fea437d4ff..0000000000 --- a/.github/CODEOWNERS.hold +++ /dev/null @@ -1,336 +0,0 @@ -# CODEOWNERS for zed-industries/zed -# -# ┌─────────────────────────────────────────────────────────────────────────────┐ -# │ WARNING: This file is auto-generated. DO NOT EDIT MANUALLY. │ -# │ │ -# │ Changes made directly to this file will be overwritten by the next │ -# │ scheduled workflow run. To modify code ownership, update the │ -# │ team-membership-rules.yml file in the codeowner-coordinator repo: │ -# │ https://github.com/zed-industries/codeowner-coordinator │ -# └─────────────────────────────────────────────────────────────────────────────┘ -# -# Coverage: 100% (all paths have an owner) -# - -# ═══════════════════════════════════════════════════════════════════════════════ -# DEFAULT FALLBACK (must be first - CODEOWNERS is last-match-wins) -# ═══════════════════════════════════════════════════════════════════════════════ - -* @zed-industries/first-responder - -# ═══════════════════════════════════════════════════════════════════════════════ -# ai-team -# LLM integrations, agents, predictions -# ═══════════════════════════════════════════════════════════════════════════════ - -/assets/prompts/ @zed-industries/ai-team -/crates/acp_thread/ @zed-industries/ai-team -/crates/acp_tools/ @zed-industries/ai-team -/crates/agent/ @zed-industries/ai-team -/crates/agent_servers/ @zed-industries/ai-team -/crates/agent_settings/ @zed-industries/ai-team -/crates/agent_ui/ @zed-industries/ai-team -/crates/ai_onboarding/ @zed-industries/ai-team -/crates/anthropic/ @zed-industries/ai-team -/crates/bedrock/ @zed-industries/ai-team -/crates/cloud_llm_client/ @zed-industries/ai-team -/crates/codestral/ @zed-industries/ai-team -/crates/context_server/ @zed-industries/ai-team -/crates/copilot/ @zed-industries/ai-team -/crates/copilot_chat/ @zed-industries/ai-team -/crates/copilot_ui/ @zed-industries/ai-team -/crates/deepseek/ @zed-industries/ai-team -/crates/edit_prediction/ @zed-industries/ai-team -/crates/edit_prediction_cli/ @zed-industries/ai-team -/crates/edit_prediction_context/ @zed-industries/ai-team -/crates/edit_prediction_types/ @zed-industries/ai-team -/crates/edit_prediction_ui/ @zed-industries/ai-team -/crates/eval_utils/ @zed-industries/ai-team -/crates/google_ai/ @zed-industries/ai-team -/crates/language_model/ @zed-industries/ai-team -/crates/language_models/ @zed-industries/ai-team -/crates/lmstudio/ @zed-industries/ai-team -/crates/mistral/ @zed-industries/ai-team -/crates/ollama/ @zed-industries/ai-team -/crates/open_ai/ @zed-industries/ai-team -/crates/open_router/ @zed-industries/ai-team -/crates/prompt_store/ @zed-industries/ai-team -# SUGGESTED: Review needed - based on Richard Feldman (2 commits) -/crates/shell_command_parser/ @zed-industries/ai-team -/crates/vercel/ @zed-industries/ai-team -/crates/x_ai/ @zed-industries/ai-team -/crates/zeta_prompt/ @zed-industries/ai-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# collab-team -# Real-time collaboration, channels, calls, remote development -# ═══════════════════════════════════════════════════════════════════════════════ - -/crates/call/ @zed-industries/collab-team -/crates/channel/ @zed-industries/collab-team -/crates/client/ @zed-industries/collab-team -/crates/collab/ @zed-industries/collab-team -/crates/collab_ui/ @zed-industries/collab-team -/crates/dev_container/ @zed-industries/collab-team -/crates/livekit_api/ @zed-industries/collab-team -/crates/livekit_client/ @zed-industries/collab-team -/crates/proto/ @zed-industries/collab-team -/crates/remote/ @zed-industries/collab-team -/crates/remote_server/ @zed-industries/collab-team -/crates/rpc/ @zed-industries/collab-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# core-team -# GPUI framework, text editing, vim, buffers, text manipulation -# ═══════════════════════════════════════════════════════════════════════════════ - -/crates/action_log/ @zed-industries/core-team -/crates/breadcrumbs/ @zed-industries/core-team -/crates/buffer_diff/ @zed-industries/core-team -/crates/editor/ @zed-industries/core-team -/crates/encoding_selector/ @zed-industries/core-team -/crates/go_to_line/ @zed-industries/core-team -/crates/gpui/ @zed-industries/core-team -/crates/gpui_macros/ @zed-industries/core-team -/crates/gpui_tokio/ @zed-industries/core-team -/crates/html_to_markdown/ @zed-industries/core-team -/crates/journal/ @zed-industries/core-team -/crates/line_ending_selector/ @zed-industries/core-team -/crates/multi_buffer/ @zed-industries/core-team -/crates/outline/ @zed-industries/core-team -/crates/outline_panel/ @zed-industries/core-team -/crates/rich_text/ @zed-industries/core-team -/crates/rope/ @zed-industries/core-team -/crates/snippet/ @zed-industries/core-team -/crates/snippet_provider/ @zed-industries/core-team -/crates/snippets_ui/ @zed-industries/core-team -/crates/streaming_diff/ @zed-industries/core-team -/crates/sum_tree/ @zed-industries/core-team -/crates/text/ @zed-industries/core-team -/crates/vim/ @zed-industries/core-team -/crates/vim_mode_setting/ @zed-industries/core-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# developer-tools-team -# Terminal, REPL, tasks, debugger, DAP, git integration -# ═══════════════════════════════════════════════════════════════════════════════ - -/crates/dap/ @zed-industries/developer-tools-team -/crates/dap_adapters/ @zed-industries/developer-tools-team -/crates/debug_adapter_extension/ @zed-industries/developer-tools-team -/crates/debugger_tools/ @zed-industries/developer-tools-team -/crates/debugger_ui/ @zed-industries/developer-tools-team -/crates/git/ @zed-industries/developer-tools-team -# SUGGESTED: Review needed - based on Anthony Eid (4 commits) -/crates/git_graph/ @zed-industries/developer-tools-team -/crates/git_hosting_providers/ @zed-industries/developer-tools-team -/crates/git_ui/ @zed-industries/developer-tools-team -/crates/inspector_ui/ @zed-industries/developer-tools-team -/crates/miniprofiler_ui/ @zed-industries/developer-tools-team -/crates/repl/ @zed-industries/developer-tools-team -/crates/task/ @zed-industries/developer-tools-team -/crates/tasks_ui/ @zed-industries/developer-tools-team -/crates/terminal/ @zed-industries/developer-tools-team -/crates/terminal_view/ @zed-industries/developer-tools-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# ecosystem-team -# Language support, LSP, Tree-sitter, extensions, extension API -# ═══════════════════════════════════════════════════════════════════════════════ - -/crates/extension/ @zed-industries/ecosystem-team -/crates/extension_api/ @zed-industries/ecosystem-team -/crates/extension_cli/ @zed-industries/ecosystem-team -/crates/extension_host/ @zed-industries/ecosystem-team -/crates/extensions_ui/ @zed-industries/ecosystem-team -/crates/json_schema_store/ @zed-industries/ecosystem-team -/crates/language/ @zed-industries/ecosystem-team -/crates/language_extension/ @zed-industries/ecosystem-team -/crates/language_onboarding/ @zed-industries/ecosystem-team -/crates/language_selector/ @zed-industries/ecosystem-team -/crates/language_tools/ @zed-industries/ecosystem-team -/crates/languages/ @zed-industries/ecosystem-team -/crates/lsp/ @zed-industries/ecosystem-team -/crates/node_runtime/ @zed-industries/ecosystem-team -/crates/prettier/ @zed-industries/ecosystem-team -# SUGGESTED: Review needed - based on Piotr Osiewicz (1 commits) -/crates/remote_connection/ @zed-industries/ecosystem-team -/crates/toolchain_selector/ @zed-industries/ecosystem-team -/extensions/ @zed-industries/ecosystem-team -/extensions/glsl/ @zed-industries/ecosystem-team -/extensions/html/ @zed-industries/ecosystem-team -/extensions/proto/ @zed-industries/ecosystem-team -/extensions/test-extension/ @zed-industries/ecosystem-team -/extensions/workflows/ @zed-industries/ecosystem-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# infrastructure-team -# Core utilities, networking, database, telemetry, reliability -# ═══════════════════════════════════════════════════════════════════════════════ - -/crates/aws_http_client/ @zed-industries/infrastructure-team -/crates/clock/ @zed-industries/infrastructure-team -/crates/collections/ @zed-industries/infrastructure-team -/crates/crashes/ @zed-industries/infrastructure-team -/crates/credentials_provider/ @zed-industries/infrastructure-team -/crates/db/ @zed-industries/infrastructure-team -/crates/denoise/ @zed-industries/infrastructure-team -/crates/feature_flags/ @zed-industries/infrastructure-team -/crates/fs/ @zed-industries/infrastructure-team -/crates/fs_benchmarks/ @zed-industries/infrastructure-team -/crates/http_client/ @zed-industries/infrastructure-team -/crates/http_client_tls/ @zed-industries/infrastructure-team -/crates/net/ @zed-industries/infrastructure-team -/crates/paths/ @zed-industries/infrastructure-team -/crates/release_channel/ @zed-industries/infrastructure-team -/crates/reqwest_client/ @zed-industries/infrastructure-team -/crates/scheduler/ @zed-industries/infrastructure-team -/crates/session/ @zed-industries/infrastructure-team -/crates/sqlez/ @zed-industries/infrastructure-team -/crates/sqlez_macros/ @zed-industries/infrastructure-team -/crates/telemetry/ @zed-industries/infrastructure-team -/crates/telemetry_events/ @zed-industries/infrastructure-team -/crates/time_format/ @zed-industries/infrastructure-team -/crates/util/ @zed-industries/infrastructure-team -/crates/util_macros/ @zed-industries/infrastructure-team -/crates/watch/ @zed-industries/infrastructure-team -/crates/web_search/ @zed-industries/infrastructure-team -/crates/web_search_providers/ @zed-industries/infrastructure-team -/crates/zlog/ @zed-industries/infrastructure-team -/crates/zlog_settings/ @zed-industries/infrastructure-team -/crates/ztracing/ @zed-industries/infrastructure-team -/crates/ztracing_macro/ @zed-industries/infrastructure-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# platform-team -# App shell, CI/CD, packaging, distribution, build scripts -# ═══════════════════════════════════════════════════════════════════════════════ - -/.factory/ @zed-industries/platform-team -/.zed/ @zed-industries/platform-team -/assets/badge/ @zed-industries/platform-team -/assets/settings/ @zed-industries/platform-team -/ci/ @zed-industries/platform-team -/crates/askpass/ @zed-industries/platform-team -/crates/audio/ @zed-industries/platform-team -/crates/auto_update/ @zed-industries/platform-team -/crates/auto_update_helper/ @zed-industries/platform-team -/crates/auto_update_ui/ @zed-industries/platform-team -/crates/cli/ @zed-industries/platform-team -/crates/command_palette/ @zed-industries/platform-team -/crates/command_palette_hooks/ @zed-industries/platform-team -/crates/docs_preprocessor/ @zed-industries/platform-team -/crates/feedback/ @zed-industries/platform-team -/crates/install_cli/ @zed-industries/platform-team -/crates/keymap_editor/ @zed-industries/platform-team -/crates/media/ @zed-industries/platform-team -/crates/migrator/ @zed-industries/platform-team -/crates/notifications/ @zed-industries/platform-team -/crates/onboarding/ @zed-industries/platform-team -# SUGGESTED: Review needed - based on Finn Evers (1 commits) -/crates/platform_title_bar/ @zed-industries/platform-team -/crates/schema_generator/ @zed-industries/platform-team -/crates/settings/ @zed-industries/platform-team -/crates/settings_content/ @zed-industries/platform-team -/crates/settings_json/ @zed-industries/platform-team -/crates/settings_macros/ @zed-industries/platform-team -/crates/settings_profile_selector/ @zed-industries/platform-team -/crates/settings_ui/ @zed-industries/platform-team -# SUGGESTED: Review needed - based on Finn Evers (2 commits) -/crates/sidebar/ @zed-industries/platform-team -/crates/system_specs/ @zed-industries/platform-team -/crates/zed/ @zed-industries/platform-team -/crates/zed_actions/ @zed-industries/platform-team -/crates/zed_env_vars/ @zed-industries/platform-team -/docs/ @zed-industries/platform-team -/docs/.conventions/ @zed-industries/platform-team -/docs/.doc-examples/ @zed-industries/platform-team -/docs/src/ @zed-industries/platform-team -/docs/theme/ @zed-industries/platform-team -/nix/ @zed-industries/platform-team -/script/ @zed-industries/platform-team -/script/danger/ @zed-industries/platform-team -/script/flatpak/ @zed-industries/platform-team -/script/lib/ @zed-industries/platform-team -/script/licenses/ @zed-industries/platform-team -/script/terms/ @zed-industries/platform-team -/script/update_top_ranking_issues/ @zed-industries/platform-team -/tooling/ @zed-industries/platform-team -/tooling/perf/ @zed-industries/platform-team -/tooling/xtask/ @zed-industries/platform-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# project-team -# Project/workspace management, file navigation, search -# ═══════════════════════════════════════════════════════════════════════════════ - -/crates/diagnostics/ @zed-industries/project-team -/crates/explorer_command_injector/ @zed-industries/project-team -/crates/file_finder/ @zed-industries/project-team -/crates/file_icons/ @zed-industries/project-team -/crates/fuzzy/ @zed-industries/project-team -/crates/project/ @zed-industries/project-team -/crates/project_benchmarks/ @zed-industries/project-team -/crates/project_panel/ @zed-industries/project-team -/crates/project_symbols/ @zed-industries/project-team -/crates/recent_projects/ @zed-industries/project-team -/crates/search/ @zed-industries/project-team -/crates/workspace/ @zed-industries/project-team -/crates/worktree/ @zed-industries/project-team -/crates/worktree_benchmarks/ @zed-industries/project-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# ui-team -# UI components, themes, visual design -# ═══════════════════════════════════════════════════════════════════════════════ - -/assets/ @zed-industries/ui-team -/assets/fonts/ @zed-industries/ui-team -/assets/icons/ @zed-industries/ui-team -/assets/images/ @zed-industries/ui-team -/assets/keymaps/ @zed-industries/ui-team -/assets/sounds/ @zed-industries/ui-team -/assets/themes/ @zed-industries/ui-team -/crates/activity_indicator/ @zed-industries/ui-team -/crates/assets/ @zed-industries/ui-team -/crates/component/ @zed-industries/ui-team -/crates/component_preview/ @zed-industries/ui-team -/crates/icons/ @zed-industries/ui-team -/crates/image_viewer/ @zed-industries/ui-team -/crates/markdown/ @zed-industries/ui-team -/crates/markdown_preview/ @zed-industries/ui-team -/crates/menu/ @zed-industries/ui-team -/crates/panel/ @zed-industries/ui-team -/crates/picker/ @zed-industries/ui-team -/crates/refineable/ @zed-industries/ui-team -/crates/story/ @zed-industries/ui-team -/crates/svg_preview/ @zed-industries/ui-team -/crates/tab_switcher/ @zed-industries/ui-team -/crates/theme/ @zed-industries/ui-team -/crates/theme_extension/ @zed-industries/ui-team -/crates/theme_importer/ @zed-industries/ui-team -/crates/theme_selector/ @zed-industries/ui-team -/crates/title_bar/ @zed-industries/ui-team -/crates/ui/ @zed-industries/ui-team -/crates/ui_input/ @zed-industries/ui-team -/crates/ui_macros/ @zed-industries/ui-team -/crates/ui_prompt/ @zed-industries/ui-team -/crates/which_key/ @zed-industries/ui-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# zed-dev-team -# Zed website, marketing, documentation site -# ═══════════════════════════════════════════════════════════════════════════════ - -# SUGGESTED: Review needed - based on Joseph T. Lyons (1 commits) -/crates/open_path_prompt/ @zed-industries/zed-dev-team - -# ═══════════════════════════════════════════════════════════════════════════════ -# DIRECT OVERRIDES -# Paths with specific owner assignments (not derived from team paths) -# ═══════════════════════════════════════════════════════════════════════════════ - -/.cloudflare/ @zed-industries/cloud-team -/crates/cloud_api_client/ @zed-industries/cloud-team -/crates/cloud_api_types/ @zed-industries/cloud-team -/legal/ @zed-industries/bizops-team diff --git a/renovate.json b/.github/renovate.json similarity index 100% rename from renovate.json rename to .github/renovate.json diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 7524b2a489..4337917ad3 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -61,7 +61,7 @@ jobs: run: cargo fix --workspace --allow-dirty --allow-staged - name: autofix_pr::run_autofix::run_cargo_shear_fix if: inputs.run_clippy == true || inputs.run_clippy == 'true' - run: cargo shear --fix + run: cargo shear --fix --check-test-targets - name: autofix_pr::run_autofix::run_clippy_fix if: inputs.run_clippy == true || inputs.run_clippy == 'true' run: cargo clippy --workspace --fix --allow-dirty --allow-staged diff --git a/.github/workflows/catch_blank_issues.yml b/.github/workflows/catch_blank_issues.yml index 15a61a50cf..c37ea06d86 100644 --- a/.github/workflows/catch_blank_issues.yml +++ b/.github/workflows/catch_blank_issues.yml @@ -57,15 +57,27 @@ jobs: github-token: ${{ steps.get-app-token.outputs.token }} script: | const issue = context.payload.issue; - const hasTriageLabel = issue.labels.some( - label => label.name === 'state:needs triage' - ); + const labelNames = issue.labels.map(label => label.name); - if (hasTriageLabel) { + if (labelNames.includes('state:needs triage')) { console.log('Issue already has state:needs triage, skipping'); return; } + if (context.payload.action === 'reopened') { + const requiredPrefixes = ['area:', 'reach:', 'severity:']; + const alreadyTriaged = requiredPrefixes.every( + prefix => labelNames.some(name => name.startsWith(prefix)) + ); + + if (alreadyTriaged) { + console.log( + `Reopened issue #${issue.number} already has area:, reach: and severity: labels, skipping` + ); + return; + } + } + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/comment_on_potential_duplicate_issues.yml b/.github/workflows/comment_on_potential_duplicate_issues.yml index 4b2cdd1fca..9509c3656e 100644 --- a/.github/workflows/comment_on_potential_duplicate_issues.yml +++ b/.github/workflows/comment_on_potential_duplicate_issues.yml @@ -31,7 +31,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-check-new-issue-for-duplicates.py + sparse-checkout: | + script/github-check-new-issue-for-duplicates.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Get github app token diff --git a/.github/workflows/community_pr_board.yml b/.github/workflows/community_pr_board.yml index 4cc9a5fac5..82e5df9419 100644 --- a/.github/workflows/community_pr_board.yml +++ b/.github/workflows/community_pr_board.yml @@ -1,11 +1,11 @@ # Community PR Board — route labeled community PRs to a GitHub Project board # -# When an area/platform label is added to a community PR (not staff, not bot), -# the PR is added to the project board with a Track field set to the matching -# review area group. Status transitions for assignment, re-request, and -# comment events are handled here. Review-based status changes (approved → -# "In Progress (us)", changes requested → "In Progress (author)") are handled -# by built-in board automations. +# When an area/platform label is added to or already present when reopening a +# community PR (not staff, not bot), the PR is added to the project board with a +# Track field set to the matching review area group. Status transitions for +# assignment, re-request, and comment events are handled here. Review-based +# status changes (approved → "In Progress (us)", changes requested → "In +# Progress (author)") are handled by built-in board automations. # # See script/community-pr-track-mapping.json for the label→track mapping. @@ -16,7 +16,7 @@ on: # Fork PRs must be supported, but this workflow only reads event metadata and # checks out scripts from the trusted default branch; it never executes PR code. pull_request_target: - types: [labeled, unlabeled, assigned, review_requested, edited] + types: [labeled, unlabeled, reopened, assigned, review_requested, edited] issue_comment: types: [created] workflow_dispatch: @@ -64,6 +64,7 @@ jobs: with: sparse-checkout: | script/github-community-pr-board.py + script/github_helpers.py script/community-pr-track-mapping.json sparse-checkout-cone-mode: false diff --git a/.github/workflows/community_pr_board_refresh.yml b/.github/workflows/community_pr_board_refresh.yml index f4b0c293cf..81540f3ace 100644 --- a/.github/workflows/community_pr_board_refresh.yml +++ b/.github/workflows/community_pr_board_refresh.yml @@ -42,6 +42,7 @@ jobs: with: sparse-checkout: | script/github-community-pr-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index ace3c0729e..a92e303c59 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -109,7 +109,7 @@ jobs: - name: deploy_docs::lychee_link_check uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 with: - args: --no-progress --exclude '^http' './docs/src/**/*' + args: --config .config/lychee.toml --no-progress --exclude '^http' './docs/src/**/*' fail: true jobSummary: false - name: deploy_docs::install_mdbook @@ -126,7 +126,7 @@ jobs: - name: deploy_docs::lychee_link_check uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 with: - args: --no-progress --exclude '^http' 'target/deploy/docs' + args: --config .config/lychee.toml --no-progress --exclude '^http' 'target/deploy/docs' fail: true jobSummary: false - name: deploy_docs::docs_deploy_steps::deploy_to_cf_pages diff --git a/.github/workflows/guild_assignment_status.yml b/.github/workflows/guild_assignment_status.yml index c91c05bb54..efd24a0f6b 100644 --- a/.github/workflows/guild_assignment_status.yml +++ b/.github/workflows/guild_assignment_status.yml @@ -45,6 +45,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml index 52b4107bb2..f63839c504 100644 --- a/.github/workflows/guild_new_pr_notify.yml +++ b/.github/workflows/guild_new_pr_notify.yml @@ -45,6 +45,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/guild_stale_assignments.yml b/.github/workflows/guild_stale_assignments.yml index 81a7f0c0ff..d2cd5d6358 100644 --- a/.github/workflows/guild_stale_assignments.yml +++ b/.github/workflows/guild_stale_assignments.yml @@ -43,6 +43,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/guild_weekly_shipped.yml b/.github/workflows/guild_weekly_shipped.yml index 1f3bf71fea..dad19e1483 100644 --- a/.github/workflows/guild_weekly_shipped.yml +++ b/.github/workflows/guild_weekly_shipped.yml @@ -41,6 +41,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 037bc5bda5..585280568f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ jobs: run_tests_mac: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-mac-large + env: + RUST_LIB_BACKTRACE: '0' steps: - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd @@ -569,6 +571,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac aarch64-apple-darwin - name: run_bundling::upload_artifact @@ -621,6 +634,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac x86_64-apple-darwin - name: run_bundling::upload_artifact diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 45cc17f970..1652e104b6 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -6,7 +6,7 @@ env: RUST_BACKTRACE: '1' on: schedule: - - cron: 0 */4 * * * + - cron: 0 2,14 * * * workflow_dispatch: {} permissions: contents: read @@ -300,6 +300,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac aarch64-apple-darwin - name: run_bundling::upload_artifact @@ -356,6 +367,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac x86_64-apple-darwin - name: run_bundling::upload_artifact diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index a16651db4b..a697d96f2c 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -204,6 +204,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac aarch64-apple-darwin - name: run_bundling::upload_artifact @@ -255,6 +266,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac x86_64-apple-darwin - name: run_bundling::upload_artifact diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index bf7b83a7bf..3babc1e5c8 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -114,8 +114,8 @@ jobs: check_pattern "run_action_checks" '^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/' -qP check_pattern "run_docs" '^(docs/|crates/.*\.rs)' -qP - check_pattern "run_licenses" '^(Cargo.lock|script/.*licenses)' -qP - check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)' -qvP + check_pattern "run_licenses" '^(Cargo\.lock$|script/.*licenses|(?:.*/)?LICENSE[^/]*$)' -qP + check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|[.]wezel/|extensions/)' -qvP # Detect changed extension directories (excluding extensions/workflows) CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true) # Filter out deleted extensions @@ -171,7 +171,7 @@ jobs: - name: run_tests::check_style::check_for_typos uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 with: - config: ./typos.toml + config: ./.config/typos.toml - name: run_tests::fetch_ts_query_ls run: gh release download v3.15.1 --repo ribru17/ts_query_ls --pattern ts_query_ls-x86_64-unknown-linux-gnu.tar.gz --dir "$GITHUB_WORKSPACE" env: @@ -189,6 +189,7 @@ jobs: clippy_windows: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: self-32vcpu-windows-2022 steps: @@ -219,6 +220,7 @@ jobs: clippy_linux: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' runs-on: namespace-profile-16x32-ubuntu-2204 env: @@ -261,6 +263,7 @@ jobs: clippy_mac: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-mac-large steps: @@ -292,6 +295,7 @@ jobs: clippy_mac_x86_64: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-mac-large steps: @@ -325,6 +329,7 @@ jobs: run_tests_windows: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: self-32vcpu-windows-2022 steps: @@ -369,6 +374,7 @@ jobs: run_tests_linux: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-16x32-ubuntu-2204 env: @@ -435,8 +441,11 @@ jobs: run_tests_mac: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-mac-large + env: + RUST_LIB_BACKTRACE: '0' steps: - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd @@ -484,8 +493,9 @@ jobs: miri_scheduler: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' - runs-on: namespace-profile-16x32-ubuntu-2204 + runs-on: namespace-profile-4x8-ubuntu-2204 steps: - name: steps::harden_runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 @@ -505,9 +515,11 @@ jobs: cache: rust path: ~/.rustup - name: run_tests::miri_scheduler::install_miri - run: rustup toolchain install nightly --profile minimal --component miri --component rust-src + run: rustup toolchain install nightly-2026-09-09 --profile minimal --component miri --component rust-src + - name: run_tests::miri_scheduler::clean_miri + run: cargo +nightly-2026-09-09 miri clean - name: run_tests::miri_scheduler::run_scheduler_tests_under_miri - run: cargo +nightly -q miri test -p scheduler + run: cargo +nightly-2026-09-09 -q miri test -p scheduler - name: steps::cleanup_cargo_config if: always() run: | @@ -516,6 +528,7 @@ jobs: doctests: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-16x32-ubuntu-2204 env: @@ -564,6 +577,7 @@ jobs: check_workspace_binaries: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-8x16-ubuntu-2204 env: @@ -612,6 +626,7 @@ jobs: build_visual_tests_binary: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-mac-large steps: @@ -637,6 +652,7 @@ jobs: check_wasm: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-8x16-ubuntu-2204 steps: @@ -681,7 +697,8 @@ jobs: check_dependencies: needs: - orchestrate - if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' + - check_style + if: needs.orchestrate.outputs.run_tests == 'true' runs-on: namespace-profile-2x4-ubuntu-2404 env: CC: clang @@ -705,7 +722,7 @@ jobs: with: tool: cargo-shear@1.13.4 - name: run_tests::check_dependencies::run_cargo_shear - run: cargo shear --locked --deny-warnings + run: cargo shear --locked --deny-warnings --check-test-targets - name: run_tests::check_dependencies::check_cargo_lock run: cargo update --locked --workspace - name: run_tests::check_dependencies::check_crate_graph @@ -719,6 +736,7 @@ jobs: check_docs: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_docs == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-16x32-ubuntu-2204 env: @@ -753,7 +771,7 @@ jobs: - name: deploy_docs::lychee_link_check uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 with: - args: --no-progress --exclude '^http' './docs/src/**/*' + args: --config .config/lychee.toml --no-progress --exclude '^http' './docs/src/**/*' fail: true jobSummary: false - name: deploy_docs::install_mdbook @@ -770,13 +788,14 @@ jobs: - name: deploy_docs::lychee_link_check uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 with: - args: --no-progress --exclude '^http' 'target/deploy/docs' + args: --config .config/lychee.toml --no-progress --exclude '^http' 'target/deploy/docs' fail: true jobSummary: false timeout-minutes: 60 check_licenses: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_licenses == 'true' && github.event_name != 'merge_group' runs-on: namespace-profile-2x4-ubuntu-2404 steps: @@ -800,6 +819,7 @@ jobs: check_scripts: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_action_checks == 'true' runs-on: namespace-profile-8x16-ubuntu-2204 steps: @@ -840,11 +860,121 @@ jobs: min-severity: high version: latest timeout-minutes: 60 + check_remote_server_linux_x86_64: + needs: + - orchestrate + - check_style + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-8x16-ubuntu-2204 + env: + CC: clang + CXX: clang++ + CC_x86_64_unknown_linux_musl: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS: -C target-feature=+crt-static + steps: + - name: steps::harden_runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 + with: + egress-policy: audit + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 + with: + cache: rust + path: ~/.rustup + - name: steps::setup_linux + run: ./script/linux + - name: run_tests::check_remote_server + run: rustup target add x86_64-unknown-linux-musl + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed + - name: run_tests::check_remote_server + run: cargo --config .cargo/ci-config.toml check --locked --release --package remote_server --target x86_64-unknown-linux-musl + - name: steps::show_sccache_stats + run: sccache --show-stats || true + timeout-minutes: 60 + check_remote_server_mac_aarch64: + needs: + - orchestrate + - check_style + if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' + runs-on: namespace-profile-mac-large + steps: + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 + with: + cache: rust + path: ~/.rustup + - name: run_tests::check_remote_server + run: rustup target add aarch64-apple-darwin + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed + - name: run_tests::check_remote_server + run: cargo --config .cargo/ci-config.toml check --locked --release --package remote_server --target aarch64-apple-darwin + - name: steps::show_sccache_stats + run: sccache --show-stats || true + timeout-minutes: 60 + check_remote_server_windows_x86_64: + needs: + - orchestrate + - check_style + if: needs.orchestrate.outputs.run_tests == 'true' && github.event_name != 'merge_group' + runs-on: self-32vcpu-windows-2022 + steps: + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + - name: run_tests::check_remote_server + run: rustup target add x86_64-pc-windows-msvc + shell: pwsh + - name: steps::setup_sccache + run: ./script/setup-sccache.ps1 + shell: pwsh + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed + - name: run_tests::check_remote_server + run: | + $hostArchitecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) { + "X64" { "amd64" } + "Arm64" { "arm64" } + default { throw "Unsupported architecture" } + } + Push-Location + & "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -HostArch $hostArchitecture + Pop-Location + cargo --config .cargo/ci-config.toml check --locked --release --package remote_server --target x86_64-pc-windows-msvc + shell: pwsh + - name: steps::show_sccache_stats + run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0 + shell: pwsh + timeout-minutes: 60 check_postgres_and_protobuf_migrations: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.run_tests == 'true' - runs-on: namespace-profile-16x32-ubuntu-2204 + runs-on: namespace-profile-4x8-ubuntu-2204 env: GIT_AUTHOR_NAME: Protobuf Action GIT_AUTHOR_EMAIL: ci@zed.dev @@ -888,6 +1018,7 @@ jobs: extension_tests: needs: - orchestrate + - check_style if: needs.orchestrate.outputs.changed_extensions != '[]' permissions: contents: read @@ -919,6 +1050,9 @@ jobs: - check_docs - check_licenses - check_scripts + - check_remote_server_linux_x86_64 + - check_remote_server_mac_aarch64 + - check_remote_server_windows_x86_64 - extension_tests if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && always() runs-on: namespace-profile-2x4-ubuntu-2404 @@ -951,6 +1085,9 @@ jobs: check_result "check_docs" "$RESULT_CHECK_DOCS" check_result "check_licenses" "$RESULT_CHECK_LICENSES" check_result "check_scripts" "$RESULT_CHECK_SCRIPTS" + check_result "check_remote_server_linux_x86_64" "$RESULT_CHECK_REMOTE_SERVER_LINUX_X86_64" + check_result "check_remote_server_mac_aarch64" "$RESULT_CHECK_REMOTE_SERVER_MAC_AARCH64" + check_result "check_remote_server_windows_x86_64" "$RESULT_CHECK_REMOTE_SERVER_WINDOWS_X86_64" check_result "extension_tests" "$RESULT_EXTENSION_TESTS" exit $EXIT_CODE @@ -973,6 +1110,9 @@ jobs: RESULT_CHECK_DOCS: ${{ needs.check_docs.result }} RESULT_CHECK_LICENSES: ${{ needs.check_licenses.result }} RESULT_CHECK_SCRIPTS: ${{ needs.check_scripts.result }} + RESULT_CHECK_REMOTE_SERVER_LINUX_X86_64: ${{ needs.check_remote_server_linux_x86_64.result }} + RESULT_CHECK_REMOTE_SERVER_MAC_AARCH64: ${{ needs.check_remote_server_mac_aarch64.result }} + RESULT_CHECK_REMOTE_SERVER_WINDOWS_X86_64: ${{ needs.check_remote_server_windows_x86_64.result }} RESULT_EXTENSION_TESTS: ${{ needs.extension_tests.result }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} diff --git a/.github/workflows/track_duplicate_bot_effectiveness.yml b/.github/workflows/track_duplicate_bot_effectiveness.yml index 4ddf23e8bb..b7c5ae146a 100644 --- a/.github/workflows/track_duplicate_bot_effectiveness.yml +++ b/.github/workflows/track_duplicate_bot_effectiveness.yml @@ -25,7 +25,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-track-duplicate-bot-effectiveness.py + sparse-checkout: | + script/github-track-duplicate-bot-effectiveness.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Get github app token @@ -68,7 +70,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-track-duplicate-bot-effectiveness.py + sparse-checkout: | + script/github-track-duplicate-bot-effectiveness.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Get github app token diff --git a/.github/workflows/triage_queue_board.yml b/.github/workflows/triage_queue_board.yml index db80b4a22b..2898283baa 100644 --- a/.github/workflows/triage_queue_board.yml +++ b/.github/workflows/triage_queue_board.yml @@ -42,7 +42,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-triage-queue-board.py + sparse-checkout: | + script/github-triage-queue-board.py + script/github_helpers.py sparse-checkout-cone-mode: false persist-credentials: false diff --git a/.github/workflows/wezel.yml b/.github/workflows/wezel.yml new file mode 100644 index 0000000000..a0535b810b --- /dev/null +++ b/.github/workflows/wezel.yml @@ -0,0 +1,43 @@ +name: Wezel assigned run + +on: + workflow_dispatch: + inputs: + run_id: + description: Fiflok run ID + required: true + type: string + experiment_name: + description: Experiment to run + required: true + type: string + commit_sha: + description: Assigned commit + required: true + type: string + project_dir: + description: Directory containing the Wezel project + required: true + type: string + +permissions: + contents: read + +jobs: + run: + runs-on: "ubuntu-latest" + concurrency: + group: wezel-run-${{ inputs.run_id }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.commit_sha }} + - name: Install Linux build dependencies + run: ./script/linux + - uses: wezel-build/gh-action@49be2cba2eaa12f1b99ae1c3ee9af3c815cb3125 # v1.0.1 + with: + token: ${{ secrets.WEZEL_RUNNER_TOKEN }} + run-id: ${{ inputs.run_id }} + experiment-name: ${{ inputs.experiment_name }} + project-dir: ${{ inputs.project_dir }} diff --git a/.wezel/config.toml b/.wezel/config.toml index ffe52f025c..e62e1e3a5d 100644 --- a/.wezel/config.toml +++ b/.wezel/config.toml @@ -11,3 +11,9 @@ targets = [ [tools.foragers.cargo] github = "wezel-build/forager_cargo" + +[tools.foragers.exec] +github = "wezel-build/wezel_exec" + +[tools.foragers.filesize] +github = "wezel-build/wezel_filesize" diff --git a/.wezel/experiments/binary-size/experiment.toml b/.wezel/experiments/binary-size/experiment.toml new file mode 100644 index 0000000000..971fc50845 --- /dev/null +++ b/.wezel/experiments/binary-size/experiment.toml @@ -0,0 +1,19 @@ +#:schema ../../schema.json + +description = "Measures stripped and unstripped release binary size" + +[step.cargo.build-release] +command = "build" +build_target = ["zed"] +profile = "release" + +[step.filesize.measure-unstripped] +glob = "target/release/zed" +summary.unstripped-binary-size = { outcome = "target/release/zed" } + +[step.exec.strip-copy] +cmd = "cp target/release/zed target/release/zed-stripped && strip target/release/zed-stripped" + +[step.filesize.measure-stripped] +glob = "target/release/zed-stripped" +summary.stripped-binary-size = { outcome = "target/release/zed-stripped" } diff --git a/.wezel/experiments/incremental-editor/editor-doc-comment.patch b/.wezel/experiments/incremental-editor/editor-doc-comment.patch deleted file mode 100644 index 42839fdf71..0000000000 --- a/.wezel/experiments/incremental-editor/editor-doc-comment.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs -index 31ddc4b5c4..cd8e8e77e2 100644 ---- a/crates/editor/src/editor.rs -+++ b/crates/editor/src/editor.rs -@@ -11,6 +11,7 @@ - //! All other submodules and structs are mostly concerned with holding editor data about the way it displays current buffer region(s). - //! - //! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides its behavior. -+//! - pub mod actions; - pub mod blink_manager; - mod bracket_colorization; diff --git a/.wezel/experiments/incremental-editor/experiment.toml b/.wezel/experiments/incremental-editor/experiment.toml deleted file mode 100644 index 1d664fa18f..0000000000 --- a/.wezel/experiments/incremental-editor/experiment.toml +++ /dev/null @@ -1,23 +0,0 @@ -#:schema ../../schema.json - -description = "Measures rebuild time after a trivial edit to the editor crate" - -# `zed` rather than `workspace`: it pulls in the whole editor -> ... -> binary -# chain that a developer actually waits on, without also building collab, the -# benchmarks and the other workspace members that no editor edit can affect. - -# Prime the build cache off a clean build, so the measured step below pays only -# for editor and its dependents. -[step.cargo.prime-cache] -command = "build" -build_target = ["zed"] - -# The patch only appends an empty `//!` line to the crate-root doc comment, so -# nothing about editor's public surface changes. What's left is the floor cost -# of touching the crate at all: rustc's incremental reuse for editor itself, -# plus the relink of every crate downstream of it. -[step.cargo.editor-doc-comment] -apply-diff = true -command = "build" -build_target = ["zed"] -summary.incremental-editor-build = { outcome = "time_ms", aggregation = "min", samples = 5 } diff --git a/.wezel/schema.json b/.wezel/schema.json index e95c3d9e9d..58e4d5081f 100644 --- a/.wezel/schema.json +++ b/.wezel/schema.json @@ -165,7 +165,7 @@ { "properties": { "outcome": { - "description": "**`time_ms`** — cargo's own reported build duration (the `Finished … in ` line), in milliseconds. Excludes process startup and dependency resolution.", + "description": "**`time_ms`** — how long `cargo ` took, in milliseconds.", "type": "string" } } @@ -192,6 +192,121 @@ "command" ], "type": "object" + }, + "Step_exec": { + "additionalProperties": false, + "description": "Body of a `[step..]` table. The tool name is recovered from the outer key, not a field; flatten-collected `rest` is forwarded to the forager plugin via `FORAGER_INPUTS` as JSON.", + "properties": { + "apply-diff": { + "anyOf": [ + { + "$ref": "#/definitions/DiffField" + }, + { + "type": "null" + } + ], + "description": "Apply a patch before running this step. `true` uses `.patch`; a string overrides the filename." + }, + "cmd": { + "description": "Shell command to run.", + "type": "string" + }, + "cwd": { + "description": "Working directory override.", + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Extra environment variables.", + "type": "object" + }, + "summary": { + "additionalProperties": { + "allOf": [ + { + "$ref": "#/definitions/EmbeddedSummaryToml" + }, + { + "properties": { + "outcome": { + "description": "Every successful command emits these outcomes on Linux and macOS. No root access\nor external profiling tool is required.\n\n| Outcome | Unit | Meaning |\n| --- | --- | --- |\n| `wall_time_ms` | milliseconds | Elapsed time from immediately before command launch until its exit is collected, including waiting. |\n| `user_time_ms` | milliseconds | CPU time spent executing in user mode. |\n| `system_time_ms` | milliseconds | CPU time spent executing in kernel mode. |\n| `max_rss_bytes` | bytes | Largest individual process peak resident memory reported by the OS. |\n| `minor_page_faults` | count | Page faults serviced without I/O. |\n| `major_page_faults` | count | Page faults requiring I/O. |\n| `voluntary_context_switches` | count | Context switches caused by yielding or blocking. |\n| `involuntary_context_switches` | count | Context switches caused by preemption. |\n\nTime values retain fractional milliseconds. Memory and count values are integers.\nAll outcomes have unit metadata, use the default lower-is-better direction, and\nhave no tags. Fault and context-switch counts are diagnostic signals; a reduction\nalone does not establish that a build improved.\n\nCPU time and resource counters include the shell and descendants whose usage is\npropagated by their parents waiting for them. Commands must wait for their work to\nfinish; detached processes and work performed by an existing build daemon are not\nfully accounted for. CPU time can exceed wall time when work runs in parallel.\n\n`max_rss_bytes` is **not the combined peak memory of a parallel build**. It is the\nlargest individual peak reported through the process hierarchy. OS accounting\nsemantics differ, so compare measurements on the same platform and testbed.\n\nA command that cannot start, exits unsuccessfully, or is terminated by a signal\nfails the step and produces no outcome report.\n", + "type": "string" + } + } + } + ] + }, + "description": "Summaries emitted by this step, keyed by summary name.", + "type": "object" + } + }, + "required": [ + "cmd" + ], + "type": "object" + }, + "Step_filesize": { + "additionalProperties": false, + "description": "Body of a `[step..]` table. The tool name is recovered from the outer key, not a field; flatten-collected `rest` is forwarded to the forager plugin via `FORAGER_INPUTS` as JSON.", + "properties": { + "apply-diff": { + "anyOf": [ + { + "$ref": "#/definitions/DiffField" + }, + { + "type": "null" + } + ], + "description": "Apply a patch before running this step. `true` uses `.patch`; a string overrides the filename." + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "glob": { + "description": "Glob pattern, evaluated against the experiment workspace cwd.", + "type": "string" + }, + "summary": { + "additionalProperties": { + "allOf": [ + { + "$ref": "#/definitions/EmbeddedSummaryToml" + }, + { + "properties": { + "outcome": { + "description": "One outcome per matched file. The outcome name is the file's path (relative to cwd); the value is its size in bytes.", + "type": "string" + } + } + } + ] + }, + "description": "Summaries emitted by this step, keyed by summary name.", + "type": "object" + } + }, + "required": [ + "glob" + ], + "type": "object" } }, "description": "Top-level shape of `.wezel/experiments//experiment.toml`.\n\nSteps live under `[step..]`. The outer map is keyed by tool name and the inner by step name; bodies are wrapped in `toml::Spanned` so the parser can recover file-line order across reopened tables.", @@ -211,7 +326,21 @@ "additionalProperties": { "$ref": "#/definitions/Step_cargo" }, - "description": "Runs `cargo ` and records the build time cargo reports", + "description": "Runs `cargo ` and records wall-clock time", + "type": "object" + }, + "exec": { + "additionalProperties": { + "$ref": "#/definitions/Step_exec" + }, + "description": "Executes a shell command and measures time and resource usage", + "type": "object" + }, + "filesize": { + "additionalProperties": { + "$ref": "#/definitions/Step_filesize" + }, + "description": "Reports byte size of every file matching a glob", "type": "object" } }, diff --git a/.wezel/wezel.lock b/.wezel/wezel.lock index c4c1d9ed27..1a6c044c69 100644 --- a/.wezel/wezel.lock +++ b/.wezel/wezel.lock @@ -5,6 +5,27 @@ github = "wezel-build/forager_cargo" tag = "nightly-2026-07-17-dd1c13f" [tools.foragers.cargo.assets] +aarch64-apple-darwin = "sha256:6ab37a321c528404f5fc998c55ac7b499e8d8dd8d24c807fe11ab9ec6885f071" aarch64-unknown-linux-gnu = "sha256:4162756b5268d29046242a1f29d41649e07ef024c72a690914bff61af82dcee1" x86_64-apple-darwin = "sha256:263224ed47256fabd71a02e638fccd768cda4ff7023f34cc35bd520edcee0162" x86_64-unknown-linux-gnu = "sha256:18092f74eabef4e0774d14ac3ca967505b6d5ccf6fd2a1f1ddef79d8317b0722" + +[tools.foragers.exec] +github = "wezel-build/wezel_exec" +tag = "nightly-2026-09-13-f96d366" + +[tools.foragers.exec.assets] +aarch64-apple-darwin = "sha256:7114aeece8eabecf36090169913bbb939be0034e8ead187fbdedadca9cdc31a7" +aarch64-unknown-linux-gnu = "sha256:c4e10f11b36ad70817aa1897e072185f624cce248cd560b8f6f7884d85c0edb5" +x86_64-apple-darwin = "sha256:7b284018b759d8a07c3143bbbf015de39baa2ac54aecfe0a16e18eb47eb11c20" +x86_64-unknown-linux-gnu = "sha256:292709a40dad042d2c9f7054a01c48e8d06514e1713d20e372980f980936ebee" + +[tools.foragers.filesize] +github = "wezel-build/wezel_filesize" +tag = "nightly-2026-09-13-b72395f" + +[tools.foragers.filesize.assets] +aarch64-apple-darwin = "sha256:7dcf99b47444b06217aa9b21b570068181e8d5983b071ef82d4ff7c50568d789" +aarch64-unknown-linux-gnu = "sha256:cacc578d36333b1f64b057b5393663b9e096fbcb09ec3e3a985fcbd0b206fb5a" +x86_64-apple-darwin = "sha256:2f1c0a4b3346d59032fc1e589c00e5c8fb5f4230a732eaf7936fecd40b57e5e4" +x86_64-unknown-linux-gnu = "sha256:a42a28956781548936993d7518db5881c19dfdf20177d359f21f5dc9cad6f91e" diff --git a/.zed/settings.json b/.zed/settings.json index 521cf786ab..5a19c3c318 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,5 +1,8 @@ { "languages": { + "Docker Compose": { + "tab_size": 2, + }, "Markdown": { "tab_size": 2, "formatter": "prettier", @@ -26,6 +29,10 @@ "tab_size": 2, "formatter": "prettier", }, + "TypeScript": { + "tab_size": 2, + "formatter": "prettier", + }, "CSS": { "tab_size": 2, "formatter": "prettier", @@ -56,17 +63,6 @@ "formatter": "auto", "remove_trailing_whitespace_on_save": true, "ensure_final_newline_on_save": true, - "file_scan_exclusions": [ - "crates/agent/src/tools/evals/fixtures", - "**/.git", - "**/.svn", - "**/.hg", - "**/.jj", - "**/CVS", - "**/.DS_Store", - "**/Thumbs.db", - "**/.classpath", - "**/.settings", - ], + "file_scan_exclusions": ["...", "crates/agent/src/tools/evals/fixtures"], "read_only_files": ["**/.rustup/**", "**/.cargo/registry/**", "**/.cargo/git/**", "target/**/*.rs", "**/*.lock"], } diff --git a/Cargo.lock b/Cargo.lock index 93e9750ee8..e601b4d31c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,12 +14,12 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842fd8203e6dfcf531d24f5bac792088edfba7d6b35844fead191603fb32a260" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", "atspi-common", "phf 0.13.1", "serde", @@ -28,19 +28,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" dependencies = [ "accesskit", "hashbrown 0.16.1", @@ -48,12 +38,12 @@ dependencies = [ [[package]] name = "accesskit_macos" -version = "0.26.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534bc3fdc89a64a1db3c46b33c198fde2b7c3c7d094e5809c8c8bf2970c18243" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", "hashbrown 0.16.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -62,9 +52,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90e549dd7c6562b6a2ea807b44726e6241707db054a817dc4c7e2b8d3b39bfac" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -80,12 +70,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.33.1" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" dependencies = [ "accesskit", - "accesskit_consumer 0.37.0", + "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", "windows 0.62.2", @@ -98,6 +88,7 @@ version = "0.1.0" dependencies = [ "action_log", "agent-client-protocol", + "agent_settings", "anyhow", "async-channel 2.5.0", "base64 0.22.1", @@ -107,7 +98,7 @@ dependencies = [ "env_logger 0.11.8", "feature_flags", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_proxy", "image", @@ -170,7 +161,7 @@ dependencies = [ "collections", "ctor", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "indoc", @@ -197,7 +188,7 @@ dependencies = [ "editor", "extension_host", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "language", "project", @@ -232,6 +223,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aes" version = "0.8.4" @@ -271,7 +268,7 @@ dependencies = [ "eval_utils", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "gpui_tokio", @@ -293,7 +290,7 @@ dependencies = [ "project", "prompt_store", "proptest", - "quick-xml 0.38.3", + "quick-xml 0.41.0", "rand 0.9.4", "regex", "release_channel", @@ -324,21 +321,21 @@ dependencies = [ "web_search", "zed_env_vars", "zlog", - "zstd", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] name = "agent-client-protocol" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d87bc7769eba641753ba5dc52f73ec3765d51022c6753bf040967125ddc86a8" +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", "async-io", "async-process", "blocking", - "futures 0.3.32", + "futures 0.3.34", "futures-concurrency", "rustc-hash 2.1.1", "rustix", @@ -353,9 +350,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de" dependencies = [ "quote", "syn 3.0.3", @@ -363,9 +360,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "1.5.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" dependencies = [ "anyhow", "derive_more", @@ -392,7 +389,7 @@ dependencies = [ "env_logger 0.11.8", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "gpui", "gpui_tokio", @@ -426,7 +423,7 @@ dependencies = [ "collections", "convert_case 0.11.0", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "language_model", "log", @@ -449,7 +446,7 @@ dependencies = [ "base64 0.22.1", "const_format", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "paths", "serde", @@ -491,7 +488,7 @@ dependencies = [ "feature_flags", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "git", "git_ui_core", @@ -516,7 +513,7 @@ dependencies = [ "menu", "multi_buffer", "notifications", - "ordered-float 2.10.1", + "ordered-float 5.5.0", "parking_lot", "paths", "picker", @@ -789,7 +786,7 @@ dependencies = [ "anyhow", "chrono", "collections", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "log", @@ -906,9 +903,9 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.13.2" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0848bedd08067dca1c02c31cbb371a94ad4f2f8a61a82f2c43d96ec36a395244" +checksum = "0b09507a218cf6eb4ab0659883e54880cea3984e3dbaa4989b6cda3f8f8a97a5" dependencies = [ "enumflags2", "futures-channel", @@ -927,7 +924,7 @@ name = "askpass" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "gpui", "log", "net", @@ -935,7 +932,7 @@ dependencies = [ "tempfile", "util", "which", - "windows 0.61.3", + "windows 0.62.2", "zeroize", ] @@ -1114,7 +1111,7 @@ name = "async-pipe" version = "0.1.3" source = "git+https://github.com/zed-industries/async-pipe-rs?rev=82d00a04211cf4e1236029aa03e6b6ce2a74c553#82d00a04211cf4e1236029aa03e6b6ce2a74c553" dependencies = [ - "futures 0.3.32", + "futures 0.3.34", "log", ] @@ -1258,7 +1255,7 @@ dependencies = [ "pin-project-lite", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite 0.28.0", ] @@ -1385,8 +1382,8 @@ dependencies = [ "clock", "ctor", "db", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "gpui", "http_client", "log", @@ -1414,7 +1411,7 @@ dependencies = [ "scopeguard", "simplelog", "tempfile", - "windows 0.61.3", + "windows 0.62.2", "windows_resources", ] @@ -1848,23 +1845,17 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", "h2 0.4.12", - "http 0.2.12", "http 1.3.1", - "http-body 0.4.6", - "hyper 0.14.32", "hyper 1.7.0", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.9", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower 0.5.2", "tracing", ] @@ -2106,7 +2097,7 @@ dependencies = [ "anyhow", "aws-sdk-bedrockruntime", "aws-smithy-types", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -2131,7 +2122,7 @@ dependencies = [ "assets", "criterion", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "indoc", @@ -2187,26 +2178,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.71.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.13.1", - "cexpr", - "clang-sys", - "itertools 0.11.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex 1.3.0", - "syn 2.0.117", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -2217,6 +2188,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.11.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -2662,7 +2635,7 @@ dependencies = [ "collections", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_tokio", "language", @@ -2683,7 +2656,7 @@ name = "call_hierarchy" version = "0.1.0" dependencies = [ "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "language", @@ -2730,9 +2703,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.1" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -2952,7 +2925,7 @@ dependencies = [ "client", "clock", "collections", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language", @@ -3139,7 +3112,7 @@ dependencies = [ "tempfile", "util", "walkdir", - "windows 0.61.3", + "windows 0.62.2", "windows_resources", ] @@ -3160,7 +3133,7 @@ dependencies = [ "derive_more", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_tokio", "http_client", @@ -3191,10 +3164,10 @@ dependencies = [ "tiny_http", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.4", + "tokio-rustls", "url", "util", - "windows 0.61.3", + "windows 0.62.2", "worktree", "zed_credentials_provider", ] @@ -3215,8 +3188,8 @@ dependencies = [ "anyhow", "async-lock", "cloud_api_types", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "gpui", "gpui_tokio", "http_client", @@ -3350,7 +3323,7 @@ dependencies = [ "anyhow", "edit_prediction", "edit_prediction_types", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "icons", @@ -3397,7 +3370,7 @@ dependencies = [ "extension", "file_finder", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_hosting_providers", "git_ui", @@ -3472,7 +3445,7 @@ dependencies = [ "collections", "db", "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "livekit_client", @@ -3585,7 +3558,7 @@ dependencies = [ "anyhow", "async-trait", "derive_more", - "futures 0.3.32", + "futures 0.3.34", "indoc", "itertools 0.14.0", "jsonwebtoken", @@ -3753,8 +3726,8 @@ dependencies = [ "async-trait", "base64 0.22.1", "collections", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "gpui", "http_client", "log", @@ -3803,7 +3776,7 @@ dependencies = [ "edit_prediction_types", "editor", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "icons", "indoc", @@ -3835,7 +3808,7 @@ dependencies = [ "anyhow", "collections", "credentials_provider", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language_model", @@ -4050,7 +4023,7 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" dependencies = [ - "bindgen 0.72.1", + "bindgen", ] [[package]] @@ -4318,8 +4291,8 @@ dependencies = [ "serde", "serde_json", "system_specs", - "windows 0.61.3", - "zstd", + "windows 0.62.2", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] @@ -4632,7 +4605,7 @@ dependencies = [ "collections", "dap-types", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language", @@ -4673,7 +4646,7 @@ dependencies = [ "dap", "dotenvy", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "json_dotpath", @@ -4792,6 +4765,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + [[package]] name = "dashmap" version = "6.1.0" @@ -4854,6 +4833,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "debug-ignore" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe7ed1d93f4553003e20b629abe9085e1e81b1429520f897f8f8860bc6dfc21" + [[package]] name = "debug_adapter_extension" version = "0.1.0" @@ -4876,7 +4861,7 @@ dependencies = [ "anyhow", "dap", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "project", "serde_json", @@ -4901,7 +4886,7 @@ dependencies = [ "editor", "feature_flags", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "hex", @@ -4923,7 +4908,7 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", - "sysinfo 0.37.2", + "sysinfo 0.39.6", "task", "tasks_ui", "terminal", @@ -4955,7 +4940,7 @@ name = "deepseek" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -5052,7 +5037,7 @@ dependencies = [ "async-trait", "env_logger 0.11.8", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http 1.3.1", "http_client", @@ -5085,7 +5070,7 @@ dependencies = [ "collections", "ctor", "editor", - "futures-lite 1.13.0", + "futures-lite 2.6.1", "gpui", "indoc", "itertools 0.14.0", @@ -5385,7 +5370,7 @@ dependencies = [ "edit_prediction_types", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "heapless", @@ -5427,7 +5412,7 @@ dependencies = [ "zed_credentials_provider", "zeta_prompt", "zlog", - "zstd", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] @@ -5450,7 +5435,7 @@ dependencies = [ "extension", "flate2", "fs", - "futures 0.3.32", + "futures 0.3.34", "gaoya", "gpui", "gpui_platform", @@ -5501,7 +5486,7 @@ dependencies = [ "clock", "collections", "env_logger 0.11.8", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "language", @@ -5562,7 +5547,7 @@ dependencies = [ "editor", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "language", @@ -5571,7 +5556,6 @@ dependencies = [ "multi_buffer", "paths", "project", - "regex", "settings", "telemetry", "text", @@ -5605,8 +5589,8 @@ dependencies = [ "feature_flags", "file_icons", "fs", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "fuzzy", "git", "gpui", @@ -5621,7 +5605,7 @@ dependencies = [ "markdown", "menu", "multi_buffer", - "ordered-float 2.10.1", + "ordered-float 5.5.0", "parking_lot", "pretty_assertions", "project", @@ -6036,7 +6020,7 @@ dependencies = [ "extension", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "gpui_tokio", @@ -6111,9 +6095,9 @@ dependencies = [ name = "explorer_command_injector" version = "0.1.0" dependencies = [ - "windows 0.61.3", - "windows-core 0.61.2", - "windows-registry 0.5.3", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-registry 0.6.1", ] [[package]] @@ -6147,7 +6131,7 @@ dependencies = [ "collections", "dap", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "heck 0.5.0", "http_client", @@ -6168,8 +6152,8 @@ dependencies = [ "tracing", "url", "util", - "wasm-encoder 0.252.0", - "wasmparser 0.252.0", + "wasm-encoder 0.254.0", + "wasmparser 0.254.0", "which", "ztracing", ] @@ -6184,7 +6168,7 @@ dependencies = [ "env_logger 0.11.8", "extension", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui_platform", "http_client", "language", @@ -6219,7 +6203,7 @@ dependencies = [ "dap", "extension", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_tokio", "http_client", @@ -6250,7 +6234,7 @@ dependencies = [ "tracing", "url", "util", - "wasmparser 0.252.0", + "wasmparser 0.254.0", "wasmtime", "wasmtime-wasi", "zlog", @@ -6274,15 +6258,19 @@ dependencies = [ "fuzzy", "git", "gpui", + "http_client", "language", "log", + "markdown", "num-format", + "paths", "picker", "project", "release_channel", "schemars 1.0.4", "semver", "serde", + "serde_json", "settings", "strum 0.28.0", "telemetry", @@ -6428,7 +6416,7 @@ dependencies = [ "ctor", "editor", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "fuzzy_nucleo", "gpui", @@ -6707,7 +6695,7 @@ dependencies = [ "collections", "dunce", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "ignore", @@ -6732,7 +6720,7 @@ dependencies = [ "trash", "unicode-normalization", "util", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -6800,9 +6788,9 @@ checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -6815,9 +6803,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -6838,15 +6826,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -6866,9 +6854,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -6900,32 +6888,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures 0.1.31", "futures-channel", @@ -6975,7 +6963,7 @@ dependencies = [ "itertools 0.10.5", "num-traits", "rand 0.8.6", - "rand_pcg", + "rand_pcg 0.3.1", "random_choice", "rayon", "seahash", @@ -6988,16 +6976,17 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.7" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", "cfg-if", "libc", "log", "rustversion", - "windows 0.61.3", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -7054,11 +7043,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -7139,7 +7130,7 @@ dependencies = [ "async-trait", "collections", "derive_more", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "itertools 0.14.0", @@ -7172,7 +7163,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "http_client", @@ -7206,8 +7197,8 @@ dependencies = [ "editor", "file_icons", "fs", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "fuzzy", "fuzzy_nucleo", "git", @@ -7222,7 +7213,6 @@ dependencies = [ "menu", "multi_buffer", "notifications", - "panel", "picker", "pretty_assertions", "project", @@ -7237,7 +7227,7 @@ dependencies = [ "settings", "smallvec", "strum 0.28.0", - "sysinfo 0.37.2", + "sysinfo 0.39.6", "task", "telemetry", "terminal", @@ -7268,7 +7258,7 @@ dependencies = [ "db", "editor", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "git", "gpui", @@ -7455,7 +7445,7 @@ name = "google_ai" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "log", @@ -7508,7 +7498,7 @@ dependencies = [ "async-channel 2.5.0", "async-task", "backtrace", - "bindgen 0.71.1", + "bindgen", "bitflags 2.13.1", "chrono", "collections", @@ -7519,7 +7509,7 @@ dependencies = [ "embed-resource", "env_logger 0.11.8", "etagere", - "futures 0.3.32", + "futures 0.3.34", "futures-concurrency", "getrandom 0.3.4", "gpui_macros", @@ -7536,6 +7526,8 @@ dependencies = [ "lyon", "num_cpus", "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-video", "objc2-metal 0.3.2", "parking", "parking_lot", @@ -7563,7 +7555,6 @@ dependencies = [ "sum_tree", "taffy", "thiserror 2.0.17", - "tracing", "ttf-parser", "unicode-segmentation", "url", @@ -7574,10 +7565,9 @@ dependencies = [ "wasm-bindgen", "web-sys", "web-time", - "windows 0.61.3", + "windows 0.62.2", "zed-font-kit", "zed-scap", - "ztracing", ] [[package]] @@ -7588,7 +7578,6 @@ dependencies = [ "block", "cbindgen", "cocoa 0.26.0", - "collections", "core-foundation 0.10.0", "core-video", "derive_more", @@ -7599,6 +7588,7 @@ dependencies = [ "log", "metal", "objc", + "objc2 0.6.3", "parking_lot", ] @@ -7617,7 +7607,7 @@ dependencies = [ "calloop-wayland-source", "collections", "filedescriptor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_util", "gpui_wgpu", @@ -7655,7 +7645,6 @@ dependencies = [ "accesskit_macos", "anyhow", "async-task", - "block", "block2 0.6.2", "cocoa 0.26.0", "collections", @@ -7666,7 +7655,7 @@ dependencies = [ "ctor", "dispatch2", "foreign-types 0.5.0", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_apple", "gpui_util", @@ -7675,12 +7664,13 @@ dependencies = [ "libc", "log", "mach2 0.5.0", - "media", - "metal", "objc", "objc2 0.6.3", "objc2-app-kit 0.3.2", + "objc2-core-graphics", + "objc2-core-media", "objc2-foundation 0.3.2", + "objc2-screen-capture-kit", "objc2-user-notifications", "parking_lot", "pathfinder_geometry", @@ -7708,6 +7698,7 @@ name = "gpui_platform" version = "0.1.0" dependencies = [ "console_error_panic_hook", + "criterion", "gpui", "gpui_linux", "gpui_macos", @@ -7749,7 +7740,7 @@ version = "0.1.0" dependencies = [ "anyhow", "console_error_panic_hook", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_wgpu", "http_client", @@ -7758,6 +7749,10 @@ dependencies = [ "parking_lot", "raw-window-handle", "scheduler", + "smallvec", + "unicode-properties", + "unicode-script", + "unicode-segmentation", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -7803,7 +7798,7 @@ dependencies = [ "collections", "dunce", "etagere", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_util", "image", @@ -7814,10 +7809,10 @@ dependencies = [ "raw-window-handle", "smallvec", "uuid", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics 0.2.0", - "windows-registry 0.5.3", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-numerics 0.3.1", + "windows-registry 0.6.1", "zed-scap", ] @@ -7828,7 +7823,6 @@ dependencies = [ "anyhow", "language_core", "rust-embed", - "toml 0.8.23", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", @@ -7871,6 +7865,39 @@ dependencies = [ "subtle", ] +[[package]] +name = "guppy" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eaa3f2acc0210783d4af5ab8fe019ab07935d8dc0e64324120bbee9b142b7" +dependencies = [ + "ahash 0.8.12", + "camino", + "cargo_metadata 0.23.1", + "cfg-if", + "debug-ignore", + "fixedbitset", + "guppy-workspace-hack", + "indexmap 2.14.0", + "itertools 0.15.0", + "nested", + "once_cell", + "pathdiff", + "petgraph", + "semver", + "serde", + "serde_json", + "smallvec", + "static_assertions", + "target-spec", +] + +[[package]] +name = "guppy-workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92620684d99f750bae383ecb3be3748142d6095760afd5cbcf2261e9a279d780" + [[package]] name = "h2" version = "0.3.27" @@ -8299,7 +8326,7 @@ dependencies = [ "async-tar", "bytes 1.11.1", "derive_more", - "futures 0.3.32", + "futures 0.3.34", "http 1.3.1", "http-body 1.0.1", "log", @@ -8318,7 +8345,7 @@ name = "http_client_tls" version = "0.1.0" dependencies = [ "log", - "rustls 0.23.40", + "rustls", "rustls-platform-verifier", "webpki-roots 1.0.8", ] @@ -8329,7 +8356,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64 0.22.1", - "futures 0.3.32", + "futures 0.3.34", "httparse", "idna", "log", @@ -8409,21 +8436,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -8434,10 +8446,10 @@ dependencies = [ "hyper 1.7.0", "hyper-util", "log", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", ] @@ -8485,7 +8497,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -8783,6 +8795,39 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd 0.13.3", +] + [[package]] name = "indenter" version = "0.3.4" @@ -8888,12 +8933,18 @@ dependencies = [ "anyhow", "command_palette_hooks", "editor", + "fs", + "futures 0.3.34", "fuzzy", "gpui", "language", + "paths", "project", "serde_json", "serde_json_lenient", + "settings", + "snippet_provider", + "theme", "theme_settings", "ui", "util", @@ -9107,10 +9158,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde", + "windows-sys 0.59.0", ] [[package]] @@ -9124,6 +9177,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -9355,7 +9423,7 @@ dependencies = [ "async-trait", "bytes 1.11.1", "chrono", - "futures 0.3.32", + "futures 0.3.34", "serde", "serde_json", "thiserror 2.0.17", @@ -9372,7 +9440,7 @@ dependencies = [ "async-trait", "async-tungstenite", "bytes 1.11.1", - "futures 0.3.32", + "futures 0.3.34", "jupyter-protocol", "serde", "serde_json", @@ -9496,8 +9564,8 @@ dependencies = [ "ec4rs", "encoding_rs", "fs", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "fuzzy_nucleo", "globset", "gpui", @@ -9560,12 +9628,14 @@ dependencies = [ "gpui_shared_string", "gpui_util", "log", + "lru", "parking_lot", "path", "regex", "schemars 1.0.4", "serde", "serde_json", + "smallvec", "strum 0.28.0", "toml 0.8.23", "tree-sitter", @@ -9588,7 +9658,7 @@ dependencies = [ "async-trait", "collections", "extension", - "futures 0.3.32", + "futures 0.3.34", "gpui", "language", "log", @@ -9610,7 +9680,7 @@ dependencies = [ "collections", "credentials_provider", "env_var", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_util", "http_client", @@ -9630,7 +9700,8 @@ dependencies = [ "anyhow", "async-lock", "cloud_llm_client", - "futures 0.3.32", + "collections", + "futures 0.3.34", "gpui_shared_string", "http_client", "log", @@ -9674,7 +9745,7 @@ dependencies = [ "extension_host", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "gpui", "gpui_tokio", @@ -9706,6 +9777,7 @@ dependencies = [ "ui_input", "util", "x_ai", + "x_ai_subscribed", ] [[package]] @@ -9715,7 +9787,7 @@ dependencies = [ "anthropic", "anyhow", "cloud_llm_client", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "gpui", "http_client", @@ -9771,7 +9843,7 @@ dependencies = [ "command_palette_hooks", "edit_prediction", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "itertools 0.14.0", "language", @@ -9784,7 +9856,7 @@ dependencies = [ "semver", "serde_json", "settings", - "sysinfo 0.37.2", + "sysinfo 0.39.6", "telemetry", "theme", "theme_settings", @@ -9808,7 +9880,7 @@ dependencies = [ "chrono", "collections", "fs", - "futures 0.3.32", + "futures 0.3.34", "globset", "gpui", "grammars", @@ -9922,6 +9994,30 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libflate" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + [[package]] name = "libfuzzer-sys" version = "0.4.10" @@ -10119,7 +10215,7 @@ dependencies = [ "sha2", "thiserror 1.0.69", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-tungstenite 0.28.0", "url", ] @@ -10172,13 +10268,12 @@ dependencies = [ "anyhow", "async-trait", "audio", - "cocoa 0.26.0", "collections", "core-foundation 0.10.0", "core-video", "coreaudio-rs 0.12.1", "cpal", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "gpui_tokio", @@ -10189,7 +10284,7 @@ dependencies = [ "livekit_api", "log", "nanoid", - "objc", + "objc2-core-foundation", "parking_lot", "postage", "rodio", @@ -10211,8 +10306,9 @@ name = "llama_cpp" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", + "language_model_core", "schemars 1.0.4", "serde", "serde_json", @@ -10235,8 +10331,9 @@ name = "lmstudio" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", + "language_model_core", "schemars 1.0.4", "serde", "serde_json", @@ -10392,8 +10489,8 @@ dependencies = [ "async-pipe", "collections", "ctor", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "gpui", "gpui_util", "log", @@ -10420,6 +10517,29 @@ dependencies = [ "url", ] +[[package]] +name = "lsp_command_selector" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "futures 0.3.34", + "fuzzy", + "gpui", + "language", + "log", + "lsp", + "menu", + "picker", + "project", + "serde_json", + "settings", + "ui", + "util", + "workspace", + "zlog", +] + [[package]] name = "lsp_locations" version = "0.1.0" @@ -10722,19 +10842,6 @@ dependencies = [ "warp", ] -[[package]] -name = "media" -version = "0.1.0" -dependencies = [ - "anyhow", - "bindgen 0.71.1", - "core-foundation 0.10.0", - "core-video", - "foreign-types 0.5.0", - "metal", - "objc", -] - [[package]] name = "memchr" version = "2.8.3" @@ -10805,7 +10912,7 @@ dependencies = [ "gpui", "mermaid_render", "merman", - "quick-xml 0.38.3", + "quick-xml 0.41.0", "serde_json", "tracing", "usvg", @@ -10938,7 +11045,6 @@ dependencies = [ "streaming-iterator", "tree-sitter", "tree-sitter-json", - "unindent", ] [[package]] @@ -11095,7 +11201,7 @@ name = "mistral" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -11149,7 +11255,7 @@ dependencies = [ "clock", "collections", "ctor", - "futures-lite 1.13.0", + "futures-lite 2.6.1", "gpui", "indoc", "itertools 0.14.0", @@ -11277,6 +11383,12 @@ dependencies = [ "jni-sys 0.3.0", ] +[[package]] +name = "nested" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b420f638f07fe83056b55ea190bb815f609ec5a35e7017884a10f78839c9e" + [[package]] name = "net" version = "0.1.0" @@ -11284,7 +11396,7 @@ dependencies = [ "async-io", "smol", "tempfile", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -11307,9 +11419,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.29.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.13.1", "cfg-if", @@ -11319,15 +11431,12 @@ dependencies = [ ] [[package]] -name = "nix" -version = "0.30.1" +name = "no_std_io2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", + "memchr", ] [[package]] @@ -11340,7 +11449,7 @@ dependencies = [ "async-tar", "async-trait", "chrono", - "futures 0.3.32", + "futures 0.3.34", "http_client", "log", "paths", @@ -11395,7 +11504,7 @@ dependencies = [ "channel", "client", "component", - "futures-lite 1.13.0", + "futures-lite 2.6.1", "gpui", "rpc", "sum_tree", @@ -11428,7 +11537,7 @@ dependencies = [ [[package]] name = "notify" version = "9.0.0-rc.4" -source = "git+https://github.com/zed-industries/notify?rev=0890bbb8ca40a4b5d1f67031698dd7918b37d991#0890bbb8ca40a4b5d1f67031698dd7918b37d991" +source = "git+https://github.com/zed-industries/notify?rev=d842f16b2716bd60f09caf3ae3a894237ab38f54#d842f16b2716bd60f09caf3ae3a894237ab38f54" dependencies = [ "bitflags 2.13.1", "inotify 0.11.0", @@ -11472,7 +11581,7 @@ dependencies = [ [[package]] name = "notify-types" version = "2.1.0" -source = "git+https://github.com/zed-industries/notify?rev=0890bbb8ca40a4b5d1f67031698dd7918b37d991#0890bbb8ca40a4b5d1f67031698dd7918b37d991" +source = "git+https://github.com/zed-industries/notify?rev=d842f16b2716bd60f09caf3ae3a894237ab38f54#d842f16b2716bd60f09caf3ae3a894237ab38f54" dependencies = [ "bitflags 2.13.1", ] @@ -11703,7 +11812,7 @@ version = "0.9.2" source = "git+https://github.com/KillTheMule/nvim-rs?rev=764dd270c642f77f10f3e19d05cc178a6cbe69f3#764dd270c642f77f10f3e19d05cc178a6cbe69f3" dependencies = [ "async-trait", - "futures 0.3.32", + "futures 0.3.34", "log", "rmp", "rmpv", @@ -11716,7 +11825,7 @@ name = "oauth_callback_server" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "log", "tiny_http", "url", @@ -11870,6 +11979,19 @@ dependencies = [ "objc2 0.6.3", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -11892,6 +12014,21 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.3", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + [[package]] name = "objc2-core-services" version = "0.3.2" @@ -11902,6 +12039,19 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -11943,6 +12093,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.3", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -11969,6 +12130,17 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -11995,6 +12167,20 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-screen-capture-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "objc2 0.6.3", + "objc2-core-graphics", + "objc2-core-media", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-user-notifications" version = "0.3.2" @@ -12061,7 +12247,7 @@ dependencies = [ "cfg-if", "chrono", "either", - "futures 0.3.32", + "futures 0.3.34", "futures-core", "futures-util", "getrandom 0.2.16", @@ -12069,7 +12255,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.7.0", - "hyper-rustls 0.27.9", + "hyper-rustls", "hyper-timeout", "hyper-util", "jsonwebtoken", @@ -12094,7 +12280,7 @@ name = "ollama" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -12204,7 +12390,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "log", @@ -12223,7 +12409,7 @@ version = "0.1.0" dependencies = [ "editor", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "picker", @@ -12243,7 +12429,7 @@ name = "open_router" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "open_ai", @@ -12261,7 +12447,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "credentials_provider", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language_model", @@ -12283,7 +12469,7 @@ name = "opencode" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "http_client", "language_model_core", @@ -12367,18 +12553,18 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "2.10.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ "num-traits", ] [[package]] name = "ordered-float" -version = "4.6.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] @@ -12422,7 +12608,7 @@ name = "outline" version = "0.1.0" dependencies = [ "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy_nucleo", "gpui", "indoc", @@ -12448,14 +12634,15 @@ version = "0.1.0" dependencies = [ "anyhow", "async-channel 2.5.0", + "buffer_diff", "collections", "db", "editor", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", + "git", "gpui", - "itertools 0.14.0", "language", "log", "lsp", @@ -12527,15 +12714,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "panel" -version = "0.1.0" -dependencies = [ - "gpui", - "ui", - "workspace", -] - [[package]] name = "parking" version = "2.2.1" @@ -12619,6 +12797,9 @@ name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +dependencies = [ + "camino", +] [[package]] name = "pathfinder_geometry" @@ -12821,7 +13002,7 @@ dependencies = [ [[package]] name = "pet" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "clap", "env_logger 0.10.2", @@ -12864,7 +13045,7 @@ dependencies = [ [[package]] name = "pet-conda" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -12884,7 +13065,7 @@ dependencies = [ [[package]] name = "pet-core" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "clap", "lazy_static", @@ -12899,7 +13080,7 @@ dependencies = [ [[package]] name = "pet-env-var-path" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -12915,7 +13096,7 @@ dependencies = [ [[package]] name = "pet-fs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "glob", "lazy_static", @@ -12927,7 +13108,7 @@ dependencies = [ [[package]] name = "pet-global-virtualenvs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -12940,7 +13121,7 @@ dependencies = [ [[package]] name = "pet-hatch" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "pet-core", @@ -12953,7 +13134,7 @@ dependencies = [ [[package]] name = "pet-homebrew" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -12972,7 +13153,7 @@ dependencies = [ [[package]] name = "pet-jsonrpc" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "log", @@ -12985,7 +13166,7 @@ dependencies = [ [[package]] name = "pet-linux-global-python" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -12998,7 +13179,7 @@ dependencies = [ [[package]] name = "pet-mac-commandlinetools" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13011,7 +13192,7 @@ dependencies = [ [[package]] name = "pet-mac-python-org" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13024,7 +13205,7 @@ dependencies = [ [[package]] name = "pet-mac-xcode" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13037,7 +13218,7 @@ dependencies = [ [[package]] name = "pet-pipenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13052,7 +13233,7 @@ dependencies = [ [[package]] name = "pet-pixi" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13064,7 +13245,7 @@ dependencies = [ [[package]] name = "pet-poetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "base64 0.22.1", "lazy_static", @@ -13085,7 +13266,7 @@ dependencies = [ [[package]] name = "pet-pyenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13103,7 +13284,7 @@ dependencies = [ [[package]] name = "pet-python-utils" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -13120,7 +13301,7 @@ dependencies = [ [[package]] name = "pet-reporter" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "log", @@ -13134,7 +13315,7 @@ dependencies = [ [[package]] name = "pet-telemetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -13149,7 +13330,7 @@ dependencies = [ [[package]] name = "pet-uv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "glob", "log", @@ -13163,7 +13344,7 @@ dependencies = [ [[package]] name = "pet-venv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13175,7 +13356,7 @@ dependencies = [ [[package]] name = "pet-virtualenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13187,7 +13368,7 @@ dependencies = [ [[package]] name = "pet-virtualenvwrapper" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13200,7 +13381,7 @@ dependencies = [ [[package]] name = "pet-windows-registry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13218,7 +13399,7 @@ dependencies = [ [[package]] name = "pet-windows-store" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13234,7 +13415,7 @@ dependencies = [ [[package]] name = "pet-winpython" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13503,20 +13684,20 @@ dependencies = [ "theme", "theme_settings", "ui", - "windows 0.61.3", + "windows 0.62.2", "workspace", "zed_actions", ] [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.3", + "quick-xml 0.41.0", "serde", "time", ] @@ -13663,7 +13844,7 @@ checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" dependencies = [ "atomic", "crossbeam-queue", - "futures 0.3.32", + "futures 0.3.34", "log", "parking_lot", "pin-project", @@ -13793,6 +13974,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -13876,6 +14079,7 @@ dependencies = [ "anyhow", "askpass", "async-channel 2.5.0", + "async-lock", "async-trait", "base64 0.22.1", "buffer_diff", @@ -13890,7 +14094,7 @@ dependencies = [ "extension", "fancy-regex", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "fuzzy_nucleo", "git", @@ -13958,7 +14162,7 @@ dependencies = [ "askpass", "clap", "client", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "http_client", @@ -13984,7 +14188,7 @@ dependencies = [ "editor", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_ui", "git_ui_core", @@ -14021,12 +14225,12 @@ version = "0.1.0" dependencies = [ "anyhow", "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "language", "lsp", - "ordered-float 2.10.1", + "ordered-float 5.5.0", "picker", "picker_preview", "project", @@ -14066,7 +14270,7 @@ dependencies = [ "collections", "db", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "handlebars 4.5.0", "heed", @@ -14250,7 +14454,7 @@ name = "proxy_handshake" version = "0.1.0" dependencies = [ "base64 0.22.1", - "futures 0.3.32", + "futures 0.3.34", "httparse", "percent-encoding", "piper", @@ -14400,15 +14604,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.38.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.39.3" @@ -14440,8 +14635,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls 0.23.40", - "socket2 0.6.3", + "rustls", + "socket2 0.6.5", "thiserror 2.0.17", "tokio", "tracing", @@ -14450,17 +14645,18 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "bytes 1.11.1", - "getrandom 0.3.4", + "getrandom 0.4.1", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg 0.10.2", "ring", "rustc-hash 2.1.1", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.17", @@ -14478,7 +14674,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] @@ -14637,6 +14833,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -14807,7 +15012,7 @@ dependencies = [ "extension", "extension_host", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy_nucleo", "gpui", "http_client", @@ -14817,7 +15022,7 @@ dependencies = [ "menu", "node_runtime", "open_path_prompt", - "ordered-float 2.10.1", + "ordered-float 5.5.0", "paths", "picker", "project", @@ -14978,12 +15183,11 @@ dependencies = [ "base64 0.22.1", "collections", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "log", "parking_lot", "paths", - "prost", "release_channel", "rpc", "schemars 1.0.4", @@ -15008,7 +15212,7 @@ dependencies = [ "askpass", "auto_update", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "log", "markdown", @@ -15048,7 +15252,7 @@ dependencies = [ "extension", "extension_host", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_hosting_providers", "gpui", @@ -15080,7 +15284,7 @@ dependencies = [ "settings", "shellexpand", "smol", - "sysinfo 0.37.2", + "sysinfo 0.39.6", "task", "telemetry", "tempfile", @@ -15092,7 +15296,7 @@ dependencies = [ "util", "uuid", "watch", - "windows 0.61.3", + "windows 0.62.2", "workspace", "worktree", "zlog", @@ -15129,7 +15333,7 @@ dependencies = [ "editor", "feature_flags", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "gpui", "html_to_markdown", "http_client", @@ -15224,14 +15428,14 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.7.0", - "hyper-rustls 0.27.9", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "rustls-pki-types", "serde", @@ -15239,7 +15443,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower 0.5.2", "tower-http 0.6.11", "tower-service", @@ -15255,7 +15459,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes 1.11.1", - "futures 0.3.32", + "futures 0.3.34", "gpui_util", "http_client", "http_client_tls", @@ -15345,6 +15549,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rmp" version = "0.8.14" @@ -15447,7 +15657,7 @@ dependencies = [ "async-tungstenite", "base64 0.22.1", "collections", - "futures 0.3.32", + "futures 0.3.34", "gpui", "parking_lot", "proto", @@ -15460,7 +15670,7 @@ dependencies = [ "tracing", "util", "zlog", - "zstd", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] @@ -15513,7 +15723,7 @@ dependencies = [ "chrono", "data-encoding", "dirs", - "futures 0.3.32", + "futures 0.3.34", "glob", "jupyter-protocol", "serde", @@ -15531,6 +15741,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ + "include-flate", "rust-embed-impl", "rust-embed-utils", "walkdir", @@ -15637,18 +15848,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.40" @@ -15660,7 +15859,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] @@ -15716,10 +15915,10 @@ dependencies = [ "jni 0.22.4", "log", "once_cell", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", + "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -15732,16 +15931,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted 0.9.0", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -15822,12 +16011,12 @@ name = "sandbox" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_proxy", "libc", "log", - "nix 0.29.0", + "nix 0.30.1", "seccompiler", "serde", "serde_json", @@ -15863,7 +16052,7 @@ dependencies = [ "backtrace", "chrono", "flume 0.12.0", - "futures 0.3.32", + "futures 0.3.34", "parking_lot", "rand 0.9.4", "wasm_thread", @@ -15981,16 +16170,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted 0.9.0", -] - [[package]] name = "sdd" version = "4.6.2" @@ -16102,7 +16281,7 @@ dependencies = [ "editor", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "itertools 0.14.0", "language", @@ -16214,9 +16393,9 @@ checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -16224,9 +16403,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -16244,22 +16423,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -16363,9 +16542,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -16373,6 +16552,7 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.0.4", "serde_core", @@ -16383,9 +16563,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -16458,7 +16638,7 @@ dependencies = [ "collections", "ec4rs", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "inventory", @@ -16570,7 +16750,7 @@ dependencies = [ "extension_host", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "heck 0.5.0", @@ -16579,6 +16759,7 @@ dependencies = [ "itertools 0.14.0", "language", "language_model", + "language_models", "log", "menu", "paths", @@ -16918,9 +17099,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" dependencies = [ "serde", ] @@ -17000,7 +17181,7 @@ dependencies = [ "collections", "extension", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "parking_lot", @@ -17042,9 +17223,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -17112,7 +17293,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "futures 0.3.32", + "futures 0.3.34", "indoc", "libsqlite3-sys", "log", @@ -17182,7 +17363,7 @@ dependencies = [ "once_cell", "percent-encoding", "rust_decimal", - "rustls 0.23.40", + "rustls", "serde", "serde_json", "sha2", @@ -17409,7 +17590,7 @@ name = "streaming_diff" version = "0.1.0" dependencies = [ "criterion", - "ordered-float 2.10.1", + "ordered-float 5.5.0", "rand 0.9.4", "rope", "util", @@ -17651,53 +17832,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ "lazy_static", - "symphonia-bundle-flac", - "symphonia-bundle-mp3", - "symphonia-codec-aac", "symphonia-codec-pcm", - "symphonia-codec-vorbis", "symphonia-core", - "symphonia-format-isomp4", - "symphonia-format-ogg", "symphonia-format-riff", "symphonia-metadata", ] -[[package]] -name = "symphonia-bundle-flac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" -dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-bundle-mp3" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-codec-aac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", -] - [[package]] name = "symphonia-codec-pcm" version = "0.5.5" @@ -17708,17 +17848,6 @@ dependencies = [ "symphonia-core", ] -[[package]] -name = "symphonia-codec-vorbis" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" -dependencies = [ - "log", - "symphonia-core", - "symphonia-utils-xiph", -] - [[package]] name = "symphonia-core" version = "0.5.5" @@ -17732,31 +17861,6 @@ dependencies = [ "log", ] -[[package]] -name = "symphonia-format-isomp4" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" -dependencies = [ - "encoding_rs", - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-format-ogg" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" -dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - [[package]] name = "symphonia-format-riff" version = "0.5.5" @@ -17781,16 +17885,6 @@ dependencies = [ "symphonia-core", ] -[[package]] -name = "symphonia-utils-xiph" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" -dependencies = [ - "symphonia-core", - "symphonia-metadata", -] - [[package]] name = "syn" version = "1.0.109" @@ -17893,16 +17987,17 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.37.2" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.61.3", + "objc2-open-directory", + "windows 0.62.2", ] [[package]] @@ -17971,7 +18066,7 @@ dependencies = [ "release_channel", "semver", "serde", - "sysinfo 0.37.2", + "sysinfo 0.39.6", ] [[package]] @@ -18065,13 +18160,24 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "target-spec" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e117cdea67a83bf392bc69fb07cfc37e948e95991eca900c413a2bb1b494807" +dependencies = [ + "cfg-expr", + "guppy-workspace-hack", + "target-lexicon", +] + [[package]] name = "task" version = "0.1.0" dependencies = [ "anyhow", "collections", - "futures 0.3.32", + "futures 0.3.34", "gpui", "hex", "log", @@ -18130,7 +18236,7 @@ dependencies = [ name = "telemetry" version = "0.1.0" dependencies = [ - "futures 0.3.32", + "futures 0.3.34", "serde_json", "telemetry_events", ] @@ -18146,12 +18252,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -18183,8 +18289,8 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "collections", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "gpui", "itertools 0.14.0", "libc", @@ -18197,7 +18303,7 @@ dependencies = [ "schemars 1.0.4", "serde", "settings", - "sysinfo 0.37.2", + "sysinfo 0.39.6", "task", "theme", "theme_settings", @@ -18207,7 +18313,7 @@ dependencies = [ "util", "util_macros", "vte", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -18231,7 +18337,7 @@ dependencies = [ "db", "dirs", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "itertools 0.14.0", "language", @@ -18480,7 +18586,7 @@ dependencies = [ "core-foundation-sys", "sys-locale", "time", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -18599,6 +18705,7 @@ dependencies = [ "smallvec", "telemetry", "theme", + "theme_settings", "ui", "util", "workspace", @@ -18617,7 +18724,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -18654,23 +18761,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls", "tokio", ] @@ -18729,11 +18826,11 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite 0.28.0", ] @@ -18859,7 +18956,7 @@ dependencies = [ "anyhow", "convert_case 0.11.0", "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "language", @@ -19391,7 +19488,7 @@ dependencies = [ "httparse", "log", "rand 0.9.4", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "sha1", "thiserror 2.0.17", @@ -19713,15 +19810,15 @@ dependencies = [ "command-fds", "dirs", "dunce", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "globset", "gpui_util", "itertools 0.14.0", "libc", "log", "mach2 0.5.0", - "nix 0.29.0", + "nix 0.30.1", "path", "percent-encoding", "rand 0.9.4", @@ -19741,7 +19838,7 @@ dependencies = [ "util_macros", "walkdir", "which", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -19860,7 +19957,7 @@ dependencies = [ "db", "editor", "env_logger 0.11.8", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "git_ui", "gpui", @@ -20134,16 +20231,6 @@ dependencies = [ "wasmparser 0.244.0", ] -[[package]] -name = "wasm-encoder" -version = "0.252.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" -dependencies = [ - "leb128fmt", - "wasmparser 0.252.0", -] - [[package]] name = "wasm-encoder" version = "0.254.0" @@ -20231,7 +20318,7 @@ name = "wasm_thread" version = "0.3.3" source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" dependencies = [ - "futures 0.3.32", + "futures 0.3.34", "js-sys", "wasm-bindgen", "web-sys", @@ -20272,19 +20359,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.252.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" -dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.17.1", - "indexmap 2.14.0", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.254.0" @@ -20321,7 +20395,7 @@ dependencies = [ "bumpalo", "cc", "encoding_rs", - "futures 0.3.32", + "futures 0.3.34", "libc", "log", "mach2 0.6.0", @@ -20546,7 +20620,7 @@ dependencies = [ "bytes 1.11.1", "cap-fs-ext", "cap-primitives", - "futures 0.3.32", + "futures 0.3.34", "rand 0.10.2", "rustix", "thiserror 2.0.17", @@ -20567,7 +20641,7 @@ checksum = "6a557bc8a7232cd079f6b423a9bb385f3ff9148824f3c26c07a028b499fab2bf" dependencies = [ "async-trait", "bytes 1.11.1", - "futures 0.3.32", + "futures 0.3.34", "tracing", "wasmtime", ] @@ -20585,8 +20659,9 @@ dependencies = [ name = "watch" version = "0.1.0" dependencies = [ + "arc-swap", "ctor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "parking_lot", "zlog", @@ -20758,7 +20833,7 @@ dependencies = [ "cloud_api_client", "cloud_api_types", "cloud_llm_client", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language_model", @@ -20960,7 +21035,7 @@ dependencies = [ "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", "once_cell", - "ordered-float 4.6.0", + "ordered-float 5.5.0", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -21018,13 +21093,17 @@ dependencies = [ name = "which_key" version = "0.1.0" dependencies = [ + "collections", "command_palette", "gpui", "settings", + "theme", "theme_settings", "ui", "util", + "vim_mode_setting", "workspace", + "zed_actions", ] [[package]] @@ -21422,17 +21501,6 @@ dependencies = [ "windows-targets 0.53.5", ] -[[package]] -name = "windows-registry" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" -dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-registry" version = "0.6.1" @@ -22005,7 +22073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" dependencies = [ "bitflags 2.13.1", - "futures 0.3.32", + "futures 0.3.34", "once_cell", ] @@ -22276,12 +22344,11 @@ dependencies = [ "db", "dirs", "fs", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "git", "gpui", "http_client", - "image", "itertools 0.14.0", "language", "log", @@ -22289,19 +22356,18 @@ dependencies = [ "menu", "node_runtime", "parking_lot", - "paths", "postage", "pretty_assertions", "project", "proptest", "proptest-derive", + "release_channel", "remote", "schemars 1.0.4", "serde", "serde_json", "session", "settings", - "shellexpand", "smallvec", "sqlez", "strum 0.28.0", @@ -22330,8 +22396,8 @@ dependencies = [ "collections", "encoding_rs", "fs", - "futures 0.3.32", - "futures-lite 1.13.0", + "futures 0.3.34", + "futures-lite 2.6.1", "fuzzy", "git", "gpui", @@ -22448,6 +22514,31 @@ dependencies = [ "strum 0.28.0", ] +[[package]] +name = "x_ai_subscribed" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "credentials_provider", + "futures 0.3.34", + "gpui", + "http_client", + "language_model", + "log", + "oauth_callback_server", + "open_ai", + "parking_lot", + "rand 0.9.4", + "serde", + "serde_json", + "sha2", + "smol", + "url", + "util", + "x_ai", +] + [[package]] name = "xattr" version = "1.6.1" @@ -22550,6 +22641,7 @@ dependencies = [ "clap", "compliance", "gh-workflow", + "guppy", "indexmap 2.14.0", "indoc", "itertools 0.14.0", @@ -22561,6 +22653,7 @@ dependencies = [ "tokio", "toml 0.8.23", "toml_edit 0.22.27", + "url", ] [[package]] @@ -22600,7 +22693,7 @@ dependencies = [ "base64 0.22.1", "bytes 1.11.1", "flate2", - "futures 0.3.32", + "futures 0.3.34", "getrandom 0.2.16", "http-body-util", "hyper 1.7.0", @@ -22613,7 +22706,7 @@ dependencies = [ "sha1", "thiserror 2.0.17", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "url", "wasm-bindgen", @@ -22761,7 +22854,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.19.0-Y0" +version = "1.22.0" dependencies = [ "acp_thread", "acp_tools", @@ -22818,7 +22911,7 @@ dependencies = [ "feedback", "file_finder", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_hosting_providers", "git_ui", @@ -22827,6 +22920,7 @@ dependencies = [ "gpui", "gpui_platform", "gpui_tokio", + "hdrhistogram", "http_client", "image", "image_viewer", @@ -22847,6 +22941,7 @@ dependencies = [ "languages", "line_ending_selector", "log", + "lsp_command_selector", "lsp_locations", "markdown", "markdown_preview", @@ -22894,7 +22989,7 @@ dependencies = [ "snippets_ui", "spin 0.10.0", "svg_preview", - "sysinfo 0.37.2", + "sysinfo 0.39.6", "system_specs", "tab_switcher", "tabular_data_preview", @@ -22924,7 +23019,7 @@ dependencies = [ "web_search", "web_search_providers", "which_key", - "windows 0.61.3", + "windows 0.62.2", "windows_resources", "workspace", "zed-reqwest", @@ -22975,7 +23070,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.7.0", - "hyper-rustls 0.27.9", + "hyper-rustls", "hyper-util", "ipnet", "js-sys", @@ -22986,7 +23081,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "rustls-pemfile 2.2.0", "rustls-pki-types", @@ -22996,7 +23091,7 @@ dependencies = [ "sync_wrapper 1.0.2", "system-configuration 0.8.0", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-socks", "tokio-util", "tower 0.5.2", @@ -23059,7 +23154,7 @@ version = "0.1.0" dependencies = [ "anyhow", "credentials_provider", - "futures 0.3.32", + "futures 0.3.34", "gpui", "paths", "release_channel", @@ -23106,21 +23201,21 @@ dependencies = [ [[package]] name = "zed_glsl" -version = "0.2.4" +version = "0.2.5" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_html" -version = "0.3.1" +version = "0.3.2" dependencies = [ "zed_extension_api 0.7.0", ] [[package]] name = "zed_proto" -version = "0.3.3" +version = "0.3.4" dependencies = [ "zed_extension_api 0.7.0", ] @@ -23211,7 +23306,7 @@ dependencies = [ "asynchronous-codec", "bytes 1.11.1", "crossbeam-queue", - "futures 0.3.32", + "futures 0.3.34", "log", "num-traits", "once_cell", @@ -23286,7 +23381,7 @@ dependencies = [ "pbkdf2 0.11.0", "sha1", "time", - "zstd", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] @@ -23322,7 +23417,16 @@ version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe", + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.3.0", ] [[package]] @@ -23335,6 +23439,15 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + [[package]] name = "zstd-sys" version = "2.0.16+zstd.1.5.7" diff --git a/Cargo.toml b/Cargo.toml index 6e4907333e..b1929a0a4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,10 +132,10 @@ members = [ "crates/llama_cpp", "crates/lmstudio", "crates/lsp", + "crates/lsp_command_selector", "crates/lsp_locations", "crates/markdown", "crates/markdown_preview", - "crates/media", "crates/menu", "crates/mermaid_render", "crates/migrator", @@ -155,7 +155,6 @@ members = [ "crates/opencode", "crates/outline", "crates/outline_panel", - "crates/panel", "crates/path", "crates/paths", "crates/picker", @@ -237,6 +236,7 @@ members = [ "crates/worktree", "crates/worktree_benchmarks", "crates/x_ai", + "crates/x_ai_subscribed", "crates/zed", "crates/zed_actions", "crates/zed_credentials_provider", @@ -400,10 +400,10 @@ livekit_client = { path = "crates/livekit_client" } llama_cpp = { path = "crates/llama_cpp" } lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } +lsp_command_selector = { path = "crates/lsp_command_selector" } lsp_locations = { path = "crates/lsp_locations" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } -media = { path = "crates/media" } menu = { path = "crates/menu" } mermaid_render = { path = "crates/mermaid_render" } migrator = { path = "crates/migrator" } @@ -423,7 +423,6 @@ openai_subscribed = { path = "crates/openai_subscribed" } opencode = { path = "crates/opencode" } outline = { path = "crates/outline" } outline_panel = { path = "crates/outline_panel" } -panel = { path = "crates/panel" } path = { path = "crates/path" } paths = { path = "crates/paths" } perf = { path = "tooling/perf" } @@ -445,7 +444,7 @@ remote_connection = { path = "crates/remote_connection" } remote_server = { path = "crates/remote_server" } repl = { path = "crates/repl" } reqwest_client = { path = "crates/reqwest_client" } -rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } +rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", default-features = false, features = ["wav", "playback", "wav_output", "recording", "dither"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } sandbox = { path = "crates/sandbox" } @@ -501,6 +500,7 @@ which_key = { path = "crates/which_key" } workspace = { path = "crates/workspace" } worktree = { path = "crates/worktree" } x_ai = { path = "crates/x_ai" } +x_ai_subscribed = { path = "crates/x_ai_subscribed" } zed_actions = { path = "crates/zed_actions" } zed_credentials_provider = { path = "crates/zed_credentials_provider" } zed_env_vars = { path = "crates/zed_env_vars" } @@ -516,15 +516,17 @@ ztracing_macro = { path = "crates/ztracing_macro" } accesskit = { version = "0.24.0", features = ["enumn"] } accesskit_macos = "0.26.0" -accesskit_unix = "0.21.0" -accesskit_windows = "0.33.1" -agent-client-protocol = { version = "=2.0.0", features = ["unstable"] } +accesskit_unix = "0.22" +accesskit_windows = "0.34" +agent-client-protocol = { version = "=2.2.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" anyhow = "1.0.86" +arc-swap = "1.9.0" ashpd = { version = "0.13", default-features = false, features = [ "async-io", + "inhibit", "notification", "open_uri", "file_chooser", @@ -622,9 +624,9 @@ fancy-regex = "0.19" flume = "0.12" foreign-types = "0.5" fork = "0.4.0" -futures = "0.3.32" +futures = "0.3.34" futures-concurrency = "7.7.1" -futures-lite = "1.13" +futures-lite = "2" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } globset = "0.4" handlebars = "4.3" @@ -686,7 +688,7 @@ minidumper = "0.11" moka = { version = "0.12.10", features = ["sync"] } nanoid = "0.4" nbformat = "1.2.0" -nix = "0.29" +nix = "0.30" notify-rust = "4" nucleo = "0.5" num-format = "0.4.4" @@ -697,13 +699,26 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSApplication", "NSButton", "NSControl", + "NSDraggingItem", "NSGraphics", + "NSImage", + "NSOpenPanel", + "NSPanel", + "NSPasteboard", "NSResponder", + "NSSavePanel", + "NSScreen", + "NSTrackingArea", "NSView", "NSWindow", + "NSWorkspace", "block2", "objc2-core-foundation", ] } +objc2-core-foundation = { version = "=0.3.2", default-features = false } +objc2-core-graphics = { version = "=0.3.2", default-features = false, features = ["CGDirectDisplay"] } +objc2-core-media = { version = "=0.3.2", default-features = false, features = ["CMSampleBuffer", "objc2-core-video"] } +objc2-core-video = { version = "=0.3.2", default-features = false, features = ["CVBuffer", "CVImageBuffer"] } objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "NSArray", "NSAttributedString", @@ -730,9 +745,18 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "objc2-core-foundation", "std", ] } +objc2-screen-capture-kit = { version = "=0.3.2", default-features = false, features = [ + "block2", + "dispatch2", + "objc2-core-graphics", + "objc2-core-media", + "SCShareableContent", + "SCStream", + "std" +] } objc2-user-notifications = "0.3" open = "5.0.0" -ordered-float = "2.1.1" +ordered-float = "5" palette = { version = "0.7.5", default-features = false, features = ["std"] } parking_lot = "0.12.1" parse_int = "0.9" @@ -740,13 +764,13 @@ partial-json-fixer = "0.5.3" pathfinder_geometry = "0.5" pciid-parser = "0.8.0" percent-encoding = "2.3.2" -pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } +pet = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-conda = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-core = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-fs = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-poetry = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-reporter = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-virtualenv = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } piper = "0.2" portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } @@ -763,7 +787,7 @@ prost-types = "0.14" protox = "0.9" proxyvars = "0.2" pulldown-cmark = { version = "0.13.0", default-features = false } -quick-xml = "0.38" +quick-xml = "0.41" quote = "1.0.9" rand = "0.9" raw-window-handle = "0.6" @@ -789,7 +813,7 @@ rsa = "0.9.6" runtimelib = { version = "1.4.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } -rust-embed = { version = "8.11", features = ["include-exclude"] } +rust-embed = { version = "8.11", features = ["include-exclude", "compression"] } rustc-hash = "2.1.0" rustix = { version = "1.1", features = ["fs"] } rustls = { version = "0.23.26" } @@ -824,7 +848,7 @@ strum = { version = "0.28", features = ["derive"] } swash = "0.2.6" syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] } sys-locale = "0.3.1" -sysinfo = "0.37.0" +sysinfo = "0.39" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" @@ -883,8 +907,8 @@ usvg = { version = "0.46.0", default-features = false } uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } vte = { version = "0.15.0", features = ["ansi"] } walkdir = "2.5" -wasm-encoder = "0.252" -wasmparser = "0.252" +wasm-encoder = "0.254" +wasmparser = "0.254" wasmtime = { version = "48", default-features = false, features = [ "anyhow", "async", @@ -904,7 +928,7 @@ webpki-roots = "1.0.8" webrtc-sys = "0.3.23" wgpu = "29.0.4" which = "8" -windows-core = "0.61" +windows-core = "0.62" windows-registry = "0.6.0" yaml-rust2 = "0.8" yawc = { git = "https://github.com/zed-industries/yawc", rev = "71a452f551cac178367eaac5d7418a09afa1f3a2", version = "0.3.3" } @@ -912,7 +936,7 @@ zeroize = "1.8" zstd = "0.11" [workspace.dependencies.windows] -version = "0.61" +version = "0.62" features = [ "Data_Xml_Dom", "Foundation_Numerics", @@ -955,6 +979,7 @@ features = [ "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Pipes", + "Win32_System_Power", "Win32_System_RestartManager", "Win32_System_SystemInformation", "Win32_System_SystemServices", @@ -984,8 +1009,8 @@ windows-capture = { git = "https://github.com/zed-industries/windows-capture.git calloop = { git = "https://github.com/zed-industries/calloop" } livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "0a1c519cfce9b365229026b55de9b9dbdb6fed3c" } libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "0a1c519cfce9b365229026b55de9b9dbdb6fed3c" } -notify = { git = "https://github.com/zed-industries/notify", rev = "0890bbb8ca40a4b5d1f67031698dd7918b37d991" } -notify-types = { git = "https://github.com/zed-industries/notify", rev = "0890bbb8ca40a4b5d1f67031698dd7918b37d991" } +notify = { git = "https://github.com/zed-industries/notify", rev = "d842f16b2716bd60f09caf3ae3a894237ab38f54" } +notify-types = { git = "https://github.com/zed-industries/notify", rev = "d842f16b2716bd60f09caf3ae3a894237ab38f54" } webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "0a1c519cfce9b365229026b55de9b9dbdb6fed3c" } # corgi runs build.rs/proc-macros in a sandbox with no write access outside the @@ -998,7 +1023,7 @@ webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev # needs symlink support that Windows CI lacks). webrtc-sys is self-contained, so # per-crate scratch dirs are sufficient. Upstream issue: # https://github.com/dtolnay/cxx/issues/1752 -scratch = { path = "corgi-patches/scratch" } +scratch = { path = "tooling/corgi/patches/scratch" } [profile.dev] split-debuginfo = "unpacked" diff --git a/Dockerfile-collab b/Dockerfile-collab index 5250f52102..fa10876de5 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.97-bookworm as builder +FROM rust:1.98.1-bookworm AS builder WORKDIR app COPY . . @@ -32,7 +32,7 @@ RUN --mount=type=cache,target=./target \ cp /app/target/release/collab /app/collab # Copy collab server binary to the runtime image -FROM debian:bookworm-slim as runtime +FROM debian:bookworm-slim AS runtime RUN apt-get update; \ apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates \ linux-perf binutils diff --git a/REVIEWERS.conl b/REVIEWERS.conl deleted file mode 100644 index 85cdb3ee89..0000000000 --- a/REVIEWERS.conl +++ /dev/null @@ -1,126 +0,0 @@ -; This file contains a list of people who're interested in reviewing pull requests -; to certain parts of the code-base. -; -; This is mostly used internally for PR assignment, and may change over time. -; -; If you have permission to merge PRs (mostly equivalent to "do you work at Zed Industries"), -; we strongly encourage you to put your name in the "all" bucket, but you can also add yourself -; to other areas too. - - - = @cole-miller - = @ConradIrwin - = @danilo-leal - = @dinocosta - = @kubkon - = @maxdeviant - = @probably-neb - = @smitbarmase - = @SomeoneToIgnore - = @Veykril - -ai - = @benbrandt - = @bennetbo - = @danilo-leal - = @rtfeldman - -audio - = @yara-blue - -crashes - = @Veykril - -debugger - = @Anthony-Eid - = @kubkon - = @osiewicz - -design - = @danilo-leal - -docs - = @miguelraz - = @probably-neb - = @yeskunall - -extension - = @kubkon - -git - = @cole-miller - = @danilo-leal - = @yara-blue - = @kubkon - = @Anthony-Eid - = @cameron1024 - -gpui - = @Anthony-Eid - = @cameron1024 - = @mikayla-maki - = @probably-neb - -helix - = @kubkon - -languages - = @osiewicz - = @probably-neb - = @smitbarmase - = @SomeoneToIgnore - = @Veykril - -linux - = @cole-miller - = @yara-blue - = @probably-neb - = @smitbarmase - -lsp - = @osiewicz - = @smitbarmase - = @SomeoneToIgnore - = @Veykril - -multi_buffer - = @Veykril - = @SomeoneToIgnore - -pickers - = @yara-blue - = @SomeoneToIgnore - -project_panel - = @smitbarmase - -settings_ui - = @Anthony-Eid - = @danilo-leal - = @probably-neb - -sum_tree - = @Veykril - -support - = @miguelraz - -tasks - = @SomeoneToIgnore - = @Veykril - -terminal - = @kubkon - = @Veykril - -text - = @Veykril - -vim - = @ConradIrwin - = @dinocosta - = @probably-neb - -windows - = @reflectronic - = @Veykril diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index db670d2655..89fb803d58 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -226,14 +226,6 @@ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, - { - "context": "AgentPanel > Markdown", - "bindings": { - "copy": "markdown::CopyAsMarkdown", - "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "bindings": { @@ -387,6 +379,15 @@ "enter": "editor::Newline", }, }, + { + "context": "Elicitation || (Elicitation > Editor)", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + "tab": "menu::SelectNext", + "shift-tab": "menu::SelectPrevious", + }, + }, { "context": "ThreadHistory", "bindings": { @@ -555,8 +556,8 @@ "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", - "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], "ctrl-k ctrl-/": "editor::ToggleBlockComments", "shift-alt-a": "editor::ToggleBlockComments", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], @@ -736,8 +737,9 @@ // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", - "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock", + "ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-alt-w": "workspace::CloseActiveDock", }, }, { @@ -834,6 +836,12 @@ "enter": "editor::ConfirmRename", }, }, + { + "context": "Editor && inline_input", + "bindings": { + "enter": "menu::Confirm", + }, + }, { "context": "Editor && showing_completions", "bindings": { @@ -1011,6 +1019,8 @@ "alt-d": "project_panel::CompareMarkedFiles", "shift-find": "project_panel::NewSearchInDirectory", "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", + "menu": "project_panel::OpenContextMenu", + "shift-f10": "project_panel::OpenContextMenu", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "escape": "menu::Cancel", @@ -1029,6 +1039,17 @@ "ctrl-2": "git_panel::ActivateHistoryTab", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "shift-down": "menu::SelectNext", + "enter": "menu::Confirm", + "escape": "menu::Cancel", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "bindings": { @@ -1036,9 +1057,6 @@ "right": "git_panel::ExpandSelectedEntry", "up": "git_panel::PreviousEntry", "down": "git_panel::NextEntry", - "shift-up": "menu::SelectPrevious", - "shift-down": "menu::SelectNext", - "enter": "menu::Confirm", "alt-y": "git::StageFile", "alt-shift-y": "git::UnstageFile", "ctrl-alt-y": "git::ToggleStaged", @@ -1050,7 +1068,6 @@ "alt-ctrl-shift-c": "workspace::CopyRelativePath", "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", - "escape": "menu::Cancel", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], @@ -1217,6 +1234,12 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "bindings": { @@ -1237,6 +1260,12 @@ "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, + { + "context": "CommandPalette || (CommandPalette > Picker > Editor)", + "bindings": { + "shift-backspace": "command_palette::RemoveSelected", + }, + }, { "context": "RecentProjects || (RecentProjects > Picker > Editor)", "bindings": { @@ -1396,6 +1425,12 @@ "ctrl-e": "zed::OpenKeymapFile", }, }, + { + "context": "KeymapEditor > BufferSearchBar", + "bindings": { + "alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch", + }, + }, { "context": "KeystrokeInput", "use_key_equivalents": true, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 3324bce7da..2226cb43eb 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -264,13 +264,6 @@ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, - { - "context": "AgentPanel > Markdown", - "use_key_equivalents": true, - "bindings": { - "cmd-c": "markdown::CopyAsMarkdown", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "use_key_equivalents": true, @@ -421,6 +414,15 @@ "enter": "editor::Newline", }, }, + { + "context": "Elicitation || (Elicitation > Editor)", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + "tab": "menu::SelectNext", + "shift-tab": "menu::SelectPrevious", + }, + }, { "context": "ThreadHistory", "bindings": { @@ -596,7 +598,7 @@ "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "cmd-k cmd-i": "editor::Hover", "cmd-k cmd-b": "editor::BlameHover", - "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], + "cmd-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], "cmd-k cmd-/": "editor::ToggleBlockComments", "shift-alt-a": "editor::ToggleBlockComments", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], @@ -775,7 +777,8 @@ "cmd-k shift-down": "workspace::SwapPaneDown", "cmd-shift-x": "zed::Extensions", "f5": "debugger::Rerun", - "cmd-w": "workspace::CloseActiveDock", + "cmd-w": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-alt-w": "workspace::CloseActiveDock", }, }, { @@ -887,6 +890,13 @@ "enter": "editor::ConfirmRename", }, }, + { + "context": "Editor && inline_input", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + }, + }, { "context": "Editor && showing_completions", "use_key_equivalents": true, @@ -1054,6 +1064,7 @@ "alt-d": "project_panel::CompareMarkedFiles", "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }], "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", + "shift-f10": "project_panel::OpenContextMenu", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "escape": "menu::Cancel", @@ -1087,19 +1098,28 @@ "cmd-2": "git_panel::ActivateHistoryTab", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "shift-down": "menu::SelectNext", + "enter": "menu::Confirm", + "escape": "menu::Cancel", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "up": "git_panel::PreviousEntry", "down": "git_panel::NextEntry", - "shift-up": "menu::SelectPrevious", - "shift-down": "menu::SelectNext", "cmd-up": "git_panel::FirstEntry", "cmd-down": "git_panel::LastEntry", "left": "git_panel::CollapseSelectedEntry", "right": "git_panel::ExpandSelectedEntry", - "enter": "menu::Confirm", "cmd-alt-y": "git::ToggleStaged", "space": "git::ToggleStaged", "shift-space": "git::StageRange", @@ -1110,7 +1130,6 @@ "alt-down": "git_panel::FocusEditor", "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", - "escape": "menu::Cancel", "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], @@ -1267,6 +1286,13 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, @@ -1288,6 +1314,13 @@ "cmd-shift-i": "search::ToggleIncludeIgnored", }, }, + { + "context": "CommandPalette || (CommandPalette > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "shift-backspace": "command_palette::RemoveSelected", + }, + }, { "context": "RecentProjects || (RecentProjects > Picker > Editor)", "use_key_equivalents": true, @@ -1480,6 +1513,13 @@ "cmd-e": "zed::OpenKeymapFile", }, }, + { + "context": "KeymapEditor > BufferSearchBar", + "use_key_equivalents": true, + "bindings": { + "cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch", + }, + }, { "context": "KeystrokeInput", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1afd97082a..56aa6d3a50 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -228,13 +228,6 @@ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, - { - "context": "AgentPanel > Markdown", - "use_key_equivalents": true, - "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "use_key_equivalents": true, @@ -389,6 +382,15 @@ "enter": "editor::Newline", }, }, + { + "context": "Elicitation || (Elicitation > Editor)", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + "tab": "menu::SelectNext", + "shift-tab": "menu::SelectPrevious", + }, + }, { "context": "ThreadHistory", "use_key_equivalents": true, @@ -557,8 +559,8 @@ "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-k ctrl-f": "editor::FormatSelections", - "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], "ctrl-k ctrl-/": "editor::ToggleBlockComments", "shift-alt-a": "editor::ToggleBlockComments", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], @@ -723,8 +725,9 @@ // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", - "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock", + "ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-alt-w": "workspace::CloseActiveDock", }, }, { @@ -824,6 +827,13 @@ "enter": "editor::ConfirmRename", }, }, + { + "context": "Editor && inline_input", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + }, + }, { "context": "Editor && showing_completions", "use_key_equivalents": true, @@ -1002,6 +1012,8 @@ "ctrl-shift-enter": "project_panel::OpenWithSystem", "alt-d": "project_panel::CompareMarkedFiles", "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", + "menu": "project_panel::OpenContextMenu", + "shift-f10": "project_panel::OpenContextMenu", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "escape": "menu::Cancel", @@ -1021,17 +1033,26 @@ "ctrl-2": "git_panel::ActivateHistoryTab", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "shift-down": "menu::SelectNext", + "enter": "menu::Confirm", + "escape": "menu::Cancel", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "up": "git_panel::PreviousEntry", "down": "git_panel::NextEntry", - "shift-up": "menu::SelectPrevious", - "shift-down": "menu::SelectNext", "left": "git_panel::CollapseSelectedEntry", "right": "git_panel::ExpandSelectedEntry", - "enter": "menu::Confirm", "alt-y": "git::StageFile", "shift-alt-y": "git::UnstageFile", "space": "git::ToggleStaged", @@ -1040,7 +1061,6 @@ "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath", "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", - "escape": "menu::Cancel", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], @@ -1222,6 +1242,13 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, @@ -1244,6 +1271,13 @@ "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, + { + "context": "CommandPalette || (CommandPalette > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "shift-backspace": "command_palette::RemoveSelected", + }, + }, { "context": "RecentProjects || (RecentProjects > Picker > Editor)", "use_key_equivalents": true, diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 68df92d284..3a7af84ede 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -52,8 +52,8 @@ "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word "ctrl-t": "editor::Transpose", // transpose-chars - "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-x ctrl-;": "editor::ToggleComments", + "alt-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], + "ctrl-x ctrl-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "alt-.": "editor::GoToDefinition", // xref-find-definitions "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 0243e04030..40a66abadf 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -35,7 +35,7 @@ "alt-shift-g": "editor::SplitSelectionIntoLines", "alt-j": ["editor::SelectNext", { "replace_newest": false }], "alt-shift-j": ["editor::SelectPrevious", { "replace_newest": false }], - "ctrl-/": ["editor::ToggleComments", { "advance_downwards": true }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": true, "comment_empty_lines": true }], "ctrl-w": "editor::SelectLargerSyntaxNode", "ctrl-shift-w": "editor::SelectSmallerSyntaxNode", "shift-alt-up": "editor::MoveLineUp", diff --git a/assets/keymaps/linux/vscode.json b/assets/keymaps/linux/vscode.json index 4de83db2f0..74e648d3aa 100644 --- a/assets/keymaps/linux/vscode.json +++ b/assets/keymaps/linux/vscode.json @@ -13,6 +13,8 @@ "alt-z": "editor::ToggleSoftWrap", // Toggle Word Wrap "ctrl-shift-space": "editor::ShowSignatureHelp", // Trigger Parameter Hints "ctrl-shift-a": "editor::ToggleBlockComments", // Toggle Block Comment + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], // Add Line Comment + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "ctrl-k f12": "editor::GoToDefinitionSplit", // Open Definition to the Side }, }, diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 2f11e2ce00..3ab09a4981 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -46,8 +46,8 @@ "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word "ctrl-t": "editor::Transpose", // transpose-chars - "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-x ctrl-;": "editor::ToggleComments", + "alt-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], + "ctrl-x ctrl-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "alt-.": "editor::GoToDefinition", // xref-find-definitions "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 38ce4d9853..8eadc80645 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -35,7 +35,7 @@ "ctrl-g": ["editor::SelectNext", { "replace_newest": false }], "ctrl-shift-g": "editor::UndoSelection", "ctrl-cmd-g": ["editor::SelectPrevious", { "replace_newest": false }], - "cmd-/": ["editor::ToggleComments", { "advance_downwards": true }], + "cmd-/": ["editor::ToggleComments", { "advance_downwards": true, "comment_empty_lines": true }], "alt-up": "editor::SelectLargerSyntaxNode", "alt-down": "editor::SelectSmallerSyntaxNode", "shift-alt-up": "editor::MoveLineUp", diff --git a/assets/keymaps/macos/vscode.json b/assets/keymaps/macos/vscode.json index 69e000a8df..5798bdce41 100644 --- a/assets/keymaps/macos/vscode.json +++ b/assets/keymaps/macos/vscode.json @@ -14,7 +14,8 @@ "cmd-shift-space": "editor::ShowSignatureHelp", // Trigger Parameter Hints "ctrl-shift-cmd-right": "editor::SelectLargerSyntaxNode", // Expand AST Selection "ctrl-shift-cmd-left": "editor::SelectSmallerSyntaxNode", // Shrink AST Selection - "cmd-k cmd-c": ["editor::ToggleComments", { "advance_downwards": false }], // Add Line Comment (closest analog) + "cmd-k cmd-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], // Add Line Comment (closest analog) + "cmd-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "cmd-k f12": "editor::GoToDefinitionSplit", // Open Definition to the Side }, }, diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index f5ce59ce27..f2d7b26261 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -504,7 +504,7 @@ "*": "buffer_search::UseSelectionForFind", "shift-x": "editor::SelectLine", "_": "vim::HelixTrimSelections", - "ctrl-c": "editor::ToggleComments", + "ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "alt-o": "editor::SelectLargerSyntaxNode", "alt-i": "editor::SelectSmallerSyntaxNode", "alt-p": "editor::SelectPreviousSyntaxNode", @@ -562,7 +562,7 @@ "space r": "editor::Rename", "space a": "editor::ToggleCodeActions", "space h": "editor::SelectAllMatches", - "space c": "editor::ToggleComments", + "space c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "space p": "editor::Paste", "space y": "editor::Copy", "space /": "pane::DeploySearch", @@ -1123,17 +1123,23 @@ "g g": "menu::SelectFirst", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "use_key_equivalents": true, + "bindings": { + "j": "menu::SelectNext", + "k": "menu::SelectPrevious", + "g f": "menu::Confirm", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "h": "git_panel::CollapseSelectedEntry", - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", "l": "git_panel::ExpandSelectedEntry", "g g": "menu::SelectFirst", "shift-g": "menu::SelectLast", - "g f": "menu::Confirm", "i": "git_panel::FocusEditor", "x": "git::ToggleStaged", "shift-x": "git::StageAll", diff --git a/assets/settings/default.json b/assets/settings/default.json index dc779b9e31..fccbd5d478 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -79,12 +79,6 @@ "agent_buffer_font_size": 12, // The default font size for the commit editor in the git panel and commit modal. "git_commit_buffer_font_size": 12, - // The default font size for the markdown preview. Falls back to the UI font size if unset. - "markdown_preview_font_size": null, - // The font family for the markdown preview. Falls back to the UI font family if unset. - "markdown_preview_font_family": null, - // The font family for code blocks in the markdown preview. Falls back to the editor font family if unset. - "markdown_preview_code_font_family": null, // How much to fade out unused code. "unnecessary_code_fade": 0.3, // Active pane styling settings. @@ -118,6 +112,14 @@ }, // Markdown preview settings "markdown_preview": { + // The default font size for the markdown preview. Falls back to the UI font size if unset. + "font_size": null, + // The font family for the markdown preview. Falls back to the UI font family if unset. + "font_family": null, + // The font family for code blocks in the markdown preview. Falls back to the editor font family if unset. + "code_font_family": null, + // Whether to automatically open Markdown files in the preview. + "open_markdown_files_in_preview": false, // Whether to limit the width of the rendered markdown content. When // enabled, content is constrained to `max_width` and centered // horizontally within the preview pane. @@ -210,6 +212,13 @@ // 3. Never close the window // "when_closing_with_no_tabs": "keep_window_open", "when_closing_with_no_tabs": "platform_default", + // What to show when opening a new window. + // May take 2 values: + // 1. Show an empty untitled buffer + // "on_new_window": "empty_tab" + // 2. Show the launchpad with recent projects + // "on_new_window": "launchpad" + "on_new_window": "launchpad", // What to do when the last window is closed. // May take 2 values: // 1. Use the current platform's convention @@ -232,6 +241,23 @@ // // Default: true "zoomed_padding": true, + // Window title template. + // Available variables are ${projectName}, ${fileName}, ${filePath}, + // ${relativePath}, ${fileStem}, ${remoteName}, ${remoteHost}, + // ${appName}, ${branch}, and ${separator}. + // ${separator} is omitted when adjacent variables are empty, + // but literal text is preserved. + // The collaboration indicator, when present, is appended after the + // rendered template. + // If the template renders to nothing, the default template is used instead. + // + // Default: "${projectName}${separator}${fileName}" + "window_title_format": "${projectName}${separator}${fileName}", + // String substituted for ${separator} in the window title format. + // Include any surrounding whitespace in the value. + // + // Default: " — " + "window_title_separator": " — ", // On Linux, what draws Zed's window decorations (titlebar). // This setting has no effect on other platforms: // 1. Zed draws its own window decorations. @@ -269,6 +295,12 @@ // // Default: "bar" "cursor_shape": "bar", + // Cursor movement animation settings. + // Enabling this may cause additional editor redraws and increase rendering overhead. + "cursor_animation": { + // Whether cursor movement animation is enabled. + "enabled": false, + }, // Determines when the mouse cursor should be hidden in response to keyboard // input. // @@ -508,8 +540,18 @@ // Controls whether edit predictions are shown immediately (true) // or manually by triggering `editor::ShowEditPrediction` (false). "show_edit_predictions": true, - // Controls whether edit predictions are shown in a given language scope. - // Example: ["string", "comment"] + // Disable edit predictions in these language scopes, such as "comment" and + // "string". + // + // Use `"..."` to add scopes without repeating the inherited list. In project + // settings, it extends the user or parent configuration value. In + // language-specific settings, it extends the scopes inherited by that + // language. Omit `"..."` to replace the inherited list. + // + // Inherited scopes are inserted at `"..."`, and duplicates keep their first + // occurrence. + // + // Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. "edit_predictions_disabled_in": [], // Whether to show tabs and spaces in the editor. // This setting can take four values: @@ -554,7 +596,7 @@ // Whether to show code action buttons in the editor toolbar. "code_actions": false, }, - // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). + // Whether to allow windows to tab together based on the user's tabbing preference (macOS only). "use_system_window_tabs": false, // Which fullscreen mode the `zed::ToggleFullScreen` action enters (macOS only). // @@ -842,6 +884,8 @@ "button": true, // Whether to hide the gitignore entries in the project panel. "hide_gitignore": false, + // Delay in milliseconds before tooltips appear for project panel titles. + "title_tooltip_delay": "default", // Default width of the project panel. "default_width": 240, // Where to dock the project panel. Can be 'left' or 'right'. @@ -1080,9 +1124,9 @@ // // Default: false "collapse_untracked_diff": false, - /// Whether to show entries with tree or flat view in the panel - /// - /// Default: false + // Whether to show entries with tree or flat view in the panel + // + // Default: false "tree_view": false, // Whether the git panel should open on startup. // @@ -1126,13 +1170,23 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "left", - // Whether the agent panel should use flexible (proportional) sizing. + // Whether the agent panel should use flexible (proportional) sizing when docked + // to the left or right. When enabled, `default_width` does not control the panel + // width, and resetting the panel restores the default proportion. // // Default: true "flexible": true, - // Where to position the threads sidebar. Can be 'left' or 'right'. - "sidebar_side": "left", - // Default width when the agent panel is docked to the left or right. + // Settings for the sidebar holding the agent panel and the list of agent threads. + "threads_sidebar": { + // Where to position the Threads Sidebar. Can be 'left' or 'right'. + "position": "left", + // Default width of the Threads Sidebar. + "default_width": 300, + // Automatically open the Threads Sidebar when opening a folder in an existing window. + "auto_open": true, + }, + // Default fixed width when the agent panel is docked to the left or right and + // `flexible` is false. "default_width": 640, // Default height when the agent panel is docked to the bottom. "default_height": 320, @@ -1310,6 +1364,10 @@ // // Default: never "play_sound_when_agent_done": "never", + // Whether to keep the system awake while agent threads are running. + // + // Default: true + "prevent_idle_sleep": true, // Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. // // Default: true @@ -1461,7 +1519,7 @@ // when you open another preview tab. // This is useful for quickly viewing files without cluttering your workspace. "enabled": true, - // Whether to open tabs in preview mode when opened from the project panel with a single click. + // Whether to open tabs in preview mode when opened from the project panel with a single click or the Open action. "enable_preview_from_project_panel": true, // Whether to open tabs in preview mode when selected from the file finder. "enable_preview_from_file_finder": false, @@ -1482,6 +1540,11 @@ // Default: medium "modal_max_width": "medium", }, + // Settings related to the command palette. + "command_palette": { + // Whether to use command history ranking for sorting in the command palette. + "use_command_history": true, + }, // Settings related to the file finder. "file_finder": { // Whether to show file icons in the file finder. @@ -1748,9 +1811,9 @@ // 2. Hide the gutter // "git_gutter": "hide" "git_gutter": "tracked_files", - /// Sets the debounce threshold (in milliseconds) after which changes are reflected in the git gutter. - /// - /// Default: 0 + // Sets the debounce threshold (in milliseconds) after which changes are reflected in the git gutter. + // + // Default: 0 "gutter_debounce": 0, // Control whether the git blame information is shown for the currently // focused line, and where it is rendered. @@ -1834,11 +1897,20 @@ "edit_predictions": { // Which edit prediction provider to use. "provider": "zed", - // A list of globs representing files that edit predictions should be disabled for. - // There's a sensible default list of globs already included. - // Any addition to this list will be merged with the default list. - // Globs are matched relative to the worktree root, - // except when starting with a slash (/) or equivalent in Windows. + // Disable edit predictions for files matching these glob patterns. + // + // Use `"..."` to add patterns without repeating Zed's defaults. In project + // settings, it extends the user or parent configuration value. Omit + // `"..."` to replace the inherited list. + // + // Inherited patterns are inserted at `"..."`, and duplicates keep their first + // occurrence. + // + // Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. + // + // Relative patterns are matched against paths relative to the worktree root. + // Absolute patterns are matched against absolute paths. + // A leading `~` is expanded to your home folder. "disabled_globs": [ "**/.env*", "**/*.pem", @@ -1934,6 +2006,10 @@ "line_endings_button": false, // Control when to show the active encoding in the status bar. "active_encoding_button": "non_utf8", + // Whether to show an indicator while multi-stroke input is pending. + // If the input has a timeout, a countdown is shown and hovering pauses it. + // Its binding preview popover is disabled when "which_key.enabled" is true. + "pending_keystrokes_indicator": true, }, // Settings specific to the terminal "terminal": { @@ -2145,14 +2221,23 @@ // // [ // "\\s+(-->|:::|at) (?(?.+?))(:$|$)", - // "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)" + // "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)", + // "..." // ], // - // could be used. Processing stops at the first regex with a match, even if no link is + // could be used. The `"..."` entry inserts inherited regexes at that position. In project + // settings, it extends the user or parent configuration value. Omit `"..."` to replace the + // inherited list, or set `[]` to clear it. Duplicates keep their first occurrence, comparing + // regex text after joining multiline entries with newlines. The marker is recognized only + // as a top-level string. Write `["..."]` as an entry to keep the three-character regex instead. + // A `"..."` line inside a multiline entry is always regex text. Invalid regexes are logged + // and ignored. + // + // Processing stops at the first regex with a match, even if no link is // produced which is the case when the cursor is not over the hyperlinked text. For best // performance it is recommended to order regexes from most common to least common. For - // readability and documentation, each regex may be an array of strings which are collected - // into one multi-line regex string for use in terminal path hyperlink detection. + // readability and documentation, each regex may be an array of strings which are joined + // with newlines into one regex string for use in terminal path hyperlink detection. "path_hyperlink_regexes": [ // Python-style diagnostics "File \"(?[^\"]+)\", line (?[0-9]+)", @@ -2756,6 +2841,8 @@ // Which-key popup settings "which_key": { // Whether to show the which-key popup when holding down key combinations. + // When enabled, the pending keystrokes indicator remains visible, but its + // binding preview popover is disabled. "enabled": false, // Delay in milliseconds before showing the which-key popup. "delay_ms": 1000, diff --git a/compose.yml b/compose.yml index 17f6f51293..10433d2a75 100644 --- a/compose.yml +++ b/compose.yml @@ -20,4 +20,4 @@ services: - 7881:7881 - 7882:7882/udp volumes: - - ./livekit.yaml:/livekit.yaml + - ./.config/livekit.yaml:/livekit.yaml diff --git a/corgi-patches/scratch/LICENSE-APACHE b/corgi-patches/scratch/LICENSE-APACHE deleted file mode 120000 index 1cd601d0a3..0000000000 --- a/corgi-patches/scratch/LICENSE-APACHE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-APACHE \ No newline at end of file diff --git a/corgi.toml b/corgi.toml index 6f5367f459..b2050713d2 100644 --- a/corgi.toml +++ b/corgi.toml @@ -63,8 +63,12 @@ profiles = ["release"] # Own non-.rs files read at build time (protobufs, bindgen headers, doc/data). proto = ["proto/**"] livekit_api = ["vendored/protocol/**"] -media = ["src/bindings.h"] -gpui = ["README.md"] +gpui = [ + "README.md", + "examples/image/exif-orientation-rotate-180.jpg", + "../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf", + "../../assets/fonts/lilex/Lilex-Regular.ttf", +] git = ["src/checkpoint.gitignore"] git_ui = ["src/commit_message_prompt.txt"] editor = ["../grammars/src/**"] @@ -78,7 +82,8 @@ zed_extension_api = ["wit/**"] gpui_wgpu = ["src/*.wgsl"] # Cross-package reads (and, for gpui_apple, its own metal shader source). extension_host = ["../extension_api/wit/**"] -gpui_apple = ["../gpui/src/**", "src/shaders.metal"] +# cbindgen reads the renderer source directly from build.rs to generate shader bindings. +gpui_apple = ["../gpui/src/**", "src/metal_renderer.rs", "src/shaders.metal"] eval_cli = ["../zed/Cargo.toml"] prompt_store = ["../git_ui/src/commit_message_prompt.txt"] release_channel = ["../zed/RELEASE_CHANNEL"] diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 3c4f592a55..a6d873d8ea 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -17,6 +17,7 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"] [dependencies] action_log.workspace = true +agent_settings.workspace = true agent-client-protocol.workspace = true async-channel.workspace = true base64.workspace = true @@ -59,6 +60,7 @@ urlencoding.workspace = true env_logger.workspace = true gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true +markdown = { workspace = true, features = ["test-support"] } parking_lot.workspace = true project = { workspace = true, "features" = ["test-support"] } rand.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 194a528590..b170b62351 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -5,6 +5,7 @@ mod terminal; pub use ::terminal::HeadlessTerminal; use action_log::{ActionLog, ActionLogTelemetry}; use agent_client_protocol::schema::{MaybeUndefined, v1 as acp}; +use agent_settings::AgentSettings; use anyhow::{Context as _, Result, anyhow}; use collections::HashSet; pub use connection::*; @@ -12,8 +13,8 @@ pub use diff::*; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; use gpui::{ - AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, - WeakEntity, + ActivityGuard, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, + Task, WeakEntity, }; use itertools::Itertools; use language::language_settings::FormatOnSave; @@ -29,6 +30,7 @@ use project::{ }; use serde::{Deserialize, Serialize}; use serde_json::to_string_pretty; +use settings::{Settings, SettingsStore}; use std::collections::HashMap; use std::error::Error; use std::fmt::{Formatter, Write}; @@ -61,19 +63,18 @@ impl std::fmt::Display for MaxOutputTokensError { impl std::error::Error for MaxOutputTokensError {} -/// Key used in ACP ToolCall meta to store the tool's programmatic name. -/// This is a workaround since ACP's ToolCall doesn't have a dedicated name field. +/// Legacy ACP metadata key used before tool calls had a dedicated name field. pub const TOOL_NAME_META_KEY: &str = "tool_name"; -/// Helper to extract tool name from ACP meta +/// Extracts a tool name from the legacy ACP metadata field. pub fn tool_name_from_meta(meta: &Option) -> Option { meta.as_ref() - .and_then(|m| m.get(TOOL_NAME_META_KEY)) - .and_then(|v| v.as_str()) - .map(|s| SharedString::from(s.to_owned())) + .and_then(|meta| meta.get(TOOL_NAME_META_KEY)) + .and_then(|value| value.as_str()) + .map(|name| SharedString::from(name.to_owned())) } -/// Helper to create meta with tool name +/// Creates ACP metadata containing the legacy tool-name field. pub fn meta_with_tool_name(tool_name: &str) -> acp::Meta { acp::Meta::from_iter([(TOOL_NAME_META_KEY.into(), tool_name.into())]) } @@ -295,7 +296,7 @@ pub struct UserMessage { pub protocol_id: Option, pub client_id: Option, pub is_optimistic: bool, - pub content: ContentBlock, + pub content: MessageContent, pub chunks: Vec, pub checkpoint: Option, pub indented: bool, @@ -349,11 +350,11 @@ impl AssistantMessage { pub enum AssistantMessageChunk { Message { id: Option, - block: ContentBlock, + block: MessageContent, }, Thought { id: Option, - block: ContentBlock, + block: MessageContent, }, } @@ -366,13 +367,13 @@ impl AssistantMessageChunk { ) -> Self { Self::Message { id: None, - block: ContentBlock::new(chunk.into(), language_registry, path_style, cx), + block: MessageContent::new(chunk.into(), language_registry, path_style, cx), } } fn to_markdown(&self, cx: &App) -> String { match self { - Self::Message { block, .. } => block.to_markdown(cx).to_string(), + Self::Message { block, .. } => block.to_markdown(cx), Self::Thought { block, .. } => { format!("\n{}\n", block.to_markdown(cx)) } @@ -753,11 +754,26 @@ impl ElicitationStore { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextCompactionId(pub Arc); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ContextCompactionStatus { InProgress, Completed, + Failed, Canceled, + Other(Arc), +} + +impl From for ContextCompactionStatus { + fn from(status: acp::CompactionStatus) -> Self { + match status { + acp::CompactionStatus::InProgress => Self::InProgress, + acp::CompactionStatus::Completed => Self::Completed, + acp::CompactionStatus::Failed => Self::Failed, + acp::CompactionStatus::Cancelled => Self::Canceled, + acp::CompactionStatus::Other(status) => Self::Other(status.into()), + _ => Self::Other("unknown".into()), + } + } } /// A point in the thread where the conversation history was compacted to free @@ -767,15 +783,60 @@ pub enum ContextCompactionStatus { pub struct ContextCompaction { pub id: ContextCompactionId, pub status: ContextCompactionStatus, - /// The compaction summary, streamed in as the model produces it. This is - /// `None` for provider-native compaction, which produces no summary to show. - pub summary: Option>, + pub error: Option>, + pub summary: Vec, } impl ContextCompaction { pub fn is_in_progress(&self) -> bool { self.status == ContextCompactionStatus::InProgress } + + fn apply_update( + &mut self, + update: acp::CompactionUpdate, + language_registry: &Arc, + cx: &mut App, + ) { + self.status = update.status.into(); + match update.summary { + MaybeUndefined::Undefined => {} + MaybeUndefined::Null => self.summary.clear(), + MaybeUndefined::Value(blocks) => { + self.summary.clear(); + for block in blocks { + self.append_summary(block, language_registry, cx); + } + } + } + match update.error { + MaybeUndefined::Undefined => {} + MaybeUndefined::Null => self.error = None, + MaybeUndefined::Value(error) => { + if let Some(markdown) = &self.error { + markdown.update(cx, |markdown, cx| markdown.reset(error.into(), cx)); + } else { + self.error = Some(cx.new(|cx| Markdown::new_text(error.into(), cx))); + } + } + } + } + + fn append_summary( + &mut self, + content: acp::ContentBlock, + language_registry: &Arc, + cx: &mut App, + ) { + if let acp::ContentBlock::Text(text) = &content + && let Some(ContentBlock::Markdown { markdown }) = self.summary.last() + { + markdown.update(cx, |markdown, cx| markdown.append(&text.text, cx)); + } else { + self.summary + .push(ContentBlock::new_output(content, language_registry, cx)); + } + } } #[derive(Debug)] @@ -811,7 +872,27 @@ impl AgentThreadEntry { } md } - Self::ContextCompaction(_) => "--- Context Compacted ---\n\n".to_string(), + Self::ContextCompaction(compaction) => { + let status = match &compaction.status { + ContextCompactionStatus::InProgress => "In Progress", + ContextCompactionStatus::Completed => "Completed", + ContextCompactionStatus::Failed => "Failed", + ContextCompactionStatus::Canceled => "Canceled", + ContextCompactionStatus::Other(status) => status, + }; + let mut markdown = + format!("## Context Compaction ({})\n\n", MarkdownEscaped(status)); + for block in &compaction.summary { + markdown.push_str(block.to_markdown(cx)); + markdown.push_str("\n\n"); + } + if let Some(error) = &compaction.error { + markdown.push_str("**Error:** "); + markdown.push_str(&MarkdownEscaped(error.read(cx).source()).to_string()); + markdown.push_str("\n\n"); + } + markdown + } } } @@ -860,6 +941,7 @@ impl AgentThreadEntry { pub struct ToolCall { pub id: acp::ToolCallId, pub label: Entity, + title: Option, pub kind: acp::ToolKind, pub content: Vec, pub status: ToolCallStatus, @@ -883,28 +965,16 @@ impl ToolCall { tool_call: acp::ToolCall, status: ToolCallStatus, language_registry: Arc, - path_style: PathStyle, terminals: &HashMap>, cx: &mut App, ) -> Result { - let title = if tool_call.kind == acp::ToolKind::Execute { - tool_call.title - } else if tool_call.kind == acp::ToolKind::Edit { - MarkdownEscaped(tool_call.title.as_str()).to_string() - } else if let Some((first_line, _)) = tool_call.title.split_once("\n") { - first_line.to_owned() + "…" - } else { - tool_call.title - }; + let title = + Some(SharedString::from(tool_call.title)).filter(|title| !title.trim().is_empty()); let mut content = Vec::with_capacity(tool_call.content.len()); for item in tool_call.content { - if let Some(item) = ToolCallContent::from_acp( - item, - language_registry.clone(), - path_style, - terminals, - cx, - )? { + if let Some(item) = + ToolCallContent::from_acp(item, language_registry.clone(), terminals, cx)? + { content.push(item); } } @@ -914,7 +984,10 @@ impl ToolCall { .as_ref() .and_then(|input| markdown_for_raw_output(input, &language_registry, cx)); - let tool_name = tool_name_from_meta(&tool_call.meta); + let tool_name = tool_call + .name + .map(SharedString::from) + .or_else(|| tool_name_from_meta(&tool_call.meta)); let subagent_session_info = subagent_session_info_from_meta(&tool_call.meta); let sandbox_authorization_details = @@ -923,15 +996,18 @@ impl ToolCall { sandbox_fallback_authorization_details_from_meta(&tool_call.meta); let sandbox_not_applied = sandbox_not_applied_from_meta(&tool_call.meta); - let label = if tool_call.kind == acp::ToolKind::Execute { - cx.new(|cx| Markdown::new_text(title.into(), cx)) - } else { - cx.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)) - }; + let label = Self::new_label( + title.as_ref(), + tool_name.as_ref(), + tool_call.kind, + language_registry, + cx, + ); let result = Self { id: tool_call.tool_call_id, label, + title, kind: tool_call.kind, content, locations: tool_call.locations, @@ -949,12 +1025,51 @@ impl ToolCall { Ok(result) } + fn label_text( + title: Option<&SharedString>, + tool_name: Option<&SharedString>, + kind: acp::ToolKind, + ) -> SharedString { + let Some(title) = title else { + return tool_name + .filter(|name| !name.trim().is_empty()) + .cloned() + .unwrap_or_else(|| "Tool call".into()); + }; + + if kind == acp::ToolKind::Execute { + title.clone() + } else if kind == acp::ToolKind::Edit { + MarkdownEscaped(title).to_string().into() + } else if let Some((first_line, _)) = title.split_once('\n') { + (first_line.to_owned() + "…").into() + } else { + title.clone() + } + } + + fn new_label( + title: Option<&SharedString>, + tool_name: Option<&SharedString>, + kind: acp::ToolKind, + language_registry: Arc, + cx: &mut App, + ) -> Entity { + let text = Self::label_text(title, tool_name, kind); + cx.new(|cx| { + if title.is_none() || kind == acp::ToolKind::Execute { + Markdown::new_text(text, cx) + } else { + Markdown::new(text, Some(language_registry), None, cx) + } + }) + } + fn update_fields( &mut self, fields: acp::ToolCallUpdateFields, meta: Option, language_registry: Arc, - path_style: PathStyle, terminals: &HashMap>, cx: &mut App, ) -> Result<()> { @@ -962,6 +1077,7 @@ impl ToolCall { kind, status, title, + name, content, locations, raw_input, @@ -969,6 +1085,8 @@ impl ToolCall { .. } = fields; + let was_plain_text = self.title.is_none() || self.kind == acp::ToolKind::Execute; + let mut label_changed = title.is_some() || kind.is_some(); if let Some(kind) = kind { self.kind = kind; } @@ -977,6 +1095,16 @@ impl ToolCall { self.update_acp_status(status); } + if let Some(tool_name) = name.map(SharedString::from) { + self.tool_name = Some(tool_name); + label_changed = true; + } else if self.tool_name.is_none() { + // Legacy metadata only fills a missing name so it cannot replace a + // first-class name received earlier. + self.tool_name = tool_name_from_meta(&meta); + label_changed |= self.tool_name.is_some(); + } + if let Some(subagent_session_info) = subagent_session_info_from_meta(&meta) { self.subagent_session_info = Some(subagent_session_info); } @@ -995,24 +1123,35 @@ impl ToolCall { } if let Some(title) = title { - if self.kind == acp::ToolKind::Execute { + self.title = Some(SharedString::from(title)).filter(|title| !title.trim().is_empty()); + if self.kind == acp::ToolKind::Execute + && let Some(title) = &self.title + { + // A missing tool title must not overwrite an actual terminal command. for terminal in self.terminals() { terminal.update(cx, |terminal, cx| { - terminal.update_command_label(&title, cx); + terminal.update_command_label(title, cx); }); } } - self.label.update(cx, |label, cx| { - if self.kind == acp::ToolKind::Execute { - label.replace(title, cx); - } else if self.kind == acp::ToolKind::Edit { - label.replace(MarkdownEscaped(&title).to_string(), cx) - } else if let Some((first_line, _)) = title.split_once("\n") { - label.replace(first_line.to_owned() + "…", cx); - } else { - label.replace(title, cx); + } + if label_changed { + let is_plain_text = self.title.is_none() || self.kind == acp::ToolKind::Execute; + if was_plain_text != is_plain_text { + self.label = Self::new_label( + self.title.as_ref(), + self.tool_name.as_ref(), + self.kind, + language_registry.clone(), + cx, + ); + } else { + let text = + Self::label_text(self.title.as_ref(), self.tool_name.as_ref(), self.kind); + if self.label.read(cx).source() != &text { + self.label.update(cx, |label, cx| label.replace(text, cx)); } - }); + } } if let Some(content) = content { @@ -1022,19 +1161,15 @@ impl ToolCall { // Reuse existing content if we can for (old, new) in self.content.iter_mut().zip(content.by_ref()) { let valid_content = - old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + old.update_from_acp(new, language_registry.clone(), terminals, cx)?; if !valid_content { new_content_len -= 1; } } for new in content { - if let Some(new) = ToolCallContent::from_acp( - new, - language_registry.clone(), - path_style, - terminals, - cx, - )? { + if let Some(new) = + ToolCallContent::from_acp(new, language_registry.clone(), terminals, cx)? + { self.content.push(new); } else { new_content_len -= 1; @@ -1113,11 +1248,13 @@ impl ToolCall { } pub fn to_markdown(&self, cx: &App) -> String { - let mut markdown = format!( - "**Tool Call: {}**\nStatus: {}\n\n", - self.label.read(cx).source(), - self.status - ); + let label = self.label.read(cx).source(); + let label = if self.title.is_none() { + MarkdownEscaped(label).to_string() + } else { + label.to_string() + }; + let mut markdown = format!("**Tool Call: {}**\nStatus: {}\n\n", label, self.status); for content in &self.content { markdown.push_str(content.to_markdown(cx).as_str()); markdown.push_str("\n\n"); @@ -1343,9 +1480,108 @@ fn elicitation_status_for_response(response: &acp::CreateElicitationResponse) -> } } +#[derive(Debug, Default, PartialEq)] +pub struct MessageContent { + blocks: Vec, +} + +impl MessageContent { + pub fn new( + block: acp::ContentBlock, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) -> Self { + let mut content = Self::default(); + content.append(block, language_registry, path_style, cx); + content + } + + pub fn blocks(&self) -> &[ContentBlock] { + &self.blocks + } + + pub fn markdowns(&self) -> impl Iterator> { + self.blocks.iter().filter_map(ContentBlock::markdown) + } + + pub fn visible_content(&self, cx: &App) -> bool { + self.blocks.iter().any(|block| block.visible_content(cx)) + } + + pub fn to_markdown(&self, cx: &App) -> String { + self.blocks + .iter() + .map(|block| block.to_markdown(cx)) + .filter(|text| !text.is_empty()) + .join("\n\n") + } + + fn trailing_text(&self) -> Option<&Entity> { + match self.blocks.last()? { + ContentBlock::Markdown { markdown } => Some(markdown), + _ => None, + } + } + + pub fn append( + &mut self, + block: acp::ContentBlock, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) { + let block = match block { + acp::ContentBlock::ResourceLink(resource) => { + let mut text = ContentBlock::resource_link_md(&resource.uri, path_style); + if self.blocks.is_empty() { + // Legacy leading links separated the next chunk. Include that separator + // now because streamed text can append directly to the Markdown entity. + text.push('\n'); + } + text.into() + } + block => block, + }; + if let acp::ContentBlock::Text(text) = &block { + if text.text.is_empty() { + return; + } + if let Some(markdown) = self.trailing_text() { + markdown.update(cx, |markdown, cx| markdown.append(&text.text, cx)); + return; + } + } + self.blocks + .push(ContentBlock::new_output(block, language_registry, cx)); + } + + fn append_prompt( + &mut self, + block: acp::ContentBlock, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) { + // Prompt text resources are mentions, not output previews. Keep their text + // inline while retaining the original payloads in UserMessage::chunks. + let block = match block { + acp::ContentBlock::Resource(resource) + if matches!( + resource.resource, + acp::EmbeddedResourceResource::TextResourceContents(_) + ) => + { + ContentBlock::embedded_resource_string_contents(&resource, path_style).into() + } + block => block, + }; + self.append(block, language_registry, path_style, cx); + } +} + #[derive(Debug, PartialEq, Clone)] pub enum ContentBlock { - Empty, Markdown { markdown: Entity, }, @@ -1360,41 +1596,30 @@ pub enum ContentBlock { image: Arc, dimensions: Option>, }, + Unsupported { + content: acp::ContentBlock, + markdown: Entity, + }, } impl ContentBlock { - pub fn new( + pub fn new_output( block: acp::ContentBlock, language_registry: &Arc, - path_style: PathStyle, - cx: &mut App, - ) -> Self { - let mut this = Self::Empty; - this.append(block, language_registry, path_style, cx); - this - } - - pub fn new_combined( - blocks: impl IntoIterator, - language_registry: Arc, - path_style: PathStyle, - cx: &mut App, - ) -> Self { - let mut this = Self::Empty; - for block in blocks { - this.append(block, &language_registry, path_style, cx); - } - this - } - - pub fn new_tool_call_content( - block: acp::ContentBlock, - language_registry: &Arc, - path_style: PathStyle, cx: &mut App, ) -> Self { match block { - acp::ContentBlock::Resource(resource) => { + acp::ContentBlock::Text(text) => { + Self::create_markdown_block(text.text, language_registry, cx) + } + acp::ContentBlock::ResourceLink(resource_link) => Self::ResourceLink { resource_link }, + acp::ContentBlock::Resource(resource) + if matches!( + &resource.resource, + acp::EmbeddedResourceResource::TextResourceContents(_) + | acp::EmbeddedResourceResource::BlobResourceContents(_) + ) => + { if let Some((image, dimensions)) = Self::decode_embedded_resource_image(&resource) { Self::Image { image, dimensions } } else { @@ -1403,56 +1628,30 @@ impl ContentBlock { Self::EmbeddedResource { resource, markdown } } } - block => Self::new(block, language_registry, path_style, cx), - } - } - - pub fn append( - &mut self, - block: acp::ContentBlock, - language_registry: &Arc, - path_style: PathStyle, - cx: &mut App, - ) { - match (&mut *self, &block) { - (ContentBlock::Empty, acp::ContentBlock::ResourceLink(resource_link)) => { - *self = ContentBlock::ResourceLink { - resource_link: resource_link.clone(), - }; - } - (ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => { - if let Some((image, dimensions)) = Self::decode_image(image_content) { - *self = ContentBlock::Image { image, dimensions }; + acp::ContentBlock::Image(image) => { + if let Some((image, dimensions)) = Self::decode_image(&image) { + Self::Image { image, dimensions } } else { - let new_content = Self::image_md(image_content); - *self = Self::create_markdown_block(new_content, language_registry, cx); + Self::Unsupported { + content: acp::ContentBlock::Image(image), + markdown: Self::create_markdown( + "Image content could not be displayed.".into(), + language_registry, + cx, + ), + } } } - (ContentBlock::Empty, _) => { - let new_content = Self::block_string_contents(&block, path_style); - *self = Self::create_markdown_block(new_content, language_registry, cx); - } - (ContentBlock::Markdown { markdown }, _) => { - let new_content = Self::block_string_contents(&block, path_style); - markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx)); - } - (ContentBlock::ResourceLink { resource_link }, _) => { - let existing_content = Self::resource_link_md(&resource_link.uri, path_style); - let new_content = Self::block_string_contents(&block, path_style); - let combined = format!("{}\n{}", existing_content, new_content); - *self = Self::create_markdown_block(combined, language_registry, cx); - } - (ContentBlock::EmbeddedResource { resource, .. }, _) => { - let existing_content = - Self::embedded_resource_string_contents(resource, path_style); - let new_content = Self::block_string_contents(&block, path_style); - let combined = format!("{}\n{}", existing_content, new_content); - *self = Self::create_markdown_block(combined, language_registry, cx); - } - (ContentBlock::Image { .. }, _) => { - let new_content = Self::block_string_contents(&block, path_style); - let combined = format!("`Image`\n{}", new_content); - *self = Self::create_markdown_block(combined, language_registry, cx); + content => { + let description = if matches!(&content, acp::ContentBlock::Audio(_)) { + "Audio content is not supported." + } else { + "This content is not supported." + }; + Self::Unsupported { + content, + markdown: Self::create_markdown(description.into(), language_registry, cx), + } } } } @@ -1588,15 +1787,15 @@ impl ContentBlock { pub fn text_content<'a>(&'a self, cx: &'a App) -> Option<&'a str> { match self { - ContentBlock::Markdown { markdown } => Some(markdown.read(cx).source()), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + Some(markdown.read(cx).source()) + } ContentBlock::EmbeddedResource { resource, .. } => match &resource.resource { acp::EmbeddedResourceResource::TextResourceContents(text) => Some(&text.text), acp::EmbeddedResourceResource::BlobResourceContents(_) => None, _ => None, }, - ContentBlock::Empty - | ContentBlock::ResourceLink { .. } - | ContentBlock::Image { .. } => None, + ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => None, } } @@ -1667,8 +1866,9 @@ impl ContentBlock { pub fn visible_content(&self, cx: &App) -> bool { match self { - ContentBlock::Empty => false, - ContentBlock::Markdown { markdown } => !markdown.read(cx).source().trim().is_empty(), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + !markdown.read(cx).source().trim().is_empty() + } ContentBlock::EmbeddedResource { resource, markdown } => match markdown { Some(markdown) => !markdown.read(cx).source().trim().is_empty(), None => !Self::embedded_resource_text(resource).trim().is_empty(), @@ -1677,25 +1877,6 @@ impl ContentBlock { } } - fn block_string_contents(block: &acp::ContentBlock, path_style: PathStyle) -> String { - match block { - acp::ContentBlock::Text(text_content) => text_content.text.clone(), - acp::ContentBlock::ResourceLink(resource_link) => { - Self::resource_link_md(&resource_link.uri, path_style) - } - acp::ContentBlock::Resource(acp::EmbeddedResource { - resource: - acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents { - uri, - .. - }), - .. - }) => Self::resource_link_md(uri, path_style), - acp::ContentBlock::Image(image) => Self::image_md(image), - _ => String::new(), - } - } - fn resource_link_md(uri: &str, path_style: PathStyle) -> String { if let Some(uri) = MentionUri::parse(uri, path_style).log_err() { uri.as_link().to_string() @@ -1704,14 +1885,11 @@ impl ContentBlock { } } - fn image_md(_image: &acp::ImageContent) -> String { - "`Image`".into() - } - pub fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { match self { - ContentBlock::Empty => "", - ContentBlock::Markdown { markdown } => markdown.read(cx).source(), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + markdown.read(cx).source() + } ContentBlock::EmbeddedResource { resource, markdown } => { if let Some(markdown) = markdown { markdown.read(cx).source() @@ -1726,8 +1904,9 @@ impl ContentBlock { pub fn markdown(&self) -> Option<&Entity> { match self { - ContentBlock::Empty => None, - ContentBlock::Markdown { markdown } => Some(markdown), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + Some(markdown) + } ContentBlock::EmbeddedResource { markdown, .. } => markdown.as_ref(), ContentBlock::ResourceLink { .. } => None, ContentBlock::Image { .. } => None, @@ -1815,18 +1994,12 @@ impl ToolCallContent { pub fn from_acp( content: acp::ToolCallContent, language_registry: Arc, - path_style: PathStyle, terminals: &HashMap>, cx: &mut App, ) -> Result> { match content { acp::ToolCallContent::Content(acp::Content { content, .. }) => Ok(Some( - Self::ContentBlock(ContentBlock::new_tool_call_content( - content, - &language_registry, - path_style, - cx, - )), + Self::ContentBlock(ContentBlock::new_output(content, &language_registry, cx)), )), acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| { Diff::finalized( @@ -1850,7 +2023,6 @@ impl ToolCallContent { &mut self, new: acp::ToolCallContent, language_registry: Arc, - path_style: PathStyle, terminals: &HashMap>, cx: &mut App, ) -> Result { @@ -1876,7 +2048,7 @@ impl ToolCallContent { _ => true, }; - if let Some(update) = Self::from_acp(new, language_registry, path_style, terminals, cx)? { + if let Some(update) = Self::from_acp(new, language_registry, terminals, cx)? { if needs_update { *self = update; } @@ -2108,6 +2280,7 @@ pub struct AcpThread { prompt_capabilities: acp::PromptCapabilities, available_commands: Vec, _observe_prompt_capabilities: Task>, + _idle_sleep_subscriptions: Vec, terminals: HashMap>, pending_terminal_output: HashMap>>, pending_terminal_exit: HashMap, @@ -2121,6 +2294,14 @@ pub struct AcpThread { /// gradually to create a fluid typing effect instead of choppy chunk-at-a-time /// updates. streaming_text_buffer: Option, + idle_sleep_prevention: IdleSleepPrevention, +} + +enum IdleSleepPrevention { + Inactive, + Acquiring { _task: Task<()> }, + Active { _guard: ActivityGuard }, + Failed, } struct StreamingTextBuffer { @@ -2283,6 +2464,34 @@ impl AcpThread { })?; } }); + let idle_sleep_settings_subscription = cx.observe_global::(|this, cx| { + this.update_idle_sleep_prevention(cx); + }); + let idle_sleep_event_subscription = + cx.subscribe_self(|this, event: &AcpThreadEvent, cx| match event { + AcpThreadEvent::StatusChanged + | AcpThreadEvent::EntriesRemoved(_) + | AcpThreadEvent::ToolAuthorizationRequested(_) + | AcpThreadEvent::ToolAuthorizationReceived(_) + | AcpThreadEvent::ElicitationRequested(_) + | AcpThreadEvent::ElicitationResponded(_) => this.update_idle_sleep_prevention(cx), + AcpThreadEvent::PromptUpdated + | AcpThreadEvent::NewEntry + | AcpThreadEvent::TitleUpdated + | AcpThreadEvent::TokenUsageUpdated + | AcpThreadEvent::EntryUpdated(_) + | AcpThreadEvent::Retry(_) + | AcpThreadEvent::SubagentSpawned(_) + | AcpThreadEvent::Stopped(_) + | AcpThreadEvent::Error + | AcpThreadEvent::LoadError(_) + | AcpThreadEvent::PromptCapabilitiesUpdated + | AcpThreadEvent::Refusal + | AcpThreadEvent::AvailableCommandsUpdated(_) + | AcpThreadEvent::ModeUpdated(_) + | AcpThreadEvent::ConfigOptionsUpdated(_) + | AcpThreadEvent::WorkingDirectoriesUpdated => {} + }); let git_store = project.read(cx).git_store().clone(); let _git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| { @@ -2321,6 +2530,10 @@ impl AcpThread { prompt_capabilities, available_commands: Vec::new(), _observe_prompt_capabilities: task, + _idle_sleep_subscriptions: vec![ + idle_sleep_settings_subscription, + idle_sleep_event_subscription, + ], terminals: HashMap::default(), pending_terminal_output: HashMap::default(), pending_terminal_exit: HashMap::default(), @@ -2328,6 +2541,7 @@ impl AcpThread { draft_prompt: None, ui_scroll_position: None, streaming_text_buffer: None, + idle_sleep_prevention: IdleSleepPrevention::Inactive, } } @@ -2395,7 +2609,7 @@ impl AcpThread { } pub fn is_compacting(&self) -> bool { - self.entries.last().is_some_and(|entry| { + self.entries.iter().rev().any(|entry| { matches!( entry, AgentThreadEntry::ContextCompaction(compaction) if compaction.is_in_progress() @@ -2414,7 +2628,7 @@ impl AcpThread { AssistantMessageChunk::Message { block, .. } => block, AssistantMessageChunk::Thought { block, .. } => block, }; - if let Some(markdown) = block.markdown() { + for markdown in block.markdowns() { markdown.update(cx, |markdown, cx| { markdown.invalidate_mermaid_cache(cx); }); @@ -2607,6 +2821,12 @@ impl AcpThread { acp::SessionUpdate::ToolCallUpdate(tool_call_update) => { self.update_tool_call(tool_call_update, cx)?; } + acp::SessionUpdate::CompactionUpdate(compaction_update) => { + self.upsert_context_compaction_update(compaction_update, cx); + } + acp::SessionUpdate::CompactionSummaryChunk(summary_chunk) => { + self.append_context_compaction_summary(summary_chunk, cx); + } acp::SessionUpdate::Plan(plan) => { self.update_plan(plan, cx); } @@ -2727,12 +2947,13 @@ impl AcpThread { if existing_protocol_id.is_none() { *existing_protocol_id = protocol_id; } - content.append(chunk.clone(), &language_registry, path_style, cx); + content.append_prompt(chunk.clone(), &language_registry, path_style, cx); chunks.push(chunk); let idx = entries_len - 1; cx.emit(AcpThreadEvent::EntryUpdated(idx)); } else { - let content = ContentBlock::new(chunk.clone(), &language_registry, path_style, cx); + let mut content = MessageContent::default(); + content.append_prompt(chunk.clone(), &language_registry, path_style, cx); self.push_entry( AgentThreadEntry::UserMessage(UserMessage { protocol_id, @@ -2824,7 +3045,7 @@ impl AcpThread { block.append(chunk, &language_registry, path_style, cx) } _ => { - let block = ContentBlock::new(chunk, &language_registry, path_style, cx); + let block = MessageContent::new(chunk, &language_registry, path_style, cx); if is_thought { chunks.push(AssistantMessageChunk::Thought { id: message_id, @@ -2839,7 +3060,7 @@ impl AcpThread { } } } else { - let block = ContentBlock::new(chunk, &language_registry, path_style, cx); + let block = MessageContent::new(chunk, &language_registry, path_style, cx); let chunk = if is_thought { AssistantMessageChunk::Thought { id: message_id, @@ -2882,17 +3103,18 @@ impl AcpThread { ( AssistantMessageChunk::Message { id: existing_id, - block: ContentBlock::Markdown { markdown }, + block, }, false, ) | ( AssistantMessageChunk::Thought { id: existing_id, - block: ContentBlock::Markdown { markdown }, + block, }, true, ) if can_merge_message_chunks(existing_id.as_ref(), message_id) => { + let markdown = block.trailing_text()?; if existing_id.is_none() { *existing_id = message_id.cloned(); } @@ -3025,6 +3247,67 @@ impl AcpThread { } } + fn upsert_context_compaction_update( + &mut self, + update: acp::CompactionUpdate, + cx: &mut Context, + ) { + let id = ContextCompactionId(update.compaction_id.0.clone()); + let language_registry = self.project.read(cx).languages().clone(); + + if let Some((entry_index, compaction)) = + self.entries + .iter_mut() + .enumerate() + .rev() + .find_map(|(entry_index, entry)| match entry { + AgentThreadEntry::ContextCompaction(compaction) if compaction.id == id => { + Some((entry_index, compaction)) + } + _ => None, + }) + { + compaction.apply_update(update, &language_registry, cx); + cx.emit(AcpThreadEvent::EntryUpdated(entry_index)); + return; + } + + let mut compaction = ContextCompaction { + id, + status: update.status.clone().into(), + error: None, + summary: Vec::new(), + }; + compaction.apply_update(update, &language_registry, cx); + self.push_entry(AgentThreadEntry::ContextCompaction(compaction), cx); + } + + fn append_context_compaction_summary( + &mut self, + chunk: acp::CompactionSummaryChunk, + cx: &mut Context, + ) { + let language_registry = self.project.read(cx).languages().clone(); + if let Some((entry_index, compaction)) = + self.entries + .iter_mut() + .enumerate() + .rev() + .find_map(|(entry_index, entry)| match entry { + AgentThreadEntry::ContextCompaction(compaction) + if compaction.id.0 == chunk.compaction_id.0 + && compaction.is_in_progress() => + { + Some((entry_index, compaction)) + } + _ => None, + }) + { + compaction.append_summary(chunk.content, &language_registry, cx); + cx.emit(AcpThreadEvent::EntryUpdated(entry_index)); + } + } + pub fn update_context_compaction( &mut self, update: ContextCompactionUpdate, @@ -3045,20 +3328,11 @@ impl AcpThread { }; if !update.summary_delta.is_empty() { - if compaction.summary.is_none() { - compaction.summary = Some(cx.new(|cx| { - Markdown::new( - update.summary_delta.into(), - Some(language_registry), - None, - cx, - ) - })); - } else if let Some(summary) = compaction.summary.clone() { - summary.update(cx, |markdown, cx| { - markdown.append(&update.summary_delta, cx) - }); - } + compaction.append_summary( + acp::ContentBlock::Text(acp::TextContent::new(update.summary_delta)), + &language_registry, + cx, + ); } if let Some(status) = update.status { @@ -3119,7 +3393,6 @@ impl AcpThread { ) -> Result<()> { let update = update.into(); let languages = self.project.read(cx).languages().clone(); - let path_style = self.project.read(cx).path_style(cx); let ix = match self.index_for_tool_call(update.id()) { Some(ix) => ix, @@ -3128,11 +3401,11 @@ impl AcpThread { let failed_tool_call = ToolCall { id: update.id().clone(), label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), + title: Some("Tool call not found".into()), kind: acp::ToolKind::Fetch, - content: vec![ToolCallContent::ContentBlock(ContentBlock::new( + content: vec![ToolCallContent::ContentBlock(ContentBlock::new_output( "Tool call not found".into(), &languages, - path_style, cx, ))], status: ToolCallStatus::Failed, @@ -3158,14 +3431,12 @@ impl AcpThread { match update { ToolCallUpdate::UpdateFields(update) => { let location_updated = update.fields.locations.is_some(); - call.update_fields( - update.fields, - update.meta, - languages, - path_style, - &self.terminals, - cx, - )?; + if let Err(error) = + call.update_fields(update.fields, update.meta, languages, &self.terminals, cx) + { + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + return Err(error); + } if location_updated { self.resolve_locations(update.tool_call_id, cx); } @@ -3204,7 +3475,6 @@ impl AcpThread { cx: &mut Context, ) -> Result<(), acp::Error> { let language_registry = self.project.read(cx).languages().clone(); - let path_style = self.project.read(cx).path_style(cx); let id = update.tool_call_id.clone(); let agent_telemetry_id = self.connection().telemetry_id(); @@ -3230,14 +3500,16 @@ impl AcpThread { unreachable!() }; - call.update_fields( + if let Err(error) = call.update_fields( update.fields, update.meta, language_registry, - path_style, &self.terminals, cx, - )?; + ) { + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + return Err(acp::Error::from(error)); + } call.update_status(status); cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -3246,7 +3518,6 @@ impl AcpThread { update.try_into()?, status, language_registry, - self.project.read(cx).path_style(cx), &self.terminals, cx, )?; @@ -3653,12 +3924,12 @@ impl AcpThread { push_user_message: bool, cx: &mut Context, ) -> BoxFuture<'static, Result>> { - let block = ContentBlock::new_combined( - message.clone(), - self.project.read(cx).languages().clone(), - self.project.read(cx).path_style(cx), - cx, - ); + let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); + let mut block = MessageContent::default(); + for chunk in &message { + block.append_prompt(chunk.clone(), &language_registry, path_style, cx); + } let request = acp::PromptRequest::new(self.session_id.clone(), message.clone()); let git_store = self.project.read(cx).git_store().clone(); @@ -3753,14 +4024,21 @@ impl AcpThread { }); cx.emit(AcpThreadEvent::StatusChanged); - cx.spawn(async move |this, cx| { + let completion = async move |thread: WeakEntity, cx: &mut AsyncApp| { let response = rx.await; - this.update(cx, |this, cx| this.update_last_checkpoint(cx))? + thread + .update(cx, |this, cx| { + if this.turn_id == turn_id { + this.update_last_checkpoint(cx) + } else { + Task::ready(Ok(())) + } + })? .await?; - this.update(cx, |this, cx| { - if this.parent_session_id.is_none() { + thread.update(cx, |this, cx| { + if this.turn_id == turn_id && this.parent_session_id.is_none() { this.project .update(cx, |project, cx| project.set_agent_location(None, cx)); } @@ -3787,6 +4065,10 @@ impl AcpThread { return Ok(None); }; + if this.turn_id != turn_id { + return response.map(Some); + } + match response { Ok(r) => { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); @@ -3894,8 +4176,13 @@ impl AcpThread { } } })? + }; + let (completion_tx, completion_rx) = oneshot::channel(); + cx.spawn(async move |thread, cx| { + completion_tx.send(completion(thread, cx).await).ok(); }) - .boxed() + .detach(); + async move { completion_rx.await? }.boxed() } pub fn cancel(&mut self, cx: &mut Context) -> Task<()> { @@ -3921,6 +4208,38 @@ impl AcpThread { cx.background_spawn(turn.send_task) } + fn update_idle_sleep_prevention(&mut self, cx: &mut Context) { + if !AgentSettings::get_global(cx).prevent_idle_sleep + || self.running_turn.is_none() + || self.is_waiting_for_confirmation() + { + self.idle_sleep_prevention = IdleSleepPrevention::Inactive; + return; + } + + if !matches!(self.idle_sleep_prevention, IdleSleepPrevention::Inactive) { + return; + } + + let acquisition = cx.prevent_idle_sleep("Agent thread in progress"); + self.idle_sleep_prevention = IdleSleepPrevention::Acquiring { + _task: cx.spawn(async move |thread, cx| { + let result = acquisition.await; + thread + .update(cx, |thread, _| { + thread.idle_sleep_prevention = match result { + Ok(guard) => IdleSleepPrevention::Active { _guard: guard }, + Err(error) => { + log::error!("Failed to prevent idle sleep: {error:#}"); + IdleSleepPrevention::Failed + } + }; + }) + .log_err(); + }), + }; + } + fn cancel_pending_turn_entries(&mut self, cx: &mut Context) { self.mark_pending_entries_as_canceled(RequestPermissionOutcome::Cancelled, cx); self.cancel_outstanding_elicitations(cx); @@ -4649,30 +4968,46 @@ impl AcpThread { output_byte_limit, terminal, } => { - let entity = self.register_terminal_created( - terminal_id.clone(), - label, - cwd, - output_byte_limit, - terminal, - cx, - ); + if self.terminals.contains_key(&terminal_id) { + return; + } + let language_registry = self.project.read(cx).languages().clone(); + let entity = cx.new(|cx| { + Terminal::new_display( + terminal_id.clone(), + &label, + cwd, + output_byte_limit.map(|limit| limit as usize), + terminal, + language_registry, + cx, + ) + }); + // Provider output and exit can arrive without another tool-call update. + cx.observe(&entity, |this, terminal, cx| { + for (index, entry) in this.entries.iter().enumerate() { + if entry + .terminals() + .any(|entry_terminal| entry_terminal == &terminal) + { + cx.emit(AcpThreadEvent::EntryUpdated(index)); + } + } + cx.notify(); + }) + .detach(); + self.terminals.insert(terminal_id.clone(), entity.clone()); if let Some(mut chunks) = self.pending_terminal_output.remove(&terminal_id) { for data in chunks.drain(..) { entity.update(cx, |term, cx| { - term.inner().update(cx, |inner, cx| { - inner.write_output(&data, cx); - }) + term.write_display_output(&data, cx); }); } } - if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) { - entity.update(cx, |term, cx| { - term.inner().update(cx, |inner, _| inner.shrink_to_used()); - cx.notify(); - }); + if let Some(status) = self.pending_terminal_exit.remove(&terminal_id) { + entity.update(cx, |term, cx| term.finish_display(status, cx)); } cx.notify(); @@ -4680,9 +5015,7 @@ impl AcpThread { TerminalProviderEvent::Output { terminal_id, data } => { if let Some(entity) = self.terminals.get(&terminal_id) { entity.update(cx, |term, cx| { - term.inner().update(cx, |inner, cx| { - inner.write_output(&data, cx); - }) + term.write_display_output(&data, cx); }); } else { self.pending_terminal_output @@ -4706,12 +5039,11 @@ impl AcpThread { status, } => { if let Some(entity) = self.terminals.get(&terminal_id) { - entity.update(cx, |term, cx| { - term.inner().update(cx, |inner, _| inner.shrink_to_used()); - cx.notify(); - }); + entity.update(cx, |term, cx| term.finish_display(status, cx)); } else { - self.pending_terminal_exit.insert(terminal_id, status); + self.pending_terminal_exit + .entry(terminal_id) + .or_insert(status); } } } @@ -4852,6 +5184,378 @@ mod tests { }); } + fn message_test_image() -> acp::ContentBlock { + acp::ContentBlock::Image(acp::ImageContent::new( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "image/png", + )) + } + + #[gpui::test] + fn test_message_content_preserves_mixed_order(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + let language_registry = + Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + for (chunks, expected_kinds, expected_export) in [ + ( + vec!["before".into(), message_test_image()], + vec!["text", "image"], + "before\n\n`Image`", + ), + ( + vec![message_test_image(), "after".into()], + vec!["image", "text"], + "`Image`\n\nafter", + ), + ( + vec![message_test_image(), message_test_image()], + vec!["image", "image"], + "`Image`\n\n`Image`", + ), + ( + vec!["".into(), message_test_image()], + vec!["image"], + "`Image`", + ), + ] { + let mut content = MessageContent::default(); + for chunk in chunks { + content.append(chunk, &language_registry, PathStyle::local(), cx); + } + assert_eq!( + content + .blocks() + .iter() + .map(|block| match block { + ContentBlock::Markdown { .. } => "text", + ContentBlock::Image { .. } => "image", + other => panic!("unexpected block {other:?}"), + }) + .collect::>(), + expected_kinds, + ); + assert_eq!(content.to_markdown(cx), expected_export); + assert!(content.visible_content(cx)); + } + }); + } + + #[gpui::test] + fn test_message_content_resources_and_fallbacks(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + let language_registry = + Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let mut content = MessageContent::default(); + for chunk in [ + "before".into(), + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("resource text", "tool://preview"), + ), + )), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "link", + "https://example.com/resource", + )), + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::BlobResourceContents( + acp::BlobResourceContents::new("private-blob-data", "tool://archive.bin"), + ), + )), + acp::ContentBlock::Image(acp::ImageContent::new("not-base64", "image/png")), + acp::ContentBlock::Audio(acp::AudioContent::new("private-audio-data", "audio/wav")), + "after".into(), + ] { + content.append(chunk, &language_registry, PathStyle::local(), cx); + } + assert!(matches!( + content.blocks(), + [ + ContentBlock::Markdown { .. }, + ContentBlock::EmbeddedResource { .. }, + ContentBlock::Markdown { .. }, + ContentBlock::EmbeddedResource { markdown: None, .. }, + ContentBlock::Unsupported { .. }, + ContentBlock::Unsupported { .. }, + ContentBlock::Markdown { .. }, + ] + )); + assert_eq!( + content.to_markdown(cx), + concat!( + "before\n\n```\nresource text\n```\n\n", + "[@https://example.com/resource](https://example.com/resource)\n\n", + "tool://archive.bin\n\n", + "Image content could not be displayed.\n\n", + "Audio content is not supported.\n\nafter", + ) + ); + assert_eq!( + content + .markdowns() + .map(|markdown| markdown.read(cx).source()) + .collect::>(), + [ + "before", + "```\nresource text\n```", + "[@https://example.com/resource](https://example.com/resource)", + "Image content could not be displayed.", + "Audio content is not supported.", + "after", + ] + ); + assert!(!content.to_markdown(cx).contains("private-audio-data")); + assert!(!content.to_markdown(cx).contains("private-blob-data")); + }); + } + + #[gpui::test] + async fn test_message_content_preserves_streaming_entities(cx: &mut TestAppContext) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("session should be created"); + + let (before, after, image) = thread.update(cx, |thread, cx| { + thread.push_assistant_content_block("before".into(), false, cx); + let before = thread + .streaming_markdown_target(None, false, false) + .expect("text should have a streaming target"); + thread.push_assistant_content_block(" image".into(), false, cx); + assert!(thread.streaming_text_buffer.is_some()); + thread.push_assistant_content_block(message_test_image(), false, cx); + assert!(thread.streaming_text_buffer.is_none()); + assert_eq!(before.read(cx).source(), "before image"); + assert!( + thread + .streaming_markdown_target(None, false, false) + .is_none() + ); + + thread.push_assistant_content_block("after".into(), false, cx); + let after = thread + .streaming_markdown_target(None, false, false) + .expect("text after an image should have its own streaming target"); + assert_ne!(before.entity_id(), after.entity_id()); + thread.push_assistant_content_block(" image".into(), false, cx); + assert_eq!( + thread + .streaming_text_buffer + .as_ref() + .expect("streaming text") + .target, + after, + ); + + let [AgentThreadEntry::AssistantMessage(message)] = thread.entries() else { + panic!("expected one assistant message"); + }; + let [AssistantMessageChunk::Message { block, .. }] = message.chunks.as_slice() else { + panic!("expected one message chunk"); + }; + let [_, ContentBlock::Image { image, .. }, _] = block.blocks() else { + panic!("expected text, image, text"); + }; + (before, after, image.clone()) + }); + + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + + thread.read_with(cx, |thread, cx| { + assert_eq!(after.read(cx).source(), "after image"); + let [AgentThreadEntry::AssistantMessage(message)] = thread.entries() else { + panic!("expected one assistant message"); + }; + let [AssistantMessageChunk::Message { block, .. }] = message.chunks.as_slice() else { + panic!("expected one message chunk"); + }; + let [ + ContentBlock::Markdown { markdown: first }, + ContentBlock::Image { + image: current_image, + .. + }, + ContentBlock::Markdown { markdown: last }, + ] = block.blocks() + else { + panic!("expected text, image, text"); + }; + assert_eq!(first, &before); + assert_eq!(last, &after); + assert!(Arc::ptr_eq(&image, current_image)); + }); + + let whole = cx.update(|cx| { + let language_registry = + Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let mut content = MessageContent::default(); + for chunk in [ + "before image".into(), + message_test_image(), + "after image".into(), + ] { + content.append(chunk, &language_registry, PathStyle::local(), cx); + } + content.to_markdown(cx) + }); + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + format!("## Assistant\n\n{whole}\n\n") + ); + }); + + for (content, preview) in [ + ( + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("Resource preview", "tool://preview") + .mime_type("text/markdown".to_string()), + ), + )), + "Resource preview", + ), + ( + acp::ContentBlock::Audio(acp::AudioContent::new("private-audio-data", "audio/wav")), + "Audio content is not supported.", + ), + ] { + thread.update(cx, |thread, cx| { + thread.push_assistant_content_block(content, false, cx); + assert!( + thread + .streaming_markdown_target(None, false, false) + .is_none() + ); + let Some(AgentThreadEntry::AssistantMessage(message)) = thread.entries().last() + else { + panic!("expected assistant output"); + }; + let Some(AssistantMessageChunk::Message { block, .. }) = message.chunks.last() + else { + panic!("expected message content"); + }; + let preview_markdown = block + .blocks() + .last() + .and_then(ContentBlock::markdown) + .expect("Markdown-backed non-text content") + .clone(); + thread.push_assistant_content_block("Following".into(), false, cx); + let target = thread + .streaming_markdown_target(None, false, false) + .expect("new text target"); + assert_ne!(target, preview_markdown); + thread.push_assistant_content_block(" text".into(), false, cx); + AcpThread::flush_streaming_text(&mut thread.streaming_text_buffer, cx); + assert_eq!(target.read(cx).source(), "Following text"); + assert_eq!(preview_markdown.read(cx).source(), preview); + }); + } + + let (streamed, expected) = thread.update(cx, |thread, cx| { + let path_style = thread.project.read(cx).path_style(cx); + let languages = thread.project.read(cx).languages().clone(); + let uri = "file:///project/report.md"; + let link = acp::ContentBlock::ResourceLink(acp::ResourceLink::new("report", uri)); + let expected = format!( + "{}\n## Details\n", + ContentBlock::resource_link_md(uri, path_style), + ); + let mut direct = MessageContent::new(link.clone(), &languages, path_style, cx); + direct.append("## Details\n".into(), &languages, path_style, cx); + assert_eq!(direct.to_markdown(cx), expected); + + thread.push_user_content_block(None, "Next response".into(), cx); + thread.push_assistant_content_block(link, false, cx); + let target = thread + .streaming_markdown_target(None, false, false) + .expect("leading link should retain its Markdown entity"); + thread.push_assistant_content_block("## Det".into(), false, cx); + thread.push_assistant_content_block("ails\n".into(), false, cx); + assert_eq!( + thread + .streaming_text_buffer + .as_ref() + .expect("buffered text") + .target, + target, + ); + (target, expected) + }); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + streamed.read_with(cx, |markdown, _| assert_eq!(markdown.source(), &expected)); + } + + #[gpui::test] + async fn test_message_content_preserves_prompt_mentions_and_raw_chunks( + cx: &mut TestAppContext, + ) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("session should be created"); + let chunks = vec![ + "read ".into(), + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + "attached file contents", + "https://example.com/file", + ), + ), + )), + " and ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "link", + "https://example.com/link", + )), + message_test_image(), + ]; + + thread.update(cx, |thread, cx| { + for chunk in &chunks { + thread.push_user_content_block(None, chunk.clone(), cx); + } + let [AgentThreadEntry::UserMessage(message)] = thread.entries() else { + panic!("expected one user message"); + }; + assert_eq!(message.chunks, chunks); + assert!(matches!( + message.content.blocks(), + [ContentBlock::Markdown { .. }, ContentBlock::Image { .. }] + )); + let expected = format!( + "read {} and {}\n\n`Image`", + ContentBlock::resource_link_md("https://example.com/file", PathStyle::local()), + ContentBlock::resource_link_md("https://example.com/link", PathStyle::local()), + ); + assert_eq!(message.content.to_markdown(cx), expected); + assert!( + !message + .content + .to_markdown(cx) + .contains("attached file contents") + ); + }); + } + #[test] fn text_resource_markdown_uses_mime_type_for_code_blocks() { let shell = acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview") @@ -4905,12 +5609,7 @@ mod tests { ), )); - let block = ContentBlock::new_tool_call_content( - content, - &language_registry, - PathStyle::local(), - cx, - ); + let block = ContentBlock::new_output(content, &language_registry, cx); let ContentBlock::EmbeddedResource { resource, markdown } = &block else { panic!("expected embedded resource block, got {block:?}"); @@ -4937,14 +5636,13 @@ mod tests { ); assert_eq!(block.text_content(cx), Some("echo 'hello from exec test'")); - let untyped = ContentBlock::new_tool_call_content( + let untyped = ContentBlock::new_output( acp::ContentBlock::Resource(acp::EmbeddedResource::new( acp::EmbeddedResourceResource::TextResourceContents( acp::TextResourceContents::new("# plain preview", "tool://preview"), ), )), &language_registry, - PathStyle::local(), cx, ); assert_eq!(untyped.to_markdown(cx), "```\n# plain preview\n```"); @@ -4971,10 +5669,9 @@ mod tests { ), )); - let block = ContentBlock::new_tool_call_content( + let block = ContentBlock::new_output( image_blob, &language_registry, - PathStyle::local(), cx, ); @@ -5007,12 +5704,7 @@ mod tests { ), )); - let block = ContentBlock::new_tool_call_content( - archive_blob, - &language_registry, - PathStyle::local(), - cx, - ); + let block = ContentBlock::new_output(archive_blob, &language_registry, cx); let ContentBlock::EmbeddedResource { resource, markdown } = &block else { panic!("expected embedded resource block, got {block:?}"); @@ -5034,12 +5726,7 @@ mod tests { .mime_type("image/png".to_string()), ), )); - let invalid = ContentBlock::new_tool_call_content( - invalid_image_blob, - &language_registry, - PathStyle::local(), - cx, - ); + let invalid = ContentBlock::new_output(invalid_image_blob, &language_registry, cx); let ContentBlock::EmbeddedResource { resource, markdown } = &invalid else { panic!("expected embedded resource block, got {invalid:?}"); }; @@ -5179,6 +5866,22 @@ mod tests { cx, ); }); + cx.run_until_parked(); + thread.update(cx, |thread, cx| { + let terminal = thread + .terminal(terminal_id.clone()) + .expect("display terminal"); + terminal.update(cx, |terminal, cx| { + assert!(!terminal.is_process_backed()); + assert!(terminal.wait_for_exit().is_err()); + terminal.stop_by_user(cx); + assert!(!terminal.was_stopped_by_user()); + assert!( + terminal.output().is_none(), + "a display terminal must wait for the provider's exit event", + ); + }); + }); let mut output = String::new(); for line in 0..15_000 { @@ -5193,17 +5896,36 @@ mod tests { }, cx, ); + }); + cx.run_until_parked(); + thread.read_with(cx, |thread, cx| { + let terminal = thread + .terminal(terminal_id.clone()) + .expect("display terminal"); + let output = terminal.read(cx).current_output(cx); + assert!(output.output.contains("line 14999")); + assert!(output.exit_status.is_none()); + }); + + thread.update(cx, |thread, cx| { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id.clone(), - status: acp::TerminalExitStatus::new().exit_code(0), + status: acp::TerminalExitStatus::new().exit_code(7), }, cx, ); }); + cx.run_until_parked(); let content = thread.read_with(cx, |thread, cx| { let term = thread.terminal(terminal_id.clone()).unwrap(); + let output = term.read(cx).current_output(cx); + assert_eq!( + output.exit_status.and_then(|status| status.exit_code), + Some(7) + ); + assert!(output.output.contains("line 14999")); term.read_with(cx, |term, cx| term.inner().read(cx).get_content()) }); @@ -5211,6 +5933,68 @@ mod tests { content.contains("line 14999"), "expected output to remain visible after terminal exit, got: {content}" ); + + let terminal = thread.read_with(cx, |thread, _| { + thread + .terminal(terminal_id.clone()) + .expect("display terminal") + }); + let ended_at = terminal.read_with(cx, |terminal, _| { + terminal.output().expect("completed output").ended_at + }); + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "Duplicate".to_string(), + cwd: None, + output_byte_limit: None, + terminal: lower.clone(), + }, + cx, + ); + assert_eq!( + thread + .terminal(terminal_id.clone()) + .expect("terminal") + .entity_id(), + terminal.entity_id(), + ); + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id.clone(), + status: acp::TerminalExitStatus::new().signal("SIGTERM"), + }, + cx, + ); + }); + terminal.read_with(cx, |terminal, _| { + let output = terminal.output().expect("completed output"); + assert_eq!(output.ended_at, ended_at); + assert_eq!(output.exit_status.exit_code, Some(7)); + assert_eq!(output.exit_status.signal, None); + assert_eq!(output.content, content); + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id, + data: b"late output\n".to_vec(), + }, + cx, + ); + }); + terminal.read_with(cx, |terminal, cx| { + let output = terminal.output().expect("completed output"); + assert_eq!(output.ended_at, ended_at); + assert_eq!(output.exit_status.exit_code, Some(7)); + assert_eq!(output.exit_status.signal, None); + assert!(output.content.contains("late output")); + assert_eq!(output.content, lower.read(cx).get_content()); + assert_eq!(output.original_content_len, output.content.len()); + assert_eq!(output.content_line_count, lower.read(cx).total_lines()); + }); } #[gpui::test] @@ -5246,6 +6030,13 @@ mod tests { // Send Exit BEFORE Created thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id.clone(), + status: acp::TerminalExitStatus::new().signal("SIGTERM"), + }, + cx, + ); thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id.clone(), @@ -5253,6 +6044,13 @@ mod tests { }, cx, ); + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: b"\nlate output".to_vec(), + }, + cx, + ); }); // Now create a display-only lower-level terminal and send Created @@ -5284,6 +6082,12 @@ mod tests { // Output should be present after Created (flushed from buffer) let content = thread.read_with(cx, |thread, cx| { let term = thread.terminal(terminal_id.clone()).unwrap(); + let output = term.read(cx).current_output(cx); + let exit_status = output.exit_status.expect("buffered exit status"); + assert_eq!(exit_status.signal.as_deref(), Some("SIGTERM")); + assert_eq!(exit_status.exit_code, None); + assert!(output.output.contains("pre-exit data")); + assert!(output.output.contains("late output")); term.read_with(cx, |t, cx| t.inner().read(cx).get_content()) }); @@ -5303,12 +6107,24 @@ mod tests { #[cfg(unix)] #[gpui::test] async fn test_terminal_kill_allows_wait_for_exit_to_complete(cx: &mut gpui::TestAppContext) { + assert_process_terminal_can_stop(false, cx).await; + } + + #[cfg(unix)] + #[gpui::test] + async fn test_headless_terminal_retains_process_control(cx: &mut gpui::TestAppContext) { + assert_process_terminal_can_stop(true, cx).await; + } + + #[cfg(unix)] + async fn assert_process_terminal_can_stop(headless: bool, cx: &mut gpui::TestAppContext) { use std::collections::HashMap; use task::Shell; use util::shell_builder::ShellBuilder; init_test(cx); cx.executor().allow_parking(); + cx.update(|cx| cx.set_global(::terminal::HeadlessTerminal(headless))); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; @@ -5326,7 +6142,6 @@ mod tests { let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); - // Create a real PTY terminal that runs a command which prints output then sleeps // We use printf instead of echo and chain with && sleep to ensure proper execution let (program, args) = ShellBuilder::new(&Shell::System, false).build( Some("printf 'output_before_kill\\n' && sleep 60".to_owned()), @@ -5365,49 +6180,61 @@ mod tests { .unwrap(); let lower_terminal = cx.new(|cx| builder.subscribe(cx)); + assert_eq!( + lower_terminal.read_with(cx, |terminal, _| terminal.is_pty()), + !headless + ); // Create the acp_thread Terminal wrapper thread.update(cx, |thread, cx| { - thread.on_terminal_provider_event( - TerminalProviderEvent::Created { - terminal_id: terminal_id.clone(), - label: "printf output_before_kill && sleep 60".to_string(), - cwd: None, - output_byte_limit: None, - terminal: lower_terminal.clone(), - }, + let terminal = thread.register_terminal_created( + terminal_id.clone(), + "printf output_before_kill && sleep 60".to_string(), + None, + None, + lower_terminal.clone(), cx, ); + assert!(terminal.read(cx).is_process_backed()); }); // Poll until the printf command produces output, rather than using a // fixed sleep which is flaky on loaded machines. - let deadline = std::time::Instant::now() + Duration::from_secs(10); - loop { - let has_output = thread.read_with(cx, |thread, cx| { - let term = thread - .terminals - .get(&terminal_id) - .expect("terminal not found"); - let content = term.read(cx).inner().read(cx).get_content(); - content.contains("output_before_kill") - }); - if has_output { - break; + if !headless { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let has_output = thread.read_with(cx, |thread, cx| { + let term = thread + .terminals + .get(&terminal_id) + .expect("terminal not found"); + let content = term.read(cx).inner().read(cx).get_content(); + content.contains("output_before_kill") + }); + if has_output { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Timed out waiting for printf output to appear in terminal", + ); + cx.executor().timer(Duration::from_millis(50)).await; } - assert!( - std::time::Instant::now() < deadline, - "Timed out waiting for printf output to appear in terminal", - ); - cx.executor().timer(Duration::from_millis(50)).await; } // Get the acp_thread Terminal and kill it + let killed_at = std::time::Instant::now(); let wait_for_exit = thread.update(cx, |thread, cx| { let term = thread.terminals.get(&terminal_id).unwrap(); - let wait_for_exit = term.read(cx).wait_for_exit(); + let wait_for_exit = term.read(cx).wait_for_exit().expect("process terminal"); term.update(cx, |term, cx| { - term.kill(cx); + assert!(term.output().is_none(), "the process must still be running"); + if headless { + term.stop_by_user(cx); + assert!(term.was_stopped_by_user()); + } else { + term.kill(cx); + } }); wait_for_exit }); @@ -5426,22 +6253,28 @@ mod tests { "wait_for_exit should complete after kill, but it timed out. \ This indicates kill_active_task is not properly killing the shell process." ); + assert!( + killed_at.elapsed() < Duration::from_secs(5), + "the process must stop without waiting for its natural exit", + ); // Give the system a chance to process any pending updates cx.run_until_parked(); - // Verify that the underlying terminal still has the output that was - // written before the kill. This verifies that killing doesn't lose output. - let inner_content = thread.read_with(cx, |thread, cx| { - let term = thread.terminals.get(&terminal_id).unwrap(); - term.read(cx).inner().read(cx).get_content() - }); + if !headless { + // Verify that the underlying terminal still has the output that was + // written before the kill. This verifies that killing doesn't lose output. + let inner_content = thread.read_with(cx, |thread, cx| { + let term = thread.terminals.get(&terminal_id).unwrap(); + term.read(cx).inner().read(cx).get_content() + }); - assert!( - inner_content.contains("output_before_kill"), - "Underlying terminal should contain output from before kill, got: {}", - inner_content - ); + assert!( + inner_content.contains("output_before_kill"), + "Underlying terminal should contain output from before kill, got: {}", + inner_content + ); + } } #[gpui::test] @@ -5885,7 +6718,8 @@ mod tests { ContextCompaction { id: ContextCompactionId("c1".into()), status: ContextCompactionStatus::Completed, - summary: None, + error: None, + summary: Vec::new(), }, cx, ); @@ -6348,6 +7182,713 @@ mod tests { }); } + #[gpui::test] + async fn test_context_compaction_session_updates(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + + thread.update(cx, |thread, cx| { + for summary in ["retained context", "replacement summary"] { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new( + "compaction", + acp::CompactionStatus::Completed, + ) + .summary(vec![acp::ContentBlock::Text( + acp::TextContent::new(summary), + )]), + ), + cx, + ) + .expect("failed to set context compaction summary"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must exist"); + }; + assert_eq!(compaction.status, ContextCompactionStatus::Completed); + assert!(compaction.error.is_none()); + assert_eq!( + compaction + .summary + .first() + .map(|block| block.to_markdown(cx)), + Some(summary) + ); + assert_eq!( + thread.to_markdown(cx), + format!("## Context Compaction (Completed)\n\n{summary}\n\n") + ); + } + + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Failed) + .summary(None::>) + .error("model unavailable"), + ), + cx, + ) + .expect("failed to record context compaction failure"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must still exist"); + }; + assert_eq!(compaction.status, ContextCompactionStatus::Failed); + assert!(compaction.summary.is_empty()); + assert_eq!( + compaction + .error + .as_ref() + .map(|error| error.read(cx).source().as_ref()), + Some("model unavailable") + ); + assert_eq!( + thread.to_markdown(cx), + "## Context Compaction (Failed)\n\n**Error:** model unavailable\n\n" + ); + + for error in [ + MaybeUndefined::Undefined, + MaybeUndefined::Value("model *still* unavailable".to_string()), + MaybeUndefined::Null, + ] { + let expected_error = match &error { + MaybeUndefined::Undefined => Some("model unavailable".to_string()), + MaybeUndefined::Value(error) => Some(error.clone()), + MaybeUndefined::Null => None, + }; + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Failed) + .error(error), + ), + cx, + ) + .expect("failed to patch compaction error"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must still exist"); + }; + assert_eq!( + compaction + .error + .as_ref() + .map(|error| error.read(cx).source().to_string()), + expected_error + ); + if let Some(error) = expected_error { + assert!( + thread + .to_markdown(cx) + .contains(&format!("**Error:** {}", MarkdownEscaped(&error))) + ); + } + } + + for summary in [ + vec![acp::ContentBlock::Text(acp::TextContent::new(""))], + Vec::new(), + ] { + let expected_length = summary.len(); + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new( + "compaction", + acp::CompactionStatus::Completed, + ) + .summary(summary), + ), + cx, + ) + .expect("failed to patch compaction summary"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must still exist"); + }; + assert_eq!(compaction.summary.len(), expected_length); + } + }); + } + + #[gpui::test] + async fn test_context_compaction_preserves_summary_content(cx: &mut TestAppContext) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + let text_resource = + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("Retained resource text", "summary://context") + .mime_type("text/markdown".to_string()), + )); + let blob_resource = + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::BlobResourceContents( + acp::BlobResourceContents::new("c3VtbWFyeQ==", "summary://attachment") + .mime_type("application/octet-stream".to_string()), + )); + let audio = acp::ContentBlock::Audio(acp::AudioContent::new("YXVkaW8=", "audio/wav")); + let blocks = vec![ + acp::ContentBlock::Text(acp::TextContent::new("retained ")), + acp::ContentBlock::Text(acp::TextContent::new("context")), + acp::ContentBlock::Resource(text_resource.clone()), + acp::ContentBlock::Image(acp::ImageContent::new(image_data, "image/png")), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "Reference", + "https://example.com/context", + )), + audio.clone(), + acp::ContentBlock::Resource(blob_resource.clone()), + ]; + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "compaction", + acp::CompactionStatus::InProgress, + )), + cx, + ) + .expect("failed to start compaction"); + for block in &blocks { + thread + .handle_session_update( + acp::SessionUpdate::CompactionSummaryChunk( + acp::CompactionSummaryChunk::new("compaction", block.clone()), + ), + cx, + ) + .expect("failed to append summary block"); + } + }); + + for replace_summary in [false, true] { + thread.update(cx, |thread, cx| { + let mut update = + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Completed); + if replace_summary { + update = update.summary(blocks.clone()); + } + thread + .handle_session_update(acp::SessionUpdate::CompactionUpdate(update), cx) + .expect("failed to complete compaction"); + }); + thread.read_with(cx, |thread, cx| { + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.first() + else { + panic!("expected compaction entry"); + }; + let [text, resource, image, link, unsupported, blob] = + compaction.summary.as_slice() + else { + panic!("expected six retained content blocks"); + }; + assert_eq!(text.to_markdown(cx), "retained context"); + assert_eq!( + resource.embedded_resource().map(|(resource, _)| resource), + Some(&text_resource) + ); + assert_eq!(resource.to_markdown(cx), "Retained resource text"); + assert_eq!( + image + .image() + .and_then(|(_, dimensions)| dimensions) + .map(|size| (size.width, size.height)), + Some((1, 1)) + ); + assert_eq!( + link.resource_link().map(|link| link.uri.as_str()), + Some("https://example.com/context") + ); + let ContentBlock::Unsupported { content, .. } = unsupported else { + panic!("expected preserved audio with a visible fallback"); + }; + assert_eq!(content, &audio); + assert!(unsupported.visible_content(cx)); + assert_eq!( + blob.embedded_resource().map(|(resource, _)| resource), + Some(&blob_resource) + ); + assert!(blob.visible_content(cx)); + assert_eq!( + thread.to_markdown(cx), + concat!( + "## Context Compaction (Completed)\n\n", + "retained context\n\n", + "Retained resource text\n\n", + "`Image`\n\n", + "https://example.com/context\n\n", + "Audio content is not supported.\n\n", + "summary://attachment\n\n", + ) + ); + }); + } + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Completed) + .summary(vec![audio.clone()]), + ), + cx, + ) + .expect("failed to replace summary with audio"); + }); + thread.read_with(cx, |thread, cx| { + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.first() + else { + panic!("expected compaction entry"); + }; + assert!(matches!( + compaction.summary.as_slice(), + [ContentBlock::Unsupported { content, .. }] if content == &audio + )); + assert!( + thread + .to_markdown(cx) + .contains("Audio content is not supported.") + ); + }); + } + + #[gpui::test] + async fn test_context_compaction_updates_preserve_timeline_and_emit_events( + cx: &mut TestAppContext, + ) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&thread, move |_, event, _| match event { + AcpThreadEvent::NewEntry => events.borrow_mut().push(None), + AcpThreadEvent::EntryUpdated(index) => events.borrow_mut().push(Some(*index)), + _ => {} + }) + }); + + thread.update(cx, |thread, cx| { + for update in [ + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "first", + acp::CompactionStatus::InProgress, + )), + acp::SessionUpdate::ToolCall(acp::ToolCall::new("tool", "Unrelated tool")), + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "second", + acp::CompactionStatus::InProgress, + )), + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "second", + acp::CompactionStatus::Completed, + )), + acp::SessionUpdate::CompactionSummaryChunk(acp::CompactionSummaryChunk::new( + "first", + acp::ContentBlock::Text(acp::TextContent::new("partial summary")), + )), + ] { + thread + .handle_session_update(update, cx) + .expect("failed to apply interleaved session update"); + } + assert!(thread.is_compacting()); + }); + assert_eq!(*events.borrow(), [None, None, None, Some(2), Some(0)]); + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "first", + acp::CompactionStatus::Cancelled, + )), + cx, + ) + .expect("failed to cancel compaction"); + assert!(!thread.is_compacting()); + }); + assert_eq!( + *events.borrow(), + [None, None, None, Some(2), Some(0), Some(0)] + ); + thread.read_with(cx, |thread, cx| { + let [ + AgentThreadEntry::ContextCompaction(first), + AgentThreadEntry::ToolCall(_), + AgentThreadEntry::ContextCompaction(second), + ] = thread.entries.as_slice() + else { + panic!("updates must preserve the original timeline positions"); + }; + assert_eq!(first.id.0.as_ref(), "first"); + assert_eq!(first.status, ContextCompactionStatus::Canceled); + assert_eq!(second.id.0.as_ref(), "second"); + assert_eq!(second.status, ContextCompactionStatus::Completed); + assert!( + thread + .to_markdown(cx) + .starts_with("## Context Compaction (Canceled)\n\npartial summary\n\n") + ); + }); + } + + #[gpui::test] + fn test_tool_call_label_fallback(cx: &mut TestAppContext) { + use markdown::parser::{MarkdownEvent, MarkdownTag}; + + init_test(cx); + let languages = + cx.update(|cx| Arc::new(LanguageRegistry::test(cx.background_executor().clone()))); + for (call, expected, has_strong_text) in [ + ( + acp::ToolCall::new("tool", "Reading **file**").name("read_file"), + "Reading **file**", + true, + ), + (acp::ToolCall::new("tool", ""), "Tool call", false), + ( + acp::ToolCall::new("tool", "\n\t ").name(" \t"), + "Tool call", + false, + ), + ( + acp::ToolCall::new("tool", "").name("**mcp__tool**"), + "**mcp__tool**", + false, + ), + ( + acp::ToolCall::new("tool", "\n\t ") + .name("**mcp__tool**") + .kind(acp::ToolKind::Edit), + "**mcp__tool**", + false, + ), + ( + acp::ToolCall::new("tool", "") + .name("**mcp__tool**") + .kind(acp::ToolKind::Execute), + "**mcp__tool**", + false, + ), + ( + acp::ToolCall::new("tool", "").meta(meta_with_tool_name("legacy_tool")), + "legacy_tool", + false, + ), + ] { + let call = cx.update(|cx| { + ToolCall::from_acp( + call, + ToolCallStatus::Pending, + languages.clone(), + &HashMap::default(), + cx, + ) + .expect("tool call should convert") + }); + cx.run_until_parked(); + cx.read(|cx| { + let label = call.label.read(cx); + assert_eq!(label.source().as_ref(), expected); + assert_eq!( + label.parsed_markdown().events().iter().any(|(_, event)| { + matches!(event, MarkdownEvent::Start(MarkdownTag::Strong)) + }), + has_strong_text + ); + if call.title.is_none() { + assert_eq!( + call.to_markdown(cx), + format!( + "**Tool Call: {}**\nStatus: Pending\n\n", + MarkdownEscaped(expected) + ) + ); + } + }); + } + } + + #[gpui::test] + fn test_tool_call_label_updates_preserve_titles(cx: &mut TestAppContext) { + use markdown::parser::{MarkdownEvent, MarkdownTag}; + + init_test(cx); + let languages = + cx.update(|cx| Arc::new(LanguageRegistry::test(cx.background_executor().clone()))); + let mut call = cx.update(|cx| { + ToolCall::from_acp( + acp::ToolCall::new("tool", ""), + ToolCallStatus::Pending, + languages.clone(), + &HashMap::default(), + cx, + ) + .expect("tool call should convert") + }); + for (update, expected, has_strong_text) in [ + ( + acp::ToolCallUpdateFields::new().name("**tool_name**"), + "**tool_name**", + false, + ), + ( + acp::ToolCallUpdateFields::new().title("**Readable title**"), + "**Readable title**", + true, + ), + ( + acp::ToolCallUpdateFields::new().name("renamed_tool"), + "**Readable title**", + true, + ), + ( + acp::ToolCallUpdateFields::new().kind(acp::ToolKind::Execute), + "**Readable title**", + false, + ), + ( + acp::ToolCallUpdateFields::new().kind(acp::ToolKind::Read), + "**Readable title**", + true, + ), + ( + acp::ToolCallUpdateFields::new().title("\n\t "), + "renamed_tool", + false, + ), + ( + acp::ToolCallUpdateFields::new().name(" \t"), + "Tool call", + false, + ), + ( + acp::ToolCallUpdateFields::new() + .title("**same_name**") + .name("**same_name**"), + "**same_name**", + true, + ), + ( + acp::ToolCallUpdateFields::new().name("different_name"), + "**same_name**", + true, + ), + ] { + cx.update(|cx| { + call.update_fields(update, None, languages.clone(), &HashMap::default(), cx) + .expect("tool label update should apply"); + }); + cx.run_until_parked(); + cx.read(|cx| { + let label = call.label.read(cx); + assert_eq!(label.source().as_ref(), expected); + assert_eq!( + label.parsed_markdown().events().iter().any(|(_, event)| { + matches!(event, MarkdownEvent::Start(MarkdownTag::Strong)) + }), + has_strong_text + ); + }); + } + } + + #[gpui::test] + async fn test_tool_call_name_precedence_and_updates(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool-call", "Tool call") + .name("first_class") + .meta(meta_with_tool_name("legacy")), + ), + cx, + ) + }) + .expect("failed to create first-class named tool call"); + + thread.read_with(cx, |thread, _| { + let Some(AgentThreadEntry::ToolCall(tool_call)) = thread.entries.last() else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("first_class")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("legacy-tool-call", "Legacy tool call") + .meta(meta_with_tool_name("legacy")), + ), + cx, + ) + }) + .expect("failed to create legacy named tool call"); + + thread.read_with(cx, |thread, _| { + let Some(AgentThreadEntry::ToolCall(tool_call)) = thread.entries.last() else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("legacy")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new("tool-call", acp::ToolCallUpdateFields::new()) + .meta(meta_with_tool_name("legacy_update")), + ), + cx, + ) + }) + .expect("failed to apply stale legacy tool-call name update"); + + thread.read_with(cx, |thread, _| { + let Some(tool_call) = thread.entries.iter().find_map(|entry| match entry { + AgentThreadEntry::ToolCall(tool_call) + if tool_call.id == acp::ToolCallId::new("tool-call") => + { + Some(tool_call) + } + _ => None, + }) else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("first_class")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new( + "legacy-tool-call", + acp::ToolCallUpdateFields::new().name("first_class_update"), + ) + .meta(meta_with_tool_name("ignored_legacy_update")), + ), + cx, + ) + }) + .expect("failed to apply first-class tool-call name update"); + + thread.read_with(cx, |thread, _| { + let Some(tool_call) = thread.entries.iter().find_map(|entry| match entry { + AgentThreadEntry::ToolCall(tool_call) + if tool_call.id == acp::ToolCallId::new("legacy-tool-call") => + { + Some(tool_call) + } + _ => None, + }) else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("first_class_update")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall(acp::ToolCall::new( + "late-legacy-tool-call", + "Late legacy tool call", + )), + cx, + ) + }) + .expect("failed to create unnamed tool call"); + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new( + "late-legacy-tool-call", + acp::ToolCallUpdateFields::new(), + ) + .meta(meta_with_tool_name("late_legacy")), + ), + cx, + ) + }) + .expect("failed to apply late legacy tool-call name"); + + thread.read_with(cx, |thread, _| { + let Some(AgentThreadEntry::ToolCall(tool_call)) = thread.entries.last() else { + unreachable!("tool call update must preserve the tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("late_legacy")); + }); + } + + #[gpui::test] + fn test_context_compaction_exports_status(cx: &mut App) { + for (status, expected_label) in [ + (acp::CompactionStatus::InProgress, "In Progress"), + (acp::CompactionStatus::Completed, "Completed"), + (acp::CompactionStatus::Failed, "Failed"), + (acp::CompactionStatus::Cancelled, "Canceled"), + ( + acp::CompactionStatus::Other("interrupted".into()), + "interrupted", + ), + ] { + let entry = AgentThreadEntry::ContextCompaction(ContextCompaction { + id: ContextCompactionId("compaction".into()), + status: status.into(), + error: None, + summary: Vec::new(), + }); + assert_eq!( + entry.to_markdown(cx), + format!("## Context Compaction ({expected_label})\n\n") + ); + } + } + #[gpui::test] async fn test_tool_call_location_resolves_external_file(cx: &mut TestAppContext) { init_test(cx); @@ -9002,7 +10543,6 @@ mod tests { let markdown_text = markdown.read(cx).source(); assert!(markdown_text.contains("Tool call not found")); } - ContentBlock::Empty => panic!("Expected markdown content, got empty"), ContentBlock::ResourceLink { .. } => { panic!("Expected markdown content, got resource link") } @@ -9012,6 +10552,9 @@ mod tests { ContentBlock::Image { .. } => { panic!("Expected markdown content, got image") } + ContentBlock::Unsupported { .. } => { + panic!("Expected markdown content, got unsupported content") + } } } else { panic!("Expected ContentBlock, got: {:?}", tool_call.content[0]); @@ -9371,7 +10914,7 @@ mod tests { protocol_id: None, client_id: Some(ClientUserMessageId::new()), is_optimistic: true, - content: ContentBlock::Empty, + content: MessageContent::default(), chunks: vec!["Injected message (no checkpoint)".into()], checkpoint: None, indented: false, @@ -9481,6 +11024,8 @@ mod tests { // First handler waits for this signal before completing let (first_complete_tx, first_complete_rx) = futures::channel::oneshot::channel::<()>(); let first_complete_rx = RefCell::new(Some(first_complete_rx)); + let (second_complete_tx, second_complete_rx) = oneshot::channel::<()>(); + let second_complete_rx = RefCell::new(Some(second_complete_rx)); let connection = Rc::new(FakeAgentConnection::new().on_user_message({ move |params, _thread, _cx| { @@ -9489,6 +11034,11 @@ mod tests { .prompt .iter() .any(|c| matches!(c, acp::ContentBlock::Text(t) if t.text.contains("first"))); + let second_complete_rx = if is_first { + None + } else { + second_complete_rx.borrow_mut().take() + }; async move { if is_first { @@ -9496,6 +11046,10 @@ mod tests { if let Some(rx) = first_complete_rx { rx.await.ok(); } + } else { + second_complete_rx + .expect("second completion receiver should be available") + .await?; } Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } @@ -9512,7 +11066,9 @@ mod tests { // Send first message (turn_id=1) - handler will block let first_request = thread.update(cx, |thread, cx| thread.send_raw("first", cx)); + cx.run_until_parked(); assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 1); + assert_eq!(cx.active_idle_sleep_preventions(), 1); let permission_task = thread .update(cx, |thread, cx| { thread.request_tool_call_authorization( @@ -9523,6 +11079,7 @@ mod tests { ) }) .unwrap(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); // Send second message (turn_id=2) while first is still blocked // This calls cancel() which takes turn 1's running_turn and sets turn 2's @@ -9532,6 +11089,7 @@ mod tests { RequestPermissionOutcome::InterruptedByFollowUp )); assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 2); + assert_eq!(cx.active_idle_sleep_preventions(), 1); let running_turn_after_second_send = thread.read_with(cx, |thread, _| thread.running_turn.as_ref().map(|t| t.id)); @@ -9555,8 +11113,12 @@ mod tests { Some(2), "first turn completing should not clear running_turn (belongs to turn 2)" ); + assert_eq!(cx.active_idle_sleep_preventions(), 1); // Second request completes - SHOULD clear running_turn + second_complete_tx + .send(()) + .expect("second completion receiver should still be alive"); second_request.await.unwrap(); let running_turn_after_second = @@ -9565,6 +11127,40 @@ mod tests { !running_turn_after_second, "second turn completing should clear running_turn" ); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_setting_toggle_updates_idle_sleep_prevention_for_running_turn( + cx: &mut TestAppContext, + ) { + init_test(cx); + set_prevent_idle_sleep(false, cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + for (enabled, expected_count) in [(true, 1), (true, 1), (false, 0), (false, 0), (true, 1)] { + set_prevent_idle_sleep(enabled, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + } + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + for enabled in [false, true] { + set_prevent_idle_sleep(enabled, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } } #[gpui::test] @@ -9601,7 +11197,8 @@ mod tests { ContextCompaction { id: compaction_id, status: ContextCompactionStatus::InProgress, - summary: None, + error: None, + summary: Vec::new(), }, cx, ); @@ -9969,7 +11566,8 @@ mod tests { ContextCompaction { id: ContextCompactionId("compaction-1".into()), status: ContextCompactionStatus::InProgress, - summary: None, + error: None, + summary: Vec::new(), }, cx, ); @@ -10197,4 +11795,1095 @@ mod tests { "running_turn must be cleared even when tx was dropped without send" ); } + + #[gpui::test] + async fn test_dropped_completion_waiter_does_not_leave_thread_generating( + cx: &mut TestAppContext, + ) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, completion) = oneshot::channel::<()>(); + let backend_finished = Rc::new(AtomicBool::new(false)); + let statuses = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + cx.subscribe(&thread, { + let statuses = statuses.clone(); + move |thread, event, cx| { + if matches!(event, AcpThreadEvent::StatusChanged) { + statuses.borrow_mut().push(thread.read(cx).status()); + } + } + }) + }); + let request = thread.update(cx, |thread, cx| { + thread.run_turn(cx, { + let backend_finished = backend_finished.clone(); + async move |_, _| { + completion.await?; + backend_finished.store(true, SeqCst); + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + }) + }); + cx.run_until_parked(); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + assert!(!backend_finished.load(SeqCst)); + + drop(request); + cx.run_until_parked(); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + complete.send(()).expect("backend should still be running"); + cx.run_until_parked(); + + assert!(backend_finished.load(SeqCst)); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Idle + ); + assert_eq!( + *statuses.borrow(), + vec![ThreadStatus::Generating, ThreadStatus::Idle] + ); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_released_on_turn_completion(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + assert!(cx.read(|cx| AgentSettings::get_global(cx).prevent_idle_sleep)); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + for (delay, expected_count) in [(Duration::ZERO, 1), (Duration::from_secs(2), 0)] { + cx.set_idle_sleep_prevention_delay(delay); + for (backend_result, expected_result, expected_had_error) in [ + ( + Ok(acp::StopReason::EndTurn), + Ok(acp::StopReason::EndTurn), + false, + ), + ( + Ok(acp::StopReason::Cancelled), + Ok(acp::StopReason::Cancelled), + false, + ), + ( + Ok(acp::StopReason::Refusal), + Ok(acp::StopReason::Refusal), + true, + ), + ( + Ok(acp::StopReason::MaxTokens), + Err("output token limit reached"), + true, + ), + (Err(anyhow!("backend failed")), Err("backend failed"), true), + ] { + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + assert!(!thread.read_with(cx, |thread, _| thread.had_error())); + + complete + .send(backend_result.map(acp::PromptResponse::new)) + .expect("turn should still be running"); + let result = request + .await + .map(|response| response.map(|response| response.stop_reason)) + .map_err(|error| error.to_string()); + assert_eq!(result, expected_result.map(Some).map_err(String::from)); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Idle); + assert_eq!(thread.had_error(), expected_had_error); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_overlaps_distinct_threads_and_subagent( + cx: &mut TestAppContext, + ) { + init_test(cx); + let parent = new_test_thread(cx).await; + let independent = new_test_thread(cx).await; + let subagent = cx.update(|cx| { + let parent = parent.read(cx); + let session_id = parent.session_id().clone(); + let connection = parent.connection.clone(); + let project = parent.project.clone(); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + cx.new(|cx| { + AcpThread::new( + Some(session_id), + None, + None, + connection, + project, + action_log, + acp::SessionId::new("subagent"), + watch::Receiver::constant(acp::PromptCapabilities::new()), + cx, + ) + }) + }); + assert_ne!(parent.entity_id(), independent.entity_id()); + assert_ne!(parent.entity_id(), subagent.entity_id()); + assert_ne!(independent.entity_id(), subagent.entity_id()); + assert_eq!( + subagent.read_with(cx, |thread, _| thread.parent_session_id().cloned()), + Some(parent.read_with(cx, |thread, _| thread.session_id().clone())) + ); + + let (parent_complete, parent_request) = start_test_turn(&parent, cx); + let (independent_complete, independent_request) = start_test_turn(&independent, cx); + let (subagent_complete, subagent_request) = start_test_turn(&subagent, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 3); + + let parent_cancel = parent.update(cx, |thread, cx| thread.cancel(cx)); + assert_eq!(cx.active_idle_sleep_preventions(), 2); + parent_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))) + .expect("parent backend should still be running"); + parent_cancel.await; + parent_request.await.expect("parent turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 2); + + independent_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("independent turn should still be running"); + independent_request + .await + .expect("independent turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + assert_eq!( + subagent.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + + subagent_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("subagent should still be running"); + subagent_request + .await + .expect("subagent turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_cancel_releases_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + for (delay, acquisition_fails, expected_count) in [ + (Duration::ZERO, false, 1), + (Duration::from_secs(2), false, 0), + (Duration::from_secs(2), true, 0), + ] { + cx.set_idle_sleep_prevention_delay(delay); + cx.set_idle_sleep_prevention_fails(acquisition_fails); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Acquiring { .. } + )); + }); + + let cancel = thread.update(cx, |thread, cx| thread.cancel(cx)); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Idle); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("backend should still be running"); + cancel.await; + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_setting_disables_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + cx.set_idle_sleep_prevention_delay(Duration::from_secs(2)); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + set_prevent_idle_sleep(false, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Generating); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_follow_up_keeps_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + cx.set_idle_sleep_prevention_delay(Duration::from_secs(2)); + let thread = new_test_thread(cx).await; + let (first_complete, first_request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + let (second_complete, second_request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Acquiring { .. } + )); + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + first_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))) + .expect("first backend should still be running"); + first_request.await.expect("first turn should complete"); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + second_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("second backend should still be running"); + second_request.await.expect("second turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_dropping_thread_releases_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + for (delay, expected_count) in [(Duration::ZERO, 1), (Duration::from_secs(2), 0)] { + cx.set_idle_sleep_prevention_delay(delay); + let thread = new_test_thread(cx).await; + let weak_thread = thread.downgrade(); + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + + cx.update(|_| drop(thread)); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert!(weak_thread.upgrade().is_none()); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + assert!(request.await.is_err()); + drop(complete); + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_failure_retries_only_on_setting_toggle( + cx: &mut TestAppContext, + ) { + init_test(cx); + for delay in [Duration::ZERO, Duration::from_secs(2)] { + cx.set_idle_sleep_prevention_delay(delay); + cx.set_idle_sleep_prevention_fails(true); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(delay); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + )); + assert_eq!(thread.status(), ThreadStatus::Generating); + assert!(!thread.had_error()); + }); + + cx.set_idle_sleep_prevention_fails(false); + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(4)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + assert!(thread.read_with(cx, |thread, _| matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + ))); + + set_prevent_idle_sleep(false, cx); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + ))); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + cx.executor().advance_clock(delay); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_failure_clears_when_turn_stops(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + for cancel_turn in [false, true] { + cx.set_idle_sleep_prevention_fails(true); + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert!(thread.read_with(cx, |thread, _| matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + ))); + + let cancel = if cancel_turn { + let cancel = thread.update(cx, |thread, cx| thread.cancel(cx)); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + assert_eq!(thread.status(), ThreadStatus::Idle); + }); + Some(cancel) + } else { + None + }; + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("backend should still be running"); + if let Some(cancel) = cancel { + cancel.await; + } + request.await.expect("turn should complete"); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + assert_eq!(thread.status(), ThreadStatus::Idle); + }); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + cx.set_idle_sleep_prevention_fails(false); + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("next turn should still be running"); + request.await.expect("next turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_waits_for_all_confirmations(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + let first_id = acp::ToolCallId::new("first-permission"); + let second_id = acp::ToolCallId::new("second-permission"); + let first_permission = request_test_permission(&thread, first_id.clone(), cx); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + let second_permission = request_test_permission(&thread, second_id.clone(), cx); + let (elicitation_id, elicitation_response) = request_test_form_elicitation(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + for enabled in [false, true] { + set_prevent_idle_sleep(enabled, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + thread.update(cx, |thread, cx| { + thread + .update_tool_call( + acp::ToolCallUpdate::new( + first_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + ), + cx, + ) + .expect("tool update should succeed"); + thread.authorize_tool_call( + second_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("reject"), + acp::PermissionOptionKind::RejectOnce, + ), + cx, + ); + }); + assert!(matches!( + second_permission.await, + RequestPermissionOutcome::Selected(outcome) + if outcome.option_kind == acp::PermissionOptionKind::RejectOnce + )); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + assert_eq!( + elicitation_response.await.action, + acp::ElicitationAction::Decline + ); + cx.run_until_parked(); + assert!(thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + first_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + }); + assert!(matches!( + first_permission.await, + RequestPermissionOutcome::Selected(outcome) + if outcome.option_kind == acp::PermissionOptionKind::AllowOnce + )); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_resumes_after_permission_cancellation_or_terminal_update( + cx: &mut TestAppContext, + ) { + init_test(cx); + for status in [ + None, + Some(acp::ToolCallStatus::Completed), + Some(acp::ToolCallStatus::Failed), + ] { + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + if let Some(status) = status { + thread + .handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id, + acp::ToolCallUpdateFields::new().status(status), + )), + cx, + ) + .expect("terminal tool update should succeed"); + } else { + thread.cancel_tool_call_authorization(&tool_call_id, cx); + } + }); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Cancelled + )); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1, "status: {status:?}"); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_permission_suspends_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + cx.set_idle_sleep_prevention_delay(Duration::from_secs(2)); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert!(thread.is_waiting_for_confirmation()); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + tool_call_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + }); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Selected(_) + )); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_resumes_after_form_elicitation(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + for action in [ + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()), + acp::ElicitationAction::Decline, + acp::ElicitationAction::Cancel, + ] { + let (elicitation_id, response) = request_test_form_elicitation(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(action.clone()), + cx, + ); + }); + assert_eq!(response.await.action, action); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), + cx, + ); + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + } + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_resumes_after_url_elicitation(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + for (url_id, accept) in [("accepted", true), ("canceled", false)] { + let url_id = acp::ElicitationId::new(url_id); + let (entry_id, response) = thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(thread.session_id().clone()), + url_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .expect("URL elicitation should succeed") + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_id, cx) + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + let expected_action = if accept { + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()) + } else { + acp::ElicitationAction::Cancel + }; + thread.update(cx, |thread, cx| { + if accept { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(expected_action.clone()), + cx, + ); + } else { + thread.cancel_elicitation(&entry_id, cx); + } + }); + assert_eq!(response.await.action, expected_action); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_id, cx) + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + } + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_confirmation_resolution_does_not_reacquire_idle_sleep_prevention_after_cancel( + cx: &mut TestAppContext, + ) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + let (elicitation_id, elicitation_response) = request_test_form_elicitation(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + let cancel = thread.update(cx, |thread, cx| thread.cancel(cx)); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Cancelled + )); + assert_eq!( + elicitation_response.await.action, + acp::ElicitationAction::Cancel + ); + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + tool_call_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + cx.run_until_parked(); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Idle + ); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))) + .expect("backend should still be running"); + cancel.await; + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_stale_refusal_does_not_affect_follow_up_turn(cx: &mut TestAppContext) { + assert_stale_completion_does_not_affect_follow_up_turn(Ok(acp::StopReason::Refusal), cx) + .await; + } + + #[gpui::test] + async fn test_stale_error_does_not_affect_follow_up_turn(cx: &mut TestAppContext) { + assert_stale_completion_does_not_affect_follow_up_turn(Err("first turn failed"), cx).await; + } + + #[gpui::test] + async fn test_failed_tool_update_resumes_sleep_prevention(cx: &mut TestAppContext) { + assert_failed_tool_update_resumes_sleep_prevention(false, cx).await; + } + + #[gpui::test] + async fn test_failed_tool_upsert_resumes_sleep_prevention(cx: &mut TestAppContext) { + assert_failed_tool_update_resumes_sleep_prevention(true, cx).await; + } + + fn start_test_turn( + thread: &Entity, + cx: &mut TestAppContext, + ) -> ( + oneshot::Sender>, + BoxFuture<'static, Result>>, + ) { + let (complete, completion) = oneshot::channel::>(); + let request = thread.update(cx, |thread, cx| { + thread.run_turn(cx, async move |_, _| completion.await?) + }); + (complete, request) + } + + fn request_test_permission( + thread: &Entity, + tool_call_id: acp::ToolCallId, + cx: &mut TestAppContext, + ) -> Task { + thread.update(cx, |thread, cx| { + thread + .request_tool_call_authorization( + acp::ToolCall::new(tool_call_id, "Needs permission").into(), + PermissionOptions::Flat(vec![ + acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("reject"), + "Reject", + acp::PermissionOptionKind::RejectOnce, + ), + ]), + AuthorizationKind::PermissionGrant, + cx, + ) + .expect("permission request should succeed") + }) + } + + fn request_test_form_elicitation( + thread: &Entity, + cx: &mut TestAppContext, + ) -> (ElicitationEntryId, Task) { + thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(thread.session_id().clone()), + acp::ElicitationSchema::new().string("name", false), + ), + "Provide a name", + ), + cx, + ) + .expect("form elicitation should succeed") + }) + } + + fn set_prevent_idle_sleep(enabled: bool, cx: &mut TestAppContext) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content.agent.get_or_insert_default().prevent_idle_sleep = Some(enabled); + }); + }); + }); + } + + async fn assert_stale_completion_does_not_affect_follow_up_turn( + backend_result: Result, + cx: &mut TestAppContext, + ) { + init_test(cx); + for drop_waiter in [false, true] { + let thread = new_test_thread(cx).await; + let (first_complete, first_request) = start_test_turn(&thread, cx); + thread.update(cx, |thread, cx| { + thread.push_user_content_block(None, "first".into(), cx); + thread.push_assistant_content_block("first reply".into(), false, cx); + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + let first_request = if drop_waiter { + drop(first_request); + None + } else { + Some(first_request) + }; + first_complete + .send( + backend_result + .map(acp::PromptResponse::new) + .map_err(|message| anyhow!(message)), + ) + .expect("first backend should still be running"); + let (second_complete, second_request) = start_test_turn(&thread, cx); + thread.update(cx, |thread, cx| { + thread.push_user_content_block(None, "second".into(), cx); + thread.push_assistant_content_block("second reply".into(), false, cx); + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert!(!thread.had_error()); + }); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + cx.subscribe(&thread, { + let events = events.clone(); + move |_, event, _| { + let event = match event { + AcpThreadEvent::StatusChanged => "status changed", + AcpThreadEvent::EntriesRemoved(_) => "entries removed", + AcpThreadEvent::Stopped(_) => "stopped", + AcpThreadEvent::Refusal => "refusal", + AcpThreadEvent::Error => "error", + _ => return, + }; + events.borrow_mut().push(event); + } + }) + }); + cx.run_until_parked(); + if let Some(first_request) = first_request { + assert_eq!( + first_request + .await + .map(|response| response.map(|response| response.stop_reason)) + .map_err(|error| error.to_string()), + backend_result.map(Some).map_err(String::from) + ); + } + + thread.read_with(cx, |thread, cx| { + assert_eq!(thread.entries().len(), 4); + assert_eq!( + thread.to_markdown(cx), + concat!( + "## User\n\nfirst\n\n", + "## Assistant\n\nfirst reply\n\n", + "## User\n\nsecond\n\n", + "## Assistant\n\nsecond reply\n\n", + ) + ); + assert!(!thread.had_error()); + assert_eq!(thread.turn_id, 2); + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert_eq!(thread.status(), ThreadStatus::Generating); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + assert_eq!(*events.borrow(), Vec::<&str>::new()); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + second_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("second backend should still be running"); + second_request.await.expect("second turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + async fn assert_failed_tool_update_resumes_sleep_prevention( + upsert: bool, + cx: &mut TestAppContext, + ) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + cx.run_until_parked(); + assert!(thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + let update = acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + acp::TerminalId::new("unknown-terminal"), + ))]), + ); + if upsert { + assert!( + thread + .upsert_tool_call_inner(update, ToolCallStatus::Completed, cx) + .is_err() + ); + } else { + assert_eq!( + thread + .update_tool_call(update, cx) + .map_err(|error| error.to_string()), + Err(String::from( + "Terminal with id `unknown-terminal` not found" + )) + ); + } + assert_eq!( + thread + .tool_call(&tool_call_id) + .expect("tool call should remain present") + .1 + .status + .as_acp_status(), + Some(acp::ToolCallStatus::Completed) + ); + assert!(!thread.is_waiting_for_confirmation()); + }); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Cancelled + )); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Generating); + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(1)); + assert!(!thread.had_error()); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("backend should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } } diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 1dde55ea43..a785de67a4 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,5 +1,5 @@ use agent_client_protocol::schema::v1 as acp; -use anyhow::Result; +use anyhow::{Result, bail}; use collections::HashMap; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; @@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap as StdHashMap, path::PathBuf, - process::ExitStatus, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -407,7 +406,7 @@ pub struct Terminal { started_at: Instant, output: Option, output_byte_limit: Option, - _output_task: Shared>, + execution: TerminalExecution, /// Flag indicating whether this terminal was stopped by explicit user action /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. @@ -419,9 +418,14 @@ pub struct Terminal { _sandbox: Option, } +enum TerminalExecution { + Process(Shared>), + Display, +} + pub struct TerminalOutput { pub ended_at: Instant, - pub exit_status: Option, + pub exit_status: acp::TerminalExitStatus, pub content: String, pub original_content_len: usize, pub content_line_count: usize, @@ -469,21 +473,17 @@ impl Terminal { output: None, output_byte_limit, user_stopped: Arc::new(AtomicBool::new(false)), - _output_task: cx - .spawn(async move |this, cx| { - let exit_status = command_task.await; + execution: TerminalExecution::Process( + cx.spawn(async move |this, cx| { + let exit_status = command_task.await.map(portable_pty::ExitStatus::from); + let exit_status = acp::TerminalExitStatus::new() + .exit_code(exit_status.as_ref().map(|status| status.exit_code())) + .signal( + exit_status.and_then(|status| status.signal().map(ToOwned::to_owned)), + ); this.update(cx, |this, cx| { - let (content, original_content_len) = this.truncated_output(cx); - let content_line_count = this.terminal.read(cx).total_lines(); - - this.output = Some(TerminalOutput { - ended_at: Instant::now(), - exit_status, - content, - original_content_len, - content_line_count, - }); + this.cache_output(exit_status.clone(), Instant::now(), cx); this.terminal.update(cx, |terminal, _cx| { terminal.release_pty_resources(); }); @@ -497,17 +497,43 @@ impl Terminal { .spawn(async move { sandbox.drop_on_current_thread() }) .detach(); } - cx.notify(); }) .ok(); - let exit_status = exit_status.map(portable_pty::ExitStatus::from); - - acp::TerminalExitStatus::new() - .exit_code(exit_status.as_ref().map(|e| e.exit_code())) - .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))) + exit_status }) .shared(), + ), + } + } + + pub fn new_display( + id: acp::TerminalId, + command_label: &str, + working_dir: Option, + output_byte_limit: Option, + terminal: Entity, + language_registry: Arc, + cx: &mut Context, + ) -> Self { + Self { + id, + command: cx.new(|cx| { + Markdown::new( + format!("```\n{}\n```", command_label).into(), + Some(language_registry), + None, + cx, + ) + }), + working_dir, + terminal, + started_at: Instant::now(), + output: None, + output_byte_limit, + execution: TerminalExecution::Display, + user_stopped: Arc::new(AtomicBool::new(false)), + _sandbox: None, } } @@ -515,11 +541,24 @@ impl Terminal { &self.id } - pub fn wait_for_exit(&self) -> Shared> { - self._output_task.clone() + /// Ownership is independent of the renderer's PTY: headless tasks still own a process. + pub fn is_process_backed(&self) -> bool { + matches!(self.execution, TerminalExecution::Process(_)) + } + + pub fn wait_for_exit(&self) -> Result>> { + match &self.execution { + TerminalExecution::Process(task) => Ok(task.clone()), + TerminalExecution::Display => { + bail!("Agent-provided terminals have no client-owned process to wait for") + } + } } pub fn kill(&mut self, cx: &mut App) { + if !self.is_process_backed() { + return; + } self.terminal.update(cx, |terminal, _cx| { terminal.kill_active_task(); }); @@ -528,6 +567,9 @@ impl Terminal { /// Marks this terminal as stopped by user action and then kills it. /// This should be called when the user explicitly clicks a Stop button. pub fn stop_by_user(&mut self, cx: &mut App) { + if !self.is_process_backed() { + return; + } self.user_stopped.store(true, Ordering::SeqCst); self.kill(cx); } @@ -539,17 +581,11 @@ impl Terminal { pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse { if let Some(output) = self.output.as_ref() { - let exit_status = output.exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalOutputResponse::new( output.content.clone(), output.original_content_len > output.content.len(), ) - .exit_status( - acp::TerminalExitStatus::new() - .exit_code(exit_status.as_ref().map(|e| e.exit_code())) - .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))), - ) + .exit_status(output.exit_status.clone()) } else { let (current_content, original_len) = self.truncated_output(cx); let truncated = current_content.len() < original_len; @@ -557,6 +593,50 @@ impl Terminal { } } + pub(crate) fn write_display_output(&mut self, data: &[u8], cx: &mut Context) { + if self.is_process_backed() { + return; + } + self.terminal + .update(cx, |terminal, cx| terminal.write_output(data, cx)); + if let Some(output) = &self.output { + // A process may exit before its final output arrives. Refresh the + // cached content without changing its completion time or status. + self.cache_output(output.exit_status.clone(), output.ended_at, cx); + } + } + + pub(crate) fn finish_display( + &mut self, + exit_status: acp::TerminalExitStatus, + cx: &mut Context, + ) { + if self.is_process_backed() || self.output.is_some() { + return; + } + self.terminal + .update(cx, |terminal, _| terminal.shrink_to_used()); + self.cache_output(exit_status, Instant::now(), cx); + } + + fn cache_output( + &mut self, + exit_status: acp::TerminalExitStatus, + ended_at: Instant, + cx: &mut Context, + ) { + let (content, original_content_len) = self.truncated_output(cx); + let content_line_count = self.terminal.read(cx).total_lines(); + self.output = Some(TerminalOutput { + ended_at, + exit_status, + content, + original_content_len, + content_line_count, + }); + cx.notify(); + } + fn truncated_output(&self, cx: &App) -> (String, usize) { let terminal = self.terminal.read(cx); let mut content = terminal.get_content(); diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml index f38ca890e2..4ee524b368 100644 --- a/crates/acp_tools/Cargo.toml +++ b/crates/acp_tools/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [lib] path = "src/acp_tools.rs" doctest = false +test = false [features] test-support = ["workspace/test-support"] diff --git a/crates/action_log/Cargo.toml b/crates/action_log/Cargo.toml index c8d3b6a36d..88a86c9684 100644 --- a/crates/action_log/Cargo.toml +++ b/crates/action_log/Cargo.toml @@ -7,6 +7,7 @@ license = "GPL-3.0-or-later" [lib] path = "src/action_log.rs" +doctest = false [lints] workspace = true diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index df4c4a0db6..198033c680 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/activity_indicator.rs" doctest = false +test = false [features] test-support = [] diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index dccc0bcafc..86cb029a6b 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -7,8 +7,7 @@ use gpui::{ SharedString, Styled, Task, Window, actions, }; use language::{ - BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName, - LanguageServerStatusUpdate, ServerHealth, + BinaryStatus, LanguageServerId, LanguageServerName, LanguageServerStatusUpdate, ServerHealth, }; use project::{ LanguageServerProgress, LspStoreEvent, ProgressToken, Project, ProjectEnvironmentEvent, @@ -91,28 +90,11 @@ struct Content { impl ActivityIndicator { pub fn new( workspace: &mut Workspace, - languages: Arc, window: &mut Window, cx: &mut Context, ) -> Entity { let project = workspace.project().clone(); let this = cx.new(|cx| { - let mut status_events = languages.language_server_binary_statuses(); - cx.spawn(async move |this, cx| { - while let Some((name, binary_status)) = status_events.next().await { - this.update(cx, |this: &mut ActivityIndicator, cx| { - this.statuses.retain(|s| s.name != name); - this.statuses.push(ServerStatus { - name, - status: LanguageServerStatusUpdate::Binary(binary_status), - }); - cx.notify(); - })?; - } - anyhow::Ok(()) - }) - .detach(); - let fs = project.read(cx).fs().clone(); let mut job_events = fs.subscribe_to_jobs(); cx.spawn(async move |this, cx| { @@ -120,11 +102,17 @@ impl ActivityIndicator { this.update(cx, |this: &mut ActivityIndicator, cx| { match job_event { fs::JobEvent::Started { info } => { - this.fs_jobs.retain(|j| j.id != info.id); + this.fs_jobs.retain(|job| job.id != info.id); this.fs_jobs.push(info); } + fs::JobEvent::Updated { id, message } => { + if let Some(job) = this.fs_jobs.iter_mut().find(|job| job.id == id) + { + job.message = message; + } + } fs::JobEvent::Completed { id } => { - this.fs_jobs.retain(|j| j.id != id); + this.fs_jobs.retain(|job| job.id != id); } } cx.notify(); diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 62d6f271c4..5c7c8de346 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,6 +7,7 @@ license = "GPL-3.0-or-later" [lib] path = "src/agent.rs" +doctest = false [features] test-support = ["db/test-support"] diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 6139ce7955..9ea7e6bbfa 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -228,7 +228,8 @@ struct Session { /// The internal thread that processes messages thread: Entity, /// The ACP thread that handles protocol communication - acp_thread: Entity, + acp_thread: WeakEntity, + subagents: Vec>, project_id: EntityId, /// Latest snapshot to persist. Overwritten in place on every save request; /// the single save worker drains it, coalescing bursts into one write. @@ -236,12 +237,19 @@ struct Session { save_wake: watch::Sender<()>, save_worker: Task>, _subscriptions: Vec, - ref_count: usize, +} + +impl Session { + fn draft_prompt(&self, cx: &App) -> Option> { + match self.acp_thread.upgrade() { + Some(acp_thread) => acp_thread.read(cx).draft_prompt().map(Vec::from), + None => self.thread.read(cx).draft_prompt().map(Vec::from), + } + } } struct PendingSession { task: Shared, Arc>>>, - ref_count: usize, } pub struct LanguageModels { @@ -776,14 +784,13 @@ impl NativeAgent { ) }); - self.register_session(thread, project_id, 1, cx) + self.register_session(thread, project_id, cx) } fn register_session( &mut self, thread_handle: Entity, project_id: EntityId, - ref_count: usize, cx: &mut Context, ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); @@ -848,6 +855,14 @@ impl NativeAgent { cx.observe(&thread_handle, move |this, thread, cx| { this.save_thread(thread, cx) }), + cx.observe_release(&acp_thread, { + let session_id = session_id.clone(); + let acp_thread_id = acp_thread.entity_id(); + move |this, released_acp_thread, cx| { + let draft_prompt = released_acp_thread.draft_prompt().map(Vec::from); + this.release_session(&session_id, acp_thread_id, draft_prompt, cx); + } + }), ]; let (save_wake, save_wake_rx) = watch::channel(()); @@ -874,13 +889,13 @@ impl NativeAgent { session_id, Session { thread: thread_handle, - acp_thread: acp_thread.clone(), + acp_thread: acp_thread.downgrade(), + subagents: Vec::new(), project_id, pending_save, save_wake, save_worker, _subscriptions: subscriptions, - ref_count, }, ); @@ -1359,7 +1374,7 @@ impl NativeAgent { }; let thread = thread.downgrade(); - let acp_thread = session.acp_thread.downgrade(); + let acp_thread = session.acp_thread.clone(); cx.spawn(async move |_, cx| { let title = thread.read_with(cx, |thread, _| thread.title())?; if let Some(title) = title { @@ -1381,9 +1396,12 @@ impl NativeAgent { let Some(session) = self.sessions.get(thread.read(cx).id()) else { return; }; - session.acp_thread.update(cx, |acp_thread, cx| { - acp_thread.update_token_usage(usage.0.clone(), cx); - }); + session + .acp_thread + .update(cx, |acp_thread, cx| { + acp_thread.update_token_usage(usage.0.clone(), cx); + }) + .ok(); } fn handle_project_event( @@ -1536,16 +1554,19 @@ impl NativeAgent { if session.project_id != project_id { continue; } - session.acp_thread.update(cx, |thread, cx| { - thread - .handle_session_update( - acp::SessionUpdate::AvailableCommandsUpdate( - acp::AvailableCommandsUpdate::new(available_commands.clone()), - ), - cx, - ) - .log_err(); - }); + session + .acp_thread + .update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AvailableCommandsUpdate( + acp::AvailableCommandsUpdate::new(available_commands.clone()), + ), + cx, + ) + .log_err(); + }) + .ok(); } } @@ -1661,13 +1682,16 @@ impl NativeAgent { project: Entity, cx: &mut Context, ) -> Task>> { - if let Some(session) = self.sessions.get_mut(&id) { - session.ref_count += 1; - return Task::ready(Ok(session.acp_thread.clone())); + if let Some(session) = self.sessions.get(&id) { + if let Some(acp_thread) = session.acp_thread.upgrade() { + return Task::ready(Ok(acp_thread)); + } + let acp_thread_id = session.acp_thread.entity_id(); + let draft_prompt = session.draft_prompt(cx); + self.release_session(&id, acp_thread_id, draft_prompt, cx); } - if let Some(pending) = self.pending_sessions.get_mut(&id) { - pending.ref_count += 1; + if let Some(pending) = self.pending_sessions.get(&id) { let task = pending.task.clone(); return cx.background_spawn(async move { task.await.map_err(|err| anyhow!(err)) }); } @@ -1690,11 +1714,8 @@ impl NativeAgent { let acp_thread = this .update(cx, |this, cx| { let project_id = this.get_or_create_project_state(&project, cx); - let ref_count = this - .pending_sessions - .remove(&id) - .map_or(1, |pending| pending.ref_count); - this.register_session(thread.clone(), project_id, ref_count, cx) + this.pending_sessions.remove(&id); + this.register_session(thread.clone(), project_id, cx) }) .map_err(Arc::new)?; let events = thread.update(cx, |thread, cx| thread.replay(cx)); @@ -1719,7 +1740,6 @@ impl NativeAgent { id, PendingSession { task: shared_task.clone(), - ref_count: 1, }, ); @@ -1739,48 +1759,44 @@ impl NativeAgent { .update(cx, |this, cx| { this.sessions .get(&id) - .unwrap() + .context("session released before summary")? .thread - .update(cx, |thread, cx| thread.summary(cx)) - })? + .update(cx, |thread, cx| anyhow::Ok(thread.summary(cx))) + })?? .await .context("Failed to generate summary")?; - this.update(cx, |this, cx| this.close_session(&id, cx))? - .await?; drop(acp_thread); Ok(result) }) } - fn close_session( + fn release_session( &mut self, session_id: &acp::SessionId, + acp_thread_id: EntityId, + draft_prompt: Option>, cx: &mut Context, - ) -> Task> { - let Some(session) = self.sessions.get_mut(session_id) else { - return Task::ready(Ok(())); + ) { + let Some(session) = self.sessions.get(session_id) else { + return; }; - - session.ref_count -= 1; - if session.ref_count > 0 { - return Task::ready(Ok(())); + if session.acp_thread.entity_id() != acp_thread_id { + return; } - let thread = session.thread.clone(); - self.save_thread(thread, cx); + self.enqueue_save(session_id, draft_prompt, cx); let Some(session) = self.sessions.remove(session_id) else { - return Task::ready(Ok(())); + return; }; let project_id = session.project_id; + session.save_worker.detach_and_log_err(cx); let has_remaining = self.sessions.values().any(|s| s.project_id == project_id); if !has_remaining { self.projects.remove(&project_id); self.publish_skill_index(cx); } - - session.save_worker } fn save_thread(&mut self, thread: Entity, cx: &mut Context) { @@ -1788,7 +1804,22 @@ impl NativeAgent { let Some(session) = self.sessions.get(&id) else { return; }; - let Some((id, folder_paths, db_thread)) = self.thread_save_payload(session, cx) else { + let draft_prompt = session.draft_prompt(cx); + self.enqueue_save(&id, draft_prompt, cx); + } + + fn enqueue_save( + &mut self, + id: &acp::SessionId, + draft_prompt: Option>, + cx: &mut Context, + ) { + let Some(session) = self.sessions.get(id) else { + return; + }; + let Some((id, folder_paths, db_thread)) = + self.thread_save_payload(session, draft_prompt, cx) + else { return; }; @@ -1843,6 +1874,7 @@ impl NativeAgent { fn thread_save_payload( &self, session: &Session, + draft_prompt: Option>, cx: &mut App, ) -> Option<(acp::SessionId, PathList, Task)> { if session.thread.read(cx).is_empty() { @@ -1857,7 +1889,6 @@ impl NativeAgent { .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) .collect::>(), ); - let draft_prompt = session.acp_thread.read(cx).draft_prompt().map(Vec::from); let id = session.thread.read(cx).id().clone(); let db_thread = session.thread.update(cx, |thread, cx| { thread.set_draft_prompt(draft_prompt); @@ -1876,7 +1907,8 @@ impl NativeAgent { let mut saves = Vec::new(); for session in self.sessions.values() { - saves.extend(self.thread_save_payload(session, cx)); + let draft_prompt = session.draft_prompt(cx); + saves.extend(self.thread_save_payload(session, draft_prompt, cx)); } async move { @@ -1928,7 +1960,11 @@ impl NativeAgent { .sessions .get(&session_id) .context("Failed to get session")?; - anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .context("Session was released")?; + anyhow::Ok((acp_thread, session.thread.clone())) })??; let mut last_is_user = true; @@ -2018,7 +2054,11 @@ impl NativeAgent { .sessions .get(&session_id) .context("Failed to get session")?; - anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .context("Session was released")?; + anyhow::Ok((acp_thread, session.thread.clone())) })??; let response_stream = @@ -2069,7 +2109,11 @@ impl NativeAgent { .sessions .get(&session_id) .context("Failed to get session")?; - anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .context("Session was released")?; + anyhow::Ok((acp_thread, session.thread.clone())) })??; // Build the model-context message: skill envelope first, then @@ -2214,10 +2258,8 @@ impl NativeAgentConnection { + FnOnce(Entity, &mut App) -> Result>>, ) -> Task> { let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| { - agent - .sessions - .get_mut(&session_id) - .map(|s| (s.thread.clone(), s.acp_thread.clone())) + let session = agent.sessions.get(&session_id)?; + Some((session.thread.clone(), session.acp_thread.clone())) }) else { log::error!("Session not found in run_turn: {}", session_id); return Task::ready(Err(anyhow!("Session not found"))); @@ -2228,12 +2270,7 @@ impl NativeAgentConnection { Ok(stream) => stream, Err(err) => return Task::ready(Err(err)), }; - Self::handle_thread_events( - response_stream, - acp_thread.downgrade(), - Some(self.clone()), - cx, - ) + Self::handle_thread_events(response_stream, acp_thread, Some(self.clone()), cx) } fn handle_thread_events( @@ -2723,19 +2760,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection { .update(cx, |agent, cx| agent.open_thread(session_id, project, cx)) } - fn supports_close_session(&self) -> bool { - true - } - - fn close_session( - self: Rc, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - self.0 - .update(cx, |agent, cx| agent.close_session(session_id, cx)) - } - fn auth_methods(&self) -> &[acp::AuthMethod] { &[] // No auth for in-process } @@ -2804,7 +2828,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { agent.sessions.get(session_id).map(|session| { Rc::new(NativeAgentSessionTruncate { thread: session.thread.clone(), - acp_thread: session.acp_thread.downgrade(), + acp_thread: session.acp_thread.clone(), }) as _ }) }) @@ -3192,7 +3216,13 @@ impl NativeThreadEnvironment { .get(&parent_session_id) .map(|s| s.project_id) .context("parent session not found")?; - Ok(agent.register_session(subagent_thread.clone(), project_id, 1, cx)) + let acp_thread = agent.register_session(subagent_thread.clone(), project_id, cx); + let parent_session = agent + .sessions + .get_mut(&parent_session_id) + .context("parent session not found")?; + parent_session.subagents.push(acp_thread.clone()); + Ok(acp_thread) })??; let depth = current_depth + 1; @@ -3218,7 +3248,11 @@ impl NativeThreadEnvironment { .sessions .get(&session_id) .ok_or_else(|| anyhow!("No subagent session found with id {session_id}"))?; - anyhow::Ok((session.thread.clone(), session.acp_thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .ok_or_else(|| anyhow!("Subagent session {session_id} was released"))?; + anyhow::Ok((session.thread.clone(), acp_thread)) })??; let depth = subagent_thread.read(cx).depth(); @@ -3406,14 +3440,6 @@ impl ThreadEnvironment for NativeThreadEnvironment { } } -#[derive(Debug, Clone)] -enum SubagentPromptResult { - Completed, - Cancelled, - ContextWindowWarning, - Error(String), -} - pub struct NativeSubagentHandle { session_id: acp::SessionId, parent_thread: WeakEntity, @@ -3453,99 +3479,87 @@ impl SubagentHandle for NativeSubagentHandle { let parent_thread = self.parent_thread.clone(); cx.spawn(async move |cx| { - let (task, _subscription) = cx.update(|cx| { - let ratio_before_prompt = thread - .read(cx) - .latest_token_usage() - .map(|usage| usage.ratio()); - + let (task, token_limit_rx, _subscription) = cx.update(|cx| { parent_thread .update(cx, |parent_thread, _cx| { parent_thread.register_running_subagent(thread.downgrade()) }) .ok(); - let task = acp_thread.update(cx, |acp_thread, cx| { - acp_thread.send(vec![message.into()], cx) - }); - - let (token_limit_tx, token_limit_rx) = oneshot::channel::<()>(); + let ratio_before_prompt = thread + .read(cx) + .latest_token_usage() + .map_or(TokenUsageRatio::Normal, |usage| usage.ratio()); + let (token_limit_tx, token_limit_rx) = oneshot::channel(); let mut token_limit_tx = Some(token_limit_tx); - let subscription = cx.subscribe( &thread, - move |_thread, event: &TokenUsageUpdated, _cx| { - if let Some(usage) = &event.0 { - let old_ratio = ratio_before_prompt - .clone() - .unwrap_or(TokenUsageRatio::Normal); - let new_ratio = usage.ratio(); - if old_ratio == TokenUsageRatio::Normal - && new_ratio == TokenUsageRatio::Warning - { - if let Some(tx) = token_limit_tx.take() { - tx.send(()).ok(); - } - } + move |thread, event: &TokenUsageUpdated, cx| { + if !thread.read(cx).auto_compaction_enabled(cx) + && event.0.as_ref().is_some_and(|usage| usage.ratio() > ratio_before_prompt) + && let Some(sender) = token_limit_tx.take() + { + sender.send(()).ok(); } }, ); - - let wait_for_prompt = cx - .background_spawn(async move { - futures::select! { - response = task.fuse() => match response { - Ok(Some(response)) => { - match response.stop_reason { - acp::StopReason::Cancelled => SubagentPromptResult::Cancelled, - acp::StopReason::MaxTokens => SubagentPromptResult::Error("The agent reached the maximum number of tokens.".into()), - acp::StopReason::MaxTurnRequests => SubagentPromptResult::Error("The agent reached the maximum number of allowed requests between user turns. Try prompting again.".into()), - acp::StopReason::Refusal => SubagentPromptResult::Error("The agent refused to process that prompt. Try again.".into()), - acp::StopReason::EndTurn | _ => SubagentPromptResult::Completed, - } - } - Ok(None) => SubagentPromptResult::Error("No response from the agent. You can try messaging again.".into()), - Err(error) => SubagentPromptResult::Error(error.to_string()), - }, - _ = token_limit_rx.fuse() => SubagentPromptResult::ContextWindowWarning, - } - }); - - (wait_for_prompt, subscription) + let task = acp_thread.update(cx, |acp_thread, cx| { + acp_thread.send(vec![message.into()], cx) + }); + (task, token_limit_rx, subscription) }); - let result = match task.await { - SubagentPromptResult::Completed => thread.read_with(cx, |thread, _cx| { - thread - .last_message() - .and_then(|message| { - let content = message.as_agent_message()? - .content - .iter() - .filter_map(|c| match c { - AgentMessageContent::Text(text) => Some(text.as_str()), - _ => None, - }) - .join("\n\n"); - if content.is_empty() { - None - } else { - Some( content) - } - }) - .context("No response from subagent") - }), - SubagentPromptResult::Cancelled => Err(anyhow!("User canceled")), - SubagentPromptResult::Error(message) => Err(anyhow!("{message}")), - SubagentPromptResult::ContextWindowWarning => { - thread.update(cx, |thread, cx| thread.cancel(cx)).await; - Err(anyhow!( - "The agent is nearing the end of its context window and has been \ - stopped. You can prompt the thread again to have the agent wrap up \ - or hand off its work." - )) + let mut task = task.fuse(); + let response = futures::select_biased! { + response = task => response, + _ = token_limit_rx.fuse() => { + if thread.read_with(cx, |thread, _| thread.is_turn_complete()) { + task.await + } else { + thread.update(cx, |thread, cx| thread.cancel(cx)).await; + Err(anyhow!( + "The agent is nearing the end of its context window and has been \ + stopped. You can prompt the thread again to have the agent wrap up \ + or hand off its work." + )) + } } }; + let discard_partial_output = matches!( + &response, + Ok(Some(response)) if response.stop_reason == acp::StopReason::Cancelled + || response.stop_reason == acp::StopReason::Refusal + ); + let result = match response { + Ok(Some(response)) => match response.stop_reason { + acp::StopReason::Cancelled => Err(anyhow!("User canceled")), + acp::StopReason::MaxTokens => Err(anyhow!("The agent reached the maximum number of tokens.")), + acp::StopReason::MaxTurnRequests => Err(anyhow!("The agent reached the maximum number of allowed requests between user turns. Try prompting again.")), + acp::StopReason::Refusal => Err(anyhow!("The agent refused to process that prompt. Try again.")), + _ => thread.read_with(cx, |thread, _cx| { + thread + .last_message() + .and_then(|message| { + let content = message.as_agent_message()? + .content + .iter() + .filter_map(|content| match content { + AgentMessageContent::Text(text) => Some(text.as_str()), + _ => None, + }) + .join("\n\n"); + if content.is_empty() { + None + } else { + Some(content) + } + }) + .context("No response from subagent") + }), + }, + Ok(None) => Err(anyhow!("No response from the agent. You can try messaging again.")), + Err(error) => Err(error), + }; parent_thread .update(cx, |parent_thread, cx| { @@ -3553,7 +3567,18 @@ impl SubagentHandle for NativeSubagentHandle { }) .ok(); - result + if discard_partial_output { + result + } else { + result.map_err(|error| { + let partial_output = thread.read_with(cx, |thread, _| thread.subagent_partial_output()); + if partial_output.is_empty() { + anyhow!("{error:#}") + } else { + anyhow!("{error:#}\n\nPartial subagent output (last 3 messages, up to 4096 characters each):\n\n{partial_output}") + } + }) + } }) } } @@ -3569,9 +3594,8 @@ impl TerminalHandle for AcpTerminalHandle { } fn wait_for_exit(&self, cx: &AsyncApp) -> Result>> { - Ok(self - .terminal - .read_with(cx, |term, _cx| term.wait_for_exit())) + self.terminal + .read_with(cx, |term, _cx| term.wait_for_exit()) } fn current_output(&self, cx: &AsyncApp) -> Result { @@ -3832,6 +3856,7 @@ mod internal_tests { use std::path::Path; use super::*; + use crate::tests::release_dropped_entities; use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; use agent_settings::COMPACTION_PROMPT; use fs::FakeFs; @@ -3839,8 +3864,8 @@ mod internal_tests { use indoc::formatdoc; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; use language_model::{ - CompletionIntent, LanguageModelCompletionEvent, LanguageModelProviderId, - LanguageModelProviderName, + CompletionIntent, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelProviderId, LanguageModelProviderName, }; use serde_json::json; use settings::SettingsStore; @@ -3863,7 +3888,7 @@ mod internal_tests { }); let temp_dir = tempfile::tempdir().expect("create terminal working directory"); - let fs = Arc::new(fs::RealFs::new(None, cx.executor())); + let fs = fs::RealFs::new(None, cx.executor()); let project = Project::test(fs.clone(), [temp_dir.path()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs, cx)); @@ -4049,6 +4074,10 @@ mod internal_tests { #[gpui::test] async fn test_compact_prompt_routes_to_manual_compaction(cx: &mut TestAppContext) { + use feature_flags::{ + AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _, FeatureFlagsSettings, + }; + init_test(cx); let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); @@ -4057,6 +4086,16 @@ mod internal_tests { let old_message_id = ClientUserMessageId::new(); cx.update(|cx| { + cx.update_flags(true, Vec::new()); + FeatureFlagsSettings::override_global( + FeatureFlagsSettings { + overrides: HashMap::from_iter([( + AcpBetaFeatureFlag::NAME.into(), + "off".into(), + )]), + }, + cx, + ); let path_style = project.read(cx).path_style(cx); thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); @@ -4072,6 +4111,7 @@ mod internal_tests { let compact_message_id = ClientUserMessageId::new(); let prompt_task = cx.update(|cx| { + assert!(!cx.has_flag::()); acp_thread::AgentSessionClientUserMessageIds::prompt( connection.as_ref(), compact_message_id, @@ -4095,10 +4135,554 @@ mod internal_tests { ] ); - model.send_completion_stream_text_chunk(&request, "summary"); + let compaction_id = acp_thread.read_with(cx, |thread, _| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("native compaction should create an ACP-visible entry"); + }; + assert!(thread.is_compacting()); + assert!(compaction.is_in_progress()); + assert!(compaction.summary.is_empty()); + compaction.id.clone() + }); + + model.send_completion_stream_text_chunk(&request, "retained "); + cx.run_until_parked(); + let summary = acp_thread.read_with(cx, |thread, cx| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("native compaction entry should remain in the timeline"); + }; + assert_eq!(compaction.id, compaction_id); + assert!(compaction.is_in_progress()); + let [acp_thread::ContentBlock::Markdown { markdown }] = compaction.summary.as_slice() + else { + panic!("native text chunks should create one retained Markdown block"); + }; + assert_eq!(markdown.read(cx).source().as_ref(), "retained "); + markdown.clone() + }); + model.send_completion_stream_text_chunk(&request, "context"); model.end_completion_stream(&request); cx.run_until_parked(); - prompt_task.await.unwrap(); + prompt_task + .await + .expect("native compaction should complete"); + acp_thread.read_with(cx, |thread, cx| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("completed native compaction should remain in the timeline"); + }; + assert_eq!(compaction.id, compaction_id); + assert!(!thread.is_compacting()); + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Completed + ); + assert!(compaction.error.is_none()); + assert_eq!(compaction.summary.len(), 1); + assert_eq!( + compaction + .summary + .first() + .and_then(|block| block.markdown()), + Some(&summary) + ); + assert_eq!(summary.read(cx).source().as_ref(), "retained context"); + }); + + agent.update(cx, |agent, cx| agent.save_thread(thread.clone(), cx)); + cx.run_until_parked(); + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { + assert!(!agent.sessions.contains_key(&session_id)); + }); + let restored = cx + .update(|cx| { + connection.clone().load_session( + session_id, + project, + PathList::new(&[Path::new("/a")]), + None, + cx, + ) + }) + .await + .expect("compacted native session should reload"); + cx.run_until_parked(); + restored.read_with(cx, |thread, cx| { + let compactions = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => Some(compaction), + _ => None, + }) + .collect::>(); + let [compaction] = compactions.as_slice() else { + panic!("replay should restore exactly one compaction"); + }; + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Completed + ); + assert!(!thread.is_compacting()); + assert_eq!( + compaction + .summary + .first() + .map(|block| block.to_markdown(cx)), + Some("retained context") + ); + }); + } + + #[gpui::test] + async fn test_load_session_replays_provider_native_compaction(cx: &mut TestAppContext) { + init_test(cx); + let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let mut saved_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await; + saved_thread.messages.push(Arc::new(Message::Compaction( + CompactionInfo::ProviderNative { + provider: LanguageModelProviderId::from("openai".to_string()), + items: vec![json!({"type": "compaction", "encrypted_content": "opaque state"})], + }, + ))); + let restored_session_id = acp::SessionId::new("provider-native-compaction"); + let database = cx + .update(|cx| ThreadsDatabase::connect(cx)) + .await + .expect("thread database should connect"); + database + .save_thread( + restored_session_id.clone(), + saved_thread, + PathList::new(&[Path::new("/a")]), + ) + .await + .expect("provider-native compaction should save"); + + let restored = cx + .update(|cx| { + connection.load_session( + restored_session_id, + project, + PathList::new(&[Path::new("/a")]), + None, + cx, + ) + }) + .await + .expect("provider-native compaction should load"); + cx.run_until_parked(); + restored.read_with(cx, |thread, _| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("provider-native replay should create a compaction entry"); + }; + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Completed + ); + assert!(compaction.summary.is_empty()); + assert!(compaction.error.is_none()); + assert!(!thread.is_compacting()); + }); + } + + #[gpui::test] + async fn test_retried_auto_compaction_terminalizes_each_attempt(cx: &mut TestAppContext) { + init_test(cx); + let (connection, agent, _project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let model = Arc::new(FakeLanguageModel::default()); + model.set_max_token_count(1_000_000); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.auto_compact = agent_settings::AutoCompactSettings { + enabled: true, + threshold: agent_settings::AutoCompactThreshold::Percentage(0.5), + }; + agent_settings::AgentSettings::override_global(settings, cx); + thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx)); + }); + + let first_prompt = cx.update(|cx| { + acp_thread::AgentSessionClientUserMessageIds::prompt( + connection.as_ref(), + ClientUserMessageId::new(), + acp::PromptRequest::new(session_id.clone(), vec!["old user".into()]), + cx, + ) + }); + cx.run_until_parked(); + + let first_request = model + .pending_completions() + .pop() + .expect("first user prompt should start a model request"); + model.send_completion_stream_event( + &first_request, + LanguageModelCompletionEvent::UsageUpdate(language_model::TokenUsage { + input_tokens: 750_000, + ..Default::default() + }), + ); + model.send_completion_stream_text_chunk(&first_request, "old assistant"); + model.end_completion_stream(&first_request); + cx.run_until_parked(); + first_prompt + .await + .expect("first user prompt should complete"); + + let second_prompt = cx.update(|cx| { + acp_thread::AgentSessionClientUserMessageIds::prompt( + connection.as_ref(), + ClientUserMessageId::new(), + acp::PromptRequest::new(session_id, vec!["new user".into()]), + cx, + ) + }); + cx.run_until_parked(); + + let first_compaction_request = model + .pending_completions() + .pop() + .expect("token threshold should start automatic compaction"); + assert_eq!( + first_compaction_request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + model.send_completion_stream_error( + &first_compaction_request, + LanguageModelCompletionError::from_provider_response( + language_model::ANTHROPIC_PROVIDER_NAME, + None, + Some("rate_limit_error".to_string()), + "Rate limit exceeded".to_string(), + Some(Duration::ZERO), + language_model::ProviderErrorCategory::RateLimit, + ), + ); + model.end_completion_stream(&first_compaction_request); + cx.run_until_parked(); + + let second_compaction_request = model + .pending_completions() + .pop() + .expect("retryable error should start another compaction attempt"); + assert_eq!( + second_compaction_request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + acp_thread.read_with(cx, |thread, _cx| { + let statuses = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => { + Some(compaction.status.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!( + statuses, + [ + acp_thread::ContextCompactionStatus::Failed, + acp_thread::ContextCompactionStatus::InProgress, + ] + ); + assert!(thread.is_compacting()); + }); + + model.send_completion_stream_text_chunk(&second_compaction_request, "summary"); + model.end_completion_stream(&second_compaction_request); + cx.run_until_parked(); + + acp_thread.read_with(cx, |thread, _cx| { + let statuses = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => { + Some(compaction.status.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!( + statuses, + [ + acp_thread::ContextCompactionStatus::Failed, + acp_thread::ContextCompactionStatus::Completed, + ] + ); + assert!( + !thread.is_compacting(), + "a successful retry must not leave an older attempt in progress" + ); + }); + + let final_request = model + .pending_completions() + .pop() + .expect("successful compaction should continue the user turn"); + assert_eq!(final_request.intent, Some(CompletionIntent::UserPrompt)); + model.send_completion_stream_text_chunk(&final_request, "new assistant"); + model.end_completion_stream(&final_request); + cx.run_until_parked(); + second_prompt + .await + .expect("user prompt should complete after the compaction retry"); + } + + #[gpui::test] + async fn test_native_compaction_cancellation_is_bridged_before_stop(cx: &mut TestAppContext) { + init_test(cx); + + for (scenario, cancel_before_first_poll, partial_summary) in [ + ("before first poll", true, None), + ("after initial update", false, None), + ( + "after partial summary", + false, + Some("retained partial summary"), + ), + ] { + let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + let path_style = project.read(cx).path_style(cx); + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread.push_acp_user_block( + ClientUserMessageId::new(), + [acp::ContentBlock::from("old user")], + path_style, + cx, + ); + thread.push_acp_agent_block("old assistant".into(), cx); + }); + }); + + let (response_stream, cancellation_task) = if cancel_before_first_poll { + thread.update(cx, |thread, cx| { + let response_stream = thread + .compact(ClientUserMessageId::new(), cx) + .expect("manual compaction should start"); + let cancellation_task = thread.cancel(cx); + (response_stream, cancellation_task) + }) + } else { + let response_stream = thread + .update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + .expect("manual compaction should start"); + cx.run_until_parked(); + + let request = model + .pending_completions() + .pop() + .expect("manual compaction should reach the model"); + if let Some(partial_summary) = partial_summary { + model.send_completion_stream_text_chunk(&request, partial_summary); + cx.run_until_parked(); + } + + let cancellation_task = thread.update(cx, |thread, cx| thread.cancel(cx)); + (response_stream, cancellation_task) + }; + cancellation_task.await; + + let response = cx + .update(|cx| { + NativeAgentConnection::handle_thread_events( + response_stream, + acp_thread.downgrade(), + Some(connection.as_ref().clone()), + cx, + ) + }) + .await + .expect("canceled compaction events should be bridged"); + assert_eq!( + response.stop_reason, + acp::StopReason::Cancelled, + "{scenario}" + ); + + acp_thread.read_with(cx, |thread, cx| { + let compactions = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => { + Some(compaction) + } + _ => None, + }) + .collect::>(); + + if cancel_before_first_poll { + assert!( + compactions.is_empty(), + "{scenario}: cancellation before the task's first poll must not create a late compaction" + ); + assert!(!thread.is_compacting(), "{scenario}"); + return; + } + + let [compaction] = compactions.as_slice() else { + panic!("{scenario}: expected exactly one visible compaction"); + }; + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Canceled, + "{scenario}" + ); + assert!(!thread.is_compacting(), "{scenario}"); + assert_eq!( + compaction.summary.first().map(|block| block.to_markdown(cx)), + partial_summary, + "{scenario}" + ); + }); + } + } + + #[gpui::test] + async fn test_native_tool_names_survive_reused_ids_and_reload(cx: &mut TestAppContext) { + use language_model::{LanguageModelToolUse, LanguageModelToolUseInput}; + + init_test(cx); + let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let model = Arc::new(FakeLanguageModel::default()); + let fs = project.read_with(cx, |project, _| project.fs().clone()); + fs.write(Path::new("/a/file.txt"), b"retained file contents") + .await + .expect("test file should be written"); + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + settings.tool_permissions.tools.clear(); + agent_settings::AgentSettings::override_global(settings, cx); + thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx)); + }); + + for tool_name in ["read_file", "unavailable_tool"] { + let prompt_task = cx.update(|cx| { + acp_thread::AgentSessionClientUserMessageIds::prompt( + connection.as_ref(), + ClientUserMessageId::new(), + acp::PromptRequest::new(session_id.clone(), vec!["use a tool".into()]), + cx, + ) + }); + cx.run_until_parked(); + let request = model + .pending_completions() + .pop() + .expect("user prompt should reach the model"); + let input = json!({"path": "a/file.txt"}); + model.send_completion_stream_event( + &request, + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: "reused_id".into(), + name: tool_name.into(), + raw_input: input.to_string(), + input: LanguageModelToolUseInput::Json(input), + is_input_complete: true, + thought_signature: None, + }), + ); + model.end_completion_stream(&request); + cx.run_until_parked(); + let request = model + .pending_completions() + .pop() + .expect("tool result should reach the model"); + assert_eq!(request.intent, Some(CompletionIntent::ToolResults)); + model.send_completion_stream_text_chunk(&request, "done"); + model.end_completion_stream(&request); + cx.run_until_parked(); + prompt_task.await.expect("native tool turn should complete"); + } + + let tool_names = |thread: &AcpThread| { + thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ToolCall(tool_call) => Some(( + tool_call.id.clone(), + tool_call.tool_name.clone(), + std::mem::discriminant(&tool_call.status), + )), + _ => None, + }) + .collect::>() + }; + let live_tool_names = acp_thread.read_with(cx, |thread, _| tool_names(thread)); + let [ + (read_file_id, read_file_name, read_file_status), + (unavailable_id, unavailable_name, unavailable_status), + ] = live_tool_names.as_slice() + else { + panic!("native turns should produce two distinct ACP tool calls"); + }; + assert_ne!(read_file_id, unavailable_id); + assert_eq!(read_file_name.as_deref(), Some("read_file")); + assert_eq!( + *read_file_status, + std::mem::discriminant(&acp_thread::ToolCallStatus::Completed) + ); + assert_eq!(unavailable_name.as_deref(), Some("unavailable_tool")); + assert_eq!( + *unavailable_status, + std::mem::discriminant(&acp_thread::ToolCallStatus::Failed) + ); + + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { + assert!(!agent.sessions.contains_key(&session_id)); + }); + + let restored = cx + .update(|cx| { + connection.clone().load_session( + session_id, + project, + PathList::new(&[Path::new("/a")]), + None, + cx, + ) + }) + .await + .expect("native tool session should reload"); + cx.run_until_parked(); + assert_eq!( + restored.read_with(cx, |thread, _| tool_names(thread)), + live_tool_names + ); } #[gpui::test] @@ -4817,7 +5401,7 @@ mod internal_tests { state.skill_loading_issues.iter().any(|issue| { issue.kind == SkillLoadingIssueKind::DescriptionTooLong && issue.path == skill_path - && issue.message.to_string().contains("1024-byte limit") + && issue.message.to_string().contains("1024-character limit") }), "expected a description-length warning issue, got {:?}", state.skill_loading_issues @@ -4838,7 +5422,7 @@ mod internal_tests { available_skill .warning .as_ref() - .is_some_and(|warning| warning.contains("1024-byte limit")), + .is_some_and(|warning| warning.contains("1024-character limit")), "available skill should expose warning text, got {:?}", available_skill.warning ); @@ -5249,7 +5833,7 @@ mod internal_tests { // Run the subagent through the production registration path. // This is what installs the `SkillTool` on the thread. let _subagent_acp = agent.update(cx, |agent, cx| { - agent.register_session(subagent_thread.clone(), parent_project_id, 1, cx) + agent.register_session(subagent_thread.clone(), parent_project_id, cx) }); // Verify the subagent thread has the `SkillTool` installed — @@ -5494,7 +6078,12 @@ mod internal_tests { cx: &mut TestAppContext, fs: Arc, root: &str, - ) -> (Entity, Entity, WorktreeId) { + ) -> ( + Entity, + Entity, + WorktreeId, + Entity, + ) { use collections::{HashMap, HashSet}; use project::trusted_worktrees::{self, PathTrust, TrustedWorktrees}; @@ -5508,7 +6097,7 @@ mod internal_tests { cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); - let _acp_thread = cx + let acp_thread = cx .update(|cx| { Rc::new(connection).new_session( project.clone(), @@ -5536,7 +6125,7 @@ mod internal_tests { }); cx.run_until_parked(); - (agent, project, worktree_id) + (agent, project, worktree_id, acp_thread) } /// The body resolver for a project-local skill must read the file @@ -5564,7 +6153,7 @@ mod internal_tests { ) .await; - let (agent, project, worktree_id) = + let (agent, project, worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -5631,7 +6220,7 @@ mod internal_tests { ) .await; - let (agent, project, _worktree_id) = + let (agent, project, _worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -5681,7 +6270,7 @@ mod internal_tests { ) .await; - let (agent, project, _worktree_id) = + let (agent, project, _worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -5729,7 +6318,7 @@ mod internal_tests { ) .await; - let (agent, project, worktree_id) = + let (agent, project, worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -6164,11 +6753,9 @@ mod internal_tests { cx.run_until_parked(); // Close the session so it can be reloaded from disk. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); @@ -6267,11 +6854,9 @@ mod internal_tests { cx.run_until_parked(); // Close the session so it can be reloaded from disk. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); @@ -6362,10 +6947,8 @@ mod internal_tests { send.await.unwrap(); cx.run_until_parked(); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(acp_thread); + release_dropped_entities(cx); (agent, connection, project, session_id, provider) } @@ -6624,11 +7207,9 @@ mod internal_tests { }); // Close the session so it can be reloaded from disk. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert_eq!(agent.sessions.keys().cloned().collect::>(), []); }); @@ -6688,7 +7269,7 @@ mod internal_tests { } #[gpui::test] - async fn test_close_session_saves_thread(cx: &mut TestAppContext) { + async fn test_releasing_session_saves_thread(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -6746,10 +7327,12 @@ mod internal_tests { }); // Close the session immediately — no run_until_parked in between. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); - cx.run_until_parked(); + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { + assert!(agent.sessions.is_empty()); + }); // Reopen and verify the draft prompt was saved. let reloaded = agent @@ -6762,11 +7345,93 @@ mod internal_tests { assert_eq!( thread.draft_prompt(), Some(draft_blocks.as_slice()), - "close_session must save the thread; draft prompt was lost" + "releasing the session must save the thread; draft prompt was lost" ); }); } + #[gpui::test] + async fn test_releasing_thread_releases_subagent_sessions_and_project(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": { + "file.txt": "hello" + } + }), + ) + .await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + let warmup_project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + drop(warmup_project); + release_dropped_entities(cx); + + let leak_snapshot = cx.update(|cx| cx.leak_detector_snapshot()); + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + let weak_project = project.downgrade(); + + let acp_thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let environment = NativeThreadEnvironment { + agent: agent.downgrade(), + thread: thread.downgrade(), + acp_thread: acp_thread.downgrade(), + }; + + let first_subagent = cx + .update(|cx| environment.create_subagent_thread("first".to_string(), cx)) + .unwrap(); + let second_subagent = cx + .update(|cx| environment.create_subagent_thread("second".to_string(), cx)) + .unwrap(); + cx.run_until_parked(); + + let session_ids = agent.read_with(cx, |agent, _| { + let mut ids = agent + .sessions + .keys() + .map(ToString::to_string) + .collect::>(); + ids.sort(); + ids + }); + let mut expected_ids = vec![ + session_id.to_string(), + first_subagent.id().to_string(), + second_subagent.id().to_string(), + ]; + expected_ids.sort(); + assert_eq!(session_ids, expected_ids); + + drop(first_subagent); + drop(second_subagent); + drop(environment); + drop(thread); + drop(acp_thread); + drop(project); + release_dropped_entities(cx); + + cx.update(|cx| cx.assert_no_new_leaks(&leak_snapshot)); + let (session_count, project_count) = + agent.read_with(cx, |agent, _| (agent.sessions.len(), agent.projects.len())); + assert_eq!(session_count, 0); + assert_eq!(project_count, 0); + assert!(weak_project.upgrade().is_none()); + } + #[gpui::test] async fn test_save_burst_preserves_in_flight_write(cx: &mut TestAppContext) { init_test(cx); @@ -6918,25 +7583,20 @@ mod internal_tests { cx.run_until_parked(); agent.read_with(cx, |agent, _| { - let session = agent - .sessions - .get(&session_id) - .expect("thread_summary should not close the active session"); - assert_eq!( - session.ref_count, 1, - "thread_summary should release its temporary session reference" + assert!( + agent.sessions.contains_key(&session_id), + "thread_summary should not close the active session" ); }); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); - cx.run_until_parked(); + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!( agent.sessions.is_empty(), - "closing the active session after thread_summary should unload it" + "dropping the active session after thread_summary should unload it" ); }); } @@ -6993,11 +7653,9 @@ mod internal_tests { send.await.unwrap(); cx.run_until_parked(); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); @@ -7032,14 +7690,13 @@ mod internal_tests { "concurrent loads for the same session should share one AcpThread" ); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); + drop(first_loaded_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!( agent.sessions.contains_key(&session_id), - "closing one loaded session should not drop shared session state" + "dropping one loaded handle should not drop shared session state" ); }); @@ -7078,14 +7735,9 @@ mod internal_tests { ); }); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); - - cx.run_until_parked(); - - drop(first_loaded_thread); drop(second_loaded_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 44ac412dcb..358b114538 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -4,7 +4,7 @@ use acp_thread::{ ThreadStatus, }; use agent_client_protocol::schema::v1 as acp; -use agent_settings::AgentProfileId; +use agent_settings::{AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT}; use anyhow::Result; use client::{Client, RefreshLlmTokenListener, UserStore}; use collections::IndexMap; @@ -64,6 +64,11 @@ pub(crate) fn init_test(cx: &mut TestAppContext) { }); } +pub(crate) fn release_dropped_entities(cx: &mut TestAppContext) { + cx.update(|_| ()); + cx.run_until_parked(); +} + pub(crate) struct FakeTerminalHandle { killed: Arc, stopped_by_user: Arc, @@ -4267,10 +4272,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) { request.await.expect("prompt should fail gracefully"); // Explicitly close the session and drop the ACP thread. - cx.update(|cx| Rc::new(connection.clone()).close_session(&session_id, cx)) - .await - .unwrap(); drop(acp_thread); + release_dropped_entities(cx); let result = cx .update(|cx| { acp_thread::AgentSessionClientUserMessageIds::prompt( @@ -4338,8 +4341,8 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { assert_eq!( tool_call, acp::ToolCall::new(tool_call_id.clone(), "Echo") + .name("echo") .raw_input(json!({})) - .meta(acp::Meta::from_iter([("tool_name".into(), "echo".into())])) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( @@ -5596,7 +5599,8 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); model.send_last_completion_stream_text_chunk("subagent task response"); @@ -5732,7 +5736,8 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); model.send_last_completion_stream_text_chunk("subagent task response 1"); @@ -5880,7 +5885,8 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); // model.send_last_completion_stream_text_chunk("subagent task response"); @@ -6012,7 +6018,8 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); // Subagent responds @@ -6579,307 +6586,361 @@ async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - LanguageModelRegistry::test(cx); - }); - cx.update(|cx| { - cx.update_flags(true, vec!["subagents".to_string()]); - }); +async fn test_subagent_auto_compaction(cx: &mut TestAppContext) { + let test = SubagentCompactionTest::new(cx).await; + let send = test.send("subagent task prompt", cx); + test.tool_round(900_000, cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/", - json!({ - "a": { - "b.md": "Lorem" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = - cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); - let connection = Rc::new(NativeAgentConnection(agent.clone())); + let request = test.compaction_request(); + assert_eq!(request.thread_id, Some(test.handle.id().to_string())); + assert_eq!( + request.messages.last().unwrap().string_contents(), + COMPACTION_PROMPT + ); + assert_eq!( + request + .messages + .iter() + .flat_map(|message| &message.content) + .filter_map(|content| match content { + MessageContent::ToolResult(result) => Some(result.text_contents()), + _ => None, + }) + .collect::>(), + vec!["tool output"], + ); + test.fail_compaction( + http_client::StatusCode::SERVICE_UNAVAILABLE, + Some(Duration::from_secs(3)), + cx, + ); + test.assert_running(cx); + assert_eq!(test.model.pending_completions(), Vec::new()); + cx.executor() + .advance_clock(crate::maximum_retry_delay_with_jitter(Duration::from_secs( + 3, + ))); + cx.run_until_parked(); + let retry_request = test.compaction_request(); + assert_eq!(retry_request.messages, request.messages); + let request = retry_request; - let acp_thread = cx + test.model + .send_completion_stream_text_chunk(&request, "subagent summary"); + test.model.end_completion_stream(&request); + cx.run_until_parked(); + + let request = test.model.pending_completions().pop().unwrap(); + assert_eq!(request.intent, Some(CompletionIntent::ToolResults)); + assert_eq!(request.thread_id, Some(test.handle.id().to_string())); + assert_eq!( + request + .messages + .iter() + .filter(|message| message.role != Role::System) + .map(|message| message.string_contents()) + .collect::>(), + vec![ + "subagent task prompt", + "The previous conversation was compacted. Use this summary as context:\n\nsubagent summary", + ], + ); + test.model + .send_completion_stream_text_chunk(&request, "subagent answer"); + test.model.end_completion_stream(&request); + assert_eq!(send.await.unwrap(), "subagent answer"); + test.assert_stopped(cx); + + let handle = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) + test.environment + .resume_subagent_thread(test.handle.id(), cx) }) - .await .unwrap(); - let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); - let thread = agent.read_with(cx, |agent, _| { - agent.sessions.get(&session_id).unwrap().thread.clone() - }); - let model = Arc::new(FakeLanguageModel::default()); - - thread.update(cx, |thread, cx| { - thread.set_model(model.clone(), cx); - }); + assert_eq!(handle.id(), test.handle.id()); + let send = cx.update(|cx| handle.send("follow-up task".to_string(), &cx.to_async())); cx.run_until_parked(); - - // Start the parent turn - let send = acp_thread.update(cx, |thread, cx| thread.send_raw("Prompt", cx)); - cx.run_until_parked(); - model.send_last_completion_stream_text_chunk("spawning subagent"); - let subagent_tool_input = SpawnAgentToolInput { - label: "label".to_string(), - message: "subagent task prompt".to_string(), - session_id: None, - }; - let subagent_tool_use = LanguageModelToolUse { - id: "subagent_1".into(), - name: SpawnAgentTool::NAME.into(), - raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: language_model::LanguageModelToolUseInput::Json( - serde_json::to_value(&subagent_tool_input).unwrap(), - ), - is_input_complete: true, - thought_signature: None, - }; - model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - subagent_tool_use, - )); - model.end_last_completion_stream(); - - cx.run_until_parked(); - - // Verify subagent is running - let subagent_session_id = thread.read_with(cx, |thread, cx| { - thread - .running_subagent_ids(cx) - .get(0) - .expect("subagent thread should be running") - .clone() - }); - - // Send a usage update that crosses the warning threshold (80% of 1,000,000) - model.send_last_completion_stream_text_chunk("partial work"); - model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( - TokenUsage { - input_tokens: 850_000, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - )); - - cx.run_until_parked(); - - // The subagent should no longer be running - thread.read_with(cx, |thread, cx| { - assert!( - thread.running_subagent_ids(cx).is_empty(), - "subagent should be stopped after context window warning" - ); - }); - - // The parent model should get a new completion request to respond to the tool error - model.send_last_completion_stream_text_chunk("Response after warning"); - model.end_last_completion_stream(); - - send.await.unwrap(); - - // Verify the parent thread shows the warning error in the tool call - let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); - assert!( - markdown.contains("nearing the end of its context window"), - "tool output should contain context window warning message, got:\n{markdown}" - ); - assert!( - markdown.contains("Status: Failed"), - "tool call should have Failed status, got:\n{markdown}" + let request = test.model.pending_completions().pop().unwrap(); + assert_eq!(request.intent, Some(CompletionIntent::Subagent)); + assert_eq!( + request.messages.last().unwrap().string_contents(), + "follow-up task" ); + test.model + .send_completion_stream_text_chunk(&request, "follow-up answer"); + test.model.end_completion_stream(&request); + assert_eq!(send.await.unwrap(), "follow-up answer"); + test.assert_stopped(cx); - // Verify the subagent session still exists (can be resumed) - agent.read_with(cx, |agent, _cx| { - assert!( - agent.sessions.contains_key(&subagent_session_id), - "subagent session should still exist for potential resume" + for use_tool in [false, true] { + let send = test.send("refused follow-up", cx); + if use_tool { + test.tool_round(0, cx); + } + test.model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop( + StopReason::Refusal, + )); + test.model.end_last_completion_stream(); + assert_eq!( + send.await.unwrap_err().to_string(), + "The agent refused to process that prompt. Try again." ); - }); + test.assert_stopped(cx); + } } #[gpui::test] -async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - LanguageModelRegistry::test(cx); - }); - cx.update(|cx| { - cx.update_flags(true, vec!["subagents".to_string()]); - }); +async fn test_subagent_compaction_respects_settings_and_context_window(cx: &mut TestAppContext) { + for (enabled, max_tokens, max_output_tokens, input_tokens) in [ + (true, 1_000_000, None, 899_999), + (false, 1_000_000, None, 799_999), + (true, 79_999, None, 63_999), + (true, 100_000, Some(20_001), 79_999), + ] { + let test = SubagentCompactionTest::new(cx).await; + test.configure_compaction(enabled, max_tokens, max_output_tokens, cx); + let send = test.send("subagent task prompt", cx); + test.tool_round(input_tokens, cx); + let request = test.model.pending_completions().pop().unwrap(); + assert_eq!(request.intent, Some(CompletionIntent::ToolResults)); + test.assert_running(cx); + test.model + .send_completion_stream_text_chunk(&request, "subagent answer"); + test.model.end_completion_stream(&request); + assert_eq!(send.await.unwrap(), "subagent answer"); + test.assert_stopped(cx); + } +} - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/", - json!({ - "a": { - "b.md": "Lorem" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = - cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); - let connection = Rc::new(NativeAgentConnection(agent.clone())); - - let acp_thread = cx - .update(|cx| { - connection - .clone() - .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) - }) - .await - .unwrap(); - let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); - let thread = agent.read_with(cx, |agent, _| { - agent.sessions.get(&session_id).unwrap().thread.clone() - }); - let model = Arc::new(FakeLanguageModel::default()); - - thread.update(cx, |thread, cx| { - thread.set_model(model.clone(), cx); - }); - cx.run_until_parked(); - - // === First turn: create subagent, trigger context window warning === - let send = acp_thread.update(cx, |thread, cx| thread.send_raw("First prompt", cx)); - cx.run_until_parked(); - model.send_last_completion_stream_text_chunk("spawning subagent"); - let subagent_tool_input = SpawnAgentToolInput { - label: "initial task".to_string(), - message: "do the first task".to_string(), - session_id: None, - }; - let subagent_tool_use = LanguageModelToolUse { - id: "subagent_1".into(), - name: SpawnAgentTool::NAME.into(), - raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: language_model::LanguageModelToolUseInput::Json( - serde_json::to_value(&subagent_tool_input).unwrap(), - ), - is_input_complete: true, - thought_signature: None, - }; - model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - subagent_tool_use, - )); - model.end_last_completion_stream(); - - cx.run_until_parked(); - - let subagent_session_id = thread.read_with(cx, |thread, cx| { - thread - .running_subagent_ids(cx) - .get(0) - .expect("subagent thread should be running") - .clone() - }); - - // Subagent sends a usage update that crosses the warning threshold. - // This triggers Normal→Warning, stopping the subagent. - model.send_last_completion_stream_text_chunk("partial work"); - model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( - TokenUsage { - input_tokens: 850_000, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - )); - - cx.run_until_parked(); - - // Verify the first turn was stopped with a context window warning - thread.read_with(cx, |thread, cx| { - assert!( - thread.running_subagent_ids(cx).is_empty(), - "subagent should be stopped after context window warning" +#[gpui::test] +async fn test_subagent_context_limit_warning_when_compaction_unavailable(cx: &mut TestAppContext) { + for (enabled, max_tokens, max_output_tokens, warning_tokens) in [ + (false, 1_000_000, None, 800_000), + (true, 79_999, None, 64_000), + (true, 100_000, Some(20_001), 80_000), + ] { + let test = SubagentCompactionTest::new(cx).await; + test.configure_compaction(enabled, max_tokens, max_output_tokens, cx); + let send = test.send("subagent task prompt", cx); + let request = test.model.pending_completions().pop().unwrap(); + test.model + .send_completion_stream_text_chunk(&request, "partial work"); + test.update_usage(warning_tokens - 1, cx); + test.assert_running(cx); + assert!(!test.model.is_completion_stream_closed(&request)); + test.update_usage(warning_tokens, cx); + assert_eq!( + send.await.unwrap_err().to_string(), + format!( + "{SUBAGENT_CONTEXT_LIMIT_WARNING}\n\nPartial subagent output (last 3 messages, up to 4096 characters each):\n\npartial work" + ), ); - }); + test.assert_stopped(cx); + assert!(test.model.is_completion_stream_closed(&request)); + test.model.end_completion_stream(&request); + assert_eq!(test.model.pending_completions(), Vec::new()); + test.assert_no_compaction(cx).await; - // Parent model responds to complete first turn - model.send_last_completion_stream_text_chunk("First response"); - model.end_last_completion_stream(); + let send = test.send("wrap up", cx); + let request = test.model.pending_completions().pop().unwrap(); + test.model + .send_completion_stream_text_chunk(&request, "wrapped up"); + test.update_usage(warning_tokens + 1, cx); + test.assert_running(cx); + test.model.end_completion_stream(&request); + assert_eq!(send.await.unwrap(), "wrapped up"); + test.assert_stopped(cx); + } +} - send.await.unwrap(); - - let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); - assert!( - markdown.contains("nearing the end of its context window"), - "first turn should have context window warning, got:\n{markdown}" +#[gpui::test] +async fn test_subagent_context_limit_exceeded_without_partial_output(cx: &mut TestAppContext) { + let test = SubagentCompactionTest::new(cx).await; + test.configure_compaction(false, 1_000_000, None, cx); + let send = test.send("subagent task prompt", cx); + let request = test.model.pending_completions().pop().unwrap(); + test.update_usage(1_000_000, cx); + assert_eq!( + send.await.unwrap_err().to_string(), + SUBAGENT_CONTEXT_LIMIT_WARNING, ); + test.assert_stopped(cx); + assert!(test.model.is_completion_stream_closed(&request)); + test.model.end_completion_stream(&request); + assert_eq!(test.model.pending_completions(), Vec::new()); +} - // === Second turn: resume the same subagent (now at Warning level) === - let send2 = acp_thread.update(cx, |thread, cx| thread.send_raw("Follow up", cx)); - cx.run_until_parked(); - model.send_last_completion_stream_text_chunk("resuming subagent"); - let resume_tool_input = SpawnAgentToolInput { - label: "follow-up task".to_string(), - message: "do the follow-up task".to_string(), - session_id: Some(subagent_session_id.clone()), - }; - let resume_tool_use = LanguageModelToolUse { - id: "subagent_2".into(), - name: SpawnAgentTool::NAME.into(), - raw_input: serde_json::to_string(&resume_tool_input).unwrap(), - input: language_model::LanguageModelToolUseInput::Json( - serde_json::to_value(&resume_tool_input).unwrap(), +#[gpui::test] +async fn test_subagent_context_limit_preserves_terminal_result_during_checkpoint( + cx: &mut TestAppContext, +) { + for provider_error in [false, true] { + let test = SubagentCompactionTest::new_with_files(json!({".git": {}}), cx).await; + test.configure_compaction(false, 1_000_000, None, cx); + let mut send = test.send("subagent task prompt", cx); + let repository = test.thread.read_with(cx, |thread, cx| { + thread + .project() + .read(cx) + .git_store() + .read(cx) + .active_repository() + .unwrap() + }); + let (resume_checkpoint, checkpoint_gate) = oneshot::channel::<()>(); + let checkpoint_job = repository.update(cx, |repository, _| { + repository.send_job("hold checkpoint", None, move |_, _| checkpoint_gate) + }); + test.model + .send_last_completion_stream_text_chunk("partial work"); + if provider_error { + test.model.send_last_completion_stream_error( + LanguageModelCompletionError::from_http_status( + LanguageModelProviderName::new("test"), + http_client::StatusCode::BAD_REQUEST, + "terminal provider error".to_string(), + None, + ), + ); + } + test.model.end_last_completion_stream(); + cx.run_until_parked(); + + test.thread + .read_with(cx, |thread, _| assert!(thread.is_turn_complete())); + assert!((&mut send).now_or_never().is_none()); + repository.read_with(cx, |repository, _| { + let queue = repository.job_debug_queue().to_debug_value(); + assert_eq!( + queue["entries"] + .as_array() + .unwrap() + .iter() + .filter(|job| job["description"] == "checkpoint" && job["status"] == "Pending") + .count(), + 1, + ); + }); + test.thread.update(cx, |thread, cx| { + assert!(thread.is_turn_complete()); + cx.emit(TokenUsageUpdated(Some(acp_thread::TokenUsage { + max_tokens: 1_000_000, + used_tokens: 800_000, + input_tokens: 800_000, + ..acp_thread::TokenUsage::default() + }))); + }); + cx.run_until_parked(); + assert!((&mut send).now_or_never().is_none()); + test.parent.read_with(cx, |thread, cx| { + assert_eq!(thread.running_subagent_ids(cx), vec![test.handle.id()]); + }); + + resume_checkpoint.send(()).unwrap(); + checkpoint_job.await.unwrap().unwrap(); + if provider_error { + assert_eq!( + send.await.unwrap_err().to_string(), + concat!( + "terminal provider error\n\n", + "Partial subagent output (last 3 messages, up to 4096 characters each):\n\n", + "partial work", + ), + ); + } else { + assert_eq!(send.await.unwrap(), "partial work"); + } + test.assert_stopped(cx); + assert_eq!(test.model.pending_completions(), Vec::new()); + } +} + +#[gpui::test] +async fn test_subagent_compaction_exhausts_transient_retries(cx: &mut TestAppContext) { + let test = SubagentCompactionTest::new(cx).await; + let send = test.send("subagent task prompt", cx); + test.tool_round(950_000, cx); + for request_index in 0..5 { + test.fail_compaction( + http_client::StatusCode::SERVICE_UNAVAILABLE, + Some(Duration::from_secs(3)), + cx, + ); + assert_eq!(test.model.pending_completions(), Vec::new()); + if request_index < 4 { + test.assert_running(cx); + cx.executor() + .advance_clock(crate::maximum_retry_delay_with_jitter(Duration::from_secs( + 3, + ))); + cx.run_until_parked(); + } + } + assert_eq!( + send.await.unwrap_err().to_string(), + concat!( + "Automatic context compaction failed: compaction provider error\n\n", + "Partial subagent output (last 3 messages, up to 4096 characters each):\n\n", + "partial work", ), - is_input_complete: true, - thought_signature: None, - }; - model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(resume_tool_use)); - model.end_last_completion_stream(); - - cx.run_until_parked(); - - // Subagent responds with tokens still at warning level (no worse). - // Since ratio_before_prompt was already Warning, this should NOT - // trigger the context window warning again. - model.send_last_completion_stream_text_chunk("follow-up task response"); - model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( - TokenUsage { - input_tokens: 870_000, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - )); - model.end_last_completion_stream(); - - cx.run_until_parked(); - - // Parent model responds to complete second turn - model.send_last_completion_stream_text_chunk("Second response"); - model.end_last_completion_stream(); - - send2.await.unwrap(); - - // The resumed subagent should have completed normally since the ratio - // didn't transition (it was Warning before and stayed at Warning) - let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); - assert!( - markdown.contains("follow-up task response"), - "resumed subagent should complete normally when already at warning, got:\n{markdown}" - ); - // The second tool call should NOT have a context window warning - let second_tool_pos = markdown - .find("follow-up task") - .expect("should find follow-up tool call"); - let after_second_tool = &markdown[second_tool_pos..]; - assert!( - !after_second_tool.contains("nearing the end of its context window"), - "should NOT contain context window warning for resumed subagent at same level, got:\n{after_second_tool}" ); + test.assert_stopped(cx); + test.assert_no_compaction(cx).await; + cx.executor() + .advance_clock(crate::maximum_retry_delay_with_jitter(Duration::from_secs( + 3, + ))); + cx.run_until_parked(); + assert_eq!(test.model.pending_completions(), Vec::new()); +} + +#[gpui::test] +async fn test_subagent_compaction_parent_cancellation(cx: &mut TestAppContext) { + let test = SubagentCompactionTest::new(cx).await; + let send = test.send("subagent task prompt", cx); + test.tool_round(950_000, cx); + let request = test.compaction_request(); + test.model + .send_completion_stream_text_chunk(&request, "partial summary"); + cx.run_until_parked(); + assert!(!test.model.is_completion_stream_closed(&request)); + test.parent.update(cx, |thread, cx| thread.cancel(cx)).await; + assert_eq!(send.await.unwrap_err().to_string(), "User canceled"); + cx.run_until_parked(); + assert!(test.model.is_completion_stream_closed(&request)); + test.assert_stopped(cx); + test.assert_no_compaction(cx).await; + test.model.end_completion_stream(&request); + cx.run_until_parked(); + assert_eq!(test.model.pending_completions(), Vec::new()); +} + +#[gpui::test] +async fn test_subagent_compaction_error_propagation(cx: &mut TestAppContext) { + for provider_error in [false, true] { + let test = SubagentCompactionTest::new(cx).await; + let send = test.send("subagent task prompt", cx); + test.tool_round(950_000, cx); + let error = if provider_error { + test.fail_compaction(http_client::StatusCode::BAD_REQUEST, None, cx); + "compaction provider error" + } else { + test.model.end_completion_stream(&test.compaction_request()); + "Compaction produced an empty summary" + }; + assert_eq!( + send.await.unwrap_err().to_string(), + format!( + "Automatic context compaction failed: {error}\n\n\ + Partial subagent output (last 3 messages, up to 4096 characters each):\n\n\ + partial work" + ) + ); + test.assert_stopped(cx); + test.assert_no_compaction(cx).await; + assert_eq!(test.model.pending_completions(), Vec::new()); + } } #[gpui::test] @@ -6887,6 +6948,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { LanguageModelRegistry::test(cx); + let mut settings = AgentSettings::get_global(cx).clone(); + settings.auto_compact.enabled = false; + AgentSettings::override_global(settings, cx); }); cx.update(|cx| { cx.update_flags(true, vec!["subagents".to_string()]); @@ -6953,44 +7017,63 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { cx.run_until_parked(); - // Verify subagent is running - thread.read_with(cx, |thread, cx| { - assert!( - !thread.running_subagent_ids(cx).is_empty(), - "subagent should be running" - ); + let subagent_session_id = thread.read_with(cx, |thread, cx| { + let subagent_ids = thread.running_subagent_ids(cx); + assert_eq!(subagent_ids.len(), 1); + subagent_ids.into_iter().next().unwrap() }); - // The subagent's model returns a non-retryable error + model.send_last_completion_stream_text_chunk("partial work"); model.send_last_completion_stream_error(LanguageModelCompletionError::from_http_status( LanguageModelProviderName::new("test"), http_client::StatusCode::PAYLOAD_TOO_LARGE, "prompt too large".to_string(), None, )); + model.end_last_completion_stream(); cx.run_until_parked(); - // The subagent should no longer be running thread.read_with(cx, |thread, cx| { - assert!( - thread.running_subagent_ids(cx).is_empty(), - "subagent should not be running after error" + assert_eq!( + thread.running_subagent_ids(cx), + Vec::new(), + "The subagent should no longer be running" ); }); - // The parent model should get a new completion request to respond to the tool error - model.send_last_completion_stream_text_chunk("Response after error"); - model.end_last_completion_stream(); + let request = model.pending_completions().pop().unwrap(); + assert_eq!(request.intent, Some(CompletionIntent::ToolResults)); + let tool_results = request + .messages + .iter() + .flat_map(|message| &message.content) + .filter_map(|content| match content { + MessageContent::ToolResult(result) => Some(result), + _ => None, + }) + .collect::>(); + assert_eq!(tool_results.len(), 1); + let tool_result = tool_results.first().unwrap(); + assert_eq!(tool_result.tool_use_id.to_string(), "subagent_1"); + assert_eq!(&*tool_result.tool_name, SpawnAgentTool::NAME); + assert!(tool_result.is_error); + assert_eq!( + tool_result.text_contents(), + json!({ + "session_id": subagent_session_id, + "error": concat!( + "prompt too large\n\n", + "Partial subagent output (last 3 messages, up to 4096 characters each):\n\n", + "partial work", + ), + }) + .to_string(), + ); + model.send_completion_stream_text_chunk(&request, "Response after error"); + model.end_completion_stream(&request); send.await.unwrap(); - - // Verify the parent thread shows the error in the tool call - let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); - assert!( - markdown.contains("Status: Failed"), - "tool call should have Failed status after model error, got:\n{markdown}" - ); } #[gpui::test] @@ -8736,3 +8819,194 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) { // Thinking should now be enabled. assert!(model_b_completions[0].thinking_allowed); } + +const SUBAGENT_CONTEXT_LIMIT_WARNING: &str = "The agent is nearing the end of its context window and has been stopped. You can prompt the thread again to have the agent wrap up or hand off its work."; + +struct SubagentCompactionTest { + _agent: Entity, + _acp_thread: Entity, + parent: Entity, + thread: Entity, + environment: NativeThreadEnvironment, + handle: Rc, + model: Arc, +} + +impl SubagentCompactionTest { + async fn new(cx: &mut TestAppContext) -> Self { + Self::new_with_files(json!({}), cx).await + } + + async fn new_with_files(files: serde_json::Value, cx: &mut TestAppContext) -> Self { + init_test(cx); + cx.update(|cx| { + LanguageModelRegistry::test(cx); + let mut settings = AgentSettings::get_global(cx).clone(); + settings.auto_compact.enabled = true; + settings.auto_compact.threshold = AutoCompactThreshold::Percentage(0.9); + for profile in settings.profiles.values_mut() { + profile + .tools + .insert(EchoTool::NAME.to_string().into(), true); + } + AgentSettings::override_global(settings, cx); + }); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), files).await; + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let store = cx.new(ThreadStore::new); + let agent = cx.update(|cx| NativeAgent::new(store, Templates::new(), fs, cx)); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + let acp_thread = cx + .update(|cx| connection.new_session(project, PathList::new(&[Path::new("")]), cx)) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let parent = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + let model = Arc::new(FakeLanguageModel::default()); + parent.update(cx, |thread, cx| thread.set_model(model.clone(), cx)); + let environment = NativeThreadEnvironment { + agent: agent.downgrade(), + thread: parent.downgrade(), + acp_thread: acp_thread.downgrade(), + }; + let handle = cx + .update(|cx| environment.create_subagent_thread("subagent".to_string(), cx)) + .unwrap(); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&handle.id()).unwrap().thread.clone() + }); + thread.update(cx, |thread, _| thread.add_tool(EchoTool)); + cx.run_until_parked(); + Self { + _agent: agent, + _acp_thread: acp_thread, + parent, + thread, + environment, + handle, + model, + } + } + + fn configure_compaction( + &self, + enabled: bool, + max_tokens: u64, + max_output_tokens: Option, + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + let mut settings = AgentSettings::get_global(cx).clone(); + settings.auto_compact.enabled = enabled; + AgentSettings::override_global(settings, cx); + }); + self.model.set_max_token_count(max_tokens); + self.model.set_max_output_tokens(max_output_tokens); + } + + fn send(&self, message: &str, cx: &mut TestAppContext) -> Task> { + let send = cx.update(|cx| self.handle.send(message.to_string(), &cx.to_async())); + cx.run_until_parked(); + send + } + + fn update_usage(&self, input_tokens: u64, cx: &mut TestAppContext) { + self.model + .send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + TokenUsage { + input_tokens, + ..TokenUsage::default() + }, + )); + cx.run_until_parked(); + } + + fn tool_round(&self, input_tokens: u64, cx: &mut TestAppContext) { + self.model + .send_last_completion_stream_text_chunk("partial work"); + self.update_usage(input_tokens, cx); + self.assert_running(cx); + self.model + .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "echo_1".into(), + name: EchoTool::NAME.into(), + raw_input: r#"{"text":"tool output"}"#.to_string(), + input: language_model::LanguageModelToolUseInput::Json( + json!({"text": "tool output"}), + ), + is_input_complete: true, + thought_signature: None, + }, + )); + self.model.end_last_completion_stream(); + cx.run_until_parked(); + } + + fn compaction_request(&self) -> LanguageModelRequest { + let mut requests = self.model.pending_completions(); + assert_eq!(requests.len(), 1); + let request = requests.pop().unwrap(); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + request + } + + fn fail_compaction( + &self, + status: http_client::StatusCode, + retry_after: Option, + cx: &mut TestAppContext, + ) { + let request = self.compaction_request(); + self.model + .send_completion_stream_text_chunk(&request, "failed summary"); + self.model.send_completion_stream_error( + &request, + LanguageModelCompletionError::from_http_status( + LanguageModelProviderName::new("test"), + status, + "compaction provider error".to_string(), + retry_after, + ), + ); + self.model.end_completion_stream(&request); + cx.run_until_parked(); + } + + async fn assert_no_compaction(&self, cx: &mut TestAppContext) { + let saved = self + .thread + .read_with(cx, |thread, cx| thread.to_db(cx)) + .await; + assert_eq!( + saved + .messages + .iter() + .filter(|message| matches!(&***message, Message::Compaction(_))) + .count(), + 0 + ); + } + + fn assert_running(&self, cx: &mut TestAppContext) { + self.parent.read_with(cx, |thread, cx| { + assert_eq!(thread.running_subagent_ids(cx), vec![self.handle.id()]) + }); + self.thread + .read_with(cx, |thread, _| assert!(!thread.is_turn_complete())); + } + + fn assert_stopped(&self, cx: &mut TestAppContext) { + self.parent.read_with(cx, |thread, cx| { + assert_eq!(thread.running_subagent_ids(cx), Vec::new()) + }); + self.thread + .read_with(cx, |thread, _| assert!(thread.is_turn_complete())); + } +} diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 6185cb54cc..c14739b249 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -267,7 +267,12 @@ impl Message { Message::User(message) => message.to_markdown(), Message::Agent(message) => message.to_markdown(), Message::Resume => "[resume]\n".into(), - Message::Compaction(_) => "--- Context Compacted ---\n".into(), + Message::Compaction(CompactionInfo::Summary(summary)) => { + format!("## Context Compaction (Completed)\n\n{summary}\n\n") + } + Message::Compaction(CompactionInfo::ProviderNative { .. }) => { + "## Context Compaction (Completed)\n\n".into() + } } } @@ -1532,7 +1537,7 @@ impl Thread { cx: &mut Context, ) -> mpsc::UnboundedReceiver> { let (tx, rx) = mpsc::unbounded(); - let stream = ThreadEventStream(tx); + let stream = ThreadEventStream::new(tx); for (message_ix, message) in self.messages.iter().enumerate() { match &**message { Message::User(user_message) => stream.send_user_message(user_message), @@ -1637,9 +1642,10 @@ impl Thread { // We need to send both ToolCall and ToolCallUpdate events because the UI // only converts raw_output to displayable content in update_fields, not from_acp. stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_call_id.clone(), tool_use.name.to_string()) + .name(tool_use.name.to_string()) .status(status) .raw_input(tool_use.input.to_display_json()), ))) @@ -2428,6 +2434,16 @@ impl Thread { self.cumulative_token_usage } + /// Maximum input after reserving the selected model's output allowance. + pub fn input_token_capacity(&self) -> Option { + let model = self.model()?; + Some(compaction_input_capacity( + model.max_input_tokens(), + model.max_total_tokens(), + model.max_output_tokens(), + )) + } + pub fn latest_token_usage(&self) -> Option { let usage = self.latest_request_token_usage()?; let model = self.model()?; @@ -2588,7 +2604,7 @@ impl Thread { cx.notify(); let (events_tx, events_rx) = mpsc::unbounded::>(); - let event_stream = ThreadEventStream(events_tx); + let event_stream = ThreadEventStream::new(events_tx); let (cancellation_tx, mut cancellation_rx) = watch::channel(false); let task = cx.spawn({ let event_stream = event_stream.clone(); @@ -2697,7 +2713,7 @@ impl Thread { self.cancel(cx).detach(); let (events_tx, events_rx) = mpsc::unbounded::>(); - let event_stream = ThreadEventStream(events_tx); + let event_stream = ThreadEventStream::new(events_tx); let message_ix = self.messages.len().saturating_sub(1); self.clear_summary(); let tools = self.enabled_tools(cx); @@ -2819,7 +2835,9 @@ impl Thread { Some(error_message), ) })?; - return Err(retry_error); + return Err( + retry_error.context("Automatic context compaction failed") + ); } } } @@ -2830,7 +2848,7 @@ impl Thread { Some(error_message), ) })?; - return Err(error); + return Err(error.context("Automatic context compaction failed")); } } } @@ -3175,103 +3193,119 @@ impl Thread { insertion: CompactionInsertion, cx: &mut AsyncApp, ) -> Result> { + if *cancellation_rx.borrow() { + return Ok(ControlFlow::Break(())); + } + log::debug!("Running compaction"); let compaction_id = acp_thread::ContextCompactionId(Uuid::new_v4().to_string().into()); event_stream.send_context_compaction( compaction_id.clone(), acp_thread::ContextCompactionStatus::InProgress, ); - let stream = futures::select! { - result = model.stream_completion(request, cx).fuse() => result, - _ = cancellation_rx.changed().fuse() => { - if *cancellation_rx.borrow() { - log::debug!("Compaction cancelled before request started"); - return Ok(ControlFlow::Break(())); - } - return Ok(ControlFlow::Continue(())); - } - }; - let mut stream = stream?; - - let mut summary = String::new(); - loop { - let event = futures::select! { - event = stream.next().fuse() => event, + let result: Result> = async { + let stream = futures::select! { + result = model.stream_completion(request, cx).fuse() => result, _ = cancellation_rx.changed().fuse() => { if *cancellation_rx.borrow() { - log::debug!("Compaction cancelled while summarizing"); + log::debug!("Compaction cancelled before request started"); return Ok(ControlFlow::Break(())); } - continue; + return Ok(ControlFlow::Continue(())); } }; + let mut stream = stream?; - let Some(event) = event else { - break; - }; + let mut summary = String::new(); + loop { + let event = futures::select! { + event = stream.next().fuse() => event, + _ = cancellation_rx.changed().fuse() => { + if *cancellation_rx.borrow() { + log::debug!("Compaction cancelled while summarizing"); + return Ok(ControlFlow::Break(())); + } + continue; + } + }; - match event? { - LanguageModelCompletionEvent::Text(text) => { - summary.push_str(&text); - event_stream.send_context_compaction_update(compaction_id.clone(), &text); + let Some(event) = event else { + break; + }; + + match event? { + LanguageModelCompletionEvent::Text(text) => { + summary.push_str(&text); + event_stream.send_context_compaction_update(compaction_id.clone(), &text); + } + LanguageModelCompletionEvent::UsageUpdate(usage) => { + this.update(cx, |this, _cx| { + this.accumulate_token_usage(usage); + })?; + } + LanguageModelCompletionEvent::Stop(_) + | LanguageModelCompletionEvent::Started + | LanguageModelCompletionEvent::Queued { .. } + | LanguageModelCompletionEvent::Thinking { .. } + | LanguageModelCompletionEvent::RedactedThinking { .. } + | LanguageModelCompletionEvent::ReasoningDetails(_) + | LanguageModelCompletionEvent::ToolUse(_) + | LanguageModelCompletionEvent::ToolUseJsonParseError { .. } + | LanguageModelCompletionEvent::StartMessage { .. } + | LanguageModelCompletionEvent::Compaction(_) => {} } - LanguageModelCompletionEvent::UsageUpdate(usage) => { - this.update(cx, |this, _cx| { - this.accumulate_token_usage(usage); - })?; - } - LanguageModelCompletionEvent::Stop(_) - | LanguageModelCompletionEvent::Started - | LanguageModelCompletionEvent::Queued { .. } - | LanguageModelCompletionEvent::Thinking { .. } - | LanguageModelCompletionEvent::RedactedThinking { .. } - | LanguageModelCompletionEvent::ReasoningDetails(_) - | LanguageModelCompletionEvent::ToolUse(_) - | LanguageModelCompletionEvent::ToolUseJsonParseError { .. } - | LanguageModelCompletionEvent::StartMessage { .. } - | LanguageModelCompletionEvent::Compaction(_) => {} } - } - if *cancellation_rx.borrow() { - log::debug!("Compaction cancelled after summarizing"); - return Ok(ControlFlow::Break(())); - } + if *cancellation_rx.borrow() { + log::debug!("Compaction cancelled after summarizing"); + return Ok(ControlFlow::Break(())); + } - let summary = summary.trim().to_string(); - if summary.is_empty() { - log::warn!("Compaction produced an empty summary"); - return Err(anyhow::anyhow!("Compaction produced an empty summary")); - } + let summary = summary.trim().to_string(); + if summary.is_empty() { + log::warn!("Compaction produced an empty summary"); + return Err(anyhow::anyhow!("Compaction produced an empty summary")); + } - log::debug!("Compaction succeeded:\n{summary}"); - event_stream.update_context_compaction_status( - compaction_id, - acp_thread::ContextCompactionStatus::Completed, - ); + log::debug!("Compaction succeeded:\n{summary}"); + event_stream.update_context_compaction_status( + compaction_id.clone(), + acp_thread::ContextCompactionStatus::Completed, + ); - this.update(cx, |this, cx| { - let compaction = Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))); - match insertion { - CompactionInsertion::Auto { insertion_ix } => { - if insertion_ix <= this.messages.len() { - this.messages.insert(insertion_ix, compaction); - } else { + this.update(cx, |this, cx| { + let compaction = + Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))); + match insertion { + CompactionInsertion::Auto { insertion_ix } => { + if insertion_ix <= this.messages.len() { + this.messages.insert(insertion_ix, compaction); + } else { + this.messages.push(compaction); + } + } + CompactionInsertion::Manual { marker_id } => { + this.messages.push(Arc::new(Message::User(UserMessage { + id: marker_id, + content: Arc::from([]), + }))); this.messages.push(compaction); } } - CompactionInsertion::Manual { marker_id } => { - this.messages.push(Arc::new(Message::User(UserMessage { - id: marker_id, - content: Arc::from([]), - }))); - this.messages.push(compaction); - } - } - cx.notify(); - })?; + cx.notify(); + })?; - Ok(ControlFlow::Continue(())) + Ok(ControlFlow::Continue(())) + } + .await; + + if result.is_err() { + event_stream.update_context_compaction_status( + compaction_id, + acp_thread::ContextCompactionStatus::Failed, + ); + } + result } fn process_tool_result( @@ -3848,6 +3882,7 @@ impl Thread { return Task::ready(None).shared(); }; let mut request = LanguageModelRequest { + thread_id: Some(self.id.to_string()), intent: Some(CompletionIntent::ThreadContextSummarization), temperature: AgentSettings::temperature_for_model(&model, cx), ..Default::default() @@ -3936,7 +3971,7 @@ impl Thread { log::debug!("Generating title with model: {:?}", model.name()); let temperature = AgentSettings::temperature_for_model(&model, cx); - let request = build_thread_title_request(&self.messages, temperature); + let request = build_thread_title_request(&self.id, &self.messages, temperature); let title_generation = cx.spawn(async move |_this, cx| { stream_thread_title(model, request, cx) @@ -4113,6 +4148,7 @@ impl Thread { let request = LanguageModelRequest { thread_id: Some(self.id.to_string()), + prompt_cache_key: None, prompt_id: Some(self.prompt_id.to_string()), intent: Some(completion_intent), messages, @@ -4127,6 +4163,7 @@ impl Thread { thinking_effort: self.thinking_effort.clone(), speed: self.speed(), compact_at_tokens: None, + max_output_tokens: None, }; log::debug!("Completion request built successfully"); @@ -4295,6 +4332,19 @@ impl Thread { self.running_turn.is_none() } + pub(crate) fn auto_compaction_enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).auto_compact.enabled + && self.input_token_capacity().is_some_and(|max_input_tokens| { + // Models with a small context window don't leave enough headroom for a + // compaction pass; the UI warns the user about the token limit instead. + max_input_tokens >= MIN_COMPACTION_CONTEXT_WINDOW + }) + } + + pub(crate) fn subagent_partial_output(&self) -> String { + subagent_partial_output_from_messages(&self.messages, self.pending_message.as_ref()) + } + fn build_request_messages( &self, available_tools: Vec, @@ -4368,7 +4418,7 @@ impl Thread { let model = self.model()?; let auto_compact = AgentSettings::get_global(cx).auto_compact; let max_tokens = model.max_token_count(); - let max_input_tokens = max_tokens.saturating_sub(model.max_output_tokens().unwrap_or(0)); + let max_input_tokens = self.input_token_capacity()?; let tokens_before = self .latest_request_token_usage() .map(|usage| total_input_tokens(usage).saturating_add(usage.output_tokens)); @@ -4402,20 +4452,12 @@ impl Thread { } fn compaction_message_target_ix(&self, cx: &App) -> Option { - let auto_compact = AgentSettings::get_global(cx).auto_compact; - if !auto_compact.enabled { + if !self.auto_compaction_enabled(cx) { return None; } - let model = self.model()?; - let max_token_count = model.max_token_count(); - let max_input_tokens = - max_token_count.saturating_sub(model.max_output_tokens().unwrap_or(0)); - // Models with a small context window don't leave enough headroom for a - // compaction pass; the UI warns the user about the token limit instead. - if max_input_tokens < MIN_COMPACTION_CONTEXT_WINDOW { - return None; - } + let auto_compact = AgentSettings::get_global(cx).auto_compact; + let max_input_tokens = self.input_token_capacity()?; let (usage_ix, usage) = { let this = &self; this.messages @@ -4530,7 +4572,7 @@ impl Thread { // provider's requested delay when it gave one, and fall back to // exponential backoff when it didn't. ProviderRejection { retry_after, .. } => { - if error.retry_delay(1).is_none() { + if !error.is_transient() { return None; } Some(match retry_after { @@ -4569,6 +4611,50 @@ impl Thread { } } +fn subagent_partial_output_from_messages( + messages: &[Arc], + pending_message: Option<&AgentMessage>, +) -> String { + let Some(user_message_ix) = messages + .iter() + .rposition(|message| matches!(&**message, Message::User(_))) + else { + return String::new(); + }; + + let mut text_messages = pending_message + .into_iter() + .chain( + messages + .iter() + .skip(user_message_ix + 1) + .rev() + .filter_map(|message| message.as_agent_message()), + ) + .filter_map(|message| { + let characters = message + .content + .iter() + .rev() + .filter_map(|content| match content { + AgentMessageContent::Text(text) => Some(text), + _ => None, + }) + .flat_map(|text| text.chars().rev()) + .take(4096) + .collect::>(); + if characters.is_empty() { + None + } else { + Some(characters.into_iter().rev().collect::()) + } + }) + .take(3) + .collect::>(); + text_messages.reverse(); + text_messages.join("\n\n") +} + fn total_input_tokens(usage: language_model::TokenUsage) -> u64 { usage .input_tokens @@ -4576,6 +4662,17 @@ fn total_input_tokens(usage: language_model::TokenUsage) -> u64 { .saturating_add(usage.cache_read_input_tokens) } +/// Reserves output without subtracting it from an independent input ceiling. +fn compaction_input_capacity( + input_limit: u64, + combined_limit: Option, + output_limit: Option, +) -> u64 { + combined_limit.map_or(input_limit, |combined| { + input_limit.min(combined.saturating_sub(output_limit.unwrap_or(0))) + }) +} + fn auto_compact_threshold_token_count( threshold: AutoCompactThreshold, max_token_count: u64, @@ -4855,10 +4952,12 @@ fn retained_user_request_messages_before( } pub fn build_thread_title_request( + thread_id: &acp::SessionId, messages: &[Arc], temperature: Option, ) -> LanguageModelRequest { let mut request = LanguageModelRequest { + thread_id: Some(thread_id.to_string()), intent: Some(CompletionIntent::ThreadSummarization), temperature, ..Default::default() @@ -5272,23 +5371,33 @@ pub(crate) fn scoped_tool_call_id( } #[derive(Clone)] -struct ThreadEventStream(mpsc::UnboundedSender>); +struct ThreadEventStream { + sender: mpsc::UnboundedSender>, + active_compaction: Rc>>, +} impl ThreadEventStream { + fn new(sender: mpsc::UnboundedSender>) -> Self { + Self { + sender, + active_compaction: Rc::default(), + } + } + fn send_user_message(&self, message: &UserMessage) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::UserMessage(message.clone()))) .ok(); } fn send_text(&self, text: &str) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::AgentText(text.to_string()))) .ok(); } fn send_thinking(&self, text: &str) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string()))) .ok(); } @@ -5301,7 +5410,7 @@ impl ThreadEventStream { kind: acp::ToolKind, input: serde_json::Value, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call( id, tool_name, @@ -5320,9 +5429,9 @@ impl ThreadEventStream { input: serde_json::Value, ) -> acp::ToolCall { acp::ToolCall::new(id.clone(), title) + .name(tool_name) .kind(kind) .raw_input(input) - .meta(acp_thread::meta_with_tool_name(tool_name)) } fn update_tool_call_fields( @@ -5331,7 +5440,7 @@ impl ThreadEventStream { fields: acp::ToolCallUpdateFields, meta: Option, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp::ToolCallUpdate::new(tool_call_id.clone(), fields) .meta(meta) @@ -5345,7 +5454,7 @@ impl ThreadEventStream { tool_call_id: &acp::ToolCallId, outcome: acp_thread::SelectedPermissionOutcome, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ToolCallAuthorizationResolved { tool_call_id: tool_call_id.clone(), outcome, @@ -5354,7 +5463,9 @@ impl ThreadEventStream { } fn send_retry(&self, status: acp_thread::RetryStatus) { - self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); + self.sender + .unbounded_send(Ok(ThreadEvent::Retry(status))) + .ok(); } fn send_context_compaction( @@ -5362,12 +5473,16 @@ impl ThreadEventStream { id: acp_thread::ContextCompactionId, status: acp_thread::ContextCompactionStatus, ) { - self.0 + if status == acp_thread::ContextCompactionStatus::InProgress { + self.active_compaction.replace(Some(id.clone())); + } + self.sender .unbounded_send(Ok(ThreadEvent::ContextCompaction( acp_thread::ContextCompaction { id, status, - summary: None, + error: None, + summary: Vec::new(), }, ))) .ok(); @@ -5378,7 +5493,7 @@ impl ThreadEventStream { id: acp_thread::ContextCompactionId, summary_delta: &str, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( acp_thread::ContextCompactionUpdate { id, @@ -5394,7 +5509,12 @@ impl ThreadEventStream { id: acp_thread::ContextCompactionId, status: acp_thread::ContextCompactionStatus, ) { - self.0 + if status != acp_thread::ContextCompactionStatus::InProgress + && self.active_compaction.borrow().as_ref() == Some(&id) + { + self.active_compaction.replace(None); + } + self.sender .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( acp_thread::ContextCompactionUpdate { id, @@ -5406,17 +5526,26 @@ impl ThreadEventStream { } fn send_stop(&self, reason: acp::StopReason) { - self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok(); - } - - fn send_canceled(&self) { - self.0 - .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) + self.sender + .unbounded_send(Ok(ThreadEvent::Stop(reason))) .ok(); } + fn send_canceled(&self) { + // The bridge stops consuming at Stop, and its entry cancellation scan + // may have run before the queued compaction-start event was applied. + let compaction_id = self.active_compaction.replace(None); + if let Some(compaction_id) = compaction_id { + self.update_context_compaction_status( + compaction_id, + acp_thread::ContextCompactionStatus::Canceled, + ); + } + self.send_stop(acp::StopReason::Cancelled); + } + fn send_error(&self, error: impl Into) { - self.0.unbounded_send(Err(error.into())).ok(); + self.sender.unbounded_send(Err(error.into())).ok(); } } @@ -5476,7 +5605,7 @@ impl ToolCallEventStream { let stream = ToolCallEventStream::new( "test_id".into(), acp::ToolCallId::new("0:test_id"), - ThreadEventStream(events_tx), + ThreadEventStream::new(events_tx), None, cancellation_rx, sandbox_grants, @@ -5497,7 +5626,7 @@ impl ToolCallEventStream { let stream = ToolCallEventStream::new( "test_id".into(), acp::ToolCallId::new("0:test_id"), - ThreadEventStream(events_tx), + ThreadEventStream::new(events_tx), None, cancellation_rx, Rc::new(RefCell::new(ThreadSandboxGrants::default())), @@ -5610,7 +5739,7 @@ impl ToolCallEventStream { pub fn update_diff(&self, diff: Entity) { self.stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdateDiff { id: self.tool_call_id.clone(), @@ -5623,7 +5752,7 @@ impl ToolCallEventStream { pub fn subagent_spawned(&self, id: acp::SessionId) { self.stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::SubagentSpawned(id))) .ok(); } @@ -5821,25 +5950,28 @@ impl ToolCallEventStream { }; cx.spawn(async move |cx| { let (response_tx, mut response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new( - tool_call_id.clone(), - // Leave the title untouched so the card keeps - // showing the command (matching the fallback flow). - acp::ToolCallUpdateFields::new(), - ) - .meta(acp_thread::meta_with_sandbox_authorization( - sandbox_authorization_details, - )), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::PermissionGrant, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + tool_call_id.clone(), + // Leave the title untouched so the card keeps + // showing the command (matching the fallback flow). + acp::ToolCallUpdateFields::new(), + ) + .meta( + acp_thread::meta_with_sandbox_authorization( + sandbox_authorization_details, + ), + ), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::PermissionGrant, + }, + ))) { log::error!("Failed to send sandbox authorization: {error}"); return Err(anyhow!("Failed to send sandbox authorization: {error}")); @@ -5938,24 +6070,25 @@ impl ToolCallEventStream { let tool_call_id = self.tool_call_id.clone(); cx.spawn(async move |_cx| { let (response_tx, response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new( - tool_call_id, - // Leave the title untouched so the card keeps - // showing the command (matching the escalation - // flow). - acp::ToolCallUpdateFields::new(), - ) - .meta(acp_thread::meta_with_sandbox_authorization(details)), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::PermissionGrant, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + tool_call_id, + // Leave the title untouched so the card keeps + // showing the command (matching the escalation + // flow). + acp::ToolCallUpdateFields::new(), + ) + .meta(acp_thread::meta_with_sandbox_authorization(details)), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::PermissionGrant, + }, + ))) { log::error!("Failed to send Windows-drive sandbox warning: {error}"); return Err(anyhow!( @@ -6210,28 +6343,29 @@ impl ToolCallEventStream { let thread = self.thread.clone(); cx.spawn(async move |cx| { let (response_tx, response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - // Deliberately leave the tool-call title untouched so - // the card keeps showing the *command* (not the - // failure reason): it's critical the user can see what - // they're approving to run unsandboxed. The reason is - // surfaced separately by the fallback details / warning. - tool_call: acp::ToolCallUpdate::new( - tool_call_id.clone(), - acp::ToolCallUpdateFields::new(), - ) - .meta( - acp_thread::meta_with_sandbox_fallback_authorization(details), - ), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::ActionChoice, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + // Deliberately leave the tool-call title untouched so + // the card keeps showing the *command* (not the + // failure reason): it's critical the user can see what + // they're approving to run unsandboxed. The reason is + // surfaced separately by the fallback details / warning. + tool_call: acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new(), + ) + .meta( + acp_thread::meta_with_sandbox_fallback_authorization(details), + ), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::ActionChoice, + }, + ))) { log::error!("Failed to send sandbox fallback authorization: {error}"); return Err(anyhow!( @@ -6323,17 +6457,18 @@ impl ToolCallEventStream { } let (response_tx, response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new(tool_call_id.clone(), fields), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::ActionChoice, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new(tool_call_id.clone(), fields), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::ActionChoice, + }, + ))) { log::error!("Failed to send tool call decision prompt: {error}"); return Err(anyhow!("Failed to send tool call decision prompt: {error}")); @@ -6361,14 +6496,14 @@ impl ToolCallEventStream { cx: &mut App, ) -> Task> { let stream = self.stream.clone(); - let tool_use_id = self.tool_use_id.clone(); + let tool_call_id = self.tool_call_id.clone(); cx.spawn(async move |_cx| { let (response_tx, response_rx) = oneshot::channel(); if let Err(error) = stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::Elicitation(ElicitationRequest { - tool_call_id: acp::ToolCallId::new(tool_use_id.to_string()), + tool_call_id, message, schema, response: response_tx, @@ -6431,20 +6566,21 @@ impl ToolCallEventStream { }; cx.spawn(async move |cx| { let (response_tx, mut response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new( - tool_call_id.clone(), - acp::ToolCallUpdateFields::new().title(title), - ), - options, - response: response_tx, - context, - kind: acp_thread::AuthorizationKind::PermissionGrant, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new().title(title), + ), + options, + response: response_tx, + context, + kind: acp_thread::AuthorizationKind::PermissionGrant, + }, + ))) { log::error!("Failed to send tool call authorization: {error}"); return Err(anyhow!("Failed to send tool call authorization: {error}")); @@ -6820,6 +6956,30 @@ mod tests { use settings::LanguageModelProviderSetting; use std::sync::Arc; + #[test] + fn compaction_capacity_respects_prompt_and_combined_limits() { + assert_eq!( + compaction_input_capacity(90_000, Some(200_000), Some(16_384)), + 90_000 + ); + assert_eq!( + compaction_input_capacity(128_000, Some(128_000), Some(64_000)), + 64_000 + ); + assert_eq!( + compaction_input_capacity(90_000, None, Some(64_000)), + 90_000 + ); + assert_eq!( + compaction_input_capacity(2_000, Some(2_000), Some(3_000)), + 0 + ); + assert_eq!( + compaction_input_capacity(128_000, Some(128_000), None), + 128_000 + ); + } + async fn setup_thread_for_test(cx: &mut TestAppContext) -> (Entity, ThreadEventStream) { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); @@ -6850,7 +7010,7 @@ mod tests { }); let (event_tx, _event_rx) = mpsc::unbounded(); - let event_stream = ThreadEventStream(event_tx); + let event_stream = ThreadEventStream::new(event_tx); (thread, event_stream) }) @@ -6880,7 +7040,10 @@ mod tests { let message = Message::Compaction(CompactionInfo::Summary("Older context".into())); assert_eq!(message.role(), Role::User); - assert_eq!(message.to_markdown(), "--- Context Compacted ---\n"); + assert_eq!( + message.to_markdown(), + "## Context Compaction (Completed)\n\nOlder context\n\n" + ); let request_messages = message.to_request(); assert_eq!(request_messages.len(), 1); @@ -6936,9 +7099,131 @@ mod tests { .collect() } + #[test] + fn test_subagent_partial_output_filters_non_text() { + let tool_use = LanguageModelToolUse { + id: LanguageModelToolUseId::from("tool"), + name: Arc::from("echo"), + raw_input: "{}".to_string(), + input: language_model::LanguageModelToolUseInput::Json(json!({})), + is_input_complete: true, + thought_signature: None, + }; + let tool_result = LanguageModelToolResult { + tool_use_id: tool_use.id.clone(), + tool_name: tool_use.name.clone(), + is_error: false, + content: vec![LanguageModelToolResultContent::Text(Arc::from( + "tool result", + ))], + output: Some(json!("raw tool result")), + }; + let messages = [ + user_text_message(ClientUserMessageId::new(), "old task"), + agent_text_message("old output"), + user_text_message(ClientUserMessageId::new(), "current task"), + Arc::new(Message::Agent(AgentMessage { + content: vec![ + AgentMessageContent::Text("first".to_string()), + AgentMessageContent::Thinking { + text: "hidden thinking".to_string(), + signature: None, + }, + AgentMessageContent::Text(" part".to_string()), + AgentMessageContent::RedactedThinking("redacted thinking".to_string()), + ], + ..AgentMessage::default() + })), + Arc::new(Message::Resume), + summary_compaction("compaction summary"), + Arc::new(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::ToolUse(tool_use)], + tool_results: IndexMap::from_iter([(tool_result.tool_use_id.clone(), tool_result)]), + ..AgentMessage::default() + })), + agent_text_message(""), + Arc::new(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::Thinking { + text: "more thinking".to_string(), + signature: None, + }], + ..AgentMessage::default() + })), + agent_text_message("second part"), + ]; + let pending_message = AgentMessage { + content: vec![ + AgentMessageContent::Text("pending".to_string()), + AgentMessageContent::Thinking { + text: "pending thinking".to_string(), + signature: None, + }, + AgentMessageContent::Text(" part".to_string()), + ], + ..AgentMessage::default() + }; + + assert_eq!( + subagent_partial_output_from_messages(&messages, Some(&pending_message)), + "first part\n\nsecond part\n\npending part" + ); + assert_eq!(subagent_partial_output_from_messages(&[], None), ""); + assert_eq!( + subagent_partial_output_from_messages(&[], Some(&pending_message)), + "" + ); + let mut messages = messages.to_vec(); + messages.push(user_text_message(ClientUserMessageId::new(), "next task")); + assert_eq!(subagent_partial_output_from_messages(&messages, None), ""); + } + + #[test] + fn test_subagent_partial_output_bounds_messages_and_unicode_characters() { + let first = "🦀".repeat(4096); + let second_prefix = "🚀".repeat(2048); + let second_suffix = "🦀".repeat(2048); + let third_prefix = "🌍".repeat(4095); + let messages = [ + user_text_message(ClientUserMessageId::new(), "current task"), + agent_text_message("discarded message"), + agent_text_message(&format!("discarded prefix{first}")), + Arc::new(Message::Agent(AgentMessage { + content: vec![ + AgentMessageContent::Text(format!("discarded prefix{second_prefix}")), + AgentMessageContent::Thinking { + text: "hidden thinking".to_string(), + signature: None, + }, + AgentMessageContent::Text(second_suffix.clone()), + ], + ..AgentMessage::default() + })), + ]; + let pending_message = AgentMessage { + content: vec![ + AgentMessageContent::Text(format!("discarded prefix{third_prefix}")), + AgentMessageContent::Thinking { + text: "pending thinking".to_string(), + signature: None, + }, + AgentMessageContent::Text("🦀".to_string()), + ], + ..AgentMessage::default() + }; + + let output = subagent_partial_output_from_messages(&messages, Some(&pending_message)); + assert_eq!( + output, + format!("{first}\n\n{second_prefix}{second_suffix}\n\n{third_prefix}🦀") + ); + assert_eq!(output.chars().count(), 12_292); + assert_eq!(output.len(), 49_156); + } + #[gpui::test] async fn test_thread_summary_request_uses_compacted_history(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_id = thread.read_with(cx, |thread, _| thread.id().to_string()); let summary_model = Arc::new(FakeLanguageModel::default()); let summary_task = cx.update(|cx| { @@ -6968,6 +7253,10 @@ mod tests { cx.run_until_parked(); let summary_request = summary_model.pending_completions().pop().unwrap(); + assert_eq!( + summary_request.thread_id.as_deref(), + Some(thread_id.as_str()) + ); assert_eq!( summary_request.intent, Some(CompletionIntent::ThreadContextSummarization) @@ -7002,8 +7291,10 @@ mod tests { agent_text_message("after assistant"), ]; - let request = build_thread_title_request(&messages, Some(0.2)); + let request = + build_thread_title_request(&acp::SessionId::new("thread-id"), &messages, Some(0.2)); + assert_eq!(request.thread_id.as_deref(), Some("thread-id")); assert_eq!(request.intent, Some(CompletionIntent::ThreadSummarization)); assert_eq!(request.temperature, Some(0.2)); assert_eq!( @@ -7118,6 +7409,47 @@ mod tests { }); } + #[gpui::test] + async fn test_compaction_threshold_respects_independent_input_limit(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let mut model = FakeLanguageModel::default(); + model.set_max_token_count(200_000); + model.set_max_input_tokens(90_000); + model.set_max_output_tokens(Some(16_384)); + let model = Arc::new(model); + let user_message_id = ClientUserMessageId::new(); + + cx.update(|cx| { + set_auto_compact_settings( + cx, + agent_settings::AutoCompactSettings { + enabled: true, + threshold: AutoCompactThreshold::Percentage(0.9), + }, + ); + thread.update(cx, |thread, cx| { + thread.set_model(model, cx); + thread.messages.push(user_text_message( + user_message_id.clone(), + "near the independent input limit", + )); + for (input_tokens, expected_target) in [(80_999, None), (81_000, Some(1))] { + thread.request_token_usage.insert( + user_message_id.clone(), + language_model::TokenUsage { + input_tokens, + ..Default::default() + }, + ); + + assert_eq!(thread.compaction_message_target_ix(cx), expected_target); + assert_eq!(thread.input_token_capacity(), Some(90_000)); + assert_eq!(thread.latest_token_usage().unwrap().max_tokens, 200_000); + } + }); + }); + } + #[gpui::test] async fn test_compaction_threshold_respects_enabled_setting(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; @@ -8586,35 +8918,50 @@ mod tests { }) }); + let registered_tool_call_id = scoped_tool_call_id(0, ®istered_tool_use_id).to_string(); + let missing_tool_call_id = scoped_tool_call_id(0, &missing_tool_use_id).to_string(); + let mut tool_names_by_id = HashMap::default(); let mut tool_use_ids_with_image_content = HashSet::default(); while let Some(event) = replay_events.next().await { let event = event.unwrap(); - if let ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) = - event - && let Some(content) = &update.fields.content - && content.iter().any(|content| { - matches!( - content, - acp::ToolCallContent::Content(acp::Content { - content: acp::ContentBlock::Image(_), - .. + match event { + ThreadEvent::ToolCall(tool_call) => { + tool_names_by_id.insert(tool_call.tool_call_id.to_string(), tool_call.name); + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.fields.content.as_ref().is_some_and(|content| { + content.iter().any(|content| { + matches!( + content, + acp::ToolCallContent::Content(acp::Content { + content: acp::ContentBlock::Image(_), + .. + }) + ) }) - ) - }) - { - tool_use_ids_with_image_content.insert(update.tool_call_id.to_string()); + }) => + { + tool_use_ids_with_image_content.insert(update.tool_call_id.to_string()); + } + _ => {} } } // Both tool uses live in the message pushed above, at index 0 (see // `scoped_tool_call_id`). - assert!( - tool_use_ids_with_image_content - .contains(&scoped_tool_call_id(0, ®istered_tool_use_id).to_string()) + assert!(tool_use_ids_with_image_content.contains(®istered_tool_call_id)); + assert!(tool_use_ids_with_image_content.contains(&missing_tool_call_id)); + assert_eq!( + tool_names_by_id + .get(®istered_tool_call_id) + .and_then(Option::as_deref), + Some(ReplayImageTool::NAME) ); - assert!( - tool_use_ids_with_image_content - .contains(&scoped_tool_call_id(0, &missing_tool_use_id).to_string()) + assert_eq!( + tool_names_by_id + .get(&missing_tool_call_id) + .and_then(Option::as_deref), + Some("missing_image_tool") ); } diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 601c24d73a..bbb680d39e 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -590,6 +590,7 @@ mod tests { profiles: Default::default(), notify_when_agent_waiting: NotifyWhenAgentWaiting::default(), play_sound_when_agent_done: PlaySoundWhenAgentDone::default(), + prevent_idle_sleep: true, single_file_review: false, model_parameters: vec![], auto_compact: agent_settings::AutoCompactSettings { @@ -607,7 +608,11 @@ mod tests { sandbox_permissions: Default::default(), show_turn_stats: false, show_merge_conflict_indicator: true, - sidebar_side: Default::default(), + threads_sidebar: agent_settings::ThreadsSidebarSettings { + auto_open: true, + default_width: px(300.), + position: settings::SidebarDockPosition::Left, + }, thinking_display: Default::default(), } } diff --git a/crates/agent/src/tools/ask_user_tool.rs b/crates/agent/src/tools/ask_user_tool.rs index 50b3f2debc..ef84fe3e95 100644 --- a/crates/agent/src/tools/ask_user_tool.rs +++ b/crates/agent/src/tools/ask_user_tool.rs @@ -234,6 +234,7 @@ mod tests { #[gpui::test] async fn test_ask_user_returns_selected_option(cx: &mut TestAppContext) { let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let tool_call_id = event_stream.tool_call_id().clone(); let tool = Arc::new(AskUserTool); let task = cx.update(|cx| { tool.run( @@ -248,6 +249,7 @@ mod tests { }); let request = event_rx.expect_elicitation().await; + assert_eq!(request.tool_call_id, tool_call_id); assert_eq!(request.message, "Which approach?"); assert!(request.schema.properties.contains_key(CHOICE_FIELD)); assert!(!request.schema.properties.contains_key(OTHER_FIELD)); diff --git a/crates/agent/src/tools/rename_tool.rs b/crates/agent/src/tools/rename_tool.rs index 46e1bddc89..b9065cc5ca 100644 --- a/crates/agent/src/tools/rename_tool.rs +++ b/crates/agent/src/tools/rename_tool.rs @@ -81,6 +81,7 @@ impl AgentTool for RenameTool { resolved.buffer.clone(), resolved.position, input.new_name.clone(), + None, cx, ) }); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 4789ec012c..c4d8a0fd6b 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -7,7 +7,9 @@ use agent_client_protocol::schema::{ ProtocolVersion, v1::{self as acp, ErrorCode}, }; -use agent_client_protocol::{Agent, Client, ConnectionTo, JsonRpcResponse, Lines, Responder}; +use agent_client_protocol::{ + Agent, Builder, Client, ConnectionTo, HandleDispatchFrom, JsonRpcResponse, Lines, Responder, +}; use anyhow::anyhow; use async_channel; use collections::{HashMap, HashSet}; @@ -491,7 +493,6 @@ impl AcpConnectionDefaults { struct PendingAcpSession { task: Shared, Arc>>>, - ref_count: usize, } struct SessionConfigResponse { @@ -522,7 +523,7 @@ pub struct AcpSession { suppress_abort_err: bool, session_modes: Option>>, config_options: Option, - ref_count: usize, + _release_subscription: Subscription, } pub struct AcpSessionList { @@ -662,42 +663,45 @@ pub async fn connect( const MINIMUM_SUPPORTED_VERSION: ProtocolVersion = ProtocolVersion::V1; -/// Build a `Client` connection over `transport` with Zed's full -/// agent→client handler set wired up. -/// -/// All incoming requests and notifications are forwarded to the foreground -/// dispatch queue via `dispatch_tx`, where they are handled by the -/// `handle_*` functions on a GPUI context. The returned future drives the -/// connection and completes when the transport closes; callers are expected -/// to poll it in the background and hold the task for the lifetime of the -/// connection. In unoptimized builds each inbound dispatch needs ~0.5 MiB -/// of stack, so poll it on a thread with room to spare (macOS GCD workers' -/// 512 KiB is not enough — see `AcpConnection::stdio`). The `connection_tx` -/// oneshot receives the `ConnectionTo` handle as soon as the builder -/// runs its `main_fn`. fn connect_client_future( name: &'static str, transport: impl agent_client_protocol::ConnectTo + 'static, dispatch_tx: mpsc::UnboundedSender, connection_tx: futures::channel::oneshot::Sender>, ) -> impl Future> { + client_builder(name, dispatch_tx).connect_with( + transport, + move |connection: ConnectionTo| async move { + if connection_tx.send(connection).is_err() { + log::error!("failed to send ACP connection handle — receiver was dropped"); + } + // Keep the connection alive until the transport closes. + futures::future::pending::>().await + }, + ) +} + +fn client_builder( + name: &'static str, + dispatch_sender: mpsc::UnboundedSender, +) -> Builder> { // Each handler forwards its inputs onto the foreground dispatch queue. // The SDK requires the closure to be `Send`, so we move a clone of // `dispatch_tx` into each one. macro_rules! on_request { ($handler:ident) => {{ - let dispatch_tx = dispatch_tx.clone(); - async move |req, responder, _connection| { - enqueue_request(&dispatch_tx, req, responder, $handler); + let dispatch_sender = dispatch_sender.clone(); + async move |request, responder, _connection| { + enqueue_request(&dispatch_sender, request, responder, $handler); Ok(()) } }}; } macro_rules! on_notification { ($handler:ident) => {{ - let dispatch_tx = dispatch_tx.clone(); - async move |notif, _connection| { - enqueue_notification(&dispatch_tx, notif, $handler); + let dispatch_sender = dispatch_sender.clone(); + async move |notification, _connection| { + enqueue_notification(&dispatch_sender, notification, $handler); Ok(()) } }}; @@ -752,19 +756,12 @@ fn connect_client_future( on_notification!(handle_complete_elicitation), agent_client_protocol::on_receive_notification!(), ) - .connect_with( - transport, - move |connection: ConnectionTo| async move { - if connection_tx.send(connection).is_err() { - log::error!("failed to send ACP connection handle — receiver was dropped"); - } - // Keep the connection alive until the transport closes. - futures::future::pending::>().await - }, - ) } -fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities { +fn client_capabilities_for_agent( + agent_id: &AgentId, + beta_features_enabled: bool, +) -> acp::ClientCapabilities { let mut meta = acp::Meta::from_iter([ ("terminal_output".into(), true.into()), ("terminal-auth".into(), true.into()), @@ -774,18 +771,21 @@ fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into()); } + let mut session_capabilities = acp::ClientSessionCapabilities::new().config_options( + acp::SessionConfigOptionsCapabilities::new() + .boolean(acp::BooleanConfigOptionCapabilities::new()), + ); + if beta_features_enabled { + session_capabilities = session_capabilities.compaction(acp::CompactionCapabilities::new()); + } + acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) .terminal(true) .auth(acp::AuthCapabilities::new().terminal(true)) - .session( - acp::ClientSessionCapabilities::new().config_options( - acp::SessionConfigOptionsCapabilities::new() - .boolean(acp::BooleanConfigOptionCapabilities::new()), - ), - ) + .session(session_capabilities) .elicitation( acp::ElicitationCapabilities::new() .form(acp::ElicitationFormCapabilities::new()) @@ -927,26 +927,14 @@ impl AcpConnection { } }); - // `connect_client_future` installs the production handler set and - // hands us back both the connection-future and a oneshot receiver - // that produces the `ConnectionTo` once the transport - // handshake is ready. The future must be polled on a dedicated - // thread rather than via `background_spawn`: in unoptimized builds - // its dispatch chain needs ~0.5 MiB of stack per inbound message, - // which overflows the fixed 512 KiB stacks of the GCD workers that - // poll background tasks on macOS, crashing dev builds as soon as an - // agent sends its first message. See `spawn_dedicated` for the - // stack guarantee that makes the dedicated thread sufficient. let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); let connection_future = connect_client_future("zed", transport, dispatch_tx.clone(), connection_tx); - let io_task = cx - .background_executor() - .spawn_dedicated(move |_executor| async move { - if let Err(err) = connection_future.await { - log::error!("ACP connection error: {err}"); - } - }); + let io_task = cx.background_spawn(async move { + if let Err(err) = connection_future.await { + log::error!("ACP connection error: {err}"); + } + }); let connection_rx = async move { connection_rx @@ -988,10 +976,14 @@ impl AcpConnection { } }); + let beta_features_enabled = cx.update(|cx| cx.has_flag::()); let initialize_response = connection .send_request( acp::InitializeRequest::new(ProtocolVersion::V1) - .client_capabilities(client_capabilities_for_agent(&agent_id)) + .client_capabilities(client_capabilities_for_agent( + &agent_id, + beta_features_enabled, + )) .client_info( acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), @@ -1163,6 +1155,60 @@ impl AcpConnection { session_directories_from_work_dirs(work_dirs, supports_additional_directories) } + fn register_session( + &self, + session_id: acp::SessionId, + thread: &Entity, + session_modes: Option>>, + config_options: Option, + cx: &mut App, + ) { + let release_subscription = cx.observe_release(thread, { + let session_id = session_id.clone(); + let thread_id = thread.entity_id(); + let sessions = self.sessions.clone(); + let connection = self.connection.clone(); + let supports_close = self.agent_supports_session_close(); + move |_, cx| { + let removed = { + let mut sessions = sessions.borrow_mut(); + match sessions.get(&session_id) { + Some(session) if session.thread.entity_id() == thread_id => { + sessions.remove(&session_id) + } + _ => None, + } + }; + if removed.is_none() || !supports_close { + return; + } + cx.foreground_executor() + .spawn(async move { + connection + .send_request(acp::CloseSessionRequest::new(session_id)) + .block_task() + .await + .log_err(); + }) + .detach(); + } + }); + self.sessions.borrow_mut().insert( + session_id, + AcpSession { + thread: thread.downgrade(), + suppress_abort_err: false, + session_modes, + config_options, + _release_subscription: release_subscription, + }, + ); + } + + fn agent_supports_session_close(&self) -> bool { + self.agent_capabilities.session_capabilities.close.is_some() + } + fn open_or_create_session( self: Rc, session_id: acp::SessionId, @@ -1184,19 +1230,20 @@ impl AcpConnection { // Concurrent loads should still wait for the in-flight task so that // ref-counting happens in one place and the caller sees a fully loaded // session. - if let Some(pending) = self.pending_sessions.borrow_mut().get_mut(&session_id) { - pending.ref_count += 1; + if let Some(pending) = self.pending_sessions.borrow().get(&session_id) { let task = pending.task.clone(); return cx .foreground_executor() .spawn(async move { task.await.map_err(|err| anyhow!(err)) }); } - if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { - session.ref_count += 1; - if let Some(thread) = session.thread.upgrade() { - return Task::ready(Ok(thread)); - } + if let Some(thread) = self + .sessions + .borrow() + .get(&session_id) + .and_then(|session| session.thread.upgrade()) + { + return Task::ready(Ok(thread)); } let directories = match self.session_directories_from_work_dirs(&work_dirs) { @@ -1230,16 +1277,9 @@ impl AcpConnection { // `session/update` notifications that arrive during the call // (e.g. history replay during `session/load`) can find the thread. // Modes/config are filled in once the response arrives. - this.sessions.borrow_mut().insert( - session_id.clone(), - AcpSession { - thread: thread.downgrade(), - suppress_abort_err: false, - session_modes: None, - config_options: None, - ref_count: 1, - }, - ); + cx.update(|cx| { + this.register_session(session_id.clone(), &thread, None, None, cx) + }); let response = match rpc_call(this.connection.clone(), session_id.clone(), directories) @@ -1260,17 +1300,8 @@ impl AcpConnection { this.apply_default_config_options(&session_id, config_opts, cx); } - let ref_count = this - .pending_sessions - .borrow_mut() - .remove(&session_id) - .map_or(1, |pending| pending.ref_count); + this.pending_sessions.borrow_mut().remove(&session_id); - // If `close_session` ran to completion while the load RPC was in - // flight, it will have removed both the pending entry and the - // sessions entry (and dispatched the ACP close RPC). In that case - // the thread has no live session to attach to, so fail the load - // instead of handing back an orphaned thread. { let mut sessions = this.sessions.borrow_mut(); let Some(session) = sessions.get_mut(&session_id) else { @@ -1280,7 +1311,6 @@ impl AcpConnection { }; session.session_modes = modes; session.config_options = config_options.map(ConfigOptions::new); - session.ref_count = ref_count; } Ok(thread) @@ -1292,7 +1322,6 @@ impl AcpConnection { session_id, PendingAcpSession { task: shared_task.clone(), - ref_count: 1, }, ); @@ -1567,12 +1596,7 @@ fn meta_terminal_auth_task( env: HashMap, } - let meta = match method { - acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(), - acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(), - acp::AuthMethod::Agent(agent) => agent.meta.as_ref(), - _ => None, - }?; + let meta = method.meta()?; let terminal_auth = serde_json::from_value::(meta.get("terminal-auth")?.clone()).ok()?; @@ -1693,16 +1717,15 @@ impl AgentConnection for AcpConnection { ) }); - self.sessions.borrow_mut().insert( - response.session_id, - AcpSession { - thread: thread.downgrade(), - suppress_abort_err: false, - session_modes: modes, - config_options: config_options.map(ConfigOptions::new), - ref_count: 1, - }, - ); + cx.update(|cx| { + self.register_session( + response.session_id, + &thread, + modes, + config_options.map(ConfigOptions::new), + cx, + ) + }); Ok(thread) }) @@ -1810,76 +1833,6 @@ impl AgentConnection for AcpConnection { ) } - fn supports_close_session(&self) -> bool { - self.agent_capabilities.session_capabilities.close.is_some() - } - - fn close_session( - self: Rc, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - if !self.supports_close_session() { - return Task::ready(Err(anyhow!(LoadError::Other( - "Closing sessions is not supported by this agent.".into() - )))); - } - - // If a load is still in flight, decrement its ref count. The pending - // entry is the source of truth for how many handles exist during a - // load, so we must tick it down here as well as the `sessions` entry - // that was pre-registered to receive history-replay notifications. - // Only once the pending ref count hits zero do we actually close the - // session; the load task will observe the missing sessions entry and - // fail with "session was closed before load completed". - let pending_ref_count = { - let mut pending_sessions = self.pending_sessions.borrow_mut(); - pending_sessions.get_mut(session_id).map(|pending| { - pending.ref_count = pending.ref_count.saturating_sub(1); - pending.ref_count - }) - }; - match pending_ref_count { - Some(0) => { - self.pending_sessions.borrow_mut().remove(session_id); - self.sessions.borrow_mut().remove(session_id); - - let conn = self.connection.clone(); - let session_id = session_id.clone(); - return cx.foreground_executor().spawn(async move { - conn.send_request(acp::CloseSessionRequest::new(session_id)) - .block_task() - .await?; - Ok(()) - }); - } - Some(_) => return Task::ready(Ok(())), - None => {} - } - - let mut sessions = self.sessions.borrow_mut(); - let Some(session) = sessions.get_mut(session_id) else { - return Task::ready(Ok(())); - }; - - session.ref_count = session.ref_count.saturating_sub(1); - if session.ref_count > 0 { - return Task::ready(Ok(())); - } - - sessions.remove(session_id); - drop(sessions); - - let conn = self.connection.clone(); - let session_id = session_id.clone(); - cx.foreground_executor().spawn(async move { - conn.send_request(acp::CloseSessionRequest::new(session_id.clone())) - .block_task() - .await?; - Ok(()) - }) - } - fn auth_methods(&self) -> &[acp::AuthMethod] { &self.auth_methods } @@ -1895,7 +1848,7 @@ impl AgentConnection for AcpConnection { .find(|method| method.id() == method_id)?; match method { - acp::AuthMethod::Terminal(terminal) if cx.has_flag::() => { + acp::AuthMethod::Terminal(terminal) => { let agent_id = self.id.clone(); let terminal = terminal.clone(); let store = self.agent_server_store.clone(); @@ -2225,6 +2178,7 @@ pub mod test_support { pub connection: Rc, pub load_session_count: Arc, pub close_session_count: Arc, + pub authenticate_count: Arc, pub logout_count: Arc, pub keep_agent_alive: Task>, } @@ -2274,18 +2228,6 @@ pub mod test_support { .load_session(session_id, project, work_dirs, title, cx) } - fn supports_close_session(&self) -> bool { - self.inner.supports_close_session() - } - - fn close_session( - self: Rc, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - self.inner.clone().close_session(session_id, cx) - } - fn supports_resume_session(&self) -> bool { self.inner.supports_resume_session() } @@ -2421,6 +2363,7 @@ pub mod test_support { ) -> Result { let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let authenticate_count = Arc::new(AtomicUsize::new(0)); let logout_count = Arc::new(AtomicUsize::new(0)); let sessions: Rc>> = Rc::new(RefCell::new(HashMap::default())); @@ -2447,10 +2390,12 @@ pub mod test_support { ) .on_receive_request( { + let authenticate_count = authenticate_count.clone(); let auth_elicitation_request = auth_elicitation_request.clone(); let auth_elicitation_response = auth_elicitation_response.clone(); let auth_elicitation_completion = auth_elicitation_completion.clone(); async move |_req: acp::AuthenticateRequest, responder, cx| { + authenticate_count.fetch_add(1, Ordering::SeqCst); let request = auth_elicitation_request .lock() .expect("auth elicitation request lock should not be poisoned") @@ -2608,6 +2553,7 @@ pub mod test_support { connection: Rc::new(connection), load_session_count, close_session_count, + authenticate_count, logout_count, keep_agent_alive, }) @@ -2705,7 +2651,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; - use feature_flags::FeatureFlag as _; + use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _}; use settings::Settings as _; fn init_feature_flags_test(cx: &mut gpui::TestAppContext) { @@ -2722,7 +2668,7 @@ mod tests { cx: &mut gpui::TestAppContext, ) { init_feature_flags_test(cx); - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); let elicitation = capabilities .elicitation .expect("elicitation should always be advertised"); @@ -3015,7 +2961,7 @@ mod tests { #[test] fn cursor_client_capabilities_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID)); + let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -3030,7 +2976,7 @@ mod tests { #[test] fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -3040,7 +2986,7 @@ mod tests { #[test] fn client_capabilities_include_boolean_config_options() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); assert!( capabilities @@ -3051,6 +2997,71 @@ mod tests { ); } + #[test] + fn client_capabilities_only_include_compaction_with_acp_beta() { + for (beta_enabled, expected_compaction) in + [(false, None), (true, Some(serde_json::json!({})))] + { + let capabilities = + client_capabilities_for_agent(&AgentId::new("codex-acp"), beta_enabled); + let capabilities = + serde_json::to_value(capabilities).expect("client capabilities should serialize"); + let session = capabilities + .get("session") + .expect("session capabilities should be advertised"); + assert_eq!(session.get("compaction"), expected_compaction.as_ref()); + } + } + + #[gpui::test] + async fn connection_routes_terminal_auth_without_acp_beta(cx: &mut gpui::TestAppContext) { + init_feature_flags_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "project": {} })) + .await; + let project = project::Project::test(fs, [std::path::Path::new("/project")], cx).await; + let mut harness = test_support::connect_fake_acp_connection(project, cx).await; + let method_id = acp::AuthMethodId::new("login"); + let method = acp::AuthMethod::Terminal( + acp::AuthMethodTerminal::new(method_id.clone(), "First-class login") + .args(vec!["first-class-auth".into()]) + .meta(acp::Meta::from_iter([( + "terminal-auth".to_string(), + serde_json::json!({ + "label": "Legacy login", + "command": "legacy-agent", + "args": ["legacy-auth"], + }), + )])), + ); + Rc::get_mut(&mut harness.connection) + .expect("test harness should have the only connection handle") + .auth_methods = vec![method]; + + let terminal_task = cx + .update(|cx| { + cx.update_flags(true, Vec::new()); + feature_flags::FeatureFlagsSettings::override_global( + feature_flags::FeatureFlagsSettings { + overrides: HashMap::from_iter([( + AcpBetaFeatureFlag::NAME.into(), + "off".into(), + )]), + }, + cx, + ); + assert!(!cx.has_flag::()); + harness.connection.terminal_auth_task(&method_id, cx) + }) + .expect("first-class terminal auth should be routed without ACP beta"); + terminal_task + .await + .expect_err("first-class routing should resolve the test agent's external command"); + + assert_eq!(harness.authenticate_count.load(Ordering::SeqCst), 0); + } + #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { let command = AgentServerCommand { @@ -4035,7 +4046,9 @@ mod tests { } #[gpui::test] - async fn test_loaded_sessions_keep_state_until_last_close(cx: &mut gpui::TestAppContext) { + async fn test_loaded_sessions_keep_state_until_last_handle_drops( + cx: &mut gpui::TestAppContext, + ) { let ( connection, project, @@ -4085,36 +4098,30 @@ mod tests { "underlying ACP load_session should be called exactly once for concurrent loads" ); - // The session has ref_count 2. The first close should not send the ACP - // close_session RPC — the session is still referenced. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("first close failed"); + drop(first_thread); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), 0, - "ACP close_session should not be sent while ref_count > 0" + "ACP close_session should not be sent while a thread handle is alive" ); assert!( connection.sessions.borrow().contains_key(&session_id), - "session should still be tracked after first close" + "session should still be tracked while a thread handle is alive" ); - // The second close drops ref_count to 0 — now the ACP RPC must be sent. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("second close failed"); - cx.run_until_parked(); + drop(second_thread); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), 1, - "ACP close_session should be sent exactly once when ref_count reaches 0" + "ACP close_session should be sent exactly once when the last handle drops" ); assert!( !connection.sessions.borrow().contains_key(&session_id), - "session should be removed after final close" + "session should be removed after the last handle drops" ); } @@ -4189,13 +4196,10 @@ mod tests { ); } - // Regression test: if `close_session` is issued while a `load_session` - // RPC is still in flight, the close must take effect cleanly — the load - // must fail with a recognizable error (not return an orphaned thread), - // no entry must remain in `sessions` or `pending_sessions`, and the ACP - // `close_session` RPC must be dispatched. #[gpui::test] - async fn test_close_session_during_in_flight_load(cx: &mut gpui::TestAppContext) { + async fn test_dropping_loader_during_in_flight_load_closes_session( + cx: &mut gpui::TestAppContext, + ) { let ( connection, project, @@ -4246,24 +4250,19 @@ mod tests { "sessions entry should be pre-registered to receive replay notifications" ); - // Close the session while the load is still parked. This should take - // the pending path and dispatch the ACP close RPC. - let close_task = cx.update(|cx| connection.clone().close_session(&session_id, cx)); + drop(load_task); + cx.run_until_parked(); + assert_eq!( + close_count.load(Ordering::SeqCst), + 0, + "ACP close_session must not be sent while the load RPC is in flight" + ); // Release the gate so the load RPC can finally respond. gate_tx.send(()).await.expect("gate send failed"); drop(gate_tx); - - let load_result = load_task.await; - close_task.await.expect("close failed"); cx.run_until_parked(); - - let err = load_result.expect_err("load should fail after close-during-load"); - assert!( - err.to_string() - .contains("session was closed before load completed"), - "expected close-during-load error, got: {err}" - ); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), @@ -4272,26 +4271,19 @@ mod tests { ); assert!( !connection.sessions.borrow().contains_key(&session_id), - "sessions entry should be removed after close-during-load" + "sessions entry should be removed once the loaded thread has no holders" ); assert!( !connection .pending_sessions .borrow() .contains_key(&session_id), - "pending_sessions entry should be removed after close-during-load" + "pending_sessions entry should be removed once the load resolves" ); } - // Regression test: when two concurrent `load_session` calls share a pending - // task and one of them issues `close_session` before the load RPC - // resolves, the remaining load must still succeed and the session must - // stay live. If `close_session` incorrectly short-circuits via the - // `sessions` path (removing the entry while a load is still in flight), - // the pending task will fail and both concurrent loaders will lose - // their handle. #[gpui::test] - async fn test_close_during_load_preserves_other_concurrent_loader( + async fn test_dropping_one_loader_during_load_preserves_other_concurrent_loader( cx: &mut gpui::TestAppContext, ) { let ( @@ -4340,34 +4332,29 @@ mod tests { "load_session RPC should only be dispatched once for concurrent loads" ); - // Close one of the two handles while the shared load is still parked. - // Because a second loader still holds a pending ref, this should be a - // no-op on the wire. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("close during load failed"); + drop(first_load); + cx.run_until_parked(); assert_eq!( close_count.load(Ordering::SeqCst), 0, - "close_session RPC must not be dispatched while another load handle remains" + "close_session RPC must not be dispatched while another loader remains" ); // Release the gate so the load RPC can finally respond. gate_tx.send(()).await.expect("gate send failed"); drop(gate_tx); - let first_thread = first_load.await.expect("first load should still succeed"); let second_thread = second_load.await.expect("second load should still succeed"); cx.run_until_parked(); assert_eq!( - first_thread.entity_id(), - second_thread.entity_id(), - "concurrent loads should share one AcpThread" + close_count.load(Ordering::SeqCst), + 0, + "close_session RPC must not be dispatched while a thread handle is alive" ); assert!( connection.sessions.borrow().contains_key(&session_id), - "session must remain tracked while a load handle is still outstanding" + "session must remain tracked while a thread handle is alive" ); assert!( !connection @@ -4377,11 +4364,8 @@ mod tests { "pending_sessions entry should be cleared once the load resolves" ); - // Final close drops ref_count to 0 and dispatches the ACP close RPC. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("final close failed"); - cx.run_until_parked(); + drop(second_thread); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), 1, @@ -4389,9 +4373,14 @@ mod tests { ); assert!( !connection.sessions.borrow().contains_key(&session_id), - "session should be removed after final close" + "session should be removed after the last handle is released" ); } + + fn release_dropped_entities(cx: &mut gpui::TestAppContext) { + cx.update(|_| ()); + cx.run_until_parked(); + } } fn mcp_servers_for_project(project: &Entity, cx: &App) -> Vec { @@ -5124,7 +5113,7 @@ fn handle_wait_for_terminal_exit( .run_until_cancelled(async { let exit_status = thread .update(cx, |thread, cx| { - anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) + thread.terminal(args.terminal_id)?.read(cx).wait_for_exit() }) .flatten_acp()? .await; diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index c79ebdc45c..9d682fdcbc 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -361,6 +361,7 @@ mod tests { version: SharedString::from("1.0.0"), repository: None, website: None, + license_url: None, icon_path: None, }, package: id, diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 5d50e7251a..82a430e5e1 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/agent_settings.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 875b8fef45..193cc5a8c0 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -10,7 +10,7 @@ use anyhow::Context as _; use collections::{HashSet, IndexMap}; use fs::Fs; use futures::channel::oneshot; -use gpui::{App, Pixels, SharedString}; +use gpui::{App, Pixels, SharedString, px}; use language_model::LanguageModel; use project::DisableAiSettings; use schemars::JsonSchema; @@ -31,6 +31,12 @@ pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str = include_str!("prompts/summarize_thread_detailed_prompt.txt"); pub const COMPACTION_PROMPT: &str = include_str!("prompts/compaction_prompt.txt"); +/// Bounds on the width of the threads list. They constrain the configured +/// default as well as the width the user drags to, so that no width the sidebar +/// can hold is able to crowd out the editor or collapse the list. +pub const THREADS_LIST_MIN_WIDTH: Pixels = px(200.0); +pub const THREADS_LIST_MAX_WIDTH: Pixels = px(800.0); + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PanelLayout { pub(crate) agent_dock: Option, @@ -207,7 +213,7 @@ pub struct AgentSettings { pub button: bool, pub dock: DockPosition, pub flexible: bool, - pub sidebar_side: SidebarDockPosition, + pub threads_sidebar: ThreadsSidebarSettings, pub default_width: Pixels, pub default_height: Pixels, pub max_content_width: Option, @@ -227,6 +233,7 @@ pub struct AgentSettings { pub notify_when_agent_waiting: NotifyWhenAgentWaiting, pub play_sound_when_agent_done: PlaySoundWhenAgentDone, + pub prevent_idle_sleep: bool, pub single_file_review: bool, pub model_parameters: Vec, pub auto_compact: AutoCompactSettings, @@ -244,6 +251,13 @@ pub struct AgentSettings { pub sandbox_permissions: SandboxPermissions, } +#[derive(Clone, Copy, Debug)] +pub struct ThreadsSidebarSettings { + pub auto_open: bool, + pub position: SidebarDockPosition, + pub default_width: Pixels, +} + impl AgentSettings { pub fn enabled(&self, cx: &App) -> bool { self.enabled && !DisableAiSettings::get_global(cx).disable_ai @@ -268,7 +282,7 @@ impl AgentSettings { } pub fn sidebar_side(&self) -> SidebarSide { - match self.sidebar_side { + match self.threads_sidebar.position { SidebarDockPosition::Left => SidebarSide::Left, SidebarDockPosition::Right => SidebarSide::Right, } @@ -754,11 +768,22 @@ pub fn normalize_path(raw: &str) -> String { impl Settings for AgentSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let agent = content.agent.clone().unwrap(); + let threads_sidebar = agent.threads_sidebar.unwrap(); Self { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), - sidebar_side: agent.sidebar_side.unwrap(), + threads_sidebar: ThreadsSidebarSettings { + auto_open: threads_sidebar.auto_open.unwrap(), + position: threads_sidebar.position.unwrap(), + // Clamped once here so that every reader gets a width the sidebar can + // actually hold, rather than each call site having to remember to. + default_width: threads_sidebar + .default_width + .unwrap() + .into_gpui() + .clamp(THREADS_LIST_MIN_WIDTH, THREADS_LIST_MAX_WIDTH), + }, default_width: agent.default_width.unwrap().into_gpui(), default_height: agent.default_height.unwrap().into_gpui(), max_content_width: if agent.limit_content_width.unwrap() { @@ -792,6 +817,7 @@ impl Settings for AgentSettings { notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(), play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap_or_default(), + prevent_idle_sleep: agent.prevent_idle_sleep.unwrap(), single_file_review: agent.single_file_review.unwrap(), model_parameters: agent.model_parameters, auto_compact: { @@ -989,10 +1015,9 @@ fn compile_regex_rules( #[cfg(test)] mod tests { use super::*; - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::{TestAppContext, UpdateGlobal, px}; use serde_json::json; - use settings::ToolPermissionMode; - use settings::ToolPermissionsContent; + use settings::{ToolPermissionMode, ToolPermissionsContent}; use std::path::PathBuf; #[test] @@ -1062,6 +1087,234 @@ mod tests { assert!(result.is_none()); } + #[gpui::test] + fn test_prevent_idle_sleep_defaults_to_true_and_follows_user_settings(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + assert!(AgentSettings::get_global(cx).prevent_idle_sleep); + + for (content, expected) in [ + (r#"{"agent": {"prevent_idle_sleep": false}}"#, false), + (r#"{"agent": {"prevent_idle_sleep": true}}"#, true), + (r#"{"agent": {"prevent_idle_sleep": null}}"#, true), + (r#"{"agent": {}}"#, true), + ] { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings(content, cx) + .expect("user settings should load"); + }); + assert_eq!( + AgentSettings::get_global(cx).prevent_idle_sleep, + expected, + "{content}" + ); + } + } + + #[gpui::test] + fn test_threads_sidebar_settings(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + for (content, expected_position, expected_width) in [ + (r#"{}"#, SidebarDockPosition::Left, px(300.)), + ( + r#"{ "agent": { "threads_sidebar": { "position": "right", "default_width": 360 } } }"#, + SidebarDockPosition::Right, + px(360.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "position": "right" } } }"#, + SidebarDockPosition::Right, + px(300.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "default_width": 360 } } }"#, + SidebarDockPosition::Left, + px(360.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "position": null, "default_width": null } } }"#, + SidebarDockPosition::Left, + px(300.), + ), + ( + r#"{ "agent": { "threads_sidebar": null } }"#, + SidebarDockPosition::Left, + px(300.), + ), + ( + r#"{ "agent": { "threads_sidebar": { "default_width": 5 } } }"#, + SidebarDockPosition::Left, + THREADS_LIST_MIN_WIDTH, + ), + ( + r#"{ "agent": { "threads_sidebar": { "default_width": 5000 } } }"#, + SidebarDockPosition::Left, + THREADS_LIST_MAX_WIDTH, + ), + ] { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings(content, cx) + .expect("user settings load"); + }); + let settings = AgentSettings::get_global(cx); + assert_eq!( + settings.threads_sidebar.position, expected_position, + "{content}" + ); + assert_eq!( + settings.threads_sidebar.default_width, expected_width, + "{content}" + ); + assert_eq!( + settings.sidebar_side(), + match expected_position { + SidebarDockPosition::Left => SidebarSide::Left, + SidebarDockPosition::Right => SidebarSide::Right, + }, + "{content}" + ); + assert_eq!(settings.default_width, px(640.), "{content}"); + assert!(settings.threads_sidebar.auto_open, "{content}"); + } + + for (value, expected) in [("false", false), ("true", true), ("null", true)] { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + &format!(r#"{{"agent":{{"threads_sidebar":{{"auto_open":{value}}}}}}}"#), + cx, + ) + .expect("user settings load"); + }); + assert_eq!( + AgentSettings::get_global(cx).threads_sidebar.auto_open, + expected + ); + } + } + + #[gpui::test] + fn test_threads_sidebar_settings_edit_and_reset_clears_legacy_keys(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + #[derive(Clone, Copy)] + enum Field { + Position, + DefaultWidth, + AutoOpen, + } + + let cases = [ + (Field::Position, "sidebar_side"), + (Field::DefaultWidth, "threads_sidebar_default_width"), + (Field::AutoOpen, "threads_sidebar_auto_open"), + ]; + + let content = r#" + { + // This comment and the unrelated setting should survive edits. + "agent": { + "sidebar_side": "right", + "threads_sidebar_default_width": 420, + "threads_sidebar_auto_open": true, + "threads_sidebar": { + "position": "left", + "default_width": 360, + "auto_open": false + } + }, + "unrelated": { "keep": true } + } + "#; + + for (field, legacy_key) in cases { + let mut rewrite = |old_text: String, set_value: bool| { + SettingsStore::update_global(cx, |store, _| { + store + .new_text_for_update(old_text, |settings| { + let agent = settings.agent.get_or_insert_default(); + match field { + Field::Position => agent.set_threads_sidebar_position( + set_value.then_some(SidebarDockPosition::Right), + ), + Field::DefaultWidth => agent.set_threads_sidebar_default_width( + set_value.then_some(500.0.into()), + ), + Field::AutoOpen => { + agent.set_threads_sidebar_auto_open(set_value.then_some(true)) + } + } + }) + .expect("settings update should succeed") + }) + }; + let assert_legacy_edit = |text: &str| { + let value: serde_json_lenient::Value = + serde_json_lenient::from_str(text).expect("rewritten settings are valid"); + let agent = value + .get("agent") + .and_then(serde_json_lenient::Value::as_object) + .expect("agent settings should remain an object"); + assert!( + !agent.contains_key(legacy_key), + "{legacy_key} was not removed" + ); + for sibling in [ + "sidebar_side", + "threads_sidebar_default_width", + "threads_sidebar_auto_open", + ] { + if sibling != legacy_key { + assert!(agent.contains_key(sibling), "{sibling} was not preserved"); + } + } + assert_eq!( + value + .get("unrelated") + .and_then(serde_json_lenient::Value::as_object) + .and_then(|object| object.get("keep")) + .and_then(serde_json_lenient::Value::as_bool), + Some(true) + ); + assert!(text.contains("This comment and the unrelated setting")); + }; + + let reset_text = rewrite(content.to_string(), false); + assert_legacy_edit(&reset_text); + let set_text = rewrite(content.to_string(), true); + assert_legacy_edit(&set_text); + let reset_after_set_text = rewrite(set_text, false); + assert_legacy_edit(&reset_after_set_text); + + let expected_width = match field { + Field::DefaultWidth => px(300.), + Field::Position | Field::AutoOpen => px(360.), + }; + let expected_auto_open = matches!(field, Field::AutoOpen); + for rewritten in [reset_text, reset_after_set_text] { + SettingsStore::update_global(cx, |store, cx| { + let result = store.set_user_settings(&rewritten, cx); + assert!(result.parse_error().is_none(), "settings should parse"); + }); + let settings = AgentSettings::get_global(cx); + assert_eq!(settings.threads_sidebar.position, SidebarDockPosition::Left); + assert_eq!(settings.threads_sidebar.default_width, expected_width); + assert_eq!(settings.threads_sidebar.auto_open, expected_auto_open); + } + } + } + #[gpui::test] fn test_terminal_init_command_filters_empty_without_trimming(cx: &mut gpui::App) { let store = SettingsStore::test(cx); diff --git a/crates/agent_skills/Cargo.toml b/crates/agent_skills/Cargo.toml index 31864f7a4f..d277ff33bc 100644 --- a/crates/agent_skills/Cargo.toml +++ b/crates/agent_skills/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "agent_skills.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 731a1cb23c..7153b5e6dd 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -64,7 +64,7 @@ impl SkillLoadWarning { actual_len, max_len, } => format!( - "Skill description is {actual_len} bytes, exceeding the {max_len}-byte limit. The skill was loaded, but long descriptions may consume more model-context tokens." + "Skill description is {actual_len} characters, exceeding the {max_len}-character limit. The skill was loaded, but long descriptions may consume more model-context tokens." ), } } @@ -330,9 +330,10 @@ fn validate_description_for_loading( } let mut warnings = Vec::new(); - if description.len() > MAX_SKILL_DESCRIPTION_LEN { + let description_len = description.chars().count(); + if description_len > MAX_SKILL_DESCRIPTION_LEN { warnings.push(SkillLoadWarning::DescriptionTooLong { - actual_len: description.len(), + actual_len: description_len, max_len: MAX_SKILL_DESCRIPTION_LEN, }); } @@ -416,13 +417,9 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { /// by [`validate_name`]. pub const MAX_SKILL_NAME_LEN: usize = 64; -/// Maximum recommended length (in bytes) for a skill description. The +/// Maximum recommended length (in Unicode scalar values) for a skill description. The /// create-skill UI enforces this as a hard limit, while the loader emits a /// warning and still loads longer descriptions. -/// -/// Byte-based rather than char-based because that's what `.len()` returns -/// and what every caller currently measures; the UI also surfaces this -/// limit as a byte count so the editor's counter matches the validator. pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; /// Convert an arbitrary human-readable string into a valid skill name, or @@ -532,9 +529,9 @@ pub fn validate_description(description: &str) -> Result<(), &'static str> { if description.trim().is_empty() { return Err("Skill description cannot be empty"); } - if description.len() > MAX_SKILL_DESCRIPTION_LEN { + if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN { return Err(formatcp!( - "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes" + "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} characters" )); } Ok(()) @@ -1373,9 +1370,33 @@ Content. } } + #[test] + fn test_parse_unicode_description_at_limit_loads_without_warning() { + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN); + let content = format!( + r#"--- +name: test +description: {description} +--- + +Content. +"# + ); + + let skill = parse_skill_frontmatter( + Path::new("/skills/test/SKILL.md"), + &content, + SkillSource::Global, + ) + .expect("descriptions at the character limit should load without a warning"); + + assert_eq!(skill.description, description); + assert!(skill.load_warnings.is_empty()); + } + #[test] fn test_parse_description_too_long_loads_with_warning() { - let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let long_desc = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); let content = format!( r#"--- name: test @@ -1406,7 +1427,7 @@ Content. #[test] fn test_parse_skill_file_content_rejects_description_too_long() { - let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let long_desc = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); let content = format!( r#"--- name: test @@ -1419,7 +1440,7 @@ Content. let result = parse_skill_file_content(&content); assert!(result.is_err()); - let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes"); + let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} characters"); assert!(result.unwrap_err().to_string().contains(&expected)); } @@ -2133,16 +2154,12 @@ description: A skill with no body content } #[test] - fn validate_description_length_is_measured_in_bytes() { - // "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has - // only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be - // rejected by a byte-based validator (and accepted by a char-based - // one). This regression-tests the byte semantics that strict - // validation and load-time warnings both rely on. - let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1; - let description = "é".repeat(chars); - assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN); + fn validate_description_length_is_measured_in_characters() { + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN); assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN); + assert!(validate_description(&description).is_ok()); + + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); assert!(validate_description(&description).is_err()); } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index bef45baa5d..d6ec9ce81e 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -118,6 +118,11 @@ impl AgentDiffPane { cx.subscribe(&thread, |this, _thread, event, cx| { this.handle_acp_thread_event(event, cx) }), + cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| { + if event == &(EditorEvent::SelectionsChanged { local: true }) { + cx.emit(event.clone()) + } + }), ], multibuffer, editor, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 998c1fd787..4efc64b652 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -50,9 +50,9 @@ use crate::{ }; use crate::{ AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, LoadThreadFromClipboard, - NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetFastModeWarnings, - ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata, - ToggleNewThreadMenu, ToggleOptionsMenu, + NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, RenameSelectedThread, + ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, + ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu, conversation_view::{ AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings, }, @@ -1169,6 +1169,8 @@ pub struct AgentPanel { retained_threads: HashMap>, terminals: HashMap, pending_terminal_spawn: Option, + #[cfg(test)] + test_terminal_spawn_gate: Option>, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, _extension_subscription: Option, @@ -1582,6 +1584,8 @@ impl AgentPanel { retained_threads: HashMap::default(), terminals: HashMap::default(), pending_terminal_spawn: None, + #[cfg(test)] + test_terminal_spawn_gate: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -2073,11 +2077,14 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + // The spawn is async, so the panel keeps its current base view until + // the terminal lands. Marking the spawn as pending stops + // `ensure_thread_initialized` from treating a still-uninitialized panel + // as empty and spawning a second, "initial" terminal on activation. + self.pending_terminal_spawn = Some(terminal_id); let terminal_working_directory = working_directory.clone(); let init_command = Self::terminal_init_command(run_init_command, cx); - let terminal_task = self.project.update(cx, |project, cx| { - project.create_terminal_shell(working_directory, cx) - }); + let terminal_task = self.create_terminal_shell(working_directory, cx); let workspace = self.workspace.clone(); let workspace_id = self.workspace_id; let project = self.project.downgrade(); @@ -2128,6 +2135,27 @@ impl AgentPanel { .detach_and_log_err(cx); } + fn create_terminal_shell( + &mut self, + working_directory: Option, + cx: &mut Context, + ) -> Task>> { + // A real shell ties the spawn's timing to the host, so a test that needs + // to observe the panel mid-spawn installs a gate and gets a display-only + // terminal when it releases it. + #[cfg(test)] + if let Some(gate) = self.test_terminal_spawn_gate.take() { + return cx.spawn(async move |this, cx| { + gate.await.ok(); + this.update(cx, |this, cx| this.build_display_only_terminal(cx)) + }); + } + + self.project.update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }) + } + fn terminal_init_command(run_init_command: bool, cx: &App) -> Option { run_init_command .then(|| AgentSettings::get_global(cx).terminal_init_command.clone()) @@ -2452,7 +2480,6 @@ impl AgentPanel { return; } - self.pending_terminal_spawn = Some(metadata.terminal_id); let working_directory = self.terminal_restore_working_directory(&metadata, workspace, cx); let initial_title = Self::terminal_restore_initial_title(&metadata); self.spawn_terminal( @@ -6519,6 +6546,12 @@ impl Render for AgentPanel { cx.stop_propagation(); this.new_terminal(None, AgentThreadSource::AgentPanel, window, cx); })) + .on_action(cx.listener(|this, _: &RenameSelectedThread, window, cx| { + let Some(terminal_id) = this.active_terminal_id() else { + return; + }; + this.edit_terminal_title(terminal_id, window, cx); + })) .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { this.open_configuration(window, cx); })) @@ -6791,6 +6824,21 @@ impl AgentPanel { ) } + #[cfg(any(test, feature = "test-support"))] + fn build_display_only_terminal(&self, cx: &mut Context) -> Entity { + let settings = TerminalSettings::get_global(cx).clone(); + let path_style = self.project.read(cx).path_style(cx); + let builder = terminal::TerminalBuilder::new_display_only( + settings.cursor_shape, + settings.alternate_scroll, + settings.max_scroll_history_lines, + cx.entity_id().as_u64(), + cx.background_executor(), + path_style, + ); + cx.new(|cx| builder.subscribe(cx)) + } + #[cfg(any(test, feature = "test-support"))] fn insert_display_only_terminal( &mut self, @@ -6807,17 +6855,7 @@ impl AgentPanel { cx: &mut Context, ) -> Result<()> { let init_command = Self::terminal_init_command(run_init_command, cx); - let settings = TerminalSettings::get_global(cx).clone(); - let path_style = self.project.read(cx).path_style(cx); - let builder = terminal::TerminalBuilder::new_display_only( - settings.cursor_shape, - settings.alternate_scroll, - settings.max_scroll_history_lines, - cx.entity_id().as_u64(), - cx.background_executor(), - path_style, - ); - let terminal = cx.new(|cx| builder.subscribe(cx)); + let terminal = self.build_display_only_terminal(cx); let terminal_for_init_command = terminal.clone(); let terminal_view = cx.new(|cx| { let mut view = TerminalView::new( @@ -7668,6 +7706,62 @@ mod tests { }); } + #[gpui::test] + async fn test_new_terminal_prevents_initial_terminal_creation(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + let (terminal_landed, spawn_gate) = futures::channel::oneshot::channel::<()>(); + + // `create_new_terminal` in the sidebar calls `new_terminal` and then + // focuses the panel, which activates it while the spawn is still in + // flight. The gate keeps the spawn in flight for as long as the test + // wants, instead of racing a real shell. + panel.update_in(&mut cx, |panel, window, cx| { + panel.test_terminal_spawn_gate = Some(spawn_gate); + panel.new_terminal(None, AgentThreadSource::AgentPanel, window, cx); + assert!( + panel.pending_terminal_spawn.is_some(), + "a new terminal spawn should be marked pending until it lands" + ); + panel.set_active(true, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + assert!( + panel.terminals(cx).is_empty(), + "no terminal should land while the spawn is held" + ); + assert!( + panel.pending_terminal_spawn.is_some(), + "the in-flight spawn should still be pending after activation" + ); + }); + + terminal_landed + .send(()) + .expect("the panel should still be waiting on the terminal spawn"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!( + terminals.len(), + 1, + "activation while a new terminal is spawning should not create a second terminal" + ); + assert_eq!( + panel.active_terminal_id(), + terminals.first().map(|terminal| terminal.id), + "the terminal that was spawned should be the active one" + ); + assert!( + panel.pending_terminal_spawn.is_none(), + "the pending marker should be cleared once the terminal lands" + ); + }); + } + #[gpui::test] async fn test_restored_terminal_runs_init_command_once(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; @@ -9806,6 +9900,131 @@ mod tests { }); } + #[gpui::test] + async fn test_rename_selected_thread_action_opens_and_focuses_terminal_title_editor( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + panel.update_in(&mut cx, |panel, window, cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + assert!(panel.is_title_editor_focused(window, cx)); + }); + } + + #[gpui::test] + async fn test_rename_selected_thread_action_does_not_edit_terminal_from_agent_thread( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + }); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert!(matches!( + panel.visible_surface(), + VisibleSurface::AgentThread(_) + )); + assert!( + panel + .terminals + .get(&terminal_id) + .is_some_and(|terminal| terminal.title_editor.is_none()) + ); + }); + } + + #[gpui::test] + async fn test_rename_selected_thread_action_reuses_terminal_title_editor( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + let first_editor_id = panel.read_with(&cx, |panel, _cx| { + panel + .terminals + .get(&terminal_id) + .and_then(|terminal| terminal.title_editor.as_ref()) + .expect("terminal title editor should be active") + .entity_id() + }); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + panel.read_with(&cx, |panel, _cx| { + let editor = panel + .terminals + .get(&terminal_id) + .and_then(|terminal| terminal.title_editor.as_ref()) + .expect("terminal title editor should remain active"); + assert_eq!(editor.entity_id(), first_editor_id); + }); + } + + #[gpui::test] + async fn test_rename_selected_thread_action_edits_only_active_terminal( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let first_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("first test terminal should be inserted"); + let second_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("second test terminal should be inserted"); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(second_terminal_id)); + assert!( + panel + .terminals + .get(&first_terminal_id) + .is_some_and(|terminal| terminal.title_editor.is_none()) + ); + assert!( + panel + .terminals + .get(&second_terminal_id) + .is_some_and(|terminal| terminal.title_editor.is_some()) + ); + }); + } + #[gpui::test] async fn test_restored_terminal_uses_metadata_title_until_shell_title_arrives( cx: &mut TestAppContext, diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 72fb346391..5cdf6c7e37 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -444,6 +444,27 @@ impl AgentRegistryPage { }) }); + let license_button = agent.license_url().map(|license_url| { + let license_url = license_url.clone(); + let license_url_for_click = license_url.clone(); + IconButton::new( + SharedString::from(format!("agent-license-{}", agent.id())), + IconName::FileTextOutlined, + ) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "View Agent License or Terms of Service", + None, + license_url.clone(), + cx, + ) + }) + .on_click(move |_, _, cx| { + cx.open_url(&license_url_for_click); + }) + }); + AgentRegistryCard::new() .child( h_flex() @@ -483,7 +504,8 @@ impl AgentRegistryPage { .truncate(), ) .when_some(repository_button, |this, button| this.child(button)) - .when_some(website_button, |this, button| this.child(button)), + .when_some(website_button, |this, button| this.child(button)) + .when_some(license_button, |this, button| this.child(button)), ), ) } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 1570315f88..c9e823bd26 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -865,15 +865,12 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("multi_workspace"); } - // Hide `agent: manage skills` — skills are surfaced through the - // settings UI now. Applied after the disable-ai / agent-enabled - // branches so it overrides the `show_namespace("assistant")` call - // above without affecting the rest of that namespace's actions. + // Skills are surfaced through the settings UI now, so this command + // should never appear in the palette. + filter.hide_action_types(&manage_skills_action); if !disable_ai { - filter.hide_action_types(&manage_skills_action); filter.show_action_types(skill_creator_actions.iter()); } else { - filter.show_action_types(manage_skills_action.iter()); filter.hide_action_types(&skill_creator_actions); } }); @@ -995,6 +992,7 @@ mod tests { profiles: Default::default(), notify_when_agent_waiting: NotifyWhenAgentWaiting::default(), play_sound_when_agent_done: PlaySoundWhenAgentDone::Never, + prevent_idle_sleep: true, single_file_review: false, model_parameters: vec![], auto_compact: agent_settings::AutoCompactSettings { @@ -1012,7 +1010,11 @@ mod tests { sandbox_permissions: Default::default(), show_turn_stats: false, show_merge_conflict_indicator: true, - sidebar_side: Default::default(), + threads_sidebar: agent_settings::ThreadsSidebarSettings { + auto_open: true, + default_width: px(300.), + position: settings::SidebarDockPosition::Left, + }, thinking_display: Default::default(), }; @@ -1051,6 +1053,10 @@ mod tests { !filter.is_hidden(&zed_actions::assistant::OpenProjectAgentsMdRules), "OpenProjectAgentsMdRules should be visible by default" ); + assert!( + filter.is_hidden(&zed_actions::assistant::ManageSkills), + "ManageSkills should be hidden even when AI is enabled" + ); }); // Disable agent @@ -1128,6 +1134,29 @@ mod tests { "EditPrediction should be hidden when provider is None" ); }); + + // Disable AI entirely + cx.update(|cx| { + AgentSettings::override_global(agent_settings.clone(), cx); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + update_command_palette_filter(cx); + }); + + cx.update(|cx| { + let filter = CommandPaletteFilter::try_global(cx).unwrap(); + assert!( + filter.is_hidden(&zed_actions::assistant::ManageSkills), + "ManageSkills should be hidden when AI is disabled" + ); + assert!( + filter.is_hidden(&zed_actions::assistant::OpenSkillCreator), + "OpenSkillCreator should be hidden when AI is disabled" + ); + assert!( + filter.is_hidden(&NewThread), + "NewThread should be hidden when AI is disabled" + ); + }); } async fn setup_backfill_test(cx: &mut TestAppContext) -> Arc { diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 020e0bc432..38798cadd9 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -497,6 +497,7 @@ impl CodegenAlternative { .context("generating content prompt")?; let temperature = AgentSettings::temperature_for_model(model, cx); + let session_id = self.session_id.to_string(); let tool_choice = model .supports_tool_choice(LanguageModelToolChoice::Any) @@ -544,7 +545,8 @@ impl CodegenAlternative { ]; LanguageModelRequest { - thread_id: None, + thread_id: Some(session_id), + prompt_cache_key: None, prompt_id: None, intent: Some(CompletionIntent::InlineAssist), tools, @@ -556,6 +558,7 @@ impl CodegenAlternative { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, } })) } @@ -609,6 +612,7 @@ impl CodegenAlternative { .context("generating content prompt")?; let temperature = AgentSettings::temperature_for_model(model, cx); + let session_id = self.session_id.to_string(); Ok(cx.spawn(async move |_cx| { let mut request_message = LanguageModelRequestMessage { @@ -625,7 +629,8 @@ impl CodegenAlternative { request_message.content.push(prompt.into()); LanguageModelRequest { - thread_id: None, + thread_id: Some(session_id), + prompt_cache_key: None, prompt_id: None, intent: Some(CompletionIntent::InlineAssist), tools: Vec::new(), @@ -637,6 +642,7 @@ impl CodegenAlternative { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, } })) } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index cc33d45f23..efe36015d7 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -34,7 +34,9 @@ use gpui::{ linear_gradient, list, pulsating_between, }; use language::{Buffer, Language, Rope}; -use language_model::{LanguageModelCompletionError, ProviderErrorCategory}; +use language_model::{ + LanguageModelCompletionError, ProviderErrorCategory, ZED_CLOUD_PROVIDER_NAME, +}; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle, }; @@ -122,7 +124,7 @@ enum ThreadFeedback { #[derive(Debug)] pub(crate) enum ThreadError { - PaymentRequired, + ZedPaymentRequired, DataRetentionConsentRequired, Refusal, AuthenticationRequired(SharedString), @@ -186,7 +188,11 @@ impl From for ThreadError { provider: provider.to_string().into(), }, ProviderErrorCategory::PromptTooLarge { .. } => Self::PromptTooLarge, - ProviderErrorCategory::PaymentRequired => Self::PaymentRequired, + ProviderErrorCategory::PaymentRequired + if provider == &ZED_CLOUD_PROVIDER_NAME => + { + Self::ZedPaymentRequired + } ProviderErrorCategory::Authentication => Self::AuthenticationFailed { provider: provider.to_string().into(), }, @@ -199,6 +205,7 @@ impl From for ThreadError { }, ProviderErrorCategory::InvalidEncryptedContent | ProviderErrorCategory::ContentPolicy + | ProviderErrorCategory::PaymentRequired | ProviderErrorCategory::InvalidRequest | ProviderErrorCategory::Conflict | ProviderErrorCategory::Timeout @@ -3750,6 +3757,56 @@ pub(crate) mod tests { )); } + #[test] + fn test_payment_required_preserves_non_zed_provider_message() { + for provider in [ + language_model::LanguageModelProviderName::new("OpenRouter"), + language_model::OPEN_AI_PROVIDER_NAME, + language_model::ANTHROPIC_PROVIDER_NAME, + ] { + for status in [None, Some(http_client::StatusCode::PAYMENT_REQUIRED)] { + let provider_error = LanguageModelCompletionError::from_provider_response( + provider.clone(), + status, + Some("402".to_string()), + "Insufficient credits. Add credits to your account.".to_string(), + None, + ProviderErrorCategory::PaymentRequired, + ); + + let error = ThreadError::from(anyhow!(provider_error)); + + assert!( + matches!( + &error, + ThreadError::ProviderRejection { message } + if message == "Insufficient credits. Add credits to your account." + ), + "expected provider billing message for {provider}, got: {error:?}" + ); + } + } + } + + #[test] + fn test_payment_required_from_zed_uses_upgrade_prompt() { + let provider_error = LanguageModelCompletionError::from_provider_response( + ZED_CLOUD_PROVIDER_NAME, + Some(http_client::StatusCode::PAYMENT_REQUIRED), + None, + "Payment required".to_string(), + None, + ProviderErrorCategory::PaymentRequired, + ); + + let error = ThreadError::from(anyhow!(provider_error)); + + assert!( + matches!(error, ThreadError::ZedPaymentRequired), + "expected Zed upgrade prompt, got: {error:?}" + ); + } + #[gpui::test] async fn test_drop(cx: &mut TestAppContext) { init_test(cx); @@ -4323,10 +4380,12 @@ pub(crate) mod tests { "Conversation should transition to LoadError when an ACP thread exits" ); }); + + release_dropped_entities(cx); assert_eq!( close_session_count.load(std::sync::atomic::Ordering::SeqCst), 1, - "ConversationView should close the ACP session after a thread exit" + "dropping the thread views after a thread exit should close the ACP session" ); } @@ -5621,6 +5680,31 @@ pub(crate) mod tests { setup_conversation_view_with_initial_content_opt(agent, None, cx).await } + fn png_image() -> acp::ContentBlock { + acp::ContentBlock::Image(acp::ImageContent::new( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "image/png", + )) + } + + async fn setup_full_size_conversation_and_send<'a>( + connection: StubAgentConnection, + prompt: &str, + cx: &'a mut TestAppContext, + ) -> (Entity, &'a mut VisualTestContext) { + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace_with_size(conversation_view.clone(), true, cx); + let thread = + active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone()); + thread + .update(cx, |thread, cx| thread.send_raw(prompt, cx)) + .await + .expect("prompt should succeed"); + cx.run_until_parked(); + (conversation_view, cx) + } + #[gpui::test] async fn test_completed_plan_snapshot_keeps_list_state_in_sync(cx: &mut TestAppContext) { init_test(cx); @@ -5732,13 +5816,24 @@ pub(crate) mod tests { } fn add_to_workspace(conversation_view: Entity, cx: &mut VisualTestContext) { + add_to_workspace_with_size(conversation_view, false, cx); + } + + fn add_to_workspace_with_size( + conversation_view: Entity, + fill_space: bool, + cx: &mut VisualTestContext, + ) { let workspace = conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone()); workspace .update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane( - Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))), + Box::new(cx.new(|_| ThreadViewItem { + conversation_view: conversation_view.clone(), + fill_space, + })), None, true, window, @@ -5748,7 +5843,10 @@ pub(crate) mod tests { .unwrap(); } - struct ThreadViewItem(Entity); + struct ThreadViewItem { + conversation_view: Entity, + fill_space: bool, + } impl Item for ThreadViewItem { type Event = (); @@ -5766,7 +5864,7 @@ pub(crate) mod tests { impl Focusable for ThreadViewItem { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.0.read(cx).focus_handle(cx) + self.conversation_view.read(cx).focus_handle(cx) } } @@ -5775,12 +5873,15 @@ pub(crate) mod tests { // Render the title editor in the element tree too. In the real app // it is part of the agent panel let title_editor = self - .0 + .conversation_view .read(cx) .active_thread() .map(|t| t.read(cx).title_editor.clone()); - v_flex().children(title_editor).child(self.0.clone()) + v_flex() + .when(self.fill_space, |this| this.size_full()) + .children(title_editor) + .child(self.conversation_view.clone()) } } @@ -6671,6 +6772,11 @@ pub(crate) mod tests { cx.read(|cx| thread.read(cx).message_editor.clone()) } + fn release_dropped_entities(cx: &mut VisualTestContext) { + cx.update(|_, _| ()); + cx.run_until_parked(); + } + #[gpui::test] async fn test_rewind_views(cx: &mut TestAppContext) { init_test(cx); @@ -6901,9 +7007,9 @@ pub(crate) mod tests { // parent's action log through the linked-log mechanism. connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( acp::ToolCall::new("spawn1", "Subagent task") + .name("spawn_agent") .kind(acp::ToolKind::Other) - .status(acp::ToolCallStatus::Completed) - .meta(acp_thread::meta_with_tool_name("spawn_agent")), + .status(acp::ToolCallStatus::Completed), )]); thread @@ -7201,6 +7307,17 @@ pub(crate) mod tests { acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( "Hidden papaya reasoning.".into(), )), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + "A second hidden papaya.", + "thought://details", + ) + .mime_type("text/markdown".to_string()), + ), + )), + )), acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( "Final answer without that fruit.".into(), )), @@ -7267,11 +7384,297 @@ pub(crate) mod tests { assert_eq!( bar.read_with(cx, |bar, _| bar.match_count()), - 1, - "expanded thinking content should be searchable", + 2, + "every Markdown leaf in expanded thinking content should be searchable", ); } + #[gpui::test] + async fn test_empty_assistant_text_followed_by_image_is_rendered(cx: &mut TestAppContext) { + init_test(cx); + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![ + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("".into())), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(png_image())), + ]); + let (_conversation_view, cx) = + setup_full_size_conversation_and_send(connection, "Show an image", cx).await; + + let image_bounds = cx + .debug_bounds("agent-output-image") + .expect("image-only output must not be hidden as a blank assistant message"); + assert!( + image_bounds.size.width > px(0.) && image_bounds.size.height > px(0.), + "decoded image must occupy nonzero rendered bounds, got {image_bounds:?}", + ); + } + + #[gpui::test] + async fn test_mixed_assistant_content_search_and_copy_preserve_order(cx: &mut TestAppContext) { + init_test(cx); + + let image = png_image(); + let resource = acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + "Resource needle follows the image.", + "memory://ordered", + ) + .mime_type("text/markdown".to_string()), + ), + )); + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![ + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "Private needle must not be copied.".into(), + )), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Before needle.".into())), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(image.clone())), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("After needle.".into())), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(image)), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(resource)), + ]); + + let (conversation_view, cx) = + setup_full_size_conversation_and_send(connection, "Show mixed content", cx).await; + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + let first_image_bounds = cx + .debug_bounds("message-content-1-1-1") + .expect("first mixed-content image should be rendered"); + let second_image_bounds = cx + .debug_bounds("message-content-1-1-3") + .expect("second mixed-content image should be rendered"); + for image_bounds in [first_image_bounds, second_image_bounds] { + assert!( + image_bounds.size.width > px(0.) && image_bounds.size.height > px(0.), + "each decoded image must occupy nonzero rendered bounds, got {image_bounds:?}", + ); + } + assert!( + first_image_bounds.origin.y < second_image_bounds.origin.y, + "images must remain visible in content order: first {first_image_bounds:?}, second {second_image_bounds:?}", + ); + + let markdowns = thread.read_with(cx, |thread, _| { + let message = thread + .entries() + .iter() + .find_map(|entry| match entry { + AgentThreadEntry::AssistantMessage(message) => Some(message), + _ => None, + }) + .expect("assistant message should exist"); + message + .chunks + .iter() + .flat_map(|chunk| match chunk { + AssistantMessageChunk::Message { block, .. } + | AssistantMessageChunk::Thought { block, .. } => block.markdowns(), + }) + .cloned() + .collect::>() + }); + + let first_text_bounds = cx + .debug_bounds("message-content-1-1-0") + .expect("first text content"); + cx.simulate_mouse_down( + first_text_bounds.center(), + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + first_text_bounds.center(), + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + let copy_response = cx + .debug_bounds("MENU_ITEM-Copy This Agent Response") + .expect("Copy This Agent Response"); + cx.simulate_click(copy_response.center(), gpui::Modifiers::default()); + let copied = cx + .update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())) + .expect("assistant response should be copied"); + assert_eq!( + copied, + "Before needle.\n\n`Image`\n\nAfter needle.\n\n`Image`\n\nResource needle follows the image." + ); + assert!(!copied.contains("Private needle")); + + thread_view.update_in(cx, |view, window, cx| { + view.toggle_search(&crate::ToggleSearch, window, cx); + }); + let search_bar = thread_view + .read_with(cx, |view, _| view.thread_search_bar.clone()) + .expect("thread search should be open"); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.query_editor.update(cx, |editor, cx| { + editor.set_text("needle", window, cx); + }); + search_bar.update_matches(window, cx); + }); + cx.run_until_parked(); + + assert_eq!(search_bar.read_with(cx, |bar, _| bar.match_count()), 3); + for (source, should_be_highlighted) in [ + ("Private needle must not be copied.", false), + ("Before needle.", true), + ("After needle.", true), + ("Resource needle follows the image.", true), + ] { + let markdown = markdowns + .iter() + .find(|markdown| markdown.read_with(cx, |markdown, _| markdown.source() == source)) + .unwrap_or_else(|| panic!("Markdown entity for {source:?} should exist")); + assert_eq!( + markdown.read_with(cx, |markdown, _| !markdown.search_highlights().is_empty()), + should_be_highlighted, + "unexpected search highlights for {source:?}", + ); + } + } + + #[gpui::test] + async fn test_message_context_menu_uses_clicked_content(cx: &mut TestAppContext) { + init_test(cx); + let acp::ContentBlock::Image(image) = png_image() else { + panic!("expected image fixture"); + }; + let linked_image = format!( + "[![second](data:{};base64,{})](https://two.example/)", + image.mime_type, image.data, + ); + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![ + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "[first](https://one.example/)".into(), + )), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(png_image())), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(linked_image.into())), + ]); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace_with_size(conversation_view.clone(), true, cx); + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + thread + .update(cx, |thread, cx| thread.send_raw("Show links", cx)) + .await + .expect("link prompt"); + cx.run_until_parked(); + + for (selector, offset, expected) in [ + ( + "message-content-1-0-0", + point(px(12.), px(8.)), + "https://one.example/", + ), + ( + "message-content-1-0-2", + point(px(0.5), px(0.5)), + "https://two.example/", + ), + ] { + let bounds = cx.debug_bounds(selector).expect("content leaf"); + let position = bounds.origin + offset; + cx.simulate_mouse_down( + position, + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + position, + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + let copy = cx.debug_bounds("MENU_ITEM-Copy Link").expect("Copy Link"); + cx.simulate_click(copy.center(), gpui::Modifiers::default()); + let copied = cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())); + assert_eq!(copied.as_deref(), Some(expected)); + } + + let image = cx + .debug_bounds("message-content-1-0-1") + .expect("image leaf"); + cx.simulate_mouse_down( + image.center(), + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + image.center(), + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + assert!(cx.debug_bounds("MENU_ITEM-Copy Link").is_none()); + assert!( + cx.debug_bounds("MENU_ITEM-Copy This Agent Response") + .is_some() + ); + } + + #[gpui::test] + async fn test_message_context_menu_copies_selected_content(cx: &mut TestAppContext) { + init_test(cx); + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![ + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("**first**".into())), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(png_image())), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("*second*".into())), + ]); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace_with_size(conversation_view.clone(), true, cx); + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + thread + .update(cx, |thread, cx| thread.send_raw("Show text", cx)) + .await + .expect("text prompt"); + cx.run_until_parked(); + + for (selector, plain, markdown) in [ + ("message-content-1-0-0", "first", "**first**"), + ("message-content-1-0-2", "second", "*second*"), + ] { + for (menu_item, expected) in [ + ("MENU_ITEM-Copy", plain), + ("MENU_ITEM-Copy as Markdown", markdown), + ] { + let bounds = cx.debug_bounds(selector).expect("content leaf"); + let position = bounds.origin + point(px(12.), bounds.size.height / 2.); + cx.simulate_event(gpui::MouseDownEvent { + position, + button: gpui::MouseButton::Left, + modifiers: gpui::Modifiers::default(), + click_count: 2, + first_mouse: false, + }); + cx.simulate_mouse_up( + position, + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_down( + position, + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + position, + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + let copy = cx.debug_bounds(menu_item).expect("selection copy command"); + cx.simulate_click(copy.center(), gpui::Modifiers::default()); + let copied = + cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())); + assert_eq!(copied.as_deref(), Some(expected)); + } + } + } + #[gpui::test] async fn test_thread_search_includes_expanded_tool_call_content(cx: &mut TestAppContext) { init_test(cx); @@ -7333,6 +7736,57 @@ pub(crate) mod tests { 1, "expanded tool-call content should be searchable", ); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "search-tool-content", + acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new( + "Replacement contains mango.", + )), + )), + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + "Resource mango output.", + "tool://replacement", + ) + .mime_type("text/markdown".to_string()), + ), + )), + )), + ]), + )), + cx, + ) + }) + .expect("tool output replacement should apply"); + cx.run_until_parked(); + cx.executor() + .advance_clock(super::thread_search_bar::SEARCH_UPDATE_DEBOUNCE * 2); + cx.run_until_parked(); + assert_eq!( + bar.read_with(cx, |bar, _| bar.match_count()), + 0, + "replaced tool output should no longer match the old query", + ); + + bar.update_in(cx, |bar, window, cx| { + bar.query_editor.update(cx, |editor, cx| { + editor.set_text("mango", window, cx); + }); + bar.update_matches(window, cx); + }); + cx.run_until_parked(); + assert_eq!( + bar.read_with(cx, |bar, _| bar.match_count()), + 2, + "all Markdown leaves in replacement tool output should be searchable", + ); } #[gpui::test] @@ -7568,7 +8022,7 @@ pub(crate) mod tests { AgentThreadEntry::AssistantMessage(message) => { message.chunks.iter().find_map(|chunk| match chunk { AssistantMessageChunk::Message { block, .. } => { - block.markdown().cloned() + block.markdowns().next().cloned() } AssistantMessageChunk::Thought { .. } => None, }) @@ -7617,6 +8071,169 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_thread_search_highlights_expanded_compaction_details(cx: &mut TestAppContext) { + init_test(cx); + + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + thread_view.update_in(cx, |view, window, cx| { + view.toggle_search(&crate::ToggleSearch, window, cx); + }); + let search_bar = thread_view + .read_with(cx, |view, _| view.thread_search_bar.clone()) + .expect("thread search bar should be open"); + let resource = + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("retained resource details", "summary://context") + .mime_type("text/markdown".to_string()), + )); + + for (update, query) in [ + ( + acp::CompactionUpdate::new("failed", acp::CompactionStatus::Failed) + .error("model *still* unavailable
"), + "model *still* unavailable
", + ), + ( + acp::CompactionUpdate::new("completed", acp::CompactionStatus::Completed).summary( + vec![ + acp::ContentBlock::Text(acp::TextContent::new("Retained summary")), + acp::ContentBlock::Resource(resource), + ], + ), + "retained resource details", + ), + ] { + thread + .update(cx, |thread, cx| { + thread.handle_session_update(acp::SessionUpdate::CompactionUpdate(update), cx) + }) + .expect("failed to receive compaction details"); + cx.run_until_parked(); + + let (entry_index, markdown) = thread.read_with(cx, |thread, cx| { + let (entry_index, entry) = thread + .entries() + .iter() + .enumerate() + .next_back() + .expect("compaction entry should exist"); + let AgentThreadEntry::ContextCompaction(compaction) = entry else { + panic!("expected a compaction entry"); + }; + let markdown = compaction + .summary + .iter() + .filter_map(|content| content.markdown()) + .chain(compaction.error.iter()) + .find(|markdown| markdown.read(cx).source().contains(query)) + .expect("compaction should retain searchable details") + .clone(); + (entry_index, markdown) + }); + thread_view.update_in(cx, |view, window, cx| { + view.toggle_compaction_expansion(entry_index, window, cx); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.query_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + search_bar.update_matches(window, cx); + }); + cx.run_until_parked(); + + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.match_count(), 1); + assert_eq!(search_bar.active_match_index(), Some(0)); + }); + assert!( + markdown.read_with(cx, |markdown, _| !markdown.search_highlights().is_empty()), + "the visible compaction details should be highlighted", + ); + + thread_view.update_in(cx, |view, window, cx| { + view.toggle_compaction_expansion(entry_index, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + search_bar.read_with(cx, |search_bar, _| search_bar.match_count()), + 0 + ); + assert!(markdown.read_with(cx, |markdown, _| markdown.search_highlights().is_empty())); + } + } + + #[gpui::test] + async fn test_thread_search_tracks_tool_name_fallback(cx: &mut TestAppContext) { + init_test(cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + thread_view.update_in(cx, |view, window, cx| { + view.toggle_search(&crate::ToggleSearch, window, cx); + }); + let search_bar = thread_view + .read_with(cx, |view, _| view.thread_search_bar.clone()) + .expect("thread search should be open"); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.query_editor.update(cx, |editor, cx| { + editor.set_text("mcp__search_tool", window, cx); + }); + search_bar.update_matches(window, cx); + }); + cx.run_until_parked(); + + for (update, expected_count) in [ + ( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool", "") + .name("mcp__search_tool") + .status(acp::ToolCallStatus::Completed), + ), + 1, + ), + ( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "tool", + acp::ToolCallUpdateFields::new().title("Reading **file**"), + )), + 0, + ), + ( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "tool", + acp::ToolCallUpdateFields::new().title("\n\t "), + )), + 1, + ), + ] { + thread + .update(cx, |thread, cx| thread.handle_session_update(update, cx)) + .expect("tool update should apply"); + cx.run_until_parked(); + cx.executor() + .advance_clock(super::thread_search_bar::SEARCH_UPDATE_DEBOUNCE * 2); + cx.run_until_parked(); + assert_eq!( + search_bar.read_with(cx, |search_bar, _| search_bar.match_count()), + expected_count + ); + thread.read_with(cx, |thread, cx| { + let (_, call) = thread + .tool_call(&acp::ToolCallId::new("tool")) + .expect("tool call should exist"); + assert_eq!( + !call.label.read(cx).search_highlights().is_empty(), + expected_count > 0 + ); + }); + } + } + #[gpui::test] async fn test_thread_search_refreshes_on_new_thread_entry(cx: &mut TestAppContext) { init_test(cx); @@ -8908,6 +9525,231 @@ pub(crate) mod tests { }) } + #[gpui::test] + async fn test_display_terminal_does_not_move_to_background_when_tool_completes( + cx: &mut TestAppContext, + ) { + init_test(cx); + for (exit_status, failure_selector) in [ + ( + acp::TerminalExitStatus::new().exit_code(7), + "terminal-tool-failed-Some(7)", + ), + ( + acp::TerminalExitStatus::new().signal("SIGTERM"), + "terminal-tool-failed-None", + ), + ( + acp::TerminalExitStatus::new().exit_code(u32::MAX), + "terminal-tool-failed-Some(4294967295)", + ), + ] { + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; + add_to_workspace_with_size(conversation_view.clone(), true, cx); + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + let terminal_id = acp::TerminalId::new("provider-terminal"); + let tool_id = acp::ToolCallId::new("provider-tool"); + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + Default::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + util::paths::PathStyle::local(), + ) + .subscribe(cx) + }); + let display_terminal = terminal.clone(); + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + acp_thread::TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "provider command".into(), + cwd: None, + output_byte_limit: None, + terminal, + }, + cx, + ); + thread + .handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(tool_id.clone(), "provider command") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + terminal_id.clone(), + ))]), + ), + cx, + ) + .expect("terminal tool call"); + }); + cx.run_until_parked(); + let entry_state = thread_view.read_with(cx, |view, _| view.entry_view_state.clone()); + entry_state.update(cx, |state, cx| { + state.expand_tool_call(tool_id.clone()); + cx.notify(); + }); + let acp_terminal = thread.read_with(cx, |thread, _| { + thread + .terminal(terminal_id.clone()) + .expect("terminal tool call should contain a terminal") + }); + let terminal_view = entry_state.read_with(cx, |state, _| { + state + .entry(0) + .and_then(|entry| entry.terminal(&acp_terminal)) + .expect("terminal tool card should contain a terminal view") + }); + assert!(terminal_view.read_with(cx, |view, _| view.is_read_only())); + + display_terminal.update(cx, |terminal, _| { + assert!(terminal.take_input_log().is_empty()); + }); + terminal_view.update_in(cx, |view, window, cx| { + window.focus(&view.focus_handle(cx), cx); + }); + cx.simulate_keystrokes("a"); + assert!( + display_terminal + .update(cx, |terminal, _| terminal.take_input_log()) + .is_empty(), + "input dispatched to a display-only terminal view must not reach the lower terminal", + ); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + acp_thread::TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: b"provider output remains visible".to_vec(), + }, + cx, + ); + }); + cx.run_until_parked(); + assert!( + display_terminal + .read_with(cx, |terminal, _| terminal.get_content()) + .contains("provider output remains visible"), + "provider output should continue to render in a read-only terminal", + ); + assert!(cx.debug_bounds("ICON-Stop").is_none()); + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), + cx, + ) + .expect("tool completion"); + }); + cx.run_until_parked(); + assert!(entry_state.read_with(cx, |state, _| state.is_tool_call_expanded(&tool_id))); + assert!(cx.debug_bounds(failure_selector).is_none()); + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + acp_thread::TerminalProviderEvent::Exit { + terminal_id, + status: exit_status, + }, + cx, + ); + }); + cx.run_until_parked(); + assert!(entry_state.read_with(cx, |state, _| state.is_tool_call_expanded(&tool_id))); + assert!( + cx.debug_bounds(failure_selector).is_some(), + "expected rendered header metadata: {failure_selector}", + ); + assert!(cx.debug_bounds("ICON-Stop").is_none()); + } + } + + #[gpui::test] + async fn test_acp_owned_terminal_tool_card_is_interactive(cx: &mut TestAppContext) { + init_test(cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; + add_to_workspace_with_size(conversation_view.clone(), true, cx); + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + let terminal_id = acp::TerminalId::new("acp-owned-terminal"); + let tool_id = acp::ToolCallId::new("acp-owned-tool"); + let lower_terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + Default::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + util::paths::PathStyle::local(), + ) + .subscribe(cx) + }); + assert!(!lower_terminal.read_with(cx, |terminal, _| terminal.is_pty())); + + let acp_terminal = thread.update(cx, |thread, cx| { + let acp_terminal = thread.register_terminal_created( + terminal_id.clone(), + "client-owned command".into(), + None, + None, + lower_terminal.clone(), + cx, + ); + thread + .handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(tool_id.clone(), "client-owned command") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + terminal_id, + ))]), + ), + cx, + ) + .expect("terminal tool call"); + acp_terminal + }); + assert!( + acp_terminal.read_with(cx, |terminal, _| terminal.is_process_backed()), + "registration ownership, not the lower renderer's PTY, marks this terminal process-backed", + ); + + cx.run_until_parked(); + let entry_state = thread_view.read_with(cx, |view, _| view.entry_view_state.clone()); + entry_state.update(cx, |state, cx| { + state.expand_tool_call(tool_id); + cx.notify(); + }); + let terminal_view = entry_state.read_with(cx, |state, _| { + state + .entry(0) + .and_then(|entry| entry.terminal(&acp_terminal)) + .expect("terminal tool card should contain a terminal view") + }); + assert!(!terminal_view.read_with(cx, |view, _| view.is_read_only())); + + lower_terminal.update(cx, |terminal, _| { + assert!(terminal.take_input_log().is_empty()); + }); + terminal_view.update_in(cx, |view, window, cx| { + window.focus(&view.focus_handle(cx), cx); + }); + cx.simulate_keystrokes("a"); + assert_eq!( + lower_terminal.update(cx, |terminal, _| terminal.take_input_log()), + vec![b"a".to_vec()], + ); + } + #[gpui::test] async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs index 6260d46893..872eafbb4e 100644 --- a/crates/agent_ui/src/conversation_view/elicitation.rs +++ b/crates/agent_ui/src/conversation_view/elicitation.rs @@ -4,7 +4,7 @@ use collections::{HashMap, HashSet}; use component::{Component, ComponentScope, example_group_with_title, single_example}; use editor::Editor; use futures::channel::oneshot; -use gpui::{AnyElement, App, Div, Empty, Entity, Hsla, SharedString, Window, div}; +use gpui::{AnyElement, App, Div, Empty, Entity, Focusable, Hsla, SharedString, Window, div}; use std::collections::BTreeMap; use std::rc::Rc; use ui::{ @@ -363,7 +363,352 @@ impl ElicitationFormSubmission { #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{KeyUpEvent, Keystroke, PlatformInput, TestAppContext, VisualTestContext}; + use std::cell::RefCell; + + struct TestElicitationView { + elicitation: Elicitation, + form_state: ElicitationFormState, + events: Rc>>, + } + + impl TestElicitationView { + fn new(schema: acp::ElicitationSchema, window: &mut Window, cx: &mut App) -> Self { + Self { + form_state: ElicitationFormState::new(&schema, window, cx), + elicitation: Elicitation { + id: ElicitationEntryId("keyboard-test".into()), + request: acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new(preview_request_scope(0), schema), + "Choose an answer.", + ), + status: pending_status(), + }, + events: Rc::default(), + } + } + + fn editor(&self, field_name: &str) -> Entity { + match self.form_state.fields.get(field_name) { + Some(ElicitationFieldState::Text(editor)) => editor.clone(), + _ => panic!("expected a text field named {field_name}"), + } + } + } + + impl Render for TestElicitationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let response_handler = |response: &'static str| -> RespondHandler { + let events = self.events.clone(); + let expected_id = self.elicitation.id.clone(); + Rc::new(move |id, _, _| { + assert_eq!(id, expected_id); + events.borrow_mut().push(response.into()); + }) + }; + let handlers = ElicitationCardHandlers { + on_submit: response_handler("submit"), + on_decline: response_handler("decline"), + on_cancel: response_handler("cancel"), + on_dismiss_url: response_handler("dismiss"), + on_open_url: { + let events = self.events.clone(); + let expected_id = self.elicitation.id.clone(); + Rc::new(move |id, url, _, _| { + assert_eq!(id, expected_id); + events.borrow_mut().push(format!("open: {url}")); + }) + }, + on_boolean_change: { + let events = self.events.clone(); + Rc::new(move |_, name, value, _| { + events.borrow_mut().push(format!("{name}: {value}")); + }) + }, + on_single_select_change: { + let events = self.events.clone(); + Rc::new(move |_, name, value, _| { + events.borrow_mut().push(format!("{name}: {value}")); + }) + }, + on_multi_select_change: { + let events = self.events.clone(); + Rc::new(move |_, name, value, selected, _| { + events + .borrow_mut() + .push(format!("{name}: {value} = {selected}")); + }) + }, + }; + + div() + .key_context("AcpThread") + .on_action(|_: &crate::CycleModeSelector, _, _| { + panic!("form navigation must not cycle the agent mode"); + }) + .on_action(|_: &zed_actions::agent::Chat, _, _| { + panic!("form submission must not send a chat message"); + }) + .child( + ElicitationCard::new( + 0, + &self.elicitation, + "Claude Code".into(), + Some(&self.form_state), + handlers, + ) + .render(cx), + ) + } + } + + fn init_keyboard_test(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + cx.update(|cx| { + let bindings = settings::KeymapFile::load_asset_allow_partial_failure( + "keymaps/default-linux.json", + cx, + ) + .expect("default keymap should load"); + cx.bind_keys(bindings); + }); + } + + fn press_keys(cx: &mut VisualTestContext, keys: &str) { + for key in keys.split_whitespace() { + cx.simulate_keystrokes(key); + cx.update(|window, cx| { + // Click handlers activate buttons on key release, not key press. + window.dispatch_event( + PlatformInput::KeyUp(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("valid test keystroke"), + }), + cx, + ); + }); + cx.run_until_parked(); + } + } + + #[gpui::test] + fn form_text_fields_submit_on_enter(cx: &mut TestAppContext) { + init_keyboard_test(cx); + let (view, cx) = cx.add_window_view(|window, cx| { + TestElicitationView::new( + acp::ElicitationSchema::new() + .string("other", true) + .property("count", acp::IntegerPropertySchema::new(), true) + .property("amount", acp::NumberPropertySchema::new(), true), + window, + cx, + ) + }); + let events = view.read_with(cx, |view, _| view.events.clone()); + + for (field_name, value) in [ + ("other", " My answer "), + ("count", "2"), + ("amount", "1.5"), + ] { + let editor = view.read_with(cx, |view, _| view.editor(field_name)); + cx.update(|window, cx| window.focus(&editor.focus_handle(cx), cx)); + cx.simulate_input(value); + press_keys(cx, "enter"); + assert_eq!(editor.read_with(cx, |editor, cx| editor.text(cx)), value); + } + assert_eq!(*events.borrow(), ["submit", "submit", "submit"]); + + view.update(cx, |view, cx| { + assert!(view.form_state.begin_submission(cx).is_some()); + cx.notify(); + }); + press_keys(cx, "enter"); + assert_eq!(events.borrow().len(), 3); + } + + #[gpui::test] + fn form_tab_navigation_reaches_fields_and_actions(cx: &mut TestAppContext) { + init_keyboard_test(cx); + let (view, cx) = cx.add_window_view(|window, cx| { + TestElicitationView::new( + acp::ElicitationSchema::new() + .string("first", false) + .string("other", false), + window, + cx, + ) + }); + let (first, other, events) = view.read_with(cx, |view, _| { + ( + view.editor("first"), + view.editor("other"), + view.events.clone(), + ) + }); + cx.update(|window, cx| window.focus(&first.focus_handle(cx), cx)); + + press_keys(cx, "tab"); + cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window))); + press_keys(cx, "shift-tab"); + cx.update(|window, cx| assert!(first.focus_handle(cx).is_focused(window))); + + press_keys(cx, "tab tab"); + let submit_focus = + cx.update(|window, cx| window.focused(cx).expect("Submit should be focused")); + press_keys(cx, "shift-tab"); + cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window))); + press_keys(cx, "tab"); + cx.update(|window, _| assert!(submit_focus.is_focused(window))); + press_keys(cx, "enter tab enter tab space"); + assert_eq!(*events.borrow(), ["submit", "decline", "cancel"]); + + press_keys(cx, "shift-tab space"); + assert_eq!(*events.borrow(), ["submit", "decline", "cancel", "decline"]); + let decline_focus = + cx.update(|window, cx| window.focused(cx).expect("Decline should be focused")); + + cx.update(|window, cx| window.focus(&submit_focus, cx)); + view.update(cx, |view, cx| { + assert!(view.form_state.begin_submission(cx).is_some()); + cx.notify(); + }); + press_keys(cx, "tab"); + cx.update(|window, _| assert!(decline_focus.is_focused(window))); + press_keys(cx, "shift-tab"); + cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window))); + press_keys(cx, "tab"); + cx.update(|window, _| assert!(decline_focus.is_focused(window))); + press_keys(cx, "shift-tab"); + cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window))); + + view.update(cx, |view, cx| { + view.form_state.set_errors(HashMap::default()); + cx.notify(); + }); + press_keys(cx, "tab"); + cx.update(|window, _| assert!(submit_focus.is_focused(window))); + } + + #[gpui::test] + fn form_choices_are_keyboard_accessible(cx: &mut TestAppContext) { + init_keyboard_test(cx); + let (view, cx) = cx.add_window_view(|window, cx| { + TestElicitationView::new( + acp::ElicitationSchema::new() + .string("answer", false) + .property("boolean", acp::BooleanPropertySchema::new(), false) + .property( + "choice", + acp::StringPropertySchema::new() + .enum_values(vec!["first".into(), "second".into()]), + false, + ) + .property( + "multiple", + acp::MultiSelectPropertySchema::new(vec!["first".into(), "second".into()]), + false, + ), + window, + cx, + ) + }); + let (editor, events) = + view.read_with(cx, |view, _| (view.editor("answer"), view.events.clone())); + cx.update(|window, cx| window.focus(&editor.focus_handle(cx), cx)); + press_keys( + cx, + "tab space tab enter tab space tab space tab enter tab enter", + ); + assert_eq!( + *events.borrow(), + [ + "boolean: true", + "choice: first", + "choice: second", + "multiple: first = true", + "multiple: second = true", + "submit", + ] + ); + } + + #[gpui::test] + fn tab_navigation_between_form_and_url_cards(cx: &mut TestAppContext) { + struct Cards { + form: Entity, + url: Entity, + } + + impl Render for Cards { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + v_flex().child(self.form.clone()).child(self.url.clone()) + } + } + + init_keyboard_test(cx); + let (cards, cx) = cx.add_window_view(|window, cx| Cards { + form: cx.new(|cx| { + TestElicitationView::new( + acp::ElicitationSchema::new().string("other", false), + window, + cx, + ) + }), + url: cx.new(|cx| { + let mut view = TestElicitationView::new(acp::ElicitationSchema::new(), window, cx); + view.elicitation.id = ElicitationEntryId("url-test".into()); + view.elicitation.request = acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + preview_request_scope(1), + acp::ElicitationId::new("url-test"), + "https://example.com/authorize", + ), + "Authorize access.", + ); + view + }), + }); + let (form, url) = cards.read_with(cx, |cards, _| (cards.form.clone(), cards.url.clone())); + let (editor, form_events) = + form.read_with(cx, |form, _| (form.editor("other"), form.events.clone())); + let url_events = url.read_with(cx, |url, _| url.events.clone()); + cx.update(|window, cx| window.focus(&editor.focus_handle(cx), cx)); + + press_keys(cx, "tab tab tab enter"); + assert_eq!(*form_events.borrow(), ["cancel"]); + press_keys(cx, "tab enter"); + assert_eq!( + *url_events.borrow(), + ["open: https://example.com/authorize", "submit"] + ); + + press_keys(cx, "shift-tab enter"); + assert_eq!(*form_events.borrow(), ["cancel", "cancel"]); + press_keys(cx, "shift-tab shift-tab enter"); + assert_eq!(*form_events.borrow(), ["cancel", "cancel", "submit"]); + press_keys(cx, "shift-tab"); + cx.update(|window, cx| assert!(editor.focus_handle(cx).is_focused(window))); + + url.update(cx, |url, cx| { + url.elicitation.status = ElicitationStatus::Accepted; + cx.notify(); + }); + press_keys(cx, "tab tab tab tab enter tab enter"); + assert_eq!( + *url_events.borrow(), + [ + "open: https://example.com/authorize", + "submit", + "open: https://example.com/authorize", + "dismiss", + ] + ); + assert_eq!(*form_events.borrow(), ["cancel", "cancel", "submit"]); + } #[test] fn string_validation_rejects_email_format_mismatch() { @@ -1489,6 +1834,10 @@ impl<'a> ElicitationCard<'a> { }; v_flex() + .key_context("Elicitation") + .tab_group() + .on_action(|_: &menu::SelectNext, window, cx| window.focus_next(cx)) + .on_action(|_: &menu::SelectPrevious, window, cx| window.focus_prev(cx)) .mx_5() .my_1p5() .rounded_md() @@ -1576,6 +1925,7 @@ impl<'a> ElicitationCard<'a> { let label = property_title(field_name, property); let description = property_description(property); let border_color = cx.theme().colors().border.opacity(0.8); + let focused_border_color = cx.theme().colors().border_focused; let field_border_color = if error.is_some() { Color::Error.color(cx) } else { @@ -1606,10 +1956,12 @@ impl<'a> ElicitationCard<'a> { .child( h_flex() .id(row_id) + .tab_index(0) .w_full() .items_start() .gap_1() .cursor_pointer() + .focus_visible(|this| this.bg(cx.theme().colors().element_hover)) .on_click(move |_, _window, cx| { on_boolean_change( elicitation_id.clone(), @@ -1655,16 +2007,29 @@ impl<'a> ElicitationCard<'a> { ) }) .child(match field { - ElicitationFieldState::Text(editor) => div() - .rounded_sm() - .border_1() - .border_color(field_border_color) - .bg(editor_background) - .px_1() - .py_0p5() - .text_xs() - .child(editor.clone().into_any_element()) - .into_any_element(), + ElicitationFieldState::Text(editor) => { + let on_submit = self.handlers.on_submit.clone(); + let elicitation_id = self.elicitation.id.clone(); + let is_submitting = self.form_state.is_some_and(|state| state.is_submitting); + + div() + .track_focus(&editor.focus_handle(cx).tab_stop(true)) + .on_action(move |_: &menu::Confirm, window, cx| { + if !is_submitting { + on_submit(elicitation_id.clone(), window, cx); + } + }) + .rounded_sm() + .border_1() + .border_color(field_border_color) + .focus_visible(|this| this.border_color(focused_border_color)) + .bg(editor_background) + .px_1() + .py_0p5() + .text_xs() + .child(editor.clone().into_any_element()) + .into_any_element() + } ElicitationFieldState::Boolean(_) => Empty.into_any_element(), ElicitationFieldState::SingleSelect { value } => { let options = match property { @@ -1714,6 +2079,7 @@ impl<'a> ElicitationCard<'a> { "elicitation-multi-option-{}-{field_name}-{}", self.entry_ix, option.value ))) + .tab_index(0) .w_full() .min_h(rems_from_px(28_f32)) .items_start() @@ -1725,6 +2091,7 @@ impl<'a> ElicitationCard<'a> { .px_2() .py_1() .hover(move |this| this.bg(hover_background).cursor_pointer()) + .focus_visible(|this| this.border_color(focused_border_color)) .on_click(move |_, _window, cx| { on_multi_select_change( elicitation_id.clone(), @@ -1781,6 +2148,7 @@ impl<'a> ElicitationCard<'a> { h_flex() .id(option_id) + .tab_index(0) .w_full() .min_h(rems_from_px(28_f32)) .items_start() @@ -1792,6 +2160,7 @@ impl<'a> ElicitationCard<'a> { .px_2() .py_1() .hover(move |this| this.bg(hover_background).cursor_pointer()) + .focus_visible(|this| this.border_color(cx.theme().colors().border_focused)) .on_click(move |_, _window, cx| { on_single_select_change( elicitation_id.clone(), @@ -1823,7 +2192,7 @@ impl<'a> ElicitationCard<'a> { .min_w_0() .flex_1() .gap_0p5() - .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .child(Label::new(option.label).size(LabelSize::Small)) .when_some(option.description, |this, description| { this.child( Label::new(description) @@ -1976,6 +2345,7 @@ impl<'a> ElicitationCard<'a> { .border_color(border_color) .child( Button::new(("elicitation-accept", self.entry_ix), accept_label) + .tab_index(0_isize) .start_icon( Icon::new(accept_icon) .size(IconSize::XSmall) @@ -1997,6 +2367,7 @@ impl<'a> ElicitationCard<'a> { .when(!is_accepted_url, |this| { this.child( Button::new(("elicitation-decline", self.entry_ix), "Decline") + .tab_index(0_isize) .start_icon( Icon::new(IconName::Close) .size(IconSize::XSmall) @@ -2009,6 +2380,7 @@ impl<'a> ElicitationCard<'a> { ) .child( Button::new(("elicitation-cancel", self.entry_ix), "Cancel") + .tab_index(0_isize) .label_size(LabelSize::Small) .on_click(move |_, window, cx| { on_cancel(cancel_id.clone(), window, cx); @@ -2018,6 +2390,7 @@ impl<'a> ElicitationCard<'a> { .when(is_accepted_url, |this| { this.child( Button::new(("elicitation-dismiss-url", self.entry_ix), "Cancel") + .tab_index(0_isize) .label_size(LabelSize::Small) .on_click(move |_, window, cx| { on_dismiss_url(dismiss_id.clone(), window, cx); diff --git a/crates/agent_ui/src/conversation_view/thread_search_bar.rs b/crates/agent_ui/src/conversation_view/thread_search_bar.rs index 643cceda36..d0ac1e6bf4 100644 --- a/crates/agent_ui/src/conversation_view/thread_search_bar.rs +++ b/crates/agent_ui/src/conversation_view/thread_search_bar.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use std::time::Duration; use acp_thread::{ - AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessageChunk, ContentBlock, - ToolCallContent, + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessageChunk, ToolCallContent, }; use collections::HashMap; use editor::{ @@ -892,18 +891,14 @@ fn collect_markdowns( for (chunk_ix, chunk) in message.chunks.iter().enumerate() { match chunk { AssistantMessageChunk::Message { block, .. } => { - if let Some(md) = block.markdown() { - out.push(md.clone()); - } + out.extend(block.markdowns().cloned()); } AssistantMessageChunk::Thought { block, .. } if entry_view_state .thinking_block_state((entry_ix, chunk_ix), cx) .0 => { - if let Some(md) = block.markdown() { - out.push(md.clone()); - } + out.extend(block.markdowns().cloned()); } AssistantMessageChunk::Thought { .. } => {} } @@ -917,21 +912,8 @@ fn collect_markdowns( .content .iter() .filter_map(|content| match content { - ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => { - Some(markdown.clone()) - } - ToolCallContent::ContentBlock(ContentBlock::EmbeddedResource { - markdown: Some(markdown), - .. - }) => Some(markdown.clone()), - ToolCallContent::ContentBlock( - ContentBlock::Empty - | ContentBlock::EmbeddedResource { markdown: None, .. } - | ContentBlock::ResourceLink { .. } - | ContentBlock::Image { .. }, - ) - | ToolCallContent::Diff(_) - | ToolCallContent::Terminal(_) => None, + ToolCallContent::ContentBlock(content) => content.markdown().cloned(), + ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => None, }), ); } @@ -939,15 +921,62 @@ fn collect_markdowns( AgentThreadEntry::CompletedPlan(entries) => { out.extend(entries.iter().map(|e| e.content.clone())) } - AgentThreadEntry::ContextCompaction(compaction) - if entry_view_state.is_compaction_expanded(entry_ix) => - { - if let Some(summary) = &compaction.summary { - out.push(summary.clone()); - } - } + AgentThreadEntry::ContextCompaction(compaction) => out.extend(compaction_markdowns( + compaction, + entry_view_state.is_compaction_expanded(entry_ix), + )), AgentThreadEntry::Elicitation(_) => {} - AgentThreadEntry::ContextCompaction(_) => {} } out } + +fn compaction_markdowns( + compaction: &acp_thread::ContextCompaction, + is_expanded: bool, +) -> impl Iterator> + '_ { + compaction + .summary + .iter() + .filter_map(|content| content.markdown().cloned()) + .chain(compaction.error.iter().cloned()) + .filter(move |_| is_expanded) +} + +#[cfg(test)] +mod tests { + use super::*; + use acp_thread::{ + ContentBlock, ContextCompaction, ContextCompactionId, ContextCompactionStatus, + }; + use agent_client_protocol::schema::v1 as acp; + + #[gpui::test] + fn test_compaction_markdowns_include_summary_and_error(cx: &mut App) { + let summary = cx.new(|cx| Markdown::new("summary match".into(), None, None, cx)); + let unsupported = + cx.new(|cx| Markdown::new("unsupported content match".into(), None, None, cx)); + let error = cx.new(|cx| Markdown::new("error match".into(), None, None, cx)); + let compaction = ContextCompaction { + id: ContextCompactionId("compaction".into()), + status: ContextCompactionStatus::Failed, + summary: vec![ + ContentBlock::Markdown { + markdown: summary.clone(), + }, + ContentBlock::Unsupported { + content: acp::ContentBlock::Audio(acp::AudioContent::new( + "YXVkaW8=", + "audio/wav", + )), + markdown: unsupported.clone(), + }, + ], + error: Some(error.clone()), + }; + + assert!(compaction_markdowns(&compaction, false).next().is_none()); + + let markdowns = compaction_markdowns(&compaction, true).collect::>(); + assert_eq!(markdowns, vec![summary, unsupported, error]); + } +} diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index dfff3050a3..dea51ba96c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1864,7 +1864,7 @@ impl ThreadView { fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context) { let (error_kind, acp_error_code, message): (&str, Option, SharedString) = match error { - ThreadError::PaymentRequired => ( + ThreadError::ZedPaymentRequired => ( "payment_required", None, "You reached your free usage limit. Upgrade to Zed Pro for more prompts." @@ -3945,17 +3945,22 @@ impl ThreadView { cx: &Context, ) -> AnyElement { let is_compacting = compaction.is_in_progress(); - let summary = compaction.summary.clone(); + let summary = &compaction.summary; + let error = compaction.error.clone(); + let has_details = !summary.is_empty() || error.is_some(); let is_expanded = self .entry_view_state .read(cx) .is_compaction_expanded(entry_ix); + let details = (is_expanded && has_details).then_some((summary, error)); let id = format!("context-compaction-{entry_ix}"); - let header_label = match compaction.status { + let header_label = match &compaction.status { acp_thread::ContextCompactionStatus::InProgress => "Compacting Context…", acp_thread::ContextCompactionStatus::Completed => "Context Compacted", + acp_thread::ContextCompactionStatus::Failed => "Compaction Failed", acp_thread::ContextCompactionStatus::Canceled => "Compaction Canceled", + acp_thread::ContextCompactionStatus::Other(_) => "Context Compaction", }; let chevron_end = if is_expanded { IconName::ChevronUp @@ -3970,13 +3975,13 @@ impl ThreadView { Button::new(id, header_label) .label_size(LabelSize::Small) .loading(is_compacting) - .disabled(is_compacting) + .disabled(!has_details) .start_icon( Icon::new(IconName::Compact) .size(IconSize::XSmall) .color(Color::Muted), ) - .when(!is_compacting, |this| { + .when(has_details, |this| { this.end_icon( Icon::new(chevron_end) .size(IconSize::XSmall) @@ -4003,20 +4008,37 @@ impl ThreadView { .border_color(gpui::transparent_black()) .rounded_sm() .child(header) - .when_some(summary.filter(|_| is_expanded), |this, summary| { + .when_some(details, |this, (summary, error)| { this.border_color(self.tool_card_border_color(cx)) .bg(cx.theme().colors().editor_background.opacity(0.2)) - .child( - div() - .id(("compaction-summary", entry_ix)) - .p_2() - .text_ui(cx) - .child(self.render_markdown( - summary, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - cx, - )), - ) + .when(!summary.is_empty(), |this| { + this.child( + v_flex() + .id(("compaction-summary", entry_ix)) + .p_2() + .gap_2() + .text_ui(cx) + .children(summary.iter().enumerate().map( + |(content_ix, content)| { + self.render_output_content_block( + entry_ix, content_ix, content, None, true, + window, cx, + ) + }, + )), + ) + }) + .when_some(error, |this, error| { + let mut style = + MarkdownStyle::themed(MarkdownFont::Agent, window, cx); + style.base_text_style.color = Color::Error.color(cx); + this.child( + div() + .id(("compaction-error", entry_ix)) + .p_2() + .child(self.render_markdown(error, style, cx)), + ) + }) .child( h_flex() .border_t_1() @@ -4048,7 +4070,7 @@ impl ThreadView { .into_any() } - fn toggle_compaction_expansion( + pub(super) fn toggle_compaction_expansion( &mut self, entry_ix: usize, window: &mut Window, @@ -4740,13 +4762,20 @@ impl ThreadView { let workspace = self.workspace.clone(); - let max_output_tokens = self + let (max_input_tokens, max_output_tokens) = self .as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .and_then(|model| model.max_output_tokens()) - .unwrap_or(0); - let input_max_label = - crate::humanize_token_count(usage.max_tokens.saturating_sub(max_output_tokens)); + .map(|thread| { + let thread = thread.read(cx); + ( + thread.input_token_capacity().unwrap_or(usage.max_tokens), + thread + .model() + .and_then(|model| model.max_output_tokens()) + .unwrap_or(0), + ) + }) + .unwrap_or((usage.max_tokens, 0)); + let input_max_label = crate::humanize_token_count(max_input_tokens); let output_max_label = crate::humanize_token_count(max_output_tokens); let build_tooltip = { @@ -4782,7 +4811,7 @@ impl ThreadView { }; if show_split { - let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens); + let input_max_raw = max_input_tokens; let output_max_raw = max_output_tokens; let input_ratio = if input_max_raw > 0 { @@ -6324,43 +6353,31 @@ impl ThreadView { let mut is_blank = true; let is_last = entry_ix + 1 == total_entries; - let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); let message_body = v_flex() .w_full() .gap_3() .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { AssistantMessageChunk::Message { block, .. } => { - block.markdown().and_then(|md| { - let this_is_blank = md.read(cx).source().trim().is_empty(); - is_blank = is_blank && this_is_blank; - if this_is_blank { - return None; - } - - Some( - self.render_markdown(md.clone(), style.clone(), cx) - .into_any_element(), - ) + let this_is_blank = !block.visible_content(cx); + is_blank = is_blank && this_is_blank; + (!this_is_blank).then(|| { + div() + .id(("assistant-message-chunk", chunk_ix)) + .child(self.render_message_content( + entry_ix, chunk_ix, block, window, cx, + )) + .into_any_element() }) } AssistantMessageChunk::Thought { block, .. } => { - block.markdown().and_then(|md| { - let this_is_blank = md.read(cx).source().trim().is_empty(); - is_blank = is_blank && this_is_blank; - if this_is_blank { - return None; - } - Some( - self.render_thinking_block( - entry_ix, - chunk_ix, - md.clone(), - window, - cx, - ) - .into_any_element(), + let this_is_blank = !block.visible_content(cx); + is_blank = is_blank && this_is_blank; + (!this_is_blank).then(|| { + self.render_thinking_block( + entry_ix, chunk_ix, block, window, cx, ) + .into_any_element() }) } }, @@ -6378,7 +6395,7 @@ impl ThreadView { .when(is_last, |this| this.pb_4()) .w_full() .text_ui(cx) - .child(self.render_message_context_menu(entry_ix, message_body, cx)) + .child(message_body) .when_some( self.entry_view_state .read(cx) @@ -7429,11 +7446,44 @@ impl ThreadView { cx.notify(); } + fn render_message_content( + &self, + entry_ix: usize, + chunk_ix: usize, + content: &acp_thread::MessageContent, + window: &Window, + cx: &Context, + ) -> Div { + v_flex().w_full().gap_3().children( + content + .blocks() + .iter() + .enumerate() + .filter(|(_, block)| block.visible_content(cx)) + .map(|(block_ix, block)| { + let content = self.render_output_content_block( + entry_ix, block_ix, block, None, false, window, cx, + ); + div() + .id(("message-content-block", block_ix)) + .debug_selector(move || { + format!("message-content-{entry_ix}-{chunk_ix}-{block_ix}") + }) + .child(self.render_message_context_menu( + entry_ix, + block.markdown().cloned(), + content, + cx, + )) + }), + ) + } + fn render_thinking_block( &self, entry_ix: usize, chunk_ix: usize, - chunk: Entity, + chunk: &acp_thread::MessageContent, window: &Window, cx: &Context, ) -> AnyElement { @@ -7458,6 +7508,7 @@ impl ThreadView { let panel_bg = cx.theme().colors().panel_background; v_flex() + .id(("thinking-block", chunk_ix)) .gap_1() .child( h_flex() @@ -7495,7 +7546,10 @@ impl ThreadView { ) .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { this.toggle_thinking_block_expansion(key, window, cx); - })), + })) + .map(|header| { + self.render_message_context_menu(entry_ix, None, header.into_any(), cx) + }), ) .when(is_open, |this| { this.child( @@ -7513,11 +7567,11 @@ impl ThreadView { this.track_scroll(&scroll_handle) }) .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - cx, - )), + .child( + self.render_message_content( + entry_ix, chunk_ix, chunk, window, cx, + ), + ), ) .when(is_constrained, |this| { this.child( @@ -7541,6 +7595,7 @@ impl ThreadView { fn render_message_context_menu( &self, entry_ix: usize, + markdown: Option>, message_body: AnyElement, cx: &Context, ) -> AnyElement { @@ -7553,44 +7608,18 @@ impl ThreadView { let focus = window.focused(cx); let entity = entity.clone(); let workspace = workspace.clone(); + let markdown = markdown.clone(); ContextMenu::build(window, cx, move |menu, _, cx| { let this = entity.read(cx); let is_at_top = this.list_state.logical_scroll_top().item_ix == 0; - - let chunks = - this.thread.read(cx).entries().get(entry_ix).and_then( - |entry| match &entry { - AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), - _ => None, - }, - ); - - let has_selection = chunks - .map(|chunks| { - chunks.iter().any(|chunk| { - let md = match chunk { - AssistantMessageChunk::Message { block, .. } => { - block.markdown() - } - AssistantMessageChunk::Thought { block, .. } => { - block.markdown() - } - }; - md.map_or(false, |m| m.read(cx).has_selection()) - }) - }) - .unwrap_or(false); - - let context_menu_link = chunks.and_then(|chunks| { - chunks.iter().find_map(|chunk| { - let md = match chunk { - AssistantMessageChunk::Message { block, .. } => block.markdown(), - AssistantMessageChunk::Thought { block, .. } => block.markdown(), - }; - md.and_then(|m| m.read(cx).context_menu_link().cloned()) - }) - }); + let markdown = markdown.as_ref().map(|markdown| markdown.read(cx)); + let context_menu_link = + markdown.and_then(|markdown| markdown.context_menu_link().cloned()); + let selected_text = markdown + .and_then(|markdown| markdown.context_menu_selected_text().cloned()); + let selected_markdown = markdown + .and_then(|markdown| markdown.context_menu_selected_markdown().cloned()); let copy_this_agent_response = ContextMenuEntry::new("Copy This Agent Response").handler({ @@ -7649,11 +7678,24 @@ impl ThreadView { }) .separator() }) - .action_disabled_when( - !has_selection, - "Copy Selection", - Box::new(markdown::CopyAsMarkdown), - ) + .when_some(selected_text, |menu, selected_text| { + menu.entry("Copy", Some(Box::new(markdown::Copy)), move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + selected_text.to_string(), + )); + }) + }) + .when_some(selected_markdown, |menu, selected_markdown| { + menu.entry( + "Copy as Markdown", + Some(Box::new(markdown::CopyAsMarkdown)), + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + selected_markdown.to_string(), + )); + }, + ) + }) .item(copy_this_agent_response) .separator() .item(scroll_item) @@ -7697,7 +7739,7 @@ impl ThreadView { if markdown.trim().is_empty() { None } else { - Some(markdown.to_string()) + Some(markdown) } } AssistantMessageChunk::Thought { .. } => None, @@ -7854,7 +7896,13 @@ impl ThreadView { let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); let command_failed = command_finished - && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success())); + && output.is_some_and(|output| { + output + .exit_status + .exit_code + .is_some_and(|exit_code| exit_code != 0) + || output.exit_status.signal.is_some() + }); let time_elapsed = if let Some(output) = output { output.ended_at.duration_since(started_at) @@ -7925,26 +7973,24 @@ impl ThreadView { cx.notify(); } })) - .on_stop({ - let terminal = terminal.clone(); - cx.listener(move |this, _event, _window, cx| { - terminal.update(cx, |terminal, cx| { - terminal.stop_by_user(cx); - }); - if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { - this.cancel_generation(cx); - } + .when(terminal_data.is_process_backed(), |header| { + header.on_stop({ + let terminal = terminal.clone(); + cx.listener(move |this, _event, _window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.stop_by_user(cx); + }); + if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { + this.cancel_generation(cx); + } + }) }) }) .when_some(truncated_tooltip, |header, tooltip| { header.truncated(tooltip) }) .when(tool_failed || command_failed, |header| { - header.failed( - output - .and_then(|o| o.exit_status) - .map(|status| status.code().unwrap_or(-1)), - ) + header.failed(output.and_then(|output| output.exit_status.exit_code)) }) .when_some(tool_call.sandbox_not_applied.as_ref(), |header, reason| { header.sandbox_warning(self.sandbox_not_applied_warning(reason, cx)) @@ -7975,7 +8021,7 @@ impl ThreadView { .border_t_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) - .bg(cx.theme().colors().editor_background) + .bg(cx.theme().colors().terminal_background) .rounded_b_md() .text_ui_sm(cx) .h_full() @@ -9956,6 +10002,19 @@ impl ThreadView { .into_any_element() } + fn tool_call_icon_tooltip( + tool_name: Option<&SharedString>, + interrupted_edit: bool, + ) -> Option { + let tool_name = tool_name.filter(|name| !name.trim().is_empty()); + match (tool_name, interrupted_edit) { + (Some(name), true) => Some(format!("Interrupted Edit\nTool: {name}").into()), + (Some(name), false) => Some(format!("Tool: {name}").into()), + (None, true) => Some("Interrupted Edit".into()), + (None, false) => None, + } + } + fn render_tool_call_label( &self, entry_ix: usize, @@ -9979,10 +10038,9 @@ impl ThreadView { Icon::new(IconName::ToolPencil).color(Color::Muted) }; - let tool_icon = if is_file && has_failed && has_revealed_diff { + let interrupted_edit = is_file && has_failed && has_revealed_diff; + let tool_icon = if interrupted_edit { div() - .id(entry_ix) - .tooltip(Tooltip::text("Interrupted Edit")) .child(DecoratedIcon::new( file_icon, Some( @@ -10064,7 +10122,19 @@ impl ThreadView { .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) }) .overflow_hidden() - .child(tool_icon) + .child( + div() + .id(("tool-call-icon", entry_ix)) + .flex_none() + .when_some( + Self::tool_call_icon_tooltip( + tool_call.tool_name.as_ref(), + interrupted_edit, + ), + |this, tooltip| this.tooltip(Tooltip::text(tooltip)), + ) + .child(tool_icon), + ) .child(if has_location { h_flex() .id(("open-tool-call-location", entry_ix)) @@ -10188,37 +10258,15 @@ impl ThreadView { cx: &Context, ) -> AnyElement { match content { - ToolCallContent::ContentBlock(content) => { - if let Some((resource, markdown)) = content.embedded_resource() { - self.render_embedded_resource_output( - resource, - markdown.cloned(), - entry_ix, - context_ix, - tool_call, - card_layout, - window, - cx, - ) - } else if let Some(resource_link) = content.resource_link() { - self.render_resource_link(resource_link, cx) - } else if let Some(markdown) = content.markdown() { - self.render_markdown_output( - markdown.clone(), - entry_ix, - context_ix, - tool_call, - card_layout, - window, - cx, - ) - } else if let Some((image, _)) = content.image() { - let location = tool_call.locations.first().cloned(); - self.render_image_output(entry_ix, image.clone(), location, card_layout, cx) - } else { - Empty.into_any_element() - } - } + ToolCallContent::ContentBlock(content) => self.render_output_content_block( + entry_ix, + context_ix, + content, + Some(tool_call), + card_layout, + window, + cx, + ), ToolCallContent::Diff(diff) => { self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx) } @@ -10235,35 +10283,58 @@ impl ThreadView { } } - fn render_embedded_resource_output( + fn render_output_content_block( &self, - resource: &acp::EmbeddedResource, - markdown: Option>, entry_ix: usize, context_ix: usize, - tool_call: &ToolCall, + content: &acp_thread::ContentBlock, + tool_call: Option<&ToolCall>, card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { - if let Some(markdown) = markdown { - return self.render_markdown_output( - markdown, - entry_ix, - context_ix, - tool_call, - card_layout, - window, - cx, - ); + if let Some(markdown) = content.markdown() { + if let Some(tool_call) = tool_call { + self.render_markdown_output( + markdown.clone(), + entry_ix, + context_ix, + tool_call, + card_layout, + window, + cx, + ) + } else { + self.render_markdown( + markdown.clone(), + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + cx, + ) + .into_any() + } + } else if let Some((resource, _)) = content.embedded_resource() { + if tool_call.is_some() { + self.render_embedded_resource_output(resource, context_ix, card_layout, cx) + } else { + self.render_embedded_resource_label(resource) + } + } else if let Some(resource_link) = content.resource_link() { + self.render_resource_link(resource_link, cx) + } else if let Some((image, _)) = content.image() { + let location = tool_call.and_then(|tool_call| tool_call.locations.first().cloned()); + self.render_image_output(entry_ix, image.clone(), location, card_layout, cx) + } else { + Empty.into_any_element() } + } - let uri = match &resource.resource { - acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(), - acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(), - _ => "", - }; - + fn render_embedded_resource_output( + &self, + resource: &acp::EmbeddedResource, + context_ix: usize, + card_layout: bool, + cx: &Context, + ) -> AnyElement { v_flex() .gap_1() .map(|this| { @@ -10279,16 +10350,26 @@ impl ThreadView { .border_color(self.tool_card_border_color(cx)) } }) - .when(!uri.is_empty(), |this| { - this.child( - Label::new(uri.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }) + .child(self.render_embedded_resource_label(resource)) .into_any_element() } + fn render_embedded_resource_label(&self, resource: &acp::EmbeddedResource) -> AnyElement { + let uri = match &resource.resource { + acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(), + acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(), + _ => "", + }; + if uri.is_empty() { + Empty.into_any_element() + } else { + Label::new(uri.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .into_any_element() + } + } + fn render_resource_link( &self, resource_link: &acp::ResourceLink, @@ -10487,6 +10568,7 @@ impl ThreadView { cx: &Context, ) -> AnyElement { v_flex() + .debug_selector(|| "agent-output-image".into()) .gap_2() .map(|this| { if card_layout { @@ -11030,7 +11112,7 @@ impl ThreadView { ThreadError::AuthenticationRequired(error) => { self.render_authentication_required_error(error.clone(), cx) } - ThreadError::PaymentRequired => self.render_payment_required_error(cx), + ThreadError::ZedPaymentRequired => self.render_zed_payment_required_error(cx), ThreadError::RateLimitExceeded { provider } => self.render_error_callout( "Rate Limit Reached", format!( @@ -11163,7 +11245,7 @@ impl ThreadView { .dismiss_action(self.dismiss_error_button(cx)) } - fn render_payment_required_error(&self, cx: &mut Context) -> Callout { + fn render_zed_payment_required_error(&self, cx: &mut Context) -> Callout { const ERROR_MESSAGE: &str = "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; @@ -11674,7 +11756,7 @@ impl ThreadView { .gap_1() .child( Label::new(format!( - "Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} bytes; longer ones may consume more model-context tokens." + "Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} characters; longer ones may consume more model-context tokens." )) .size(LabelSize::Small) .color(Color::Muted), @@ -12672,6 +12754,33 @@ mod tests { use util::path; use workspace::MultiWorkspace; + #[test] + fn test_tool_call_icon_tooltip() { + for (name, interrupted_edit, expected) in [ + (None, false, None), + (Some(" \t\n"), false, None), + (Some("read_file"), false, Some("Tool: read_file")), + ( + Some(" mcp__server__**read_file** "), + false, + Some("Tool: mcp__server__**read_file** "), + ), + (None, true, Some("Interrupted Edit")), + (Some(" \t\n"), true, Some("Interrupted Edit")), + ( + Some("edit_file"), + true, + Some("Interrupted Edit\nTool: edit_file"), + ), + ] { + let name = name.map(SharedString::from); + assert_eq!( + ThreadView::tool_call_icon_tooltip(name.as_ref(), interrupted_edit).as_deref(), + expected, + ); + } + } + fn native_command(name: &str) -> acp::AvailableCommand { acp::AvailableCommand::new(name, "").meta(acp_thread::meta_with_command_category( acp_thread::CommandCategory::Native, diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 1553879628..7f9a9e61d9 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -323,7 +323,11 @@ impl EntryViewState { entry.insert(element); } collections::hash_map::Entry::Occupied(_entry) => { - if is_tool_call_completed && terminal.read(cx).output().is_none() { + let terminal = terminal.read(cx); + if is_tool_call_completed + && terminal.is_process_backed() + && terminal.output().is_none() + { cx.emit(EntryViewEvent { entry_index: index, view_event: ViewEvent::TerminalMovedToBackground(id.clone()), @@ -639,6 +643,7 @@ fn create_terminal( cx: &mut App, ) -> Entity { cx.new(|cx| { + let read_only = !terminal.read(cx).is_process_backed(); let mut view = TerminalView::new( terminal.read(cx).inner().clone(), workspace, @@ -646,7 +651,8 @@ fn create_terminal( project, window, cx, - ); + ) + .with_read_only(read_only); view.set_embedded_mode(Some(1000), cx); view }) diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index f84d291027..c7c9e7fb8e 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -244,6 +244,7 @@ impl TerminalInlineAssistant { )?; let temperature = AgentSettings::temperature_for_model(&model, cx); + let session_id = assist.codegen.read(cx).session_id().to_string(); let mention_set = prompt_editor.read(cx).mention_set().clone(); let load_context_task = load_context(&mention_set, cx); @@ -263,7 +264,8 @@ impl TerminalInlineAssistant { request_message.content.push(prompt.into()); LanguageModelRequest { - thread_id: None, + thread_id: Some(session_id), + prompt_cache_key: None, prompt_id: None, intent: Some(CompletionIntent::TerminalInlineAssist), messages: vec![request_message], @@ -275,6 +277,7 @@ impl TerminalInlineAssistant { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, } })) } diff --git a/crates/agent_ui/src/ui/terminal_tool_header.rs b/crates/agent_ui/src/ui/terminal_tool_header.rs index f22fce6797..7627c31c6c 100644 --- a/crates/agent_ui/src/ui/terminal_tool_header.rs +++ b/crates/agent_ui/src/ui/terminal_tool_header.rs @@ -24,7 +24,7 @@ pub struct TerminalToolHeader { running: bool, truncated_tooltip: Option, failed: bool, - exit_code: Option, + exit_code: Option, sandbox_warning: Option, on_toggle_expand: Option, on_stop: Option, @@ -70,7 +70,7 @@ impl TerminalToolHeader { self } - pub fn failed(mut self, exit_code: Option) -> Self { + pub fn failed(mut self, exit_code: Option) -> Self { self.failed = true; self.exit_code = exit_code; self @@ -178,22 +178,25 @@ impl RenderOnce for TerminalToolHeader { .color(Color::Muted) .with_rotate_animation(2), ) - .child(Divider::vertical().color(DividerColor::Border).ml_1()) - .child( - IconButton::new(child_id("stop"), IconName::Stop) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Stop This Command", - None, - "Also possible by placing your cursor inside the terminal \ - and using regular terminal bindings.", - cx, - ) - }) - .when_some(on_stop, |this, handler| this.on_click(handler)), - ) + .when_some(on_stop, |header, handler| { + header + .child(Divider::vertical().color(DividerColor::Border).ml_1()) + .child( + IconButton::new(child_id("stop"), IconName::Stop) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Stop This Command", + None, + "Also possible by placing your cursor inside the \ + terminal and using regular terminal bindings.", + cx, + ) + }) + .on_click(handler), + ) + }) }) .when_some(truncated_tooltip, |header, tooltip| { header.child( @@ -206,16 +209,18 @@ impl RenderOnce for TerminalToolHeader { ) }) .when(failed, |header| { - header.child( - IconButton::new(child_id("failed"), IconName::Close) - .cursor_style(CursorStyle::Arrow) - .style(ButtonStyle::Transparent) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .when_some(exit_code, |this, code| { - this.tooltip(Tooltip::text(format!("Exited with code {code}"))) - }), - ) + header + .debug_selector(|| format!("terminal-tool-failed-{exit_code:?}")) + .child( + IconButton::new(child_id("failed"), IconName::Close) + .cursor_style(CursorStyle::Arrow) + .style(ButtonStyle::Transparent) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .when_some(exit_code, |this, code| { + this.tooltip(Tooltip::text(format!("Exited with code {code}"))) + }), + ) }) .when_some(sandbox_warning, |header, warning| { let TerminalSandboxWarning { @@ -247,6 +252,48 @@ impl RenderOnce for TerminalToolHeader { } } +#[cfg(test)] +mod tests { + use super::*; + + #[gpui::test] + fn running_header_only_offers_stop_when_control_is_available(cx: &mut gpui::TestAppContext) { + struct Header { + stoppable: bool, + stops: usize, + } + impl Render for Header { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(400.)).child( + TerminalToolHeader::new("test", "test", "/project", true) + .running(true) + .when(self.stoppable, |header| { + header.on_stop(cx.listener(|this, _, _, _| this.stops += 1)) + }), + ) + } + } + + crate::test_support::init_test(cx); + let (header, cx) = cx.add_window_view(|_, _| Header { + stoppable: false, + stops: 0, + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("ICON-Stop").is_none()); + + header.update(cx, |header, cx| { + header.stoppable = true; + cx.notify(); + }); + let stop = cx + .debug_bounds("ICON-Stop") + .expect("owned process Stop control"); + cx.simulate_click(stop.center(), gpui::Modifiers::default()); + assert_eq!(header.read_with(cx, |header, _| header.stops), 1); + } +} + impl Component for TerminalToolHeader { fn scope() -> ComponentScope { ComponentScope::Agent diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml index 8215ea820b..0d537aaf81 100644 --- a/crates/ai_onboarding/Cargo.toml +++ b/crates/ai_onboarding/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/ai_onboarding.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index dce3264cc2..1f04600e67 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -167,13 +167,13 @@ impl ZedAiOnboarding { .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( - Label::new("Sign in to try Zed Pro free for 14 days.") + Label::new("Sign in to try GPT Luna. Your 14 days begin when you start the trial.") .color(Color::Muted) .mb_2(), ) .child(PlanDefinitions.sign_in_upsell()) .child( - Button::new("sign_in", "Try Zed Pro for Free") + Button::new("sign_in", "Sign In to Try GPT Luna") .disabled(signing_in) .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) @@ -300,7 +300,7 @@ impl ZedAiOnboarding { .child(Self::pro_trial_stamp(cx)) .child(Headline::new("Welcome to the Zed Pro Trial")) .child( - Label::new("Here's what you get for the next 14 days:") + Label::new("Included for 14 days from when your trial started:") .color(Color::Muted) .mb_2(), ) diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 674c74ebb8..58c47a582b 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -17,17 +17,17 @@ impl PlanDefinitions { pub fn sign_in_upsell(&self) -> impl IntoElement { List::new() .child(ListBulletItem::new("Unlimited edit predictions")) - .child(ListBulletItem::new("$20 of tokens in Zed agent")) + .child(ListBulletItem::new("$5 of GPT Luna")) .child(ListBulletItem::new("No credit card required")) } pub fn pro_trial(&self, period: bool) -> impl IntoElement { List::new() - .child(ListBulletItem::new("$20 of tokens in Zed agent")) + .child(ListBulletItem::new("$5 of GPT Luna")) .child(ListBulletItem::new("Unlimited edit predictions")) .when(period, |this| { this.child(ListBulletItem::new( - "Try it out for 14 days, no credit card required", + "14 days from trial start, no credit card required", )) }) } diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index afb5a2f2ca..ca41a0ecec 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [lib] path = "src/anthropic.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index e24f9fd927..ed1e761c89 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -423,6 +423,45 @@ pub async fn non_streaming_completion( } } +/// Estimates input tokens without generating a message. +/// +/// Anthropic's estimate may differ slightly from usage reported during generation. +pub async fn count_input_tokens( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: CountTokensRequest, + beta_headers: Option, + extra_headers: &CustomHeaders, +) -> Result { + let (mut response, rate_limits) = send_request_to_route( + client, + api_url, + "/v1/messages/count_tokens", + api_key, + request, + beta_headers, + extra_headers, + ) + .await?; + if !response.status().is_success() { + return Err(handle_error_response(response, rate_limits).await); + } + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + #[derive(Deserialize)] + struct CountTokensResponse { + input_tokens: u64, + } + serde_json::from_str::(&body) + .map(|response| response.input_tokens) + .map_err(AnthropicError::DeserializeResponse) +} + async fn send_request( client: &dyn HttpClient, api_url: &str, @@ -431,7 +470,28 @@ async fn send_request( beta_headers: Option, extra_headers: &CustomHeaders, ) -> Result<(http::Response, RateLimitInfo), AnthropicError> { - let uri = format!("{api_url}/v1/messages"); + send_request_to_route( + client, + api_url, + "/v1/messages", + api_key, + request, + beta_headers, + extra_headers, + ) + .await +} + +async fn send_request_to_route( + client: &dyn HttpClient, + api_url: &str, + route: &str, + api_key: &str, + request: impl Serialize, + beta_headers: Option, + extra_headers: &CustomHeaders, +) -> Result<(http::Response, RateLimitInfo), AnthropicError> { + let uri = format!("{api_url}{route}"); let mut request_builder = HttpRequest::builder() .method(Method::POST) @@ -949,7 +1009,36 @@ pub struct Request { pub top_p: Option, } +/// Input counted by Anthropic, excluding generation and service settings. +#[derive(Serialize, Debug)] +pub struct CountTokensRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_management: Option, +} + impl Request { + pub fn into_count_tokens_request(self) -> CountTokensRequest { + CountTokensRequest { + model: self.model, + messages: self.messages, + tools: self.tools, + tool_choice: self.tool_choice, + system: self.system, + thinking: self.thinking, + context_management: self.context_management, + } + } + /// Configures this request to stop after native compaction. /// /// Tool definitions remain in the request so the trigger observes the same @@ -1317,6 +1406,13 @@ fn completion_error_from_anthropic_api_with_status( ProviderErrorCategory::PromptTooLarge { tokens: Some(tokens), } + } else if error + .message + .starts_with("Your credit balance is too low to access the Anthropic API.") + { + // Anthropic sends credit exhaustion as invalid_request_error rather than + // the billing_error documented at https://platform.claude.com/docs/en/api/errors. + ProviderErrorCategory::PaymentRequired } else { ProviderErrorCategory::InvalidRequest } @@ -1350,6 +1446,115 @@ mod tests { use super::*; use http_client::FakeHttpClient; + #[test] + fn count_input_tokens_preserves_input_without_generation_fields() { + let request: Request = serde_json::from_value(serde_json::json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 1234, + "temperature": 0.5, + "system": "Be precise", + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "Describe this"}, + {"type": "image", "source": { + "type": "base64", "media_type": "image/png", "data": "aW1hZ2U=" + }} + ]}], + "tools": [{"name": "look", "description": "Look up data", "input_schema": {"type": "object"}}], + "tool_choice": {"type": "auto"}, + "context_management": {"edits": [{"type": "compact_20260112"}]} + })).expect("generation request"); + let generation = serde_json::to_value(&request).expect("generation payload"); + assert_eq!(generation["max_tokens"], 1234); + assert_eq!(generation["temperature"], 0.5); + let client = FakeHttpClient::create(move |mut request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!( + request.uri(), + "https://api.anthropic.com/v1/messages/count_tokens" + ); + assert!( + request + .headers() + .get("X-Api-Key") + .is_some_and(|value| value == "test-key") + ); + assert_eq!(request.headers()["Anthropic-Version"], "2023-06-01"); + assert_eq!(request.headers()["Anthropic-Beta"], "test-beta"); + let mut body = String::new(); + request.body_mut().read_to_string(&mut body).await?; + assert_eq!( + serde_json::from_str::(&body)?, + serde_json::json!({ + "model": "claude-sonnet-4-6", + "system": "Be precise", + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "Describe this"}, + {"type": "image", "source": { + "type": "base64", "media_type": "image/png", "data": "aW1hZ2U=" + }} + ]}], + "tools": [{"name": "look", "description": "Look up data", "input_schema": {"type": "object"}}], + "tool_choice": {"type": "auto"}, + "context_management": {"edits": [{"type": "compact_20260112"}]} + }) + ); + Ok(http::Response::builder() + .status(200) + .body(AsyncBody::from(r#"{"input_tokens":321}"#))?) + }); + let count = futures::executor::block_on(count_input_tokens( + client.as_ref(), + ANTHROPIC_API_URL, + " test-key ", + request.into_count_tokens_request(), + Some("test-beta".into()), + &CustomHeaders::default(), + )) + .expect("count succeeds"); + assert_eq!(count, 321); + } + + #[test] + fn count_input_tokens_preserves_typed_errors() { + for (status, body) in [ + ( + 401, + r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#, + ), + (200, "{}"), + ] { + let client = FakeHttpClient::create(move |_| async move { + Ok(http::Response::builder() + .status(status) + .body(AsyncBody::from(body))?) + }); + let request: Request = serde_json::from_value(serde_json::json!({ + "model": "claude-sonnet-4-6", "max_tokens": 100, "messages": [] + })) + .expect("request"); + let error = futures::executor::block_on(count_input_tokens( + client.as_ref(), + ANTHROPIC_API_URL, + "test-key", + request.into_count_tokens_request(), + None, + &CustomHeaders::default(), + )) + .expect_err("count fails"); + if status == 401 { + assert!(matches!( + error, + AnthropicError::ApiError { + status: Some(StatusCode::UNAUTHORIZED), + .. + } + )); + } else { + assert!(matches!(error, AnthropicError::DeserializeResponse(_))); + } + } + } + #[test] fn list_models_preserves_anthropic_api_errors() { let client = FakeHttpClient::create(|_| async move { diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index d2a601ad55..7fdfeb319e 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -290,6 +290,9 @@ pub fn into_anthropic( cache_mode: AnthropicPromptCacheMode, compaction_state_owner: &LanguageModelProviderId, ) -> Result { + let max_output_tokens = request + .max_output_tokens + .map_or(max_output_tokens, |limit| limit.min(max_output_tokens)); let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); let mut any_message_wants_cache = false; @@ -892,6 +895,7 @@ mod tests { }, ], thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -907,6 +911,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: Some(1024), }; let anthropic_request = into_anthropic( @@ -920,6 +925,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&anthropic_request).unwrap()["max_tokens"], + 1024 + ); // No message content block should carry cache_control anymore; the // conversation breakpoint is set via top-level automatic caching. assert_eq!(anthropic_request.messages.len(), 1); @@ -1000,6 +1009,7 @@ mod tests { }, ], thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -1015,6 +1025,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: Some(8192), }; let anthropic_request = into_anthropic( @@ -1028,6 +1039,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&anthropic_request).unwrap()["max_tokens"], + 4096 + ); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( anthropic_request.system, @@ -1065,6 +1080,7 @@ mod tests { reasoning_details: None, }], thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -1075,6 +1091,7 @@ mod tests { thinking_effort: Some("xhigh".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1116,6 +1133,7 @@ mod tests { reasoning_details: None, }], thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -1126,6 +1144,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1185,6 +1204,7 @@ mod tests { reasoning_details: None, }], thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -1195,6 +1215,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1246,6 +1267,7 @@ mod tests { }, ], thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -1261,6 +1283,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1274,6 +1297,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&anthropic_request).unwrap()["max_tokens"], + 4096 + ); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( anthropic_request.system, @@ -1292,6 +1319,7 @@ mod tests { }], thinking_effort: None, thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -1301,6 +1329,7 @@ mod tests { thinking_allowed: true, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; request.messages.push(LanguageModelRequestMessage { role: Role::Assistant, diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 8c0cabcdb6..4792fdbea7 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/askpass.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 475b6d457d..643bbacdc8 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -7,6 +7,8 @@ license = "GPL-3.0-or-later" [lib] path = "src/assets.rs" +test = false +doctest = false [lints] workspace = true diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 0d86651c00..38324d5348 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -3,8 +3,8 @@ use client::Client; use db::kvp::KeyValueStore; use futures_lite::StreamExt; use gpui::{ - App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, TaskExt, - Window, actions, + App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Global, + Task, TaskExt, Window, actions, }; use http_client::{HttpClient, HttpClientWithUrl}; use paths::remote_servers_dir; @@ -173,6 +173,11 @@ impl AutoUpdateStatus { } } +pub enum AutoUpdateEvent { + /// A manual check received a release response and found no newer version. + UpToDate, +} + pub struct AutoUpdater { status: AutoUpdateStatus, current_version: Version, @@ -419,6 +424,8 @@ impl UpdateCheckType { } } +impl EventEmitter for AutoUpdater {} + impl AutoUpdater { pub fn get(cx: &mut App) -> Option> { cx.default_global::().0.clone() @@ -763,11 +770,15 @@ impl AutoUpdater { let Some(newer_version) = newer_version else { this.update(cx, |this, cx| { - let status = match previous_status { + this.status = match previous_status { AutoUpdateStatus::Updated { .. } => previous_status, - _ => AutoUpdateStatus::Idle, + _ => { + if this.update_check_type.is_manual() { + cx.emit(AutoUpdateEvent::UpToDate); + } + AutoUpdateStatus::Idle + } }; - this.status = status; cx.notify(); }); return Ok(()); diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml index a7d54d4cb9..f15ead3df2 100644 --- a/crates/auto_update_ui/Cargo.toml +++ b/crates/auto_update_ui/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/auto_update_ui.rs" +test = false +doctest = false [dependencies] agent_skills.workspace = true diff --git a/crates/aws_http_client/Cargo.toml b/crates/aws_http_client/Cargo.toml index 24569a764d..fc9d7fc593 100644 --- a/crates/aws_http_client/Cargo.toml +++ b/crates/aws_http_client/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/aws_http_client.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/bedrock/Cargo.toml b/crates/bedrock/Cargo.toml index 17f69cde6b..e9def2d251 100644 --- a/crates/bedrock/Cargo.toml +++ b/crates/bedrock/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/bedrock.rs" +doctest = false [features] default = [] diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml index 2415ebc396..63fa36275a 100644 --- a/crates/benchmarks/Cargo.toml +++ b/crates/benchmarks/Cargo.toml @@ -8,6 +8,7 @@ license = "GPL-3.0-or-later" [lib] path = "src/benchmarks.rs" doctest = false +test = false [lints] workspace = true diff --git a/crates/benchmarks/benches/editor_render.rs b/crates/benchmarks/benches/editor_render.rs index 695cab7e8f..e4f7492f8d 100644 --- a/crates/benchmarks/benches/editor_render.rs +++ b/crates/benchmarks/benches/editor_render.rs @@ -1,14 +1,20 @@ use std::{path::PathBuf, sync::Arc}; +use benchmarks::bench_utils::random_rust_file; use editor::{ Editor, EditorMode, MultiBuffer, actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines}, }; -use gpui::{App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _}; +use gpui::{ + App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _, UpdateGlobal as _, +}; use indoc::{formatdoc, indoc}; -use language::{Buffer, Capability, DiskState, File, LocalFile}; +use language::{Buffer, Capability, DiskState, File, LocalFile, Rope}; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; -use settings::{LocalSettingsKind, LocalSettingsPath, SettingsStore, WorktreeId}; +use settings::{ + DisplayIn, LocalSettingsKind, LocalSettingsPath, SettingsStore, ShowMinimap, WorktreeId, +}; +use theme::ActiveTheme as _; use util::{RandomCharIter, paths::PathStyle, rel_path::RelPath}; use zed_actions::editor::{MoveDown, MoveUp}; @@ -352,6 +358,78 @@ fn jetbrains_editorconfig() -> String { content } +#[gpui::bench] +fn editor_render_highlighted(cx: &mut BenchAppContext) { + init_context(cx); + render_highlighted_editor(cx); +} + +#[gpui::bench] +fn editor_render_highlighted_minimap(cx: &mut BenchAppContext) { + init_context(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let minimap = settings.editor.minimap.get_or_insert_default(); + minimap.show = Some(ShowMinimap::Always); + minimap.display_in = Some(DisplayIn::AllEditors); + }); + }); + }); + render_highlighted_editor(cx); +} + +fn render_highlighted_editor(cx: &mut BenchAppContext) { + let mut rng = StdRng::seed_from_u64(1); + let text = random_rust_file(&mut rng, 10_000).join("\n"); + let language = language::rust_lang(); + let syntax_theme = cx.update(|cx| { + let syntax_theme = cx.theme().syntax().clone(); + language.set_theme(&syntax_theme); + syntax_theme + }); + let probe = "fn main() {}"; + assert!( + !language + .highlight_text(&Rope::from(probe), 0..probe.len()) + .is_empty(), + "the benchmark language must resolve syntax highlights against the theme" + ); + let buffer = cx.update(|cx| { + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + cx.new(|cx| MultiBuffer::singleton(buffer, cx)) + }); + cx.run_until_idle(); + + let mut window = cx.add_empty_window(); + let editor = window.update(|window, cx| { + let editor = window.replace_root(cx, |window, cx| { + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_style( + editor::EditorStyle { + syntax: syntax_theme.clone(), + ..editor::EditorStyle::default() + }, + window, + cx, + ); + editor + }); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + + let mut move_down = true; + cx.bench_renderer(editor, move |editor, window, cx| { + if move_down { + editor.move_down(&MoveDown, window, cx); + } else { + editor.move_up(&MoveUp, window, cx); + } + move_down = !move_down; + }); +} + fn init_context(cx: &mut BenchAppContext) { cx.update(|cx| { let store = SettingsStore::test(cx); @@ -375,6 +453,8 @@ gpui::bench_group!( editor_multi_cursor_input, open_editor_with_one_long_line, editor_render, - editor_render_with_editorconfig + editor_render_with_editorconfig, + editor_render_highlighted, + editor_render_highlighted_minimap ); gpui::bench_main!(benches); diff --git a/crates/breadcrumbs/Cargo.toml b/crates/breadcrumbs/Cargo.toml index e6db2b8f20..766e82c9ad 100644 --- a/crates/breadcrumbs/Cargo.toml +++ b/crates/breadcrumbs/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/breadcrumbs.rs" doctest = false +test = false [dependencies] gpui.workspace = true diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index 53be798fff..53997f7a68 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/buffer_diff.rs" +doctest = false [features] test-support = ["settings"] diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs index 94390fd1ad..bbc53c4ed7 100644 --- a/crates/client/src/llm_token.rs +++ b/crates/client/src/llm_token.rs @@ -132,6 +132,7 @@ impl RefreshLlmTokenListener { MessageToClient::UserUpdated => { this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx)); } + MessageToClient::NotificationsUpdated => {} } } } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 898a810e53..fba6861828 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -902,29 +902,29 @@ impl UserStore { } fn handle_message_to_client(this: WeakEntity, message: &MessageToClient, cx: &App) { - cx.spawn(async move |cx| { - match message { - MessageToClient::UserUpdated => { - let (cloud_client, system_id) = cx - .update(|cx| { - this.read_with(cx, |this, _cx| { - this.client.upgrade().map(|client| { - let system_id = - client.telemetry().system_id().map(|id| id.to_string()); - (client.cloud_client(), system_id) - }) - }) - })? - .ok_or(anyhow::anyhow!("Failed to get Cloud client"))?; + match message { + MessageToClient::UserUpdated => {} + MessageToClient::NotificationsUpdated => return, + } - let response = cloud_client.get_authenticated_user(system_id).await?; - cx.update(|cx| { - this.update(cx, |this, cx| { - this.update_authenticated_user(response, cx); + cx.spawn(async move |cx| { + let (cloud_client, system_id) = cx + .update(|cx| { + this.read_with(cx, |this, _cx| { + this.client.upgrade().map(|client| { + let system_id = client.telemetry().system_id().map(|id| id.to_string()); + (client.cloud_client(), system_id) }) - })?; - } - } + }) + })? + .ok_or(anyhow::anyhow!("Failed to get Cloud client"))?; + + let response = cloud_client.get_authenticated_user(system_id).await?; + cx.update(|cx| { + this.update(cx, |this, cx| { + this.update_authenticated_user(response, cx); + }) + })?; anyhow::Ok(()) }) diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml index 4f8322063b..64f8fa80ee 100644 --- a/crates/cloud_api_client/Cargo.toml +++ b/crates/cloud_api_client/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/cloud_api_client.rs" +doctest = false [dependencies] anyhow.workspace = true @@ -21,9 +22,15 @@ http_client.workspace = true parking_lot.workspace = true serde.workspace = true serde_json.workspace = true -async-lock.workspace = true thiserror.workspace = true yawc.workspace = true +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Workspace inheritance would re-enable defaults, including blocking synchronization. +async-lock = { version = "3.4.2", default-features = false } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +async-lock.workspace = true + [target.'cfg(not(target_family = "wasm"))'.dependencies] gpui_tokio.workspace = true diff --git a/crates/cloud_api_types/Cargo.toml b/crates/cloud_api_types/Cargo.toml index a220f688f5..2d461993da 100644 --- a/crates/cloud_api_types/Cargo.toml +++ b/crates/cloud_api_types/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/cloud_api_types.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/cloud_api_types/src/websocket_protocol.rs b/crates/cloud_api_types/src/websocket_protocol.rs index 75f6a73b43..f3f52bd5e1 100644 --- a/crates/cloud_api_types/src/websocket_protocol.rs +++ b/crates/cloud_api_types/src/websocket_protocol.rs @@ -8,10 +8,12 @@ pub const PROTOCOL_VERSION: u32 = 0; pub const PROTOCOL_VERSION_HEADER_NAME: &str = "x-zed-protocol-version"; /// A message from Cloud to the Zed client. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum MessageToClient { /// The user was updated and should be refreshed. UserUpdated, + /// The user's notifications were updated. + NotificationsUpdated, } impl MessageToClient { @@ -26,3 +28,30 @@ impl MessageToClient { ciborium::from_reader(data).context("failed to deserialize message") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notifications_updated_message_round_trips() -> Result<()> { + let message = MessageToClient::NotificationsUpdated; + + assert_eq!( + MessageToClient::deserialize(&message.serialize()?)?, + message + ); + + Ok(()) + } + + #[test] + fn notifications_updated_message_uses_the_expected_wire_format() -> Result<()> { + assert_eq!( + MessageToClient::NotificationsUpdated.serialize()?, + b"\x74NotificationsUpdated" + ); + + Ok(()) + } +} diff --git a/crates/cloud_llm_client/Cargo.toml b/crates/cloud_llm_client/Cargo.toml index 7cc59f255a..4a7a4a705f 100644 --- a/crates/cloud_llm_client/Cargo.toml +++ b/crates/cloud_llm_client/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [lib] path = "src/cloud_llm_client.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index b4429c0aa0..167507e86a 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -91,7 +91,6 @@ impl FromStr for UsageLimit { #[strum(serialize_all = "snake_case")] pub enum LanguageModelProvider { Anthropic, - Baseten, OpenAi, Google, XAi, diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index 801221d312..812820c2b5 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -1,12 +1,14 @@ [package] name = "codestral" version = "0.1.0" -edition = "2021" +edition.workspace = true publish = false license = "GPL-3.0-or-later" [lib] path = "src/codestral.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true @@ -24,5 +26,3 @@ serde_json.workspace = true text.workspace = true zed_credentials_provider.workspace = true zeta_prompt.workspace = true - -[dev-dependencies] diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index c495167593..917e8a5452 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -239,10 +239,10 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate { let snapshot = buffer.read(cx).snapshot(); // Check if current completion is still valid - if let Some(current_completion) = self.current_completion.as_ref() { - if current_completion.interpolate(&snapshot).is_some() { - return; - } + if let Some(current_completion) = self.current_completion.as_ref() + && current_completion.interpolate(&snapshot).is_some() + { + return; } let http_client = self.http_client.clone(); diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 9e401280e6..2f4c6b76b0 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -19,6 +19,7 @@ test-support = ["sqlite"] [lib] test = false +doctest = false [[test]] name = "collab_tests" @@ -30,8 +31,8 @@ anyhow.workspace = true async-trait.workspace = true async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots" ] } aws-config = { version = "1.1.5" } -aws-sdk-kinesis = "1.51.0" -aws-sdk-s3 = { version = "1.15.0" } +aws-sdk-kinesis = { version = "1.51.0", default-features = false, features = ["default-https-client", "rt-tokio"] } +aws-sdk-s3 = { version = "1.15.0", default-features = false, features = ["sigv4a", "http-1x", "default-https-client", "rt-tokio"] } axum = { version = "0.6", features = ["json", "headers", "ws"] } chrono.workspace = true clock.workspace = true diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 9265f682a1..0ebba9964b 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -183,6 +183,7 @@ async fn main() -> Result<()> { Ok(()) } +#[expect(clippy::result_large_err)] async fn setup_app_database(config: &Config) -> Result<()> { let db_options = db::ConnectOptions::new(config.database_url.clone()); let mut db = Database::new(db_options).await?; @@ -196,6 +197,7 @@ async fn handle_root(Extension(mode): Extension) -> String { format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown")) } +#[expect(clippy::result_large_err)] async fn handle_liveness_probe(app_state: Option>>) -> Result { if let Some(state) = app_state { state.db.project_count_excluding_admins().await?; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 8d8f3fd306..6c41ceb061 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -376,6 +376,9 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler( + forward_read_only_project_request::, + ) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler( @@ -398,6 +401,7 @@ impl Server { forward_mutating_project_request::, ) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) @@ -417,7 +421,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(lsp_query) - .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler(forward_lsp_query_response) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) @@ -433,6 +437,9 @@ impl Server { broadcast_project_message_from_host::, ) .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler( + broadcast_project_message_from_host::, + ) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler( broadcast_project_message_from_host::, @@ -2470,6 +2477,26 @@ async fn lsp_query( } } +async fn forward_lsp_query_response( + request: proto::LspQueryResponse, + session: MessageContext, +) -> Result<()> { + let project_id = ProjectId::from_proto(request.project_id); + session + .db() + .await + .check_user_is_project_host(project_id, session.connection_id) + .await?; + if let Some(peer_id) = request.peer_id { + session + .peer + .forward_send(session.connection_id, peer_id.into(), request)?; + Ok(()) + } else { + broadcast_project_message_from_host(request, session).await + } +} + /// Notify other participants that a new buffer has been created async fn create_buffer_for_peer( request: proto::CreateBufferForPeer, diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index eabfd10373..737800833a 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -8,12 +8,12 @@ use editor::{ actions::{ ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, CopyFileLocation, CopyFileName, CopyFileNameWithoutExtension, ExpandMacroRecursively, MoveToEnd, Redo, - Rename, SelectAll, ToggleCodeActions, Undo, + Rename, SelectAll, ToggleCodeActions, Undo, WrapWithAbbreviation, }, code_context_menus::CodeContextMenu, test::{ editor_test_context::{AssertionContextManager, EditorTestContext}, - expand_macro_recursively, + expand_macro_recursively, wrap_with_abbreviation, }, }; use fs::Fs; @@ -24,13 +24,20 @@ use gpui::{ VisualTestContext, }; use indoc::indoc; -use language::{FakeLspAdapter, language_settings::LanguageSettings, rust_lang}; +use language::{ + FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, language_settings::LanguageSettings, + rust_lang, +}; use lsp::DEFAULT_LSP_REQUEST_TIMEOUT; use multi_buffer::{AnchorRangeExt as _, MultiBufferRow}; use pretty_assertions::assert_eq; use project::{ ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT, - lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro}, + lsp_store::{ + TokenType, + emmet_ext::LspExpandAbbreviation, + lsp_ext_command::{ExpandedMacro, LspExtExpandMacro}, + }, trusted_worktrees::{PathTrust, TrustedWorktrees}, }; use recent_projects::disconnected_overlay::DisconnectedOverlay; @@ -38,7 +45,7 @@ use rpc::RECEIVE_TIMEOUT; use serde_json::json; use settings::{ DocumentFoldingRanges, DocumentSymbols, InlayHintSettingsContent, InlineBlameSettings, - SemanticTokens, SettingsStore, + SemanticTokens, Settings as _, SettingsStore, }; use std::{ collections::BTreeSet, @@ -1195,6 +1202,613 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T }) } +#[gpui::test] +async fn test_remote_rename_uses_server_that_prepared_it( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + cx_b.update(editor::init); + + let unprepared_capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(false), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }; + let prepared_capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut unprepared_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "unprepared-rename-server", + capabilities: unprepared_capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + let mut prepared_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "prepared-rename-server", + capabilities: prepared_capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "unprepared-rename-server", + capabilities: unprepared_capabilities, + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "prepared-rename-server", + capabilities: prepared_capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let unprepared_server = unprepared_servers.next().await.unwrap(); + let prepared_server = prepared_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let unprepared_prepare_count = Arc::new(AtomicUsize::new(0)); + let _unprepared_prepare_requests = unprepared_server + .set_request_handler::({ + let unprepared_prepare_count = unprepared_prepare_count.clone(); + move |_, _| { + unprepared_prepare_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let _prepared_prepare_requests = prepared_server + .set_request_handler::(|_, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + }); + let unprepared_rename_count = Arc::new(AtomicUsize::new(0)); + let _unprepared_rename_requests = unprepared_server + .set_request_handler::({ + let unprepared_rename_count = unprepared_rename_count.clone(); + move |_, _| { + unprepared_rename_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let prepared_rename_count = Arc::new(AtomicUsize::new(0)); + let _prepared_rename_requests = prepared_server + .set_request_handler::({ + let prepared_rename_count = prepared_rename_count.clone(); + move |_, _| { + prepared_rename_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)]) + }); + editor.rename(&Rename, window, cx).unwrap() + }); + prepare_rename.await.unwrap(); + editor_b.update(cx_b, |editor, cx| { + let rename = editor.pending_rename().unwrap(); + rename.editor.update(cx, |rename_editor, cx| { + rename_editor.buffer().update(cx, |rename_buffer, cx| { + rename_buffer.edit( + [(MultiBufferOffset(0)..MultiBufferOffset(3), "TWO")], + None, + cx, + ); + }); + }); + }); + + let confirm_rename = editor_b.update_in(cx_b, |editor, window, cx| { + Editor::confirm_rename(editor, &ConfirmRename, window, cx).unwrap() + }); + confirm_rename.await.unwrap(); + + assert_eq!(unprepared_prepare_count.load(atomic::Ordering::SeqCst), 0); + assert_eq!( + unprepared_rename_count.load(atomic::Ordering::SeqCst), + 0, + "expected remote rename not to switch back to the first capable server", + ); + assert_eq!( + prepared_rename_count.load(atomic::Ordering::SeqCst), + 1, + "expected remote rename to use the server that prepared it", + ); +} + +#[gpui::test] +async fn test_remote_rename_with_stale_server_id_falls_back_to_capable_server( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + let capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "rename-server", + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rename-server", + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let (buffer_b, _handle_b) = project_b + .update(cx_b, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("one.rs")), cx) + }) + .await + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let rename_request_count = Arc::new(AtomicUsize::new(0)); + let _rename_requests = fake_server.set_request_handler::({ + let rename_request_count = rename_request_count.clone(); + move |params, _| { + rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!(params.new_name, "TWO"); + Ok(Some(lsp::WorkspaceEdit { + changes: Some( + [( + lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + "TWO".to_string(), + )], + )] + .into_iter() + .collect(), + ), + ..lsp::WorkspaceEdit::default() + })) + } + } + }); + + let transaction = project_b + .update(cx_b, |project, cx| { + project.perform_rename( + buffer_b.clone(), + 7, + "TWO".to_string(), + Some(lsp::LanguageServerId(4242)), + cx, + ) + }) + .await + .unwrap() + .0; + + assert_eq!( + rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the host to drop the stale server id and fall back to the capable server", + ); + assert_eq!(transaction.len(), 1); + buffer_b.read_with(cx_b, |buffer, _| { + assert_eq!(buffer.text(), "const TWO: usize = 1;"); + }); +} + +#[gpui::test] +async fn test_remote_dynamic_call_hierarchy_followups_use_prepared_server( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + cx_b.update(editor::init); + + client_a.language_registry().add(rust_lang()); + let mut fake_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "fn one() {}" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let buffer_b = editor_b + .read_with(cx_b, |editor, cx| editor.buffer().read(cx).as_singleton()) + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-call-hierarchy".to_string(), + method: "textDocument/prepareCallHierarchy".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let item = lsp::CallHierarchyItem { + name: "one".to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + detail: None, + uri: lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 11)), + selection_range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)), + data: None, + }; + let _prepare_requests = fake_server + .set_request_handler::({ + let item = item.clone(); + move |_, _| { + let item = item.clone(); + async move { Ok(Some(vec![item])) } + } + }); + let incoming_request_count = Arc::new(AtomicUsize::new(0)); + let _incoming_requests = fake_server + .set_request_handler::({ + let incoming_request_count = incoming_request_count.clone(); + move |_, _| { + incoming_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let outgoing_request_count = Arc::new(AtomicUsize::new(0)); + let _outgoing_requests = fake_server + .set_request_handler::({ + let outgoing_request_count = outgoing_request_count.clone(); + move |_, _| { + outgoing_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let items = project_b + .update(cx_b, |project, cx| { + project.prepare_call_hierarchy(&buffer_b, 0, cx) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(items.len(), 1); + let item = items.into_iter().next().unwrap(); + + project_b + .update(cx_b, |project, cx| project.incoming_calls(item.clone(), cx)) + .await + .unwrap() + .unwrap(); + project_b + .update(cx_b, |project, cx| project.outgoing_calls(item, cx)) + .await + .unwrap() + .unwrap(); + + assert_eq!(incoming_request_count.load(atomic::Ordering::SeqCst), 1); + assert_eq!(outgoing_request_count.load(atomic::Ordering::SeqCst), 1); +} + +#[gpui::test] +async fn test_remote_dynamic_document_highlight_registration_refreshes_guest_editor( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + cx_b.update(editor::init); + + let request_count = Arc::new(AtomicUsize::new(0)); + client_a.language_registry().add(rust_lang()); + let mut fake_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + initializer: Some(Box::new({ + let request_count = request_count.clone(); + move |fake_server| { + let request_count = request_count.clone(); + fake_server + .set_request_handler::( + move |_, _| { + request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(vec![lsp::DocumentHighlight { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 2), + ), + kind: Some(lsp::DocumentHighlightKind::READ), + }])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "fn one() {}" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + let debounce = Duration::from_millis(cx_b.update(|_, cx| { + editor::EditorSettings::get_global(cx) + .lsp_highlight_debounce + .0 + })); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no document highlight request before the capability is registered", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the guest editor to request document highlights after the host registration", + ); + assert!(editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + })); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "expected no document highlight request after the host unregistration", + ); + assert!( + !editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + }), + "expected the guest editor to clear stale document highlights after the host unregistration", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2); + assert!(editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + })); + + let server_id = fake_server.server.server_id(); + let buffer_a = project_a + .update(cx_a, |project, cx| { + project.open_local_buffer(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + project_a.update(cx_a, |project, cx| { + project.stop_language_servers_for_buffers(vec![buffer_a], HashSet::default(), cx); + }); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + project_b.read_with(cx_b, |project, cx| { + project + .language_server_statuses(cx) + .map(|(server_id, _)| server_id) + .collect::>() + }), + Vec::::new(), + "expected the guest to drop the stopped server {server_id}", + ); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "expected no document highlight request after the host stopped the server", + ); + assert!( + !editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + }), + "expected the guest editor to clear stale document highlights after the host stopped the server", + ); +} + #[gpui::test] async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let mut server = TestServer::start(cx_a.executor()).await; @@ -1440,7 +2054,7 @@ async fn test_collaborating_with_code_lens_resolve( ..lsp::ServerCapabilities::default() }; client_a.language_registry().add(rust_lang()); - client_a.language_registry().register_fake_lsp( + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( "Rust", FakeLspAdapter { capabilities: capabilities.clone(), @@ -1507,6 +2121,26 @@ async fn test_collaborating_with_code_lens_resolve( .unwrap() .downcast::() .unwrap(); + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-code-lens".to_string(), + method: "textDocument/codeLens".to_string(), + register_options: Some(json!({ + "documentSelector": [ + { "language": "rust", "scheme": "untitled" } + ], + "resolveProvider": false, + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); cx_a.run_until_parked(); cx_b.run_until_parked(); @@ -5144,6 +5778,154 @@ async fn test_client_can_query_lsp_ext(cx_a: &mut TestAppContext, cx_b: &mut Tes }); } +#[gpui::test] +async fn test_client_can_wrap_with_emmet_abbreviation( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_b.update(editor::init); + + let html_lang = || { + Arc::new(Language::new( + LanguageConfig { + name: "HTML".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["html".into()], + ..LanguageMatcher::default() + } + .into(), + ..LanguageConfig::default() + }, + None, + )) + }; + client_a.language_registry().add(html_lang()); + client_b.language_registry().add(html_lang()); + let mut fake_html_servers = client_a.language_registry().register_fake_lsp( + "HTML", + FakeLspAdapter { + name: "vscode-html-language-server", + ..FakeLspAdapter::default() + }, + ); + let mut fake_emmet_servers = client_a.language_registry().register_fake_lsp( + "HTML", + FakeLspAdapter { + name: "emmet-language-server", + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().register_fake_lsp_adapter( + "HTML", + FakeLspAdapter { + name: "emmet-language-server", + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/a"), + json!({ + "index.html": "

hello

", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let buffer_a = project_a + .update(cx_a, |project, cx| { + project.open_buffer((worktree_id, rel_path("index.html")), cx) + }) + .await + .unwrap(); + + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("index.html")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let _fake_html_server = fake_html_servers.next().await.unwrap(); + let fake_emmet_server = fake_emmet_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let mut wrap_request = fake_emmet_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div.wrap"); + assert_eq!(params.language, "html"); + assert_eq!(params.options.text, Some(vec!["hello".to_string()])); + Ok(Some("
hello
".to_string())) + }, + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 3)..Point::new(0, 8)]) + }); + wrap_with_abbreviation(editor, &WrapWithAbbreviation, window, cx); + }); + editor_b.update_in(cx_b, |editor, window, cx| { + let input = editor + .pending_inline_input() + .expect("emmet wrap input should be pending on the client") + .editor + .clone(); + input.update(cx, |input, cx| input.set_text("div.wrap", window, cx)); + editor.confirm_inline_input(window, cx); + }); + wrap_request.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_b.update(cx_b, |editor, cx| { + assert_eq!(editor.text(cx), "

hello

"); + }); + buffer_a.read_with(cx_a, |buffer, _| { + assert_eq!( + buffer.text(), + "

hello

", + "the wrap edit should sync back to the host" + ); + }); +} + #[gpui::test] async fn test_copy_file_name_without_extension( cx_a: &mut TestAppContext, @@ -5784,6 +6566,92 @@ async fn test_mutual_editor_semantic_token_cache_update( }); } +#[gpui::test] +async fn test_guest_semantic_tokens_honor_dynamic_document_selectors( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + include_static_capability: true, + include_matching_registration: true, + expected_token_type: Some("keyword"), + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_does_not_guess_semantic_token_legend_without_matching_adapter( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + remove_guest_adapter_before_stylizing: true, + include_static_capability: true, + include_matching_registration: true, + expect_unstyled_tokens: true, + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_receives_dynamic_document_selectors_when_project_is_shared_later( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + share_after_registration: true, + include_static_capability: true, + include_matching_registration: true, + expected_token_type: Some("keyword"), + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_preserves_static_capability_after_nonmatching_dynamic_registration( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + include_static_capability: true, + expected_token_type: Some("type"), + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_does_not_treat_nonmatching_dynamic_capability_as_static( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig::default(), + cx_a, + cx_b, + ) + .await; +} + #[gpui::test(iterations = 10)] async fn test_semantic_token_refresh_is_forwarded( cx_a: &mut TestAppContext, @@ -6459,3 +7327,229 @@ fn blame_entry(sha: &str, range: Range) -> git::blame::BlameEntry { boundary: false, } } + +#[derive(Default)] +struct GuestSemanticTokensTestConfig { + share_after_registration: bool, + remove_guest_adapter_before_stylizing: bool, + include_static_capability: bool, + include_matching_registration: bool, + expect_unstyled_tokens: bool, + expected_token_type: Option<&'static str>, +} + +async fn run_guest_semantic_tokens_document_selector_test( + config: GuestSemanticTokensTestConfig, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let GuestSemanticTokensTestConfig { + share_after_registration, + remove_guest_adapter_before_stylizing, + include_static_capability, + include_matching_registration, + expect_unstyled_tokens, + expected_token_type, + } = config; + let mut server = TestServer::start(cx_a.executor()).await; + let executor = cx_a.executor(); + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + let static_capabilities = lsp::ServerCapabilities { + semantic_tokens_provider: include_static_capability.then_some( + lsp::SemanticTokensServerCapabilities::SemanticTokensOptions( + lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["type".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(true)), + ..lsp::SemanticTokensOptions::default() + }, + ), + ), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: static_capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities: static_capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/a"), json!({ "main.rs": "fn main() {}" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + let project_id = if share_after_registration { + None + } else { + Some( + active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(), + ) + }; + + let (_buffer_a, _handle_a) = project_a + .update(cx_a, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let fake_language_server = fake_language_servers.next().await.unwrap(); + executor.run_until_parked(); + + let semantic_tokens_registration = + |scheme: &str, full: lsp::SemanticTokensFullOptions, token_type: &str| { + serde_json::to_value(lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some(scheme.to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec![token_type.to_owned().into()], + token_modifiers: Vec::new(), + }, + full: Some(full), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok() + }; + let mut registrations = Vec::new(); + if include_matching_registration { + registrations.push(lsp::Registration { + id: "file-semantic-tokens".to_string(), + method: "textDocument/semanticTokens".to_string(), + register_options: semantic_tokens_registration( + "file", + lsp::SemanticTokensFullOptions::Delta { delta: Some(true) }, + "keyword", + ), + }); + } + registrations.push(lsp::Registration { + id: "untitled-semantic-tokens".to_string(), + method: "textDocument/semanticTokens".to_string(), + register_options: semantic_tokens_registration( + "untitled", + lsp::SemanticTokensFullOptions::Bool(true), + "comment", + ), + }); + fake_language_server + .request::( + lsp::RegistrationParams { registrations }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + + let semantic_token_requests = Arc::new(AtomicUsize::new(0)); + fake_language_server.set_request_handler::({ + let semantic_token_requests = semantic_token_requests.clone(); + move |_, _| { + semantic_token_requests.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(lsp::SemanticTokensResult::Tokens( + lsp::SemanticTokens { + data: vec![0, 3, 4, 0, 0], + result_id: None, + }, + ))) + } + } + }); + executor.run_until_parked(); + + let project_id = match project_id { + Some(project_id) => project_id, + None => active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(), + }; + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let buffer_b = project_b + .update(cx_b, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + + let requests_before_guest_fetch = semantic_token_requests.load(atomic::Ordering::SeqCst); + let lsp_store_b = project_b.read_with(cx_b, |project, _| project.lsp_store()); + let guest_tokens = lsp_store_b + .update(cx_b, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer_b.clone(), cx) + }) + .await + .unwrap(); + + let expected_server_count = + usize::from(expected_token_type.is_some() || expect_unstyled_tokens); + assert_eq!( + semantic_token_requests.load(atomic::Ordering::SeqCst) - requests_before_guest_fetch, + expected_server_count, + "expected the guest request routing to honor the applicable providers", + ); + assert_eq!( + guest_tokens.tokens.as_ref().map_or(0, HashMap::len), + expected_server_count, + "expected the guest token result to honor the applicable providers", + ); + if expected_server_count == 0 { + return; + } + if remove_guest_adapter_before_stylizing { + client_b.language_registry().remove_lsp_adapter( + &language::LanguageName::new("Rust"), + &lsp::LanguageServerName::new_static("the-fake-language-server"), + ); + } + let server_id = guest_tokens + .tokens + .as_ref() + .and_then(|tokens| tokens.keys().next().copied()) + .expect("expected semantic tokens from one server"); + let token_type = lsp_store_b.update(cx_b, |lsp_store, cx| { + let language = buffer_b.read(cx).language().map(|language| language.name()); + lsp_store + .get_or_create_token_stylizer(server_id, language.as_ref(), cx) + .and_then(|stylizer| stylizer.token_type_name(TokenType(0)).cloned()) + }); + assert_eq!( + token_type.as_deref(), + expected_token_type, + "expected the guest to decode tokens only when the applicable provider's legend is known", + ); +} diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index c0f7932b66..1443566112 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -21,7 +21,7 @@ use gpui::{ }; use project::{ ProjectPath, - git_store::{CommitDataState, Repository}, + git_store::{CommitDataState, MAX_CONCURRENT_BLOB_READS, Repository}, }; use rand::{SeedableRng, rngs::StdRng}; use serde_json::json; @@ -449,6 +449,87 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) }); } +#[gpui::test] +async fn test_blob_read_rpcs_are_bounded_on_host( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + const FILE_COUNT: usize = MAX_CONCURRENT_BLOB_READS + 4; + let names = (0..FILE_COUNT) + .map(|index| format!("f{index:02}.txt")) + .collect::>(); + + let mut tree = serde_json::Map::new(); + tree.insert(".git".to_owned(), json!({})); + for (index, name) in names.iter().enumerate() { + tree.insert(name.clone(), json!(format!("new-{index:02}\n"))); + } + client_a + .fs() + .insert_tree(path!("/dir"), serde_json::Value::Object(tree)) + .await; + + let merge_base = names + .iter() + .enumerate() + .map(|(index, name)| (name.as_str(), format!("old-{index:02}\n"))) + .collect::>(); + let dot_git = Path::new(path!("/dir/.git")); + let oids = client_a + .fs() + .set_merge_base_content_for_repo(dot_git, &merge_base); + let gate = client_a.fs().install_blob_read_gate_for_repo(dot_git); + + let (project_local, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| { + call.share_project(project_local.clone(), cx) + }) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let project_c = client_c.join_remote_project(project_id, cx_c).await; + executor.run_until_parked(); + + // Two guests exceed the cap combined; only the host's own limiter holds them to it. + let mut diffs = Vec::with_capacity(names.len() * 2); + for (project, cx) in [(&project_b, &mut *cx_b), (&project_c, &mut *cx_c)] { + let git_store = project.read_with(cx, |project, _| project.git_store().clone()); + let repo = project.read_with(cx, |project, cx| project.active_repository(cx).unwrap()); + for (index, name) in names.iter().enumerate() { + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path(name)), cx) + }) + .await + .unwrap(); + diffs.push(git_store.update(cx, |git_store, cx| { + git_store.open_diff_since(Some(oids[index]), buffer, repo.clone(), cx) + })); + } + } + executor.run_until_parked(); + + assert_eq!(gate.peak_concurrent(), MAX_CONCURRENT_BLOB_READS); + + gate.open(); + executor.run_until_parked(); + for diff in diffs { + diff.await.unwrap(); + } +} + #[gpui::test] async fn test_remote_git_worktrees( executor: BackgroundExecutor, diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index eccf080e85..5650b26db9 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -9,7 +9,10 @@ use client::{RECEIVE_TIMEOUT, User}; use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}; use collections::{BTreeMap, HashMap, HashSet}; use fs::{FakeFs, Fs as _, RemoveOptions}; -use futures::{StreamExt as _, channel::mpsc}; +use futures::{ + StreamExt as _, + channel::{mpsc, oneshot}, +}; use git::{ repository::repo_path, status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}, @@ -5489,6 +5492,107 @@ async fn test_references( }); } +#[gpui::test] +async fn test_concurrent_guest_lsp_requests_do_not_cancel_each_other( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + let capabilities = lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "my-fake-lsp-adapter", + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + for client in [&client_b, &client_c] { + client.language_registry().add(rust_lang()); + client.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "my-fake-lsp-adapter", + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + } + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "main.rs": "const ONE: usize = 1;" + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let project_c = client_c.join_remote_project(project_id, cx_c).await; + let (buffer_b, _handle_b) = project_b + .update(cx_b, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + let (buffer_c, _handle_c) = project_c + .update(cx_c, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + let fake_language_server = fake_language_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + cx_c.run_until_parked(); + + let (request_started_tx, mut request_started_rx) = mpsc::unbounded(); + let _reference_requests = fake_language_server + .set_request_handler::(move |params, _| { + let (response_tx, response_rx) = oneshot::channel(); + request_started_tx + .unbounded_send((params.text_document_position.position, response_tx)) + .unwrap(); + async move { response_rx.await.unwrap() } + }); + + let references_b = project_b.update(cx_b, |project, cx| project.references(&buffer_b, 1, cx)); + let (position_b, response_b) = request_started_rx.next().await.unwrap(); + assert_eq!(position_b, lsp::Position::new(0, 1)); + + let references_c = project_c.update(cx_c, |project, cx| project.references(&buffer_c, 7, cx)); + let (position_c, response_c) = request_started_rx.next().await.unwrap(); + assert_eq!(position_c, lsp::Position::new(0, 7)); + assert!(!response_b.is_canceled()); + assert!(!response_c.is_canceled()); + + response_b.send(Ok(Some(Vec::new()))).unwrap(); + response_c.send(Ok(Some(Vec::new()))).unwrap(); + + assert_eq!(references_b.await.unwrap().unwrap(), Vec::new()); + assert_eq!(references_c.await.unwrap().unwrap(), Vec::new()); +} + #[gpui::test(iterations = 10)] async fn test_project_search( executor: BackgroundExecutor, @@ -7586,3 +7690,82 @@ async fn test_guest_can_rejoin_shared_project_after_leaving_call( ); }) } + +/// Tests that a guest's project search does not return the contents of files the host has marked +/// private. +#[gpui::test] +async fn test_project_search_excludes_private_files( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + cx_a.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |file| { + file.project.worktree.private_files = Some(vec!["**/.env".to_string()].into()); + }); + }); + }); + + client_a + .fs() + .insert_tree( + "/root", + json!({ + "dir-1": { + "a.txt": "the secret is out", + ".env": "API_KEY=secret", + } + }), + ) + .await; + let (project_a, _) = client_a.build_local_project("/root/dir-1", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let mut results = HashMap::default(); + let search_rx = project_b.update(cx_b, |project, cx| { + project.search( + SearchQuery::text( + "secret", + false, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .unwrap(), + cx, + ) + }); + while let Ok(result) = search_rx.rx.recv().await { + if let SearchResult::Buffer { buffer, ranges } = result { + results.entry(buffer).or_insert(ranges); + } + } + + let mut paths = results + .into_keys() + .map(|buffer| buffer.read_with(cx_b, |buffer, cx| buffer.file().unwrap().full_path(cx))) + .collect::>(); + paths.sort(); + + assert_eq!( + paths, + &[PathBuf::from("dir-1/a.txt")], + "the guest's search returned a file the host marked private" + ); +} diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index d039bce3b3..06989f8136 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -927,12 +927,12 @@ async fn test_ssh_restarting_language_server_replaces_remote_status( let matching_server_ids = log_store .language_servers .iter() - .filter_map(|(server_id, state)| { + .filter_map(|(key, state)| { state .name .as_ref() .is_some_and(|name| name.0 == "the-language-server") - .then_some(*server_id) + .then_some(key.server_id) }) .collect::>(); assert_eq!(matching_server_ids, vec![first_server_id]); @@ -958,10 +958,6 @@ async fn test_ssh_restarting_language_server_replaces_remote_status( statuses[0].0, restarted_server_id, "restarting a remote language server should publish the replacement server id" ); - assert_ne!( - statuses[0].0, first_server_id, - "restarting a remote language server should remove the previous server id" - ); assert_eq!(statuses[0].1.name.0, "the-language-server"); }); cx_a.read_global::(|global, cx| { @@ -969,12 +965,12 @@ async fn test_ssh_restarting_language_server_replaces_remote_status( let matching_server_ids = log_store .language_servers .iter() - .filter_map(|(server_id, state)| { + .filter_map(|(key, state)| { state .name .as_ref() .is_some_and(|name| name.0 == "the-language-server") - .then_some(*server_id) + .then_some(key.server_id) }) .collect::>(); assert_eq!( @@ -982,10 +978,6 @@ async fn test_ssh_restarting_language_server_replaces_remote_status( vec![restarted_server_id], "restarting a remote language server should replace the old log store entry" ); - assert!( - !log_store.language_servers.contains_key(&first_server_id), - "restarting a remote language server should remove the previous log store entry" - ); }); } diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 2b0e865f95..4d4117755d 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -1,3 +1,4 @@ +mod command_palette_settings; mod persistence; use std::{ @@ -7,28 +8,35 @@ use std::{ time::Duration, }; +use anyhow::Context as _; use client::parse_zed_link; use command_palette_hooks::{ CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter, GlobalCommandPaletteInterceptor, }; +use command_palette_settings::CommandPaletteSettings; use fuzzy_nucleo::{StringMatch, StringMatchCandidate}; use gpui::{ - Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - ParentElement, Render, Styled, Task, TaskExt, WeakEntity, Window, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseButton, + ParentElement, Render, Styled, Task, TaskExt, WeakEntity, Window, actions, }; use persistence::CommandPaletteDB; use picker::Direction; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use ui::{ + ButtonLike, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, +}; use util::ResultExt; use workspace::{ModalView, Workspace, WorkspaceSettings}; use zed_actions::{OpenZedUrl, command_palette::Toggle}; +actions!(command_palette, [RemoveSelected]); + pub fn init(cx: &mut App) { + CommandPaletteSettings::register(cx); command_palette_hooks::init(cx); cx.observe_new(CommandPalette::register).detach(); } @@ -120,8 +128,9 @@ impl CommandPalette { } Some(Command { - name: humanize_action_name(action.name()), + name: SharedString::from(humanize_action_name(action.name())), action, + usage: None, }) }) .collect(); @@ -136,6 +145,7 @@ impl CommandPalette { let picker = cx.new(|cx| { // One-shot action; there's nothing to reopen. let picker = Picker::uniform_list(delegate, window, cx) + .initial_width(rems(38.0)) .reopenable(false, cx) .show_scrollbar(true); picker.set_query(query, window, cx); @@ -148,6 +158,22 @@ impl CommandPalette { self.picker .update(cx, |picker, cx| picker.set_query(query, window, cx)) } + + fn remove_selected(&mut self, _: &RemoveSelected, window: &mut Window, cx: &mut Context) { + self.picker.update(cx, |picker, cx| { + let delegate = &mut picker.delegate; + let Some(command_name) = delegate + .selected_command() + .filter(|command| command.usage.is_some()) + .map(|command| command.name.clone()) + else { + return; + }; + delegate + .remove_command_history(command_name, window, cx) + .detach(); + }); + } } impl EventEmitter for CommandPalette {} @@ -159,9 +185,10 @@ impl Focusable for CommandPalette { } impl Render for CommandPalette { - fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("CommandPalette") + .on_action(cx.listener(Self::remove_selected)) .child(self.picker.clone()) } } @@ -183,8 +210,9 @@ pub struct CommandPaletteDelegate { } struct Command { - name: String, + name: SharedString, action: Box, + usage: Option, } #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -283,6 +311,7 @@ impl Clone for Command { Self { name: self.name.clone(), action: self.action.boxed_clone(), + usage: self.usage, } } } @@ -298,13 +327,13 @@ impl CommandPaletteDelegate { command_palette, workspace, all_commands: commands.clone(), - matches: vec![], + matches: Vec::new(), commands, selected_ix: 0, previous_focus_handle, latest_query: String::new(), updating_matches: None, - query_history: Default::default(), + query_history: QueryHistory::default(), } } @@ -333,13 +362,15 @@ impl CommandPaletteDelegate { { matches.remove(idx); } + let string = SharedString::from(string); commands.push(Command { name: string.clone(), action, + usage: None, }); new_matches.push(StringMatch { candidate_id: commands.len() - 1, - string: string.into(), + string, positions, score: 0.0, }) @@ -359,13 +390,17 @@ impl CommandPaletteDelegate { /// Hit count for each command in the palette. /// We only account for commands triggered directly via command palette and not by e.g. keystrokes because /// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it. - fn command_usage(&self, cx: &App) -> HashMap { + fn command_usage(&self, cx: &App) -> HashMap { + if !CommandPaletteSettings::get_global(cx).use_command_history { + return HashMap::new(); + } + if let Ok(commands) = CommandPaletteDB::global(cx).list_commands_used() { commands .into_iter() .map(|command| { ( - command.command_name, + SharedString::from(command.command_name), CommandUsage { last_invoked: command.last_invoked.unix_timestamp(), invocations: command.invocations, @@ -378,6 +413,86 @@ impl CommandPaletteDelegate { } } + fn remove_command_history( + &mut self, + command_name: SharedString, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let db = CommandPaletteDB::global(cx); + cx.spawn_in(window, async move |picker, cx| { + if db + .delete_command_history(command_name.to_string()) + .await + .with_context(|| format!("removing {command_name:?} from command history")) + .log_err() + .is_none() + { + return; + } + picker + .update_in(cx, |picker, window, cx| { + picker.delegate.query_history = QueryHistory::default(); + picker.refresh(window, cx); + }) + .ok(); + }) + } + + fn render_history_button( + ix: usize, + command_name: SharedString, + cx: &mut Context>, + ) -> impl IntoElement { + h_flex() + .id(("command-history", ix)) + .debug_selector(move || format!("command-history-{ix}")) + .group("command-history-button") + .on_mouse_down(MouseButton::Left, |_, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + }) + .on_mouse_up(MouseButton::Right, |_, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + }) + .child( + ButtonLike::new(("remove-command-history", ix)) + .aria_label("Remove from Command History") + .tooltip(Tooltip::for_action_title( + "Remove from Command History", + &RemoveSelected, + )) + .child( + h_flex() + .relative() + .child( + h_flex() + .group_hover("command-history-button", |this| this.invisible()) + .child( + Icon::new(IconName::HistoryRerun) + .color(Color::Muted) + .size(IconSize::Small), + ), + ) + .child( + h_flex() + .absolute() + .inset_0() + .visible_on_hover("command-history-button") + .child(Icon::new(IconName::Close).size(IconSize::Small)), + ), + ) + .on_click(cx.listener(move |picker, _, window, cx| { + window.prevent_default(); + picker + .delegate + .remove_command_history(command_name.clone(), window, cx) + .detach(); + })), + ) + } + fn selected_command(&self) -> Option<&Command> { if self.matches.is_empty() { return None; @@ -489,12 +604,10 @@ impl PickerDelegate for CommandPaletteDelegate { let query = normalize_action_query(query_str); let query_for_link = query_str.to_string(); async move { - commands.sort_by_key(|action| { - ( - Reverse(command_usage.get(&action.name).copied()), - action.name.clone(), - ) - }); + for command in &mut commands { + command.usage = command_usage.get(&command.name).copied(); + } + commands.sort_by_key(|command| (Reverse(command.usage), command.name.clone())); let candidates = commands .iter() @@ -515,7 +628,7 @@ impl PickerDelegate for CommandPaletteDelegate { let used_commands = commands .iter() - .take_while(|command| command_usage.contains_key(&command.name)) + .take_while(|command| command.usage.is_some()) .count(); matches.sort_by_key(|string_match| { if string_match.candidate_id < used_commands { @@ -668,15 +781,31 @@ impl PickerDelegate for CommandPaletteDelegate { .w_full() .py_px() .justify_between() - .child(HighlightedLabel::new( - command.name.clone(), - matching_command.positions.clone(), - )) - .child(KeyBinding::for_action_in( - &*command.action, - &self.previous_focus_handle, - cx, - )), + .gap_2() + .child( + HighlightedLabel::new( + command.name.clone(), + matching_command.positions.clone(), + ) + .truncate(), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_2() + .child(KeyBinding::for_action_in( + &*command.action, + &self.previous_focus_handle, + cx, + )) + .when(command.usage.is_some(), |this| { + this.child(Self::render_history_button( + ix, + command.name.clone(), + cx, + )) + }), + ), ), ) } @@ -909,7 +1038,7 @@ mod tests { editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) }); - cx.simulate_keystrokes("cmd-shift-p"); + cx.dispatch_action(Toggle); let palette = workspace.update(cx, |workspace, cx| { workspace @@ -933,13 +1062,75 @@ mod tests { assert_eq!(palette.delegate.matches[0].string, "editor: backspace"); }); - cx.simulate_keystrokes("enter"); + cx.dispatch_action(menu::Confirm); workspace.update(cx, |workspace, cx| { assert!(workspace.active_modal::(cx).is_none()); assert_eq!(editor.read(cx).text(cx), "ab") }); + cx.dispatch_action(Toggle); + let history_button = cx.debug_bounds("command-history-0").unwrap(); + cx.simulate_mouse_move(history_button.center(), None, gpui::Modifiers::default()); + cx.simulate_mouse_down( + history_button.center(), + MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + history_button.center(), + MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_down( + history_button.center(), + MouseButton::Left, + gpui::Modifiers::default(), + ); + let label_position = history_button.center() - gpui::point(px(100.0), px(0.0)); + cx.simulate_mouse_move( + label_position, + MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + label_position, + MouseButton::Left, + gpui::Modifiers::default(), + ); + workspace.read_with(cx, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_some()); + assert_eq!(editor.read(cx).text(cx), "ab"); + }); + assert_eq!( + db.get_command_usage("editor: backspace") + .unwrap() + .unwrap() + .invocations, + 1 + ); + + cx.simulate_mouse_move(history_button.center(), None, gpui::Modifiers::default()); + cx.simulate_click(history_button.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + + assert_eq!(db.get_command_usage("editor: backspace").unwrap(), None); + workspace.update_in(cx, |workspace, window, cx| { + assert_eq!(editor.read(cx).text(cx), "ab"); + let palette = workspace.active_modal::(cx).unwrap(); + let picker = palette.read(cx).picker.read(cx); + assert_eq!( + picker + .delegate + .matches + .iter() + .filter(|matching_command| matching_command.string == "editor: backspace") + .count(), + 1, + ); + workspace.hide_modal(window, cx); + }); + // Add namespace filter, and redeploy the palette cx.update(|_window, cx| { CommandPaletteFilter::update_global(cx, |filter, _| { @@ -947,7 +1138,7 @@ mod tests { }); }); - cx.simulate_keystrokes("cmd-shift-p"); + cx.dispatch_action(Toggle); cx.simulate_input("bcksp"); let palette = workspace.update(cx, |workspace, cx| { @@ -1029,8 +1220,8 @@ mod tests { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - cx.simulate_keystrokes("cmd-n"); - cx.simulate_keystrokes("cmd-shift-p"); + cx.dispatch_action(workspace::NewFile); + cx.dispatch_action(Toggle); let palette = workspace.update(cx, |workspace, cx| { workspace.active_modal::(cx).unwrap() @@ -1042,6 +1233,14 @@ mod tests { }); cx.run_until_parked(); + let unranked_names = picker.read_with(cx, |picker, _| { + picker + .delegate + .matches + .iter() + .map(|matching_command| matching_command.string.to_string()) + .collect::>() + }); let (worst_match, second_worst_match, match_count) = picker.read_with(cx, |picker, _| { let matches = &picker.delegate.matches; ( @@ -1081,6 +1280,150 @@ mod tests { assert_eq!(names[1], second_worst_match); assert_eq!(names.len(), match_count); }); + + picker.update_in(cx, |picker, window, cx| { + CommandPaletteSettings::override_global( + CommandPaletteSettings { + use_command_history: false, + }, + cx, + ); + picker.refresh(window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(RemoveSelected); + picker.read_with(cx, |picker, _| { + assert_eq!( + picker + .delegate + .matches + .iter() + .map(|matching_command| matching_command.string.to_string()) + .collect::>(), + unranked_names + ); + assert!( + picker + .delegate + .commands + .iter() + .all(|command| command.usage.is_none()) + ); + }); + assert_eq!(db.list_commands_used().unwrap().len(), 2); + + picker.update_in(cx, |picker, window, cx| { + CommandPaletteSettings::override_global( + CommandPaletteSettings { + use_command_history: true, + }, + cx, + ); + picker.refresh(window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(RemoveSelected); + cx.run_until_parked(); + picker.read_with(cx, |picker, _| { + let expected = std::iter::once(second_worst_match.clone()) + .chain( + unranked_names + .iter() + .filter(|name| *name != &second_worst_match) + .cloned(), + ) + .collect::>(); + assert_eq!( + picker + .delegate + .matches + .iter() + .map(|matching_command| matching_command.string.to_string()) + .collect::>(), + expected + ); + }); + + let remaining_usage = db.list_commands_used().unwrap(); + picker.update_in(cx, |picker, window, cx| { + picker.delegate.set_selected_index(1, window, cx); + }); + cx.dispatch_action(RemoveSelected); + assert_eq!(db.list_commands_used().unwrap(), remaining_usage); + } + + #[gpui::test] + async fn test_intercepted_commands_are_not_history(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |workspace, _| workspace.workspace().clone()); + let picker = open_palette_with_history(&workspace, &[], cx); + + for (exclusive, expected) in [ + ( + false, + vec![("intercepted", false), ("older", true), ("unused", false)], + ), + (true, vec![("intercepted", false)]), + ] { + picker.update(cx, |picker, cx| { + let commands = [ + ("recent", Toggle.boxed_clone()), + ("older", menu::Confirm.boxed_clone()), + ("unused", menu::Cancel.boxed_clone()), + ] + .into_iter() + .enumerate() + .map(|(index, (name, action))| Command { + name: SharedString::from(name), + action, + usage: (index < 2).then_some(CommandUsage { + last_invoked: 0, + invocations: 1, + }), + }) + .collect::>(); + let matches = commands + .iter() + .enumerate() + .map(|(candidate_id, command)| StringMatch { + candidate_id, + string: command.name.clone(), + positions: Vec::new(), + score: 0.0, + }) + .collect::>(); + let intercept_result = CommandInterceptResult { + results: vec![CommandInterceptItem { + action: Toggle.boxed_clone(), + string: "intercepted".to_string(), + positions: Vec::new(), + }], + exclusive, + }; + picker.delegate.matches_updated( + String::new(), + commands, + matches, + intercept_result, + cx, + ); + assert_eq!( + picker + .delegate + .matches + .iter() + .map(|matching_command| { + let command = &picker.delegate.commands[matching_command.candidate_id]; + (command.name.as_str(), command.usage.is_some()) + }) + .collect::>(), + expected, + ); + }); + } } #[gpui::test] @@ -1103,6 +1446,7 @@ mod tests { cx.simulate_input("definitely-no-command-should-match-this"); cx.background_executor.run_until_parked(); + cx.dispatch_action(RemoveSelected); picker.read_with(cx, |picker, _cx| { assert!(picker.delegate.matches.is_empty()); @@ -1240,7 +1584,7 @@ mod tests { history: &[&str], cx: &mut VisualTestContext, ) -> Entity> { - cx.simulate_keystrokes("cmd-shift-p"); + cx.dispatch_action(Toggle); cx.run_until_parked(); let palette = workspace.update(cx, |workspace, cx| { diff --git a/crates/command_palette/src/command_palette_settings.rs b/crates/command_palette/src/command_palette_settings.rs new file mode 100644 index 0000000000..5763fcabfe --- /dev/null +++ b/crates/command_palette/src/command_palette_settings.rs @@ -0,0 +1,18 @@ +use settings::Settings; + +#[derive(Clone, Debug, PartialEq)] +pub struct CommandPaletteSettings { + pub use_command_history: bool, +} + +impl Settings for CommandPaletteSettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + Self { + use_command_history: content + .command_palette + .as_ref() + .and_then(|settings| settings.use_command_history) + .expect("missing default for command_palette.use_command_history"), + } + } +} diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index 759d1204ee..3fb8c83c83 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -86,6 +86,12 @@ impl CommandPaletteDB { .await } + query! { + pub async fn delete_command_history(command_name: String) -> Result<()> { + DELETE FROM command_invocations WHERE command_name = (?) + } + } + #[cfg(test)] query! { pub(crate) fn get_last_invoked(command: &str) -> Result> { @@ -233,6 +239,33 @@ mod tests { assert_eq!(commands.as_slice()[1].invocations, 1); } + #[gpui::test] + async fn test_deletes_all_history_for_one_command() { + let db = CommandPaletteDB::open_test_db("test_deletes_all_history_for_one_command").await; + db.write_command_invocation("editor: backspace", "back") + .await + .unwrap(); + db.write_command_invocation("editor: backspace", "backspace") + .await + .unwrap(); + db.write_command_invocation("go to line: toggle", "line") + .await + .unwrap(); + let remaining_usage = db.get_command_usage("go to line: toggle").unwrap().unwrap(); + + for _ in 0..2 { + db.delete_command_history("editor: backspace".to_string()) + .await + .unwrap(); + + assert_eq!( + db.list_commands_used().unwrap(), + vec![remaining_usage.clone()] + ); + assert_eq!(db.list_recent_queries().unwrap(), vec!["line"]); + } + } + #[gpui::test] async fn test_handles_max_invocation_entries() { let db = CommandPaletteDB::open_test_db("test_handles_max_invocation_entries").await; diff --git a/crates/command_palette_hooks/Cargo.toml b/crates/command_palette_hooks/Cargo.toml index 6ba771562d..0a618a10c4 100644 --- a/crates/command_palette_hooks/Cargo.toml +++ b/crates/command_palette_hooks/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/command_palette_hooks.rs" doctest = false +test = false [dependencies] collections.workspace = true diff --git a/crates/component/Cargo.toml b/crates/component/Cargo.toml index 4ca95cbbbd..84cb5efa8a 100644 --- a/crates/component/Cargo.toml +++ b/crates/component/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/component.rs" +test = false [dependencies] collections.workspace = true diff --git a/crates/component_preview/Cargo.toml b/crates/component_preview/Cargo.toml index 8b7ad1bc50..c805fb4bf3 100644 --- a/crates/component_preview/Cargo.toml +++ b/crates/component_preview/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/component_preview.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/component_preview/examples/component_preview.rs b/crates/component_preview/examples/component_preview.rs index a65821d071..0af577b6b4 100644 --- a/crates/component_preview/examples/component_preview.rs +++ b/crates/component_preview/examples/component_preview.rs @@ -44,7 +44,7 @@ fn main() { ReqwestClient::user_agent("component_preview").expect("Failed to create HTTP client"); cx.set_http_client(Arc::new(http_client)); - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); ::set_global(fs.clone(), cx); settings::init(cx); diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index e2c7380cf1..01141ce862 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/context_server.rs" +doctest = false [features] test-support = ["gpui/test-support"] diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index dfb8ebcdb7..02b319c189 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -246,7 +246,7 @@ mod tests { use lsp::Uri; use project::Project; use serde_json::json; - use settings::{AllLanguageSettingsContent, DelayMs, SettingsStore}; + use settings::{AllLanguageSettingsContent, DelayMs, SettingsStore, SplicingVec}; use std::future::Future; use util::{ path, @@ -979,7 +979,7 @@ mod tests { settings .edit_predictions .get_or_insert(Default::default()) - .disabled_globs = Some(vec![".env*".to_string()]); + .disabled_globs = Some(SplicingVec::from(vec![".env*".to_string()])); }); let (copilot, copilot_lsp) = Copilot::fake(cx); diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 92073f5c0b..9b9b208a1b 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -279,8 +279,14 @@ impl Model { self.capabilities.limits.max_context_window_tokens as u64 } - pub fn max_output_tokens(&self) -> usize { - self.capabilities.limits.max_output_tokens + pub fn max_prompt_tokens(&self) -> Option { + let limit = self.capabilities.limits.max_prompt_tokens; + (limit > 0).then_some(limit) + } + + pub fn max_output_tokens(&self) -> Option { + let limit = self.capabilities.limits.max_output_tokens as u64; + (limit > 0).then_some(limit) } pub fn supports_tools(&self) -> bool { @@ -356,6 +362,8 @@ pub struct Request { pub temperature: f32, pub model: String, pub messages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/copilot_chat/src/model.rs b/crates/copilot_chat/src/model.rs index bbd12c4d5a..e778a18258 100644 --- a/crates/copilot_chat/src/model.rs +++ b/crates/copilot_chat/src/model.rs @@ -130,6 +130,16 @@ impl LanguageModel for CopilotChatLanguageModel { self.model.max_token_count() } + fn max_input_tokens(&self) -> u64 { + self.model + .max_prompt_tokens() + .unwrap_or_else(|| self.model.max_token_count()) + } + + fn max_output_tokens(&self) -> Option { + self.model.max_output_tokens() + } + fn stream_completion( &self, request: LanguageModelRequest, @@ -161,6 +171,12 @@ impl LanguageModel for CopilotChatLanguageModel { let copilot_chat = self.copilot_chat.clone(); let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { + let max_output_tokens = model + .max_output_tokens() + .or(request.max_output_tokens) + .ok_or_else(|| { + anyhow!("Copilot did not provide an output limit for this model") + })?; let effort = request .thinking_effort .as_ref() @@ -170,7 +186,7 @@ impl LanguageModel for CopilotChatLanguageModel { request, model.id().to_string(), 0.0, - model.max_output_tokens() as u64, + max_output_tokens, if model.supports_adaptive_thinking() { AnthropicModelMode::Thinking { budget_tokens: None, @@ -180,7 +196,7 @@ impl LanguageModel for CopilotChatLanguageModel { budget_tokens: compute_thinking_budget( model.min_thinking_budget(), model.max_thinking_budget(), - model.max_output_tokens() as u32, + max_output_tokens.min(u32::MAX as u64) as u32, ), } } else { @@ -814,6 +830,9 @@ fn into_copilot_chat( model: &CopilotChatModel, request: LanguageModelRequest, ) -> Result { + let max_tokens = request + .max_output_tokens + .and_then(|_| request.effective_max_output_tokens(model.max_output_tokens())); let temperature = request.temperature; let tool_choice = request.tool_choice; let thinking_allowed = request.thinking_allowed; @@ -1008,6 +1027,7 @@ fn into_copilot_chat( temperature: temperature.unwrap_or(0.1), model: model.id().to_string(), messages, + max_tokens, tools, tool_choice: tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => ToolChoice::Auto, @@ -1015,11 +1035,13 @@ fn into_copilot_chat( LanguageModelToolChoice::None => ToolChoice::None, }), thinking_budget: if thinking_allowed && model.supports_thinking() { - compute_thinking_budget( - model.min_thinking_budget(), - model.max_thinking_budget(), - model.max_output_tokens() as u32, - ) + model.max_output_tokens().or(max_tokens).and_then(|limit| { + compute_thinking_budget( + model.min_thinking_budget(), + model.max_thinking_budget(), + limit.min(u32::MAX as u64) as u32, + ) + }) } else { None }, @@ -1064,8 +1086,12 @@ fn into_copilot_responses( ) -> Result { use copilot_responses as responses; + let max_output_tokens = request + .max_output_tokens + .and_then(|_| request.effective_max_output_tokens(model.max_output_tokens())); let LanguageModelRequest { thread_id: _, + prompt_cache_key: _, prompt_id: _, intent: _, messages, @@ -1077,6 +1103,7 @@ fn into_copilot_responses( thinking_effort, speed: _, compact_at_tokens: _, + max_output_tokens: _, } = request; let mut input_items: Vec = Vec::new(); @@ -1255,6 +1282,7 @@ fn into_copilot_responses( input: input_items, stream: model.uses_streaming(), temperature, + max_output_tokens, tools: converted_tools, tool_choice: mapped_tool_choice, reasoning: if thinking_allowed { @@ -1284,6 +1312,85 @@ mod tests { use language_model::ProviderErrorCategory; use serde_json::json; + #[gpui::test] + fn language_model_exposes_token_limits(cx: &mut gpui::TestAppContext) { + use gpui::AppContext as _; + + let copilot_chat = cx.new(|cx| { + CopilotChat::new( + Arc::new(http_client::BlockedHttpClient::new()), + Arc::new(EmptyCredentialsProvider), + crate::CopilotChatConfiguration::default(), + cx, + ) + }); + for (limits, input, context, output) in [ + ( + json!({"max_context_window_tokens": 200_000, "max_prompt_tokens": 90_000, "max_output_tokens": 16_384}), + 90_000, + 200_000, + Some(16_384), + ), + ( + json!({"max_context_window_tokens": 128_000, "max_output_tokens": 4096}), + 128_000, + 128_000, + Some(4096), + ), + ( + json!({"max_context_window_tokens": 128_000, "max_prompt_tokens": 100_000}), + 100_000, + 128_000, + None, + ), + ( + json!({"max_context_window_tokens": 128_000, "max_prompt_tokens": 0, "max_output_tokens": 0}), + 128_000, + 128_000, + None, + ), + ] { + let mut value = serde_json::to_value(test_responses_model()).unwrap(); + value["capabilities"]["limits"] = limits; + let descriptor = serde_json::from_value(value).unwrap(); + let model = create_language_model(descriptor, copilot_chat.clone()); + assert_eq!(model.max_token_count(), context); + assert_eq!(model.max_input_tokens(), input); + assert_eq!(model.max_total_tokens(), Some(context)); + assert_eq!(model.max_output_tokens(), output); + } + } + + struct EmptyCredentialsProvider; + + impl credentials_provider::CredentialsProvider for EmptyCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + Box::pin(async { Ok(None) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + _username: &'a str, + _password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + } + fn map_events(events: Vec) -> Vec { futures::executor::block_on(async { CopilotResponsesEventMapper::new() @@ -1523,6 +1630,54 @@ mod tests { ); } + #[test] + fn request_output_limits_reach_copilot_payloads() -> Result<()> { + let model = test_responses_model(); + for (limit, expected) in [ + (None, None), + (Some(1024), Some(1024)), + (Some(u64::MAX), Some(4096)), + ] { + let request = LanguageModelRequest { + max_output_tokens: limit, + ..Default::default() + }; + let responses = serde_json::to_value(into_copilot_responses(&model, request.clone())?)?; + let chat = serde_json::to_value(into_copilot_chat(&model, request)?)?; + assert_eq!( + responses.get("max_output_tokens").cloned(), + expected.map(|value| json!(value)) + ); + assert_eq!( + chat.get("max_tokens").cloned(), + expected.map(|value| json!(value)) + ); + } + let mut value = serde_json::to_value(test_responses_model())?; + value["capabilities"]["limits"] + .as_object_mut() + .unwrap() + .remove("max_output_tokens"); + let model = serde_json::from_value(value)?; + for limit in [None, Some(1024)] { + let request = LanguageModelRequest { + max_output_tokens: limit, + ..Default::default() + }; + let responses = serde_json::to_value(into_copilot_responses(&model, request.clone())?)?; + let chat = serde_json::to_value(into_copilot_chat(&model, request)?)?; + assert_eq!( + responses.get("max_output_tokens").cloned(), + limit.map(|value| json!(value)) + ); + assert_eq!( + chat.get("max_tokens").cloned(), + limit.map(|value| json!(value)) + ); + } + Ok(()) + } + #[test] fn into_copilot_responses_replays_reasoning_details() { let model = test_responses_model(); diff --git a/crates/copilot_chat/src/responses.rs b/crates/copilot_chat/src/responses.rs index 5cb8cf0af4..597b058146 100644 --- a/crates/copilot_chat/src/responses.rs +++ b/crates/copilot_chat/src/responses.rs @@ -16,6 +16,8 @@ pub struct Request { pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/copilot_ui/Cargo.toml b/crates/copilot_ui/Cargo.toml index 315831525d..c5dc6d25cd 100644 --- a/crates/copilot_ui/Cargo.toml +++ b/crates/copilot_ui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/copilot_ui.rs" doctest = false +test = false [features] default = [] diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 1b118097cb..7d873b0f7f 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -30,3 +30,4 @@ workspace = true [lib] path = "src/crashes.rs" +doctest = false diff --git a/crates/credentials_provider/Cargo.toml b/crates/credentials_provider/Cargo.toml index 43f1945619..36f059911e 100644 --- a/crates/credentials_provider/Cargo.toml +++ b/crates/credentials_provider/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/credentials_provider.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/debug_adapter_extension/Cargo.toml b/crates/debug_adapter_extension/Cargo.toml index 08f916eb9e..0897925f84 100644 --- a/crates/debug_adapter_extension/Cargo.toml +++ b/crates/debug_adapter_extension/Cargo.toml @@ -21,3 +21,5 @@ workspace = true [lib] path = "src/debug_adapter_extension.rs" +test = false +doctest = false diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 6e8315c7f5..a3f09d16e2 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -889,13 +889,30 @@ impl VariableList { return; }; - let variable_value = match &entry.entry { - DapEntry::Variable(dap) => dap.value.clone(), - DapEntry::Watcher(watcher) => watcher.value.to_string(), + match &entry.entry { + DapEntry::Variable(dap) => { + let fallback_value = dap.value.clone(); + let expression = dap + .evaluate_name + .clone() + .unwrap_or_else(|| dap.name.clone()); + let frame_id = self.selected_stack_frame_id; + let task = self.session.update(cx, |session, cx| { + session.evaluate_variable_value(expression, frame_id, cx) + }); + cx.spawn(async move |_, cx| { + let value = task.await.unwrap_or(fallback_value); + cx.update(|cx| { + cx.write_to_clipboard(ClipboardItem::new_string(value)); + }); + }) + .detach(); + } + DapEntry::Watcher(watcher) => { + cx.write_to_clipboard(ClipboardItem::new_string(watcher.value.to_string())); + } DapEntry::Scope(_) => return, - }; - - cx.write_to_clipboard(ClipboardItem::new_string(variable_value)); + } } fn edit_variable(&mut self, _: &EditVariable, window: &mut Window, cx: &mut Context) { diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 5b88637306..9d5062a445 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -2592,3 +2592,58 @@ async fn test_refresh_watchers(executor: BackgroundExecutor, cx: &mut TestAppCon assert_eq!(3, watcher.variables_reference); }); } + +#[gpui::test] +async fn test_evaluate_variable_value_uses_clipboard_context( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "index.js": "const debug = { foo: 1 };", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let session = start_debug_session(&workspace, cx, |client| { + client.on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_clipboard_context: Some(true), + ..Default::default() + }) + }); + }) + .unwrap(); + + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + client.on_request::(move |_, args| { + assert_eq!("debug", args.expression); + assert_eq!(Some(dap::EvaluateArgumentsContext::Clipboard), args.context); + + Ok(dap::EvaluateResponse { + result: "full value".to_string(), + type_: None, + presentation_hint: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + value_location_reference: None, + }) + }); + + let value = session + .update(cx, |session, cx| { + session.evaluate_variable_value("debug".to_string(), Some(1), cx) + }) + .await; + + assert_eq!(Some("full value".to_string()), value); +} diff --git a/crates/deepseek/Cargo.toml b/crates/deepseek/Cargo.toml index 25e8f2f25c..451f9752e4 100644 --- a/crates/deepseek/Cargo.toml +++ b/crates/deepseek/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/deepseek.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index 95cb4523df..a12440f49f 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -50,8 +50,8 @@ impl From for String { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub enum Model { - #[serde(rename = "deepseek-v4-flash")] - V4Flash, + #[serde(rename = "deepseek-flash")] + V4_1Flash, #[serde(rename = "deepseek-v4-pro")] #[default] V4Pro, @@ -67,12 +67,12 @@ pub enum Model { impl Model { pub fn default_fast() -> Self { - Model::V4Flash + Model::V4_1Flash } pub fn from_id(id: &str) -> Result { match id { - "deepseek-v4-flash" => Ok(Self::V4Flash), + "deepseek-flash" => Ok(Self::V4_1Flash), "deepseek-v4-pro" => Ok(Self::V4Pro), _ => anyhow::bail!("invalid model id {id}"), } @@ -80,7 +80,7 @@ impl Model { pub fn id(&self) -> &str { match self { - Self::V4Flash => "deepseek-v4-flash", + Self::V4_1Flash => "deepseek-flash", Self::V4Pro => "deepseek-v4-pro", Self::Custom { name, .. } => name, } @@ -88,7 +88,7 @@ impl Model { pub fn display_name(&self) -> &str { match self { - Self::V4Flash => "DeepSeek V4 Flash", + Self::V4_1Flash => "DeepSeek V4.1 Flash", Self::V4Pro => "DeepSeek V4 Pro", Self::Custom { name, display_name, .. @@ -96,16 +96,20 @@ impl Model { } } + pub fn supports_images(&self) -> bool { + matches!(self, Self::V4_1Flash) + } + pub fn max_token_count(&self) -> u64 { match self { - Self::V4Flash | Self::V4Pro => 1_000_000, + Self::V4_1Flash | Self::V4Pro => 1_000_000, Self::Custom { max_tokens, .. } => *max_tokens, } } pub fn max_output_tokens(&self) -> Option { match self { - Self::V4Flash | Self::V4Pro => Some(384_000), + Self::V4_1Flash | Self::V4Pro => Some(384_000), Self::Custom { max_output_tokens, .. } => *max_output_tokens, @@ -195,17 +199,62 @@ pub enum RequestMessage { reasoning_content: Option, }, User { - content: String, + content: MessageContent, }, System { content: String, }, Tool { - content: String, + content: MessageContent, tool_call_id: String, }, } +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(untagged)] +pub enum MessageContent { + Plain(String), + Multipart(Vec), +} + +impl MessageContent { + pub fn push_part(&mut self, part: MessagePart) { + match self { + MessageContent::Plain(text) => { + let text = std::mem::take(text); + *self = MessageContent::Multipart(vec![MessagePart::Text { text }, part]); + } + MessageContent::Multipart(parts) => parts.push(part), + } + } +} + +impl From> for MessageContent { + fn from(mut parts: Vec) -> Self { + if let [MessagePart::Text { text }] = parts.as_mut_slice() { + MessageContent::Plain(std::mem::take(text)) + } else { + MessageContent::Multipart(parts) + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(tag = "type")] +pub enum MessagePart { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image_url")] + Image { image_url: ImageUrl }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct ImageUrl { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct ToolCall { pub id: String, diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index c9d9eb5f3f..b405cd91c7 100644 --- a/crates/dev_container/Cargo.toml +++ b/crates/dev_container/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" publish.workspace = true edition.workspace = true +[lib] +doctest = false + [dependencies] anyhow.workspace = true async-tar.workspace = true diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs index 42ee6a3b90..fe6c9984ab 100644 --- a/crates/dev_container/src/devcontainer_json.rs +++ b/crates/dev_container/src/devcontainer_json.rs @@ -4,6 +4,7 @@ use crate::{command_json::CommandRunner, devcontainer_api::DevContainerError}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json_lenient::Value; use util::command::Command; +use util::redact::is_valid_environment_name; #[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)] #[serde(untagged)] @@ -285,7 +286,28 @@ impl DevContainer { )) } + pub(crate) fn validate_environment_names(&self) -> Result<(), DevContainerError> { + for (property, environment) in [ + ("containerEnv", self.container_env.as_ref()), + ("remoteEnv", self.remote_env.as_ref()), + ] { + if environment.is_some_and(|environment| { + environment + .keys() + .any(|name| !is_valid_environment_name(name)) + }) { + return Err(DevContainerError::DevContainerValidationFailed(format!( + "{property} contains an invalid environment variable name" + ))); + } + } + + Ok(()) + } + pub(crate) fn validate_devcontainer_contents(&self) -> Result<(), DevContainerError> { + self.validate_environment_names()?; + match self.build_type() { DevContainerBuildType::Image(_) => Ok(()), DevContainerBuildType::Dockerfile(_) => { @@ -1718,6 +1740,37 @@ mod test { ); } + #[test] + fn should_reject_invalid_environment_names() { + for (json, property) in [ + ( + r#"{"image":"ubuntu","containerEnv":{"":"secret"}}"#, + "containerEnv", + ), + ( + r#"{"image":"ubuntu","containerEnv":{"NAME=VALUE":"secret"}}"#, + "containerEnv", + ), + ( + r#"{"image":"ubuntu","remoteEnv":{"LINE\nBREAK":"secret"}}"#, + "remoteEnv", + ), + ( + r#"{"image":"ubuntu","remoteEnv":{"NAME\u0000NUL":"secret"}}"#, + "remoteEnv", + ), + ] { + let devcontainer = deserialize_devcontainer_json(json).expect("valid JSON"); + + assert_eq!( + devcontainer.validate_environment_names(), + Err(DevContainerError::DevContainerValidationFailed(format!( + "{property} contains an invalid environment variable name" + ))) + ); + } + } + #[test] fn should_pass_validation_with_workspace_folder_for_docker_compose() { let given_image_container_json = r#" diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index cd4f24dabe..a71eda5ca5 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -2731,6 +2731,9 @@ pub(crate) async fn spawn_dev_container( .await?; devcontainer_manifest.parse_nonremote_vars()?; + devcontainer_manifest + .dev_container() + .validate_environment_names()?; log::debug!("Checking for existing container"); if let Some(devcontainer) = devcontainer_manifest diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 682f5b5617..903115f81b 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -64,11 +64,9 @@ impl DiagnosticRenderer { } else { append_source_and_code(&mut markdown, entry.diagnostic); - if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 { - markdown.push_str(&format!( - " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))" - )); - } + markdown.push_str(&format!( + " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))" + )); results.push(DiagnosticBlock { initial_range: entry.range.clone(), severity: entry.diagnostic.severity, diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 47d96d8791..a18719d55e 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -11,7 +11,7 @@ use editor::{ }; use gpui::{TestAppContext, VisualTestContext}; use indoc::indoc; -use language::{DiagnosticSourceKind, Rope}; +use language::{BufferId, Diagnostic, DiagnosticEntryRef, DiagnosticSourceKind, Rope}; use lsp::LanguageServerId; use pretty_assertions::assert_eq; use project::{ @@ -142,7 +142,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { § move occurs because `y` has type `Vec`, which does not implement § the `Copy` trait (back) a(x); § value moved here (back) - b(y); § value moved here + b(y); § value moved here (back) // comment 1 // comment 2 c(y); @@ -222,7 +222,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { § move occurs because `y` has type `Vec`, which does not implement § the `Copy` trait (back) a(x); § value moved here (back) - b(y); § value moved here + b(y); § value moved here (back) // comment 1 // comment 2 c(y); @@ -313,7 +313,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { § move occurs because `y` has type `Vec`, which does not implement § the `Copy` trait (back) a(x); § value moved here (back) - b(y); § value moved here + b(y); § value moved here (back) // comment 1 // comment 2 c(y); @@ -433,7 +433,7 @@ async fn test_diagnostics_with_folds(cx: &mut TestAppContext) { indoc::indoc! { "§ main.js § ----- - function test() { § method `test` defined here + function test() { § method `test` defined here (back) return 1 }; @@ -1347,6 +1347,62 @@ async fn test_diagnostics_with_links(cx: &mut TestAppContext) { cx.update_editor(|editor, _, _| assert!(editor.hover_state.diagnostic_popover.is_some())) } +#[gpui::test] +fn test_nearby_related_diagnostic_links_back_to_primary(cx: &mut TestAppContext) { + init_test(cx); + + let buffer_id = BufferId::new(1).expect("test buffer ID should be nonzero"); + let group_id = 1; + let diagnostic_group = [ + DiagnosticEntryRef { + range: text::Point::new(10, 4)..text::Point::new(10, 16), + diagnostic: &Diagnostic { + message: "the trait bound `(): HasTest` is not satisfied".into(), + is_primary: true, + group_id, + ..Default::default() + }, + }, + DiagnosticEntryRef { + range: text::Point::new(13, 0)..text::Point::new(13, 22), + diagnostic: &Diagnostic { + message: "required by a bound in `Test::TestAssoc`".into(), + group_id, + ..Default::default() + }, + }, + ]; + + let primary_index = diagnostic_group + .iter() + .position(|entry| entry.diagnostic.is_primary) + .expect("test diagnostic group should contain a primary diagnostic"); + let expected_back_link = + format!("([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_index}))"); + + cx.update(|cx| { + let blocks = diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( + diagnostic_group.to_vec(), + buffer_id, + None, + None, + cx, + ); + + let [_primary_block, related_block] = blocks.as_slice() else { + panic!("expected one primary and one related diagnostic block"); + }; + + let markdown = related_block.markdown.read(cx).source(); + + assert!( + markdown.contains(&expected_back_link), + "related diagnostic should link back to the primary diagnostic, got: {}", + markdown + ); + }); +} + #[gpui::test] async fn test_markup_content_diagnostic_messages_render_as_markdown(cx: &mut TestAppContext) { init_test(cx); @@ -2085,9 +2141,9 @@ async fn test_buffer_diagnostics(cx: &mut TestAppContext) { § the `Copy` trait (back) let y = vec![]; § move occurs because `y` has type `Vec`, which does not implement - § the `Copy` trait - a(x); § value moved here - b(y); § value moved here + § the `Copy` trait (back) + a(x); § value moved here (back) + b(y); § value moved here (back) c(y); § use of moved value § value used here after move @@ -2221,7 +2277,7 @@ async fn test_buffer_diagnostics_without_warnings(cx: &mut TestAppContext) { § move occurs because `x` has type `Vec`, which does not implement § the `Copy` trait (back) let y = vec![]; - a(x); § value moved here + a(x); § value moved here (back) b(y); c(y); d(x); diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index d8a26c016c..e86e3f5a48 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/edit_prediction.rs" +doctest = false [features] cli-support = [] diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index c11542125e..0f35f75b3c 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -17,7 +17,7 @@ use cloud_llm_client::{ }; use db::AppDatabase; use edit_prediction_types::EditPredictionRequestTrigger; -use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _, FeatureFlagsSettings}; +use feature_flags::FeatureFlagsSettings; use futures::{ AsyncReadExt, FutureExt, StreamExt, channel::{mpsc, oneshot}, diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index 48b7381020..b19c2612b2 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -66,10 +66,7 @@ pub fn init(cx: &mut App) -> EpAppState { cx.set_global(app_db); let git_binary_path = None; - let fs = Arc::new(RealFs::new( - git_binary_path, - cx.background_executor().clone(), - )); + let fs = RealFs::new(git_binary_path, cx.background_executor().clone()); let mut languages = LanguageRegistry::new(cx.background_executor().clone()); languages.set_language_server_download_dir(paths::languages_dir().clone()); diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index 8f0632aad0..7f40db303a 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/edit_prediction_context.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/edit_prediction_metrics/Cargo.toml b/crates/edit_prediction_metrics/Cargo.toml index f046b3f110..5c31f9bd17 100644 --- a/crates/edit_prediction_metrics/Cargo.toml +++ b/crates/edit_prediction_metrics/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/edit_prediction_metrics.rs" +doctest = false [features] tree-sitter = ["dep:tree-sitter"] diff --git a/crates/edit_prediction_types/Cargo.toml b/crates/edit_prediction_types/Cargo.toml index 4de0c9f62c..baa4af0b20 100644 --- a/crates/edit_prediction_types/Cargo.toml +++ b/crates/edit_prediction_types/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/edit_prediction_types.rs" +test = false +doctest = false [dependencies] client.workspace = true diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index 911cd359c0..d0e892024c 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -36,7 +36,6 @@ menu.workspace = true multi_buffer.workspace = true paths.workspace = true project.workspace = true -regex.workspace = true settings.workspace = true telemetry.workspace = true text.workspace = true diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 0a90a90b76..6a5cc13b8c 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -23,13 +23,11 @@ use language::{ }, }; use project::{DisableAiSettings, Project}; -use regex::Regex; -use settings::{Settings, SettingsStore, update_settings_file}; -use std::{ - rc::Rc, - sync::{Arc, LazyLock}, - time::Duration, +use settings::{ + Settings, SettingsContent, SettingsStore, SplicingVec, find_value_range_in_json_text, + update_settings_file, }; +use std::{ops::Range, rc::Rc, sync::Arc, time::Duration}; use ui::{ Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*, @@ -822,7 +820,12 @@ impl EditPredictionButton { ContextMenuEntry::new("Subtle") .toggleable(IconPosition::Start, subtle_mode) .documentation_aside(DocumentationSide::Left, move |_| { - Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element() + Label::new(concat!( + "Display predictions inline only when holding a modifier key (", + ui::alt_key_name!(), + " by default)." + )) + .into_any_element() }) .handler({ let fs = fs.clone(); @@ -1397,6 +1400,25 @@ impl StatusItemView for EditPredictionButton { } } +fn initialize_disabled_globs_setting(file: &mut SettingsContent) { + file.project + .all_languages + .edit_predictions + .get_or_insert_with(Default::default) + .disabled_globs + .get_or_insert_with(|| SplicingVec::from(vec![SplicingVec::REST.to_string()])); +} + +fn disabled_globs_content_range(text: &str) -> Option> { + let array_range = find_value_range_in_json_text(text, &["edit_predictions", "disabled_globs"])?; + let content = text + .get(array_range.clone())? + .strip_prefix('[')? + .strip_suffix(']')?; + let start = array_range.start + 1 + (content.len() - content.trim_start().len()); + Some(start..start + content.trim().len()) +} + async fn open_disabled_globs_setting_in_editor( workspace: WeakEntity, cx: &mut AsyncWindowContext, @@ -1418,16 +1440,8 @@ async fn open_disabled_globs_setting_in_editor( let settings = cx.global::(); - // Ensure that we always have "edit_predictions { "disabled_globs": [] }" let Some(edits) = settings - .edits_for_update(&text, |file| { - file.project - .all_languages - .edit_predictions - .get_or_insert_with(Default::default) - .disabled_globs - .get_or_insert_with(Vec::new); - }) + .edits_for_update(&text, initialize_disabled_globs_setting) .log_err() else { return; @@ -1444,16 +1458,7 @@ async fn open_disabled_globs_setting_in_editor( let text = item.buffer().read(cx).snapshot(cx).text(); - static DISABLED_GLOBS_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r#""disabled_globs":\s*\[\s*(?P(?:.|\n)*?)\s*\]"#).unwrap() - }); - // Only capture [...] - let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| { - captures - .name("content") - .map(|inner_match| inner_match.start()..inner_match.end()) - }); - if let Some(range) = range { + if let Some(range) = disabled_globs_content_range(&text) { let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end); item.change_selections( SelectionEffects::scroll(Autoscroll::newest()), @@ -1681,7 +1686,118 @@ fn copilot_settings_url(enterprise_uri: Option<&str>) -> Arc { #[cfg(test)] mod tests { use super::*; + use anyhow::Context as _; use gpui::TestAppContext; + use settings::RootUserSettings; + + #[test] + fn test_disabled_globs_selection_and_replacement() -> Result<()> { + for contents in [ + r#""[.][.][.]""#, + r#""**/[ab]/**", "...""#, + r#""escaped\"]", "backslash\\", "...""#, + r#""escaped\\]", "line\nbreak]", "unicode\u005d""#, + "\n \"é[ab]\",\n \"...\"\n ", + "// Ignore filenames containing a \" character\n \"**/private/**\"\n", + "/* Ignore \" and ] */\n \"**/private/**\"\n", + "\"**/private/**\", // Keep \" and ]\n \"...\"\n", + r#""**/build/**", "...""#, + "", + "\n ", + ] { + let mut text = format!( + r#"// "disabled_globs": ["commented/**"] +{{"languages":{{"Rust":{{"disabled_globs":["nested/**"]}}}}, +/* "disabled_globs": ["commented/**"] */ +"edit_predictions":{{"disabled_globs":[{contents}],"mode":"subtle"}}}}"# + ); + let mut expected = SettingsContent::parse_json_with_comments(&text)?; + let range = disabled_globs_content_range(&text).context("disabled globs selection")?; + assert_eq!(text.get(range.clone()), Some(contents.trim())); + + text.replace_range(range, r#""**/replacement/**", "...""#); + expected + .project + .all_languages + .edit_predictions + .as_mut() + .context("edit prediction settings")? + .disabled_globs = Some(SplicingVec::from(vec![ + "**/replacement/**".to_string(), + SplicingVec::REST.to_string(), + ])); + assert_eq!(SettingsContent::parse_json_with_comments(&text)?, expected); + } + Ok(()) + } + + #[gpui::test] + fn test_initialize_disabled_globs_setting(cx: &mut App) { + let store = SettingsStore::new(cx, &settings::default_settings()); + + let cases: &[(&str, &[&str])] = &[ + ("", &[SplicingVec::REST]), + (r#"{}"#, &[SplicingVec::REST]), + (r#"{"edit_predictions":{}}"#, &[SplicingVec::REST]), + ( + r#"{"edit_predictions":{"mode":"subtle"}}"#, + &[SplicingVec::REST], + ), + (r#"{"edit_predictions":{"disabled_globs":[]}}"#, &[]), + ( + r#"{"edit_predictions":{"disabled_globs":["**/build/**"]}}"#, + &["**/build/**"], + ), + ( + r#"{"edit_predictions":{"disabled_globs":["...","**/build/**"]}}"#, + &[SplicingVec::REST, "**/build/**"], + ), + ( + r#"{"edit_predictions":{"disabled_globs":["[.][.][.]"]}}"#, + &["[.][.][.]"], + ), + ]; + for &(content, expected_globs) in cases { + let original = if content.is_empty() { "{}" } else { content }; + let mut expected_content = SettingsContent::parse_json_with_comments(original) + .expect("settings content parses"); + let original_globs = &mut expected_content + .project + .all_languages + .edit_predictions + .get_or_insert_with(Default::default) + .disabled_globs; + let already_configured = original_globs.is_some(); + *original_globs = Some(SplicingVec::from( + expected_globs + .iter() + .map(|glob| glob.to_string()) + .collect::>(), + )); + + let edits = store + .edits_for_update(content, initialize_disabled_globs_setting) + .expect("settings edits are generated"); + assert_eq!(edits.is_empty(), already_configured, "{content}"); + let mut updated = content.to_string(); + for (range, replacement) in edits { + updated.replace_range(range, &replacement); + } + assert_eq!( + SettingsContent::parse_json_with_comments(&updated) + .expect("updated settings parse"), + expected_content, + "{content}", + ); + assert!( + store + .edits_for_update(&updated, initialize_disabled_globs_setting) + .expect("repeated settings edits are generated") + .is_empty(), + "{content}", + ); + } + } #[gpui::test] async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) { diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 95f961be80..4a0492af4b 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -140,7 +140,7 @@ pub struct ConfirmCodeAction { } /// Toggles comment markers for the selected lines. -#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] pub struct ToggleComments { @@ -148,6 +148,22 @@ pub struct ToggleComments { pub advance_downwards: bool, #[serde(default)] pub ignore_indent: bool, + /// Whether to add comment markers to blank lines inside a multi-line + /// selection. A line of only whitespace counts as blank. Defaults to true. + #[serde(default = "default_true")] + pub comment_empty_lines: bool, +} + +// `Default` is written out rather than derived because `comment_empty_lines` +// defaults to true. +impl Default for ToggleComments { + fn default() -> Self { + Self { + advance_downwards: false, + ignore_indent: false, + comment_empty_lines: true, + } + } } /// Toggles block comment markers for the selected text. @@ -938,6 +954,8 @@ actions!( UnwrapSyntaxNode, /// Wraps selections in tag specified by language. WrapSelectionsInTag, + /// Wraps selections in an expanded Emmet abbreviation. + WrapWithAbbreviation, /// Aligns selections from different rows into the same column AlignSelections, /// Saves the current location to navigation history. diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs index fbe58b06ab..b0919d5c23 100644 --- a/crates/editor/src/clangd_ext.rs +++ b/crates/editor/src/clangd_ext.rs @@ -51,6 +51,7 @@ pub fn switch_source_header( let request = proto::LspExtSwitchSourceHeader { project_id, buffer_id: buffer_id.to_proto(), + server_id: server_to_query.to_proto(), }; let response = client .request(request) @@ -63,7 +64,9 @@ pub fn switch_source_header( project.request_lsp( buffer, project::LanguageServerToQuery::Other(server_to_query), - project::lsp_store::lsp_ext_command::SwitchSourceHeader, + project::lsp_store::lsp_ext_command::SwitchSourceHeader { + server_id: server_to_query, + }, cx, ) }) diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 209fdf13bd..d635a02ff3 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1172,7 +1172,6 @@ impl CompletionsMenu { .start_slot::(start_slot) .child( h_flex() - .min_w_0() .flex_grow_1() .when(left_aligned_suffix, |this| this.justify_start()) .when(right_aligned_suffix, |this| { diff --git a/crates/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs index d8bd285e15..c8ba5be703 100644 --- a/crates/editor/src/code_lens.rs +++ b/crates/editor/src/code_lens.rs @@ -616,6 +616,10 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> R let text_style = &cx.editor_style.text; let font = text_style.font(); let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9; + let colors = cx.app.theme().colors(); + let code_lens_color = colors + .editor_code_lens_foreground + .unwrap_or(colors.text_muted); for (i, (title, action)) in resolved_items.into_iter().enumerate() { if i > 0 { @@ -623,7 +627,7 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> R div() .font(font.clone()) .text_size(font_size) - .text_color(cx.app.theme().colors().text_muted) + .text_color(code_lens_color) .child(CODE_LENS_SEPARATOR) .into_any_element(), ); @@ -634,7 +638,7 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> R .id(ElementId::from(i)) .font(font.clone()) .text_size(font_size) - .text_color(cx.app.theme().colors().text_muted) + .text_color(code_lens_color) .child(title.clone()) .when_some(action, |code_lens_div, action| { let position = line.position; diff --git a/crates/editor/src/columnar_selection.rs b/crates/editor/src/columnar_selection.rs new file mode 100644 index 0000000000..99654c89c2 --- /dev/null +++ b/crates/editor/src/columnar_selection.rs @@ -0,0 +1,2852 @@ +use language::{Bias, LanguageAwareStyling, Point}; +use multi_buffer::{MBTextSummary, MultiBufferRow}; +use std::ops::Range; +use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete}; + +use crate::{ + DisplayPoint, + display_map::{DisplaySnapshot, FoldPoint, Highlights, TabPoint, ToDisplayPoint as _}, +}; + +pub(crate) struct ColumnarSelectionRows<'a> { + snapshot: &'a DisplaySnapshot, +} + +impl<'a> ColumnarSelectionRows<'a> { + pub(crate) fn new(snapshot: &'a DisplaySnapshot) -> Self { + Self { snapshot } + } + + pub(crate) fn columns_for_ranges(&mut self, ranges: &[Range]) -> Vec> { + let tabs = self.snapshot.tab_snapshot(); + let mut endpoints = ranges + .iter() + .enumerate() + .flat_map(|(index, range)| { + [(range.start, false), (range.end, true)] + .map(|(point, end)| (tabs.point_to_tab_point(point, Bias::Left), index, end)) + }) + .collect::>(); + endpoints.sort_unstable(); + let mut answers = vec![0..0; ranges.len()]; + for endpoints in endpoints.chunk_by(|left, right| left.0.row() == right.0.row()) { + let mut text = self.row(endpoints[0].0.row()); + let mut cursor = text.forward_cursor(); + for &(point, index, end) in endpoints { + text.floor_forward(&mut cursor, point.column()); + if end { + answers[index].end = cursor.column; + } else { + answers[index].start = cursor.column; + } + } + } + for answer in &mut answers { + *answer = answer.start.min(answer.end)..answer.start.max(answer.end); + } + answers + } + + pub(crate) fn points_for_rows( + &mut self, + queries: &[(u32, Range)], + ) -> Vec> { + let tabs = self.snapshot.tab_snapshot(); + let max_row = tabs.max_point().row(); + let mut endpoints = Vec::new(); + for (index, (row, columns)) in queries.iter().enumerate() { + if *row <= max_row && columns.start <= columns.end { + endpoints.push((*row, columns.start, false, index)); + if columns.start != columns.end { + endpoints.push((*row, columns.end, true, index)); + } + } + } + endpoints.sort_unstable(); + let mut answers = vec![None::<(Point, Point)>; queries.len()]; + for endpoints in endpoints.chunk_by(|left, right| left.0 == right.0) { + let row = endpoints[0].0; + let length = tabs.line_len(row); + let mut text = self.row(row); + let mut cursor = text.cursor(0); + let mut column = 0; + let mut preceding = None; + let mut buffer_cursor = None; + for &(_, goal, end, index) in endpoints { + let columns = &queries[index].1; + if (end && answers[index].is_none()) + || (!end && columns.start != columns.end && goal >= length) + { + continue; + } + text.advance_columns(&mut cursor, goal - column); + column = goal; + if !end && columns.start != columns.end && cursor.byte() == length { + continue; + } + let point = self.point_for_cursor( + row, + &mut text, + cursor.clone(), + preceding, + &mut buffer_cursor, + ); + preceding = Some((cursor.byte(), point)); + answers[index] = if end { + answers[index] + .zip(point) + .map(|((start, _), end)| (start.min(end), start.max(end))) + } else { + point.map(|point| (point, point)) + }; + } + } + answers + } + + #[cfg(test)] + pub(crate) fn columns_for_range(&mut self, range: Range) -> Range { + self.columns_for_ranges(&[range]).pop().expect("one range") + } + + #[cfg(test)] + pub(crate) fn points_for_row( + &mut self, + row: u32, + columns: &Range, + ) -> Option<(Point, Point)> { + self.points_for_rows(&[(row, columns.clone())]) + .pop() + .flatten() + } + + fn point_for_cursor( + &self, + row: u32, + text: &mut RowText<'a>, + mut cursor: BoundaryCursor, + preceding: Option<(u32, Option)>, + buffer_cursor: &mut Option>, + ) -> Option { + let frontier = preceding.map_or(0, |(byte, _)| byte); + let mut backward_buffer_cursor = None; + if let Some(buffer_cursor) = buffer_cursor { + buffer_cursor.reverse = None; + } + loop { + if let Some((byte, point)) = preceding + && cursor.byte() <= byte + { + return point; + } + let tab_point = TabPoint::new(row, cursor.byte()); + let tabs = self.snapshot.tab_snapshot(); + let fold_point = tabs.tab_point_to_fold_point(tab_point, Bias::Left).0; + if let Some(range) = self + .snapshot + .fold_snapshot() + .placeholder_range_at(fold_point) + && range.start < fold_point + { + let start = tabs.fold_point_to_tab_point(range.start); + text.retreat_to(&mut cursor, start.column().max(frontier)); + continue; + } + let point = tabs.tab_point_to_point(tab_point, Bias::Left); + let display_point = point.to_display_point(self.snapshot); + let hidden = self.snapshot.is_block_line(display_point.row()); + let canonical = tabs.point_to_tab_point(point, Bias::Left); + if !hidden + && canonical == tab_point + && self.floor_buffer_point(buffer_cursor, &mut backward_buffer_cursor, point) + == point + { + return Some(point); + } + let preceding = if hidden && canonical == tab_point { + let hidden_start = self.snapshot.display_point_to_fold_point( + DisplayPoint::new(display_point.row(), 0), + Bias::Left, + ); + let hidden_start = tabs.fold_point_to_tab_point(hidden_start); + if hidden_start.row() != row { + return None; + } + if hidden_start < tab_point { + hidden_start + } else { + TabPoint::new(row, hidden_start.column().checked_sub(1)?) + } + } else if canonical < tab_point { + canonical + } else { + let previous = if let Some(byte) = point.column.checked_sub(1) { + self.floor_buffer_point( + buffer_cursor, + &mut backward_buffer_cursor, + Point::new(point.row, byte), + ) + } else { + let row = point.row.checked_sub(1)?; + Point::new( + row, + self.snapshot + .buffer_snapshot() + .line_len(MultiBufferRow(row)), + ) + }; + tabs.point_to_tab_point(previous, Bias::Left) + }; + if preceding.row() != row { + return None; + } + if preceding < tab_point { + text.retreat_to(&mut cursor, preceding.column().max(frontier)); + } else { + text.previous_boundary(&mut cursor)?; + } + } + } + + fn floor_buffer_point( + &self, + forward: &mut Option>, + backward: &mut Option>, + point: Point, + ) -> Point { + if forward.as_ref().is_none_or(|cursor| cursor.row < point.row) { + *backward = forward.replace(self.buffer_row_cursor(point.row)); + } + let cursor = if let Some(cursor) = forward.as_mut().filter(|cursor| cursor.row == point.row) + { + cursor + } else { + if backward + .as_ref() + .is_none_or(|cursor| cursor.row != point.row) + { + let cursor = backward.insert(self.buffer_row_cursor(point.row)); + cursor.reverse = Some(cursor.text.floor_at(point.column)); + } + backward.as_mut().expect("backward buffer row initialized") + }; + if cursor.reverse.is_none() { + cursor.text.floor_forward(&mut cursor.forward, point.column); + cursor.reverse = Some(cursor.forward.boundary.clone()); + } + let boundary = cursor + .reverse + .as_mut() + .expect("buffer boundary initialized"); + cursor.text.retreat_to(boundary, point.column); + Point::new(point.row, boundary.byte()) + } + + fn buffer_row_cursor(&self, row: u32) -> BufferRowCursor<'a> { + let text = self.buffer_row(row); + let forward = text.forward_cursor(); + BufferRowCursor { + row, + text, + forward, + reverse: None, + } + } + + fn buffer_row(&self, row: u32) -> RowText<'a> { + let buffer = self.snapshot.buffer_snapshot(); + let length = buffer.line_len(MultiBufferRow(row)); + let range = Point::new(row, 0)..Point::new(row, length); + let summary = buffer.text_summary_for_range::(range.clone()); + if summary.len.0 == summary.chars { + RowText::Ascii(length) + } else { + RowText::new(length, buffer.text_for_range(range)) + } + } + + fn row(&self, row: u32) -> RowText<'a> { + let tabs = self.snapshot.tab_snapshot(); + let folds = self.snapshot.fold_snapshot(); + let summary = folds.text_summary_for_range( + FoldPoint::new(row, 0)..FoldPoint::new(row, folds.line_len(row)), + ); + let length = tabs.line_len(row); + if summary.len.0 == summary.chars { + RowText::Ascii(length) + } else { + let chunks = tabs + .chunks( + TabPoint::new(row, 0)..TabPoint::new(row, length), + LanguageAwareStyling { + tree_sitter: false, + diagnostics: false, + }, + Highlights::default(), + ) + .map(|chunk| (chunk.text, chunk.is_tab)); + RowText::with_tabs(length, chunks) + } + } +} + +enum RowText<'a> { + Ascii(u32), + Unicode(RowChunks<'a>), +} + +struct RowChunks<'a> { + length: u32, + chunks: Vec>, + remaining: Box + 'a>, + #[cfg(test)] + context_bytes: usize, +} + +#[derive(Clone, Copy)] +struct TextChunk<'a> { + start: usize, + text: &'a str, +} + +#[derive(Clone)] +struct BoundaryCursor { + graphemes: GraphemeCursor, + chunk_index: usize, +} + +struct ForwardCursor { + boundary: BoundaryCursor, + next: BoundaryCursor, + column: u32, +} + +struct BufferRowCursor<'a> { + row: u32, + text: RowText<'a>, + forward: ForwardCursor, + reverse: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum BoundaryQuery { + Next, + Previous, + Check, +} + +impl<'a> RowText<'a> { + fn new(length: u32, chunks: impl Iterator + 'a) -> Self { + Self::with_tabs(length, chunks.map(|text| (text, false))) + } + + fn with_tabs(length: u32, chunks: impl Iterator + 'a) -> Self { + Self::Unicode(RowChunks { + length, + chunks: Vec::new(), + remaining: Box::new(chunks), + #[cfg(test)] + context_bytes: 0, + }) + } + + fn cursor(&self, byte: u32) -> BoundaryCursor { + let length = match self { + Self::Ascii(length) => *length, + Self::Unicode(chunks) => chunks.length, + }; + BoundaryCursor { + graphemes: GraphemeCursor::new(byte.min(length) as usize, length as usize, true), + chunk_index: 0, + } + } + + fn forward_cursor(&self) -> ForwardCursor { + let boundary = self.cursor(0); + ForwardCursor { + next: boundary.clone(), + boundary, + column: 0, + } + } + + fn floor_forward(&mut self, cursor: &mut ForwardCursor, byte: u32) { + debug_assert!(byte >= cursor.boundary.byte()); + if let Self::Ascii(length) = self { + cursor.column = byte.min(*length); + cursor.boundary.graphemes.set_cursor(cursor.column as usize); + return; + } + while cursor.boundary.byte() < byte { + if cursor.next.byte() == cursor.boundary.byte() + && self.next_boundary(&mut cursor.next).is_none() + { + break; + } + if cursor.next.byte() > byte { + break; + } + cursor.boundary = cursor.next.clone(); + cursor.column += 1; + } + } + + #[cfg(test)] + fn columns_for_bytes(&mut self, bytes: Range) -> Range { + let mut cursor = self.forward_cursor(); + self.floor_forward(&mut cursor, bytes.start); + let start = cursor.column; + self.floor_forward(&mut cursor, bytes.end); + start..cursor.column + } + + fn advance_columns(&mut self, cursor: &mut BoundaryCursor, count: u32) { + if let Self::Ascii(length) = self { + cursor + .graphemes + .set_cursor(cursor.byte().saturating_add(count).min(*length) as usize); + } else { + for _ in 0..count { + if self.next_boundary(cursor).is_none() { + break; + } + } + } + } + + fn floor_at(&mut self, byte: u32) -> BoundaryCursor { + let mut cursor = self.cursor(byte); + if let Self::Unicode(chunks) = self + && cursor.byte() > 0 + && cursor.byte() < chunks.length + { + cursor.chunk_index = chunks.locate(cursor.byte() as usize); + let chunk = chunks.chunk(cursor.chunk_index); + let mut offset = cursor.byte() as usize - chunk.start; + while !chunk.text.is_char_boundary(offset) { + offset -= 1; + } + cursor.graphemes.set_cursor(chunk.start + offset); + if chunks.resolve(&mut cursor, BoundaryQuery::Check).is_none() { + chunks + .resolve(&mut cursor, BoundaryQuery::Previous) + .expect("non-boundary has a preceding boundary"); + } + } + cursor + } + + fn retreat_to(&mut self, cursor: &mut BoundaryCursor, byte: u32) { + if let Self::Ascii(_) = self { + cursor + .graphemes + .set_cursor(cursor.byte().min(byte) as usize); + } else { + while cursor.byte() > byte { + if self.previous_boundary(cursor).is_none() { + break; + } + } + } + } + + fn next_boundary(&mut self, cursor: &mut BoundaryCursor) -> Option { + match self { + Self::Ascii(length) => { + if cursor.byte() >= *length { + return None; + } + cursor.graphemes.set_cursor(cursor.byte() as usize + 1); + Some(cursor.byte()) + } + Self::Unicode(chunks) => chunks.resolve(cursor, BoundaryQuery::Next), + } + } + + fn previous_boundary(&mut self, cursor: &mut BoundaryCursor) -> Option { + match self { + Self::Ascii(_) => { + let byte = cursor.byte().checked_sub(1)?; + cursor.graphemes.set_cursor(byte as usize); + Some(byte) + } + Self::Unicode(chunks) => chunks.resolve(cursor, BoundaryQuery::Previous), + } + } +} + +impl<'a> RowChunks<'a> { + fn chunk(&mut self, index: usize) -> TextChunk<'a> { + const TAB_CHARACTERS: [u8; 128] = [b'\t'; 128]; + while self.chunks.len() <= index { + let (text, is_tab) = self + .remaining + .find(|(text, _)| !text.is_empty()) + .expect("row chunks must cover the row"); + let text = if is_tab { + std::str::from_utf8(&TAB_CHARACTERS[..text.len()]).expect("tab controls are UTF-8") + } else { + text + }; + let start = self + .chunks + .last() + .map_or(0, |chunk| chunk.start + chunk.text.len()); + self.chunks.push(TextChunk { start, text }); + #[cfg(test)] + tests::ROW_WORK.with(|work| { + let (boundaries, chunks) = work.get(); + work.set((boundaries, chunks + 1)); + }); + } + self.chunks[index] + } + + fn locate(&mut self, byte: usize) -> usize { + while self + .chunks + .last() + .is_none_or(|chunk| chunk.start + chunk.text.len() <= byte) + { + self.chunk(self.chunks.len()); + } + self.chunks.partition_point(|chunk| chunk.start <= byte) - 1 + } + + fn resolve(&mut self, cursor: &mut BoundaryCursor, query: BoundaryQuery) -> Option { + #[cfg(test)] + tests::ROW_WORK.with(|work| { + let (boundaries, chunks) = work.get(); + work.set((boundaries + 1, chunks)); + }); + loop { + let offset = cursor.graphemes.cur_cursor(); + if query == BoundaryQuery::Check && (offset == 0 || offset == self.length as usize) { + return Some(offset as u32); + } + if (query == BoundaryQuery::Next && offset == self.length as usize) + || (query == BoundaryQuery::Previous && offset == 0) + { + return None; + } + let chunk = loop { + let chunk = self.chunk(cursor.chunk_index); + if offset < chunk.start + || (query == BoundaryQuery::Previous && offset == chunk.start) + { + cursor.chunk_index -= 1; + } else if offset > chunk.start + chunk.text.len() + || (query != BoundaryQuery::Previous + && offset == chunk.start + chunk.text.len()) + { + cursor.chunk_index += 1; + } else { + break chunk; + } + }; + let mut bridge = [0; 8]; + let (text, start) = + if query != BoundaryQuery::Previous && offset == chunk.start && offset > 0 { + let previous = self.chunk(cursor.chunk_index - 1); + let previous = previous.text.chars().next_back().expect("nonempty chunk"); + let following = chunk.text.chars().next().expect("nonempty chunk"); + let previous_length = previous.encode_utf8(&mut bridge).len(); + let following_length = + following.encode_utf8(&mut bridge[previous_length..]).len(); + ( + std::str::from_utf8(&bridge[..previous_length + following_length]) + .expect("encoded scalars are UTF-8"), + offset - previous_length, + ) + } else { + (chunk.text, chunk.start) + }; + loop { + let result = match query { + BoundaryQuery::Next => cursor.graphemes.next_boundary(text, start), + BoundaryQuery::Previous => cursor.graphemes.prev_boundary(text, start), + BoundaryQuery::Check => cursor + .graphemes + .is_boundary(text, start) + .map(|boundary| boundary.then_some(cursor.graphemes.cur_cursor())), + }; + match result { + Ok(boundary) => return boundary.map(|byte| byte as u32), + Err(GraphemeIncomplete::PreContext(end)) => { + let index = self.locate(end - 1); + let context = self.chunk(index); + let context_text = &context.text[..end - context.start]; + #[cfg(test)] + { + self.context_bytes += context_text.len(); + } + cursor + .graphemes + .provide_context(context_text, context.start); + } + Err(GraphemeIncomplete::NextChunk | GraphemeIncomplete::PrevChunk) => break, + Err(error) => unreachable!("row grapheme cursor: {error:?}"), + } + } + } + } +} + +impl BoundaryCursor { + fn byte(&self) -> u32 { + self.graphemes.cur_cursor() as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AddSelectionAbove, AddSelectionBelow, SelectionEffects, + display_map::{ + BlockPlacement, BlockProperties, BlockStyle, Crease, DisplayMap, FoldPlaceholder, + }, + inlays::Inlay, + test::{editor_test_context::EditorTestContext, test_font}, + }; + use collections::HashSet; + use gpui::{ + AppContext as _, BorrowAppContext as _, Element as _, Entity, Pixels, SharedString, + TestAppContext, div, px, + }; + use language::{Buffer, Capability}; + use multi_buffer::{Anchor, ExcerptRange, MultiBuffer, MultiBufferOffset, PathKey}; + use project::project_settings::DiagnosticSeverity; + use rand::prelude::*; + use settings::SettingsStore; + use std::{cell::Cell, env, iter, num::NonZeroU32, sync::Arc}; + use theme::LoadThemes; + use unicode_segmentation::UnicodeSegmentation as _; + + thread_local! { + pub(super) static ROW_WORK: Cell<(usize, usize)> = const { Cell::new((0, 0)) }; + } + + #[gpui::test] + async fn test_columnar_selection_actions_batch_work(cx: &mut TestAppContext) { + crate::editor_tests::init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let rows = [("é.", 3, 2), ("e\u{301}.", 4, 3), ("🦀.", 5, 4)]; + for count in [32, 128] { + let text = rows + .iter() + .map(|(unit, _, _)| unit.repeat(count)) + .collect::>() + .join("\n"); + for above in [false, true] { + for (nonempty, reversed) in [(false, false), (true, false), (true, true)] { + let indices = if nonempty { 0..count } else { 1..count + 1 }; + let expected = |added| { + rows.iter() + .enumerate() + .filter(|(row, _)| { + if above { + *row >= 2 - added + } else { + *row <= added + } + }) + .flat_map(|(row, &(_, stride, width))| { + separated_columnar_ranges( + row as u32, + indices.clone(), + stride, + if nonempty { width } else { 0 }, + reversed, + ) + }) + .collect::>() + }; + set_columnar_action_state(&mut cx, &text, &expected(0)); + for (direction, added) in [ + (above, 1), + (above, 2), + (above, 2), + (!above, 1), + (!above, 0), + (above, 1), + ] { + let expected = expected(added); + assert_eq!(expected.len(), (added + 1) * count); + assert_columnar_action_work(&mut cx, direction, &expected, 4 * (2 * count)); + } + } + } + } + } + + #[gpui::test] + async fn test_columnar_selection_actions_join_skipped_rows_work(cx: &mut TestAppContext) { + crate::editor_tests::init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for count in [32, 128] { + for above in [false, true] { + let row = |row| if above { 6 - row } else { row }; + let mut lines = [ + "é.".repeat(count + 1), + "é".to_owned(), + "e\u{301}.".repeat(count / 2 + 1), + String::new(), + "🦀.".to_owned(), + "🦀.".repeat(count + 1), + "e\u{301}.".repeat(count + 1), + ]; + if above { + lines.reverse(); + } + let text = lines.join("\n"); + for reversed in [false, true] { + let sources = + separated_columnar_ranges(row(0), count / 2 + 1..count + 1, 3, 2, reversed) + .into_iter() + .chain(separated_columnar_ranges( + row(2), + 1..count / 2 + 1, + 4, + 3, + reversed, + )) + .collect::>(); + let expected = |added| { + let mut expected = sources.clone(); + for (target, stride, width) in + [(5, 5, 4), (6, 4, 3)].into_iter().take(added) + { + expected.extend(separated_columnar_ranges( + row(target), + 1..count + 1, + stride, + width, + reversed, + )); + } + expected.sort_unstable_by_key(|range| range.start.min(range.end)); + expected + }; + set_columnar_action_state(&mut cx, &text, &expected(0)); + for (direction, added) in [ + (above, 1), + (above, 2), + (above, 2), + (!above, 1), + (!above, 0), + (above, 1), + ] { + let expected = expected(added); + assert_eq!(expected.len(), (added + 1) * count); + assert_columnar_action_work( + &mut cx, + direction, + &expected, + 5 * (2 * (count + 1)), + ); + } + } + } + } + } + + #[gpui::test] + async fn test_columnar_selection_actions_skip_replaced_source_rows(cx: &mut TestAppContext) { + crate::editor_tests::init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for hidden_count in [1, 128] { + let text = iter::repeat_n("éab", hidden_count as usize + 3) + .collect::>() + .join("\n"); + for above in [false, true] { + let row = |row| if above { hidden_count + 2 - row } else { row }; + for ends_inside_replacement in [false, true] { + let end_row = hidden_count + u32::from(!ends_inside_replacement); + let source = Point::new(row(0).min(row(end_row)), 2) + ..Point::new(row(0).max(row(end_row)), 3); + set_columnar_action_state(&mut cx, &text, &[source]); + let blocks = cx.update_editor(|editor, _, cx| { + let buffer = editor.buffer.read(cx).snapshot(cx); + editor.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Replace( + buffer + .anchor_after(Point::new(row(1).min(row(hidden_count)), 0)) + ..=buffer.anchor_before(Point::new( + row(1).max(row(hidden_count)), + 4, + )), + ), + height: Some(1), + style: BlockStyle::Fixed, + render: Arc::new(|_| div().into_any()), + priority: 0, + }], + None, + cx, + ) + }); + let mut expected = [0, hidden_count + 1, hidden_count + 2] + .into_iter() + .take(if ends_inside_replacement { 2 } else { 3 }) + .map(|source_row| { + Point::new(row(source_row), 2)..Point::new(row(source_row), 3) + }) + .collect::>(); + expected.sort_unstable_by_key(|range| range.start); + assert_columnar_action_work(&mut cx, above, &expected, 32); + cx.update_editor(|editor, _, cx| { + editor.remove_blocks(blocks.into_iter().collect(), None, cx); + }); + } + } + } + } + + #[gpui::test] + fn test_columnar_selection_batch_work(cx: &mut TestAppContext) { + init_test(cx); + let length = 4096; + let line = "é".repeat(length); + let snapshot = plain_state(&format!("{line}\n{line}"), cx) + .build_map(cx) + .update(cx, |map, cx| map.snapshot(cx)); + let mut query = ColumnarSelectionRows::new(&snapshot); + let mut ranges = (0..length as u32) + .flat_map(|column| { + (0..2) + .map(move |row| Point::new(row, column * 2)..Point::new(row, length as u32 * 2)) + }) + .collect::>(); + let mut expected_columns = (0..length as u32) + .flat_map(|column| [column..length as u32, column..length as u32]) + .collect::>(); + for _ in 0..2 { + ROW_WORK.set((0, 0)); + let answers = query.columns_for_ranges(&ranges); + assert_eq!(answers.len(), expected_columns.len()); + for (index, (answer, expected)) in answers.iter().zip(&expected_columns).enumerate() { + assert_eq!(answer, expected, "range {index}"); + } + let (boundaries, chunks) = ROW_WORK.get(); + assert_eq!(boundaries, 2 * length); + assert!(chunks <= 2 * length); + ranges.reverse(); + expected_columns.reverse(); + } + let requests = (0..length as u32) + .flat_map(|column| (0..2).map(move |row| (row, column..length as u32))) + .collect::>(); + let expected_points = (0..length as u32) + .flat_map(|column| { + (0..2).map(move |row| { + Some(( + Point::new(row, column * 2), + Point::new(row, length as u32 * 2), + )) + }) + }) + .collect::>(); + assert_batch_points(&mut query, requests, expected_points, 4 * length); + assert_eq!(query.columns_for_ranges(&[]), Vec::new()); + assert_eq!(query.points_for_rows(&[]), Vec::new()); + } + + #[gpui::test] + fn test_columnar_selection_batch_fold_projection(cx: &mut TestAppContext) { + init_test(cx); + let length = 128; + let prefix = "é".repeat(length); + let indicators = "🇦".repeat(length); + let mut state = plain_state(&format!("{prefix}\n{indicators}\n{indicators}"), cx); + let buffer = state.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + state.folds = vec![ + buffer.anchor_after(Point::new(0, length as u32 * 2)) + ..buffer.anchor_before(Point::new(1, 4)), + buffer.anchor_after(Point::new(1, length as u32 * 4)) + ..buffer.anchor_before(Point::new(2, 4)), + ]; + let snapshot = state.build_map(cx).update(cx, |map, cx| map.snapshot(cx)); + let visible_indicators = "🇦".repeat(length - 1); + assert_eq!( + snapshot.tab_snapshot().text(), + format!("{prefix}⋯{visible_indicators}⋯{visible_indicators}") + ); + let end_column = length as u32 * 2 + 2; + let end = Point::new(2, length as u32 * 4); + let requests = (0..=end_column) + .flat_map(|column| [(0, column..column), (0, column..end_column)]) + .collect::>(); + let expected = (0..=end_column) + .flat_map(|column| { + let point = if column <= length as u32 { + Point::new(0, column * 2) + } else if column <= length as u32 * 3 / 2 { + Point::new(0, length as u32 * 2) + } else if column < end_column { + Point::new(1, length as u32 * 4) + } else { + end + }; + [Some((point, point)), Some((point, end))] + }) + .collect::>(); + let mut query = ColumnarSelectionRows::new(&snapshot); + assert_batch_points(&mut query, requests, expected, 16 * length); + } + + #[gpui::test] + fn test_columnar_selection_batch_rejects_before_end(cx: &mut TestAppContext) { + init_test(cx); + let mut state = plain_state("x\né\n", cx); + let buffer = state.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + state.inlays.push(Inlay::mock_hint( + 0, + buffer.anchor_before(Point::new(1, 0)), + &format!("é{}\n", "a".repeat(1_048_576)), + )); + let snapshot = state.build_map(cx).update(cx, |map, cx| map.snapshot(cx)); + let mut query = ColumnarSelectionRows::new(&snapshot); + let mut requests = vec![(1, 0..u32::MAX); 4096]; + requests.extend([(2, 1..u32::MAX), (0, 2..u32::MAX), (u32::MAX, 0..0)]); + for _ in 0..2 { + ROW_WORK.set((0, 0)); + assert_eq!(query.points_for_rows(&requests), vec![None; requests.len()]); + assert_eq!(ROW_WORK.get(), (1, 1)); + requests.reverse(); + } + } + + #[gpui::test] + fn test_columnar_selection_batch_virtual_frontier_work(cx: &mut TestAppContext) { + init_test(cx); + for (folded, length, prefix_length) in [(false, 4096, 1), (true, 64, 1), (true, 4096, 256)] + { + let prefix = "a".repeat(prefix_length); + let placeholder = "é".repeat(length); + let point = Point::new(0, prefix_length as u32); + let mut state = plain_state(&format!("{prefix}xb"), cx); + if !folded { + let buffer = state.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + state.inlays.push(Inlay::mock_hint( + 0, + buffer.anchor_after(point), + &placeholder, + )); + } + let map = state.build_map(cx); + if folded { + map.update(cx, |map, cx| { + map.fold( + vec![Crease::simple( + point..Point::new(0, point.column + 1), + FoldPlaceholder { + collapsed_text: Some(SharedString::from(placeholder.clone())), + ..FoldPlaceholder::test() + }, + )], + cx, + ) + }); + } + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.tab_snapshot().text(), + if folded { + format!("{prefix}{placeholder}b") + } else { + format!("{prefix}{placeholder}xb") + } + ); + let end = point.column + length as u32 - 1; + let requests = (point.column..=end) + .flat_map(|column| [(0, column..column), (0, column..end)]) + .collect::>(); + let expected = vec![Some((point, point)); requests.len()]; + assert_batch_points( + &mut ColumnarSelectionRows::new(&snapshot), + requests, + expected, + 8 * length, + ); + } + } + + #[gpui::test] + fn test_columnar_selection_batch_frontier_across_original_rows( + cx: &mut TestAppContext, + mut rng: StdRng, + ) { + init_test(cx); + let lines = [ + "ééé", + "🇦🇶🇦🇶🇦🇶🇦", + "🇦🇶🇦🇶🇦🇶🇦🇶", + "\t\u{301}界", + "\u{600}e\u{301}", + "👩🏽\u{200d}💻", + "क्\u{200d}ष", + "\u{1100}\u{1161}\u{11a8}", + "\u{301}\u{301}", + "a\0\u{200b}b", + "é界🏀", + "✈\u{fe0f}1\u{fe0f}\u{20e3}", + "का", + "", + ]; + for tab_size in [1, 128] { + set_tab_size(tab_size, cx); + for bias in [None, Some(Bias::Left), Some(Bias::Right)] { + let mut state = plain_state(&lines.join("\n"), cx); + let buffer = state.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + for (row, line) in lines.iter().enumerate().skip(1) { + let Some(first) = line.chars().next() else { + continue; + }; + let point = Point::new(row as u32, first.len_utf8() as u32); + if row % 4 != 0 { + state.folds.push( + buffer.anchor_after(Point::new( + row as u32 - 1, + lines[row - 1].len() as u32, + ))..buffer.anchor_before(point), + ); + } + if let Some(bias) = bias { + state.inlays.push(Inlay::mock_hint( + row, + buffer.anchor_at(point, bias), + ["🇶", "\u{301}", "\t\n\u{600}"][row % 3], + )); + } + } + let snapshot = state.build_map(cx).update(cx, |map, cx| map.snapshot(cx)); + let expanded = snapshot + .fold_snapshot() + .text() + .split('\n') + .map(|line| plain_row(line, tab_size)) + .collect::>(); + assert_eq!( + snapshot.tab_snapshot().text(), + expanded + .iter() + .map(|row| row.text.as_str()) + .collect::>() + .join("\n") + ); + let raw = snapshot.buffer_snapshot().text(); + let candidates = expanded + .iter() + .enumerate() + .map(|(row, expanded)| { + enumerated_points(&snapshot, &raw, row as u32, &expanded.boundaries) + }) + .collect::>(); + let mut requests = expanded + .iter() + .enumerate() + .flat_map(|(row, expanded)| { + (0..=expanded.len + 1) + .chain(iter::once(u32::MAX)) + .flat_map(move |start| { + [0, start, start.saturating_add(1), expanded.len, u32::MAX] + .map(|end| (row as u32, start..end)) + }) + }) + .collect::>(); + requests.extend([(u32::MAX, 0..0), (expanded.len() as u32, 0..1)]); + requests.extend_from_within(..); + requests.shuffle(&mut rng); + let mut query = ColumnarSelectionRows::new(&snapshot); + for _ in 0..4 { + let answers = query.points_for_rows(&requests); + assert_eq!(answers.len(), requests.len()); + for ((row, columns), answer) in requests.iter().zip(answers) { + let expected = expanded.get(*row as usize).and_then(|expanded| { + nearest_points(columns, expanded.len, &candidates[*row as usize]) + }); + assert_eq!( + answer, expected, + "tab size {tab_size}, bias {bias:?}, row {row}, columns {columns:?}" + ); + } + requests.reverse(); + requests = requests.into_iter().step_by(7).collect(); + } + let points = lines + .iter() + .enumerate() + .flat_map(|(row, line)| { + scalar_boundaries(line) + .into_iter() + .map(move |byte| Point::new(row as u32, byte as u32)) + }) + .collect::>(); + let mut ranges = points + .iter() + .flat_map(|start| points.iter().map(move |end| *start..*end)) + .collect::>(); + ranges.extend_from_within(..); + ranges.shuffle(&mut rng); + let answers = query.columns_for_ranges(&ranges); + assert_eq!(answers.len(), ranges.len()); + for (range, answer) in ranges.iter().zip(answers) { + let column = |point| { + let tab = snapshot + .tab_snapshot() + .point_to_tab_point(point, Bias::Left); + expanded[tab.row() as usize] + .boundaries + .partition_point(|byte| *byte <= tab.column() as usize) + as u32 + - 1 + }; + let start = column(range.start); + let end = column(range.end); + assert_eq!( + answer, + start.min(end)..start.max(end), + "tab size {tab_size}, bias {bias:?}, source {range:?}" + ); + } + } + } + } + + #[test] + fn test_column_boundaries_across_chunks() { + for (text, expected) in [ + ("", vec![0]), + ("abc", vec![0, 1, 2, 3]), + ("e\u{301}x", vec![0, 3, 4]), + ("\u{301}\u{301}x", vec![0, 4, 5]), + ("👩\u{200d}💻x", vec![0, 11, 12]), + ("🇦🇶🇦🇶🇦x", vec![0, 8, 16, 20, 21]), + ("क्\u{200d}षx", vec![0, 12, 13]), + ("\r\nx", vec![0, 2, 3]), + ("\u{600}🇦🇶🇦x", vec![0, 10, 14, 15]), + ("🇦\u{301}🇶🇦x", vec![0, 6, 14, 15]), + ] { + for split in scalar_boundaries(text) { + let mut query = RowText::new( + text.len() as u32, + ["", &text[..split], "", &text[split..], ""].into_iter(), + ); + assert_row_boundaries(&mut query, &expected); + } + let boundaries = scalar_boundaries(text); + let mut query = RowText::new( + text.len() as u32, + boundaries.windows(2).map(|range| &text[range[0]..range[1]]), + ); + assert_row_boundaries(&mut query, &expected); + for start in 0..=text.len() { + for end in start..=text.len() { + assert_eq!( + query.columns_for_bytes(start as u32..end as u32), + (expected.partition_point(|boundary| *boundary <= start) - 1) as u32 + ..(expected.partition_point(|boundary| *boundary <= end) - 1) as u32, + "{text:?}, range {start}..{end}" + ); + } + } + } + } + + #[test] + fn test_column_boundaries_tab_chunks() { + let prefix = "\u{600}"; + let suffix = "\u{301}e\u{301}👩\u{200d}💻🇦🇶\0\u{200b}x"; + for width in [1, 4, 128] { + let spaces = " ".repeat(width); + let suffix_start = prefix.len() + width; + let len = suffix_start + suffix.len(); + let expected = iter::once(0) + .chain(prefix.len()..=suffix_start) + .chain([2, 5, 16, 24, 25, 28, 29].map(|offset| suffix_start + offset)) + .collect::>(); + for tab_split in 0..=width { + for split in scalar_boundaries(suffix) { + let mut query = RowText::with_tabs( + len as u32, + [ + (prefix, false), + (&spaces[..tab_split], true), + ("", false), + (&spaces[tab_split..], true), + (&suffix[..split], false), + ("", true), + (&suffix[split..], false), + ] + .into_iter(), + ); + assert_row_boundaries(&mut query, &expected); + } + } + } + } + + #[test] + fn test_column_boundaries_tab_before_large_grapheme() { + let suffix = format!("{}z", "\u{301}".repeat(1_048_576)); + let mut query = RowText::with_tabs( + 4 + suffix.len() as u32, + [(" ", true), (suffix.as_str(), false)].into_iter(), + ); + let mut cursor = query.cursor(0); + query.advance_columns(&mut cursor, 4); + assert_eq!(cursor.byte(), 4); + let RowText::Unicode(chunks) = &query else { + panic!("Unicode row") + }; + assert_eq!(chunks.chunks.len(), 2); + assert_eq!(chunks.chunks[1].text.as_ptr(), suffix.as_ptr()); + assert_eq!(chunks.context_bytes, 0); + assert_eq!(query.next_boundary(&mut cursor), Some(2_097_156)); + assert_eq!(query.next_boundary(&mut cursor), Some(2_097_157)); + assert_eq!(query.columns_for_bytes(2_097_155..2_097_155), 4..4); + query.retreat_to(&mut cursor, 2_097_155); + assert_eq!(cursor.byte(), 4); + } + + #[test] + fn test_row_cursor_forks_and_reverse_floors() { + let text = "🇦🇶🇦🇶🇦e\u{301}👩\u{200d}💻क्\u{200d}षz"; + let expected = [0, 8, 16, 20, 23, 34, 46, 47]; + let boundaries = scalar_boundaries(text); + let mut query = RowText::new( + text.len() as u32, + boundaries.windows(2).map(|range| &text[range[0]..range[1]]), + ); + assert_row_boundaries(&mut query, &expected); + let mut forward = query.cursor(0); + for pair in expected.windows(2) { + assert_eq!(forward.byte(), pair[0] as u32); + let mut backward = forward.clone(); + for byte in (0..pair[0]).rev() { + query.retreat_to(&mut backward, byte as u32); + let index = expected.partition_point(|boundary| *boundary <= byte) - 1; + assert_eq!(backward.byte(), expected[index] as u32); + } + assert_eq!(query.next_boundary(&mut forward), Some(pair[1] as u32)); + assert_eq!(backward.byte(), 0); + } + } + + #[test] + fn test_row_cursor_giant_split_clusters() { + for text in [ + format!("e{}z", "\u{301}".repeat(4096)), + format!("👩{}\u{200d}💻z", "\u{301}".repeat(4096)), + format!("क{}षz", "्\u{200d}".repeat(4096)), + ] { + let boundaries = scalar_boundaries(&text); + let mut query = RowText::new( + text.len() as u32, + boundaries.windows(2).map(|range| &text[range[0]..range[1]]), + ); + let end = text.len() as u32; + let mut cursor = query.cursor(0); + assert_eq!(query.next_boundary(&mut cursor), Some(end - 1)); + let mut fork = cursor.clone(); + assert_eq!(query.next_boundary(&mut cursor), Some(end)); + assert_eq!(query.previous_boundary(&mut fork), Some(0)); + for byte in [1, end / 2, end - 2] { + assert_eq!(query.floor_at(byte).byte(), 0); + } + assert_eq!(query.floor_at(end - 1).byte(), end - 1); + assert_eq!(query.previous_boundary(&mut cursor), Some(end - 1)); + assert_eq!(query.previous_boundary(&mut cursor), Some(0)); + let RowText::Unicode(chunks) = &query else { + panic!("Unicode row") + }; + assert_eq!(chunks.chunks.len(), boundaries.len() - 1); + assert_eq!(chunks.chunks[0].text.as_ptr(), text.as_ptr()); + } + } + + #[test] + fn test_row_cursor_lazy_polling() { + let suffix = format!("{}é", "a".repeat(1_048_576)); + let polls = Cell::new(0); + let chunks = ["éabc", suffix.as_str()].into_iter().inspect(|_| { + polls.set(polls.get() + 1); + }); + let mut query = RowText::new(5 + suffix.len() as u32, chunks); + assert_eq!(query.columns_for_bytes(0..0), 0..0); + assert_eq!(query.floor_at(0).byte(), 0); + let mut cursor = query.cursor(0); + query.advance_columns(&mut cursor, 0); + assert_eq!(polls.get(), 0); + query.advance_columns(&mut cursor, 2); + assert_eq!(cursor.byte(), 3); + assert_eq!(polls.get(), 1); + let mut fork = cursor.clone(); + query.retreat_to(&mut fork, 1); + assert_eq!(fork.byte(), 0); + assert_eq!(query.next_boundary(&mut cursor), Some(4)); + assert_eq!(polls.get(), 1); + assert_eq!(query.columns_for_bytes(0..2), 0..1); + assert_eq!(polls.get(), 1); + let RowText::Unicode(chunks) = &query else { + panic!("Unicode row") + }; + assert_eq!(chunks.chunks.len(), 1); + assert_eq!(chunks.context_bytes, 0); + } + + #[test] + fn test_row_cursor_reverse_regional_context_is_linear() { + for symbols in [127, 128, 1023, 1024, 8191, 8192] { + let text = "🇦".repeat(symbols); + for chunk_symbols in [1, 2, 7, symbols] { + for end in [text.len(), text.len() - 1, text.len() - 4] { + let chunks = text.as_bytes().chunks(4 * chunk_symbols).map(|chunk| { + std::str::from_utf8(chunk).expect("regional indicator chunks") + }); + let mut query = RowText::new(text.len() as u32, chunks); + let mut cursor = query.floor_at(end as u32); + assert_eq!( + cursor.byte(), + if end == text.len() { end } else { end / 8 * 8 } as u32 + ); + let mut expected = cursor.byte(); + while expected > 0 { + expected = (expected - 1) / 8 * 8; + assert_eq!(query.previous_boundary(&mut cursor), Some(expected)); + } + assert_eq!(query.previous_boundary(&mut cursor), None); + let RowText::Unicode(chunks) = &query else { + panic!("Unicode row") + }; + if chunk_symbols == 1 { + assert!(chunks.context_bytes > 0); + } + assert!( + chunks.context_bytes <= text.len() * 2, + "symbols {symbols}, chunk symbols {chunk_symbols}, end {end}, context bytes {}", + chunks.context_bytes + ); + } + } + } + } + + #[gpui::test] + fn test_columnar_selection_right_inlay_shifts_regional_parity(cx: &mut TestAppContext) { + init_test(cx); + for symbols in [4, 64, 8192] { + let text = format!("{}z", "🇦".repeat(symbols)); + let mut state = plain_state(&text, cx); + let buffer = state.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + state + .inlays + .push(Inlay::mock_hint(0, buffer.anchor_after(Point::zero()), "🇶")); + let snapshot = state.build_map(cx).update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(snapshot.tab_snapshot().text(), format!("🇶{text}")); + let mut query = ColumnarSelectionRows::new(&snapshot); + let last_flag = symbols as u32 / 2; + let requests = (1..=last_flag) + .flat_map(|column| [(0, column..column), (0, column..last_flag + 1)]) + .collect::>(); + let end = Point::new(0, symbols as u32 * 4); + let expected = (1..=last_flag) + .flat_map(|_| { + [ + Some((Point::zero(), Point::zero())), + Some((Point::zero(), end)), + ] + }) + .collect::>(); + assert_batch_points(&mut query, requests, expected, 8 * symbols); + assert_eq!( + query.points_for_row(0, &(last_flag..last_flag + 1)), + Some((Point::zero(), end)) + ); + assert_eq!( + query.points_for_row(0, &(last_flag + 1..last_flag + 1)), + Some((end, end)) + ); + } + } + + #[gpui::test] + fn test_columnar_selection_tab_control_boundaries(cx: &mut TestAppContext) { + init_test(cx); + let expected = plain_row("\t\u{301}x", 4); + assert_eq!(expected.text, " \u{301}x"); + assert_eq!(expected.len, 6); + assert_eq!(expected.columns, [(0, 0), (1, 4), (3, 5), (4, 6)]); + assert_eq!(expected.points, [(0, 0), (4, 1), (5, 3), (6, 4)]); + assert_eq!(expected.boundaries, [0, 1, 2, 3, 4, 6, 7]); + for tab_size in [1, 2, 4, 8, 127, 128] { + set_tab_size(tab_size, cx); + let after_prepend_tab = 1 + tab_size - 1 % tab_size; + for (line, endpoints) in [ + ( + "\t\u{301}x", + vec![(0, 0), (1, tab_size), (3, tab_size + 1), (4, tab_size + 2)], + ), + ( + "\u{600}\t\u{301}x", + vec![ + (0, 0), + (2, 1), + (3, after_prepend_tab), + (5, after_prepend_tab + 1), + (6, after_prepend_tab + 2), + ], + ), + ] { + let snapshot = plain_state(&format!("{line}\n{line}"), cx) + .build_map(cx) + .update(cx, |map, cx| map.snapshot(cx)); + let mut query = ColumnarSelectionRows::new(&snapshot); + for source_row in [0, 1] { + for (index, &(start, start_column)) in endpoints.iter().enumerate() { + for &(end, end_column) in &endpoints[index..] { + for reversed in [false, true] { + let start_point = Point::new(source_row, start); + let end_point = Point::new(source_row, end); + let columns = query.columns_for_range(if reversed { + end_point..start_point + } else { + start_point..end_point + }); + assert_eq!(columns, start_column..end_column); + let target_row = 1 - source_row; + assert_eq!( + query.points_for_row(target_row, &columns), + Some(( + Point::new(target_row, start), + Point::new(target_row, end) + )), + "line {line:?}, tab size {tab_size}, source row {source_row}, range {start}..{end}, reversed {reversed}" + ); + } + } + } + } + assert_plain_rows(&snapshot, tab_size); + } + } + } + + #[gpui::test] + fn test_columnar_selection_tab_control_boundaries_with_virtual_text(cx: &mut TestAppContext) { + init_test(cx); + let mut state = plain_state("\tQx\n\tx", cx); + let buffer = state.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + state.inlays.push(Inlay::mock_hint( + 0, + buffer.anchor_after(Point::new(1, 1)), + "\u{301}", + )); + let map = state.build_map(cx); + map.update(cx, |map, cx| { + map.fold( + vec![Crease::simple( + Point::new(0, 1)..Point::new(0, 2), + FoldPlaceholder { + collapsed_text: Some(SharedString::from("\u{301}")), + ..FoldPlaceholder::test() + }, + )], + cx, + ) + }); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(snapshot.tab_snapshot().text(), " \u{301}x\n \u{301}x"); + let mut query = ColumnarSelectionRows::new(&snapshot); + for row in [0, 1] { + assert_eq!( + query.columns_for_range(Point::new(row, 1)..Point::new(row, 1)), + 4..4 + ); + assert_eq!( + query.points_for_row(row, &(4..4)), + Some((Point::new(row, 1), Point::new(row, 1))) + ); + assert_eq!( + query.points_for_row(row, &(4..5)), + Some((Point::new(row, 1), Point::new(row, 2 - row))) + ); + } + } + + #[gpui::test] + fn test_columnar_selection_lazy_unicode_prefix(cx: &mut TestAppContext) { + init_test(cx); + let line = format!("{}é", "a".repeat(1_048_576)); + let snapshot = plain_state(&format!("{line}\n{line}"), cx) + .build_map(cx) + .update(cx, |map, cx| map.snapshot(cx)); + let mut query = ColumnarSelectionRows::new(&snapshot); + assert_eq!(query.columns_for_range(Point::zero()..Point::zero()), 0..0); + assert_eq!( + query.points_for_row(1, &(0..0)), + Some((Point::new(1, 0), Point::new(1, 0))) + ); + for row in [query.row(1), query.buffer_row(1)] { + let RowText::Unicode(chunks) = row else { + panic!("Unicode row") + }; + assert_eq!(chunks.chunks.len(), 0); + } + assert_eq!( + query.points_for_row(1, &(1..1)), + Some((Point::new(1, 1), Point::new(1, 1))) + ); + for mut row in [query.row(1), query.buffer_row(1)] { + let mut cursor = row.cursor(0); + row.advance_columns(&mut cursor, 1); + assert_eq!(cursor.byte(), 1); + let RowText::Unicode(chunks) = row else { + panic!("Unicode row") + }; + assert_eq!(chunks.chunks.len(), 1); + } + assert_eq!( + query.points_for_row(1, &(1_048_576..1_048_576)), + Some((Point::new(1, 1_048_576), Point::new(1, 1_048_576))) + ); + } + + #[gpui::test] + fn test_columnar_selection_skips_long_virtual_row(cx: &mut TestAppContext) { + init_test(cx); + let mut state = plain_state(&format!("x\n{}", "a".repeat(1_048_576)), cx); + let buffer = state.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + state.inlays.push(Inlay::mock_hint( + 0, + buffer.anchor_before(Point::new(1, 0)), + &format!("{}\n", "a".repeat(1_048_576)), + )); + let snapshot = state.build_map(cx).update(cx, |map, cx| map.snapshot(cx)); + let mut query = ColumnarSelectionRows::new(&snapshot); + assert_eq!(query.points_for_row(1, &(1_048_576..1_048_576)), None); + assert_eq!( + query.points_for_row(2, &(1_048_576..1_048_576)), + Some((Point::new(1, 1_048_576), Point::new(1, 1_048_576))) + ); + } + + #[gpui::test] + fn test_columnar_selection_graphemes_and_edges(cx: &mut TestAppContext) { + init_test(cx); + let state = plain_state("e\u{301}\néé\n\n\t\u{301}x\n👩\u{200d}💻z\n", cx); + let map = state.build_map(cx); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + let mut query = ColumnarSelectionRows::new(&snapshot); + assert_eq!( + query.columns_for_range(Point::new(0, 0)..Point::new(0, 3)), + 0..1 + ); + assert_eq!( + query.columns_for_range(Point::new(1, 0)..Point::new(1, 4)), + 0..2 + ); + assert_eq!( + query.columns_for_range(Point::new(1, 4)..Point::new(0, 0)), + 0..2 + ); + assert_eq!( + query.points_for_row(0, &(0..1)), + Some((Point::new(0, 0), Point::new(0, 3))) + ); + assert_eq!( + query.points_for_row(1, &(0..1)), + Some((Point::new(1, 0), Point::new(1, 2))) + ); + assert_eq!(query.points_for_row(0, &(1..2)), None); + assert_eq!( + query.points_for_row(0, &(u32::MAX..u32::MAX)), + Some((Point::new(0, 3), Point::new(0, 3))) + ); + assert_eq!( + query.points_for_row(2, &(0..0)), + Some((Point::new(2, 0), Point::new(2, 0))) + ); + assert_eq!(query.points_for_row(2, &(0..1)), None); + assert_eq!( + query.points_for_row(3, &(3..4)), + Some((Point::new(3, 0), Point::new(3, 1))) + ); + assert_eq!( + query.points_for_row(4, &(0..1)), + Some((Point::new(4, 0), Point::new(4, 11))) + ); + assert_eq!( + query.points_for_row(5, &(u32::MAX..u32::MAX)), + Some((Point::new(5, 0), Point::new(5, 0))) + ); + assert_eq!(query.points_for_row(6, &(0..0)), None); + assert_eq!(query.points_for_row(u32::MAX, &(0..0)), None); + assert_plain_rows(&snapshot, 4); + let empty = plain_state("", cx) + .build_map(cx) + .update(cx, |map, cx| map.snapshot(cx)); + assert_plain_rows(&empty, 4); + } + + #[gpui::test] + fn test_columnar_selection_ascii_fast_path(cx: &mut TestAppContext) { + init_test(cx); + let state = plain_state(&format!("{}\nx", "a".repeat(1_048_576)), cx); + let map = state.build_map(cx); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + let mut query = ColumnarSelectionRows::new(&snapshot); + assert_eq!(query.points_for_row(1, &(2..3)), None); + assert_eq!(size_of_val(&query), size_of::<&DisplaySnapshot>()); + assert_eq!( + query.columns_for_range(Point::zero()..Point::new(0, 1_048_576)), + 0..1_048_576 + ); + let RowText::Ascii(len) = query.row(0) else { + panic!("ASCII row allocated Unicode boundaries") + }; + assert_eq!(len, 1_048_576); + assert_eq!( + query.points_for_row(0, &(1_048_575..u32::MAX)), + Some((Point::new(0, 1_048_575), Point::new(0, 1_048_576))) + ); + let RowText::Ascii(len) = query.buffer_row(0) else { + panic!("ASCII buffer row allocated Unicode boundaries") + }; + assert_eq!(len, 1_048_576); + } + + #[gpui::test] + fn test_columnar_selection_tab_expansion_boundary(cx: &mut TestAppContext) { + init_test(cx); + let prefixes = (255..=257) + .map(|column| format!("{}{}", "α".repeat(127), "a".repeat(column - 254))) + .chain([ + format!("{}α", "a".repeat(255)), + format!("{}e\u{301}", "a".repeat(254)), + ]) + .collect::>(); + let text = prefixes + .iter() + .map(|prefix| format!("{prefix}\t\u{301}β")) + .collect::>() + .join("\n"); + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let state = MapState::new(cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx))); + let map = state.build_map(cx); + for tab_size in [1, 3, 128] { + set_tab_size(tab_size, cx); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_plain_rows(&snapshot, tab_size); + if tab_size == 128 { + assert_eq!( + snapshot.tab_snapshot().text(), + format!( + "{}{}\u{301}β\n{} \u{301}β\n{} \u{301}β\n{} \u{301}β\n{} \u{301}β", + prefixes[0], + " ".repeat(128), + prefixes[1], + prefixes[2], + prefixes[3], + prefixes[4] + ) + ); + } + buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "a")], None, cx)); + assert_plain_rows(&map.update(cx, |map, cx| map.snapshot(cx)), tab_size); + buffer.update(cx, |buffer, cx| buffer.edit([(0..1, "")], None, cx)); + let restored = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + restored.tab_snapshot().text(), + snapshot.tab_snapshot().text() + ); + assert_plain_rows(&restored, tab_size); + } + } + + #[gpui::test] + fn test_columnar_selection_folds_and_inlays_without_frames( + cx: &mut TestAppContext, + mut rng: StdRng, + ) { + init_test(cx); + let mut state = plain_state("e\u{301}x\nab\ncd", cx); + let map = state.build_map(cx); + map.update(cx, |map, cx| { + map.fold( + vec![Crease::simple( + Point::new(0, 1)..Point::new(0, 3), + FoldPlaceholder::test(), + )], + cx, + ) + }); + let folded = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(folded.tab_snapshot().text(), "e⋯x\nab\ncd"); + assert_eq!( + ColumnarSelectionRows::new(&folded).points_for_row(0, &(1..2)), + Some((Point::new(0, 0), Point::new(0, 3))) + ); + state.folds = folded + .folds_in_range(Anchor::Min..Anchor::Max) + .map(|fold| fold.range.0.clone()) + .collect(); + for (index, bias) in [Bias::Left, Bias::Right].into_iter().enumerate() { + state.inlays.push(Inlay::mock_hint( + index, + folded.buffer_snapshot().anchor_at(Point::new(1, 1), bias), + "\u{301}\n\tR", + )); + } + map.update(cx, |map, cx| { + map.splice_inlays(&[], state.inlays.clone(), cx) + }); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.tab_snapshot().text(), + "e⋯x\na\u{301}\n R\u{301}\n Rb\ncd" + ); + let mut query = ColumnarSelectionRows::new(&snapshot); + assert_eq!(query.points_for_row(2, &(0..0)), None); + assert_eq!(query.points_for_row(2, &(0..u32::MAX)), None); + let rebuilt = state.build_map(cx).update(cx, |map, cx| map.snapshot(cx)); + check_snapshot(&snapshot, &rebuilt, true, None, 4, &mut rng, 0); + } + + #[gpui::test] + fn test_columnar_selection_long_fold_placeholders(cx: &mut TestAppContext) { + init_test(cx); + for placeholder in ["...", "long placeholder", "ééé", "👩\u{200d}💻xyz"] { + for edited in [false, true] { + let buffer = cx.new(|cx| Buffer::local(if edited { "a\nxb" } else { "axb" }, cx)); + let state = MapState::new(cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx))); + let map = state.build_map(cx); + map.update(cx, |map, cx| { + map.fold( + vec![Crease::simple( + Point::new(0, 1)..if edited { + Point::new(1, 1) + } else { + Point::new(0, 2) + }, + FoldPlaceholder { + collapsed_text: Some(SharedString::from(placeholder)), + ..FoldPlaceholder::test() + }, + )], + cx, + ); + }); + if edited { + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(0, 1)..Point::new(1, 0), "")], None, cx) + }); + } + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(snapshot.tab_snapshot().text(), format!("a{placeholder}b")); + let mut query = ColumnarSelectionRows::new(&snapshot); + let end = 1 + placeholder.graphemes(true).count() as u32; + for column in 1..end { + assert_eq!( + query.points_for_row(0, &(column..column)), + Some((Point::new(0, 1), Point::new(0, 1))), + "{placeholder:?}, column {column}, edited {edited}" + ); + } + assert_eq!( + query.points_for_row(0, &(end..end)), + Some((Point::new(0, 2), Point::new(0, 2))) + ); + } + } + } + + #[gpui::test] + fn test_columnar_selection_fold_grapheme_edges(cx: &mut TestAppContext) { + init_test(cx); + for (text, placeholder, column, expected) in + [("axb", "\u{301}..", 1, 0), ("ax\u{301}b", "..", 3, 4)] + { + let state = plain_state(text, cx); + let map = state.build_map(cx); + map.update(cx, |map, cx| { + map.fold( + vec![Crease::simple( + Point::new(0, 1)..Point::new(0, 2), + FoldPlaceholder { + collapsed_text: Some(SharedString::from(placeholder)), + ..FoldPlaceholder::test() + }, + )], + cx, + ) + }); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + ColumnarSelectionRows::new(&snapshot).points_for_row(0, &(column..column)), + Some((Point::new(0, expected), Point::new(0, expected))), + "{text:?}, {placeholder:?}" + ); + } + for merge in [false, true] { + let map = plain_state("axyb", cx).build_map(cx); + map.update(cx, |map, cx| { + map.fold( + [(1, ".."), (2, "ZZ")] + .into_iter() + .map(|(start, placeholder)| { + Crease::simple( + Point::new(0, start)..Point::new(0, start + 1), + FoldPlaceholder { + merge_adjacent: merge, + collapsed_text: Some(SharedString::from(placeholder)), + ..FoldPlaceholder::test() + }, + ) + }) + .collect(), + cx, + ) + }); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.tab_snapshot().text(), + if merge { "a..b" } else { "a..ZZb" } + ); + let expected = if merge { + vec![0, 1, 1, 3, 4] + } else { + vec![0, 1, 1, 2, 2, 3, 4] + }; + let mut query = ColumnarSelectionRows::new(&snapshot); + for (column, byte) in expected.into_iter().enumerate() { + let column = column as u32; + assert_eq!( + query.points_for_row(0, &(column..column)), + Some((Point::new(0, byte), Point::new(0, byte))), + "merge={merge}, column={column}" + ); + } + } + } + + #[gpui::test] + fn test_columnar_selection_fold_ending_inside_grapheme(cx: &mut TestAppContext) { + init_test(cx); + let map = plain_state("aX\u{1100}\u{1161}z", cx).build_map(cx); + map.update(cx, |map, cx| { + map.fold( + vec![Crease::simple( + Point::new(0, 1)..Point::new(0, 5), + FoldPlaceholder::test(), + )], + cx, + ) + }); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(snapshot.tab_snapshot().text(), "a⋯\u{1161}z"); + let mut query = ColumnarSelectionRows::new(&snapshot); + for (columns, expected) in [ + (0..0, (Point::new(0, 0), Point::new(0, 0))), + (1..1, (Point::new(0, 1), Point::new(0, 1))), + (2..2, (Point::new(0, 1), Point::new(0, 1))), + (3..3, (Point::new(0, 8), Point::new(0, 8))), + (4..4, (Point::new(0, 9), Point::new(0, 9))), + (1..3, (Point::new(0, 1), Point::new(0, 8))), + (2..4, (Point::new(0, 1), Point::new(0, 9))), + ] { + assert_eq!( + query.points_for_row(0, &columns), + Some(expected), + "columns {columns:?}" + ); + } + } + + #[test] + fn test_column_boundaries_large_grapheme() { + let text = format!("e{}z", "\u{301}".repeat(1024)); + let mut query = RowText::new(text.len() as u32, iter::once(text.as_str())); + let mut cursor = query.cursor(0); + assert_eq!(cursor.byte(), 0); + assert_eq!(query.next_boundary(&mut cursor), Some(2049)); + assert_eq!(query.next_boundary(&mut cursor), Some(2050)); + assert_eq!(query.columns_for_bytes(2048..2049), 0..1); + assert_eq!(query.floor_at(2048).byte(), 0); + assert_eq!(query.floor_at(2049).byte(), 2049); + } + + #[gpui::test(iterations = 20)] + fn test_columnar_selection_during_pending_rewrap(cx: &mut TestAppContext, mut rng: StdRng) { + init_test(cx); + cx.background_executor.set_block_on_ticks(0..=0); + let text = (0..120) + .map(|row| format!("row{row} {}", random_text(&mut rng, 6))) + .collect::>() + .join("\n"); + let mut state = plain_state(&text, cx); + let map = state.build_map(cx); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = snapshot.buffer_snapshot(); + let hidden_start = rng.random_range(0..buffer.max_point().row); + let hidden_end = rng.random_range(hidden_start..=buffer.max_point().row); + state.block = Some(( + BlockPlacement::Replace( + buffer.anchor_before(Point::new(hidden_start, 0)) + ..=buffer.anchor_after(Point::new( + hidden_end, + buffer.line_len(MultiBufferRow(hidden_end)), + )), + ), + 1, + )); + map.update(cx, |map, cx| { + map.insert_blocks([state.block_properties().expect("block")], cx) + }); + state.wrap_width = Some(px(30.0)); + map.update(cx, |map, cx| map.set_wrap_width(state.wrap_width, cx)); + assert!(map.update(cx, |map, cx| map.is_rewrapping(cx))); + let interpolated = map.update(cx, |map, cx| map.snapshot(cx)); + assert!(map.update(cx, |map, cx| map.is_rewrapping(cx))); + check_snapshot(&interpolated, &interpolated, true, None, 4, &mut rng, 0); + let rebuilt = state.build_map(cx); + cx.run_until_parked(); + assert!(!map.update(cx, |map, cx| map.is_rewrapping(cx))); + let rebuilt = rebuilt.update(cx, |map, cx| map.snapshot(cx)); + let settled = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(hidden_rows(&settled), hidden_rows(&rebuilt)); + check_snapshot(&settled, &rebuilt, true, None, 4, &mut rng, 1); + check_snapshot( + &interpolated, + &settled, + hidden_rows(&interpolated) == hidden_rows(&settled), + None, + 4, + &mut rng, + 2, + ); + } + + #[gpui::test(iterations = 100)] + fn test_random_columnar_selections(cx: &mut TestAppContext, mut rng: StdRng) { + init_test(cx); + cx.background_executor.set_block_on_ticks(0..=50); + let operations = env::var("OPERATIONS") + .map(|value| value.parse::().expect("invalid OPERATIONS")) + .unwrap_or(15); + let mut texts = (0..rng.random_range(1..=2)) + .map(|_| format!("a{}z", random_text(&mut rng, 12))) + .collect::>(); + let buffers = texts + .iter() + .map(|text| cx.new(|cx| Buffer::local(text.clone(), cx))) + .collect::>(); + let multibuffer = cx.new(|cx| { + if buffers.len() == 1 { + return MultiBuffer::singleton(buffers[0].clone(), cx); + } + let mut buffer = MultiBuffer::new(Capability::ReadWrite); + for (index, source) in buffers.iter().enumerate() { + let snapshot = source.read(cx).snapshot(); + buffer.set_excerpt_ranges_for_path( + PathKey::sorted(index as u64), + source.clone(), + &snapshot, + vec![ExcerptRange::new(Point::zero()..snapshot.max_point())], + cx, + ); + } + buffer + }); + let mut state = MapState::new(multibuffer); + let map = state.build_map(cx); + let mut block_ids = HashSet::default(); + let mut kinds = [0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6]; + kinds.shuffle(&mut rng); + let mut snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + check_snapshot( + &snapshot, + &snapshot, + true, + Some(&texts.join("\n")), + 4, + &mut rng, + 0, + ); + for operation in 0..operations { + let kind = kinds[operation % kinds.len()]; + log::info!( + "operation {operation}: kind {kind}, texts {texts:?}, tabs {}, folds {:?}, inlays {:?}, block {:?}, wrap {:?}", + state.tab_size, + state.folds, + state.inlays, + state.block, + state.wrap_width + ); + let replaced_before = state.replaces_rows(); + match kind { + 0 => { + let index = rng.random_range(0..buffers.len()); + let text = &mut texts[index]; + let boundaries = text + .char_indices() + .map(|(offset, _)| offset) + .skip(1) + .collect::>(); + let start = rng.random_range(0..boundaries.len()); + let mut range = + boundaries[start]..boundaries[rng.random_range(start..boundaries.len())]; + let mut replacement = random_text(&mut rng, 4); + if text.get(range.clone()) == Some(replacement.as_str()) { + replacement.push('a'); + } + let mut expected = text.clone(); + expected.replace_range(range.clone(), &replacement); + if expected.len() > 256 + || expected.matches('\t').count() > 4 + || expected.matches('\n').count() > 12 + { + range = 1..text.len() - 1; + replacement.clear(); + expected = String::from("az"); + } + assert_ne!(expected.as_str(), text.as_str()); + log::info!("edit buffer {index}: {range:?} -> {replacement:?}"); + buffers[index].update(cx, |buffer, cx| { + buffer.edit([(range, replacement)], None, cx) + }); + *text = expected; + if state.replaces_rows() { + map.update(cx, |map, cx| { + map.remove_blocks(std::mem::take(&mut block_ids), cx); + block_ids.extend( + map.insert_blocks([state.block_properties().expect("block")], cx), + ); + }); + } + } + 1 => { + let size = rng.random_range(1..128); + state.tab_size = size + u32::from(size >= state.tab_size); + set_tab_size(state.tab_size, cx); + } + 2 => { + if state.folds.is_empty() { + let index = rng.random_range(0..texts.len()); + let base = texts + .iter() + .take(index) + .map(|text| text.len() + 1) + .sum::(); + let boundaries = scalar_boundaries(&texts[index]); + let start = rng.random_range(0..boundaries.len() - 1); + let end = rng.random_range(start + 1..boundaries.len()); + let buffer = snapshot.buffer_snapshot(); + let range = buffer.anchor_after(MultiBufferOffset(base + boundaries[start])) + ..buffer.anchor_before(MultiBufferOffset(base + boundaries[end])); + map.update(cx, |map, cx| { + map.fold(vec![Crease::simple(range, FoldPlaceholder::test())], cx) + }); + } else { + map.update(cx, |map, cx| { + map.unfold_intersecting([Anchor::Min..Anchor::Max], true, cx) + }); + if state.replaces_rows() { + block_ids.clear(); + state.block = None; + } + } + } + 3 | 6 => { + if state.inlays.is_empty() { + let text = snapshot.buffer_snapshot().text(); + let boundaries = scalar_boundaries(&text); + let offset = + MultiBufferOffset(boundaries[rng.random_range(0..boundaries.len())]); + for (index, bias) in [Bias::Left, Bias::Right].into_iter().enumerate() { + let text = format!("\n{}\t", random_text(&mut rng, 3)); + state.inlays.push(Inlay::mock_hint( + index, + snapshot.buffer_snapshot().anchor_at(offset, bias), + text.as_str(), + )); + } + map.update(cx, |map, cx| { + map.splice_inlays(&[], state.inlays.clone(), cx) + }); + } else { + let ids = state + .inlays + .iter() + .map(|inlay| inlay.id) + .collect::>(); + map.update(cx, |map, cx| map.splice_inlays(&ids, Vec::new(), cx)); + state.inlays.clear(); + } + } + 4 => { + state.wrap_width = if state.wrap_width.is_some() { + None + } else { + Some(px([0.0, 12.0, 48.0, 300.0][rng.random_range(0..4)])) + }; + map.update(cx, |map, cx| map.set_wrap_width(state.wrap_width, cx)); + } + 5 => { + if state.block.is_some() { + map.update(cx, |map, cx| { + map.remove_blocks(std::mem::take(&mut block_ids), cx) + }); + state.block = None; + } else { + let text = snapshot.buffer_snapshot().text(); + let boundaries = scalar_boundaries(&text); + let start = rng.random_range(0..boundaries.len()); + let buffer = snapshot.buffer_snapshot(); + let position = buffer.anchor_after(MultiBufferOffset(boundaries[start])); + let placement = match rng.random_range(0..3) { + 0 => BlockPlacement::Above(position), + 1 => BlockPlacement::Below(position), + _ => { + let start = rng.random_range(0..boundaries.len() - 1); + let end = rng.random_range(start + 1..boundaries.len()); + BlockPlacement::Replace( + buffer.anchor_before(MultiBufferOffset(boundaries[start])) + ..=buffer.anchor_after(MultiBufferOffset(boundaries[end])), + ) + } + }; + state.block = Some((placement, rng.random_range(1..=3))); + block_ids.extend(map.update(cx, |map, cx| { + map.insert_blocks([state.block_properties().expect("block")], cx) + })); + } + } + _ => unreachable!(), + } + let previous = snapshot; + snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + state.folds = snapshot + .folds_in_range(Anchor::Min..Anchor::Max) + .map(|fold| fold.range.0.clone()) + .collect(); + let text = texts.join("\n"); + assert_eq!( + snapshot.buffer_snapshot().text(), + text, + "operation {operation}" + ); + let plain = (state.folds.is_empty() + && state + .inlays + .iter() + .all(|inlay| !inlay.position.is_valid(snapshot.buffer_snapshot())) + && !state.replaces_rows()) + .then_some(text.as_str()); + let rewrapping = map.update(cx, |map, cx| map.is_rewrapping(cx)); + let rebuilt = state.build_map(cx); + cx.run_until_parked(); + let rebuilt = rebuilt.update(cx, |map, cx| map.snapshot(cx)); + let settled = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + hidden_rows(&settled), + hidden_rows(&rebuilt), + "operation {operation}: hidden rows" + ); + check_snapshot( + &snapshot, + &rebuilt, + !state.replaces_rows() || !rewrapping, + plain, + state.tab_size, + &mut rng, + operation, + ); + if (kind == 4 || kind == 5) && !replaced_before && !state.replaces_rows() { + check_snapshot( + &snapshot, + &previous, + true, + plain, + state.tab_size, + &mut rng, + operation, + ); + } + check_snapshot( + &settled, + &rebuilt, + true, + plain, + state.tab_size, + &mut rng, + operation, + ); + snapshot = settled; + } + } + + struct MapState { + buffer: Entity, + tab_size: u32, + folds: Vec>, + inlays: Vec, + block: Option<(BlockPlacement, u32)>, + wrap_width: Option, + } + + impl MapState { + fn new(buffer: Entity) -> Self { + Self { + buffer, + tab_size: 4, + folds: Vec::new(), + inlays: Vec::new(), + block: None, + wrap_width: None, + } + } + + fn build_map(&self, cx: &mut TestAppContext) -> Entity { + cx.new(|cx| { + let mut map = DisplayMap::new( + self.buffer.clone(), + test_font(), + px(10.0), + self.wrap_width, + 1, + 1, + FoldPlaceholder::test(), + DiagnosticSeverity::Warning, + cx, + ); + map.splice_inlays(&[], self.inlays.clone(), cx); + if !self.folds.is_empty() { + map.fold( + self.folds + .iter() + .cloned() + .map(|range| Crease::simple(range, FoldPlaceholder::test())) + .collect(), + cx, + ); + } + if let Some(block) = self.block_properties() { + map.insert_blocks([block], cx); + } + map + }) + } + + fn replaces_rows(&self) -> bool { + matches!(self.block, Some((BlockPlacement::Replace(_), _))) + } + + fn block_properties(&self) -> Option> { + self.block + .as_ref() + .map(|(placement, height)| BlockProperties { + placement: placement.clone(), + style: BlockStyle::Fixed, + height: Some(*height), + render: Arc::new(|_| div().into_any()), + priority: 0, + }) + } + } + + struct PlainRow { + text: String, + len: u32, + columns: Vec<(u32, u32)>, + points: Vec<(u32, u32)>, + boundaries: Vec, + } + + fn assert_batch_points( + query: &mut ColumnarSelectionRows<'_>, + mut requests: Vec<(u32, Range)>, + mut expected: Vec>, + work_bound: usize, + ) { + for _ in 0..2 { + ROW_WORK.set((0, 0)); + let answers = query.points_for_rows(&requests); + assert_eq!(answers.len(), expected.len()); + for (index, (answer, expected)) in answers.iter().zip(&expected).enumerate() { + assert_eq!(answer, expected, "request {index}: {:?}", requests[index]); + } + let (boundaries, chunks) = ROW_WORK.get(); + assert!(boundaries <= work_bound, "{boundaries} > {work_bound}"); + assert!(chunks <= work_bound, "{chunks} > {work_bound}"); + requests.reverse(); + expected.reverse(); + } + } + + fn enumerated_points( + snapshot: &DisplaySnapshot, + raw: &str, + row: u32, + boundaries: &[usize], + ) -> Vec<(u32, Point)> { + raw.split('\n') + .enumerate() + .flat_map(|(row, line)| { + grapheme_boundaries(line) + .into_iter() + .map(move |byte| Point::new(row as u32, byte as u32)) + }) + .filter_map(|point| { + let tab = snapshot + .tab_snapshot() + .point_to_tab_point(point, Bias::Left); + if tab.row() != row + || snapshot.tab_snapshot().tab_point_to_point(tab, Bias::Left) != point + { + return None; + } + let display = point.to_display_point(snapshot); + if display.to_point(snapshot) != point || snapshot.is_block_line(display.row()) { + return None; + } + boundaries + .binary_search(&(tab.column() as usize)) + .ok() + .map(|column| (column as u32, point)) + }) + .collect() + } + + fn nearest_points( + columns: &Range, + length: u32, + candidates: &[(u32, Point)], + ) -> Option<(Point, Point)> { + if columns.start > columns.end || (columns.start != columns.end && columns.start >= length) + { + return None; + } + let point = |column| { + candidates + .iter() + .filter(|(candidate, _)| *candidate <= column) + .max_by_key(|(candidate, _)| *candidate) + .map(|(_, point)| *point) + }; + point(columns.start).zip(point(columns.end)) + } + + fn hidden_rows(snapshot: &DisplaySnapshot) -> Vec { + (0..=snapshot.buffer_snapshot().max_point().row) + .map(|row| snapshot.is_block_line(Point::new(row, 0).to_display_point(snapshot).row())) + .collect() + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + crate::init(cx); + theme_settings::init(LoadThemes::JustBase, cx); + }); + set_tab_size(4, cx); + } + + fn set_tab_size(size: u32, cx: &mut TestAppContext) { + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.tab_size = NonZeroU32::new(size); + }) + }) + }); + } + + fn plain_state(text: &str, cx: &mut TestAppContext) -> MapState { + MapState::new(cx.update(|cx| MultiBuffer::build_simple(text, cx))) + } + + fn random_text(rng: &mut StdRng, tokens: usize) -> String { + let mut text = String::new(); + let mut tabs = 0; + for _ in 0..rng.random_range(0..=tokens) { + let mut token = *[ + "a", + " ", + "\t", + "\n", + "α", + "界", + "🏀", + "é", + "e\u{301}", + "o\u{308}\u{301}", + "\u{301}", + "👩\u{200d}💻", + "👍🏽", + "🇦🇶", + "✈\u{fe0f}", + ] + .choose(rng) + .expect("tokens"); + if token == "\t" { + if tabs == 2 { + token = "a"; + } else { + tabs += 1; + } + } + text.push_str(token); + } + text + } + + fn assert_row_boundaries(query: &mut RowText<'_>, expected: &[usize]) { + let length = *expected.last().expect("end boundary"); + for byte in 0..=length + 1 { + let column = expected.partition_point(|boundary| *boundary <= byte) - 1; + assert_eq!( + query.columns_for_bytes(byte as u32..byte as u32), + column as u32..column as u32, + "byte {byte}, expected {expected:?}" + ); + assert_eq!( + query.floor_at(byte as u32).byte(), + expected[column] as u32, + "byte {byte}, expected {expected:?}" + ); + } + let mut cursor = query.cursor(0); + for byte in expected.iter().skip(1) { + assert_eq!(query.next_boundary(&mut cursor), Some(*byte as u32)); + } + assert_eq!(query.next_boundary(&mut cursor), None); + for byte in expected.iter().rev().skip(1) { + assert_eq!(query.previous_boundary(&mut cursor), Some(*byte as u32)); + } + assert_eq!(query.previous_boundary(&mut cursor), None); + let mut cursor = query.floor_at(u32::MAX); + assert_eq!(cursor.byte(), length as u32); + for byte in expected.iter().rev().skip(1) { + assert_eq!(query.previous_boundary(&mut cursor), Some(*byte as u32)); + } + query.advance_columns(&mut cursor, u32::MAX); + assert_eq!(cursor.byte(), length as u32); + } + + fn scalar_boundaries(text: &str) -> Vec { + text.char_indices() + .map(|(offset, _)| offset) + .chain(iter::once(text.len())) + .collect() + } + + fn grapheme_boundaries(text: &str) -> Vec { + text.grapheme_indices(true) + .map(|(offset, _)| offset) + .chain(iter::once(text.len())) + .collect() + } + + fn plain_row(line: &str, tab_size: u32) -> PlainRow { + let mut text = String::new(); + let mut columns = Vec::new(); + let mut points = Vec::new(); + let mut boundaries = Vec::new(); + let mut column = 0; + let mut character_column = 0; + for (byte, grapheme) in line.grapheme_indices(true) { + points.push((column, byte as u32)); + columns.extend( + grapheme + .char_indices() + .map(|(offset, _)| ((byte + offset) as u32, column)), + ); + if grapheme == "\t" { + let width = if byte < 256 { + tab_size - character_column % tab_size + } else { + 1 + }; + boundaries.extend(text.len()..text.len() + width as usize); + text.extend(iter::repeat_n(' ', width as usize)); + character_column += width; + column += width; + } else { + boundaries.push(text.len()); + text.push_str(grapheme); + character_column += grapheme.chars().count() as u32; + column += 1; + } + } + columns.push((line.len() as u32, column)); + points.push((column, line.len() as u32)); + boundaries.push(text.len()); + PlainRow { + text, + len: column, + columns, + points, + boundaries, + } + } + + fn expected_points( + row: u32, + columns: &Range, + expected: &PlainRow, + ) -> Option<(Point, Point)> { + if columns.start != columns.end && columns.start >= expected.len { + return None; + } + let point = |column| { + Point::new( + row, + expected + .points + .iter() + .rev() + .find(|(candidate, _)| *candidate <= column) + .expect("row start") + .1, + ) + }; + Some((point(columns.start), point(columns.end))) + } + + fn assert_plain_rows(snapshot: &DisplaySnapshot, tab_size: u32) { + let raw = snapshot.buffer_snapshot().text(); + let expected = raw + .split('\n') + .map(|line| plain_row(line, tab_size)) + .collect::>(); + assert_eq!( + snapshot.tab_snapshot().text(), + expected + .iter() + .map(|row| row.text.as_str()) + .collect::>() + .join("\n") + ); + let mut query = ColumnarSelectionRows::new(snapshot); + for (row, expected) in expected.iter().enumerate() { + let row = row as u32; + assert_eq!( + query.row(row).columns_for_bytes(0..u32::MAX).end, + expected.len + ); + for &(byte, column) in &expected.columns { + assert_eq!( + query.columns_for_range(Point::new(row, byte)..Point::new(row, byte)), + column..column + ); + } + for column in 0..=expected.len + 1 { + for columns in [column..column, column..column + 1, column..u32::MAX] { + assert_eq!( + query.points_for_row(row, &columns), + expected_points(row, &columns, expected), + "row {row}, columns {columns:?}, text {:?}", + expected.text + ); + } + } + } + } + + fn check_snapshot( + snapshot: &DisplaySnapshot, + rebuilt: &DisplaySnapshot, + blocks_agree: bool, + plain: Option<&str>, + tab_size: u32, + rng: &mut StdRng, + operation: usize, + ) { + let text = snapshot.tab_snapshot().text(); + assert_eq!( + text, + rebuilt.tab_snapshot().text(), + "operation {operation}: tab text" + ); + assert_eq!( + snapshot.fold_snapshot().text(), + rebuilt.fold_snapshot().text(), + "operation {operation}: fold text" + ); + assert_eq!( + snapshot.inlay_snapshot().text(), + rebuilt.inlay_snapshot().text(), + "operation {operation}: inlay text" + ); + let raw = snapshot.buffer_snapshot().text(); + assert_eq!(raw, rebuilt.buffer_snapshot().text()); + if let Some(plain) = plain { + assert_eq!(raw, plain); + } + let expected = plain.map(|plain| { + plain + .split('\n') + .map(|line| plain_row(line, tab_size)) + .collect::>() + }); + if let Some(expected) = &expected { + assert_eq!( + text, + expected + .iter() + .map(|row| row.text.as_str()) + .collect::>() + .join("\n"), + "operation {operation}: independent expansion" + ); + } + let lines = text.split('\n').collect::>(); + let raw_lines = raw.split('\n').collect::>(); + let raw_boundaries = grapheme_boundaries(&raw); + let mut query = ColumnarSelectionRows::new(snapshot); + let mut rebuilt_query = ColumnarSelectionRows::new(rebuilt); + assert_eq!( + snapshot.tab_snapshot().max_point().row() as usize + 1, + lines.len() + ); + let sources = scalar_boundaries(&raw); + for (start, end) in [ + (0, raw.len()), + ( + sources[rng.random_range(0..sources.len())], + sources[rng.random_range(0..sources.len())], + ), + ] { + let point = |offset| { + Point::new( + raw[..offset].matches('\n').count() as u32, + raw[..offset].rsplit('\n').next().expect("line").len() as u32, + ) + }; + let range = point(start)..point(end); + let columns = query.columns_for_range(range.clone()); + assert_eq!( + columns, + rebuilt_query.columns_for_range(range.clone()), + "operation {operation}: source {range:?}" + ); + if let Some(expected) = &expected { + let column = |point: Point| { + expected[point.row as usize] + .columns + .iter() + .find(|(byte, _)| *byte == point.column) + .expect("source byte") + .1 + }; + let (start, end) = (column(range.start), column(range.end)); + assert_eq!( + columns, + start.min(end)..start.max(end), + "operation {operation}: independent source {range:?}" + ); + } + } + let folded = snapshot.fold_snapshot().text(); + let expanded = folded + .split('\n') + .map(|line| plain_row(line, tab_size)) + .collect::>(); + let mut rows = vec![ + 0, + lines.len() - 1, + rng.random_range(0..lines.len()), + rng.random_range(0..lines.len()), + ]; + rows.sort_unstable(); + rows.dedup(); + for row in rows { + assert_eq!(lines[row], expanded[row].text); + let boundaries = &expanded[row].boundaries; + let len = boundaries.len() as u32 - 1; + assert_eq!( + snapshot.tab_snapshot().line_len(row as u32), + lines[row].len() as u32 + ); + assert_eq!( + query.row(row as u32).columns_for_bytes(0..u32::MAX).end, + len, + "operation {operation}: row {row}" + ); + assert_eq!( + rebuilt_query + .row(row as u32) + .columns_for_bytes(0..u32::MAX) + .end, + len + ); + let candidates = enumerated_points(snapshot, &raw, row as u32, boundaries); + let middle = rng.random_range(0..=len + 1); + let requests = [ + 0..0, + 0..u32::MAX, + middle..middle, + middle..middle + 1, + middle..rng.random_range(middle..=len + 1), + len..len, + len..len + 1, + u32::MAX..u32::MAX, + ] + .map(|columns| (row as u32, columns)); + let answers = query.points_for_rows(&requests); + let rebuilt_answers = rebuilt_query.points_for_rows(&requests); + let mut checked_points = HashSet::default(); + for (((_, columns), points), rebuilt_points) in + requests.into_iter().zip(answers).zip(rebuilt_answers) + { + if blocks_agree { + assert_eq!( + points, rebuilt_points, + "operation {operation}: row {row}, columns {columns:?}" + ); + } + if let Some(expected) = &expected { + assert_eq!( + points, + expected_points(row as u32, &columns, &expected[row]), + "operation {operation}: independent row {row}, columns {columns:?}" + ); + } + let expected = nearest_points(&columns, len, &candidates); + assert_eq!( + points, expected, + "operation {operation}: enumerated raw boundaries, row {row}, columns {columns:?}, raw {raw:?}, tab {text:?}" + ); + if let Some((start, end)) = points { + assert!(start <= end); + for point in [start, end] { + if !checked_points.insert(point) { + continue; + } + let line = raw_lines + .get(point.row as usize) + .expect("raw row in bounds"); + assert!(point.column as usize <= line.len()); + let offset = raw_lines + .iter() + .take(point.row as usize) + .map(|line| line.len() + 1) + .sum::() + + point.column as usize; + assert_eq!( + raw_boundaries + .get(raw_boundaries.partition_point(|boundary| *boundary < offset)) + .copied(), + Some(offset), + "operation {operation}: full-buffer grapheme {point:?}, raw {raw:?}" + ); + let forward = snapshot + .tab_snapshot() + .point_to_tab_point(point, Bias::Left); + assert_eq!(forward.row(), row as u32); + assert_eq!( + snapshot + .tab_snapshot() + .tab_point_to_point(forward, Bias::Left), + point + ); + let column = boundaries + .binary_search(&(forward.column() as usize)) + .expect("tab grapheme boundary") + as u32; + assert_eq!( + query.points_for_row(row as u32, &(column..column)), + Some((point, point)) + ); + let display = point.to_display_point(snapshot); + assert!(!snapshot.is_block_line(display.row())); + assert_eq!(display.to_point(snapshot), point); + } + } + } + } + assert_eq!(query.points_for_row(lines.len() as u32, &(0..0)), None); + } + + fn separated_columnar_ranges( + row: u32, + indices: Range, + stride: u32, + width: u32, + reversed: bool, + ) -> Vec> { + indices + .map(|index| { + let start = Point::new(row, index as u32 * stride); + let end = Point::new(row, start.column + width); + if reversed { end..start } else { start..end } + }) + .collect() + } + + fn set_columnar_action_state( + cx: &mut EditorTestContext, + text: &str, + expected: &[Range], + ) { + cx.update_editor(|editor, window, cx| { + editor.set_text(text, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(expected.iter().cloned()); + }); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + expected, + ); + }); + } + + #[track_caller] + fn assert_columnar_action_work( + cx: &mut EditorTestContext, + above: bool, + expected: &[Range], + work_bound: usize, + ) { + cx.update_editor(|editor, window, cx| { + ROW_WORK.set((0, 0)); + if above { + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + } else { + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + } + let (boundaries, chunks) = ROW_WORK.replace((0, 0)); + assert!(boundaries > 0); + assert!(chunks > 0); + assert!( + boundaries + chunks <= work_bound, + "above {above}: {boundaries} boundaries + {chunks} chunks > {work_bound}", + ); + let actual = editor + .selections + .ranges::(&editor.display_snapshot(cx)); + assert_eq!(actual.len(), expected.len(), "above {above}"); + for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(actual, expected, "above {above}, selection {index}"); + } + }); + } +} diff --git a/crates/editor/src/cursor_animation.rs b/crates/editor/src/cursor_animation.rs new file mode 100644 index 0000000000..79a9faeae5 --- /dev/null +++ b/crates/editor/src/cursor_animation.rs @@ -0,0 +1,1343 @@ +// Cursor animation physics adapted from vscode-neovide-cursor: +// https://github.com/LengineerC/vscode-neovide-cursor +// +// Copyright (c) 2025 LengineerC +// Licensed under the MIT License: +// https://github.com/LengineerC/vscode-neovide-cursor/blob/main/LICENSE + +use collections::HashMap; +use gpui::{Bounds, Pixels, Point, point, px}; +use std::time::{Duration, Instant}; + +const MAX_FRAME_DURATION: Duration = Duration::from_millis(33); +const SPRING_RESET_EPSILON: f32 = 0.001; +const SPRING_ACTIVE_EPSILON: f32 = 0.01; +const CORNER_ACTIVE_DISTANCE: f32 = 0.5; +const GEOMETRY_EPSILON: f32 = 0.01; +const SHORT_MOVE_THRESHOLD: f32 = 8.0; +const LEADING_SNAP_THRESHOLD: f32 = 0.5; +const SNAP_ANIMATION_LENGTH_SECONDS: f32 = 0.02; +const ANIMATION_RESET_THRESHOLD_SECONDS: f32 = 0.075; +const MAX_TRAIL_DISTANCE_FACTOR: f32 = 100.0; +const RANK_TRAIL_FACTORS: [f32; 4] = [1.0, 0.9, 0.5, 0.3]; +const ANIMATION_LENGTH_SECONDS: f32 = 0.125; +const SHORT_ANIMATION_LENGTH_SECONDS: f32 = 0.05; +const TRAIL_SIZE: f32 = 1.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct LogicalCursorPosition { + pub row: u32, + pub column: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CursorViewport { + content_origin: Point, + text_bounds: Bounds, + scroll_position: Point, + scroll_pixel_position: Point, + line_height: Pixels, + em_advance: Pixels, +} + +impl CursorViewport { + pub(crate) fn new( + content_origin: Point, + text_bounds: Bounds, + scroll_position: Point, + scroll_pixel_position: Point, + line_height: Pixels, + em_advance: Pixels, + ) -> Self { + Self { + content_origin, + text_bounds, + scroll_position, + scroll_pixel_position, + line_height, + em_advance, + } + } + + fn is_finite(self) -> bool { + f32::from(self.content_origin.x).is_finite() + && f32::from(self.content_origin.y).is_finite() + && f32::from(self.text_bounds.origin.x).is_finite() + && f32::from(self.text_bounds.origin.y).is_finite() + && f32::from(self.text_bounds.size.width).is_finite() + && f32::from(self.text_bounds.size.height).is_finite() + && self.scroll_position.x.is_finite() + && self.scroll_position.y.is_finite() + && self.scroll_pixel_position.x.is_finite() + && self.scroll_pixel_position.y.is_finite() + && f32::from(self.line_height).is_finite() + && f32::from(self.em_advance).is_finite() + } + + fn has_same_layout(self, other: Self) -> bool { + self.content_origin == other.content_origin + && self.text_bounds == other.text_bounds + && self.line_height == other.line_height + && self.em_advance == other.em_advance + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct AnimationPoint { + x: f32, + y: f32, +} + +impl AnimationPoint { + fn length(self) -> f32 { + self.x.hypot(self.y) + } + + fn normalized(self) -> Self { + let length = self.length(); + if length == 0.0 || !length.is_finite() { + return Self::default(); + } + + Self { + x: self.x / length, + y: self.y / length, + } + } + + fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() + } +} + +impl std::ops::Add for AnimationPoint { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::Sub for AnimationPoint { + type Output = Self; + + fn sub(self, other: Self) -> Self { + Self { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct CursorGeometry { + origin: AnimationPoint, + width: f32, + height: f32, +} + +impl CursorGeometry { + fn from_bounds(bounds: Bounds) -> Self { + Self { + origin: AnimationPoint { + x: bounds.origin.x.into(), + y: bounds.origin.y.into(), + }, + width: bounds.size.width.into(), + height: bounds.size.height.into(), + } + } + + fn center(self) -> AnimationPoint { + AnimationPoint { + x: self.origin.x + self.width / 2.0, + y: self.origin.y + self.height / 2.0, + } + } + + fn is_finite(self) -> bool { + self.origin.is_finite() + && self.width.is_finite() + && self.height.is_finite() + && self.width > 0.0 + && self.height > 0.0 + } + + fn has_same_size(self, other: Self) -> bool { + nearly_equal(self.width, other.width) && nearly_equal(self.height, other.height) + } + + fn has_same_origin(self, other: Self) -> bool { + nearly_equal(self.origin.x, other.origin.x) && nearly_equal(self.origin.y, other.origin.y) + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct DampedSpringAnimation { + position: f32, + velocity: f32, +} + +impl DampedSpringAnimation { + fn update(&mut self, elapsed_seconds: f32, animation_length: f32) -> bool { + if !elapsed_seconds.is_finite() + || elapsed_seconds < 0.0 + || !animation_length.is_finite() + || animation_length <= elapsed_seconds + || self.position.abs() < SPRING_RESET_EPSILON + { + self.reset(); + return false; + } + + if elapsed_seconds == 0.0 { + return self.position.abs() >= SPRING_ACTIVE_EPSILON; + } + + let angular_frequency = 4.0 / animation_length; + let initial_position = self.position; + let combined_velocity = self.position * angular_frequency + self.velocity; + let decay = (-angular_frequency * elapsed_seconds).exp(); + self.position = (initial_position + combined_velocity * elapsed_seconds) * decay; + self.velocity = decay + * (-initial_position * angular_frequency + - combined_velocity * elapsed_seconds * angular_frequency + + combined_velocity); + + if !self.position.is_finite() + || !self.velocity.is_finite() + || self.position.abs() < SPRING_RESET_EPSILON + { + self.reset(); + return false; + } + + self.position.abs() >= SPRING_ACTIVE_EPSILON + } + + fn reset(&mut self) { + self.position = 0.0; + self.velocity = 0.0; + } +} + +#[derive(Clone, Copy, Debug)] +struct Corner { + relative_position: AnimationPoint, + current_position: AnimationPoint, + target_position: AnimationPoint, + horizontal_animation: DampedSpringAnimation, + vertical_animation: DampedSpringAnimation, + animation_length: f32, +} + +impl Corner { + fn new(relative_position: AnimationPoint) -> Self { + Self { + relative_position, + current_position: AnimationPoint::default(), + target_position: AnimationPoint::default(), + horizontal_animation: DampedSpringAnimation::default(), + vertical_animation: DampedSpringAnimation::default(), + animation_length: 0.0, + } + } + + fn destination(self, geometry: CursorGeometry) -> AnimationPoint { + let center = geometry.center(); + AnimationPoint { + x: center.x + self.relative_position.x * geometry.width, + y: center.y + self.relative_position.y * geometry.height, + } + } + + fn direction_alignment(self, geometry: CursorGeometry) -> f32 { + let travel_direction = (self.destination(geometry) - self.current_position).normalized(); + let corner_direction = self.relative_position.normalized(); + travel_direction.x * corner_direction.x + travel_direction.y * corner_direction.y + } + + fn snap(&mut self, geometry: CursorGeometry) { + let destination = self.destination(geometry); + self.current_position = destination; + self.target_position = destination; + self.horizontal_animation.reset(); + self.vertical_animation.reset(); + } + + fn retarget(&mut self, geometry: CursorGeometry, rank: usize) { + let destination = self.destination(geometry); + let horizontal_jump = + (destination.x - self.target_position.x) / geometry.width.max(f32::EPSILON); + let vertical_jump = + (destination.y - self.target_position.y) / geometry.height.max(f32::EPSILON); + let normalized_jump = AnimationPoint { + x: horizontal_jump, + y: vertical_jump, + } + .normalized(); + let corner_direction = self.relative_position.normalized(); + let leading_alignment = + normalized_jump.x * corner_direction.x + normalized_jump.y * corner_direction.y; + let is_short_jump = horizontal_jump.abs() <= SHORT_MOVE_THRESHOLD + && vertical_jump.abs() <= SPRING_RESET_EPSILON; + + let base_animation_length = if is_short_jump { + ANIMATION_LENGTH_SECONDS.min(SHORT_ANIMATION_LENGTH_SECONDS) + } else { + ANIMATION_LENGTH_SECONDS + }; + let reference_animation_length = if leading_alignment > LEADING_SNAP_THRESHOLD { + SNAP_ANIMATION_LENGTH_SECONDS + } else { + base_animation_length * RANK_TRAIL_FACTORS[rank.min(3)] + }; + self.animation_length = base_animation_length + + (reference_animation_length - base_animation_length) * TRAIL_SIZE; + + if self.animation_length > ANIMATION_RESET_THRESHOLD_SECONDS { + self.horizontal_animation.reset(); + self.vertical_animation.reset(); + } + + self.target_position = destination; + self.horizontal_animation.position = destination.x - self.current_position.x; + self.vertical_animation.position = destination.y - self.current_position.y; + } + + fn update(&mut self, elapsed_seconds: f32, max_trail_distance: f32) -> bool { + self.horizontal_animation + .update(elapsed_seconds, self.animation_length); + self.vertical_animation + .update(elapsed_seconds, self.animation_length); + self.horizontal_animation.position = self + .horizontal_animation + .position + .clamp(-max_trail_distance, max_trail_distance); + self.vertical_animation.position = self + .vertical_animation + .position + .clamp(-max_trail_distance, max_trail_distance); + self.current_position = AnimationPoint { + x: self.target_position.x - self.horizontal_animation.position, + y: self.target_position.y - self.vertical_animation.position, + }; + self.horizontal_animation.position.abs() > CORNER_ACTIVE_DISTANCE + || self.vertical_animation.position.abs() > CORNER_ACTIVE_DISTANCE + } +} + +#[derive(Clone)] +pub(crate) struct CursorAnimationState { + corners: [Corner; 4], + target_geometry: Option, + last_logical_position: Option, + last_viewport: Option, + last_frame_at: Option, + active: bool, +} + +#[derive(Default)] +pub(crate) struct CursorAnimationStates { + states: HashMap, + newest_selection_id: Option, + newest_state: Option, +} + +impl CursorAnimationStates { + pub(crate) fn reconcile_newest_selection(&mut self, newest_selection_id: Option) { + // Mouse selection begins with a fresh pending selection id. Seed that id from an + // independent snapshot of the previous newest cursor's geometry and velocity. The + // snapshot survives selection replacement and visibility filtering, so click animation + // does not depend on whether a render-time SelectionLayout happens to compare equal to + // the pending selection. + if let Some(newest_selection_id) = newest_selection_id + && self.newest_selection_id != Some(newest_selection_id) + && !self.states.contains_key(&newest_selection_id) + { + let inherited_state = self + .newest_selection_id + .and_then(|selection_id| self.states.get(&selection_id).cloned()) + .or_else(|| self.newest_state.clone()); + if let Some(state) = inherited_state { + self.states.insert(newest_selection_id, state); + } + } + + self.newest_selection_id = newest_selection_id; + } + + pub(crate) fn capture_newest_state(&mut self) { + if let Some(newest_selection_id) = self.newest_selection_id + && let Some(state) = self.states.get(&newest_selection_id) + { + self.newest_state = Some(state.clone()); + } + } + + pub(crate) fn update( + &mut self, + selection_id: usize, + logical_position: LogicalCursorPosition, + target_bounds: Bounds, + viewport: CursorViewport, + now: Instant, + ) -> Option<[Point; 4]> { + self.states.entry(selection_id).or_default().update( + logical_position, + target_bounds, + viewport, + now, + ) + } + + pub(crate) fn remove(&mut self, selection_id: usize) { + self.states.remove(&selection_id); + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(usize) -> bool) { + self.states.retain(|selection_id, _| keep(*selection_id)); + } + + pub(crate) fn clear(&mut self) { + self.states.clear(); + self.newest_selection_id = None; + self.newest_state = None; + } + + #[cfg(test)] + pub(crate) fn has_active_animation(&self) -> bool { + self.states.values().any(|state| state.active) + } +} + +impl Default for CursorAnimationState { + fn default() -> Self { + const RELATIVE_POSITIONS: [AnimationPoint; 4] = [ + AnimationPoint { x: -0.5, y: -0.5 }, + AnimationPoint { x: 0.5, y: -0.5 }, + AnimationPoint { x: 0.5, y: 0.5 }, + AnimationPoint { x: -0.5, y: 0.5 }, + ]; + + Self { + corners: RELATIVE_POSITIONS.map(Corner::new), + target_geometry: None, + last_logical_position: None, + last_viewport: None, + last_frame_at: None, + active: false, + } + } +} + +impl CursorAnimationState { + pub(crate) fn update( + &mut self, + logical_position: LogicalCursorPosition, + target_bounds: Bounds, + viewport: CursorViewport, + now: Instant, + ) -> Option<[Point; 4]> { + let target_geometry = CursorGeometry::from_bounds(target_bounds); + if !target_geometry.is_finite() || !viewport.is_finite() { + self.reset(); + return None; + } + + let Some(mut previous_geometry) = self.target_geometry else { + self.snap(logical_position, target_geometry, viewport, now); + return None; + }; + let Some(previous_viewport) = self.last_viewport else { + self.snap(logical_position, target_geometry, viewport, now); + return None; + }; + + let logical_position_changed = self.last_logical_position != Some(logical_position); + let viewport_changed = previous_viewport != viewport; + + if viewport_changed { + if !previous_viewport.has_same_layout(viewport) { + self.snap(logical_position, target_geometry, viewport, now); + return None; + } + + self.translate(AnimationPoint { + x: (previous_viewport.scroll_pixel_position.x - viewport.scroll_pixel_position.x) + as f32, + y: (previous_viewport.scroll_pixel_position.y - viewport.scroll_pixel_position.y) + as f32, + }); + if let Some(target_geometry) = self.target_geometry { + previous_geometry = target_geometry; + } + } + + let target_origin_changed = !previous_geometry.has_same_origin(target_geometry); + let geometry_size_changed = !previous_geometry.has_same_size(target_geometry); + let geometry_changed = target_origin_changed || geometry_size_changed; + + if !logical_position_changed && geometry_changed { + self.snap(logical_position, target_geometry, viewport, now); + return None; + } + + self.last_logical_position = Some(logical_position); + self.last_viewport = Some(viewport); + self.target_geometry = Some(target_geometry); + + if logical_position_changed && geometry_changed { + let elapsed = if self.active { + self.elapsed_since_last_frame(now) + } else { + Duration::ZERO + }; + self.retarget(target_geometry); + self.advance(elapsed); + self.last_frame_at = Some(now); + } else if self.active { + let elapsed = self.elapsed_since_last_frame(now); + self.advance(elapsed); + self.last_frame_at = Some(now); + } + + self.active.then(|| { + self.corners + .map(|corner| point(px(corner.current_position.x), px(corner.current_position.y))) + }) + } + + fn translate(&mut self, delta: AnimationPoint) { + for corner in &mut self.corners { + corner.current_position = corner.current_position + delta; + corner.target_position = corner.target_position + delta; + } + if let Some(target_geometry) = &mut self.target_geometry { + target_geometry.origin = target_geometry.origin + delta; + } + } + + pub(crate) fn reset(&mut self) { + self.target_geometry = None; + self.last_logical_position = None; + self.last_viewport = None; + self.last_frame_at = None; + self.active = false; + for corner in &mut self.corners { + corner.horizontal_animation.reset(); + corner.vertical_animation.reset(); + } + } + + fn snap( + &mut self, + logical_position: LogicalCursorPosition, + geometry: CursorGeometry, + viewport: CursorViewport, + now: Instant, + ) { + for corner in &mut self.corners { + corner.snap(geometry); + } + self.target_geometry = Some(geometry); + self.last_logical_position = Some(logical_position); + self.last_viewport = Some(viewport); + self.last_frame_at = Some(now); + self.active = false; + } + + fn retarget(&mut self, geometry: CursorGeometry) { + let mut aligned_corners: [(usize, f32); 4] = + std::array::from_fn(|index| (index, self.corners[index].direction_alignment(geometry))); + aligned_corners.sort_by(|left, right| left.1.total_cmp(&right.1)); + let mut ranks = [0; 4]; + for (rank, (index, _)) in aligned_corners.into_iter().enumerate() { + ranks[index] = rank; + } + + for (index, corner) in self.corners.iter_mut().enumerate() { + corner.retarget(geometry, ranks[index]); + } + self.active = self.corners.iter().any(|corner| { + corner.horizontal_animation.position != 0.0 || corner.vertical_animation.position != 0.0 + }); + } + + fn elapsed_since_last_frame(&self, now: Instant) -> Duration { + self.last_frame_at + .and_then(|last_frame_at| now.checked_duration_since(last_frame_at)) + .unwrap_or_default() + .min(MAX_FRAME_DURATION) + } + + fn advance(&mut self, elapsed: Duration) { + let elapsed_seconds = elapsed.as_secs_f32(); + let max_trail_distance = self + .target_geometry + .map(|geometry| geometry.width.max(geometry.height) * MAX_TRAIL_DISTANCE_FACTOR) + .unwrap_or_default(); + self.active = self.corners.iter_mut().fold(false, |active, corner| { + corner.update(elapsed_seconds, max_trail_distance) || active + }); + + if !self.active + && let Some(target_geometry) = self.target_geometry + { + for corner in &mut self.corners { + corner.snap(target_geometry); + } + } + } +} + +fn nearly_equal(left: f32, right: f32) -> bool { + (left - right).abs() <= GEOMETRY_EPSILON +} + +pub(crate) fn animated_corners_overlap_target( + target_bounds: Bounds, + corners: &[Point; 4], +) -> bool { + let min_x = corners + .iter() + .map(|p| p.x) + .min() + .expect("the corners argument is a 4 item array, which should always have an x min"); + let max_x = corners + .iter() + .map(|p| p.x) + .max() + .expect("the corners argument is a 4 item array, which should always have an x max"); + let min_y = corners + .iter() + .map(|p| p.y) + .min() + .expect("the corners argument is a 4 item array, which should always have an y min"); + let max_y = corners + .iter() + .map(|p| p.y) + .max() + .expect("the corners argument is a 4 item array, which should always have an x max"); + let animated_bounds = Bounds::from_corners(point(min_x, min_y), point(max_x, max_y)); + + animated_bounds.intersects(&target_bounds) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::size; + + fn bounds(x: f32, y: f32) -> Bounds { + bounds_with_width(x, y, 2.0) + } + + fn bounds_with_width(x: f32, y: f32, width: f32) -> Bounds { + Bounds { + origin: point(px(x), px(y)), + size: size(px(width), px(20.0)), + } + } + + fn viewport(scroll_y: f64) -> CursorViewport { + CursorViewport::new( + point(px(0.0), px(0.0)), + Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(800.0), px(600.0)), + }, + point(0.0, scroll_y), + point(0.0, scroll_y * 20.0), + px(20.0), + px(8.0), + ) + } + + fn logical_position(row: u32, column: u32) -> LogicalCursorPosition { + LogicalCursorPosition { row, column } + } + + fn assert_converges_to( + state: &mut CursorAnimationState, + logical_position: LogicalCursorPosition, + target_bounds: Bounds, + viewport: CursorViewport, + now: Instant, + ) { + for frame in 1..30 { + state.update( + logical_position, + target_bounds, + viewport, + now + Duration::from_millis(frame * 16), + ); + } + + assert!(!state.active); + let target_geometry = CursorGeometry::from_bounds(target_bounds); + assert_eq!( + state.corners.map(|corner| corner.current_position), + state + .corners + .map(|corner| corner.destination(target_geometry)) + ); + } + + #[test] + fn spring_converges() { + let mut spring = DampedSpringAnimation { + position: 100.0, + velocity: 0.0, + }; + + for _ in 0..60 { + spring.update(1.0 / 120.0, 0.1); + } + + assert_eq!(spring.position, 0.0); + assert_eq!(spring.velocity, 0.0); + } + + #[test] + fn first_position_snaps_and_reset_forgets_it() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + assert!( + state + .update( + logical_position(1, 1), + bounds(10.0, 20.0), + viewport(0.0), + now, + ) + .is_none() + ); + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 10.0); + + state.reset(); + assert!(state.target_geometry.is_none()); + assert!(state.last_logical_position.is_none()); + } + + #[test] + fn mid_flight_retarget_starts_from_current_geometry() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let position_before_retarget = state.corners.map(|corner| corner.current_position); + + state.update( + logical_position(0, 20), + bounds(200.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + assert_eq!( + state.corners.map(|corner| corner.current_position), + position_before_retarget + ); + + for frame in 2..30 { + state.update( + logical_position(0, 20), + bounds(200.0, 0.0), + viewport(0.0), + now + Duration::from_millis(frame * 16), + ); + } + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 200.0); + } + + #[test] + fn short_horizontal_movement_uses_short_duration() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update(logical_position(0, 1), bounds(4.0, 0.0), viewport(0.0), now); + + assert!((state.corners[0].animation_length - 0.05).abs() < f32::EPSILON); + assert!((state.corners[3].animation_length - 0.045).abs() < f32::EPSILON); + assert!((state.corners[1].animation_length - 0.02).abs() < f32::EPSILON); + assert!((state.corners[2].animation_length - 0.02).abs() < f32::EPSILON); + } + + #[test] + fn short_mid_flight_retarget_preserves_momentum() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update(logical_position(0, 1), bounds(4.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let velocity_before_retarget = state.corners[0].horizontal_animation.velocity; + assert_ne!(velocity_before_retarget, 0.0); + + state.update( + logical_position(0, 2), + bounds(8.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + + assert_eq!( + state.corners[0].horizontal_animation.velocity, + velocity_before_retarget + ); + } + + #[test] + fn movement_after_idle_starts_from_previous_geometry() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + + let corners = state + .update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_secs(5), + ) + .unwrap(); + assert_eq!(corners[0].x, px(0.0)); + + let corners = state + .update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_secs(5) + Duration::from_millis(16), + ) + .unwrap(); + assert!(corners[0].x > px(0.0)); + assert!(corners[0].x < px(4.0)); + } + + #[test] + fn moving_to_a_wider_cursor_animates_and_converges() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update( + logical_position(0, 0), + bounds_with_width(0.0, 0.0, 8.0), + viewport(0.0), + now, + ); + + assert!( + state + .update( + logical_position(0, 1), + bounds_with_width(8.0, 0.0, 16.0), + viewport(0.0), + now, + ) + .is_some() + ); + assert!(state.active); + + assert_converges_to( + &mut state, + logical_position(0, 1), + bounds_with_width(8.0, 0.0, 16.0), + viewport(0.0), + now, + ); + } + + #[test] + fn moving_to_a_narrower_cursor_animates_and_converges() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update( + logical_position(0, 0), + bounds_with_width(0.0, 0.0, 16.0), + viewport(0.0), + now, + ); + + assert!( + state + .update( + logical_position(0, 1), + bounds_with_width(16.0, 0.0, 8.0), + viewport(0.0), + now, + ) + .is_some() + ); + assert!(state.active); + + assert_converges_to( + &mut state, + logical_position(0, 1), + bounds_with_width(16.0, 0.0, 8.0), + viewport(0.0), + now, + ); + } + + #[test] + fn one_cell_movement_overlaps_target_only_after_advancing() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + let target = bounds_with_width(8.0, 0.0, 8.0); + state.update( + logical_position(0, 0), + bounds_with_width(0.0, 0.0, 8.0), + viewport(0.0), + now, + ); + + let corners = state + .update(logical_position(0, 1), target, viewport(0.0), now) + .unwrap(); + assert!(!animated_corners_overlap_target(target, &corners)); + + let corners = state + .update( + logical_position(0, 1), + target, + viewport(0.0), + now + Duration::from_millis(16), + ) + .unwrap(); + assert!(animated_corners_overlap_target(target, &corners)); + + let mut vertical_state = CursorAnimationState::default(); + let vertical_target = bounds_with_width(0.0, 20.0, 8.0); + vertical_state.update( + logical_position(0, 0), + bounds_with_width(0.0, 0.0, 8.0), + viewport(0.0), + now, + ); + let corners = vertical_state + .update(logical_position(1, 0), vertical_target, viewport(0.0), now) + .unwrap(); + assert!(!animated_corners_overlap_target(vertical_target, &corners)); + + let corners = vertical_state + .update( + logical_position(1, 0), + vertical_target, + viewport(0.0), + now + Duration::from_millis(16), + ) + .unwrap(); + assert!(animated_corners_overlap_target(vertical_target, &corners)); + } + + #[test] + fn geometry_change_without_logical_movement_snaps() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update( + logical_position(0, 0), + bounds_with_width(0.0, 0.0, 8.0), + viewport(0.0), + now, + ); + + assert!( + state + .update( + logical_position(0, 0), + bounds_with_width(4.0, 0.0, 16.0), + viewport(0.0), + now + Duration::from_millis(16), + ) + .is_none() + ); + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 4.0); + assert_eq!(state.corners[1].current_position.x, 20.0); + } + + #[test] + fn long_movement_ranks_leading_corners_ahead_of_trailing_corners() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 20), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + + assert!(state.corners[1].animation_length < state.corners[0].animation_length); + assert!(state.corners[2].animation_length < state.corners[3].animation_length); + } + + #[test] + fn diagonal_bar_jump_keeps_dimensionally_leading_edge_together() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(10, 25), + bounds(200.0, 200.0), + viewport(0.0), + now, + ); + + // Match the reference implementation: hard-snap alignment uses a jump vector + // normalized independently by cursor width and height. For a 2x20 bar moving 200px on + // both axes, horizontal travel dominates in cursor dimensions, so both right corners + // form one leading edge instead of only the diagonal-most corner racing ahead. + assert!( + (state.corners[1].animation_length - SNAP_ANIMATION_LENGTH_SECONDS).abs() + < f32::EPSILON + ); + assert!( + (state.corners[2].animation_length - SNAP_ANIMATION_LENGTH_SECONDS).abs() + < f32::EPSILON + ); + assert!(state.corners[0].animation_length > SNAP_ANIMATION_LENGTH_SECONDS); + assert!(state.corners[3].animation_length > SNAP_ANIMATION_LENGTH_SECONDS); + + state.update( + logical_position(10, 25), + bounds(200.0, 200.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let leading_edge_height = + state.corners[2].current_position.y - state.corners[1].current_position.y; + assert!((leading_edge_height - 20.0).abs() < GEOMETRY_EPSILON); + } + + #[test] + fn moving_down_while_the_viewport_scrolls_starts_animation() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + + assert!( + state + .update(logical_position(1, 0), bounds(0.0, 0.0), viewport(1.0), now,) + .is_some() + ); + assert!(state.active); + } + + #[test] + fn moving_up_while_the_viewport_scrolls_starts_animation() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(1, 0), bounds(0.0, 0.0), viewport(1.0), now); + + assert!( + state + .update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now,) + .is_some() + ); + assert!(state.active); + } + + #[test] + fn scrolling_during_animation_preserves_motion_and_velocity() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + assert!(state.active); + let positions_before_scroll = state.corners.map(|corner| corner.current_position); + let velocities_before_scroll = state.corners.map(|corner| { + ( + corner.horizontal_animation.velocity, + corner.vertical_animation.velocity, + ) + }); + + assert!( + state + .update( + logical_position(0, 10), + bounds(100.0, -20.0), + viewport(1.0), + now + Duration::from_millis(16), + ) + .is_some() + ); + assert!(state.active); + assert_eq!( + state.corners.map(|corner| corner.current_position), + positions_before_scroll.map(|position| AnimationPoint { + x: position.x, + y: position.y - 20.0, + }) + ); + assert_eq!( + state.corners.map(|corner| ( + corner.horizontal_animation.velocity, + corner.vertical_animation.velocity, + )), + velocities_before_scroll + ); + } + + #[test] + fn layout_changes_snap_even_during_animation() { + let now = Instant::now(); + + for changed_viewport in [ + CursorViewport { + line_height: px(24.0), + ..viewport(0.0) + }, + CursorViewport { + text_bounds: Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(700.0), px(500.0)), + }, + ..viewport(0.0) + }, + CursorViewport { + em_advance: px(10.0), + ..viewport(0.0) + }, + ] { + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + assert!(state.active); + + assert!( + state + .update( + logical_position(0, 10), + bounds(100.0, 0.0), + changed_viewport, + now + Duration::from_millis(16), + ) + .is_none() + ); + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 100.0); + } + } + + #[test] + fn invalid_or_large_elapsed_time_never_produces_non_finite_values() { + let mut spring = DampedSpringAnimation { + position: 100.0, + velocity: 10.0, + }; + spring.update(f32::NAN, 0.1); + assert!(spring.position.is_finite()); + assert!(spring.velocity.is_finite()); + + spring.position = f32::INFINITY; + spring.velocity = f32::NEG_INFINITY; + spring.update(1.0, 0.1); + assert!(spring.position.is_finite()); + assert!(spring.velocity.is_finite()); + } + + #[test] + fn multiple_cursor_states_animate_independently_and_cleanup() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + for (selection_id, x) in [(1, 0.0), (2, 40.0)] { + assert!( + states + .update( + selection_id, + logical_position(0, selection_id as u32), + bounds(x, 0.0), + viewport(0.0), + now, + ) + .is_none() + ); + assert!( + states + .update( + selection_id, + logical_position(0, selection_id as u32 + 1), + bounds(x + 4.0, 0.0), + viewport(0.0), + now, + ) + .is_some() + ); + } + + assert_eq!(states.states.len(), 2); + let second_cursor_before = states + .states + .get(&2) + .unwrap() + .corners + .map(|corner| corner.current_position); + + states.update( + 1, + logical_position(0, 3), + bounds(8.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + + assert_eq!( + states + .states + .get(&2) + .unwrap() + .corners + .map(|corner| corner.current_position), + second_cursor_before + ); + + states.retain(|selection_id| selection_id == 1); + assert_eq!(states.states.len(), 1); + assert!(states.states.contains_key(&1)); + states.clear(); + assert!(states.states.is_empty()); + } + + #[test] + fn replacing_newest_selection_id_preserves_click_animation() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + assert!( + states + .update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ) + .is_none() + ); + + states.capture_newest_state(); + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&1)); + assert!(states.states.contains_key(&2)); + states.retain(|selection_id| selection_id == 2); + assert!(!states.states.contains_key(&1)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + } + + #[test] + fn adding_cursor_inherits_previous_newest_geometry_without_removing_it() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + states.update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ); + + states.capture_newest_state(); + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&1)); + assert!(states.states.contains_key(&2)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + assert_eq!( + states.states.get(&1).unwrap().last_logical_position, + Some(logical_position(0, 0)) + ); + } + + #[test] + fn newest_geometry_snapshot_survives_state_visibility_cleanup() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + states.update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ); + states.capture_newest_state(); + states.retain(|_| false); + assert!(states.states.is_empty()); + + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&2)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + } +} diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index c6c19d57c8..f65dbf4052 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -127,7 +127,7 @@ use std::{ fmt::Debug, iter, num::NonZeroU32, - ops::{self, Add, Range, Sub}, + ops::{self, Add, Range, RangeInclusive, Sub}, sync::Arc, }; @@ -137,7 +137,8 @@ use crate::{ use block_map::{BlockPointCursor, BlockRow, BlockSnapshot}; use fold_map::{FoldPointCursor, FoldSnapshot}; use inlay_map::{BufferOffsetToInlayPointCursor, InlaySnapshot}; -use tab_map::{TabPoint, TabPointCursor, TabSnapshot}; +pub(crate) use tab_map::TabPoint; +use tab_map::{TabPointCursor, TabSnapshot}; use wrap_map::{WrapMap, WrapPatch, WrapPointCursor}; #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -1409,6 +1410,7 @@ pub enum ChunkReplacement { pub struct HighlightedChunk<'a> { pub text: &'a str, pub style: Option, + pub(crate) diagnostic_underline_severity: Option, pub is_tab: bool, pub is_inlay: bool, pub replacement: Option, @@ -1422,6 +1424,7 @@ impl<'a> HighlightedChunk<'a> { ) -> impl Iterator + 'a { let mut text = self.text; let style = self.style; + let diagnostic_underline_severity = self.diagnostic_underline_severity; let is_tab = self.is_tab; let renderer = self.replacement; let is_inlay = self.is_inlay; @@ -1443,6 +1446,7 @@ impl<'a> HighlightedChunk<'a> { return Some(HighlightedChunk { text: prefix, style, + diagnostic_underline_severity, is_tab, is_inlay, replacement: renderer.clone(), @@ -1467,6 +1471,7 @@ impl<'a> HighlightedChunk<'a> { return Some(HighlightedChunk { text: invisible_text, style: Some(invisible_style), + diagnostic_underline_severity: None, is_tab: false, is_inlay, replacement: match replacement(ch) { @@ -1482,6 +1487,7 @@ impl<'a> HighlightedChunk<'a> { Some(HighlightedChunk { text: remainder, style, + diagnostic_underline_severity, is_tab, is_inlay, replacement: renderer.clone(), @@ -1513,6 +1519,30 @@ impl DisplaySnapshot { self.companion_display_snapshot.as_deref() } + fn diagnostic_severity_is_visible(&self, severity: lsp::DiagnosticSeverity) -> bool { + self.diagnostics_max_severity + .into_lsp() + .is_some_and(|max_severity| severity <= max_severity) + } + + pub(crate) fn diagnostic_underline_style( + &self, + severity: lsp::DiagnosticSeverity, + underline: bool, + is_unnecessary: bool, + editor_style: &EditorStyle, + ) -> Option { + (underline + && editor_style.show_underlines + && self.diagnostic_severity_is_visible(severity) + && !(is_unnecessary && severity > lsp::DiagnosticSeverity::WARNING)) + .then(|| UnderlineStyle { + color: Some(diagnostic_style(severity, &editor_style.status)), + thickness: 1.0.into(), + wavy: true, + }) + } + pub fn wrap_snapshot(&self) -> &WrapSnapshot { &self.block_snapshot.wrap_snapshot } @@ -1520,34 +1550,6 @@ impl DisplaySnapshot { &self.block_snapshot.wrap_snapshot.tab_snapshot } - /// The column `point` sits at once tabs are expanded, which is where it appears - /// on screen when the line isn't soft-wrapped. Unlike a display column this is - /// counted from the start of the buffer row rather than the wrapped segment. - pub(crate) fn tab_expanded_column(&self, point: Point) -> u32 { - self.tab_snapshot() - .point_to_tab_point(point, Bias::Left) - .0 - .column - } - - /// Inverse of [`Self::tab_expanded_column`], clamped to the end of the row. - pub(crate) fn point_for_tab_expanded_column( - &self, - buffer_row: MultiBufferRow, - column: u32, - ) -> Point { - let tab_snapshot = self.tab_snapshot(); - let tab_row = tab_snapshot.buffer_row_to_tab_row(buffer_row); - let column = column.min(tab_snapshot.line_len(tab_row)); - tab_snapshot.tab_point_to_point(TabPoint(Point::new(tab_row, column)), Bias::Left) - } - - /// The length of `buffer_row` once tabs are expanded. - pub(crate) fn tab_expanded_line_len(&self, buffer_row: MultiBufferRow) -> u32 { - let tab_snapshot = self.tab_snapshot(); - tab_snapshot.line_len(tab_snapshot.buffer_row_to_tab_row(buffer_row)) - } - pub fn fold_snapshot(&self) -> &FoldSnapshot { &self.block_snapshot.wrap_snapshot.tab_snapshot.fold_snapshot } @@ -1860,6 +1862,7 @@ impl DisplaySnapshot { // track the current underline style so that we can apply it to // inlay hints within the diagnostic's span let mut current_diagnostic_underline: Option = None; + let mut current_diagnostic_severity: Option = None; move |chunk| { let syntax_highlight_style = chunk @@ -1884,41 +1887,35 @@ impl DisplaySnapshot { } }); - let diagnostic_highlight = if chunk.is_inlay { - current_diagnostic_underline.map(|underline| HighlightStyle { - underline: Some(underline), - ..Default::default() - }) - } else { - let highlight = chunk - .diagnostic_severity - .filter(|severity| { - self.diagnostics_max_severity - .into_lsp() - .is_some_and(|max_severity| severity <= &max_severity) - }) - .map(|severity| HighlightStyle { - fade_out: chunk - .is_unnecessary - .then_some(editor_style.unnecessary_code_fade), - underline: (chunk.underline - && editor_style.show_underlines - && !(chunk.is_unnecessary - && severity > lsp::DiagnosticSeverity::WARNING)) - .then(|| { - let diagnostic_color = - diagnostic_style(severity, &editor_style.status); - UnderlineStyle { - color: Some(diagnostic_color), - thickness: 1.0.into(), - wavy: true, - } - }), + let (diagnostic_highlight, diagnostic_severity) = if chunk.is_inlay { + ( + current_diagnostic_underline.map(|underline| HighlightStyle { + underline: Some(underline), ..Default::default() - }); + }), + current_diagnostic_severity, + ) + } else { + let severity = chunk + .diagnostic_severity + .filter(|severity| self.diagnostic_severity_is_visible(*severity)); + let highlight = severity.map(|severity| HighlightStyle { + fade_out: chunk + .is_unnecessary + .then_some(editor_style.unnecessary_code_fade), + underline: self.diagnostic_underline_style( + severity, + chunk.underline, + chunk.is_unnecessary, + editor_style, + ), + ..Default::default() + }); current_diagnostic_underline = highlight.as_ref().and_then(|h| h.underline); - highlight + current_diagnostic_severity = + current_diagnostic_underline.and_then(|_| severity); + (highlight, current_diagnostic_severity) }; let style = [ @@ -1933,6 +1930,7 @@ impl DisplaySnapshot { HighlightedChunk { text: chunk.text, style, + diagnostic_underline_severity: diagnostic_severity, is_tab: chunk.is_tab, is_inlay: chunk.is_inlay, replacement: chunk.renderer.map(ChunkReplacement::Renderer), @@ -2475,6 +2473,31 @@ impl DisplaySnapshot { Bias::Right, ) } + + pub(crate) fn fully_replaced_tab_rows(&self, row: u32) -> Option> { + if !self.block_snapshot.has_replacement_blocks() + || row > self.tab_snapshot().max_point().row() + { + return None; + } + let wraps = self.wrap_snapshot(); + let wrap_point = wraps.tab_point_to_wrap_point(TabPoint::new(row, 0)); + let input_range = self + .block_snapshot + .replacement_block_input_range(wrap_point.row())?; + let start = wraps.to_tab_point(WrapPoint::new(input_range.start, 0)); + let start_row = start.row().checked_add(u32::from(start.column() > 0))?; + let end_row = if input_range.end > wraps.max_point().row() { + self.tab_snapshot().max_point().row() + } else { + wraps + .to_tab_point(WrapPoint::new(input_range.end, 0)) + .row() + .checked_sub(1)? + }; + let rows = start_row..=end_row; + rows.contains(&row).then_some(rows) + } } fn diagnostic_style(severity: lsp::DiagnosticSeverity, colors: &StatusColors) -> Hsla { @@ -4470,6 +4493,7 @@ pub mod tests { let chunk = HighlightedChunk { text: pilot_emoji, style: None, + diagnostic_underline_severity: None, is_tab: false, is_inlay: false, replacement: None, diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 4306d239e3..f0a164abae 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -2628,6 +2628,11 @@ impl BlockSnapshot { self.wrap_snapshot.max_point() } } + + pub(super) fn replacement_block_input_range(&self, row: WrapRow) -> Option> { + let (start, end, item) = self.transforms.find::((), &row, Bias::Right); + item?.block.as_ref()?.is_replacement().then_some(start..end) + } } impl BlockChunks<'_> { @@ -2951,8 +2956,8 @@ mod tests { use super::*; use crate::{ display_map::{ - Companion, fold_map::FoldMap, fold_map::FoldPlaceholder, inlay_map::InlayMap, - tab_map::TabMap, wrap_map::WrapMap, + Companion, DisplayMap, fold_map::FoldMap, fold_map::FoldPlaceholder, + inlay_map::InlayMap, tab_map::TabMap, wrap_map::WrapMap, }, test::test_font, }; @@ -2961,6 +2966,7 @@ mod tests { use itertools::Itertools; use language::{Buffer, Capability, Point}; use multi_buffer::{MultiBuffer, PathKey}; + use project::project_settings::DiagnosticSeverity; use rand::prelude::*; use settings::SettingsStore; use std::env; @@ -5618,6 +5624,91 @@ mod tests { assert_eq!(snapshot.snapshot.text(), "aaa\nbbb\nccc\nddd\neee\n"); } + #[gpui::test] + fn test_replacement_input_ranges_preserve_partial_tab_rows(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + init_test(cx); + crate::init(cx); + }); + let buffer = cx.update(|cx| { + MultiBuffer::build_simple("one two three\nfour five six\nseven eight", cx) + }); + let map = cx.new(|cx| { + DisplayMap::new( + buffer, + font("Helvetica"), + px(14.0), + Some(px(90.)), + 1, + 1, + FoldPlaceholder::test(), + DiagnosticSeverity::Warning, + cx, + ) + }); + let mut snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + let wraps = snapshot.wrap_snapshot().clone(); + assert_eq!( + wraps.text(), + "one two \nthree\nfour five \nsix\nseven \neight" + ); + for height in [0, 1, 4] { + let block = Block::Custom(Arc::new(CustomBlock { + id: CustomBlockId(0), + placement: BlockPlacement::Replace(Anchor::Min..=Anchor::Max), + height: Some(height), + style: BlockStyle::Fixed, + render: Arc::new(Mutex::new(Arc::new(|_| div().into_any()))), + priority: 0, + })); + for (hidden, expected) in [ + (0..6, [Some(0..=2), Some(0..=2), Some(0..=2)]), + (1..5, [None, Some(1..=1), None]), + (0..5, [Some(0..=1), Some(0..=1), None]), + (1..6, [None, Some(1..=2), Some(1..=2)]), + (2..4, [None, Some(1..=1), None]), + (0..2, [Some(0..=0), None, None]), + (4..6, [None, None, Some(2..=2)]), + (0..1, [None, None, None]), + (5..6, [None, None, None]), + ] { + let mut transforms = SumTree::new(()); + push_isomorphic(&mut transforms, RowDelta(hidden.start), &wraps); + transforms.push( + Transform { + summary: TransformSummary { + input_rows: WrapRow(hidden.end - hidden.start), + output_rows: BlockRow(height), + ..TransformSummary::default() + }, + block: Some(block.clone()), + }, + (), + ); + push_isomorphic(&mut transforms, RowDelta(6 - hidden.end), &wraps); + snapshot.block_snapshot.transforms = transforms; + assert_eq!( + (0..3) + .map(|row| snapshot.fully_replaced_tab_rows(row)) + .collect::>(), + expected, + "hidden: {hidden:?}, height: {height}" + ); + for row in 0..=6 { + assert_eq!( + snapshot + .block_snapshot + .replacement_block_input_range(WrapRow(row)), + hidden + .contains(&row) + .then_some(WrapRow(hidden.start)..WrapRow(hidden.end)), + "row: {row}, hidden: {hidden:?}, height: {height}" + ); + } + } + } + } + fn init_test(cx: &mut gpui::App) { let settings = SettingsStore::test(cx); cx.set_global(settings); diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 2739a857ae..cfa9781983 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -926,6 +926,14 @@ impl FoldSnapshot { } } + pub(crate) fn placeholder_range_at(&self, point: FoldPoint) -> Option> { + let (start, end, item) = self + .transforms + .find::((), &point, Bias::Right); + item.filter(|transform| transform.placeholder.is_some()) + .map(|_| start..end) + } + #[ztracing::instrument(skip_all)] pub(crate) fn chunks<'a>( &'a self, diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index 611be1518b..ef08783339 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -321,7 +321,7 @@ impl TabSnapshot { chunk: Chunk { text: unsafe { std::str::from_utf8_unchecked(&SPACES[..to_next_stop as usize]) }, is_tab: true, - chars: 1u128.unbounded_shl(to_next_stop) - 1, + chars: u128::MAX.unbounded_shr(u128::BITS - to_next_stop), ..Default::default() }, inside_leading_tab: to_next_stop > 0, @@ -643,7 +643,7 @@ impl TabChunks<'_> { self.chunk = Chunk { text: unsafe { std::str::from_utf8_unchecked(&SPACES[..to_next_stop as usize]) }, is_tab: true, - chars: 1u128.unbounded_shl(to_next_stop) - 1, + chars: u128::MAX.unbounded_shr(u128::BITS - to_next_stop), ..Default::default() }; self.inside_leading_tab = to_next_stop > 0; @@ -692,7 +692,7 @@ impl<'a> Iterator for TabChunks<'a> { return Some(Chunk { text: unsafe { std::str::from_utf8_unchecked(&SPACES[..len as usize]) }, is_tab: true, - chars: 1u128.unbounded_shl(len) - 1, + chars: u128::MAX.unbounded_shr(u128::BITS - len), tabs: 0, newlines: 0, ..self.chunk.clone() @@ -1299,6 +1299,75 @@ mod tests { } } + #[gpui::test] + fn test_max_tab_size_full_chunks(cx: &mut gpui::App) { + for tab_size in [127, 128, 129] { + let buffer = MultiBuffer::build_simple("\tα\t🏀", cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(tab_size).unwrap()); + let width = tab_size.min(128); + let expected = format!( + "{}α{}🏀", + " ".repeat(width as usize), + " ".repeat(width as usize - 1) + ); + let mut chunks = tab_snapshot.chunks( + TabPoint::zero()..tab_snapshot.max_point(), + LanguageAwareStyling { + tree_sitter: false, + diagnostics: false, + }, + Highlights::default(), + ); + assert_max_tab_chunk_bitmaps(&mut chunks, &expected); + assert_eq!(tab_snapshot.text(), expected); + assert_eq!( + tab_snapshot.point_to_tab_point(Point::new(0, 4), Bias::Left), + TabPoint::new(0, 2 * width + 1), + ); + assert_eq!( + tab_snapshot.tab_point_to_point(TabPoint::new(0, 2 * width + 1), Bias::Left), + Point::new(0, 4), + ); + } + } + + #[gpui::test] + fn test_max_tab_size_partial_chunks_and_seek(cx: &mut gpui::App) { + let buffer = MultiBuffer::build_simple("\t🏀", cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(128).unwrap()); + let expected = format!("{}🏀", " ".repeat(128)); + let mut reused = tab_snapshot.chunks( + TabPoint::zero()..tab_snapshot.max_point(), + LanguageAwareStyling { + tree_sitter: false, + diagnostics: false, + }, + Highlights::default(), + ); + for start in 0..=128 { + for end in [start, (start + 1).min(128), 128, 132] { + let range = TabPoint::new(0, start)..TabPoint::new(0, end); + let mut chunks = tab_snapshot.chunks( + range.clone(), + LanguageAwareStyling { + tree_sitter: false, + diagnostics: false, + }, + Highlights::default(), + ); + assert_max_tab_chunk_bitmaps(&mut chunks, &expected[start as usize..end as usize]); + reused.seek(range); + assert_max_tab_chunk_bitmaps(&mut reused, &expected[start as usize..end as usize]); + } + } + } + #[gpui::test] fn test_empty_chunk_after_leading_tab_trim(cx: &mut gpui::App) { // We fold "hello" (offsets 1..6) so the fold map creates a @@ -1755,4 +1824,26 @@ mod tests { } } } + + fn assert_max_tab_chunk_bitmaps(chunks: &mut TabChunks<'_>, expected_text: &str) { + let initial_mask = chunks + .chunk + .text + .char_indices() + .fold(0u128, |mask, (index, _)| mask | (1u128 << index)); + assert_eq!(chunks.chunk.chars, initial_mask); + let text = chunks + .map(|chunk| { + let expected_mask = chunk + .text + .char_indices() + .fold(0u128, |mask, (index, _)| mask | (1u128 << index)); + assert_eq!(chunk.chars, expected_mask, "chunk {:?}", chunk.text); + assert_eq!(chunk.tabs, 0); + assert_eq!(chunk.newlines, 0); + chunk.text + }) + .collect::(); + assert_eq!(text, expected_text); + } } diff --git a/crates/editor/src/edit_prediction.rs b/crates/editor/src/edit_prediction.rs index 1d2f5d6109..b3172ce32e 100644 --- a/crates/editor/src/edit_prediction.rs +++ b/crates/editor/src/edit_prediction.rs @@ -485,8 +485,11 @@ impl Editor { self.buffer.read(cx).last_transaction_id(cx) { if transaction_id_prev != Some(transaction_id_now) { - self.selection_history - .insert_transaction(transaction_id_now, selections); + self.selection_history.insert_transaction( + transaction_id_now, + selections, + self.add_selections_state.clone(), + ); } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e52c283b60..2ad7d780c0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -23,6 +23,7 @@ mod document_links; mod document_symbols; mod editor_settings; mod element; +mod emmet_ext; mod fold; mod folding_ranges; mod git; @@ -31,6 +32,7 @@ pub mod hover_links; pub mod hover_popover; mod indent_guides; mod inlays; +mod inline_input; pub mod items; mod jsx_tag_auto_close; mod linked_editing_ranges; @@ -61,8 +63,10 @@ pub mod test; mod clipboard; mod code_actions; +mod columnar_selection; mod completions; mod config; +mod cursor_animation; mod diagnostics; mod edit_prediction; mod input; @@ -116,6 +120,8 @@ use git::{DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover}; pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator}; pub use hover_popover::hover_markdown_style; pub use inlays::Inlay; +pub use inline_input::InlineInputState; +pub(crate) use inline_input::{InlineInputHistoryDirection, InlineInputPreview}; pub use items::MAX_TAB_TITLE_LEN; pub use linked_editing_ranges::LinkedEdits; pub use lsp::CompletionContext; @@ -142,6 +148,7 @@ use code_context_menus::{ use code_lens::CodeLensState; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; +use cursor_animation::CursorAnimationStates; use dap::TelemetrySpawnLocation; use display_map::*; use document_colors::LspColorData; @@ -359,7 +366,7 @@ pub fn init(cx: &mut App) { workspace::register_serializable_item::(cx); cx.observe_new( - |workspace: &mut Workspace, _: Option<&mut Window>, _cx: &mut Context| { + |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context| { workspace.register_action(Editor::new_file); workspace.register_action(Editor::new_file_split); workspace.register_action(Editor::new_file_vertical); @@ -367,6 +374,19 @@ pub fn init(cx: &mut App) { workspace.register_action(Editor::cancel_language_server_work); workspace.register_action(Editor::toggle_focus); workspace.register_action(Editor::view_bookmarks); + if let Some(window) = window { + cx.subscribe_in( + workspace.project(), + window, + |workspace, _, event, window, cx| { + if let project::Event::LanguageServerShowDocument(request) = event { + items::handle_lsp_show_document(workspace, request, window, cx) + .detach(); + } + }, + ) + .detach(); + } }, ) .detach(); @@ -987,6 +1007,7 @@ pub struct Editor { completion_provider: Option>, collaboration_hub: Option>, blink_manager: Entity, + cursor_animations: CursorAnimationStates, show_cursor_names: bool, hovered_cursors: HashMap>, pub show_local_selections: bool, @@ -1045,6 +1066,7 @@ pub struct Editor { linked_editing_range_task: Option>>, linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges, pending_rename: Option, + pending_inline_input: Option, searchable: bool, cursor_shape: CursorShape, /// Whether the cursor is offset one character to the left when something is @@ -1415,6 +1437,8 @@ struct DeferredSelectionEffectsState { pub struct TransactionSelections { pub undo: Arc<[Selection]>, pub redo: Option]>>, + undo_add_selections_state: Option, + redo_add_selections_state: Option, } #[derive(Default)] @@ -1431,6 +1455,7 @@ impl SelectionHistory { &mut self, transaction_id: TransactionId, selections: Arc<[Selection]>, + add_selections_state: Option, ) { if selections.is_empty() { log::error!( @@ -1444,6 +1469,8 @@ impl SelectionHistory { TransactionSelections { undo: selections, redo: None, + undo_add_selections_state: add_selections_state, + redo_add_selections_state: None, }, ); } @@ -1526,12 +1553,14 @@ struct RowHighlight { #[derive(Clone, Debug)] struct AddSelectionsState { groups: Vec, + skip_soft_wrap: bool, } #[derive(Clone, Debug)] struct AddSelectionsGroup { above: bool, stack: Vec, + goal_source: Option>, } #[derive(Clone)] @@ -1564,11 +1593,17 @@ struct SnippetState { choices: Vec>>, } +pub struct RenameTarget { + range: Range, + language_server_id: Option, +} + #[doc(hidden)] pub struct RenameState { pub range: Range, pub old_name: Arc, pub editor: Entity, + language_server_id: Option, block_id: CustomBlockId, } @@ -1701,6 +1736,8 @@ struct GutterButtonTooltip { primary: GutterButtonIntent, secondary: GutterButtonIntent, focus_handle: FocusHandle, + #[cfg(test)] + on_render: Option>>>, } impl GutterButtonTooltip { @@ -1712,7 +1749,7 @@ impl GutterButtonTooltip { } } - fn meta_text(&self, intent: GutterButtonIntent) -> String { + fn meta_text(&self) -> String { const RIGHT_CLICK_HINT: &str = "right-click for more options"; if self.primary == self.secondary { @@ -1722,11 +1759,11 @@ impl GutterButtonTooltip { modifiers: Modifiers::secondary_key(), ..Default::default() }; - let other = match intent { - GutterButtonIntent::SetBookmark => "breakpoint", - GutterButtonIntent::SetBreakpoint => "bookmark", + let secondary = match self.secondary { + GutterButtonIntent::SetBookmark => "bookmark", + GutterButtonIntent::SetBreakpoint => "breakpoint", }; - format!("{modifier_as_text}-click to add a {other}\n{RIGHT_CLICK_HINT}") + format!("{modifier_as_text}-click to add a {secondary}\n{RIGHT_CLICK_HINT}") } } @@ -1734,7 +1771,14 @@ impl Render for GutterButtonTooltip { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let intent = self.active_intent(window.modifiers()); let key_binding = KeyBinding::for_action_in(intent.action(), &self.focus_handle, cx); - let meta_text = self.meta_text(intent); + let meta_text = self.meta_text(); + + #[cfg(test)] + if let Some(on_render) = &self.on_render { + on_render + .borrow_mut() + .push((intent.as_str().to_owned(), meta_text.clone())); + } tooltip_container(cx, move |this, _| { this.child( @@ -2036,6 +2080,14 @@ impl Editor { project, window, |editor, _, event, window, cx| match event { + project::Event::RemoteIdChanged(Some(_)) + | project::Event::Reshared + | project::Event::HostReshared => { + // The per-change selection broadcast is skipped while the + // project is unshared, so re-publish current selections + // once it becomes (re)shared. + editor.republish_active_selections(window, cx); + } project::Event::RefreshCodeLens { .. } => { editor.refresh_code_lenses(None, window, cx); } @@ -2045,6 +2097,9 @@ impl Editor { project::Event::RefreshDocumentLinks { .. } => { editor.refresh_document_links(None, cx); } + project::Event::RefreshDocumentHighlights { server_id } => { + editor.refresh_document_highlights_for_server(*server_id, cx); + } project::Event::RefreshFoldingRanges { .. } => { editor.refresh_folding_ranges(None, window, cx); } @@ -2069,6 +2124,7 @@ impl Editor { editor.refresh_runnables(None, window, cx); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); + editor.refresh_document_highlights(cx); } project::Event::SnippetEdit(id, snippet_edits) => { // todo(lw): Non singletons @@ -2326,6 +2382,7 @@ impl Editor { collaboration_hub: project.clone().map(|project| Box::new(project) as _), project, blink_manager: blink_manager.clone(), + cursor_animations: CursorAnimationStates::default(), show_local_selections: true, show_scrollbars: ScrollbarAxes { horizontal: full_mode, @@ -2385,6 +2442,7 @@ impl Editor { document_highlights_task: None, linked_editing_range_task: None, pending_rename: None, + pending_inline_input: None, searchable: !is_minimap, cursor_shape: EditorSettings::get_global(cx) .cursor_shape @@ -2734,6 +2792,9 @@ impl Editor { if self.pending_rename.is_some() { key_context.add("renaming"); } + if self.pending_inline_input.is_some() { + key_context.add("inline_input"); + } if let Some(snippet_stack) = self.snippet_stack.last() { key_context.add("in_snippet"); @@ -3477,9 +3538,14 @@ impl Editor { return; } + let cancelling_group = self.selections.pending_anchor().is_none() + && self.selections.disjoint_anchors().len() > 1; if self.mode.is_full() && self.change_selections(Default::default(), window, cx, |s| s.try_cancel()) { + if cancelling_group { + self.add_selections_state = None; + } cx.notify(); return; } @@ -3496,6 +3562,7 @@ impl Editor { let mut dismissed = false; dismissed |= self.take_rename(false, window, cx).is_some(); + dismissed |= self.take_inline_input(window, cx).is_some(); dismissed |= self.hide_blame_popover(true, cx); dismissed |= hide_hover(self, cx); dismissed |= self.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); @@ -3764,6 +3831,33 @@ impl Editor { None } + fn refresh_document_highlights_for_server( + &mut self, + server_id: Option, + cx: &mut Context, + ) { + let server_relevant = server_id.is_none_or(|server_id| { + let Some(project) = self.project.as_ref() else { + return false; + }; + let cursor_position = self.selections.newest_anchor().head(); + self.buffer + .read(cx) + .text_anchor_for_position(cursor_position, cx) + .is_some_and(|(cursor_buffer, _)| { + project + .read(cx) + .lsp_store() + .read(cx) + .relevant_server_ids_for_capability_check(&cursor_buffer, cx) + .contains(&server_id) + }) + }); + if server_relevant { + self.refresh_document_highlights(cx); + } + } + fn prepare_highlight_query_from_selection( &mut self, snapshot: &DisplaySnapshot, @@ -4749,6 +4843,8 @@ impl Editor { primary, secondary, focus_handle: focus_handle.clone(), + #[cfg(test)] + on_render: None, }) .into() }) @@ -4900,6 +4996,25 @@ impl Editor { snippet: Snippet, window: &mut Window, cx: &mut Context, + ) -> Result<()> { + self.insert_snippet_with_autoindent( + insertion_ranges, + snippet, + Some(AutoindentMode::Block { + original_indent_columns: Vec::new(), + }), + window, + cx, + ) + } + + pub(crate) fn insert_snippet_with_autoindent( + &mut self, + insertion_ranges: &[Range], + snippet: Snippet, + autoindent_mode: Option, + window: &mut Window, + cx: &mut Context, ) -> Result<()> { struct Tabstop { is_end_tabstop: bool, @@ -4913,10 +5028,7 @@ impl Editor { .iter() .cloned() .map(|range| (range, snippet_text.clone())); - let autoindent_mode = AutoindentMode::Block { - original_indent_columns: Vec::new(), - }; - buffer.edit(edits, Some(autoindent_mode), cx); + buffer.edit(edits, autoindent_mode, cx); let snapshot = &*buffer.read(cx); let snippet = &snippet; @@ -5754,20 +5866,21 @@ impl Editor { .collect::(); if !line_text_after_indent.is_empty() { - let block_prefix = language_scope + let block_prefixes = language_scope .block_comment() - .map(|c| c.prefix.as_ref()) - .filter(|p| !p.is_empty()); - let doc_prefix = language_scope - .documentation_comment() - .map(|c| c.prefix.as_ref()) - .filter(|p| !p.is_empty()); + .into_iter() + .chain(language_scope.documentation_comment()) + .filter(|comment| { + language_scope.override_name() == Some("comment") + && !comment.prefix.is_empty() + && !line_text_after_indent.starts_with(comment.end.as_ref()) + }) + .map(|comment| comment.prefix.as_ref()); let comment_prefixes = language_scope .line_comment_prefixes() .iter() .map(|p| p.as_ref()) - .chain(block_prefix) - .chain(doc_prefix) + .chain(block_prefixes) .map(|prefix| (prefix, false)); let all_prefixes = comment_prefixes.chain( language_scope @@ -6789,13 +6902,14 @@ impl Editor { .map(|(i, &row)| (row, i)) .collect(); - // Compute new line start offsets after rotation (handles CRLF) - let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0; - let first_line_start = line_ranges[0].start.0; - let mut new_line_starts: Vec = vec![first_line_start]; - for text in line_texts.iter().take(num_rows - 1) { - let prev_start = *new_line_starts.last().unwrap(); - new_line_starts.push(prev_start + text.len() + newline_len); + let mut old_line_end = 0; + let mut new_line_end = 0; + let mut new_line_starts = Vec::new(); + for (range, text) in line_ranges.iter().zip(&line_texts) { + let line_start = new_line_end + (range.start.0 - old_line_end); + new_line_starts.push(line_start); + old_line_end = range.end.0; + new_line_end = line_start + text.len(); } let new_selections = selections @@ -7229,7 +7343,7 @@ impl Editor { } fn convert_text_case(text: &str, case: Case) -> String { - text.lines() + text.split('\n') .map(|line| { let trimmed_start = line.trim_start(); let leading = &line[..line.len() - trimmed_start.len()]; @@ -7812,13 +7926,23 @@ impl Editor { fn restore_selections( &mut self, - selections: Option]>>, + selections: Option<(Arc<[Selection]>, Option)>, window: &mut Window, cx: &mut Context, ) { - if let Some(selections) = selections.filter(|selections| !selections.is_empty()) { - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_anchors(selections.to_vec()); + if let Some((selections, add_selections_state)) = + selections.filter(|(selections, _)| !selections.is_empty()) + { + self.with_selection_effects_deferred(window, cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selection_collection| { + selection_collection.select_anchors_unexpanded(selections.to_vec()); + }, + ); + editor.add_selections_state = add_selections_state; }); } } @@ -7833,7 +7957,12 @@ impl Editor { if transaction.is_none() { log::error!("No selection history for undone transaction; selection unchanged"); } - let selections = transaction.map(|transaction| transaction.undo.clone()); + let selections = transaction.map(|transaction| { + ( + transaction.undo.clone(), + transaction.undo_add_selections_state.clone(), + ) + }); self.restore_selections(selections, window, cx); self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(window, cx); @@ -7855,10 +7984,14 @@ impl Editor { } if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) { - let selections = self - .selection_history - .transaction(transaction_id) - .and_then(|transaction| transaction.redo.clone()); + let selections = + self.selection_history + .transaction(transaction_id) + .and_then(|transaction| { + transaction.redo.clone().map(|selections| { + (selections, transaction.redo_add_selections_state.clone()) + }) + }); self.restore_selections(selections, window, cx); self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(window, cx); @@ -7995,8 +8128,12 @@ impl Editor { drop(snapshot); Some(cx.spawn_in(window, async move |this, cx| { - let rename_range = prepare_rename.await?; - if let Some(rename_range) = rename_range { + let rename_target = prepare_rename.await?; + if let Some(RenameTarget { + range: rename_range, + language_server_id, + }) = rename_target + { this.update_in(cx, |this, window, cx| { let snapshot = cursor_buffer.read(cx).snapshot(); let rename_buffer_range = rename_range.to_offset(&snapshot); @@ -8148,6 +8285,7 @@ impl Editor { range, old_name, editor: rename_editor, + language_server_id, block_id, }); })?; @@ -8191,6 +8329,7 @@ impl Editor { &buffer, start, new_name.clone(), + rename.language_server_id, cx, )?; @@ -8379,7 +8518,7 @@ impl Editor { buffers.retain(|buffer| !buffer.read(cx).read_only()); let transaction_id_prev = buffer.read(cx).last_transaction_id(cx); - let selections_prev = transaction_id_prev + let (selections_prev, add_selections_state_prev) = transaction_id_prev .and_then(|transaction_id_prev| { // default to selections as they were after the last edit, if we have them, // instead of how they are now. @@ -8387,9 +8526,19 @@ impl Editor { // will take you back to where you made the last edit, instead of staying where you scrolled self.selection_history .transaction(transaction_id_prev) - .map(|t| t.undo.clone()) + .map(|transaction| { + ( + transaction.undo.clone(), + transaction.undo_add_selections_state.clone(), + ) + }) }) - .unwrap_or_else(|| self.selections.disjoint_anchors_arc()); + .unwrap_or_else(|| { + ( + self.selections.disjoint_anchors_arc(), + self.add_selections_state.clone(), + ) + }); let mut timeout = cx.background_executor().timer(FORMAT_TIMEOUT).fuse(); let format = project.update(cx, |project, cx| { @@ -8421,9 +8570,11 @@ impl Editor { if has_new_transaction { editor .update(cx, |editor, _| { - editor - .selection_history - .insert_transaction(transaction_id_now, selections_prev); + editor.selection_history.insert_transaction( + transaction_id_now, + selections_prev, + add_selections_state_prev, + ); }) .ok(); } @@ -8593,8 +8744,11 @@ impl Editor { .buffer .update(cx, |buffer, cx| buffer.start_transaction_at(now, cx)) { - self.selection_history - .insert_transaction(tx_id, self.selections.disjoint_anchors_arc()); + self.selection_history.insert_transaction( + tx_id, + self.selections.disjoint_anchors_arc(), + self.add_selections_state.clone(), + ); cx.emit(EditorEvent::TransactionBegun { transaction_id: tx_id, }); @@ -8615,6 +8769,7 @@ impl Editor { { if let Some(transaction) = self.selection_history.transaction_mut(transaction_id) { transaction.redo = Some(self.selections.disjoint_anchors_arc()); + transaction.redo_add_selections_state = self.add_selections_state.clone(); } else { log::error!("unexpectedly ended a transaction that wasn't started by this editor"); } @@ -8633,7 +8788,17 @@ impl Editor { ) -> bool { self.selection_history .transaction_mut(transaction_id) - .map(modify) + .map(|transaction| { + let undo = transaction.undo.clone(); + let redo = transaction.redo.clone(); + modify(transaction); + if undo != transaction.undo { + transaction.undo_add_selections_state = None; + } + if redo != transaction.redo { + transaction.redo_add_selections_state = None; + } + }) .is_some() } @@ -10011,6 +10176,18 @@ impl Editor { if !is_fresh_language { self.registered_buffers.remove(&buffer_id); } + // No event exists for a buffer detaching from a language server: a switch + // to a language with no server emits neither LanguageServerRemoved nor + // LanguageServerBufferRegistered, so the language change itself is the only + // signal that this buffer's server set changed. Run the same LSP data sweep + // as those events do, or hints and colors from the old server stay visible. + if self.project.is_some() { + self.register_buffer(*buffer_id, cx); + self.invalidate_semantic_tokens(Some(*buffer_id)); + self.update_lsp_data(Some(*buffer_id), window, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); + self.refresh_document_highlights(cx); + } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); self.update_edit_prediction_settings(cx); @@ -10742,6 +10919,7 @@ impl Editor { } fn handle_focus(&mut self, window: &mut Window, cx: &mut Context) { + self.cursor_animations.clear(); cx.emit(EditorEvent::Focused); if let Some(descendant) = self @@ -10806,6 +10984,7 @@ impl Editor { } pub fn handle_blur(&mut self, window: &mut Window, cx: &mut Context) { + self.cursor_animations.clear(); self.blink_manager.update(cx, BlinkManager::disable); self.buffer .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); @@ -11164,7 +11343,7 @@ impl Editor { self.read_scroll_position_from_db(item_id, workspace_id, window, cx); } - pub(crate) fn lsp_data_enabled(&self) -> bool { + pub fn lsp_data_enabled(&self) -> bool { self.enable_lsp_data && self.mode().is_full() } @@ -11482,6 +11661,7 @@ fn process_completion_for_edit( let replace_range = &completion.replace_range; if let CompletionSource::Lsp { insert_range: Some(insert_range), + lsp_completion, .. } = &completion.source { @@ -11520,7 +11700,7 @@ fn process_completion_for_edit( ..buffer.anchor_after(replace_range.end), ); let mut current_needle = text_to_replace.next(); - for haystack_ch in completion.label.text.chars() { + for haystack_ch in lsp_completion.label.chars() { if let Some(needle_ch) = current_needle && haystack_ch.eq_ignore_ascii_case(&needle_ch) { @@ -11543,9 +11723,8 @@ fn process_completion_for_edit( ) .collect::() .to_ascii_lowercase(); - completion + lsp_completion .label - .text .to_ascii_lowercase() .ends_with(&text_after_cursor) } else { @@ -11591,6 +11770,15 @@ pub trait CollaborationHub { fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap; fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap; fn user_names(&self, cx: &App) -> HashMap; + + /// Whether local selection changes need to be broadcast to other + /// participants. Defaults to `true`; hubs that can be certain there is no + /// audience (e.g. an unshared local project) override this so the editor can + /// skip the per-keystroke `set_active_selections` work, which is + /// `O(selections)` and pure overhead when nobody is observing. + fn should_broadcast_selections(&self, _: &App) -> bool { + true + } } impl CollaborationHub for Entity { @@ -11598,6 +11786,14 @@ impl CollaborationHub for Entity { self.read(cx).collaborators() } + fn should_broadcast_selections(&self, cx: &App) -> bool { + // `is_shared()` is true for a host that has shared the project and for a + // collab guest, and stays correct even before peer-join notifications + // have propagated locally (unlike a live collaborator count). A purely + // local project has no audience, so selections need not be broadcast. + self.read(cx).is_shared() + } + fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap { self.read(cx).user_store().read(cx).participant_indices() } @@ -11672,13 +11868,14 @@ pub trait SemanticsProvider { buffer: &Entity, position: text::Anchor, cx: &mut App, - ) -> Task>>>; + ) -> Task>>; fn perform_rename( &self, buffer: &Entity, position: text::Anchor, new_name: String, + language_server_id: Option, cx: &mut App, ) -> Option>>; } @@ -11818,7 +12015,7 @@ impl SemanticsProvider for WeakEntity { buffer: &Entity, position: text::Anchor, cx: &mut App, - ) -> Task>>> { + ) -> Task>> { let Some(this) = self.upgrade() else { return Task::ready(Ok(None)); }; @@ -11828,7 +12025,13 @@ impl SemanticsProvider for WeakEntity { let task = project.prepare_rename(buffer.clone(), position, cx); cx.spawn(async move |_, cx| { Ok(match task.await? { - PrepareRenameResponse::Success(range) => Some(range), + PrepareRenameResponse::Success { + range, + language_server_id, + } => Some(RenameTarget { + range, + language_server_id, + }), PrepareRenameResponse::InvalidPosition => None, PrepareRenameResponse::OnlyUnpreparedRenameSupported => { // Fallback on using TreeSitter info to determine identifier range @@ -11838,10 +12041,11 @@ impl SemanticsProvider for WeakEntity { if kind != Some(CharKind::Word) { return None; } - Some( - snapshot.anchor_before(range.start) + Some(RenameTarget { + range: snapshot.anchor_before(range.start) ..snapshot.anchor_after(range.end), - ) + language_server_id: None, + }) }) } }) @@ -11854,10 +12058,11 @@ impl SemanticsProvider for WeakEntity { buffer: &Entity, position: text::Anchor, new_name: String, + language_server_id: Option, cx: &mut App, ) -> Option>> { self.update(cx, |project, cx| { - project.perform_rename(buffer.clone(), position, new_name, cx) + project.perform_rename(buffer.clone(), position, new_name, language_server_id, cx) }) .ok() } diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 32b7a024d4..a06564c2ce 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -19,6 +19,7 @@ use ui::scrollbars::ShowScrollbar; pub struct EditorSettings { pub cursor_blink: bool, pub cursor_shape: Option, + pub cursor_animation: CursorAnimationSettings, pub current_line_highlight: CurrentLineHighlight, pub selection_highlight: bool, pub rounded_selection: bool, @@ -73,6 +74,11 @@ pub struct EditorSettings { pub file_diff: FileDiffSettings, } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct CursorAnimationSettings { + pub enabled: bool, +} + #[derive(Clone, Copy, Debug)] pub struct FileDiffSettings { /// Whether newly opened file diffs show the full file instead of changes only. @@ -209,6 +215,7 @@ impl EditorSettings { impl Settings for EditorSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let editor = content.editor.clone(); + let cursor_animation = editor.cursor_animation.unwrap(); let scrollbar = editor.scrollbar.unwrap(); let minimap = editor.minimap.unwrap(); let gutter = editor.gutter.unwrap(); @@ -221,6 +228,9 @@ impl Settings for EditorSettings { Self { cursor_blink: editor.cursor_blink.unwrap(), cursor_shape: editor.cursor_shape.map(Into::into), + cursor_animation: CursorAnimationSettings { + enabled: cursor_animation.enabled.unwrap(), + }, current_line_highlight: editor.current_line_highlight.unwrap(), selection_highlight: editor.selection_highlight.unwrap(), rounded_selection: editor.rounded_selection.unwrap(), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index dbc3b66c13..55a53fc0ac 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -23,7 +23,7 @@ use gpui::{ BackgroundExecutor, DismissEvent, Task, TaskExt, TestAppContext, UpdateGlobal, VisualTestContext, WindowBounds, WindowOptions, div, }; -use indoc::indoc; +use indoc::{formatdoc, indoc}; use language::{ BracketPair, BracketPairConfig, Capability::{Read, ReadOnly, ReadWrite}, @@ -3756,6 +3756,178 @@ async fn test_autoscroll(cx: &mut TestAppContext) { }); } +#[track_caller] +fn assert_cursor_row_and_scroll_top( + cx: &mut EditorTestContext, + expected_cursor_row: u32, + expected_scroll_top: f64, +) { + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let cursor_row = editor + .selections + .newest_display(&snapshot.display_snapshot) + .head() + .row(); + + assert_eq!(cursor_row, DisplayRow(expected_cursor_row)); + assert_eq!(snapshot.scroll_position().y, expected_scroll_top); + }); +} + +#[gpui::test] +async fn test_autoscroll_high_margin_keeps_cursor_centered(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + // Let viewport space, rather than the configured margin, limit the padding. + editor.set_vertical_scroll_margin(999, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + + for autoscroll in [Autoscroll::fit(), Autoscroll::newest()] { + // After allowing for the cursor row, the free space is odd, fractional, or even. + for (visible_lines, expected_top_margin) in [(6., 2.), (6.5, 2.), (7., 3.)] { + cx.simulate_window_resize(window, size(px(1000.), visible_lines * line_height)); + + // Start at zero-based row 6, away from file boundaries that could clamp scrolling. + cx.set_state("0\n1\n2\n3\n4\n5\nˇ6\n7\n8\n9\n10\n11\n12\n13\n14"); + cx.update_editor(|editor, window, cx| { + editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx); + editor.request_autoscroll(autoscroll, cx); + }); + cx.run_until_parked(); + + // The cursor's viewport offset is its row minus the scroll top. + assert_cursor_row_and_scroll_top(&mut cx, 6, 6. - expected_top_margin); + + // Moving down to row 7 must scroll with the cursor, preserving that offset. + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.request_autoscroll(autoscroll, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 7, 7. - expected_top_margin); + + // Returning to row 6 must restore the viewport, not leave room for cursor drift. + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + editor.request_autoscroll(autoscroll, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 6, 6. - expected_top_margin); + } + } +} + +#[gpui::test] +async fn test_autoscroll_auto_height_has_no_margin(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_mode(EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(4), + }); + + // Auto-height inputs must ignore even a nonzero configured margin. + editor.set_vertical_scroll_margin(3, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 4. * line_height)); + cx.set_state("ˇ0\n1\n2\n3\n4\n5\n6\n7\n8\n9"); + cx.update_editor(|editor, _, _| { + assert_eq!(editor.visible_line_count(), Some(4.)); + }); + + // Row 2 is already visible, so moving there must not reserve space below it. + cx.update_editor(|editor, window, cx| { + editor.move_down_by_lines(&MoveDownByLines { lines: 2 }, window, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 2, 0.); + + // Row 4 is just outside the viewport: scroll only far enough to reveal it. + cx.update_editor(|editor, window, cx| { + editor.move_down_by_lines(&MoveDownByLines { lines: 2 }, window, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 4, 1.); +} + +#[gpui::test] +async fn test_cursor_animation_remains_active_during_keyboard_autoscroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.cursor_animation.get_or_insert_default().enabled = Some(true); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(3, cx); + editor.set_cursor_shape(CursorShape::Block, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.0), 8.0 * line_height)); + + let text = (0..40) + .map(|row| format!("line {row}")) + .collect::>() + .join("\n"); + cx.set_state(&format!("ˇ{text}")); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + let mut previous_scroll_y = 0.0; + let mut observed_autoscroll = false; + for _ in 0..20 { + cx.update_editor(|editor, window, cx| { + editor.move_down(&Default::default(), window, cx); + }); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + let (scroll_y, animation_active) = cx.update_editor(|editor, window, cx| { + ( + editor.snapshot(window, cx).scroll_position().y, + editor.cursor_animations.has_active_animation(), + ) + }); + if scroll_y > previous_scroll_y { + assert!( + animation_active, + "cursor animation should remain active when keyboard movement autoscrolls" + ); + observed_autoscroll = true; + break; + } + previous_scroll_y = scroll_y; + } + + assert!( + observed_autoscroll, + "expected cursor movement to autoscroll" + ); +} + #[gpui::test] async fn test_autoscroll_relative(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -5189,6 +5361,213 @@ async fn test_newline_below_with_cursor_on_deleted_hunk(cx: &mut TestAppContext) ); } +#[gpui::test] +fn test_expand_excerpts_with_selection_ending_at_excerpt_boundary(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (direction, expected) in [ + (ExpandExcerptDirection::Up, "a0\na1\na2\na3\nb1\nb2\nb3"), + (ExpandExcerptDirection::Down, "a1\na2\na3\na4\nb1\nb2\nb3"), + ( + ExpandExcerptDirection::UpAndDown, + "a0\na1\na2\na3\na4\nb1\nb2\nb3", + ), + ] { + let mut cx = EditorTestContext::new_multibuffer( + cx, + ["a0\n«a1\na2\na3»\na4", "b0\n«b1\nb2\nb3»\nb4"], + ); + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::zero()..Point::new(3, 0)]); + }); + match direction { + ExpandExcerptDirection::Up => { + editor.expand_excerpts_up(&ExpandExcerptsUp { lines: 1 }, window, cx) + } + ExpandExcerptDirection::Down => { + editor.expand_excerpts_down(&ExpandExcerptsDown { lines: 1 }, window, cx) + } + ExpandExcerptDirection::UpAndDown => { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx) + } + } + }); + assert_eq!(cx.buffer_text(), expected, "{direction:?}"); + } +} + +#[gpui::test] +fn test_expand_excerpts_with_trailing_empty_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (direction, expected) in [ + (ExpandExcerptDirection::Up, "aaa\none\n"), + (ExpandExcerptDirection::Down, "aaa\n"), + (ExpandExcerptDirection::UpAndDown, "aaa\none\n"), + ] { + for select_all in [false, true] { + let mut cx = EditorTestContext::new_multibuffer(cx, ["«aaa»", "zero\none\n«»"]); + cx.update_editor(|editor, window, cx| { + if select_all { + editor.select_all(&SelectAll, window, cx); + } else { + editor.move_to_end(&MoveToEnd, window, cx); + } + match direction { + ExpandExcerptDirection::Up => { + editor.expand_excerpts_up(&ExpandExcerptsUp { lines: 1 }, window, cx) + } + ExpandExcerptDirection::Down => { + editor.expand_excerpts_down(&ExpandExcerptsDown { lines: 1 }, window, cx) + } + ExpandExcerptDirection::UpAndDown => { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx) + } + } + }); + assert_eq!( + cx.buffer_text(), + expected, + "{direction:?}, select_all={select_all}" + ); + } + } +} + +#[gpui::test] +async fn test_expand_excerpts_with_selection_on_deleted_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("zero\none\ntwo\nthree\nfour\nfive\nsix\nsevenˇ"); + let buffer = cx.multibuffer(|multi_buffer, _| multi_buffer.as_singleton().unwrap()); + cx.update_multibuffer(|multi_buffer, cx| { + let path_key = PathKey::for_buffer(&buffer, cx); + multi_buffer.set_excerpts_for_path( + path_key, + buffer, + [Point::new(2, 0)..Point::new(5, 4)], + 0, + cx, + ); + }); + cx.set_head_text("zero\none\ntwo\nthree\ndeleted\nfour\nfive\nsix\nseven"); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + assert_eq!(cx.buffer_text(), "two\nthree\ndeleted\nfour\nfive"); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + let cursor = DisplayPoint::new(DisplayRow(2), 0); + selections.select_display_ranges([cursor..cursor]); + }); + }); + assert!(cx.update_editor(|editor, _, _| { + editor + .selections + .newest_anchor() + .head() + .diff_base_anchor() + .is_some() + })); + cx.update_editor(|editor, window, cx| { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); + }); + assert_eq!( + cx.buffer_text(), + "one\ntwo\nthree\ndeleted\nfour\nfive\nsix" + ); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 4) + ]); + }); + }); + cx.update_editor(|editor, window, cx| { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); + }); + assert_eq!( + cx.buffer_text(), + "zero\none\ntwo\nthree\ndeleted\nfour\nfive\nsix\nseven" + ); +} + +#[gpui::test] +async fn test_expand_excerpts_with_selection_in_removed_path(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_a = cx.new(|cx| Buffer::local("a0\na1\na2\na3\na4", cx)); + let buffer_b = cx.new(|cx| Buffer::local("b0\nb1\nb2\nb3\nb4", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_a, + [Point::new(1, 0)..Point::new(3, 2)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_b.clone(), + [Point::new(1, 0)..Point::new(3, 2)], + 0, + cx, + ); + multibuffer + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project, multibuffer.clone(), window, cx) + }); + // The second path's excerpt begins with a deleted hunk. + editor.update_in(cx, |editor, window, cx| { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text( + "b0\ndeleted\nb1\nb2\nb3\nb4", + &buffer_b.read(cx).text_snapshot(), + cx, + ) + }); + editor + .buffer + .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "a1\na2\na3\ndeleted\nb1\nb2\nb3"); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); + }); + }); + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts(PathKey::sorted(0), cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "deleted\nb1\nb2\nb3"); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "b0\ndeleted\nb1\nb2\nb3\nb4"); + }); +} + #[gpui::test] fn test_newline_below_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -7038,22 +7417,8 @@ async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { init_test(cx, |_| {}); { - let language = Arc::new(Language::new( - LanguageConfig { - line_comments: vec!["// ".into(), "/// ".into()], - documentation_comment: Some(BlockCommentConfig { - start: "/*".into(), - end: "*/".into(), - prefix: "* ".into(), - tab_size: 1, - }), - ..LanguageConfig::default() - }, - None, - )); - let mut cx = EditorTestContext::new(cx).await; - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); // Strips the comment prefix (with trailing space) from the joined-in line. cx.set_state(indoc! {" @@ -7117,22 +7482,30 @@ async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { // Strips block comment body prefix (`* `) from the joined-in line. cx.set_state(indoc! {" + /* * ˇfoo * bar + */ "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" + /* * fooˇ bar + */ "}); // Strips bare block comment body prefix (`*` without trailing space). cx.set_state(indoc! {" + /* * ˇfoo * + */ "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" + /* * fooˇ + */ "}); } @@ -7208,6 +7581,92 @@ async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_join_lines_preserves_rust_operators(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + for insert_whitespace in [true, false] { + let separator = if insert_whitespace { " " } else { "" }; + for indent in ["", " ", "\t"] { + for (first_line, next_line) in [ + ("let value =", "*pointer;"), + ("let value =", "* pointer;"), + ("let value =", "**pointer;"), + ("let value =", "*指针;"), + ("let value = 2", "* 3;"), + ("let value = \"text", "*text\";"), + ("let value = r#\"text", "*text\"#;"), + ] { + cx.set_state(&formatdoc! {" + fn main() {{ + {first_line}ˇ + {indent}{next_line} + }}"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + fn main() {{ + {first_line}ˇ{separator}{next_line} + }}"}); + } + } + } +} + +#[gpui::test] +async fn test_join_lines_rust_block_comments(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + for insert_whitespace in [true, false] { + let separator = if insert_whitespace { " " } else { "" }; + for start in ["/*", "/**", "/*!"] { + for next_line in ["* bar", "*bar"] { + cx.set_state(&formatdoc! {" + {start} + * fooˇ + {next_line} + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ{separator}bar + */"}); + } + + cx.set_state(&formatdoc! {" + {start} + * fooˇ + * + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ + */"}); + + cx.set_state(&formatdoc! {" + {start} + * fooˇ + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ{separator}*/"}); + } + } +} + #[gpui::test] async fn test_custom_newlines_cause_no_false_positive_diffs( executor: BackgroundExecutor, @@ -8283,6 +8742,16 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { « hello_world\t\tˇ» "}); + cx.set_state(indoc! {" + «hello world + ˇ»goodbye + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + «hello_world + ˇ»goodbye + "}); + // Test selections with `line_mode() = true`. cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.set_state(indoc! {" @@ -8533,6 +9002,40 @@ async fn test_rotate_selections(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_rotate_selections_nonconsecutive_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + ˇline1 + line2 + liˇne3 + line4ˇ + "}); + + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line4ˇ + line2 + ˇline1 + liˇne3 + "}); + + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline1 + line2 + liˇne3 + line4ˇ + "}); +} + #[gpui::test] fn test_move_line_up_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -11236,6 +11739,7 @@ async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) { )); cx.set_state("package test\n\nˇ"); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.run_until_parked(); cx.assert_editor_state("package test\n\nfunc find() {\n\treturn 1\n}ˇ"); // The block moves by however far its first line moved, so a first line that @@ -11243,6 +11747,7 @@ async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) { cx.write_to_clipboard(ClipboardItem::new_string(" foo()\n bar()".into())); cx.set_state("func test() {\nˇ\n}"); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.run_until_parked(); cx.assert_editor_state("func test() {\n foo()\nbar()ˇ\n}"); } @@ -11526,6 +12031,1983 @@ async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestA ); } +#[gpui::test] +async fn test_add_selection_grapheme_columns(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (text, columns) in [ + ("e\u{301}x|Z\néé|Zˇ", [4..4, 4..4]), + ("e\u{301}x|Z\néé|Zˇ", [4..5, 4..5]), + ("éx|Z\ne\u{301}x|Zˇ", [3..3, 4..4]), + ("👩\u{200d}💻x|Z\nax|Zˇ", [12..13, 2..3]), + ("🇦🇶x|Z\nax|Zˇ", [8..9, 1..2]), + ("\t\u{301}x\n\t\u{301}xˇ", [1..1, 1..1]), + ("\t\u{301}x\n\t\u{301}xˇ", [1..3, 1..3]), + ] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state(text); + cx.update_editor(|editor, window, cx| { + assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_goal_after_moving(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + editor.move_right(&MoveRight, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcˇdef\nabcˇdef\nabcˇdef"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_unprojectable_sources(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for above in [false, true] { + for reversed in [false, true] { + for mixed in [false, true] { + cx.set_state("aˇ\nb\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(1, 0)); + editor.splice_inlays( + &[], + vec![inlays::Inlay::mock_hint(0, position, "HH")], + cx, + ); + let source = if reversed { + Point::new(1, 0)..Point::new(0, 1) + } else { + Point::new(0, 1)..Point::new(1, 0) + }; + let other_row = if above { 3 } else { 2 }; + let mut original = vec![source.clone()]; + if mixed { + original.push(Point::new(other_row, 1)..Point::new(other_row, 1)); + } + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| selections.select_ranges(original.clone()), + ); + let original_id = editor.selections.first_anchor().id; + add_selection_in_direction(editor, above, true, window, cx); + let mut expected = vec![source]; + if mixed { + expected.extend([ + Point::new(2, 1)..Point::new(2, 1), + Point::new(3, 1)..Point::new(3, 1), + ]); + } + assert_add_selection_ranges(editor, &expected, cx); + assert_eq!(editor.selections.first_anchor().id, original_id); + add_selection_in_direction(editor, !above, true, window, cx); + assert_add_selection_ranges(editor, &original, cx); + assert_eq!(editor.selections.first_anchor().id, original_id); + editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_and_reversal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, text, source, columns) in [ + ( + false, + "abc\ndefghi\nuvwxyz\nabcdefˇ", + Point::new(0, 1)..Point::new(1, 4), + [1..3, 1..4, 1..4, 1..4], + ), + ( + true, + "abcdef\nuvwxyz\ndefghi\nabcˇ", + Point::new(2, 4)..Point::new(3, 1), + [1..4, 1..4, 1..4, 1..3], + ), + ] { + for reversed in [false, true] { + for deferred in [false, true] { + cx.set_state(text); + cx.update_editor(|editor, window, cx| { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([if reversed { + source.end..source.start + } else { + source.clone() + }]); + }, + ); + for (direction, count) in [ + (above, 3), + (above, 4), + (!above, 3), + (above, 4), + (!above, 3), + (!above, 2), + (!above, 1), + (!above, 1), + (above, 2), + ] { + if deferred { + editor.with_selection_effects_deferred( + window, + cx, + |editor, window, cx| { + add_selection_in_direction(editor, direction, true, window, cx); + }, + ); + } else { + add_selection_in_direction(editor, direction, true, window, cx); + } + let expected = columns + .iter() + .enumerate() + .filter(|(row, _)| { + if above { + *row >= 4 - count + } else { + *row < count + } + }) + .map(|(row, columns)| { + let start = Point::new(row as u32, columns.start); + let end = Point::new(row as u32, columns.end); + if reversed { end..start } else { start..end } + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + } + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_single_projection_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\nˇ»\nabcdef"); + cx.update_editor(|editor, window, cx| { + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(1, 0)); + editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "HHHH")], cx); + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + }); + cx.assert_editor_state("a«bcˇ»\n\nabcdef"); + for _ in 0..2 { + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\n\na«bcdˇ»ef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\n\nabcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_after_moving_or_typing( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for typing in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + if typing { + editor.handle_input("X", window, cx); + } else { + editor.move_right(&MoveRight, window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + }); + let expected = if typing { + "aXˇ\ndXˇhi\nuXˇyz\nabˇcdef" + } else { + "abcˇ\ndefgˇhi\nuvwxˇyz\nabcˇdef" + }; + cx.assert_editor_state(expected); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state(expected); + } +} + +#[gpui::test] +fn test_add_selection_unwrapped_reseeds_after_folding_source_buffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new_multibuffer(cx, ["«abcdef»", "«x\nabcdef\nabcdef»"]); + let source_buffer_id = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .next() + .expect("source excerpt") + .context + .start + .buffer_id + }); + cx.set_selections_state("abcdˇef\nx\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdˇef\nxˇ\nabcdˇef\nabcdef"); + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(source_buffer_id, cx); + }); + cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\naˇbcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\naˇbcdef"); +} + +#[gpui::test] +fn test_add_selection_unwrapped_retains_goal_after_folding_projection_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + for above in [false, true] { + let mut cx = EditorTestContext::new_multibuffer( + cx, + if above { + ["«abcdef»", "«uvwxyz»", "«defghi\nabc»"] + } else { + ["«abc\ndefghi»", "«uvwxyz»", "«abcdef»"] + }, + ); + let projection_buffer_id = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .nth(1) + .expect("projection excerpt") + .context + .start + .buffer_id + }); + cx.set_selections_state(if above { + "abcdef\nuvwxyz\ndefg«hi\naˇ»bc" + } else { + "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef" + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(if above { + "abcdef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»" + } else { + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\nabcdef" + }); + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(projection_buffer_id, cx); + }); + cx.assert_editor_state(if above { + "abcdef\nuvwxyz\nd«efgˇ»hi\na«bcˇ»" + } else { + "a«bcˇ»\nd«efgˇ»hi\nuvwxyz\nabcdef" + }); + for _ in 0..2 { + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(if above { + "a«bcdˇ»ef\nuvwxyz\nd«efgˇ»hi\na«bcˇ»" + } else { + "a«bcˇ»\nd«efgˇ»hi\nuvwxyz\na«bcdˇ»ef" + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, !above, true, window, cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_reverses_groups_with_deleted_sources(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef\nabˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abˇcdef\nabˇcdef\nabcdef\nabˇcdef\nabˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + let snapshot = editor.display_snapshot(cx); + let source_ids = editor + .selections + .all::(&snapshot) + .into_iter() + .filter(|selection| selection.start.row % 3 == 0) + .map(|selection| selection.id) + .collect::>(); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + for id in source_ids { + selections.delete(id); + } + }); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abˇcdef\nabˇcdef\nabcdef\nabˇcdef\nabˇcdef\nabcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_after_members_span_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.select_down(&SelectDown, window, cx); + }); + cx.assert_editor_state("ab«cdef\nabˇ»«cdef\nabˇ»cdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("ab«cdef\nabˇ»«cdef\nabˇ»cdef\nabˇcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_fresh_pixel_source_reseeds_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for above in [false, true] { + cx.set_state(if above { + "abcdef\nx\nabcdˇef" + } else { + "abcdˇef\nx\nabcdef" + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, false, window, cx); + }); + cx.assert_editor_state(if above { + "abcdef\nxˇ\nabcdˇef" + } else { + "abcdˇef\nxˇ\nabcdef" + }); + cx.update_editor(|editor, window, cx| { + let source_row = if above { 2 } else { 0 }; + let source_id = editor + .selections + .disjoint_anchors() + .iter() + .find(|selection| { + selection + .start + .to_point(&editor.buffer.read(cx).snapshot(cx)) + .row + == source_row + }) + .expect("source selection") + .id; + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.delete(source_id); + }); + let clamped_id = editor.selections.disjoint_anchors()[0].id; + add_selection_in_direction(editor, above, false, window, cx); + assert_eq!( + stored_selection_ranges(editor, cx), + if above { + [ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + ] + } else { + [ + Point::new(1, 1)..Point::new(1, 1), + Point::new(2, 1)..Point::new(2, 1), + ] + } + ); + assert!( + editor + .selections + .disjoint_anchors() + .iter() + .any(|selection| selection.id == clamped_id) + ); + }); + } +} + +#[gpui::test] +async fn test_add_selection_keeps_fresh_source_inside_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 0)..Point::new(1, 6), + FoldPlaceholder::test(), + )], + false, + window, + cx, + ); + assert_eq!(editor.display_text(cx), "⋯\nabcdef"); + let source_id = editor.selections.disjoint_anchors()[0].id; + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let stored = stored_selection_ranges(editor, cx); + assert_eq!(stored[0], Point::new(0, 2)..Point::new(0, 2)); + assert_eq!(stored.len(), 2); + assert_eq!(stored[1].start.row, 2); + assert_eq!(editor.selections.disjoint_anchors()[0].id, source_id); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + assert_eq!( + stored_selection_ranges(editor, cx), + [Point::new(0, 2)..Point::new(0, 2)] + ); + assert_eq!(editor.selections.disjoint_anchors()[0].id, source_id); + }); + } +} + +#[gpui::test] +async fn test_add_selection_keeps_group_members_coalesced_by_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 0)..Point::new(1, 6), + FoldPlaceholder::test(), + )], + false, + window, + cx, + ); + assert_eq!(editor.display_text(cx), "⋯\nabcdef"); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let stored = stored_selection_ranges(editor, cx); + assert_eq!( + stored[..2], + [ + Point::new(0, 2)..Point::new(0, 2), + Point::new(1, 2)..Point::new(1, 2), + ] + ); + assert_eq!(stored.len(), 3); + assert_eq!(stored[2].start.row, 2); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + assert_eq!( + stored_selection_ranges(editor, cx), + [Point::new(0, 2)..Point::new(0, 2)] + ); + }); + } +} + +#[gpui::test] +fn test_add_selection_keeps_sources_on_block_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + for skip_soft_wrap in [false, true] { + for visible_source in [None, Some(false), Some(true)] { + let mut cx = EditorTestContext::new_multibuffer(cx, ["«abcdef»", "«abcdef\nabcdef»"]); + let buffer_ids = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>() + }); + let folded = if visible_source.is_some() { + &buffer_ids[..1] + } else { + &buffer_ids[..] + }; + cx.set_selections_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, _, cx| { + editor.fold_buffers(folded.iter().copied(), cx); + }); + let mut expected = vec![Point::zero()..Point::zero()]; + cx.update_editor(|editor, window, cx| { + assert_eq!(stored_selection_ranges(editor, cx), expected); + if let Some(pending) = visible_source { + expected.push(Point::new(1, 2)..Point::new(1, 2)); + if pending { + let position = + Point::new(1, 2).to_display_point(&editor.display_snapshot(cx)); + editor.begin_selection(position, true, 1, window, cx); + assert!(editor.selections.pending_anchor().is_some()); + } else { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges(expected.clone()); + }, + ); + } + assert_eq!(stored_selection_ranges(editor, cx), expected); + } + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + if visible_source.is_some() { + expected.push(Point::new(2, 2)..Point::new(2, 2)); + } + assert_eq!(stored_selection_ranges(editor, cx), expected); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + if visible_source.is_some() { + expected.pop(); + } + assert_eq!(stored_selection_ranges(editor, cx), expected); + editor.handle_input("X", window, cx); + }); + cx.update_editor(|editor, _, cx| { + for buffer_id in folded { + editor.unfold_buffer(*buffer_id, cx); + } + }); + cx.assert_editor_state(if visible_source.is_some() { + "Xˇabcdef\nabXˇcdef\nabcdef" + } else { + "Xˇabcdef\nabcdef\nabcdef" + }); + } + } +} + +#[gpui::test] +fn test_add_selection_skips_block_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new_multibuffer( + cx, + ["«abcdef»", "«abcdefghij\nx»", "«abcdef»", "«abcdefghij\nx»"], + ); + let folded_buffer_ids = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .skip(1) + .step_by(2) + .collect::>() + }); + for skip_soft_wrap in [false, true] { + for column in [0, 4] { + let source = Point::new(0, column)..Point::new(0, column); + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([source.clone()]); + }); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + assert_add_selection_ranges( + editor, + &[source.clone(), Point::new(1, column)..Point::new(1, column)], + cx, + ); + }); + } + } + cx.update_editor(|editor, _, cx| { + editor.fold_buffers(folded_buffer_ids, cx); + }); + for skip_soft_wrap in [false, true] { + for (column, above) in [(0, false), (4, false), (0, true), (4, true)] { + let source_row = if above { 3 } else { 0 }; + let source = Point::new(source_row, column)..Point::new(source_row, column); + let target = Point::new(3 - source_row, column)..Point::new(3 - source_row, column); + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([source.clone()]); + }); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let mut expected = vec![source.clone(), target]; + expected.sort_by_key(|range| range.start); + assert_add_selection_ranges(editor, &expected, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, &expected, cx); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, std::slice::from_ref(&source), cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_restores_groups(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, two, three) in [ + ( + false, + "abcdˇef\nab\nabcdef\nabcdef", + "abcdˇef\nabˇ\nabcdef\nabcdef", + "abcdˇef\nabˇ\nabcdˇef\nabcdef", + ), + ( + true, + "abcdef\nabcdef\nab\nabcdˇef", + "abcdef\nabcdef\nabˇ\nabcdˇef", + "abcdef\nabcdˇef\nabˇ\nabcdˇef", + ), + ] { + for skip_soft_wrap in [false, true] { + for deferred in [false, true] { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(two); + cx.update_editor(|editor, window, cx| { + if deferred { + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + } else { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + } + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state(two); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(initial); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state(two); + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state(initial); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(three); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_history_deferred_batch(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdˇef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdˇef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_history_deferred_external_movement(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for move_first in [false, true] { + cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + if move_first { + add_selection_in_direction(editor, false, true, window, cx); + } + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + if !move_first { + add_selection_in_direction(editor, false, true, window, cx); + } + editor.move_right(&MoveRight, window, cx); + if move_first { + add_selection_in_direction(editor, false, true, window, cx); + } + }); + }); + if move_first { + cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdeˇf"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); + } else { + cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdeˇf"); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_restores_mode_before_switch(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for deferred in [false, true] { + cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + if deferred { + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, false, !skip_soft_wrap, window, cx); + }); + } else { + add_selection_in_direction(editor, false, !skip_soft_wrap, window, cx); + } + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_restores_unclipped_multiline_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, three, four) in multiline_add_selection_history_states() { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(four); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(four); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state(four); + } +} + +#[gpui::test] +async fn test_add_selection_text_undo_restores_multiline_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, three, four) in multiline_add_selection_history_states() { + for grouped in [false, true] { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + editor.transact(window, cx, |editor, window, cx| { + editor.insert("X", window, cx); + if grouped { + editor.insert("Y", window, cx); + } + }); + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + editor.undo(&Undo, window, cx); + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(four); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_preserves_hidden_sources(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for source_count in [1, 2] { + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + let sources = (0..source_count) + .map(|row| Point::new(row, 2)..Point::new(row, 2)) + .collect::>(); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(sources.clone()); + }); + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 0)..Point::new(1, 6), + FoldPlaceholder::test(), + )], + false, + window, + cx, + ); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let mut added = sources.clone(); + added.push(Point::new(2, 0)..Point::new(2, 0)); + assert_eq!(stored_selection_ranges(editor, cx), added); + editor.undo_selection(&UndoSelection, window, cx); + assert_eq!(stored_selection_ranges(editor, cx), sources); + editor.redo_selection(&RedoSelection, window, cx); + assert_eq!(stored_selection_ranges(editor, cx), added); + editor.undo_selection(&UndoSelection, window, cx); + assert_eq!(stored_selection_ranges(editor, cx), sources); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_removing_projection_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for remove_by_click in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz"); + cx.update_editor(|editor, window, cx| { + if remove_by_click { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 4), true, 1, window, cx); + } else { + add_selection_in_direction(editor, true, true, window, cx); + } + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nuvwxyz"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz"); + } +} + +#[gpui::test] +async fn test_add_selection_coalesced_source_reseeds_goal_and_history(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("«abcdˇ»\nabcˇd\nx\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\nxˇ\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\n«xˇ»\naˇbcd"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\nxˇ\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\n«xˇ»\naˇbcd"); +} + +#[gpui::test] +async fn test_add_selection_replacement_discards_dead_groups_and_history(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("aˇbcd\nabcd\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + assert_eq!( + editor.add_selections_state.as_ref().map(|state| { + state + .groups + .iter() + .map(|group| group.stack.len()) + .collect::>() + }), + Some(vec![3]) + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 1)..Point::new(0, 1)]); + }); + assert!(editor.add_selections_state.is_none()); + editor.move_right(&MoveRight, window, cx); + editor.move_left(&MoveLeft, window, cx); + assert!(editor.add_selections_state.is_none()); + assert_eq!( + editor + .selection_history + .undo_stack + .iter() + .rev() + .take(2) + .map(|entry| entry + .add_selections_state + .as_ref() + .map(|state| state.groups.len())) + .collect::>(), + [None, None] + ); + editor.undo_selection(&UndoSelection, window, cx); + assert!(editor.add_selections_state.is_none()); + }); + cx.assert_editor_state("abˇcd\nabcd\nabcd"); + } +} + +#[gpui::test] +async fn test_add_selection_cancel_reseeds_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, cancelled, restarted) in [ + ( + false, + "a«bc\ndefgˇ»hi\nuvwxyz", + "a«bcˇ»\ndefghi\nuvwxyz", + "a«bcˇ»\nd«efˇ»ghi\nuvwxyz", + ), + ( + true, + "uvwxyz\na«bc\ndefgˇ»hi", + "uvwxyz\na«bcˇ»\ndefghi", + "u«vwˇ»xyz\na«bcˇ»\ndefghi", + ), + ] { + for skip_soft_wrap in [false, true] { + cx.set_state(initial); + let grouped = cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let grouped = editor + .selections + .ranges::(&editor.display_snapshot(cx)); + editor.cancel(&Cancel, window, cx); + grouped + }); + cx.assert_editor_state(cancelled); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, &grouped, cx); + editor.cancel(&Cancel, window, cx); + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(restarted); + } + } +} + +#[gpui::test] +async fn test_add_selection_unrelated_pending_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (finish_pending, cancel_pending) in [(false, false), (true, false), (false, true)] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n012345"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + editor.begin_selection(DisplayPoint::new(DisplayRow(4), 0), true, 1, window, cx); + if finish_pending { + editor.end_selection(window, cx); + } else if cancel_pending { + editor.cancel(&Cancel, window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + }); + let caret = if cancel_pending { "" } else { "ˇ" }; + cx.assert_editor_state(&format!( + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef\n{caret}012345" + )); + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_cancel_reverses_group(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for above in [false, true] { + for overlap_source in [false, true] { + for restore_history in [false, true] { + cx.set_state("abcdef\nabcdef\nabcdef\nabcdefˇ"); + cx.update_editor(|editor, window, cx| { + let source_row = if above { 2 } else { 1 }; + let added_row = if above { 1 } else { 2 }; + let source = Point::new(source_row, 2); + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([source..source]); + }, + ); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap( + editor, + if overlap_source { + source_row + } else { + added_row + }, + 0..3, + window, + cx, + ); + editor.cancel(&Cancel, window, cx); + if restore_history { + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + } + assert_add_selection_ranges( + editor, + &[ + Point::new(1, 2)..Point::new(1, 2), + Point::new(2, 2)..Point::new(2, 2), + ], + cx, + ); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, &[source..source], cx); + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + assert_add_selection_ranges(editor, &[source..source], cx); + }); + } + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_commit_or_consume(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for finish_pending in [false, true] { + for (above, overlap_row, columns) in [ + (false, 1, [(0, 0..3), (1, 0..3), (2, 2..2)]), + (false, 2, [(0, 2..2), (1, 0..3), (2, 0..3)]), + (true, 1, [(1, 0..3), (2, 0..3), (3, 2..2)]), + (true, 2, [(1, 2..2), (2, 0..3), (3, 0..3)]), + ] { + cx.set_state("abcdef\nabcdef\nabcdef\nabcdefˇ"); + cx.update_editor(|editor, window, cx| { + let source = Point::new(if above { 2 } else { 1 }, 2); + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([source..source]); + }, + ); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap(editor, overlap_row, 0..3, window, cx); + if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + let expected = columns + .into_iter() + .map(|(row, columns)| { + Point::new(row, columns.start)..Point::new(row, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + assert_eq!(editor.selections.pending_anchor(), None); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_multiline_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for (above, initial, _, _) in multiline_add_selection_history_states() { + for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap( + editor, + if above { 2 } else { 1 }, + 0..2, + window, + cx, + ); + if cancel_pending { + editor.cancel(&Cancel, window, cx); + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + } else if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let columns = if cancel_pending { + [1..3, 1..4, 1..4, 1..4] + } else { + [1..3, 0..4, 0..4, 1..if skip_soft_wrap { 3 } else { 4 }] + }; + let expected = (0..4) + .map(|row| { + let columns = &columns[if above { 3 - row } else { row }]; + Point::new(row as u32, columns.start) + ..Point::new(row as u32, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_committed_source_changes(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, _, _) in multiline_add_selection_history_states() { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + begin_add_selection_overlap(editor, if above { 2 } else { 1 }, 0..2, window, cx); + let snapshot = editor.display_snapshot(cx); + let source_row = if above { 3 } else { 0 }; + let selections = editor + .selections + .disjoint_anchors() + .iter() + .map(|selection| { + let mut selection = + selection.map(|anchor| anchor.to_point(snapshot.buffer_snapshot())); + if selection.start.row == source_row { + selection.end.column = 2; + } + selection + }) + .collect::>(); + let pending = *editor.selections.pending_anchor().expect("pending overlap"); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |collection| { + collection.select(selections); + collection.set_pending(pending, SelectMode::Character); + }); + editor.cancel(&Cancel, window, cx); + add_selection_in_direction(editor, above, true, window, cx); + let columns = [1..2, 1..4, 1..4, 1..2]; + let expected = (0..4) + .map(|row| { + let columns = &columns[if above { 3 - row } else { row }]; + Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_retreat_after_buffer_edit(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for (above, initial, _, _) in multiline_add_selection_history_states() { + for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { + cx.set_state(initial); + let overlap_row = if above { 2 } else { 1 }; + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap(editor, overlap_row, 0..2, window, cx); + }); + cx.update_buffer(|buffer, cx| { + let start = Point::new(overlap_row, 0); + buffer.edit([(start..start, "ZZ")], None, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.update_selection( + DisplayPoint::new(DisplayRow(overlap_row), 0), + 0, + gpui::Point::::default(), + window, + cx, + ); + if cancel_pending { + editor.cancel(&Cancel, window, cx); + } else if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let columns = [1..3, 3..6, 1..4, 1..if skip_soft_wrap { 6 } else { 4 }]; + let mut expected = (0..4) + .map(|row| { + let columns = &columns[if above { 3 - row } else { row }]; + Point::new(row as u32, columns.start) + ..Point::new(row as u32, columns.end) + }) + .collect::>(); + if !cancel_pending { + expected.extend((1..3).map(|row| Point::new(row, 0)..Point::new(row, 0))); + expected.sort_by_key(|range| range.start); + } + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_absorbs_multiline_source(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { + cx.set_state("a«bc\ndefgˇ»hi\nuv\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 0), true, 1, window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(1), 4), + 0, + gpui::Point::::default(), + window, + cx, + ); + if cancel_pending { + editor.cancel(&Cancel, window, cx); + } else if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let columns = if cancel_pending { + [1..3, 1..4, 1..2, 1..4] + } else { + [0..3, 0..4, 0..2, 1..2] + }; + let expected = columns + .into_iter() + .enumerate() + .map(|(row, columns)| { + Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_goal_changes_are_group_local(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for remote in [false, true] { + for (changed_row, columns, goal_end) in [ + (0, Some(1..2), 2), + (1, Some(1..2), 3), + (0, None, 4), + (1, None, 4), + (1, Some(1..4), 4), + ] { + cx.set_state( + "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n\na«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n012345", + ); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.insert_range(Point::new(9, 0)..Point::new(9, 0)); + }); + let snapshot = editor.display_snapshot(cx); + let selections = editor + .selections + .all_unexpanded::(&snapshot) + .into_iter() + .filter_map(|mut selection| { + if selection.start.row == changed_row { + let columns = columns.as_ref()?; + selection.start.column = columns.start; + selection.end.column = columns.end; + } + Some(selection) + }) + .collect::>(); + if remote { + let selections = selections + .into_iter() + .map(|selection| { + selection.map(|point| snapshot.buffer_snapshot().anchor_before(point)) + }) + .collect(); + editor.set_selections_from_remote(selections, None, window, cx); + } else { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |collection| { + collection.select(selections); + }, + ); + } + add_selection_in_direction(editor, false, true, window, cx); + let expected = (0..10) + .filter_map(|row| { + let columns = if row == changed_row { + columns.clone()? + } else { + match row { + 0 | 5 => 1..3, + 1 | 2 | 6 | 7 | 8 => 1..4, + 3 => 1..goal_end, + 9 => 0..0, + _ => return None, + } + }; + Some(Point::new(row, columns.start)..Point::new(row, columns.end)) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_overlap_invalidates_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (pending, finish_pending) in [(false, false), (true, false), (true, true)] { + for preceding_range in [false, true] { + for (columns, pending_goal, inserted_goal) in + [(0..1, 4, 4), (0..2, 3, 3), (4..4, 3, 4), (4..5, 4, 4)] + { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + if preceding_range { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.insert_range(Point::new(1, 0)..Point::new(1, 1)); + }); + } + let snapshot = editor.display_snapshot(cx); + let range = snapshot.buffer_snapshot().anchor_before(Point::new(1, columns.start)) + ..snapshot.buffer_snapshot().anchor_before(Point::new(1, columns.end)); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + if pending { + selections.set_pending_anchor_range(range, SelectMode::Character); + } else { + selections.insert_range(range); + } + }); + if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + let goal_end = if pending { pending_goal } else { inserted_goal }; + assert_eq!( + editor.selections.ranges::(&editor.display_snapshot(cx)) + .into_iter() + .filter(|range| range.start.row == 3) + .collect::>(), + [Point::new(3, 1)..Point::new(3, goal_end)], + "pending={pending}, finish_pending={finish_pending}, preceding_range={preceding_range}, columns={columns:?}", + ); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_sync_preserves_unchanged_goals(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for subscribe_before in [false, true] { + for move_source in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + let (_source, _subscription) = cx.update_editor(|editor, window, cx| { + let source = cx.new(|cx| editor.clone(window, cx)); + let subscription = + subscribe_before.then(|| editor.sync_selections(source.clone(), cx)); + source.update(cx, |source, cx| { + source.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |collection| { + let mut selections = collection.all_unexpanded::( + &collection.display_snapshot(), + ); + for selection in &mut selections { + if move_source && selection.start == MultiBufferOffset(1) { + selection.end = MultiBufferOffset(2); + } + } + collection.select(selections); + }, + ); + }); + let subscription = + subscription.unwrap_or_else(|| editor.sync_selections(source.clone(), cx)); + (source, subscription) + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state(if move_source { + "a«bˇ»c\nd«efgˇ»hi\nu«vwxˇ»yz\na«bˇ»cdef" + } else { + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef" + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_goal_reseeds_from_oldest_survivor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\ndefgˇ»hi\nuv\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + let oldest_id = editor.selections.oldest_anchor().id; + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.delete(oldest_id); + }); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abc\nd«efgˇ»hi\nu«vˇ»\na«bcdˇ»ef"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_follows_buffer_edits(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for add_pending in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdefgh"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.update_buffer(|buffer, cx| { + buffer.edit([(Point::new(1, 0)..Point::new(1, 0), "ZZ")], None, cx); + }); + cx.update_editor(|editor, window, cx| { + if add_pending { + editor.begin_selection(DisplayPoint::new(DisplayRow(3), 0), true, 1, window, cx); + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + }); + let caret = if add_pending { "ˇ" } else { "" }; + cx.assert_editor_state(&format!( + "a«bcˇ»\nZZd«efgˇ»hi\nu«vwxˇ»yz\n{caret}a«bcdefˇ»gh" + )); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_follows_tab_size(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4); + }); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\n\tˇ»defgh\nabcdefghij\nabcdefghij"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\nabcdefghij"); + for (tab_size, expected) in [ + (8, "a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\na«bcdefghˇ»ij"), + (4, "a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\na«bcdˇ»efghij"), + ] { + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.tab_size = NonZeroU32::new(tab_size); + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state(expected); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_follows_inlays(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\ndefgˇ»hi\nabcdefghij\nabcdefghij"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(1, 0)); + editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "HH")], cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\na«bcdˇ»efghij\na«bcdefˇ»ghij"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\na«bcdˇ»efghij\na«bcdˇ»efghij"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_clears_pixel_goals(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("abˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.move_with(&mut |_, selection| { + selection.goal = SelectionGoal::HorizontalRange { + start: 0.0, + end: 200.0, + }; + }); + }); + add_selection_in_direction(editor, false, true, window, cx); + assert_eq!( + editor + .selections + .all::(&editor.display_snapshot(cx)) + .iter() + .map(|selection| selection.goal) + .collect::>(), + [SelectionGoal::None, SelectionGoal::None], + ); + }); + cx.assert_editor_state("abˇcdef\nabˇcdef"); +} + +#[gpui::test] +async fn test_add_selection_skips_inlay_inside_grapheme(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (prefix, row) in [ + (String::new(), 0), + (format!("{}\n\n\n", "a".repeat(121)), 3), + ] { + cx.set_state(&format!("{prefix}👩\u{200d}💻|Z\nˇ")); + cx.update_editor(|editor, window, cx| { + let buffer = editor.buffer.read(cx).snapshot(cx); + let position = buffer.anchor_before(Point::new(row, 4)); + editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "\n")], cx); + add_selection_in_direction(editor, true, true, window, cx); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [ + Point::new(row, 0)..Point::new(row, 0), + Point::new(row + 1, 0)..Point::new(row + 1, 0) + ] + ); + }); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_en_dash(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for columns in [[4..4, 2..2], [3..4, 1..2], [4..6, 2..4]] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state("–x|Z\nab|Zˇ"); + cx.update_editor(|editor, window, cx| { + assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_unicode_tabs(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.hard_tabs = Some(true); + settings.defaults.tab_size = Some(4.try_into().expect("nonzero tab size")); + }); + let mut cx = EditorTestContext::new(cx).await; + + for columns in [[8..8, 4..4], [6..8, 3..4], [8..10, 4..6]] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state("–é\tλ|Z\nab\tc|Zˇ"); + cx.update_editor(|editor, window, cx| { + assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_source_range_across_wraps(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for wrap_width in [None, Some(px(60.0)), Some(px(90.0))] { + for columns in [3..18, 3..20, 18..18] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state(" abcdefghijklmnop|Z\n ab cd efghijklmnop|Zˇ"); + cx.update_editor(|editor, window, cx| { + editor.set_wrap_width(wrap_width, cx); + if wrap_width.is_some() { + let snapshot = editor.display_snapshot(cx); + for row in 0..2 { + assert_ne!( + Point::new(row, 3).to_display_point(&snapshot).row(), + Point::new(row, 18).to_display_point(&snapshot).row(), + ); + } + } + assert_add_selection_pair( + editor, + [columns.clone(), columns.clone()], + above, + reversed, + window, + cx, + ); + }); + } + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_clamp_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for (before, intermediate, after, above) in [ + ( + "–abcˇ|Z\nx\nwxyz|Z", + "–abcˇ|Z\nxˇ\nwxyz|Z", + "–abcˇ|Z\nxˇ\nwxyzˇ|Z", + false, + ), + ( + "wxyz|Z\nx\n–abcˇ|Z", + "wxyz|Z\nxˇ\n–abcˇ|Z", + "wxyzˇ|Z\nxˇ\n–abcˇ|Z", + true, + ), + ( + "–a«ˇbc|Z»\nxyz\nuvwxyz", + "–a«ˇbc|Z»\nxy«ˇz»\nuvwxyz", + "–a«ˇbc|Z»\nxy«ˇz»\nuv«ˇwxyz»", + false, + ), + ( + "uvwxyz\nxyz\n–a«bc|Zˇ»", + "uvwxyz\nxy«zˇ»\n–a«bc|Zˇ»", + "uv«wxyzˇ»\nxy«zˇ»\n–a«bc|Zˇ»", + true, + ), + ] { + cx.set_state(before); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(intermediate); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(after); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiple_groups(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("–aˇbcdef\nx\nabcdef\nabc«ˇde»f\nxy\nabcdef"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nxˇ\nabcdef\nabc«ˇde»f\nxy\nabc«ˇde»f"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nxˇ\nabˇcdef\nabc«ˇde»f\nxy\nabc«ˇde»f"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nxˇ\nabcdef\nabc«ˇde»f\nxy\nabcdef"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nx\nabc«ˇde»f\nabc«ˇde»f\nxy\nabcdef"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_mode_switch_resets_groups(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdefghijklmnop\nqrstuvwxyzABCDEF\n0123456789abcdef"); + cx.update_editor(|editor, window, cx| { + editor.set_wrap_width(None, cx); + let details = editor.text_layout_details(window, cx); + let width = editor + .display_snapshot(cx) + .layout_row(DisplayRow(0), &details) + .width + / 2.0; + editor.set_wrap_width(Some(width), cx); + assert_eq!( + editor.display_text(cx), + "abcdefgh\nijklmnop\nqrstuvwx\nyzABCDEF\n01234567\n89abcdef", + ); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, skip_soft_wrap, !skip_soft_wrap, window, cx); + }); + if skip_soft_wrap { + cx.assert_editor_state("abˇcdefghijˇklmnop\nqrˇstuvwxyzABCDEF\n0123456789abcdef"); + } else { + cx.assert_editor_state("abˇcdefghijˇklmnop\nqrˇstuvwxyzˇABCDEF\n0123456789abcdef"); + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_eof_and_skipped_range_terminate(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for state in [ + "abcdefghijklmnopˇ", + "abcdefghijklmnop\nqrstuvwxyzABCDEFˇ", + "abcdefghijkl«mnopˇ»\nabcdefgh", + ] { + cx.set_state(state); + cx.update_editor(|editor, window, cx| { + editor.set_wrap_width(None, cx); + let details = editor.text_layout_details(window, cx); + let width = editor + .display_snapshot(cx) + .layout_row(DisplayRow(0), &details) + .width + / 4.0; + editor.set_wrap_width(Some(width), cx); + assert!(editor.display_snapshot(cx).max_point().row().0 >= 3); + for _ in 0..2 { + add_selection_in_direction(editor, false, true, window, cx); + } + }); + cx.assert_editor_state(state); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_inlay_rows_and_widths(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for columns in [[1..1, 3..3], [1..3, 3..5]] { + for above in [false, true] { + cx.set_state("a|Z\nabc|Zˇ"); + cx.update_editor(|editor, window, cx| { + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(0, 0)); + editor.splice_inlays( + &[], + vec![inlays::Inlay::mock_hint(0, position, "hint\n–é")], + cx, + ); + assert_eq!(editor.display_text(cx), "hint\n–éa|Z\nabc|Z"); + assert_add_selection_pair(editor, columns.clone(), above, false, window, cx); + editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_folded_target_boundaries(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for (source, target) in [(4..4, 2..2), (5..5, 8..8), (4..5, 2..8)] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state(if above { + "abhidden|Z\na–|Zˇ" + } else { + "a–|Z\nabhidden|Zˇ" + }); + cx.update_editor(|editor, window, cx| { + let target_row = u32::from(!above); + editor.fold_creases( + vec![Crease::simple( + Point::new(target_row, 2)..Point::new(target_row, 8), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + assert_eq!( + editor.display_text(cx), + if above { + "ab⋯|Z\na–|Z" + } else { + "a–|Z\nab⋯|Z" + } + ); + let columns = if above { + [target.clone(), source.clone()] + } else { + [source.clone(), target.clone()] + }; + assert_add_selection_pair(editor, columns, above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_fold_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for above in [false, true] { + cx.set_state(if above { + "abhidden\nhidden\n|Z\na–ˇ|Z" + } else { + "a–ˇ|Z\nabhidden\nhidden\n|Z" + }); + cx.update_editor(|editor, window, cx| { + let target_row = u32::from(!above); + editor.fold_creases( + vec![Crease::simple( + Point::new(target_row, 2)..Point::new(target_row + 2, 0), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + assert_eq!( + editor.display_text(cx), + if above { + "ab⋯|Z\na–|Z" + } else { + "a–|Z\nab⋯|Z" + } + ); + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(if above { + "abˇhidden\nhidden\n|Z\na–ˇ|Z" + } else { + "a–ˇ|Z\nabˇhidden\nhidden\n|Z" + }); + } +} + /// A different number of tabs can align the same column on each row, so a cursor has to /// be placed by the column the tabs expand to. Counting a tab as a single column lands it /// wherever that many characters happen to reach on the next row. @@ -20770,6 +23252,394 @@ async fn test_completion_mode(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_completion_without_text_edit(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language_registry = cx.language_registry(); + let language = rust_lang(); + let completion_item = Arc::new(Mutex::new(None::)); + let mut fake_servers = language_registry.register_fake_lsp( + language.name(), + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let completion_item = completion_item.clone(); + move |server| { + let completion_item = completion_item.clone(); + server.set_request_handler::(move |_, _| { + let completion_item = completion_item + .lock() + .clone() + .expect("completion item should be set before requesting completions"); + async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: true, + items: vec![completion_item], + item_defaults: None, + }))) + } + }); + } + })), + ..FakeLspAdapter::default() + }, + ); + language_registry.add(language.clone()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + let _language_server = fake_servers + .next() + .await + .expect("language server should start"); + cx.executor().run_until_parked(); + + for (initial_state, completion_text, filter_text, expected_states) in [ + ( + "test(value1, ˇvalue2)", + "value2=", + "value2", + [ + "test(value1, value2=ˇvalue2)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇvalue2)", + ], + ), + ( + "test(value1, valˇvalue2)", + "value2=", + "value2", + [ + "test(value1, value2=ˇvalue2)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇvalue2)", + "test(value1, value2=ˇvalue2)", + ], + ), + ( + "ediˇtor", + "editor", + "edi", + ["editorˇtor", "editorˇ", "editorˇ", "editorˇ"], + ), + ( + "strˇing", + "str", + "str", + ["strˇing", "strˇ", "strˇing", "strˇing"], + ), + ( + "SuˇErr", + "SubscriptionError", + "Su", + [ + "SubscriptionErrorˇErr", + "SubscriptionErrorˇ", + "SubscriptionErrorˇ", + "SubscriptionErrorˇErr", + ], + ), + ( + "test(value1, valueˇ)", + "value2=", + "value", + ["test(value1, value2=ˇ)"; 4], + ), + ( + "test(value1, ˇ)", + "value2=", + "v2", + ["test(value1, value2=ˇ)"; 4], + ), + ( + "test(value1, ˇv2)", + "value2=", + "v2", + [ + "test(value1, value2=ˇv2)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇv2)", + ], + ), + ( + "test(\"😀\", 𐐀naïˇve)", + "𐐀naïve", + "𐐀naï", + [ + "test(\"😀\", 𐐀naïveˇve)", + "test(\"😀\", 𐐀naïveˇ)", + "test(\"😀\", 𐐀naïveˇ)", + "test(\"😀\", 𐐀naïveˇ)", + ], + ), + ( + "test(\n\tˇvalue2\n)", + "value2=", + "value2", + [ + "test(\n\tvalue2=ˇvalue2\n)", + "test(\n\tvalue2=ˇ\n)", + "test(\n\tvalue2=ˇ\n)", + "test(\n\tvalue2=ˇvalue2\n)", + ], + ), + ( + "ˇvalue2", + "value2=", + "value2", + ["value2=ˇvalue2", "value2=ˇ", "value2=ˇ", "value2=ˇvalue2"], + ), + ("valueˇ", "value2=", "value", ["value2=ˇ"; 4]), + ("ˇ", "value2=", "v2", ["value2=ˇ"; 4]), + ] { + for (lsp_insert_mode, expected_state) in [ + LspInsertMode::Insert, + LspInsertMode::Replace, + LspInsertMode::ReplaceSubsequence, + LspInsertMode::ReplaceSuffix, + ] + .into_iter() + .zip(expected_states) + { + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(lsp_insert_mode), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + + for (insert_text, detail, description, use_filter_text) in [ + (None, None, None, false), + (Some(completion_text), None, None, false), + (None, Some("string"), None, false), + (Some(completion_text), Some("string"), None, false), + (None, None, Some("string"), false), + (Some(completion_text), None, Some("string"), false), + (Some(completion_text), None, None, true), + (Some(completion_text), Some(filter_text), None, true), + ] { + eprintln!( + "{initial_state:?}, {lsp_insert_mode:?}, {insert_text:?}, {detail:?}, {description:?}, {use_filter_text:?}" + ); + cx.set_state(initial_state); + *completion_item.lock() = Some(lsp::CompletionItem { + label: completion_text.to_string(), + filter_text: use_filter_text.then(|| filter_text.to_string()), + insert_text: insert_text.map(str::to_string), + detail: detail.map(str::to_string), + label_details: description.map(|description| lsp::CompletionItemLabelDetails { + detail: None, + description: Some(description.to_string()), + }), + ..lsp::CompletionItem::default() + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }) + .expect("completion should be accepted") + .await + .expect("completion should succeed"); + cx.assert_editor_state(expected_state); + } + } + } +} + +#[gpui::test] +async fn test_completion_without_text_edit_after_typing(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(LspInsertMode::Replace), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let requests = Arc::new(AtomicUsize::new(0)); + cx.set_request_handler::({ + let requests = requests.clone(); + move |_, _, _| { + requests.fetch_add(1, atomic::Ordering::Release); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "value2=".to_string(), + insert_text: Some("value2=".to_string()), + ..lsp::CompletionItem::default() + }, + ]))) + } + } + }); + let mut resolved = cx.set_request_handler::( + |_, mut item, _| async move { + item.documentation = Some(lsp::Documentation::String("resolved".to_string())); + Ok(item) + }, + ); + cx.set_state("test(value1, ˇvalue2)"); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + resolved.next().await.expect("completion should resolve"); + cx.run_until_parked(); + + cx.simulate_input("v"); + cx.run_until_parked(); + cx.simulate_input("a"); + cx.run_until_parked(); + assert_eq!(requests.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("test(value1, vaˇvalue2)"); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion_insert(&ConfirmCompletionInsert, window, cx) + }) + .expect("completion should be accepted") + .await + .expect("completion should succeed"); + cx.assert_editor_state("test(value1, value2=ˇvalue2)"); +} + +#[gpui::test] +async fn test_completion_with_explicit_replace_range(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(LspInsertMode::Insert), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + let mut cx = EditorTestContext::new(cx).await; + let language_registry = cx.language_registry(); + let language = rust_lang(); + let completion_list = Arc::new(Mutex::new(None::)); + let mut fake_servers = language_registry.register_fake_lsp( + language.name(), + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let completion_list = completion_list.clone(); + move |server| { + let completion_list = completion_list.clone(); + server.set_request_handler::(move |_, _| { + let completion_list = completion_list + .lock() + .clone() + .expect("completion list should be set before requesting completions"); + async move { Ok(Some(lsp::CompletionResponse::List(completion_list))) } + }); + } + })), + ..FakeLspAdapter::default() + }, + ); + language_registry.add(language.clone()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + let _language_server = fake_servers + .next() + .await + .expect("language server should start"); + cx.executor().run_until_parked(); + + for (lsp_insert_mode, expected_pair_state) in [ + (LspInsertMode::ReplaceSuffix, "test(value1, value2=ˇvalue2)"), + (LspInsertMode::Insert, "test(value1, value2=ˇvalue2)"), + (LspInsertMode::Replace, "test(value1, value2=ˇ)"), + (LspInsertMode::ReplaceSubsequence, "test(value1, value2=ˇ)"), + ] { + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(lsp_insert_mode), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + for (use_default_range, use_insert_range) in + [(false, true), (true, true), (false, false), (true, false)] + { + for filter_text in [Some("value2"), None] { + eprintln!( + "{lsp_insert_mode:?}, {use_default_range:?}, {use_insert_range:?}, {filter_text:?}" + ); + cx.set_state("test(value1, ˇvalue2)"); + let insert = lsp::Range::new(lsp::Position::new(0, 13), lsp::Position::new(0, 13)); + let replace = lsp::Range::new(lsp::Position::new(0, 13), lsp::Position::new(0, 19)); + let text_edit = if use_insert_range { + lsp::CompletionTextEdit::InsertAndReplace(lsp::InsertReplaceEdit { + insert, + replace, + new_text: "value2=".to_string(), + }) + } else { + lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: replace, + new_text: "value2=".to_string(), + }) + }; + let edit_range = if use_insert_range { + lsp::CompletionListItemDefaultsEditRange::InsertAndReplace { insert, replace } + } else { + lsp::CompletionListItemDefaultsEditRange::Range(replace) + }; + *completion_list.lock() = Some(lsp::CompletionList { + items: vec![lsp::CompletionItem { + label: "value2=".to_string(), + filter_text: filter_text.map(str::to_string), + text_edit: (!use_default_range).then_some(text_edit), + ..lsp::CompletionItem::default() + }], + item_defaults: use_default_range.then(|| lsp::CompletionListItemDefaults { + edit_range: Some(edit_range), + ..lsp::CompletionListItemDefaults::default() + }), + ..lsp::CompletionList::default() + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }) + .expect("completion should be accepted") + .await + .expect("completion should succeed"); + cx.assert_editor_state(if use_insert_range { + expected_pair_state + } else { + "test(value1, value2=ˇ)" + }); + } + } + } +} + #[gpui::test] async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -23145,7 +26015,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { } "}); - // If a selection span multiple lines, empty lines are not toggled. + // If a selection spans multiple lines, empty lines are also toggled. cx.set_state(indoc! {" fn a() { «a(); @@ -23158,9 +26028,9 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { - // «a(); - - // c();ˇ» + // «a(); + //• + // c();ˇ» } "}); @@ -23200,6 +26070,7 @@ async fn test_toggle_comment_ignore_indent(cx: &mut TestAppContext) { let toggle_comments = &ToggleComments { advance_downwards: false, ignore_indent: true, + comment_empty_lines: false, }; // If multiple selections intersect a line, the line is only toggled once. @@ -23309,6 +26180,160 @@ async fn test_toggle_comment_ignore_indent(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_toggle_comment_commenting_blank_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + // A prefix without a trailing space keeps every expectation below free of + // trailing whitespace, which would otherwise be stripped on save and break + // the assertions on commented blank lines. + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let toggle_comments = &ToggleComments::default(); + + cx.set_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + //«fn a() { + // b(); + // + // c(); + //}ˇ» + "}); + + // Toggling again removes the prefix from every line, blank ones included. + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + // All prefixes in a block go at one shared column so they line up: the + // smallest indent among the rows being commented. A blank line contributes + // 0 and drags that to 0 - shifting every line in the block left. VS Code + // does the same. + cx.set_state(indoc! {" + fn a() { + «b(); + + c();ˇ» + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «b(); + // + // c();ˇ» + } + "}); +} + +#[gpui::test] +async fn test_toggle_comment_uncomments_after_parameter_change(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let skip_blank_lines = &ToggleComments { + comment_empty_lines: false, + ..Default::default() + }; + let comment_blank_lines = &ToggleComments::default(); + + cx.set_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + // Comment with one binding: the blank line gets no marker. + cx.update_editor(|e, window, cx| e.toggle_comments(skip_blank_lines, window, cx)); + + cx.assert_editor_state(indoc! {" + //«fn a() { + // b(); + + // c(); + //}ˇ» + "}); + + // Toggle with the other binding. The blank line still has no marker, but it must + // not make the block look uncommented: the markers are removed, not doubled. + cx.update_editor(|e, window, cx| e.toggle_comments(comment_blank_lines, window, cx)); + + cx.assert_editor_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); +} + +#[gpui::test] +async fn test_toggle_comment_selection_of_only_blank_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let toggle_comments = &ToggleComments::default(); + + // No line carries a marker, so there is nothing to remove: comment them. + cx.set_state(indoc! {" + fn a() { + « + + ˇ»} + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + //« + // + ˇ»} + "}); +} + #[gpui::test] async fn test_advance_downward_on_toggle_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -23331,6 +26356,7 @@ async fn test_advance_downward_on_toggle_comment(cx: &mut TestAppContext) { let toggle_comments = &ToggleComments { advance_downwards: true, ignore_indent: false, + comment_empty_lines: false, }; // Single cursor on one line -> advance @@ -32862,6 +35888,7 @@ fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut Te primary: GutterButtonIntent::SetBreakpoint, secondary: GutterButtonIntent::SetBookmark, focus_handle, + on_render: None, }; let primary_intent = tooltip.active_intent(Modifiers::none()); @@ -32874,11 +35901,10 @@ fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut Te // When both features are enabled, the meta text advertises the // modifier-click alternative. - let meta = tooltip.meta_text(primary_intent); + let meta = tooltip.meta_text(); assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); + assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); assert!(meta.contains("right-click for more options")); - let meta = tooltip.meta_text(secondary_intent); - assert!(meta.contains("-click to add a breakpoint"), "got: {meta}"); // When only one feature is enabled (primary == secondary), a // modifier-click repeats the primary action, so the tooltip must not @@ -32887,11 +35913,48 @@ fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut Te primary: GutterButtonIntent::SetBreakpoint, secondary: GutterButtonIntent::SetBreakpoint, focus_handle: tooltip.focus_handle, + on_render: None, }; - let meta = single_feature_tooltip.meta_text(GutterButtonIntent::SetBreakpoint); + let meta = single_feature_tooltip.meta_text(); assert_eq!(meta, "right-click for more options"); } +#[gpui::test] +fn test_gutter_button_tooltip_renders_modifier_transitions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let renders = Rc::new(RefCell::new(Vec::new())); + let (tooltip, cx) = cx.add_window_view({ + let renders = renders.clone(); + move |_window, cx| GutterButtonTooltip { + primary: GutterButtonIntent::SetBreakpoint, + secondary: GutterButtonIntent::SetBookmark, + focus_handle: cx.focus_handle(), + on_render: Some(renders), + } + }); + cx.run_until_parked(); + + let assert_render = |expected_title: &str| { + let (title, meta) = renders.borrow().last().cloned().expect("tooltip rendered"); + assert_eq!(title, expected_title); + assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); + assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); + }; + + assert_render("Set Breakpoint"); + + cx.simulate_modifiers_change(Modifiers::secondary_key()); + tooltip.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + assert_render("Set Bookmark"); + + cx.simulate_modifiers_change(Modifiers::none()); + tooltip.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + assert_render("Set Breakpoint"); +} + #[gpui::test] async fn test_breakpoint_toggling(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -35004,6 +38067,37 @@ async fn test_bookmarks_tab_highlights_bookmark_on_trailing_empty_line(cx: &mut ); } +#[gpui::test] +async fn test_expand_excerpts_in_bookmarks_tab_with_trailing_empty_excerpt( + cx: &mut TestAppContext, +) { + let (workspace, _pane, project, editor, mut cx) = + init_bookmarks_tab_test(cx, json!({ "main.rs": "aaa", "other.rs": "zero\none\n" })).await; + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.excerpt_context_lines = Some(0); + }); + }); + }); + toggle_bookmark_on_row(&editor, 0, &mut cx); + let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; + toggle_bookmark_on_row(&other_editor, 2, &mut cx); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + bookmarks_editor.read_with(&cx, |editor, cx| editor.text(cx)), + "aaa\n" + ); + cx.dispatch_action(SelectAll); + cx.dispatch_action(ExpandExcerptsUp { lines: 1 }); + cx.run_until_parked(); + assert_eq!( + bookmarks_editor.read_with(&cx, |editor, cx| editor.text(cx)), + "aaa\none\n" + ); +} + #[gpui::test] async fn test_bookmarks_tab_keeps_unopenable_paths(cx: &mut TestAppContext) { let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( @@ -35378,6 +38472,150 @@ async fn test_bookmarks_tab_retries_failed_path_when_file_appears(cx: &mut TestA ); } +#[gpui::test] +async fn test_dynamic_document_highlight_registration_refreshes_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let debounce = Duration::from_millis( + cx.update(|_, cx| EditorSettings::get_global(cx).lsp_highlight_debounce.0), + ); + let project = cx.update_workspace(|workspace, _, _| workspace.project().clone()); + let server_id = cx.lsp.server.server_id(); + cx.set_state(indoc! {" + fn main() { + let foo = 1; + fˇoo; + } + "}); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let _request_handler = + cx.set_request_handler::({ + let request_count = request_count.clone(); + move |_, _, _| { + request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(vec![lsp::DocumentHighlight { + range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 7)), + kind: Some(lsp::DocumentHighlightKind::READ), + }])) + } + } + }); + + register_document_highlight_capability(&cx.lsp, "python-document-highlight", "python").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 0, + "expected a registration for another language to not query the server", + ); + assert_eq!(document_highlight_count(&mut cx), 0); + + register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "expected an applicable registration to refresh document highlights", + ); + assert_eq!(document_highlight_count(&mut cx), 1); + + cx.update_editor(|editor, _, cx| { + editor.refresh_document_highlights(cx); + }); + for _ in 0..3 { + cx.executor().advance_clock(debounce / 4); + cx.run_until_parked(); + cx.update(|_, cx| { + project.update(cx, |_, cx| { + cx.emit(project::Event::RefreshDocumentHighlights { + server_id: Some(LanguageServerId(server_id.0 + 1)), + }); + }); + }); + cx.run_until_parked(); + } + cx.executor().advance_clock(debounce / 4); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "expected refreshes of servers unrelated to the buffer to not restart the debounce", + ); + + cx.update(|_, cx| { + project.update(cx, |_, cx| { + cx.emit(project::Event::RefreshDocumentHighlights { + server_id: Some(server_id), + }); + }); + }); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 3, + "expected a refresh of the buffer's server to query document highlights", + ); + + cx.lsp + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 3, + "expected no server query after the applicable registration is removed", + ); + assert_eq!( + document_highlight_count(&mut cx), + 0, + "expected stale document highlights to be cleared after unregistration", + ); + + register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!(request_count.load(atomic::Ordering::SeqCst), 4); + assert_eq!(document_highlight_count(&mut cx), 1); + + let buffer = cx.update_editor(|editor, _, cx| editor.buffer().read(cx).as_singleton().unwrap()); + cx.update(|_, cx| { + project.update(cx, |project, cx| { + project.stop_language_servers_for_buffers(vec![buffer], HashSet::default(), cx); + }); + }); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 4, + "expected no document highlight request after the server is stopped", + ); + assert_eq!( + document_highlight_count(&mut cx), + 0, + "expected stale document highlights to be cleared after the server is stopped", + ); +} + #[gpui::test] async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -40613,6 +43851,95 @@ async fn test_no_duplicated_sticky_headers(cx: &mut TestAppContext) { assert_eq!(sticky_headers(5.0), vec![]); } +#[gpui::test] +async fn test_autoscroll_margin_reserves_sticky_header_space(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.vertical_scroll_margin = Some(3.0); + settings.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }); + }); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + fn demo() { + let value_01 = 1; + let value_02 = 2; + let value_03 = 3; + let value_04 = 4; + let value_05 = 5; + let value_06 = 6; + let value_07 = 7; + let value_08 = 8; + let value_09 = 9; + let value_10 = 10; + ˇlet value_11 = 11; + let value_12 = 12; + let value_13 = 13; + } + "}); + + // Use a parsed scope so the test also exercises sticky-header discovery. + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.run_until_parked(); + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + + for visible_lines in [2., 2.5] { + cx.simulate_window_resize(window, size(px(1000.), visible_lines * line_height)); + + // Center explicitly so the starting viewport does not depend on the + // Fit calculation being tested. + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([Point::new(11, 4)..Point::new(11, 4)]); + }); + editor.scroll_cursor_center(&ScrollCursorCenter, window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(snapshot.scroll_position().y, 10.); + assert_eq!(EditorElement::sticky_headers(editor, &snapshot).len(), 1); + + // Row 10 is covered by the header; moving there must scroll to expose it. + editor.move_up(&MoveUp, window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let cursor_row = editor + .selections + .newest_display(&snapshot.display_snapshot) + .head() + .row(); + let sticky_header_count = EditorElement::sticky_headers(editor, &snapshot).len(); + + assert_eq!(cursor_row, DisplayRow(10)); + assert_eq!(snapshot.scroll_position().y, 9.); + assert_eq!(sticky_header_count, 1); + + // A scroll alone is insufficient: the full cursor row must be unobscured. + assert!( + cursor_row.as_f64() >= snapshot.scroll_position().y + sticky_header_count as f64 + ); + assert!( + cursor_row.next_row().as_f64() + <= snapshot.scroll_position().y + visible_lines as f64 + ); + }); + } +} + #[gpui::test] async fn test_autoscroll_keeps_cursor_visible_below_sticky_headers(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -45927,3 +49254,317 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test ); }); } + +#[gpui::test] +async fn test_lsp_show_document(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: Some(true), + selection: Some(lsp::Range::new( + lsp::Position::new(0, 3), + lsp::Position::new(0, 9), + )), + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + editor.update(cx, |editor, cx| { + let path = editor + .buffer() + .read(cx) + .as_singleton() + .expect("a singleton buffer should be opened") + .read(cx) + .file() + .expect("the opened buffer should have a file") + .path() + .clone(); + assert_eq!(path.as_ref(), rel_path("dir/target.rs")); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + vec![Point::new(0, 3)..Point::new(0, 9)] + ); + }); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_external(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let initial_item_id = cx + .update_workspace(|workspace, _, cx| workspace.active_item(cx).map(|item| item.item_id())); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "https://zed.dev/docs".parse::().unwrap(), + external: Some(true), + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + assert_eq!(cx.opened_url(), Some("https://zed.dev/docs".to_string())); + cx.run_until_parked(); + cx.update_workspace(|workspace, _, cx| { + assert_eq!( + workspace.active_item(cx).map(|item| item.item_id()), + initial_item_id, + "external documents should not open any workspace items" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_without_take_focus(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let initial_editor = cx.editor.clone(); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let opened_editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + assert_ne!( + opened_editor.entity_id(), + initial_editor.entity_id(), + "the shown document should become the active item" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_unsupported_uri(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "untitled:some-document".parse::().unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: false }); +} + +async fn register_document_highlight_capability( + lsp: &lsp::FakeLanguageServer, + registration_id: &str, + language: &str, +) { + lsp.request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: registration_id.to_string(), + method: "textDocument/documentHighlight".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": language, "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); +} + +fn document_highlight_count(cx: &mut EditorLspTestContext) -> usize { + cx.editor(|editor, _, _| { + editor + .background_highlights + .get(&HighlightKey::DocumentHighlightRead) + .map_or(0, |(_, ranges)| ranges.len()) + }) +} + +#[track_caller] +fn assert_add_selection_pair( + editor: &mut Editor, + columns: [Range; 2], + above: bool, + reversed: bool, + window: &mut Window, + cx: &mut Context, +) { + let ranges = columns + .into_iter() + .enumerate() + .map(|(row, columns)| { + Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) + }) + .collect::>(); + let source = &ranges[usize::from(above)]; + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([if reversed { + source.end..source.start + } else { + source.clone() + }]); + }); + add_selection_in_direction(editor, above, true, window, cx); + let selections = editor.selections.all::(&editor.display_snapshot(cx)); + assert_eq!( + selections + .iter() + .map(|selection| (selection.range(), selection.reversed)) + .collect::>(), + ranges + .into_iter() + .map(|range| { + let reversed = reversed && !range.is_empty(); + (range, reversed) + }) + .collect::>(), + "above={above}, reversed={reversed}", + ); +} + +#[track_caller] +fn assert_add_selection_ranges( + editor: &mut Editor, + expected: &[Range], + cx: &mut Context, +) { + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + expected, + ); +} + +fn stored_selection_ranges(editor: &Editor, cx: &App) -> Vec> { + let buffer = editor.buffer.read(cx).snapshot(cx); + editor + .selections + .disjoint_anchors() + .iter() + .chain(editor.selections.pending_anchor()) + .map(|selection| selection.range().to_point(&buffer)) + .collect() +} + +fn add_selection_in_direction( + editor: &mut Editor, + above: bool, + skip_soft_wrap: bool, + window: &mut Window, + cx: &mut Context, +) { + if above { + editor.add_selection_above(&AddSelectionAbove { skip_soft_wrap }, window, cx); + } else { + editor.add_selection_below(&AddSelectionBelow { skip_soft_wrap }, window, cx); + } +} + +fn begin_add_selection_overlap( + editor: &mut Editor, + row: u32, + columns: Range, + window: &mut Window, + cx: &mut Context, +) { + editor.begin_selection( + DisplayPoint::new(DisplayRow(row), columns.start), + true, + 1, + window, + cx, + ); + editor.update_selection( + DisplayPoint::new(DisplayRow(row), columns.end), + 0, + gpui::Point::::default(), + window, + cx, + ); +} + +fn multiline_add_selection_history_states() -> [(bool, &'static str, &'static str, &'static str); 2] +{ + [ + ( + false, + "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef", + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\nabcdef", + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef", + ), + ( + true, + "abcdef\nuvwxyz\ndefg«hi\naˇ»bc", + "abcdef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»", + "a«bcdˇ»ef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»", + ), + ] +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 1c618ff094..0320af6ab8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -17,6 +17,7 @@ use crate::{ SelectionDragState, SizingBehavior, SoftWrap, ToPoint, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, column_pixels, + cursor_animation::{CursorViewport, LogicalCursorPosition, animated_corners_overlap_target}, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, HighlightKey, HighlightedChunk, ToDisplayPoint, @@ -37,7 +38,7 @@ use crate::{ }, }; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; -use collections::{BTreeMap, HashMap, HashSet}; +use collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use feature_flags::{DiffReviewFeatureFlag, FeatureFlagAppExt as _}; use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage}; use gpui::{ @@ -46,20 +47,20 @@ use gpui::{ Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, FontWeight, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, - ParentElement, Pixels, ScrollHandle, ShapedLine, SharedString, Size, + ParentElement, Pixels, ScaledPixels, ScrollHandle, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, StyledText, TaskExt, TextAlign, TextRun, - TextStyleRefinement, WeakEntity, Window, div, fill, outline, pattern_slash, point, px, quad, - relative, size, solid_background, transparent_black, + TextStyleRefinement, UnderlineStyle, WeakEntity, Window, div, fill, outline, pattern_slash, + point, px, quad, relative, size, solid_background, transparent_black, underline_y_offset, }; use itertools::Itertools; use language::{ - HighlightedText, IndentGuideSettings, LanguageAwareStyling, + Diagnostic, HighlightedText, IndentGuideSettings, LanguageAwareStyling, language_settings::ShowWhitespaceSetting, }; use markdown::Markdown; use multi_buffer::{ - Anchor, ExpandExcerptDirection, ExpandInfo, MultiBufferOffset, MultiBufferPoint, - MultiBufferRow, RowInfo, ToOffset, + Anchor, ExcerptRange, ExpandExcerptDirection, ExpandInfo, MultiBufferOffset, MultiBufferPoint, + MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset, }; use project::{ @@ -81,10 +82,10 @@ use std::{ ops::{Deref, Range}, rc::Rc, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; use sum_tree::Bias; -use text::BufferId; +use text::{BufferId, ToPoint as _}; use theme::{ActiveTheme, Appearance, PlayerColor}; use theme_settings::BufferLineHeight; use ui::utils::ensure_minimum_contrast; @@ -140,6 +141,7 @@ impl LineNumberStyle { #[derive(Debug)] struct SelectionLayout { + id: usize, head: DisplayPoint, cursor_shape: CursorShape, is_newest: bool, @@ -156,6 +158,112 @@ struct InlineBlameLayout { entry: BlameEntry, } +#[derive(Debug, Clone, Copy)] +struct PointDiagnostic { + column: u32, + underline: UnderlineStyle, + severity: lsp::DiagnosticSeverity, +} + +struct DiagnosticUnderline { + origin: gpui::Point, + width: Pixels, + style: UnderlineStyle, + bounds: Bounds, + span: Range, + severity: lsp::DiagnosticSeverity, + is_point: bool, +} + +impl DiagnosticUnderline { + fn point( + diagnostic: &PointDiagnostic, + origin: gpui::Point, + width: Pixels, + window: &Window, + ) -> Self { + let bounds = window.underline_bounds(origin, width, &diagnostic.underline); + Self { + origin, + width, + style: diagnostic.underline, + bounds, + span: bounds.left()..bounds.right(), + severity: diagnostic.severity, + is_point: true, + } + } + + fn paint(&self, span: Range, window: &mut Window) { + window.paint_underline_with_exclusions( + self.origin, + self.width, + &self.style, + &[ + self.bounds.left()..span.start, + span.end..self.bounds.right(), + ], + ); + } + + fn paint_all(underlines: &[Self], window: &mut Window) { + if underlines.is_empty() { + return; + } + let mask = window.content_mask().bounds; + let scale = window.scale_factor(); + let left = ScaledPixels((f32::from(mask.left()) * scale).floor()); + let top = ScaledPixels((f32::from(mask.top()) * scale).floor()); + let right = ScaledPixels((f32::from(mask.right()) * scale).ceil()).max(left); + let bottom = ScaledPixels((f32::from(mask.bottom()) * scale).ceil()).max(top); + let mask = Bounds::from_corners(point(left, top), point(right, bottom)); + let mut events = SmallVec::<[_; 8]>::new(); + for (index, underline) in underlines.iter().enumerate() { + let bounds = underline.bounds.intersect(&mask); + let start = underline.span.start.max(bounds.left()); + let end = underline.span.end.min(bounds.right()); + if !bounds.is_empty() && start < end { + events.push((start, true, index)); + events.push((end, false, index)); + } + } + events.sort_unstable(); + + let mut active = BTreeSet::new(); + let mut previous = None; + let mut visible: Option<(usize, Range)> = None; + for (position, is_start, index) in events { + if let Some(start) = previous + && start < position + && let Some(&(_, _, winner)) = active.first() + { + if let Some((current, span)) = visible.as_mut() + && *current == winner + && span.end == start + { + span.end = position; + } else { + if let Some((current, span)) = visible.take() { + underlines[current].paint(span, window); + } + visible = Some((winner, start..position)); + } + } + let underline = &underlines[index]; + let key = (underline.severity, underline.is_point, index); + if is_start { + active.insert(key); + } else { + active.remove(&key); + } + previous = Some(position); + } + if let Some((winner, span)) = visible { + underlines[winner].paint(span, window); + } + } +} + impl SelectionLayout { fn new( selection: Selection, @@ -167,6 +275,7 @@ impl SelectionLayout { is_local: bool, user_name: Option, ) -> Self { + let id = selection.id; let buffer_snapshot = map.buffer_snapshot(); let point_selection = selection.map(|p| p.to_point(buffer_snapshot)); let display_selection = point_selection.map(|p| p.to_display_point(map)); @@ -220,6 +329,7 @@ impl SelectionLayout { } Self { + id, head, cursor_shape, is_newest, @@ -278,6 +388,13 @@ impl EditorElement { crate::rust_analyzer_ext::apply_related_actions(editor, window, cx); crate::clangd_ext::apply_related_actions(editor, window, cx); + crate::emmet_ext::apply_related_actions(editor, window, cx); + + if editor.read(cx).pending_inline_input().is_some() { + register_action(editor, window, |editor, _: &menu::Confirm, window, cx| { + editor.confirm_inline_input(window, cx); + }); + } register_action(editor, window, Editor::open_context_menu); register_action(editor, window, Editor::move_left); @@ -406,6 +523,9 @@ impl EditorElement { register_action(editor, window, Editor::go_to_prev_hunk); register_action(editor, window, Editor::go_to_next_document_highlight); register_action(editor, window, Editor::go_to_prev_document_highlight); + if editor.read(cx).lsp_data_enabled() { + register_action(editor, window, Editor::open_definition_locations); + } register_action(editor, window, |editor, action, window, cx| { editor .go_to_definition(action, window, cx) @@ -1032,8 +1152,41 @@ impl EditorElement { let mut autoscroll_bounds = None; let cursor_layouts = self.editor.update(cx, |editor, cx| { let mut cursors = Vec::new(); + let mut handled_animation_cursors = HashSet::default(); + let mut request_animation_frame = false; let show_local_cursors = editor.show_local_cursors(window, cx); + let animation_settings = EditorSettings::get_global(cx).cursor_animation; + let animation_enabled = animation_settings.enabled && !cx.reduce_motion(); + let animation_context = animation_enabled.then(|| { + ( + CursorViewport::new( + content_origin, + text_hitbox.bounds, + scroll_position, + scroll_pixel_position, + line_height, + em_advance, + ), + Instant::now(), + ) + }); + + if animation_enabled { + let newest_animation_selection_id = if editor.leader_id.is_none() + && cursor_shape_supports_cursor_animation(editor.cursor_shape) + { + Some(editor.selections.newest_anchor().id) + } else { + None + }; + + editor + .cursor_animations + .reconcile_newest_selection(newest_animation_selection_id); + } else { + editor.cursor_animations.clear(); + } for (player_color, selections) in selections { for selection in selections { @@ -1173,6 +1326,7 @@ impl EditorElement { shape: selection.cursor_shape, block_text, cursor_name: None, + animated_corners: None, }; let cursor_name = selection.user_name.clone().map(|name| CursorName { string: name, @@ -1180,10 +1334,42 @@ impl EditorElement { is_top_row: cursor_position.row().0 == 0, }); cursor.layout(content_origin, cursor_name, window, cx); + if selection.is_local + && cursor_shape_supports_cursor_animation(selection.cursor_shape) + { + if let Some((cursor_viewport, animation_now)) = animation_context { + handled_animation_cursors.insert(selection.id); + let target_bounds = + window.pixel_snap_bounds(cursor.bounds(content_origin)); + cursor.animated_corners = editor.cursor_animations.update( + selection.id, + LogicalCursorPosition { + row: cursor_position.row().0, + column: cursor_position.column(), + }, + target_bounds, + cursor_viewport, + animation_now, + ); + request_animation_frame |= cursor.animated_corners.is_some(); + } + } else if animation_enabled && selection.is_local { + editor.cursor_animations.remove(selection.id); + } cursors.push(cursor); } } + if animation_enabled { + editor.cursor_animations.capture_newest_state(); + editor + .cursor_animations + .retain(|selection_id| handled_animation_cursors.contains(&selection_id)); + if request_animation_frame { + window.request_animation_frame(); + } + } + cursors }); @@ -3113,11 +3299,14 @@ impl EditorElement { &[run], None, ); + LineWithInvisibles { width: line.width, len: line.len, fragments: smallvec![LineFragment::Text(line)], invisibles: Vec::new(), + diagnostic_underline_severity_ranges: Vec::new(), + point_diagnostics: Vec::new(), font_size, } }) @@ -3153,6 +3342,7 @@ impl EditorElement { scroll_position: gpui::Point, scroll_pixel_position: gpui::Point, content_origin: gpui::Point, + content_width: Pixels, window: &mut Window, cx: &mut App, ) -> SmallVec<[AnyElement; 1]> { @@ -3165,6 +3355,8 @@ impl EditorElement { scroll_pixel_position, row, content_origin, + self.style.text.text_align, + content_width, &mut line_elements, window, cx, @@ -5327,6 +5519,8 @@ impl EditorElement { }); } + const DELETED_MARKER_WIDTH_RATIO: f32 = 0.35 / 0.275; + fn gutter_strip_width(line_height: Pixels, cx: &App) -> Pixels { match EditorSettings::get_global(cx).gutter.git_gutter_width { GitGutterWidth::Custom(width) => px(*width), @@ -5334,6 +5528,15 @@ impl EditorElement { } } + fn deleted_marker_base_width(setting: GitGutterWidth, line_height: Pixels) -> Pixels { + match setting { + GitGutterWidth::Custom(width) => px(*width * Self::DELETED_MARKER_WIDTH_RATIO), + GitGutterWidth::Default => { + (0.275 * line_height * Self::DELETED_MARKER_WIDTH_RATIO).floor() + } + } + } + fn diff_hunk_bounds( scroll_position: gpui::Point, line_height: Pixels, @@ -5369,10 +5572,10 @@ impl EditorElement { .into(); let end_y = start_y + line_height; - let width = match EditorSettings::get_global(cx).gutter.git_gutter_width { - GitGutterWidth::Custom(width) => px(*width), - GitGutterWidth::Default => (0.35 * line_height).floor(), - }; + let width = Self::deleted_marker_base_width( + EditorSettings::get_global(cx).gutter.git_gutter_width, + line_height, + ); let highlight_origin = gutter_bounds.origin + point(px(0.), start_y); let highlight_size = size(width, end_y - start_y); Bounds::new(highlight_origin, highlight_size) @@ -5538,6 +5741,164 @@ impl EditorElement { }) } + fn populate_point_diagnostics( + &self, + snapshot: &EditorSnapshot, + visible_rows: Range, + line_layouts: &mut [LineWithInvisibles], + ) { + if visible_rows.is_empty() { + return; + } + + let display_snapshot = &snapshot.display_snapshot; + let buffer_snapshot = snapshot.buffer_snapshot(); + + let query_start = display_snapshot + .display_point_to_point(DisplayPoint::new(visible_rows.start, 0), Bias::Left); + let query_end_display = display_snapshot + .clip_ignoring_line_ends(DisplayPoint::new(visible_rows.end, 0), Bias::Right); + let query_end = display_snapshot.display_point_to_point(query_end_display, Bias::Right); + + for (point, diagnostic) in + Self::point_diagnostics_in_range(buffer_snapshot, query_start..query_end) + { + if display_snapshot.intersects_fold(point) { + continue; + } + + let Some(underline) = display_snapshot.diagnostic_underline_style( + diagnostic.severity, + diagnostic.underline, + diagnostic.is_unnecessary, + &self.style, + ) else { + continue; + }; + + let display_point = point.to_display_point(display_snapshot); + if !visible_rows.contains(&display_point.row()) { + continue; + } + + let line_ix = display_point.row().minus(visible_rows.start) as usize; + let Some(line_layout) = line_layouts.get_mut(line_ix) else { + continue; + }; + let point_diagnostic = PointDiagnostic { + column: display_point.column(), + underline, + severity: diagnostic.severity, + }; + + line_layout.add_point_diagnostic(point_diagnostic); + } + } + + fn point_diagnostic_anchor( + snapshot: &MultiBufferSnapshot, + excerpt: &ExcerptRange, + text_anchor: text::Anchor, + ) -> Option { + let buffer = snapshot.buffer_for_id(text_anchor.buffer_id)?; + if !excerpt.contains(&text_anchor, buffer) { + return None; + } + let anchor = text_anchor.bias_right(buffer); + let anchor = if excerpt.context.start.cmp(&anchor, buffer).is_gt() { + excerpt.context.start + } else if excerpt.context.end.cmp(&anchor, buffer).is_lt() { + excerpt.context.end + } else { + anchor + }; + snapshot.anchor_in_buffer(anchor) + } + + fn add_point_diagnostic<'a>( + point_diagnostics: &mut Vec<(Point, &'a Diagnostic)>, + seen: &mut HashSet<(Point, *const Diagnostic)>, + range: &Range, + point: Point, + diagnostic: &'a Diagnostic, + ) { + if point >= range.start + && point <= range.end + && seen.insert((point, std::ptr::from_ref(diagnostic))) + { + point_diagnostics.push((point, diagnostic)); + } + } + + fn point_diagnostics_in_range( + snapshot: &MultiBufferSnapshot, + range: Range, + ) -> Vec<(Point, &Diagnostic)> { + let mut point_diagnostics = Vec::new(); + let mut seen = HashSet::default(); + + for (buffer, buffer_range, excerpt) in snapshot.range_to_buffer_ranges(range.clone()) { + for entry in buffer + .diagnostic_entries_in_range(buffer_range, false) + .filter(|entry| entry.range.start == entry.range.end) + { + let Some(anchor) = + Self::point_diagnostic_anchor(snapshot, &excerpt, entry.range.start) + else { + continue; + }; + Self::add_point_diagnostic( + &mut point_diagnostics, + &mut seen, + &range, + anchor.to_point(snapshot), + &entry.diagnostic, + ); + } + } + + for (_, _, deleted_hunk_anchor) in + snapshot.range_to_buffer_ranges_with_deleted_hunks(range.clone()) + { + let Some(deleted_hunk_anchor) = deleted_hunk_anchor else { + continue; + }; + let Some((live_anchor, buffer)) = snapshot.anchor_to_buffer_anchor(deleted_hunk_anchor) + else { + continue; + }; + let Some((_, excerpt)) = + snapshot.excerpt_containing(deleted_hunk_anchor..deleted_hunk_anchor) + else { + continue; + }; + let live_point = live_anchor.to_point(buffer); + + for entry in buffer + .diagnostic_entries_in_range(live_point..live_point, false) + .filter(|entry| entry.range.start == entry.range.end) + { + if entry.range.start.to_point(buffer) != live_point { + continue; + } + let Some(anchor) = + Self::point_diagnostic_anchor(snapshot, &excerpt, entry.range.start) + else { + continue; + }; + Self::add_point_diagnostic( + &mut point_diagnostics, + &mut seen, + &range, + anchor.to_point(snapshot), + &entry.diagnostic, + ); + } + } + + point_diagnostics + } + fn paint_text(&mut self, layout: &mut EditorLayout, window: &mut Window, cx: &mut App) { window.with_content_mask( Some(ContentMask { @@ -5576,6 +5937,19 @@ impl EditorElement { CursorStyle::PointingHand, &layout.position_map.text_hitbox, ); + } else if !window.modifiers().modified() + && let Some(hovered_command) = editor.hovered_inlay_hint_command() + && hovered_command.contains_point( + &layout.position_map.snapshot, + layout + .position_map + .point_for_position(window.mouse_position()), + ) + { + window.set_cursor_style( + CursorStyle::PointingHand, + &layout.position_map.text_hitbox, + ); } else { window.set_cursor_style(CursorStyle::IBeam, &layout.position_map.text_hitbox); }; @@ -6655,6 +7029,7 @@ impl EditorElement { let start = range.start.to_display_point(display_snapshot); let end = range.end.to_display_point(display_snapshot); let selection_layout = SelectionLayout { + id: 0, head: start, range: start..end, cursor_shape: CursorShape::Bar, @@ -7051,6 +7426,8 @@ fn render_blame_entry( pub(crate) struct LineWithInvisibles { fragments: SmallVec<[LineFragment; 1]>, invisibles: Vec, + diagnostic_underline_severity_ranges: Vec<(Range, lsp::DiagnosticSeverity)>, + point_diagnostics: Vec, len: usize, pub(crate) width: Pixels, font_size: Pixels, @@ -7069,7 +7446,7 @@ enum LineFragment { impl fmt::Debug for LineFragment { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - LineFragment::Text(shaped_line) => f.debug_tuple("Text").field(shaped_line).finish(), + LineFragment::Text(line) => f.debug_tuple("Text").field(line).finish(), LineFragment::Element { size, len, .. } => f .debug_struct("Element") .field("size", size) @@ -7101,6 +7478,7 @@ impl LineWithInvisibles { // mid-line inlays/replacements, so marker offsets stay correct in that case. let mut line_byte_offset: usize = 0; let mut invisibles = Vec::new(); + let mut diagnostic_underline_severity_ranges = Vec::new(); let mut width = Pixels::ZERO; let mut len = 0; let mut styles = Vec::new(); @@ -7115,6 +7493,7 @@ impl LineWithInvisibles { for highlighted_chunk in chunks.chain([HighlightedChunk { text: "\n", style: None, + diagnostic_underline_severity: None, is_tab: false, is_inlay: false, replacement: None, @@ -7144,6 +7523,7 @@ impl LineWithInvisibles { ); width += shaped_line.width; len += shaped_line.len; + fragments.push(LineFragment::Text(shaped_line)); line.clear(); styles.clear(); @@ -7205,11 +7585,18 @@ impl LineWithInvisibles { underline: text_style.underline, strikethrough: text_style.strikethrough, }; + let line_layout = window .text_system() .shape_line(x, font_size, &[run], None) .with_len(highlighted_chunk.text.len()); + if let Some(severity) = highlighted_chunk.diagnostic_underline_severity { + diagnostic_underline_severity_ranges.push(( + line_byte_offset..line_byte_offset + highlighted_chunk.text.len(), + severity, + )); + } width += line_layout.width; len += highlighted_chunk.text.len(); line_byte_offset += highlighted_chunk.text.len(); @@ -7233,12 +7620,17 @@ impl LineWithInvisibles { ); width += shaped_line.width; len += shaped_line.len; + fragments.push(LineFragment::Text(shaped_line)); layouts.push(Self { width: mem::take(&mut width), len: mem::take(&mut len), fragments: mem::take(&mut fragments), invisibles: std::mem::take(&mut invisibles), + diagnostic_underline_severity_ranges: mem::take( + &mut diagnostic_underline_severity_ranges, + ), + point_diagnostics: Vec::new(), font_size, }); @@ -7283,6 +7675,13 @@ impl LineWithInvisibles { strikethrough: text_style.strikethrough, }); + if let Some(severity) = highlighted_chunk.diagnostic_underline_severity { + diagnostic_underline_severity_ranges.push(( + line_byte_offset..line_byte_offset + line_chunk.len(), + severity, + )); + } + if editor_mode.is_full() && !highlighted_chunk.is_inlay { // Line wrap pads its contents with fake whitespaces, // avoid printing them @@ -7326,6 +7725,20 @@ impl LineWithInvisibles { layouts } + fn add_point_diagnostic(&mut self, point_diagnostic: PointDiagnostic) { + if let Some(existing) = self + .point_diagnostics + .iter_mut() + .find(|existing| existing.column == point_diagnostic.column) + { + if point_diagnostic.severity < existing.severity { + *existing = point_diagnostic; + } + } else { + self.point_diagnostics.push(point_diagnostic); + } + } + /// Takes text runs and non-overlapping left-to-right background ranges with color. /// Returns new text runs with adjusted contrast as per background ranges. fn split_runs_by_bg_segments( @@ -7409,6 +7822,8 @@ impl LineWithInvisibles { scroll_pixel_position: gpui::Point, row: DisplayRow, content_origin: gpui::Point, + text_align: TextAlign, + content_width: Pixels, line_elements: &mut SmallVec<[AnyElement; 1]>, window: &mut Window, cx: &mut App, @@ -7419,6 +7834,8 @@ impl LineWithInvisibles { scroll_pixel_position, content_origin, line_y, + text_align, + content_width, line_elements, window, cx, @@ -7431,12 +7848,18 @@ impl LineWithInvisibles { scroll_pixel_position: gpui::Point, content_origin: gpui::Point, line_y: Pixels, + text_align: TextAlign, + content_width: Pixels, line_elements: &mut SmallVec<[AnyElement; 1]>, window: &mut Window, cx: &mut App, ) { - let mut fragment_origin = - content_origin + gpui::point(Pixels::from(-scroll_pixel_position.x), line_y); + let mut fragment_origin = content_origin + + point( + self.alignment_offset(text_align, content_width) + - Pixels::from(scroll_pixel_position.x), + line_y, + ); for fragment in &mut self.fragments { match fragment { LineFragment::Text(line) => { @@ -7495,29 +7918,188 @@ impl LineWithInvisibles { ) { let line_height = layout.position_map.line_height; let mut fragment_origin = content_origin - + gpui::point( - Pixels::from(-layout.position_map.scroll_pixel_position.x), + + point( + self.alignment_offset(layout.text_align, layout.content_width) + - Pixels::from(layout.position_map.scroll_pixel_position.x), line_y, ); + let mut points = self + .point_diagnostics + .iter() + .filter(|diagnostic| diagnostic.column as usize <= self.len) + .collect::>(); + points.sort_unstable_by_key(|diagnostic| diagnostic.column); + let has_points = !points.is_empty(); + let mut points = points.into_iter().peekable(); + let mut underlines = SmallVec::<[DiagnosticUnderline; 4]>::new(); + let mut diagnostic_ranges = self.diagnostic_underline_severity_ranges.iter().peekable(); + let mut fragment_start = 0; + let mut end_underline_offset = layout.point_diagnostic_underline_offset; for fragment in &self.fragments { - match fragment { + let (fragment_len, fragment_width, line) = match fragment { LineFragment::Text(line) => { - line.paint( - fragment_origin, - line_height, - layout.text_align, - Some(layout.content_width), - window, - cx, - ) - .log_err(); - fragment_origin.x += line.width; + if has_points { + let mut glyphs = line.runs.iter().flat_map(|run| &run.glyphs).peekable(); + line.paint_with_underline_handler( + fragment_origin, + line_height, + TextAlign::Left, + None, + window, + cx, + |range, origin, width, style, window| { + let bounds = window.underline_bounds(origin, width, style); + if bounds.is_empty() { + return; + } + let source_range = if line.text.len() == line.len() { + fragment_start + range.start..fragment_start + range.end + } else { + fragment_start..fragment_start + line.len() + }; + if source_range.is_empty() { + window.paint_underline(origin, width, style); + return; + } + let mut start = source_range.start; + let mut span_start = bounds.left(); + while start < source_range.end { + while diagnostic_ranges + .peek() + .is_some_and(|(range, _)| range.end <= start) + { + diagnostic_ranges.next(); + } + let (end, severity) = match diagnostic_ranges.peek().copied() { + Some((range, severity)) if range.start <= start => { + (range.end.min(source_range.end), Some(*severity)) + } + Some((range, _)) => { + (range.start.min(source_range.end), None) + } + None => (source_range.end, None), + }; + let span_end = if end == source_range.end { + bounds.right() + } else { + let index = end - fragment_start; + while glyphs.peek().is_some_and(|glyph| glyph.index < index) + { + glyphs.next(); + } + let x = fragment_origin.x + + glyphs + .peek() + .map_or(line.width, |glyph| glyph.position.x); + window + .underline_bounds( + point(x, origin.y), + Pixels::ZERO, + style, + ) + .left() + .clamp(span_start, bounds.right()) + }; + if span_start < span_end { + if let Some(severity) = severity { + underlines.push(DiagnosticUnderline { + origin, + width, + style: *style, + bounds, + span: span_start..span_end, + severity, + is_point: false, + }); + } else { + window.paint_underline_with_exclusions( + origin, + width, + style, + &[ + bounds.left()..span_start, + span_end..bounds.right(), + ], + ); + } + } + start = end; + span_start = span_end; + } + }, + ) + .log_err(); + } else { + line.paint( + fragment_origin, + line_height, + TextAlign::Left, + None, + window, + cx, + ) + .log_err(); + } + (line.len(), line.width, Some(line)) } - LineFragment::Element { size, .. } => { - fragment_origin.x += size.width; + LineFragment::Element { size, len, .. } => (*len, size.width, None), + }; + let fragment_end = fragment_start + fragment_len; + if has_points { + let mut glyphs = line + .into_iter() + .flat_map(|line| &line.runs) + .flat_map(|run| &run.glyphs) + .peekable(); + let has_glyphs = glyphs.peek().is_some(); + let underline_offset = line + .filter(|_| has_glyphs) + .map_or(layout.point_diagnostic_underline_offset, |line| { + underline_y_offset(line_height, line.ascent, line.descent) + }); + if fragment_len > 0 || fragment_width != Pixels::ZERO || has_glyphs { + end_underline_offset = underline_offset; + } + while points + .peek() + .is_some_and(|diagnostic| (diagnostic.column as usize) < fragment_end) + { + let Some(diagnostic) = points.next() else { + break; + }; + let index = diagnostic.column as usize - fragment_start; + let x = if let Some(line) = line { + let index = if line.text.len() == line.len() { + index + } else { + 0 + }; + while glyphs.peek().is_some_and(|glyph| glyph.index < index) { + glyphs.next(); + } + glyphs.peek().map_or(line.width, |glyph| glyph.position.x) + } else { + Pixels::ZERO + }; + underlines.push(DiagnosticUnderline::point( + diagnostic, + fragment_origin + point(x, underline_offset), + layout.position_map.em_advance, + window, + )); } } + fragment_origin.x += fragment_width; + fragment_start = fragment_end; + } + for diagnostic in points { + underlines.push(DiagnosticUnderline::point( + diagnostic, + fragment_origin + point(Pixels::ZERO, end_underline_offset), + layout.position_map.em_advance, + window, + )); } self.draw_invisibles( @@ -7531,6 +8113,7 @@ impl LineWithInvisibles { window, cx, ); + DiagnosticUnderline::paint_all(&underlines, window); } fn draw_background( @@ -7545,8 +8128,9 @@ impl LineWithInvisibles { let line_y = line_height * (row.as_f64() - layout.position_map.scroll_position.y) as f32; let mut fragment_origin = content_origin - + gpui::point( - Pixels::from(-layout.position_map.scroll_pixel_position.x), + + point( + self.alignment_offset(layout.text_align, layout.content_width) + - Pixels::from(layout.position_map.scroll_pixel_position.x), line_y, ); @@ -7556,8 +8140,8 @@ impl LineWithInvisibles { line.paint_background( fragment_origin, line_height, - layout.text_align, - Some(layout.content_width), + TextAlign::Left, + None, window, cx, ) @@ -7583,6 +8167,12 @@ impl LineWithInvisibles { window: &mut Window, cx: &mut App, ) { + let line_origin = content_origin + + point( + self.alignment_offset(layout.text_align, layout.content_width) + - Pixels::from(layout.position_map.scroll_pixel_position.x), + line_y, + ); let extract_whitespace_info = |invisible: &Invisible| { let (token_offset, token_end_offset, invisible_symbol) = match invisible { Invisible::Tab { @@ -7603,16 +8193,8 @@ impl LineWithInvisibles { // Center the marker inside the actual glyph's width so it lines up with // proportional fonts instead of assuming a monospace `em_width` cell. let glyph_width = (self.x_for_index(token_end_offset) - token_x).max(Pixels::ZERO); - let x_offset: ScrollPixelOffset = token_x.into(); - let invisible_offset: ScrollPixelOffset = - ((glyph_width - invisible_symbol.width).max(Pixels::ZERO) / 2.0).into(); - let origin = content_origin - + gpui::point( - Pixels::from( - x_offset + invisible_offset - layout.position_map.scroll_pixel_position.x, - ), - line_y, - ); + let invisible_offset = (glyph_width - invisible_symbol.width).max(Pixels::ZERO) / 2.0; + let origin = line_origin + point(token_x + invisible_offset, Pixels::ZERO); ( [token_offset, token_end_offset], @@ -8027,6 +8609,10 @@ impl Element for EditorElement { let font_id = window.text_system().resolve_font(&style.text.font()); let font_size = style.text.font_size.to_pixels(rem_size); let line_height = style.text.line_height_in_pixels(rem_size); + let ascent = window.text_system().ascent(font_id, font_size); + let descent = window.text_system().descent(font_id, font_size).abs(); + let point_diagnostic_underline_offset = + underline_y_offset(line_height, ascent, descent); let em_width = window.text_system().em_width(font_id, font_size).unwrap(); let em_advance = window.text_system().em_advance(font_id, font_size).unwrap(); let em_layout_width = window.text_system().em_layout_width(font_id, font_size); @@ -8996,6 +9582,7 @@ impl Element for EditorElement { scroll_position, scroll_pixel_position, content_origin, + text_hitbox.size.width, window, cx, ); @@ -9418,6 +10005,12 @@ impl Element for EditorElement { ) }; + self.populate_point_diagnostics( + &snapshot, + start_row..end_row, + &mut line_layouts, + ); + let position_map = Rc::new(PositionMap { size: bounds.size, visible_row_range, @@ -9473,6 +10066,7 @@ impl Element for EditorElement { line_numbers, blamed_display_rows, inline_diagnostics, + point_diagnostic_underline_offset, inline_blame_layout, inline_code_actions, blocks, @@ -9689,6 +10283,7 @@ pub struct EditorLayout { display_hunks: Vec<(DisplayDiffHunk, Option)>, blamed_display_rows: Option>, inline_diagnostics: HashMap, + point_diagnostic_underline_offset: Pixels, inline_blame_layout: Option, inline_code_actions: Option, blocks: Vec, @@ -10399,6 +10994,7 @@ pub struct CursorLayout { shape: CursorShape, block_text: Option, cursor_name: Option, + animated_corners: Option<[gpui::Point; 4]>, } #[derive(Debug)] @@ -10425,6 +11021,7 @@ impl CursorLayout { shape, block_text, cursor_name: None, + animated_corners: None, } } @@ -10497,19 +11094,34 @@ impl CursorLayout { pub fn paint(&mut self, origin: gpui::Point, window: &mut Window, cx: &mut App) { let bounds = window.pixel_snap_bounds(self.bounds(origin)); - //Draw background or border quad - let cursor = if matches!(self.shape, CursorShape::Hollow) { - outline(bounds, self.color, BorderStyle::Solid) + if let Some(corners) = self.animated_corners { + let mut builder = gpui::PathBuilder::fill(); + builder.add_polygon(&corners, true); + if let Ok(path) = builder.build() { + if let Some(name) = &mut self.cursor_name { + name.paint(window, cx); + } + window.paint_path(path, self.color); + + if !animated_corners_overlap_target(bounds, &corners) { + return; + } + } } else { - fill(bounds, self.color) - }; + //Draw background or border quad + let cursor = if matches!(self.shape, CursorShape::Hollow) { + outline(bounds, self.color, BorderStyle::Solid) + } else { + fill(bounds, self.color) + }; - if let Some(name) = &mut self.cursor_name { - name.paint(window, cx); + if let Some(name) = &mut self.cursor_name { + name.paint(window, cx); + } + + window.paint_quad(cursor); } - window.paint_quad(cursor); - if let Some(block_text) = &self.block_text { block_text .paint( @@ -10529,6 +11141,10 @@ impl CursorLayout { } } +fn cursor_shape_supports_cursor_animation(shape: CursorShape) -> bool { + matches!(shape, CursorShape::Bar | CursorShape::Block) +} + #[derive(Debug)] pub struct HighlightedRange { pub start_y: Pixels, @@ -10791,11 +11407,20 @@ mod tests { display_map::{BlockPlacement, BlockProperties, DisplayMap}, editor_tests::{init_test, update_test_language_settings}, }; - use gpui::{TestAppContext, VisualTestContext, font}; - use language::{Buffer, SelectionGoal, language_settings, tree_sitter_python}; + use buffer_diff::BufferDiff; + use gpui::{ + Render, TestAppContext, Underline, UpdateGlobal, VisualTestContext, WindowHandle, font, + }; + use language::{ + Buffer, Capability, Diagnostic, DiagnosticEntry, DiagnosticSet, SelectionGoal, + language_settings, tree_sitter_python, + }; use log::info; + use lsp::DiagnosticSeverity; + use multi_buffer::PathKey; use rand::{RngCore, rngs::StdRng}; use std::num::NonZeroU32; + use text::PointUtf16; use util::test::sample_text; enum PrimaryNavigationOverlay {} @@ -11046,6 +11671,460 @@ mod tests { } } + #[gpui::test] + fn test_point_diagnostic_stays_in_edited_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let point = Point::new(1, 0); + let diagnostic_point = PointUtf16::new(1, 0); + let buffer = point_diagnostic_buffer( + "lead\n\nhidden\nsafe", + [(diagnostic_point..diagnostic_point, ERROR)], + cx, + ); + let multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.set_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer.clone(), + vec![Point::zero()..point, Point::new(3, 0)..Point::new(3, 4)], + 0, + cx, + ); + }); + buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(); + buffer.edit( + [(snapshot.anchor_before(5)..snapshot.anchor_after(6), "")], + None, + cx, + ); + }); + + let snapshot = cx.update(|cx| multi_buffer.read(cx).snapshot(cx)); + let excerpts = snapshot.excerpts().collect::>(); + let points_by_excerpt = excerpts + .iter() + .map(|excerpt| { + let start = snapshot + .anchor_in_excerpt(excerpt.context.start) + .expect("excerpt start") + .to_point(&snapshot); + let end = snapshot + .anchor_in_excerpt(excerpt.context.end) + .expect("excerpt end") + .to_point(&snapshot); + EditorElement::point_diagnostics_in_range(&snapshot, start..end) + .into_iter() + .map(|(point, _)| point) + .collect::>() + }) + .collect::>(); + assert_eq!(points_by_excerpt, vec![vec![point], Vec::new()]); + } + + #[gpui::test] + fn test_point_diagnostic_painted_without_glyph_or_at_edited_eof(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (text, point, deletion, expected_row, expected_pixels) in [ + ("\nx", PointUtf16::new(0, 0), None, 0, 0..16), + ("x\n", PointUtf16::new(1, 0), None, 1, 0..16), + ("", PointUtf16::new(0, 0), None, 0, 0..16), + ("x\n", PointUtf16::new(1, 0), Some(1..2), 0, 16..32), + ] { + for clip_at_line_ends in [false, true] { + let buffer = point_diagnostic_buffer( + text, + [(point..point, WARNING), (point..point, ERROR)], + cx, + ); + if let Some(deletion) = deletion.clone() { + buffer.update(cx, |buffer, cx| buffer.edit([(deletion, "")], None, cx)); + } + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (window, editor) = point_diagnostic_editor(multi_buffer, cx); + editor.update(cx, |editor, cx| { + editor.set_clip_at_line_ends(clip_at_line_ends, cx); + }); + assert_painted_point_diagnostics( + window, + &[(expected_row, expected_pixels.clone(), ERROR)], + cx, + ); + } + } + } + + #[gpui::test] + fn test_point_diagnostic_painted_monospace_severity(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (point_severity, other_severity) in [(WARNING, ERROR), (ERROR, WARNING), (ERROR, ERROR)] + { + for other_range in [0..1, 0..4, 1..4, 1..1] { + let buffer = point_diagnostic_buffer( + "\niabc", + [ + (PointUtf16::new(0, 0)..PointUtf16::new(0, 0), point_severity), + ( + PointUtf16::new(1, other_range.start) + ..PointUtf16::new(1, other_range.end), + other_severity, + ), + ], + cx, + ); + buffer.update(cx, |buffer, cx| buffer.edit([(0..1, "")], None, cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (window, editor) = point_diagnostic_editor(multi_buffer, cx); + + for offset_content in [false, true] { + editor.update(cx, |editor, cx| { + editor.set_offset_content(offset_content, cx) + }); + for ( + scale, + point_width, + range_width, + full_range_width, + gutter_origins, + root_origins, + ) in [ + (1., 8, 23, 31, (4, 11), [0, 0, 0, 1, 3]), + (1.25, 10, 29, 39, (4, 14), [0, 0, 1, 1, 4]), + (1.5, 12, 35, 47, (5, 17), [0, 0, 1, 1, 5]), + (2., 16, 47, 62, (7, 23), [0, 0, 1, 2, 6]), + (3., 23, 70, 94, (11, 34), [0, 1, 1, 2, 10]), + ] { + cx.simulate_window_scale_factor_change(window.into(), scale); + for (offset, root_start) in + [0., 0.2, 0.5, 0.8, 3.25].into_iter().zip(root_origins) + { + window.root(cx).expect("test view").update(cx, |view, cx| { + view.x_offset = px(offset); + cx.notify(); + }); + let (point_start, next_start) = if offset_content { + (root_start + gutter_origins.0, root_start + gutter_origins.1) + } else { + (root_start, root_start + point_width) + }; + let point_pixels = point_start..point_start + point_width; + let other_pixels = match (other_range.start, other_range.end) { + (0, 1) => point_pixels.clone(), + (0, 4) => point_start..point_start + full_range_width, + (1, 1) => next_start..next_start + point_width, + (1, 4) => next_start..next_start + range_width, + _ => unreachable!(), + }; + let expected = (point_start..other_pixels.end.max(point_pixels.end)) + .filter_map(|x| { + let severity = match ( + point_pixels.contains(&x), + other_pixels.contains(&x), + ) { + (true, true) => point_severity.min(other_severity), + (true, false) => point_severity, + (false, true) => other_severity, + (false, false) => return None, + }; + Some((0, x..x + 1, severity)) + }) + .collect::>(); + assert_painted_point_diagnostics(window, &expected, cx); + } + } + } + } + } + } + + #[gpui::test] + fn test_point_diagnostic_painted_tabs_multibyte_and_replacements(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4); + settings.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); + }); + + for (text, range, ranged_pixels) in [ + ("\n\tabc", 0..1, 2..64), + ("\néabc", 1..4, 18..65), + ("\n😀abc", 2..5, 33..80), + ("\n\u{00a0}abc", 1..4, 18..65), + ("\na\u{00a0}bc", 1..3, 33..49), + ] { + for (point_severity, ranged_severity) in + [(WARNING, ERROR), (ERROR, WARNING), (ERROR, ERROR)] + { + let buffer = point_diagnostic_buffer( + text, + [ + (PointUtf16::new(0, 0)..PointUtf16::new(0, 0), point_severity), + ( + PointUtf16::new(1, range.start)..PointUtf16::new(1, range.end), + ranged_severity, + ), + ], + cx, + ); + buffer.update(cx, |buffer, cx| buffer.edit([(0..1, "")], None, cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (window, _) = point_diagnostic_editor(multi_buffer, cx); + window.root(cx).expect("test view").update(cx, |view, cx| { + view.x_offset = px(0.8); + cx.notify(); + }); + let expected = (2..ranged_pixels.end) + .filter_map(|x| { + let severity = match (x < 18, ranged_pixels.contains(&x)) { + (true, true) => point_severity.min(ranged_severity), + (true, false) => point_severity, + (false, true) => ranged_severity, + (false, false) => return None, + }; + Some((0, x..x + 1, severity)) + }) + .collect::>(); + assert_painted_point_diagnostics(window, &expected, cx); + } + } + } + + #[gpui::test] + fn test_point_diagnostic_ignores_non_diagnostic_replacement_underline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (point_severity, ranged_severity) in [ + (None, ERROR), + (Some(WARNING), ERROR), + (Some(ERROR), WARNING), + (Some(ERROR), ERROR), + ] { + let diagnostics = iter::once(( + PointUtf16::new(1, 0)..PointUtf16::new(1, 1), + ranged_severity, + )) + .chain( + point_severity + .map(|severity| (PointUtf16::new(0, 0)..PointUtf16::new(0, 0), severity)), + ); + let buffer = point_diagnostic_buffer("\n\u{00a0}abc", diagnostics, cx); + buffer.update(cx, |buffer, cx| buffer.edit([(0..1, "")], None, cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (window, _) = point_diagnostic_editor(multi_buffer, cx); + let expected = point_severity + .map(|severity| (0, 0..16, severity)) + .into_iter() + .collect::>(); + assert_painted_point_diagnostics(window, &expected, cx); + } + } + + #[gpui::test] + fn test_point_diagnostics_follow_expanded_deletions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (text, base_text, point, expected_display_row) in [ + ("", "two\n", PointUtf16::new(0, 0), 1), + ("\nthree\n", "one\n\nthree\n", PointUtf16::new(0, 0), 1), + ( + "one\n\nthree\n", + "one\ntwo\n\nthree\n", + PointUtf16::new(1, 0), + 2, + ), + ("one\n", "one\ntwo\n", PointUtf16::new(1, 0), 2), + ] { + let buffer = + point_diagnostic_buffer(text, [(point..point, WARNING), (point..point, ERROR)], cx); + + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text(base_text, &buffer.read(cx).text_snapshot(), cx) + }); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.add_diff(diff, cx); + multi_buffer.expand_diff_hunks(vec![Anchor::Min..Anchor::Max], cx); + }); + cx.run_until_parked(); + + let snapshot = cx.update(|cx| multi_buffer.read(cx).snapshot(cx)); + let expected_point = Point::new(expected_display_row, 0); + let deleted_region_start = snapshot + .diff_hunks_in_range(Point::zero()..snapshot.max_point()) + .find(|hunk| hunk.status.kind == DiffHunkStatusKind::Deleted) + .map(|hunk| Point::new(hunk.row_range.start.0, 0)) + .expect("expanded deletion should have a deleted display region"); + for query_start in [Point::zero(), deleted_region_start, expected_point] { + let point_diagnostics = EditorElement::point_diagnostics_in_range( + &snapshot, + query_start..snapshot.max_point(), + ); + assert_eq!( + point_diagnostics + .iter() + .map(|(point, _)| *point) + .collect::>(), + vec![expected_point; 2], + "text: {text:?}, base: {base_text:?}, query start: {query_start:?}", + ); + } + + let (window, _) = point_diagnostic_editor(multi_buffer, cx); + assert_painted_point_diagnostics(window, &[(expected_display_row, 0..16, ERROR)], cx); + } + } + + #[gpui::test] + fn test_point_diagnostics_exclude_collapsed_and_clipped_ranges(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let collapsed_buffer = point_diagnostic_buffer( + "bad ok", + [(PointUtf16::new(0, 0)..PointUtf16::new(0, 3), ERROR)], + cx, + ); + collapsed_buffer.update(cx, |buffer, cx| buffer.edit([(0..4, "")], None, cx)); + let collapsed_multi_buffer = cx.new(|cx| MultiBuffer::singleton(collapsed_buffer, cx)); + let clipped_buffer = point_diagnostic_buffer( + "bad\ngood\n", + [(PointUtf16::new(0, 0)..PointUtf16::new(1, 0), ERROR)], + cx, + ); + let clipped_multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + clipped_multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.set_excerpt_ranges_for_path( + multi_buffer::PathKey::sorted(0), + clipped_buffer.clone(), + &clipped_buffer.read(cx).snapshot(), + vec![multi_buffer::ExcerptRange::new( + Point::new(1, 0)..Point::new(1, 4), + )], + cx, + ); + }); + for multi_buffer in [collapsed_multi_buffer, clipped_multi_buffer] { + let snapshot = cx.update(|cx| multi_buffer.read(cx).snapshot(cx)); + assert_eq!( + EditorElement::point_diagnostics_in_range( + &snapshot, + Point::zero()..snapshot.max_point(), + ) + .len(), + 0, + ); + let (window, _) = point_diagnostic_editor(multi_buffer, cx); + assert_painted_point_diagnostics(window, &[], cx); + } + } + + #[gpui::test] + fn test_point_diagnostic_inside_fold_is_not_rendered(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.gutter.get_or_insert_default().folds = Some(false); + }); + }); + }); + + let diagnostic_point = PointUtf16::new(1, 0); + let buffer = point_diagnostic_buffer( + "fn f() {\n\n}\n", + [(diagnostic_point..diagnostic_point, ERROR)], + cx, + ); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (window, editor) = point_diagnostic_editor(multi_buffer, cx); + assert_painted_point_diagnostics(window, &[(1, 0..16, ERROR)], cx); + let visual_cx = &mut VisualTestContext::from_window(*window, cx); + editor.update_in(visual_cx, |editor, window, cx| { + editor.display_map.update(cx, |display_map, _| { + display_map.fold_placeholder = FoldPlaceholder::test(); + }); + editor.fold_ranges(vec![Point::new(0, 8)..Point::new(2, 0)], false, window, cx); + }); + + assert_painted_point_diagnostics(window, &[], &mut visual_cx.cx); + } + + #[gpui::test] + async fn test_point_diagnostic_painted_in_sticky_header(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.gutter.get_or_insert_default().folds = Some(false); + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }); + }); + }); + }); + + let diagnostic_point = PointUtf16::new(0, 0); + let buffer = point_diagnostic_buffer("", [(diagnostic_point..diagnostic_point, ERROR)], cx); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(languages::rust_lang()), cx); + buffer.edit( + [( + 0..0, + indoc::indoc! {" + fn foo() { + let one = 1; + let two = 2; + let three = 3; + } + "}, + )], + None, + cx, + ); + + let snapshot = buffer.snapshot(); + assert_eq!( + snapshot + .diagnostics_in_range::<_, Point>(Point::zero()..snapshot.max_point(), false) + .map(|diagnostic| diagnostic.range) + .collect::>(), + vec![Point::zero()..Point::zero()], + ); + }); + + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (window, editor) = point_diagnostic_editor(multi_buffer, cx); + cx.simulate_window_resize(window.into(), size(px(500.), px(50.))); + let cx = &mut VisualTestContext::from_window(*window, cx); + cx.cx.run_until_parked(); + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + editor.refresh_sticky_headers(&snapshot.display_snapshot, cx); + }); + cx.cx.run_until_parked(); + assert_painted_point_diagnostics(window, &[(0, 0..16, ERROR)], &mut cx.cx); + editor.update_in(cx, |editor, window, cx| { + editor.scroll(point(0., 1.), window, cx) + }); + cx.cx.run_until_parked(); + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(EditorElement::sticky_headers(editor, &snapshot).len(), 1); + }); + assert_painted_point_diagnostics(window, &[(0, 0..16, ERROR)], &mut cx.cx); + cx.update(|_, cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(false), + }); + }); + }); + }); + assert_painted_point_diagnostics(window, &[], &mut cx.cx); + } + #[gpui::test] async fn test_soft_wrap_editor_width_full_editor(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -11984,6 +13063,7 @@ mod tests { let chunks = std::iter::once(HighlightedChunk { text: "\u{00a0}", style: None, + diagnostic_underline_severity: None, is_tab: false, is_inlay: false, replacement: Some(ChunkReplacement::Str("\u{2007}".into())), @@ -11991,6 +13071,7 @@ mod tests { .chain(std::iter::once(HighlightedChunk { text: "abcdefghi", style: None, + diagnostic_underline_severity: None, is_tab: false, is_inlay: false, replacement: None, @@ -11999,6 +13080,7 @@ mod tests { std::iter::repeat_with(|| HighlightedChunk { text: "\u{00a0}", style: None, + diagnostic_underline_severity: None, is_tab: false, is_inlay: false, replacement: Some(ChunkReplacement::Str("\u{2007}".into())), @@ -12284,6 +13366,7 @@ mod tests { }; let spanning_selection = SelectionLayout { + id: 0, head: DisplayPoint::new(DisplayRow(3), 7), cursor_shape: CursorShape::Bar, is_newest: true, @@ -12333,6 +13416,7 @@ mod tests { }; let selection = SelectionLayout { + id: 0, head: DisplayPoint::new(DisplayRow(2), 0), cursor_shape: CursorShape::Bar, is_newest: true, @@ -12533,6 +13617,16 @@ mod tests { assert_eq!(EditorElement::spacer_pattern_period(20.0, 4.8), 5.0); } + #[test] + fn cursor_animation_supports_bar_and_block_shapes() { + assert!(cursor_shape_supports_cursor_animation(CursorShape::Bar)); + assert!(cursor_shape_supports_cursor_animation(CursorShape::Block)); + assert!(!cursor_shape_supports_cursor_animation( + CursorShape::Underline + )); + assert!(!cursor_shape_supports_cursor_animation(CursorShape::Hollow)); + } + #[gpui::test(iterations = 100)] fn test_random_spacer_pattern_period(mut rng: StdRng) { let line_height = rng.next_u32() as f32; @@ -12574,4 +13668,303 @@ mod tests { Some(px(400.0)), ); } + + #[test] + fn test_deleted_marker_base_width() { + use settings::PixelSetting; + + assert_eq!( + EditorElement::deleted_marker_base_width(GitGutterWidth::Default, px(22.0)), + px(7.0), + ); + + let boosted = EditorElement::deleted_marker_base_width( + GitGutterWidth::Custom(PixelSetting(6.0)), + px(22.0), + ); + assert!( + boosted > px(6.0), + "boosted={boosted:?} must exceed the raw custom width so the deleted pill stays visible" + ); + + assert_eq!( + EditorElement::deleted_marker_base_width( + GitGutterWidth::Custom(PixelSetting(0.0)), + px(22.0), + ), + px(0.0), + ); + } + + #[test] + fn test_point_diagnostic_admission_preserves_covered_column() { + let mut line = LineWithInvisibles { + fragments: SmallVec::new(), + invisibles: Vec::new(), + diagnostic_underline_severity_ranges: vec![(0..1, ERROR)], + point_diagnostics: Vec::new(), + len: 1, + width: px(3.25), + font_size: px(13.), + }; + let underline = point_diagnostic_test_style(WARNING); + line.add_point_diagnostic(PointDiagnostic { + column: 0, + underline, + severity: WARNING, + }); + assert_eq!(line.point_diagnostics.len(), 1); + let point = line.point_diagnostics.first().expect("admitted point"); + assert_eq!((point.column, point.severity), (0, WARNING)); + assert_eq!(point.underline, underline); + } + + #[gpui::test] + fn test_point_diagnostic_paint_all_ownership_and_phase(cx: &mut TestAppContext) { + let device_bounds = |span: &Range| { + Bounds::from_corners(point(span.start as f32, 8.), point(span.end as f32, 14.)) + .map(ScaledPixels) + }; + let clipped = + |underline: &Underline| underline.bounds.intersect(&underline.content_mask.bounds); + for (inputs, expected) in [ + ( + vec![(0., 11.349, WARNING, true), (0., 3.25, ERROR, false)], + vec![(0..6, 0..6, ERROR), (6..23, 0..23, WARNING)], + ), + ( + vec![(0., 11.349, ERROR, true), (0., 3.25, WARNING, false)], + vec![(0..23, 0..23, ERROR)], + ), + ( + vec![ + (2.888_183_8, 10.829_102, ERROR, true), + (2.888_183_8, 20.959_962, WARNING, false), + ], + vec![(6..28, 6..28, ERROR), (28..48, 6..48, WARNING)], + ), + ( + vec![ + (0., 3., WARNING, true), + (3., 2., ERROR, false), + (7., 2., WARNING, false), + ], + vec![ + (0..6, 0..6, WARNING), + (6..10, 6..10, ERROR), + (14..18, 14..18, WARNING), + ], + ), + ( + vec![ + (0., 12., WARNING, true), + (2., 8., WARNING, false), + (4., 4., ERROR, true), + ], + vec![ + (0..4, 0..24, WARNING), + (4..8, 4..20, WARNING), + (8..16, 8..16, ERROR), + (16..20, 4..20, WARNING), + (20..24, 0..24, WARNING), + ], + ), + ( + vec![(-2., 42., WARNING, true), (-1., 3., ERROR, false)], + vec![(0..4, -2..4, ERROR), (4..64, -4..80, WARNING)], + ), + ] { + for reversed in [false, true] { + let mut inputs = inputs.clone(); + if reversed { + inputs.reverse(); + } + let window = cx.open_window(size(px(32.), px(16.)), |_, _| { + PointDiagnosticPaintTestView(inputs) + }); + cx.simulate_window_scale_factor_change(window.into(), 2.); + let mut painted = cx + .update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(cx); + window.painted_underlines() + }) + .expect("completed diagnostic canvas"); + painted.sort_by_key(|underline| clipped(underline).left()); + assert_eq!(painted.len(), expected.len()); + for (underline, (visible, original, severity)) in painted.iter().zip(&expected) { + let style = point_diagnostic_test_style(*severity); + assert_eq!(clipped(underline), device_bounds(visible)); + assert_eq!(underline.bounds, device_bounds(original)); + assert_eq!(Some(underline.color), style.color); + assert_eq!(underline.thickness, ScaledPixels(2.)); + assert_eq!(underline.wavy, true.into()); + } + } + } + } + + const ERROR: DiagnosticSeverity = DiagnosticSeverity::ERROR; + const WARNING: DiagnosticSeverity = DiagnosticSeverity::WARNING; + + struct PointDiagnosticPaintTestView(Vec<(f32, f32, DiagnosticSeverity, bool)>); + + impl Render for PointDiagnosticPaintTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let inputs = self.0.clone(); + gpui::canvas( + |_, _, _| {}, + move |_, _, window, _| { + let underlines = inputs + .into_iter() + .map(|(x, width, severity, is_point)| { + let diagnostic = PointDiagnostic { + column: 0, + underline: point_diagnostic_test_style(severity), + severity, + }; + let mut underline = DiagnosticUnderline::point( + &diagnostic, + point(px(x), px(4.)), + px(width), + window, + ); + underline.is_point = is_point; + underline + }) + .collect::>(); + DiagnosticUnderline::paint_all(&underlines, window); + }, + ) + .size_full() + } + } + + fn point_diagnostic_test_style(severity: DiagnosticSeverity) -> UnderlineStyle { + let hue = if severity == ERROR { 0. } else { 0.15 }; + UnderlineStyle { + thickness: px(1.), + color: Some(gpui::hsla(hue, 1., 0.5, 1.)), + wavy: true, + } + } + + struct PointDiagnosticTestView { + editor: Entity, + style: EditorStyle, + x_offset: Pixels, + } + + impl Render for PointDiagnosticTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .pl(self.x_offset) + .child(EditorElement::new(&self.editor, self.style.clone())) + } + } + + fn point_diagnostic_buffer( + text: &str, + diagnostics: impl IntoIterator, DiagnosticSeverity)>, + cx: &mut impl AppContext, + ) -> Entity { + cx.new(|cx| { + let mut buffer = Buffer::local(text, cx); + let diagnostics = DiagnosticSet::new( + diagnostics.into_iter().map(|(range, severity)| { + DiagnosticEntry::new( + range, + Diagnostic { + severity, + underline: true, + ..Diagnostic::default() + }, + ) + }), + &buffer.snapshot(), + ); + buffer.update_diagnostics(lsp::LanguageServerId(0), diagnostics, cx); + buffer + }) + } + + fn point_diagnostic_editor( + buffer: Entity, + cx: &mut TestAppContext, + ) -> (WindowHandle, Entity) { + let window = cx.open_window(size(px(500.), px(200.)), |window, cx| { + let editor = cx.new(|cx| { + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.show_gutter = false; + editor.offset_content = false; + editor.set_read_only(true); + editor + }); + let mut style = editor.update(cx, |editor, cx| editor.style(cx).clone()); + style.text.font_size = px(13.).into(); + style.text.line_height = px(26.).into(); + PointDiagnosticTestView { + editor, + style, + x_offset: Pixels::ZERO, + } + }); + let editor = window + .read_with(cx, |view, _| view.editor.clone()) + .expect("test editor"); + (window, editor) + } + + #[track_caller] + fn assert_painted_point_diagnostics( + window: WindowHandle, + expected: &[(u32, Range, DiagnosticSeverity)], + cx: &mut TestAppContext, + ) { + let (error, warning, x_offset) = window + .read_with(cx, |view, _| { + ( + view.style.status.error, + view.style.status.warning, + view.x_offset, + ) + }) + .expect("diagnostic colors"); + let (underlines, scale) = cx + .update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(cx); + (window.painted_underlines(), window.scale_factor()) + }) + .expect("completed diagnostic scene"); + let mut actual = Vec::new(); + for underline in underlines { + let severity = if underline.color == error { + ERROR + } else if underline.color == warning { + WARNING + } else { + continue; + }; + let bounds = underline.bounds.intersect(&underline.content_mask.bounds); + if bounds.is_empty() { + continue; + } + assert_eq!( + [bounds.left().0.fract(), bounds.right().0.fract()], + [0., 0.] + ); + let row = (bounds.top().0 / (26. * scale)).floor() as u32; + actual.extend( + (bounds.left().0 as i32..bounds.right().0 as i32).map(|x| (row, x, severity)), + ); + } + let mut expected = expected + .iter() + .flat_map(|(row, range, severity)| range.clone().map(|x| (*row, x, *severity))) + .collect::>(); + actual.sort_by_key(|(row, x, _)| (*row, *x)); + expected.sort_by_key(|(row, x, _)| (*row, *x)); + + assert_eq!(actual, expected, "scale: {scale}, x offset: {x_offset:?}"); + } } diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index 9956141379..e7b271f6d5 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -54,7 +54,7 @@ pub(super) struct StickyHeaders { pub(super) struct StickyHeaderLine { row: DisplayRow, pub(super) offset: Pixels, - line: Rc, + pub(super) line: Rc, line_number: Option, elements: SmallVec<[AnyElement; 1]>, available_text_width: Pixels, @@ -255,7 +255,7 @@ impl EditorElement { offset, } in rows.into_iter().rev() { - let line = layout_line( + let mut line = layout_line( sticky_row, snapshot, &self.style, @@ -284,12 +284,19 @@ impl EditorElement { self.shape_line_number(SharedString::from(number.to_string()), color, window) }); + self.populate_point_diagnostics( + snapshot, + sticky_row..sticky_row.next_row(), + std::slice::from_mut(&mut line), + ); + lines.push(StickyHeaderLine::new( sticky_row, line_height * offset as f32, line, line_number, line_height, + self.style.text.text_align, scroll_pixel_position, content_origin, gutter_hitbox, @@ -473,6 +480,7 @@ impl StickyHeaderLine { mut line: LineWithInvisibles, line_number: Option, line_height: Pixels, + text_align: TextAlign, scroll_pixel_position: gpui::Point, content_origin: gpui::Point, gutter_hitbox: &Hitbox, @@ -486,6 +494,8 @@ impl StickyHeaderLine { scroll_pixel_position, content_origin, offset, + text_align, + text_hitbox.size.width, &mut elements, window, cx, diff --git a/crates/editor/src/element/mouse.rs b/crates/editor/src/element/mouse.rs index 7db3f77f5b..52f9677d39 100644 --- a/crates/editor/src/element/mouse.rs +++ b/crates/editor/src/element/mouse.rs @@ -14,7 +14,7 @@ use project::DisableAiSettings; use settings::Settings; use sum_tree::Bias; use text::SelectionGoal; -use util::{RangeExt, debug_panic, post_inc}; +use util::{RangeExt, debug_panic}; use super::{EditorElement, EditorLayout, LineNumberLayout, PositionMap, SplitSide}; use crate::{ @@ -613,6 +613,16 @@ impl EditorElement { return; } + if !event.modifiers.modified() + && text_hitbox.is_hovered(window) + && editor.hovered_inlay_hint_command().is_some_and(|command| { + command.contains_point(&position_map.snapshot, point_for_position) + }) + { + cx.stop_propagation(); + return; + } + if EditorSettings::get_global(cx) .drag_and_drop_selection .enabled @@ -943,8 +953,6 @@ impl EditorElement { if let Some(mouse_position) = event.mouse_position() && !pending_nonempty_selections - && hovered_link_modifier - && mouse_down_hovered_link_modifier && text_hitbox.is_hovered(window) && !matches!( editor.selection_drag_state, @@ -952,10 +960,27 @@ impl EditorElement { ) { let point = position_map.point_for_position(mouse_position); - editor.handle_click_hovered_link(point, event.modifiers(), window, cx); - editor.selection_drag_state = SelectionDragState::None; + if let ClickEvent::Mouse(mouse_event) = event + && mouse_event.up.click_count == 1 + && !mouse_event.down.modifiers.modified() + && !mouse_event.up.modifiers.modified() + && editor.activate_hovered_inlay_hint_command( + &position_map.snapshot, + position_map.point_for_position(mouse_event.down.position), + point, + cx, + ) + { + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + return; + } - cx.stop_propagation(); + if hovered_link_modifier && mouse_down_hovered_link_modifier { + editor.handle_click_hovered_link(point, event.modifiers(), window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } } } @@ -1065,7 +1090,7 @@ impl EditorElement { ); if mouse_down_time.elapsed() >= drag_and_drop_delay { let drop_cursor = Selection { - id: post_inc(&mut editor.selections.next_selection_id()), + id: editor.selections.next_selection_id(), start: drop_anchor, end: drop_anchor, reversed: false, diff --git a/crates/editor/src/emmet_ext.rs b/crates/editor/src/emmet_ext.rs new file mode 100644 index 0000000000..bc0d605e0d --- /dev/null +++ b/crates/editor/src/emmet_ext.rs @@ -0,0 +1,1628 @@ +use std::ops::Range; +use std::time::Duration; + +use futures::future::join_all; +use gpui::{App, AsyncWindowContext, Context, Entity, Global, SharedString, Task, Window}; +use language::{Buffer, Language}; +use lsp::LanguageServerId; +use multi_buffer::{ + Anchor, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToOffset, ToPoint, +}; +use project::Project; +use project::lsp_store::emmet_ext::{EMMET_SERVER_NAME, ExpandAbbreviation}; +use snippet::Snippet; +use text::Point; +use util::ResultExt as _; + +use crate::scroll::Autoscroll; +use crate::{ + Editor, InlineInputPreview, SelectionEffects, WrapWithAbbreviation, element::register_action, +}; + +const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(150); +const MAX_HISTORY_ENTRIES: usize = 20; + +const ELEMENT_NODE_KINDS: [&str; 5] = [ + "element", + "jsx_element", + "jsx_self_closing_element", + "script_element", + "style_element", +]; + +const TAG_NODE_KINDS: [&str; 5] = [ + "start_tag", + "end_tag", + "self_closing_tag", + "jsx_opening_element", + "jsx_closing_element", +]; + +const STYLESHEET_LANGUAGE_IDS: [&str; 6] = ["css", "sass", "scss", "less", "sss", "stylus"]; + +#[derive(Clone, Copy, Default)] +struct WrapFilters { + trim: bool, + comment: bool, + bem: bool, +} + +#[derive(Default)] +struct WrapAbbreviationHistory(Vec); + +impl Global for WrapAbbreviationHistory {} + +pub fn apply_related_actions(editor: &Entity, window: &mut Window, cx: &mut App) { + let editor_ref = editor.read(cx); + let Some(project) = editor_ref.project() else { + return; + }; + let project = project.read(cx); + if !project + .language_server_statuses(cx) + .any(|(_, status)| status.name == EMMET_SERVER_NAME) + { + return; + } + let supports_wrap = editor_ref + .buffer() + .read(cx) + .all_buffers_iter() + .any(|buffer| { + buffer + .read(cx) + .language() + .is_some_and(|language| language_supports_wrap(language, project)) + }); + if supports_wrap { + register_action(editor, window, wrap_with_abbreviation); + } +} + +pub fn wrap_with_abbreviation( + editor: &mut Editor, + _: &WrapWithAbbreviation, + window: &mut Window, + cx: &mut Context, +) { + if editor.read_only(cx) { + return; + } + + let selections = editor.selections.disjoint_anchors_arc(); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let mut offset_ranges = Vec::>::new(); + for selection in selections.iter() { + let mut range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot); + if range.start == range.end { + range = enclosing_element_range(range.start, &snapshot).unwrap_or_else(|| { + let row = MultiBufferRow(selection.start.to_point(&snapshot).row); + Point::new(row.0, 0).to_offset(&snapshot) + ..Point::new(row.0, snapshot.line_len(row)).to_offset(&snapshot) + }); + } else { + range = expand_over_partial_tags(range, &snapshot); + } + offset_ranges.push(trim_whitespace(range, &snapshot)); + } + offset_ranges.sort_by_key(|range| (range.start, range.end)); + let mut merged_ranges = Vec::>::with_capacity(offset_ranges.len()); + for range in offset_ranges { + match merged_ranges.last_mut() { + Some(last) if range.start < last.end || range.start == last.start => { + last.end = last.end.max(range.end); + } + _ => merged_ranges.push(range), + } + } + let ranges = merged_ranges + .into_iter() + .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end)) + .collect::>(); + if ranges.is_empty() { + return; + } + if !ranges.iter().any(|range| { + build_wrap_target(editor, range, WrapFilters::default(), &snapshot, cx).is_some() + }) { + log_unavailable(); + return; + } + + editor.change_selections(SelectionEffects::default(), window, cx, |selections| { + selections.select_anchor_ranges(ranges.iter().cloned()) + }); + let newest = editor.selections.newest_anchor(); + let start_column = newest.start.to_point(&snapshot).column; + let end_row = MultiBufferRow(newest.end.to_point(&snapshot).row); + let position = snapshot.anchor_before(Point::new( + end_row.0, + start_column.min(snapshot.line_len(end_row)), + )); + let preview_language = snapshot.language_at(position).cloned(); + let history = cx + .try_global::() + .map(|history| history.0.clone()) + .unwrap_or_default(); + let confirm_ranges = ranges.clone(); + let preview_ranges = ranges; + editor.show_inline_input( + "Emmet abbreviation, e.g. ul>li*", + position, + preview_language, + history, + move |editor, text, window, cx| { + wrap_targets_in_expanded_abbreviation(editor, &confirm_ranges, text, window, cx) + }, + move |editor, text, window, cx| { + update_expansion_preview(editor, &preview_ranges, text, window, cx) + }, + window, + cx, + ); +} + +struct WrapTarget { + range: Range, + text: Option>, + buffer: Entity, + server_id: LanguageServerId, + language: String, + indent: String, + base_indent: String, +} + +fn language_supports_wrap(language: &Language, project: &Project) -> bool { + project + .languages() + .lsp_adapter(&language.name(), &EMMET_SERVER_NAME) + .is_some_and(|adapter| { + !STYLESHEET_LANGUAGE_IDS.contains(&adapter.language_id(&language.name()).as_str()) + }) +} + +fn build_wrap_target( + editor: &Editor, + range: &Range, + filters: WrapFilters, + snapshot: &MultiBufferSnapshot, + cx: &App, +) -> Option { + let project = editor.project.as_ref()?.read(cx); + let multi_buffer = editor.buffer().read(cx); + let (buffer, start) = multi_buffer.text_anchor_for_position(range.start, cx)?; + let (end_buffer, _) = multi_buffer.text_anchor_for_position(range.end, cx)?; + if buffer != end_buffer { + return None; + } + let server_id = project.language_server_id_for_name(buffer.read(cx), &EMMET_SERVER_NAME, cx)?; + let language = buffer + .read(cx) + .language_at(start) + .or_else(|| buffer.read(cx).language().cloned())?; + let adapter = project + .lsp_store() + .read(cx) + .language_server_adapter_for_id(server_id)?; + let language = adapter.language_id(&language.name()); + if STYLESHEET_LANGUAGE_IDS.contains(&language.as_str()) { + return None; + } + let text = snapshot.text_for_range(range.clone()).collect::(); + let settings = snapshot.language_settings_at(range.start, cx); + let indent = if settings.hard_tabs { + "\t".to_string() + } else { + " ".repeat(settings.tab_size.get() as usize) + }; + let base_indent = base_indent_for(range.start, snapshot); + Some(WrapTarget { + text: selection_lines(&text, &base_indent, filters.trim), + range: range.clone(), + buffer, + server_id, + language, + indent, + base_indent, + }) +} + +fn selection_lines(text: &str, base_indent: &str, trim_markers: bool) -> Option> { + if text.is_empty() { + return None; + } + let lines = text + .split('\n') + .enumerate() + .map(|(ix, line)| { + let line = line.trim_end(); + let line = if ix == 0 { + line + } else { + line.strip_prefix(base_indent).unwrap_or(line) + }; + let line = if trim_markers { + trim_list_marker(line) + } else { + line + }; + line.replace('\\', "\\\\").replace('$', "\\$") + }) + .collect::>(); + Some(lines) +} + +fn trim_list_marker(line: &str) -> &str { + let trimmed = line.trim_start_matches([' ', '\t', '\u{a0}']); + let marker_len = trimmed + .chars() + .take_while(|ch| ch.is_ascii_digit() || matches!(ch, '#' | '-' | '*' | '•')) + .map(char::len_utf8) + .sum::(); + if marker_len == 0 { + return line.trim(); + } + let rest = &trimmed[marker_len..]; + let rest = rest.strip_prefix(['.', ')']).unwrap_or(rest); + rest.trim() +} + +fn parse_abbreviation(input: &str) -> Option<(String, WrapFilters)> { + let mut abbreviation = input.trim(); + let mut filters = WrapFilters::default(); + loop { + if let Some(stripped) = abbreviation.strip_suffix("|t") { + filters.trim = true; + abbreviation = stripped.trim_end(); + } else if let Some(stripped) = abbreviation.strip_suffix("|c") { + filters.comment = true; + abbreviation = stripped.trim_end(); + } else if let Some(stripped) = abbreviation.strip_suffix("|bem") { + filters.bem = true; + abbreviation = stripped.trim_end(); + } else { + break; + } + } + if abbreviation.is_empty() { + return None; + } + Some((abbreviation.to_string(), filters)) +} + +fn wrap_targets_in_expanded_abbreviation( + editor: &mut Editor, + ranges: &[Range], + text: String, + window: &mut Window, + cx: &mut Context, +) -> Option> { + let input = text.trim().to_string(); + let (abbreviation, filters) = parse_abbreviation(&input)?; + let project = editor.project.clone()?; + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let targets = ranges + .iter() + .filter_map(|range| build_wrap_target(editor, range, filters, &snapshot, cx)) + .collect::>(); + if targets.is_empty() { + log_unavailable(); + editor.take_inline_input(window, cx); + return None; + } + + Some(cx.spawn_in(window, async move |editor, cx| { + let requests = targets + .into_iter() + .map(|target| { + let range = target.range.clone(); + let request = + request_expansion(&project, abbreviation.clone(), filters, target, cx); + async move { anyhow::Ok((range, request.await?)) } + }) + .collect::>(); + let mut expansions = Vec::new(); + let mut errors = Vec::new(); + for result in join_all(requests).await { + match result { + Ok((range, Some(expansion))) => expansions.push((range, expansion)), + Ok((_, None)) => {} + Err(error) => errors.push(error), + } + } + + editor + .update_in(cx, |editor, window, cx| { + for error in &errors { + log::error!("{error:#}"); + } + if expansions.is_empty() { + let message = match errors.first() { + Some(error) => error.root_cause().to_string(), + None => no_expansion_message(&abbreviation), + }; + editor.set_inline_input_preview(Some(InlineInputPreview::Error(message)), cx); + return anyhow::Ok(()); + } + remember_abbreviation(SharedString::from(input), cx); + if let Some(mut state) = editor.take_inline_input(window, cx) + && let Some(confirm_task) = state.take_confirm_task() + { + confirm_task.detach(); + } + let snapshot = editor.buffer().read(cx).snapshot(cx); + if let [(range, expansion)] = expansions.as_slice() { + if let Ok(snippet) = Snippet::parse(expansion) { + let range = + range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + editor.insert_snippet_with_autoindent( + &[range], + snippet, + None, + window, + cx, + )?; + return anyhow::Ok(()); + } + } + let edits = expansions + .into_iter() + .map(|(range, expansion)| (range, expansion_text(&expansion))) + .collect::>(); + editor.transact(window, cx, |editor, _, cx| { + editor.buffer().update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + }); + editor.request_autoscroll(Autoscroll::fit(), cx); + anyhow::Ok(()) + }) + .and_then(|result| result) + .log_err(); + })) +} + +fn update_expansion_preview( + editor: &mut Editor, + ranges: &[Range], + text: String, + window: &mut Window, + cx: &mut Context, +) -> Option> { + let Some((abbreviation, filters)) = parse_abbreviation(&text) else { + editor.set_inline_input_preview(None, cx); + return None; + }; + let Some(project) = editor.project.clone() else { + editor.set_inline_input_preview(None, cx); + return None; + }; + let snapshot = editor.buffer().read(cx).snapshot(cx); + let Some(mut target) = ranges + .iter() + .find_map(|range| build_wrap_target(editor, range, filters, &snapshot, cx)) + else { + editor.set_inline_input_preview(None, cx); + return None; + }; + target.base_indent.clear(); + + Some(cx.spawn_in(window, async move |editor, cx| { + cx.background_executor().timer(PREVIEW_DEBOUNCE).await; + let result = request_expansion(&project, abbreviation.clone(), filters, target, cx).await; + editor + .update(cx, |editor, cx| { + let preview = match result { + Ok(Some(expansion)) => InlineInputPreview::Text(expansion_text(&expansion)), + Ok(None) => InlineInputPreview::Error(no_expansion_message(&abbreviation)), + Err(error) => InlineInputPreview::Error(error.root_cause().to_string()), + }; + editor.set_inline_input_preview(Some(preview), cx); + }) + .ok(); + })) +} + +fn request_expansion( + project: &Entity, + abbreviation: String, + filters: WrapFilters, + target: WrapTarget, + cx: &mut AsyncWindowContext, +) -> Task>> { + project.update(cx, |project, cx| { + project.request_lsp( + target.buffer, + project::LanguageServerToQuery::Other(target.server_id), + ExpandAbbreviation { + abbreviation, + text: target.text, + language: target.language, + server_id: target.server_id, + indent: target.indent, + base_indent: target.base_indent, + comment_filter: filters.comment, + bem_filter: filters.bem, + }, + cx, + ) + }) +} + +fn expansion_text(expansion: &str) -> String { + Snippet::parse(expansion) + .map(|snippet| snippet.text) + .unwrap_or_else(|_| expansion.to_string()) +} + +fn no_expansion_message(abbreviation: &str) -> String { + format!("No Emmet expansion for {abbreviation:?}") +} + +fn remember_abbreviation(abbreviation: SharedString, cx: &mut App) { + let history = &mut cx.default_global::().0; + history.retain(|entry| *entry != abbreviation); + history.insert(0, abbreviation); + history.truncate(MAX_HISTORY_ENTRIES); +} + +fn log_unavailable() { + log::info!("Wrapping with an Emmet abbreviation is not available for the selected text"); +} + +fn base_indent_for(range_start: Anchor, snapshot: &MultiBufferSnapshot) -> String { + let row = MultiBufferRow(range_start.to_point(snapshot).row); + snapshot.indent_size_for_line(row).chars().collect() +} + +fn enclosing_element_range( + position: MultiBufferOffset, + snapshot: &MultiBufferSnapshot, +) -> Option> { + let mut range = position..position; + while let Some((node, node_range)) = snapshot.syntax_ancestor(range.clone()) { + if ELEMENT_NODE_KINDS.contains(&node.kind()) { + return Some(node_range); + } + if node_range == range { + return None; + } + range = node_range; + } + None +} + +fn expand_over_partial_tags( + range: Range, + snapshot: &MultiBufferSnapshot, +) -> Range { + let mut expanded = range.clone(); + for position in [range.start, range.end] { + if let Some(element_range) = element_range_for_partial_tag(position, snapshot) { + expanded.start = expanded.start.min(element_range.start); + expanded.end = expanded.end.max(element_range.end); + } + } + expanded +} + +fn element_range_for_partial_tag( + position: MultiBufferOffset, + snapshot: &MultiBufferSnapshot, +) -> Option> { + let mut range = position..position; + let mut in_tag = false; + while let Some((node, node_range)) = snapshot.syntax_ancestor(range.clone()) { + if TAG_NODE_KINDS.contains(&node.kind()) { + if node_range.start < position && position < node_range.end { + in_tag = true; + } + } else if ELEMENT_NODE_KINDS.contains(&node.kind()) { + return in_tag.then_some(node_range); + } + if node_range == range { + return None; + } + range = node_range; + } + None +} + +fn trim_whitespace( + mut range: Range, + snapshot: &MultiBufferSnapshot, +) -> Range { + for ch in snapshot.chars_at(range.start) { + if range.start == range.end || !ch.is_whitespace() { + break; + } + range.start += ch.len_utf8(); + } + for ch in snapshot.reversed_chars_at(range.end) { + if range.end == range.start || !ch.is_whitespace() { + break; + } + range.end -= ch.len_utf8(); + } + range +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use futures::StreamExt as _; + use gpui::{AppContext as _, Focusable as _, TestAppContext, VisualTestContext}; + use language::{Capability, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher}; + use multi_buffer::{MultiBuffer, PathKey}; + use project::lsp_store::emmet_ext::{EmmetOutputOptions, LspExpandAbbreviation}; + use project::{FakeFs, Project}; + use util::path; + + use crate::editor_tests::init_test; + use crate::test::build_editor_with_project; + + use super::*; + + #[gpui::test] + async fn test_wrap_selection_in_emmet_abbreviation(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div.wrap"); + assert_eq!(params.language, "html"); + assert_eq!(params.options.text, Some(vec!["hello".to_string()])); + assert_eq!( + params.options.options, + EmmetOutputOptions { + indent: " ".to_string(), + base_indent: String::new(), + ..EmmetOutputOptions::default() + }, + "single-line wraps should not force inline breaks" + ); + Ok(Some("
${1:hello}
$0".to_string())) + }, + ); + + editor.update_in(cx, |editor, window, cx| { + let input = pending_input(editor); + assert!(input.focus_handle(cx).is_focused(window)); + input.update(cx, |input, cx| input.set_text("div.wrap", window, cx)); + }); + cx.update(|_, cx| { + cx.bind_keys([gpui::KeyBinding::new( + "enter", + menu::Confirm, + Some("Editor && inline_input"), + )]); + }); + cx.simulate_keystrokes("enter"); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "

hello

"); + assert!(editor.pending_inline_input.is_none()); + }); + } + + #[gpui::test] + async fn test_input_stays_open_on_empty_expansion(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + + let mut requests = fake_server + .set_request_handler::(|_, _| async move { Ok(None) }); + + confirm_abbreviation(&editor, "@invalid@", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "

hello

"); + assert!( + editor.pending_inline_input.is_some(), + "the input should stay open so the abbreviation can be corrected" + ); + assert_eq!( + editor + .pending_inline_input + .as_ref() + .and_then(|state| state.preview.clone()), + Some(InlineInputPreview::Error( + "No Emmet expansion for \"@invalid@\"".to_string() + )), + "the failure should show up next to the input" + ); + }); + } + + #[gpui::test] + async fn test_wrap_line_on_empty_selection(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup(" hello world\n", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 4)..Point::new(0, 4)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "strong"); + assert_eq!(params.options.text, Some(vec!["hello world".to_string()])); + Ok(Some("hello world".to_string())) + }, + ); + + confirm_abbreviation(&editor, "strong", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), " hello world\n"); + }); + } + + #[gpui::test] + async fn test_wrap_enclosing_element_on_empty_selection(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("
\n

hello

\n
", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(1, 3)..Point::new(1, 3)], cx); + editor.update(cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest::(&snapshot).range(), + Point::new(1, 2)..Point::new(1, 14), + "the element about to be wrapped should be selected so it is visible" + ); + }); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div.x"); + assert_eq!( + params.options.text, + Some(vec!["

hello

".to_string()]), + "the enclosing element should be wrapped, not the line" + ); + Ok(Some("

hello

".to_string())) + }, + ); + + confirm_abbreviation(&editor, "div.x", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "
\n

hello

\n
" + ); + }); + } + + #[gpui::test] + async fn test_multiline_expansion_preserves_surrounding_indentation(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = + setup("
\n

a

\n

b

\n
", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(1, 2)..Point::new(2, 10)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div"); + assert_eq!( + params.options.text, + Some(vec!["

a

".to_string(), "

b

".to_string()]), + "lines should be dedented by the base indentation of the first line" + ); + assert_eq!( + params.options.options.base_indent, " ", + "the wrapped line's indentation should be sent as the base indent" + ); + Ok(Some(format_expansion( + "
\n\t

a

\n\t

b

\n
", + ¶ms.options.options, + ))) + }, + ); + + confirm_abbreviation(&editor, "div", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "
\n
\n

a

\n

b

\n
\n
", + "inserted lines should be indented relative to the wrapped line, \ + and surrounding indentation should not change" + ); + }); + } + + #[gpui::test] + async fn test_escape_dismisses_emmet_wrap_input(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + editor.update_in(cx, |editor, window, cx| { + assert!(editor.pending_inline_input.is_some()); + editor.cancel(&crate::actions::Cancel, window, cx); + assert!(editor.pending_inline_input.is_none()); + assert_eq!(editor.text(cx), "

hello

"); + }); + } + + #[gpui::test] + async fn test_multiple_cursors_on_blank_line_wrap_once(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup(" \n", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap( + &editor, + [ + Point::new(0, 0)..Point::new(0, 0), + Point::new(0, 2)..Point::new(0, 2), + ], + cx, + ); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div"); + assert_eq!(params.options.text, None); + Ok(Some("
".to_string())) + }, + ); + + confirm_abbreviation(&editor, "div", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "
\n"); + assert!(editor.pending_inline_input.is_none()); + }); + } + + #[gpui::test] + async fn test_stale_confirmation_is_ignored(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + + let (release_fresh, fresh_gate) = futures::channel::oneshot::channel::<()>(); + let fresh_gate = Arc::new(std::sync::Mutex::new(Some(fresh_gate))); + fake_server.set_request_handler::({ + move |params, _| { + let fresh_gate = fresh_gate.clone(); + async move { + if params.abbreviation == "span" { + let gate = fresh_gate.lock().unwrap().take(); + if let Some(gate) = gate { + gate.await.ok(); + } + Ok(Some("hello".to_string())) + } else { + assert_eq!(params.abbreviation, "div"); + Ok(Some("
hello
".to_string())) + } + } + } + }); + + confirm_abbreviation(&editor, "div", cx); + confirm_abbreviation(&editor, "span", cx); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "

hello

", + "the stale expansion should be dropped while the fresh one is in flight" + ); + assert!(editor.pending_inline_input.is_some()); + }); + + release_fresh.send(()).ok(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "

hello

"); + assert!(editor.pending_inline_input.is_none()); + }); + } + + #[gpui::test] + async fn test_preview_updates_as_you_type(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div.wrap"); + assert_eq!(params.options.text, Some(vec!["hello".to_string()])); + Ok(Some("
${1:hello}
$0".to_string())) + }, + ); + + editor.update_in(cx, |editor, window, cx| { + let input = pending_input(editor); + input.update(cx, |input, cx| input.set_text("div.wrap", window, cx)); + }); + cx.executor().advance_clock(PREVIEW_DEBOUNCE * 2); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor + .pending_inline_input + .as_ref() + .and_then(|state| state.preview.clone()), + Some(InlineInputPreview::Text( + "
hello
".to_string() + )), + "the pending expansion should be previewed under the input" + ); + assert_eq!( + editor.text(cx), + "

hello

", + "previewing should not edit the buffer" + ); + }); + + editor.update_in(cx, |editor, window, cx| { + let input = pending_input(editor); + input.update(cx, |input, cx| input.set_text("", window, cx)); + }); + cx.executor().run_until_parked(); + editor.update(cx, |editor, _| { + assert_eq!( + editor + .pending_inline_input + .as_ref() + .and_then(|state| state.preview.clone()), + None, + "clearing the input should clear the preview" + ); + }); + } + + #[gpui::test] + async fn test_preview_does_not_embed_base_indentation(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = + setup("
\n

a

\n

b

\n
", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(1, 2)..Point::new(2, 10)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div"); + assert_eq!( + params.options.options.base_indent, "", + "previews render at the anchor column already, \ + so they must request no base indent" + ); + Ok(Some(format_expansion( + "
\n\t

a

\n\t

b

\n
", + ¶ms.options.options, + ))) + }, + ); + + editor.update_in(cx, |editor, window, cx| { + let input = pending_input(editor); + input.update(cx, |input, cx| input.set_text("div", window, cx)); + }); + cx.executor().advance_clock(PREVIEW_DEBOUNCE * 2); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, _| { + assert_eq!( + editor + .pending_inline_input + .as_ref() + .and_then(|state| state.preview.clone()), + Some(InlineInputPreview::Text( + "
\n

a

\n

b

\n
".to_string() + )), + "the preview block already renders at the anchor column, \ + so its lines must not repeat the wrapped line's base indentation" + ); + }); + } + + #[gpui::test] + async fn test_last_abbreviation_is_prefilled(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + + let mut requests = + fake_server.set_request_handler::(|_, _| async move { + Ok(Some("
hello
".to_string())) + }); + + confirm_abbreviation(&editor, "div.wrap", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "

hello

"); + }); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + editor.update(cx, |editor, cx| { + let input = pending_input(editor); + input.update(cx, |input, cx| { + assert_eq!( + input.text(cx), + "div.wrap", + "the last used abbreviation should be prefilled" + ); + let snapshot = input.display_snapshot(cx); + let selected = input.selections.newest::(&snapshot); + assert_eq!( + selected.range(), + Point::new(0, 0)..Point::new(0, "div.wrap".len() as u32), + "the prefilled abbreviation should be selected so typing replaces it" + ); + }); + }); + } + + #[gpui::test] + async fn test_up_and_down_cycle_abbreviation_history(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + cx.update(|_, cx| { + cx.bind_keys([ + gpui::KeyBinding::new("up", crate::MoveUp, Some("Editor")), + gpui::KeyBinding::new("down", crate::MoveDown, Some("Editor")), + ]); + }); + let mut requests = fake_server.set_request_handler::( + |params, _| async move { Ok(Some(format!("<{0}>hello", params.abbreviation))) }, + ); + + for abbreviation in ["em", "strong", "span"] { + let hello = editor.update(cx, |editor, cx| { + let text = editor.text(cx); + let start = text.find("hello").expect("hello should still be there") as u32; + Point::new(0, start)..Point::new(0, start + "hello".len() as u32) + }); + select_and_wrap(&editor, [hello], cx); + confirm_abbreviation(&editor, abbreviation, cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + } + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "

hello

" + ); + }); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + assert_eq!(input_text(&editor, cx), "span", "newest entry is prefilled"); + + cx.simulate_keystrokes("up"); + assert_eq!( + input_text(&editor, cx), + "strong", + "up moves to the older entry" + ); + cx.simulate_keystrokes("up"); + assert_eq!(input_text(&editor, cx), "em"); + cx.simulate_keystrokes("up"); + assert_eq!( + input_text(&editor, cx), + "em", + "up stops at the oldest entry" + ); + + cx.simulate_keystrokes("down down down"); + assert_eq!( + input_text(&editor, cx), + "", + "down past the newest entry returns to the empty draft" + ); + cx.simulate_keystrokes("down"); + assert_eq!(input_text(&editor, cx), "", "down stops at the draft"); + + editor.update_in(cx, |editor, window, cx| { + let input = pending_input(editor); + input.update(cx, |input, cx| input.set_text("div.wip", window, cx)); + }); + cx.executor().run_until_parked(); + cx.simulate_keystrokes("up"); + assert_eq!( + input_text(&editor, cx), + "span", + "up from a draft starts at the newest entry" + ); + cx.simulate_keystrokes("down"); + assert_eq!( + input_text(&editor, cx), + "div.wip", + "down restores the draft that was being typed" + ); + + editor.update(cx, |editor, cx| { + assert!( + editor.pending_inline_input.is_some(), + "cycling history must not dismiss the input" + ); + assert_eq!( + editor.text(cx), + "

hello

" + ); + }); + } + + #[gpui::test] + async fn test_wrap_individual_lines(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = + setup("
\n About\n News\n Products\n
", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(1, 4)..Point::new(3, 12)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "ul>li*"); + assert_eq!( + params.options.text, + Some(vec![ + "About".to_string(), + "News".to_string(), + "Products".to_string(), + ]), + "each selected line should be sent separately so `*` repeats per line" + ); + assert_eq!( + params.options.options, + EmmetOutputOptions { + indent: " ".to_string(), + base_indent: " ".to_string(), + inline_break: Some(1), + ..EmmetOutputOptions::default() + }, + "multi-line wraps should break inline elements per line \ + and carry the buffer's indentation to the server" + ); + Ok(Some(format_expansion( + "
    \n\t
  • About
  • \n\t
  • News
  • \n\t
  • Products
  • \n
", + ¶ms.options.options, + ))) + }, + ); + + confirm_abbreviation(&editor, "ul>li*", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "
\n
    \n
  • About
  • \n
  • News
  • \n
  • Products
  • \n
\n
" + ); + }); + } + + #[gpui::test] + async fn test_trim_filter_strips_list_markers(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup(" * one\n 2. two\n", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 0)..Point::new(1, 8)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.abbreviation, "ul>li*", + "the `|t` filter should be stripped before querying the server" + ); + assert_eq!( + params.options.text, + Some(vec!["one".to_string(), "two".to_string()]), + "list markers should be removed from the wrapped lines" + ); + Ok(Some(format_expansion( + "
    \n\t
  • one
  • \n\t
  • two
  • \n
", + ¶ms.options.options, + ))) + }, + ); + + confirm_abbreviation(&editor, "ul>li*|t", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "
    \n
  • one
  • \n
  • two
  • \n
\n" + ); + }); + } + + #[gpui::test] + async fn test_partial_tag_selection_expands_to_element(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 1)..Point::new(0, 6)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div"); + assert_eq!( + params.options.text, + Some(vec!["

hello

".to_string()]), + "a selection endpoint inside a tag should expand to the whole element" + ); + Ok(Some("

hello

".to_string())) + }, + ); + + confirm_abbreviation(&editor, "div", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "

hello

"); + }); + } + + #[gpui::test] + async fn test_dollar_signs_in_wrapped_text_are_escaped(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

costs $100

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 13)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.options.text, + Some(vec!["costs \\$100".to_string()]), + "dollar signs should be escaped so snippet insertion cannot eat them" + ); + Ok(Some("
costs \\$100
".to_string())) + }, + ); + + confirm_abbreviation(&editor, "div", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "

costs $100

", + "the escaped dollar sign should round-trip back to a literal" + ); + }); + } + + #[gpui::test] + async fn test_comment_and_bem_filters_are_forwarded(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.abbreviation, "div#page", + "filters should be stripped from the abbreviation" + ); + assert_eq!( + params.options.options, + EmmetOutputOptions { + indent: " ".to_string(), + base_indent: String::new(), + comment_enabled: Some(true), + bem_enabled: Some(true), + inline_break: None, + }, + "the |c and |bem filters should map to emmet output options" + ); + Ok(Some( + "
hello
\n".to_string(), + )) + }, + ); + + confirm_abbreviation(&editor, "div#page|c|bem", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "

hello
\n

" + ); + }); + } + + #[gpui::test] + async fn test_wrap_is_unavailable_in_stylesheets(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.css"), ".a { color: red; }".into()) + .await; + + let project = Project::test(fs, [path!("/file.css").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "CSS".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["css".into()], + ..LanguageMatcher::default() + } + .into(), + ..LanguageConfig::default() + }, + None, + ))); + let mut fake_servers = language_registry.register_fake_lsp( + "CSS", + FakeLspAdapter { + name: "emmet-language-server", + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.css"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project, multi_buffer, window, cx) + }); + fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 0)..Point::new(0, 18)], cx); + + editor.update(cx, |editor, cx| { + assert!( + editor.pending_inline_input.is_none(), + "wrapping is a markup action and should refuse stylesheets" + ); + assert_eq!(editor.text(cx), ".a { color: red; }"); + }); + } + + #[gpui::test] + async fn test_interleaved_overlapping_selections_are_merged(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

a

b

xy
", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap( + &editor, + [ + Point::new(0, 8)..Point::new(0, 9), + Point::new(0, 16)..Point::new(0, 17), + Point::new(0, 22)..Point::new(0, 22), + ], + cx, + ); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "section"); + assert_eq!( + params.options.text, + Some(vec!["

a

b

xy
".to_string()]), + "the element expanded from the trailing caret should be merged \ + with the earlier selections into a single wrap target" + ); + Ok(Some( + "

a

b

xy
".to_string(), + )) + }, + ); + + confirm_abbreviation(&editor, "section", cx); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "

a

b

xy
" + ); + assert!(editor.pending_inline_input.is_none()); + }); + } + + #[gpui::test] + async fn test_enter_confirms_when_parent_editor_is_focused(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = setup("

hello

", cx).await; + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div.wrap"); + Ok(Some("
hello
".to_string())) + }, + ); + + editor.update_in(cx, |editor, window, cx| { + let input = pending_input(editor); + input.update(cx, |input, cx| input.set_text("div.wrap", window, cx)); + }); + cx.update(|_, cx| { + cx.bind_keys([ + gpui::KeyBinding::new( + "enter", + crate::actions::Newline, + Some("Editor && mode == full"), + ), + gpui::KeyBinding::new("enter", menu::Confirm, Some("Editor && inline_input")), + ]); + }); + editor.update_in(cx, |editor, window, cx| { + let focus_handle = editor.focus_handle(cx); + window.focus(&focus_handle, cx); + }); + cx.simulate_keystrokes("enter"); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "

hello

", + "enter should confirm the wrap instead of inserting a newline" + ); + assert!(editor.pending_inline_input.is_none()); + }); + } + + #[gpui::test] + async fn test_wrap_in_multibuffer_excerpts(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + serde_json::json!({ + "a.html": "

one

", + "b.html": "

two

", + }), + ) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let mut fake_servers = register_html_with_emmet(&project, cx); + + let buffer_a = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/a.html"), cx) + }) + .await + .unwrap(); + let buffer_b = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/b.html"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_a.clone(), + [Point::new(0, 0)..Point::new(0, 10)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_b.clone(), + [Point::new(0, 0)..Point::new(0, 10)], + 0, + cx, + ); + multi_buffer + }); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project, multi_buffer, window, cx) + }); + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + select_and_wrap( + &editor, + [ + Point::new(0, 3)..Point::new(0, 6), + Point::new(1, 3)..Point::new(1, 6), + ], + cx, + ); + + let mut requests = fake_server.set_request_handler::( + |params, _| async move { + assert_eq!(params.abbreviation, "div"); + let wrapped = match params.options.text.as_deref() { + Some([line]) if line == "one" => "
one
", + Some([line]) if line == "two" => "
two
", + other => panic!("unexpected wrap text: {other:?}"), + }; + Ok(Some(wrapped.to_string())) + }, + ); + + confirm_abbreviation(&editor, "div", cx); + requests.next().await.unwrap(); + requests.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "

one

\n

two

", + "each excerpt should be wrapped in its own buffer" + ); + assert!(editor.pending_inline_input.is_none()); + }); + cx.update(|_, cx| { + assert_eq!(buffer_a.read(cx).text(), "

one

"); + assert_eq!(buffer_b.read(cx).text(), "

two

"); + }); + } + + fn select_and_wrap( + editor: &Entity, + ranges: impl IntoIterator>, + cx: &mut VisualTestContext, + ) { + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges(ranges) + }); + wrap_with_abbreviation(editor, &WrapWithAbbreviation, window, cx); + }); + } + + fn confirm_abbreviation( + editor: &Entity, + abbreviation: &str, + cx: &mut VisualTestContext, + ) { + editor.update_in(cx, |editor, window, cx| { + let input = pending_input(editor); + input.update(cx, |input, cx| input.set_text(abbreviation, window, cx)); + editor.confirm_inline_input(window, cx); + }); + } + + fn format_expansion(tab_indented: &str, options: &EmmetOutputOptions) -> String { + tab_indented + .split('\n') + .enumerate() + .map(|(ix, line)| { + let levels = line.chars().take_while(|ch| *ch == '\t').count(); + let base = if ix == 0 { "" } else { &options.base_indent }; + format!("{base}{}{}", options.indent.repeat(levels), &line[levels..]) + }) + .collect::>() + .join("\n") + } + + fn input_text(editor: &Entity, cx: &mut VisualTestContext) -> String { + editor.update(cx, |editor, cx| pending_input(editor).read(cx).text(cx)) + } + + fn pending_input(editor: &Editor) -> Entity { + editor + .pending_inline_input + .as_ref() + .expect("emmet wrap input should be pending") + .editor + .clone() + } + + async fn setup<'a>( + text: &str, + cx: &'a mut TestAppContext, + ) -> ( + Entity, + futures::channel::mpsc::UnboundedReceiver, + &'a mut VisualTestContext, + ) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.html"), text.into()).await; + + let project = Project::test(fs, [path!("/file.html").as_ref()], cx).await; + let fake_servers = register_html_with_emmet(&project, cx); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.html"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project, multi_buffer, window, cx) + }); + (editor, fake_servers, cx) + } + + fn register_html_with_emmet( + project: &Entity, + cx: &mut TestAppContext, + ) -> futures::channel::mpsc::UnboundedReceiver { + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "HTML".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["html".into()], + ..LanguageMatcher::default() + } + .into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_html::LANGUAGE.into()), + ))); + language_registry.register_fake_lsp( + "HTML", + FakeLspAdapter { + name: "emmet-language-server", + ..FakeLspAdapter::default() + }, + ) + } +} diff --git a/crates/editor/src/fold.rs b/crates/editor/src/fold.rs index c5b6459bc7..73aa6b3058 100644 --- a/crates/editor/src/fold.rs +++ b/crates/editor/src/fold.rs @@ -640,11 +640,15 @@ impl Editor { }); let snapshot = self.display_snapshot(cx); - self.selections.change_with(&snapshot, |selections| { + let previous = self.selections.disjoint_anchors_arc(); + let (changed, ()) = self.selections.change_with(&snapshot, |selections| { for buffer_id in ids_to_fold.iter().copied() { selections.remove_selections_from_buffer(buffer_id); } }); + if changed { + self.invalidate_add_selection_goals_after_change(Some(&previous)); + } cx.emit(EditorEvent::BufferFoldToggled { ids: ids_to_fold, diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index b0435bd0da..719911d12b 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1,24 +1,47 @@ use crate::{ - Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinitionSplit, - GoToTypeDefinition, GoToTypeDefinitionSplit, GotoDefinitionKind, HighlightKey, Navigated, - PointForPosition, SelectPhase, editor_settings::GoToDefinitionFallback, scroll::ScrollAmount, + Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GotoDefinitionKind, + HighlightKey, Navigated, PointForPosition, SelectPhase, + editor_settings::GoToDefinitionFallback, scroll::ScrollAmount, }; use gpui::{ - App, AsyncWindowContext, Context, Entity, Focusable, HighlightStyle, Modifiers, Pixels, Task, - UnderlineStyle, Window, px, + Action, App, AsyncWindowContext, Context, Entity, Focusable, HighlightStyle, Modifiers, Pixels, + Task, UnderlineStyle, WeakEntity, Window, px, }; use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; -use project::{InlayId, LocationLink, Project, ResolvedPath}; +use project::{InlayId, Location, LocationLink, Project, ResolvedPath}; use regex::Regex; -use settings::Settings; -use std::{ops::Range, str::FromStr as _, sync::LazyLock}; +use settings::{OpenResultsIn, Settings}; +use std::{ + ops::Range, + str::FromStr as _, + sync::{Arc, LazyLock}, +}; use text::OffsetRangeExt; use theme::ActiveTheme as _; use util::{ ResultExt, TryFutureExt as _, markdown::source_position_from_fragment, paths::PathWithPosition, }; +use workspace::pane::NavigationEntry; + +#[derive(Clone, Action)] +#[action(no_json, no_register)] +pub struct OpenDefinitionLocations(pub Arc); + +pub struct DefinitionLocations { + pub editor: WeakEntity, + pub position: Anchor, + pub kind: GotoDefinitionKind, + pub locations: Vec, + pub origin: Option, +} + +impl PartialEq for OpenDefinitionLocations { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} #[derive(Debug)] pub struct HoveredLinkState { @@ -206,24 +229,127 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let focus_handle = self.focus_handle(cx); - let reveal_task = self.cmd_click_reveal_task(point, modifiers, window, cx); - cx.spawn_in(window, async move |_, cx| { - let definition_revealed = reveal_task.await.log_err().unwrap_or(Navigated::No); - if definition_revealed == Navigated::Yes { - return; - } - cx.update(|window, cx| { - match EditorSettings::get_global(cx).go_to_definition_fallback { - GoToDefinitionFallback::None => {} - GoToDefinitionFallback::FindAllReferences => { - focus_handle.dispatch_action(&FindAllReferences::default(), window, cx); - } - } + let kind = if modifiers.shift { + GotoDefinitionKind::Type + } else { + GotoDefinitionKind::Symbol + }; + let split = Self::is_alt_pressed(&modifiers, cx); + let snapshot = self.snapshot(window, cx); + let position = snapshot + .buffer_snapshot() + .anchor_before(point.next_valid.to_point(&snapshot.display_snapshot)); + let origin = self.navigation_entry(position, cx); + let cached = self.hovered_link_state.take(); + self.hide_hovered_link(cx); + let refresh = cached.as_ref().is_none_or(|state| { + state.links.is_empty() + || (matches!(state.last_trigger_point, TriggerPoint::Text(_)) + && (state.preferred_kind != kind + || state.task.as_ref().is_some_and(|task| !task.is_ready()))) + }); + let mut links = cached.map(|state| state.links).unwrap_or_default(); + if refresh || !self.lsp_data_enabled() { + links.retain(|link| { + matches!(link, HoverLink::Url(_) | HoverLink::File(_)) + || (self.lsp_data_enabled() && matches!(link, HoverLink::LspLocation(..))) + }); + } + if refresh && point.as_valid().is_some() { + self.select( + SelectPhase::Begin { + position: point.next_valid, + add: false, + click_count: 1, + }, + window, + cx, + ); + } + self.select(SelectPhase::End, window, cx); + let Some((buffer, anchor)) = self.buffer.read(cx).text_anchor_for_position(position, cx) + else { + return; + }; + links.retain(|link| match link { + HoverLink::Text(link) => exclude_link_to_position(&buffer, &anchor, link, cx), + _ => true, + }); + let definitions = (refresh && self.lsp_data_enabled() && point.as_valid().is_some()) + .then(|| { + self.semantics_provider + .as_ref()? + .definitions(&buffer, anchor, kind, cx) }) - .ok(); - }) - .detach(); + .flatten(); + if let Some(definitions) = definitions { + let workspace = self.workspace().map(|workspace| workspace.downgrade()); + cx.spawn_in(window, async move |editor, cx| { + let definitions = definitions.await.log_err().flatten().unwrap_or_default(); + editor + .update_in(cx, |editor, window, cx| { + if editor.workspace().map(|workspace| workspace.downgrade()) != workspace + || !editor.buffer.read(cx).snapshot(cx).can_resolve(&position) + { + return; + } + if editor.lsp_data_enabled() { + links.extend( + definitions + .into_iter() + .filter(|link| { + exclude_link_to_position(&buffer, &anchor, link, cx) + }) + .map(HoverLink::Text), + ); + } else { + links.retain(|link| { + matches!(link, HoverLink::Url(_) | HoverLink::File(_)) + }); + } + editor + .reveal_clicked_links(kind, links, position, origin, split, window, cx); + }) + .ok(); + }) + .detach(); + } else { + self.reveal_clicked_links(kind, links, position, origin, split, window, cx); + } + } + + pub(crate) fn open_definition_locations( + &mut self, + action: &OpenDefinitionLocations, + window: &mut Window, + cx: &mut Context, + ) { + let action = &action.0; + if action.editor != cx.weak_entity() { + cx.propagate(); + return; + } + if !self.lsp_data_enabled() + || !self + .buffer + .read(cx) + .snapshot(cx) + .can_resolve(&action.position) + { + return; + } + let links = action + .locations + .iter() + .cloned() + .map(|target| { + HoverLink::Text(LocationLink { + origin: None, + target, + }) + }) + .collect::>(); + self.open_clicked_links(action.kind, links, action.origin.clone(), false, window, cx); } pub fn scroll_hover( @@ -250,90 +376,86 @@ impl Editor { } } - fn cmd_click_reveal_task( + fn reveal_clicked_links( &mut self, - point: PointForPosition, - modifiers: Modifiers, + kind: GotoDefinitionKind, + links: Vec, + position: Anchor, + origin: Option, + split: bool, window: &mut Window, - cx: &mut Context, - ) -> Task> { - if let Some(hovered_link_state) = self.hovered_link_state.take() { - self.hide_hovered_link(cx); - if !hovered_link_state.links.is_empty() { - if !self.focus_handle.is_focused(window) { - window.focus(&self.focus_handle, cx); - } - - // exclude links pointing back to the current anchor - let current_position = point - .next_valid - .to_point(&self.snapshot(window, cx).display_snapshot); - let Some((buffer, anchor)) = self - .buffer() - .read(cx) - .text_anchor_for_position(current_position, cx) - else { - return Task::ready(Ok(Navigated::No)); - }; - let Some(multi_buffer_anchor) = self - .buffer() - .read(cx) - .snapshot(cx) - .anchor_in_excerpt(anchor) - else { - return Task::ready(Ok(Navigated::No)); - }; - let links = hovered_link_state - .links + cx: &mut Context, + ) { + if !split + && self.lsp_data_enabled() + && self.workspace().is_some() + && EditorSettings::get_global(cx).lsp_results_location == OpenResultsIn::Picker + && links.iter().all(|link| matches!(link, HoverLink::Text(_))) + { + let action = OpenDefinitionLocations(Arc::new(DefinitionLocations { + editor: cx.weak_entity(), + position, + kind, + locations: links .into_iter() - .filter(|link| { - if let HoverLink::Text(location) = link { - exclude_link_to_position(&buffer, &anchor, location, cx) - } else { - true - } + .filter_map(|link| match link { + HoverLink::Text(link) => Some(link.target), + _ => None, }) - .collect(); - let nav_entry = self.navigation_entry(multi_buffer_anchor, cx); - let split = Self::is_alt_pressed(&modifiers, cx); - let navigate_task = - self.navigate_to_hover_links(None, links, nav_entry, split, window, cx); - self.select(SelectPhase::End, window, cx); - return navigate_task; - } - } - - // We don't have the correct kind of link cached, set the selection on - // click and immediately trigger GoToDefinition. - self.select( - SelectPhase::Begin { - position: point.next_valid, - add: false, - click_count: 1, - }, - window, - cx, - ); - - let navigate_task = if point.as_valid().is_some() { - let split = Self::is_alt_pressed(&modifiers, cx); - match (modifiers.shift, split) { - (true, true) => { - self.go_to_type_definition_split(&GoToTypeDefinitionSplit, window, cx) + .collect(), + origin, + })); + let focus_handle = self.focus_handle(cx); + window.defer(cx, move |window, cx| { + if window.is_action_available_in(&action, &focus_handle) { + focus_handle.dispatch_action(&action, window, cx); + } else { + action + .0 + .editor + .update(cx, |editor, cx| { + editor.open_definition_locations(&action, window, cx); + }) + .ok(); } - (true, false) => { - self.go_to_type_definition(&GoToTypeDefinition::default(), window, cx) - } - (false, true) => self.go_to_definition_split(&GoToDefinitionSplit, window, cx), - (false, false) => { - self.go_to_definition_of_kind(GotoDefinitionKind::Symbol, false, window, cx) - } - } + }); } else { - Task::ready(Ok(Navigated::No)) - }; - self.select(SelectPhase::End, window, cx); - navigate_task + self.open_clicked_links(kind, links, origin, split, window, cx); + } + } + + fn open_clicked_links( + &mut self, + kind: GotoDefinitionKind, + links: Vec, + origin: Option, + split: bool, + window: &mut Window, + cx: &mut Context, + ) { + let navigation = self.navigate_to_hover_links(Some(kind), links, origin, split, window, cx); + cx.spawn_in(window, async move |editor, cx| { + if navigation.await.log_err().unwrap_or(Navigated::No) == Navigated::Yes { + return; + } + let focus_handle = editor + .read_with(cx, |editor, cx| { + editor.lsp_data_enabled().then(|| editor.focus_handle(cx)) + }) + .ok() + .flatten(); + if let Some(focus_handle) = focus_handle { + cx.update(|window, cx| { + if EditorSettings::get_global(cx).go_to_definition_fallback + == GoToDefinitionFallback::FindAllReferences + { + focus_handle.dispatch_action(&FindAllReferences::default(), window, cx); + } + }) + .ok(); + } + }) + .detach(); } } @@ -1071,7 +1193,10 @@ mod tests { test::editor_lsp_test_context::EditorLspTestContext, }; use futures::StreamExt; - use gpui::{Modifiers, MousePressureEvent, PressureStage}; + use gpui::{ + Modifiers, MouseButton, MouseDownEvent, MousePressureEvent, MouseUpEvent, PlatformInput, + PressureStage, + }; use indoc::indoc; use language::Point; use lsp::request::{GotoDefinition, GotoTypeDefinition}; @@ -1711,8 +1836,13 @@ mod tests { let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["upgrade".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() }, cx, ) @@ -1756,7 +1886,12 @@ mod tests { uri: params.text_document.uri, range: target_range, }), - ..Default::default() + command: Some(lsp::Command { + title: "Upgrade".to_string(), + command: "upgrade".to_string(), + arguments: Some(vec![serde_json::json!("3.5.16")]), + }), + ..lsp::InlayHintLabelPart::default() }]), kind: Some(lsp::InlayHintKind::TYPE), text_edits: None, @@ -1770,6 +1905,13 @@ mod tests { .next() .await; cx.background_executor.run_until_parked(); + let mut command_requests = cx + .lsp + .set_request_handler::(|params, _| async move { + assert_eq!(params.command, "upgrade"); + assert_eq!(params.arguments, vec![serde_json::json!("3.5.16")]); + Ok(Some(serde_json::Value::Null)) + }); cx.update_editor(|editor, _window, cx| { let expected_layers = vec![hint_label.to_string()]; assert_eq!(expected_layers, cached_hint_labels(editor, cx)); @@ -1831,17 +1973,118 @@ mod tests { assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}"); }); + cx.update_editor(|editor, _window, cx| { + let hovered_inlay = editor + .display_map + .read(cx) + .current_inlays() + .find(|inlay| inlay.id == InlayId::Hint(0)) + .cloned() + .expect("hovered inlay"); + let hovered_inlay_id = hovered_inlay.id; + editor.splice_inlays(&[hovered_inlay_id], vec![hovered_inlay], cx); + assert!( + editor.hovered_inlay_hint_command().is_none(), + "replacing the hovered inlay should clear its command" + ); + }); + cx.simulate_click(hover_point, Modifiers::none()); + cx.background_executor.run_until_parked(); + assert!( + command_requests.try_recv().is_err(), + "clicking without moving after hint replacement should not run the stale command" + ); + + cx.simulate_mouse_move(hover_point, None, Modifiers::none()); + cx.background_executor.run_until_parked(); + cx.simulate_click(hover_point, Modifiers::none()); + command_requests + .next() + .await + .expect("execute command request"); + cx.background_executor.run_until_parked(); + cx.assert_editor_state(indoc! {" + struct TestStruct; + + fn main() { + let variableˇ = TestStruct; + } + "}); + + cx.simulate_event(MouseDownEvent { + position: hover_point, + modifiers: Modifiers::none(), + button: MouseButton::Left, + click_count: 2, + first_mouse: false, + }); + cx.simulate_event(MouseUpEvent { + position: hover_point, + modifiers: Modifiers::none(), + button: MouseButton::Left, + click_count: 2, + }); + cx.background_executor.run_until_parked(); + assert!( + command_requests.try_recv().is_err(), + "Second click of a double click should not re-run the inlay hint command" + ); + cx.simulate_modifiers_change(Modifiers::secondary_key()); cx.background_executor.run_until_parked(); cx.simulate_click(hover_point, Modifiers::secondary_key()); cx.background_executor.run_until_parked(); - cx.assert_editor_state(indoc! {" + let destination = indoc! {" struct «TestStructˇ»; fn main() { let variable = TestStruct; } - "}); + "}; + cx.assert_editor_state(destination); + + let source = cx.buffer_text().replace("= TestStruct", "= TeˇstStruct"); + let click_point = cx.pixel_position(&source); + let hover_point = cx.pixel_position_for(midpoint); + let mut definitions = + cx.set_request_handler::(move |uri, params, _| async move { + let params = params.text_document_position_params; + assert_eq!(params.text_document.uri, uri); + assert_eq!(params.position, lsp::Position::new(3, 21)); + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new(uri, target_range), + ))) + }); + let editor = cx.editor.clone(); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(hover_point, cx); + let state = editor.read(cx).hovered_link_state.as_ref().expect("hover"); + assert!(matches!( + state.last_trigger_point, + TriggerPoint::InlayHint(..) + )); + assert!(state.links.is_empty()); + assert!(!state.task.as_ref().expect("hover task").is_ready()); + let down = MouseDownEvent { + position: click_point, + modifiers: Modifiers::secondary_key(), + button: MouseButton::Left, + click_count: 1, + first_mouse: false, + }; + let up = MouseUpEvent { + position: down.position, + modifiers: down.modifiers, + button: down.button, + click_count: down.click_count, + }; + window.dispatch_event(PlatformInput::MouseDown(down), cx); + window.dispatch_event(PlatformInput::MouseUp(up), cx); + }); + cx.run_until_parked(); + assert_eq!(definitions.try_recv().ok(), Some(())); + cx.assert_editor_state(destination); } #[gpui::test] @@ -1878,6 +2121,159 @@ mod tests { ); } + #[gpui::test] + async fn test_reveal_clicked_links_picker_fallback(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + let mut settings = EditorSettings::get_global(cx).clone(); + settings.lsp_results_location = OpenResultsIn::Picker; + settings.excerpt_context_lines = 0; + EditorSettings::override_global(settings, cx); + }); + for disabled in [false, true] { + let mut cx = + EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let source = cx.editor.clone(); + let (buffer, selections) = cx.update_editor(|editor, window, cx| { + editor.set_text("origin\n\nfirst\n\n\n\n\n\nsecond\n", window, cx); + ( + editor.buffer.read(cx).as_singleton().expect("buffer"), + editor.selections.disjoint_anchors_arc(), + ) + }); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + let links = [(2, 5), (8, 6)].map(|(row, end)| { + HoverLink::Text(LocationLink { + origin: None, + target: Location { + buffer: buffer.clone(), + range: buffer.read(cx).anchor_before(Point::new(row, 0)) + ..buffer.read(cx).anchor_after(Point::new(row, end)), + }, + }) + }); + let position = editor.selections.newest_anchor().head(); + let origin = editor.navigation_entry(position, cx); + editor.reveal_clicked_links( + GotoDefinitionKind::Symbol, + links.to_vec(), + position, + origin, + false, + window, + cx, + ); + if disabled { + editor.disable_lsp_data(); + } + }); + cx.run_until_parked(); + cx.editor(|editor, _, _| { + assert_eq!(editor.selections.disjoint_anchors_arc(), selections); + }); + cx.update_workspace(|workspace, window, cx| { + assert!(!workspace.has_active_modal(window, cx)); + assert_eq!(workspace.items(cx).count(), if disabled { 1 } else { 2 }); + let active = workspace.active_item_as::(cx).expect("editor"); + if disabled { + assert_eq!(active, source); + return; + } + assert_ne!(active, source); + let multibuffer = active.read(cx).buffer().read(cx); + assert!(!multibuffer.is_singleton()); + assert_eq!( + multibuffer.all_buffers_iter().collect::>(), + std::slice::from_ref(&buffer) + ); + assert_eq!( + multibuffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.primary.to_offset(buffer.read(cx))) + .collect::>(), + [8..13, 19..25], + ); + }); + cx.update(|window, _| window.remove_window()); + cx.run_until_parked(); + } + } + + #[gpui::test] + async fn test_cmd_click_cached_links_survive_lsp_disable(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let capabilities = lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }; + let delay = std::time::Duration::from_secs(1); + let mut opened = Vec::new(); + for marked in ["hˇttps://zed.dev/", "fˇile2.rs"] { + let mut cx = EditorLspTestContext::new_rust(capabilities.clone(), cx).await; + let fs = + cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(path!("/root/dir/file2.rs"), b"target".to_vec()) + .await; + cx.run_until_parked(); + let source = format!("{marked}\nfirst\nsecond\n"); + cx.set_state(&source); + let mut symbols = + cx.set_request_handler::(|_, _, _| async { Ok(None) }); + let mut types = + cx.set_request_handler::(move |uri, _, app| async move { + app.background_executor().timer(delay).await; + let locations = [1, 2].map(|line| { + let start = lsp::Position::new(line, 0); + let end = lsp::Position::new(line, 1); + lsp::Location::new(uri.clone(), lsp::Range::new(start, end)) + }); + Ok(Some(lsp::GotoDefinitionResponse::Array(locations.to_vec()))) + }); + let position = cx.pixel_position(&source); + cx.simulate_mouse_move(position, None, Modifiers::secondary_key()); + assert_eq!(symbols.next().await, Some(())); + cx.run_until_parked(); + cx.editor(|editor, _, _| { + let state = editor.hovered_link_state.as_ref().expect("cached link"); + assert_eq!(state.preferred_kind, GotoDefinitionKind::Symbol); + assert!(state.task.as_ref().expect("hover task").is_ready()); + assert_eq!(state.links.len(), 1, "{marked}"); + }); + let mut modifiers = Modifiers::secondary_key(); + modifiers.shift = true; + cx.simulate_click(position, modifiers); + assert!(types.try_recv().is_err()); + cx.update_editor(|editor, _, _| editor.disable_lsp_data()); + cx.executor().advance_clock(delay); + assert_eq!(types.next().await, Some(())); + cx.run_until_parked(); + cx.assert_editor_state(&source); + let url = cx.opened_url(); + opened.push(cx.update_workspace(|workspace, window, cx| { + assert!(!workspace.has_active_modal(window, cx)); + if marked.starts_with("h") { + return url; + } + let editor = workspace.active_item_as::(cx).expect("editor"); + let buffer = editor.read(cx).buffer().read(cx); + let buffer = buffer.as_singleton().expect("buffer"); + let file = buffer.read(cx).file().expect("file"); + let path = file.as_local().expect("local").abs_path(cx); + Some(path.to_string_lossy().into_owned()) + })); + cx.update(|window, _| window.remove_window()); + cx.run_until_parked(); + } + assert_eq!( + opened, + ["https://zed.dev/", path!("/root/dir/file2.rs")].map(|target| Some(target.to_owned())) + ); + } + #[gpui::test] async fn test_hover_preconditions(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index c7e9761ba8..f3d0c6c2c3 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -2815,6 +2815,7 @@ mod tests { value: label.to_string(), tooltip: None, location: None, + command: None, }]; let hint_start = InlayOffset(MultiBufferOffset(100)); @@ -2859,11 +2860,13 @@ mod tests { value: "→ ".to_string(), // 4 bytes (3 + 1) tooltip: None, location: None, + command: None, }, InlayHintLabelPart { value: "path".to_string(), // 4 bytes tooltip: None, location: None, + command: None, }, ]; diff --git a/crates/editor/src/inlays.rs b/crates/editor/src/inlays.rs index 689e273ce2..f1e107529c 100644 --- a/crates/editor/src/inlays.rs +++ b/crates/editor/src/inlays.rs @@ -158,7 +158,7 @@ impl Editor { ) { if let Some(inlay_hints) = &mut self.inlay_hints { for id_to_remove in to_remove { - inlay_hints.added_hints.remove(id_to_remove); + inlay_hints.remove_inlay(id_to_remove); } } self.display_map.update(cx, |display_map, cx| { diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 05c52d429a..d206af5267 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -7,7 +7,7 @@ use std::{ use clock::Global; use collections::{HashMap, HashSet}; use futures::future::join_all; -use gpui::{App, Entity, Pixels, Task}; +use gpui::{App, Entity, Pixels, Task, TaskExt}; use itertools::Itertools; use language::{ BufferRow, @@ -16,8 +16,8 @@ use language::{ use lsp::LanguageServerId; use multi_buffer::{Anchor, MultiBufferSnapshot}; use project::{ - HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, InlayHintTooltip, - InvalidationStrategy, ResolveState, + CodeAction, HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, + InlayHintTooltip, InvalidationStrategy, LspAction, ResolveState, lsp_store::{CacheInlayHints, ResolvedHint}, }; use text::{Bias, BufferId}; @@ -33,6 +33,34 @@ use crate::{ inlays::InlaySplice, }; +#[derive(Debug)] +pub(crate) struct HoveredInlayHintCommand { + highlight: InlayHighlight, + buffer_id: BufferId, + action: CodeAction, +} + +impl HoveredInlayHintCommand { + pub(crate) fn contains_point( + &self, + snapshot: &EditorSnapshot, + point_for_position: PointForPosition, + ) -> bool { + if point_for_position.column_overshoot_after_line_end != 0 + || point_for_position.as_valid().is_some() + || !snapshot.can_resolve(&self.highlight.inlay_position) + { + return false; + } + let hovered_offset = + snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left); + let hint_start = snapshot.anchor_to_inlay_offset(self.highlight.inlay_position); + let part_range = InlayOffset(hint_start.0 + self.highlight.range.start) + ..InlayOffset(hint_start.0 + self.highlight.range.end); + part_range.contains(&hovered_offset) + } +} + pub fn inlay_hint_settings( location: Anchor, snapshot: &MultiBufferSnapshot, @@ -53,6 +81,7 @@ pub struct LspInlayHintData { hint_chunk_fetching: HashMap>)>, invalidate_hints_for_buffers: HashSet, pub added_hints: HashMap>, + hovered_command: Option, } impl LspInlayHintData { @@ -68,6 +97,7 @@ impl LspInlayHintData { invalidate_debounce: debounce_value(settings.edit_debounce_ms), append_debounce: debounce_value(settings.scroll_debounce_ms), allowed_hint_kinds: settings.enabled_inlay_hint_kinds(), + hovered_command: None, } } @@ -76,12 +106,11 @@ impl LspInlayHintData { return None; } self.modifiers_override = new_override; - if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) - { + if self.should_show() { + Some(true) + } else { self.clear(); Some(false) - } else { - Some(true) } } @@ -101,6 +130,7 @@ impl LspInlayHintData { self.hint_refresh_tasks.clear(); self.hint_chunk_fetching.clear(); self.added_hints.clear(); + self.hovered_command = None; } /// Like `clear`, but only wipes tracking state for the given buffer IDs. @@ -112,6 +142,13 @@ impl LspInlayHintData { current_hints: impl IntoIterator, snapshot: &MultiBufferSnapshot, ) { + if self + .hovered_command + .as_ref() + .is_some_and(|command| buffer_ids.contains(&command.buffer_id)) + { + self.hovered_command = None; + } for buffer_id in buffer_ids { self.hint_refresh_tasks.remove(buffer_id); self.hint_chunk_fetching.remove(buffer_id); @@ -224,6 +261,25 @@ impl LspInlayHintData { self.hint_chunk_fetching.remove(buffer_id); } } + + pub(super) fn remove_inlay(&mut self, inlay_id: &InlayId) { + self.added_hints.remove(inlay_id); + if self + .hovered_command + .as_ref() + .is_some_and(|command| command.highlight.inlay == *inlay_id) + { + self.hovered_command = None; + } + } + + fn should_refresh(&self) -> bool { + self.enabled || self.modifiers_override + } + + fn should_show(&self) -> bool { + self.enabled != self.modifiers_override + } } #[derive(Debug, Clone)] @@ -551,15 +607,12 @@ impl Editor { } }; - match &mut self.inlay_hints { - Some(inlay_hints) => { - if !inlay_hints.enabled - && !matches!(reason, InlayHintRefreshReason::ModifiersChanged(_)) - { - return None; - } - } - None => return None, + if !self + .inlay_hints + .as_ref() + .is_some_and(LspInlayHintData::should_refresh) + { + return None; } Some(invalidate_cache) @@ -595,6 +648,7 @@ impl Editor { }; let mut go_to_definition_updated = false; let mut hover_updated = false; + let mut inlay_command_updated = false; if let Some(hovered_offset) = hovered_offset { let buffer_snapshot = self.buffer().read(cx).snapshot(cx); let previous_valid_anchor = buffer_snapshot.anchor_at( @@ -619,13 +673,12 @@ impl Editor { }) .max_by_key(|hint| hint.id) { - if let Some(ResolvedHint::Resolved(cached_hint)) = buffer_snapshot - .anchor_to_buffer_anchor(hovered_hint.position) - .and_then(|(anchor, _)| { + if let Some((buffer_anchor, _)) = + buffer_snapshot.anchor_to_buffer_anchor(hovered_hint.position) + && let Some(ResolvedHint::Resolved(cached_hint)) = lsp_store.update(cx, |lsp_store, cx| { - lsp_store.resolved_hint(anchor.buffer_id, hovered_hint.id, cx) + lsp_store.resolved_hint(buffer_anchor.buffer_id, hovered_hint.id, cx) }) - }) { match cached_hint.resolve_state { ResolveState::Resolved => { @@ -693,6 +746,24 @@ impl Editor { inlay_position: hovered_hint.position, range: highlight_start..highlight_end, }; + if let Some((server_id, command)) = + hovered_hint_part.command + && let Some(inlay_hints) = self.inlay_hints.as_mut() + { + inlay_command_updated = true; + inlay_hints.hovered_command = + Some(HoveredInlayHintCommand { + highlight: highlight.clone(), + buffer_id: buffer_anchor.buffer_id, + action: CodeAction { + server_id, + range: cached_hint.position + ..cached_hint.position, + lsp_action: LspAction::Command(command), + resolved: true, + }, + }); + } if let Some(tooltip) = hovered_hint_part.tooltip { hover_popover::hover_at_inlay( self, @@ -756,6 +827,40 @@ impl Editor { if !hover_updated { hover_popover::hover_at(self, None, mouse_position, window, cx); } + if !inlay_command_updated && let Some(inlay_hints) = self.inlay_hints.as_mut() { + inlay_hints.hovered_command = None; + } + } + + pub(crate) fn hovered_inlay_hint_command(&self) -> Option<&HoveredInlayHintCommand> { + self.inlay_hints.as_ref()?.hovered_command.as_ref() + } + + pub(crate) fn activate_hovered_inlay_hint_command( + &mut self, + snapshot: &EditorSnapshot, + down: PointForPosition, + up: PointForPosition, + cx: &mut Context, + ) -> bool { + let Some(state) = self.hovered_inlay_hint_command().filter(|state| { + state.contains_point(snapshot, down) && state.contains_point(snapshot, up) + }) else { + return false; + }; + let Some(buffer) = self.buffer().read(cx).buffer(state.buffer_id) else { + return false; + }; + let Some(project) = self.project().cloned() else { + return false; + }; + let action = state.action.clone(); + project + .update(cx, |project, cx| { + project.apply_code_action(buffer, action, true, cx) + }) + .detach_and_log_err(cx); + true } fn inlay_hints_for_buffer( @@ -807,6 +912,7 @@ impl Editor { let Some(inlay_hints) = &mut self.inlay_hints else { return; }; + let should_show = inlay_hints.should_show(); let Some(buffer_snapshot) = self .buffer .read(cx) @@ -901,7 +1007,8 @@ impl Editor { hints_deduplicated }) .filter(|(hint_id, lsp_hint)| { - inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind) + should_show + && inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind) && inlay_hints .added_hints .insert(*hint_id, lsp_hint.kind) @@ -1002,7 +1109,9 @@ fn spawn_editor_hints_refresh( #[cfg(test)] pub mod tests { + use super::{HoveredInlayHintCommand, LspInlayHintData}; use crate::editor_tests::update_test_language_settings; + use crate::hover_links::InlayHighlight; use crate::inlays::inlay_hints::InlayHintRefreshReason; use crate::scroll::Autoscroll; use crate::scroll::ScrollAmount; @@ -1012,26 +1121,80 @@ pub mod tests { use futures::{StreamExt, future}; use gpui::{AppContext as _, Context, TestAppContext, WindowHandle}; use itertools::Itertools as _; - use language::language_settings::InlayHintKind; + use language::language_settings::{InlayHintKind, InlayHintSettings}; use language::{Capability, FakeLspAdapter}; use language::{Language, LanguageConfig, LanguageMatcher}; use languages::rust_lang; - use lsp::{DEFAULT_LSP_REQUEST_TIMEOUT, FakeLanguageServer}; + use lsp::{DEFAULT_LSP_REQUEST_TIMEOUT, FakeLanguageServer, LanguageServerId}; use multi_buffer::{MultiBuffer, MultiBufferOffset, PathKey}; use parking_lot::Mutex; use pretty_assertions::assert_eq; - use project::{FakeFs, InvalidationStrategy, Project}; + use project::{CodeAction, FakeFs, InlayId, InvalidationStrategy, LspAction, Project}; use serde_json::json; use settings::{AllLanguageSettingsContent, InlayHintSettingsContent, SettingsStore}; use std::ops::Range; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::time::Duration; - use text::{OffsetRangeExt, Point}; + use text::{BufferId, OffsetRangeExt, Point}; use ui::App; use util::path; use util::paths::natural_sort; + #[gpui::test] + fn test_clearing_buffers_clears_only_matching_hovered_command(cx: &mut TestAppContext) { + let hovered_buffer_id = BufferId::new(1).expect("hovered buffer id"); + let other_buffer_id = BufferId::new(2).expect("other buffer id"); + let mut inlay_hints = LspInlayHintData::new(InlayHintSettings { + enabled: true, + show_value_hints: true, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + show_background: false, + edit_debounce_ms: 0, + scroll_debounce_ms: 0, + toggle_on_modifiers_press: None, + }); + inlay_hints.hovered_command = Some(HoveredInlayHintCommand { + highlight: InlayHighlight { + inlay: InlayId::Hint(1), + inlay_position: multi_buffer::Anchor::Min, + range: 0..1, + }, + buffer_id: hovered_buffer_id, + action: CodeAction { + server_id: LanguageServerId(0), + range: language::Anchor::min_min_range_for_buffer(hovered_buffer_id), + lsp_action: LspAction::Command(lsp::Command { + title: "command".to_string(), + command: "command".to_string(), + arguments: None, + }), + resolved: true, + }, + }); + let multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let snapshot = cx.update(|cx| multi_buffer.read(cx).snapshot(cx)); + + inlay_hints.remove_inlay(&InlayId::Hint(2)); + assert!(inlay_hints.hovered_command.is_some()); + + inlay_hints.clear_for_buffers( + &HashSet::from_iter([other_buffer_id]), + Vec::new(), + &snapshot, + ); + assert!(inlay_hints.hovered_command.is_some()); + + inlay_hints.clear_for_buffers( + &HashSet::from_iter([hovered_buffer_id]), + Vec::new(), + &snapshot, + ); + assert!(inlay_hints.hovered_command.is_none()); + } + #[gpui::test] async fn test_basic_cache_update_with_duplicate_hints(cx: &mut gpui::TestAppContext) { let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]); @@ -3741,6 +3904,19 @@ let c = 3;"# ); }) .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); + }) + .unwrap(); + editor .update(cx, |editor, _, cx| { editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); @@ -3889,6 +4065,34 @@ let c = 3;"# ); }) .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx); + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + editor.handle_input("x", window, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["2".to_string()], cached_hint_labels(editor, cx)); + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["2".to_string()], cached_hint_labels(editor, cx)); + assert_eq!(vec!["2".to_string()], visible_hint_labels(editor, cx)); + }) + .unwrap(); } #[gpui::test] @@ -5034,6 +5238,81 @@ let c = 3;"# .unwrap(); } + #[gpui::test] + async fn test_hints_cleared_when_language_changes_and_no_server_attaches( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, &|settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let (_, editor, _fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 1), + label: lsp::InlayHintLabel::String("stale hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + }, + ); + }) + .await; + + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["stale hint".to_string()]; + assert_eq!(expected_hints, cached_hint_labels(editor, cx)); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + let plain_text = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + editor + .update(cx, |editor, _, cx| { + let project = editor.project().unwrap().clone(); + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, plain_text, cx); + }); + }) + .unwrap(); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _, cx| { + assert_eq!(Vec::::new(), cached_hint_labels(editor, cx)); + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + pub(crate) fn init_test(cx: &mut TestAppContext, f: &dyn Fn(&mut AllLanguageSettingsContent)) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/editor/src/inline_input.rs b/crates/editor/src/inline_input.rs new file mode 100644 index 0000000000..5cce637c25 --- /dev/null +++ b/crates/editor/src/inline_input.rs @@ -0,0 +1,370 @@ +use gpui::StyledText; +use language::HighlightId; + +use super::*; + +pub struct InlineInputState { + pub editor: Entity, + block_id: CustomBlockId, + position: Anchor, + confirm_task: Option>, + preview_task: Option>, + pub(crate) preview: Option, + preview_language: Option>, + block_height: u32, + history: Vec, + history_ix: Option, + draft: String, + _subscription: Subscription, + on_confirm: + Rc) -> Option>>, +} + +impl InlineInputState { + pub(crate) fn take_confirm_task(&mut self) -> Option> { + self.confirm_task.take() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum InlineInputPreview { + Text(String), + Error(String), +} + +#[derive(Clone, Copy)] +pub(crate) enum InlineInputHistoryDirection { + Older, + Newer, +} + +const INLINE_INPUT_PREVIEW_MIN_LINES: usize = 8; + +impl InlineInputPreview { + fn display_text(&self, max_lines: usize) -> (String, u32) { + let text = match self { + InlineInputPreview::Text(text) => text, + InlineInputPreview::Error(message) => message, + }; + let line_count = text.lines().count().max(1); + if line_count <= max_lines { + return (text.to_string(), line_count as u32); + } + let hidden_lines = line_count - max_lines; + let noun = if hidden_lines == 1 { "line" } else { "lines" }; + let shown = text.lines().take(max_lines).collect::>().join("\n"); + ( + format!("{shown}\n… +{hidden_lines} more {noun}"), + max_lines as u32 + 1, + ) + } + + fn is_error(&self) -> bool { + matches!(self, InlineInputPreview::Error(_)) + } +} + +struct RenderedPreview { + text: SharedString, + highlights: Vec<(Range, HighlightId)>, + height_in_lines: u32, + is_error: bool, +} + +impl RenderedPreview { + fn new( + preview: &InlineInputPreview, + language: Option<&Arc>, + max_lines: usize, + ) -> Self { + let (text, height_in_lines) = preview.display_text(max_lines); + let highlights = match (preview, language) { + (InlineInputPreview::Text(_), Some(language)) => { + language.highlight_text(&Rope::from(text.as_str()), 0..text.len()) + } + _ => Vec::new(), + }; + Self { + text: SharedString::from(text), + highlights, + height_in_lines, + is_error: preview.is_error(), + } + } +} + +impl Editor { + pub fn pending_inline_input(&self) -> Option<&InlineInputState> { + self.pending_inline_input.as_ref() + } + + pub fn confirm_inline_input(&mut self, window: &mut Window, cx: &mut Context) { + let Some(state) = self.pending_inline_input.as_ref() else { + return; + }; + let text = state.editor.read(cx).text(cx); + let on_confirm = state.on_confirm.clone(); + if let Some(task) = on_confirm(self, text, window, cx) + && let Some(state) = self.pending_inline_input.as_mut() + { + state.confirm_task = Some(task); + } + } + + pub(crate) fn show_inline_input( + &mut self, + placeholder: &str, + position: Anchor, + preview_language: Option>, + history: Vec, + on_confirm: impl Fn(&mut Editor, String, &mut Window, &mut Context) -> Option> + + 'static, + on_change: impl Fn(&mut Editor, String, &mut Window, &mut Context) -> Option> + + 'static, + window: &mut Window, + cx: &mut Context, + ) { + self.take_inline_input(window, cx); + + let input = cx.new(|cx| { + let mut input = Editor::single_line(window, cx); + input.set_placeholder_text(placeholder, window, cx); + if let Some(newest) = history.first() { + input.set_text(newest.clone(), window, cx); + input.select_all(&SelectAll, window, cx); + } + input + }); + let subscription = cx.subscribe_in( + &input, + window, + move |editor, input, event: &EditorEvent, window, cx| match event { + EditorEvent::Focused => cx.emit(EditorEvent::FocusedIn), + EditorEvent::BufferEdited => { + let Some(state) = editor.pending_inline_input.as_mut() else { + return; + }; + let text = input.read(cx).text(cx); + if state.history_ix.is_some_and(|ix| { + state.history.get(ix).map(SharedString::as_ref) != Some(text.as_str()) + }) { + state.history_ix = None; + } + let task = on_change(editor, text, window, cx); + if let Some(state) = editor.pending_inline_input.as_mut() { + state.preview_task = task; + } + } + _ => {} + }, + ); + let block_ids = self.insert_blocks( + [BlockProperties { + style: BlockStyle::Flex, + placement: BlockPlacement::Below(position), + height: Some(1), + render: render_inline_input_block(input.clone(), None), + priority: 0, + }], + Some(reveal_block_below( + position, + &self.buffer().read(cx).snapshot(cx), + )), + cx, + ); + let Some(&block_id) = block_ids.first() else { + return; + }; + + let input_focus_handle = input.focus_handle(cx); + window.focus(&input_focus_handle, cx); + self.pending_inline_input = Some(InlineInputState { + editor: input, + block_id, + position, + confirm_task: None, + preview_task: None, + preview: None, + preview_language, + block_height: 1, + history_ix: (!history.is_empty()).then_some(0), + history, + draft: String::new(), + _subscription: subscription, + on_confirm: Rc::new(on_confirm), + }); + } + + pub(crate) fn set_inline_input_preview( + &mut self, + preview: Option, + cx: &mut Context, + ) { + let max_lines = self.inline_input_preview_max_lines(); + let Some(state) = self.pending_inline_input.as_mut() else { + return; + }; + let block_id = state.block_id; + let position = state.position; + let input = state.editor.clone(); + let rendered = preview.as_ref().map(|preview| { + RenderedPreview::new(preview, state.preview_language.as_ref(), max_lines) + }); + let height = 1 + rendered + .as_ref() + .map_or(0, |rendered| rendered.height_in_lines); + state.preview = preview; + let resized = height != state.block_height; + state.block_height = height; + let autoscroll = + resized.then(|| reveal_block_below(position, &self.buffer().read(cx).snapshot(cx))); + self.replace_blocks( + HashMap::from_iter([(block_id, render_inline_input_block(input, rendered))]), + None, + cx, + ); + self.resize_blocks(HashMap::from_iter([(block_id, height)]), autoscroll, cx); + } + + pub(crate) fn cycle_inline_input_history( + &mut self, + direction: InlineInputHistoryDirection, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(state) = self.pending_inline_input.as_mut() else { + return false; + }; + let next_ix = match (direction, state.history_ix) { + (InlineInputHistoryDirection::Older, None) => { + if state.history.is_empty() { + return true; + } + state.draft = state.editor.read(cx).text(cx); + Some(0) + } + (InlineInputHistoryDirection::Older, Some(ix)) => { + if ix + 1 >= state.history.len() { + return true; + } + Some(ix + 1) + } + (InlineInputHistoryDirection::Newer, None) => return true, + (InlineInputHistoryDirection::Newer, Some(0)) => None, + (InlineInputHistoryDirection::Newer, Some(ix)) => Some(ix - 1), + }; + state.history_ix = next_ix; + let text = match next_ix { + Some(ix) => state.history.get(ix).cloned().unwrap_or_default(), + None => SharedString::from(mem::take(&mut state.draft)), + }; + let input = state.editor.clone(); + input.update(cx, |input, cx| { + input.set_text(text, window, cx); + input.select_all(&SelectAll, window, cx); + }); + true + } + + pub(crate) fn take_inline_input( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let state = self.pending_inline_input.take()?; + if state.editor.focus_handle(cx).is_focused(window) { + window.focus(&self.focus_handle, cx); + } + self.remove_blocks( + HashSet::from_iter([state.block_id]), + Some(Autoscroll::fit()), + cx, + ); + Some(state) + } + + fn inline_input_preview_max_lines(&self) -> usize { + let half_viewport = (self.visible_line_count().unwrap_or(0.) / 2.).floor() as usize; + half_viewport.max(INLINE_INPUT_PREVIEW_MIN_LINES) + } +} + +fn reveal_block_below(position: Anchor, snapshot: &MultiBufferSnapshot) -> Autoscroll { + let row_below = position.to_point(snapshot).row + 1; + if row_below > snapshot.max_point().row { + return Autoscroll::center().for_anchor(position); + } + Autoscroll::fit().for_anchor(snapshot.anchor_before(Point::new(row_below, 0))) +} + +fn render_inline_input_block( + input: Entity, + preview: Option, +) -> RenderBlock { + Arc::new(move |cx: &mut BlockContext| { + v_flex() + .block_mouse_except_scroll() + .w_full() + .bg(cx.theme().colors().elevated_surface_background) + .pl(cx.anchor_x) + .child(EditorElement::new( + &input, + EditorStyle { + background: cx.theme().system().transparent, + local_player: cx.editor_style.local_player, + text: cx.editor_style.text.clone(), + scrollbar_width: cx.editor_style.scrollbar_width, + syntax: cx.editor_style.syntax.clone(), + status: cx.editor_style.status.clone(), + ..EditorStyle::default() + }, + )) + .when_some(preview.as_ref(), |this, preview| { + let mut text_style = cx.editor_style.text.clone(); + text_style.color = if preview.is_error { + cx.theme().status().error + } else { + cx.theme().colors().text_muted + }; + let syntax = cx.theme().syntax(); + let highlights = preview + .highlights + .iter() + .filter_map(|(range, highlight_id)| { + Some((range.clone(), *syntax.get(*highlight_id)?)) + }); + this.child( + StyledText::new(preview.text.clone()) + .with_default_highlights(&text_style, highlights), + ) + }) + .into_any_element() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_preview_truncation_reports_hidden_lines() { + let text = (1..=12) + .map(|ix| format!("line{ix}")) + .collect::>() + .join("\n"); + let preview = InlineInputPreview::Text(text.clone()); + let (shown, height) = preview.display_text(8); + assert_eq!(height, 9); + assert_eq!( + shown, + format!( + "{}\n… +4 more lines", + text.lines().take(8).collect::>().join("\n") + ) + ); + let (shown, height) = preview.display_text(11); + assert_eq!(height, 12); + assert_eq!(shown.lines().last(), Some("… +1 more line")); + assert_eq!(preview.display_text(12), (text, 12)); + } +} diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs index b5f8d6d613..9524382be9 100644 --- a/crates/editor/src/input.rs +++ b/crates/editor/src/input.rs @@ -500,11 +500,12 @@ impl Editor { this.show_edit_predictions_in_menu() || !had_active_edit_prediction; if this.hard_wrap.is_some() { let latest: Range = this.selections.newest(&map).range(); + // Reuse the post-edit snapshot captured in `map` above; the buffer + // is not mutated between there and here (only selections move), so a + // fresh `buffer().snapshot(cx)` would be redundant. if latest.is_empty() - && this - .buffer() - .read(cx) - .snapshot(cx) + && map + .buffer_snapshot() .line_len(MultiBufferRow(latest.start.row)) == latest.start.column { @@ -1415,6 +1416,7 @@ impl Editor { let empty_str: Arc = Arc::default(); let mut suffixes_inserted = Vec::new(); let ignore_indent = action.ignore_indent; + let comment_empty_lines = action.comment_empty_lines; fn comment_prefix_range( snapshot: &MultiBufferSnapshot, @@ -1544,11 +1546,13 @@ impl Editor { .map(|p| p.trim_end_matches(' ').len()) .collect::>(); - let mut all_selection_lines_are_comments = true; + let mut commented_lines = 0; + let mut uncommented_lines = 0; for row in start_row.0..=end_row.0 { let row = MultiBufferRow(row); - if start_row < end_row && snapshot.is_line_blank(row) { + let is_blank = start_row < end_row && snapshot.is_line_blank(row); + if !comment_empty_lines && is_blank { continue; } @@ -1567,14 +1571,30 @@ impl Editor { .max_by_key(|range| range.end.column - range.start.column) .expect("prefixes is non-empty"); - if prefix_range.is_empty() { - all_selection_lines_are_comments = false; + // Blank rows are left out of the tally. They never carry a marker, + // so counting them would make a commented block that contains one + // look uncommented, and it could then never be uncommented. VS Code + // and IntelliJ exclude them here for the same reason. + // Without this, commenting a block with an empty line in between, + // and then changing the action parameter of + // ToggleComments with `comment_empty_lines: true` would add comment to the block, + // even though it should be uncommented + if !is_blank { + if prefix_range.is_empty() { + uncommented_lines += 1; + } else { + commented_lines += 1; + } } selection_edit_ranges.push(prefix_range); } - if all_selection_lines_are_comments { + // Remove markers only when at least one row was counted as commented + // and none were counted as uncommented. + let should_uncomment = uncommented_lines == 0 && commented_lines > 0; + + if should_uncomment { edits.extend( selection_edit_ranges .iter() diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 163602bf2b..6eda5462dc 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -26,8 +26,11 @@ use language::{ use lsp::DiagnosticSeverity; use multi_buffer::{BufferOffset, MultiBufferOffset, MultiBufferRow, PathKey}; use project::{ - File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger, - project_settings::ProjectSettings, search::SearchQuery, + File, Project, ProjectItem as _, ProjectPath, + git_store::GitStore, + lsp_store::{FormatTrigger, LanguageServerShowDocumentRequest}, + project_settings::ProjectSettings, + search::SearchQuery, }; use rope::TextSummary; use rpc::proto::{self, update_view}; @@ -43,10 +46,15 @@ use std::{ }; use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _}; use ui::{IconDecorationKind, prelude::*}; -use util::{ResultExt, TryFutureExt, debug_panic, paths::PathExt, rel_path::RelPath}; +use util::{ + ResultExt, TryFutureExt, debug_panic, + paths::{PathExt, UrlExt as _}, + rel_path::RelPath, +}; use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; use workspace::{ - CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + CollaboratorId, ItemId, ItemNavHistory, OpenOptions, OpenVisible, ToolbarItemLocation, ViewId, + Workspace, WorkspaceId, invalid_item_view::InvalidItemView, item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions}, searchable::{ @@ -2504,6 +2512,68 @@ fn compute_modified_ranges( merged } +pub(crate) fn handle_lsp_show_document( + workspace: &mut Workspace, + request: &LanguageServerShowDocumentRequest, + window: &mut Window, + cx: &mut Context, +) -> Task<()> { + let request = request.clone(); + if request.external { + cx.open_url(request.uri.as_str()); + request.respond(true); + return Task::ready(()); + } + let Ok(abs_path) = request.uri.to_file_path_ext(workspace.path_style(cx)) else { + log::error!( + "language server requested to show document with unsupported uri {}", + request.uri.as_str() + ); + request.respond(false); + return Task::ready(()); + }; + let open_task = workspace.open_abs_path( + abs_path, + OpenOptions { + visible: Some(OpenVisible::None), + focus: Some(request.take_focus), + ..OpenOptions::default() + }, + window, + cx, + ); + cx.spawn_in(window, async move |_, cx| { + let success = match open_task.await { + Ok(item) => match item.downcast::().zip(request.selection) { + Some((editor, selection)) => editor + .update_in(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let range = language::range_from_lsp(selection); + let start = snapshot.point_utf16_to_offset( + snapshot.clip_point_utf16(range.start, Bias::Left), + ); + let end = snapshot.point_utf16_to_offset( + snapshot.clip_point_utf16(range.end, Bias::Left), + ); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges([start..end]), + ); + }) + .is_ok(), + None => true, + }, + Err(error) => { + log::error!("failed to show document for a language server: {error:#}"); + false + } + }; + request.respond(success); + }) +} + #[cfg(test)] mod tests { use crate::editor_tests::init_test; diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs index e70f1b1202..8831728674 100644 --- a/crates/editor/src/lsp_ext.rs +++ b/crates/editor/src/lsp_ext.rs @@ -151,6 +151,7 @@ pub fn lsp_tasks( GetLspRunnables { buffer_id, position: for_position, + server_id, }, cx, ) diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs index 3766811b76..c74e150735 100644 --- a/crates/editor/src/navigation.rs +++ b/crates/editor/src/navigation.rs @@ -45,6 +45,9 @@ impl Editor { if self.take_rename(true, window, cx).is_some() { return; } + if self.cycle_inline_input_history(InlineInputHistoryDirection::Older, window, cx) { + return; + } if self.mode.is_single_line() { cx.propagate(); @@ -262,6 +265,9 @@ impl Editor { if self.take_rename(true, window, cx).is_some() { return; } + if self.cycle_inline_input_history(InlineInputHistoryDirection::Newer, window, cx) { + return; + } if self.mode.is_single_line() { cx.propagate(); @@ -2353,10 +2359,11 @@ impl Editor { .iter() .flat_map(|selection| { snapshot - .range_to_buffer_ranges(selection.range()) - .into_iter() - .filter_map(|(buffer_snapshot, range, _)| { - snapshot.anchor_in_excerpt(buffer_snapshot.anchor_after(range.start)) + .range_to_buffer_ranges_with_deleted_hunks(selection.range()) + .filter_map(|(buffer_snapshot, range, deleted_hunk_anchor)| { + deleted_hunk_anchor.or_else(|| { + snapshot.anchor_in_excerpt(buffer_snapshot.anchor_after(range.start)) + }) }) }) .collect::>(); diff --git a/crates/editor/src/rust_analyzer_ext.rs b/crates/editor/src/rust_analyzer_ext.rs index ab59586c3a..b65a5082e9 100644 --- a/crates/editor/src/rust_analyzer_ext.rs +++ b/crates/editor/src/rust_analyzer_ext.rs @@ -89,6 +89,7 @@ pub fn go_to_parent_module( project_id, buffer_id: buffer_id.to_proto(), position: Some(serialize_anchor(&trigger_anchor)), + server_id: server_to_query.to_proto(), }; let response = client .request(request) @@ -112,7 +113,10 @@ pub fn go_to_parent_module( project.request_lsp( buffer, project::LanguageServerToQuery::Other(server_to_query), - project::lsp_store::lsp_ext_command::GoToParentModule { position }, + project::lsp_store::lsp_ext_command::GoToParentModule { + position, + server_id: server_to_query, + }, cx, ) }) @@ -169,6 +173,7 @@ pub fn expand_macro_recursively( project_id, buffer_id: buffer_id.to_proto(), position: Some(serialize_anchor(&trigger_anchor)), + server_id: server_to_query.to_proto(), }; let response = client .request(request) @@ -186,7 +191,10 @@ pub fn expand_macro_recursively( project.request_lsp( buffer, project::LanguageServerToQuery::Other(server_to_query), - ExpandMacro { position }, + ExpandMacro { + position, + server_id: server_to_query, + }, cx, ) }) @@ -258,6 +266,7 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu project_id, buffer_id: buffer_id.to_proto(), position: Some(serialize_anchor(&trigger_anchor)), + server_id: server_to_query.to_proto(), }; let response = client .request(request) @@ -275,7 +284,10 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu project.request_lsp( buffer, project::LanguageServerToQuery::Other(server_to_query), - project::lsp_store::lsp_ext_command::OpenDocs { position }, + project::lsp_store::lsp_ext_command::OpenDocs { + position, + server_id: server_to_query, + }, cx, ) }) diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 74cea8461e..3efc0b3d77 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -235,9 +235,14 @@ impl Editor { let was_autoscrolled = match strategy { AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => { - let margin = margin.min(self.scroll_manager.vertical_scroll_margin); - let target_top = (target_top - margin - visible_sticky_headers as f64).max(0.0); - let target_bottom = target_bottom + margin; + let (margin_top, margin_bottom) = self.fit_autoscroll_margins( + visible_lines, + target_bottom - target_top, + visible_sticky_headers, + ); + let target_top = (target_top - margin_top - visible_sticky_headers as f64).max(0.0); + let target_bottom = target_bottom + margin_bottom; + let start_row = scroll_position.y; let end_row = start_row + visible_lines; @@ -294,6 +299,28 @@ impl Editor { (NeedsHorizontalAutoscroll(true), was_scrolled) } + fn fit_autoscroll_margins( + &self, + visible_lines: ScrollOffset, + target_height: ScrollOffset, + visible_sticky_headers: usize, + ) -> (ScrollOffset, ScrollOffset) { + if matches!(self.mode, EditorMode::AutoHeight { .. }) { + return (0., 0.); + } + + let available_margin_space = + (visible_lines - target_height - visible_sticky_headers as f64).max(0.); + let top_space = (available_margin_space / 2.).floor(); + + // Keep the rounding remainder below the target so large margins leave no + // slack for the cursor to move without scrolling. + ( + top_space.min(self.scroll_manager.vertical_scroll_margin), + (available_margin_space - top_space).min(self.scroll_manager.vertical_scroll_margin), + ) + } + pub(crate) fn visible_sticky_header_count_for_point( &self, display_map: &DisplaySnapshot, diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs index 11cd7e3826..875faf9811 100644 --- a/crates/editor/src/selection.rs +++ b/crates/editor/src/selection.rs @@ -1,4 +1,5 @@ use super::*; +use crate::columnar_selection::ColumnarSelectionRows; impl Editor { pub fn sync_selections( @@ -6,45 +7,30 @@ impl Editor { other: Entity, cx: &mut Context, ) -> gpui::Subscription { - let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); - if !other_selections.is_empty() { - self.selections - .change_with(&self.display_snapshot(cx), |selections| { - selections.select_anchors(other_selections); - }); - } + self.sync_selections_from(other.read(cx).selections.disjoint_anchors().to_vec(), cx); - let other_subscription = cx.subscribe(&other, |this, other, other_evt, cx| { - if let EditorEvent::SelectionsChanged { local: true } = other_evt { - let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); - if other_selections.is_empty() { - return; - } - let snapshot = this.display_snapshot(cx); - this.selections.change_with(&snapshot, |selections| { - selections.select_anchors(other_selections); - }); + let other_subscription = cx.subscribe(&other, |editor, other, event, cx| { + if let EditorEvent::SelectionsChanged { local: true } = event { + editor.sync_selections_from( + other.read(cx).selections.disjoint_anchors().to_vec(), + cx, + ); } }); - let this_subscription = cx.subscribe_self::(move |this, this_evt, cx| { - if let EditorEvent::SelectionsChanged { local: true } = this_evt { - let these_selections = this.selections.disjoint_anchors().to_vec(); - if these_selections.is_empty() { + let editor_subscription = cx.subscribe_self::(move |editor, event, cx| { + if let EditorEvent::SelectionsChanged { local: true } = event { + if editor.selections.disjoint_anchors().is_empty() { return; } + let selections = editor.selections.disjoint_anchors().to_vec(); other.update(cx, |other_editor, cx| { - let snapshot = other_editor.display_snapshot(cx); - other_editor - .selections - .change_with(&snapshot, |selections| { - selections.select_anchors(these_selections); - }) + other_editor.sync_selections_from(selections, cx); }); } }); - Subscription::join(other_subscription, this_subscription) + Subscription::join(other_subscription, editor_subscription) } /// Changes selections using the provided mutation function. Changes to `self.selections` occur @@ -57,34 +43,7 @@ impl Editor { cx: &mut Context, change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, ) -> R { - let snapshot = self.display_snapshot(cx); - if let Some(state) = &mut self.deferred_selection_effects_state { - state.effects.scroll = effects.scroll.or(state.effects.scroll); - state.effects.completions = effects.completions; - state.effects.nav_history = effects.nav_history.or(state.effects.nav_history); - let (changed, result) = self.selections.change_with(&snapshot, change); - state.changed |= changed; - return result; - } - let mut state = DeferredSelectionEffectsState { - changed: false, - effects, - old_cursor_position: self.selections.newest_anchor().head(), - history_entry: SelectionHistoryEntry { - selections: self.selections.disjoint_anchors_arc(), - select_next_state: self.select_next_state.clone(), - select_prev_state: self.select_prev_state.clone(), - add_selections_state: self.add_selections_state.clone(), - }, - }; - let (changed, result) = self.selections.change_with(&snapshot, change); - state.changed = state.changed || changed; - if self.defer_selection_effects { - self.deferred_selection_effects_state = Some(state); - } else { - self.apply_selection_effects(state, window, cx); - } - result + self.change_selections_with_history(effects, None, window, cx, change) } /// Re-resolves selection anchors so they reference live buffer fragments. @@ -100,10 +59,9 @@ impl Editor { pub fn refresh_selection_anchors(&mut self, cx: &mut Context) { let snapshot = self.display_snapshot(cx); let selections = self.selections.all::(&snapshot); - self.selections - .change_with(&snapshot, |selection_collection| { - selection_collection.select(selections); - }); + self.change_selections_without_effects(&snapshot, |selection_collection| { + selection_collection.select(selections); + }); } /// Defers the effects of selection change, so that the effects of multiple calls to @@ -119,12 +77,7 @@ impl Editor { let already_deferred = self.defer_selection_effects; self.defer_selection_effects = true; let result = update(self, window, cx); - if !already_deferred { - self.defer_selection_effects = false; - if let Some(state) = self.deferred_selection_effects_state.take() { - self.apply_selection_effects(state, window, cx); - } - } + self.finish_deferred_selection_effects(already_deferred, window, cx); result } @@ -182,15 +135,14 @@ impl Editor { cx: &mut Context, ) { let old_cursor_position = self.selections.newest_anchor().head(); - self.selections - .change_with(&self.display_snapshot(cx), |s| { - s.select_anchors(selections); - if let Some(pending_selection) = pending_selection { - s.set_pending(pending_selection, SelectMode::Character); - } else { - s.clear_pending(); - } - }); + self.change_selections_without_effects(&self.display_snapshot(cx), |s| { + s.select_anchors(selections); + if let Some(pending_selection) = pending_selection { + s.set_pending(pending_selection, SelectMode::Character); + } else { + s.clear_pending(); + } + }); self.selections_did_change( false, &old_cursor_position, @@ -433,11 +385,11 @@ impl Editor { if !select_prev_state.done { let first_selection = selections .iter() - .min_by_key(|s| s.id) + .min_by_key(|selection| selection.id) .context("missing selection for select previous action")?; let last_selection = selections .iter() - .max_by_key(|s| s.id) + .max_by_key(|selection| selection.id) .context("missing selection for select previous action")?; let mut next_selected_range = None; // When we're iterating matches backwards, the oldest match will actually be the furthest one in the buffer. @@ -1118,7 +1070,7 @@ impl Editor { SelectionEffects::scroll(Autoscroll::newest()), window, cx, - |s| s.select_anchors(entry.selections.to_vec()), + |s| s.select_anchors_unexpanded(entry.selections.to_vec()), ); }); self.selection_history.mode = SelectionHistoryMode::Normal; @@ -1143,7 +1095,7 @@ impl Editor { SelectionEffects::scroll(Autoscroll::newest()), window, cx, - |s| s.select_anchors(entry.selections.to_vec()), + |s| s.select_anchors_unexpanded(entry.selections.to_vec()), ); }); self.selection_history.mode = SelectionHistoryMode::Normal; @@ -1539,6 +1491,34 @@ impl Editor { } } + /// Re-publishes the current selections to collaborators immediately. The + /// per-change broadcast in `selections_did_change` is skipped while the + /// project is unshared, so when a project becomes shared this is called to + /// re-establish this editor's cursor for peers, who would otherwise not see + /// it until the next selection change. + pub(crate) fn republish_active_selections( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let should_broadcast_selections = self + .collaboration_hub() + .is_some_and(|hub| hub.should_broadcast_selections(cx)); + if should_broadcast_selections + && self.focus_handle.is_focused(window) + && self.leader_id.is_none() + { + self.buffer.update(cx, |buffer, cx| { + buffer.set_active_selections( + &self.selections.disjoint_anchors_arc(), + self.selections.line_mode(), + self.cursor_shape, + cx, + ) + }); + } + } + fn selections_did_change( &mut self, local: bool, @@ -1577,7 +1557,13 @@ impl Editor { let selection_anchors = self.selections.disjoint_anchors_arc(); - if self.focus_handle.is_focused(window) && self.leader_id.is_none() { + let should_broadcast_selections = self + .collaboration_hub() + .is_some_and(|hub| hub.should_broadcast_selections(cx)); + if should_broadcast_selections + && self.focus_handle.is_focused(window) + && self.leader_id.is_none() + { self.buffer.update(cx, |buffer, cx| { buffer.set_active_selections( &selection_anchors, @@ -1591,15 +1577,13 @@ impl Editor { .display_map .update(cx, |display_map, cx| display_map.snapshot(cx)); let buffer = display_map.buffer_snapshot(); - if self.selections.count() == 1 { - self.add_selections_state = None; - } self.select_next_state = None; self.select_prev_state = None; self.select_syntax_node_history.try_clear(); self.invalidate_autoclose_regions(&selection_anchors, buffer); self.snippet_stack.invalidate(&selection_anchors, buffer); self.take_rename(false, window, cx); + self.take_inline_input(window, cx); let newest_selection = self.selections.newest_anchor(); let new_cursor_position = newest_selection.head(); @@ -1741,12 +1725,47 @@ impl Editor { cx.notify(); } + #[inline(never)] + fn prepare_selection_effects( + &self, + effects: SelectionEffects, + history_entry: Option, + ) -> DeferredSelectionEffectsState { + DeferredSelectionEffectsState { + changed: false, + effects, + old_cursor_position: self.selections.newest_anchor().head(), + history_entry: history_entry.unwrap_or_else(|| self.selection_history_entry()), + } + } + + #[inline(never)] + fn finish_deferred_selection_effects( + &mut self, + already_deferred: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !already_deferred { + self.defer_selection_effects = false; + if let Some(state) = self.deferred_selection_effects_state.take() { + self.apply_selection_effects(state, window, cx); + } + } + } + + #[inline(never)] fn apply_selection_effects( &mut self, state: DeferredSelectionEffectsState, window: &mut Window, cx: &mut Context, ) { + if self.defer_selection_effects { + self.deferred_selection_effects_state = Some(state); + return; + } + if state.changed { self.selection_history.push(state.history_entry); @@ -1897,6 +1916,180 @@ impl Editor { )) } + pub(super) fn invalidate_add_selection_goals_after_change( + &mut self, + previous: Option<&[Selection]>, + ) { + if self.add_selections_state.is_none() { + return; + } + let Some(previous) = previous else { + self.add_selections_state = None; + return; + }; + let current = self + .selections + .disjoint_anchors() + .iter() + .map(|selection| (selection.id, selection)) + .collect::>(); + let unchanged = previous + .iter() + .filter(|selection| current.get(&selection.id) == Some(selection)) + .map(|selection| selection.id) + .collect::>(); + let removed = previous + .iter() + .filter(|selection| !current.contains_key(&selection.id)) + .map(|selection| selection.id) + .collect::>(); + if let Some(state) = &mut self.add_selections_state { + Self::retain_add_selection_goals(state, &unchanged, &removed, ¤t); + if state.groups.is_empty() { + self.add_selections_state = None; + } + } + } + + fn sync_selections_from(&mut self, selections: Vec>, cx: &mut Context) { + if !selections.is_empty() { + self.change_selections_without_effects(&self.display_snapshot(cx), |collection| { + collection.select_anchors(selections); + }); + } + } + + fn retain_add_selection_goals( + state: &mut AddSelectionsState, + unchanged: &HashSet, + removed: &HashSet, + current: &HashMap>, + ) { + let skip_soft_wrap = state.skip_soft_wrap; + state.groups.retain_mut(|group| { + if group.stack.first().is_none_or(|id| !unchanged.contains(id)) + || group + .stack + .iter() + .any(|id| !unchanged.contains(id) && !removed.contains(id)) + { + group.goal_source = None; + } + group.stack.retain(|id| current.contains_key(id)); + !group.stack.is_empty() && (skip_soft_wrap || group.stack.len() > 1) + }); + } + + fn reconcile_add_selection_groups( + state: &mut AddSelectionsState, + previous: &[Selection], + current: &[Selection], + ) { + let current_by_id = current + .iter() + .map(|selection| (selection.id, selection)) + .collect::>(); + let mut unchanged = HashSet::default(); + let mut removed = HashSet::default(); + for selection in previous { + if let Some(current) = current_by_id.get(&selection.id) { + if **current == *selection { + unchanged.insert(selection.id); + } + } else { + let index = current.partition_point(|current| current.end < selection.start); + let overlaps = current + .iter() + .skip(index) + .take_while(|current| current.start <= selection.end) + .any(|current| { + if current.is_empty() || selection.is_empty() { + current.start <= selection.end && selection.start <= current.end + } else { + current.start < selection.end && selection.start < current.end + } + }); + if !overlaps { + removed.insert(selection.id); + } + } + } + Self::retain_add_selection_goals(state, &unchanged, &removed, ¤t_by_id); + } + + fn resolve_add_selection_anchors( + selections: &[Selection], + snapshot: &DisplaySnapshot, + ) -> Vec> { + selections + .iter() + .filter(|selection| { + snapshot.can_resolve(&selection.start) && snapshot.can_resolve(&selection.end) + }) + .map(|selection| selection.map(|anchor| anchor.to_point(snapshot.buffer_snapshot()))) + .collect() + } + + fn change_selections_without_effects( + &mut self, + snapshot: &DisplaySnapshot, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, + ) -> (bool, R) { + let previous = self + .add_selections_state + .as_ref() + .map(|_| self.selections.disjoint_anchors_arc()); + let (changed, result) = self.selections.change_with(snapshot, change); + if changed + && let Some(previous) = previous + && let Some(state) = &mut self.add_selections_state + { + let previous = Self::resolve_add_selection_anchors(&previous, snapshot); + let current = + Self::resolve_add_selection_anchors(self.selections.disjoint_anchors(), snapshot); + Self::reconcile_add_selection_groups(state, &previous, ¤t); + if state.groups.is_empty() { + self.add_selections_state = None; + } + } + (changed, result) + } + + fn selection_history_entry(&self) -> SelectionHistoryEntry { + SelectionHistoryEntry { + selections: self.selections.disjoint_anchors_arc(), + select_next_state: self.select_next_state.clone(), + select_prev_state: self.select_prev_state.clone(), + add_selections_state: self.add_selections_state.clone(), + } + } + + fn change_selections_with_history( + &mut self, + effects: SelectionEffects, + history_entry: Option, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, + ) -> R { + let snapshot = self.display_snapshot(cx); + if self.deferred_selection_effects_state.is_some() { + let (changed, result) = self.change_selections_without_effects(&snapshot, change); + if let Some(state) = &mut self.deferred_selection_effects_state { + state.effects.scroll = effects.scroll.or(state.effects.scroll); + state.effects.completions = effects.completions; + state.effects.nav_history = effects.nav_history.or(state.effects.nav_history); + state.changed |= changed; + } + return result; + } + let mut state = self.prepare_selection_effects(effects, history_entry); + let (changed, result) = self.change_selections_without_effects(&snapshot, change); + state.changed = state.changed || changed; + self.apply_selection_effects(state, window, cx); + result + } + fn add_selection( &mut self, above: bool, @@ -1905,54 +2098,208 @@ impl Editor { cx: &mut Context, ) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let all_selections = self.selections.all::(&display_map); let text_layout_details = self.text_layout_details(window, cx); - - let (mut columnar_selections, new_selections_to_columnarize) = { - if let Some(state) = self.add_selections_state.as_ref() { - let columnar_selection_ids: HashSet<_> = state - .groups - .iter() - .flat_map(|group| group.stack.iter()) - .copied() - .collect(); - - all_selections - .into_iter() - .partition(|s| columnar_selection_ids.contains(&s.id)) + let buffer = display_map.buffer_snapshot(); + let all_selections = self.selections.all_unexpanded::(&display_map); + let goal_source_for_selection = |selection: &Selection| { + let end_bias = if selection.is_empty() { + Bias::Right } else { - (Vec::new(), all_selections) - } + Bias::Left + }; + buffer.anchor_after(selection.start)..buffer.anchor_at(selection.end, end_bias) }; + let history_entry = self + .deferred_selection_effects_state + .is_none() + .then(|| self.selection_history_entry()); + if self + .add_selections_state + .as_ref() + .is_some_and(|state| state.skip_soft_wrap != skip_soft_wrap) + { + self.add_selections_state = None; + } let mut state = self .add_selections_state .take() - .unwrap_or_else(|| AddSelectionsState { groups: Vec::new() }); + .unwrap_or_else(|| AddSelectionsState { + groups: Vec::new(), + skip_soft_wrap, + }); + if !state.groups.is_empty() { + let previous = Self::resolve_add_selection_anchors( + self.selections.disjoint_anchors(), + &display_map, + ); + Self::reconcile_add_selection_groups(&mut state, &previous, &all_selections); + } + let columnar_selection_ids = state + .groups + .iter() + .flat_map(|group| group.stack.iter()) + .copied() + .collect::>(); + let mut columnar_rows = ColumnarSelectionRows::new(&display_map); + let (mut columnar_selections, new_selections_to_columnarize) = + all_selections + .into_iter() + .partition::, _>(|selection| columnar_selection_ids.contains(&selection.id)); - for selection in new_selections_to_columnarize { + let mut goal_columns_by_selection_id = HashMap::default(); + let mut projected_selections = Vec::new(); + let mut projection_counts = vec![None; new_selections_to_columnarize.len()]; + if skip_soft_wrap { + let selections_by_id = columnar_selections + .iter() + .map(|selection| (selection.id, selection)) + .collect::>(); + let mut sources = Vec::new(); + for group in &mut state.groups { + let Some(oldest_selection) = + group.stack.first().and_then(|id| selections_by_id.get(id)) + else { + continue; + }; + if group.goal_source.as_ref().is_some_and(|source| { + !buffer.can_resolve(&source.start) || !buffer.can_resolve(&source.end) + }) { + group.goal_source = None; + } + let source = group + .goal_source + .get_or_insert_with(|| goal_source_for_selection(oldest_selection)); + if let Some(last_id) = group.stack.last() { + sources.push((*last_id, source.to_point(buffer))); + } + } + sources.extend( + new_selections_to_columnarize + .iter() + .map(|selection| (selection.id, selection.range())), + ); + let ranges = sources + .iter() + .map(|(_, range)| range.clone()) + .collect::>(); + goal_columns_by_selection_id.extend( + sources + .iter() + .map(|(id, _)| *id) + .zip(columnar_rows.columns_for_ranges(&ranges)), + ); + let tabs = display_map.tab_snapshot(); + let mut queries = Vec::new(); + for (selection, projection_count) in new_selections_to_columnarize + .iter() + .zip(&mut projection_counts) + { + let start = tabs.point_to_tab_point(selection.start, Bias::Left); + let end = tabs.point_to_tab_point(selection.end, Bias::Left); + if start.row() != end.row() + || display_map + .is_block_line(selection.start.to_display_point(&display_map).row()) + { + let columns = &goal_columns_by_selection_id[&selection.id]; + let query_start = queries.len(); + let mut row = start.row(); + loop { + let last_row = + if let Some(hidden) = display_map.fully_replaced_tab_rows(row) { + *hidden.end() + } else { + queries.push((row, columns.clone(), selection.reversed)); + row + }; + if last_row >= end.row() { + break; + } + row = last_row + 1; + } + *projection_count = Some(queries.len() - query_start); + } + } + projected_selections = self + .selections + .build_columnar_selections_from_tab_expanded_columns(&mut columnar_rows, &queries); + } + let mut projected_selections = projected_selections.into_iter(); + for (selection, projection_count) in new_selections_to_columnarize + .into_iter() + .zip(projection_counts) + { + if skip_soft_wrap { + let goal_source = Some(goal_source_for_selection(&selection)); + let mut stack = Vec::new(); + if let Some(count) = projection_count { + for selection in projected_selections.by_ref().take(count).flatten() { + stack.push(selection.id); + columnar_selections.push(selection); + } + } else { + stack.push(selection.id); + columnar_selections.push(selection); + } + if !stack.is_empty() { + if above { + stack.reverse(); + } + if let Some(columns) = goal_columns_by_selection_id.remove(&selection.id) + && let Some(last_id) = stack.last() + { + goal_columns_by_selection_id.insert(*last_id, columns); + } + state.groups.push(AddSelectionsGroup { + above, + stack, + goal_source, + }); + } else { + columnar_selections.push(selection); + } + continue; + } let range = selection.display_range(&display_map).sorted(); let start_x = display_map.x_for_display_point(range.start, &text_layout_details); let end_x = display_map.x_for_display_point(range.end, &text_layout_details); let positions = start_x.min(end_x)..start_x.max(end_x); let mut stack = Vec::new(); - for row in range.start.row().0..=range.end.row().0 { - if let Some(selection) = self.selections.build_columnar_selection( - &display_map, - DisplayRow(row), - &positions, - selection.reversed, - &text_layout_details, - ) { - stack.push(selection.id); - columnar_selections.push(selection); + if range.start.row() == range.end.row() && !display_map.is_block_line(range.start.row()) + { + stack.push(selection.id); + columnar_selections.push(Selection { + goal: SelectionGoal::HorizontalRange { + start: positions.start.into(), + end: positions.end.into(), + }, + ..selection + }); + } else { + for row in range.start.row().0..=range.end.row().0 { + if let Some(selection) = self.selections.build_columnar_selection( + &display_map, + DisplayRow(row), + &positions, + selection.reversed, + &text_layout_details, + ) { + stack.push(selection.id); + columnar_selections.push(selection); + } } } if !stack.is_empty() { if above { stack.reverse(); } - state.groups.push(AddSelectionsGroup { above, stack }); + state.groups.push(AddSelectionsGroup { + above, + stack, + goal_source: None, + }); + } else { + columnar_selections.push(selection); } } @@ -1963,31 +2310,11 @@ impl Editor { display_map.max_point().row() }; - // When `skip_soft_wrap` is true, we place new selections by column rather than - // pixel position, so we need to keep track of the column range of the oldest - // selection in each group, because intermediate selections may have been - // clamped to shorter lines. Columns are counted with tabs expanded so that - // tab-aligned code lines up the way it renders. - let mut goal_columns_by_selection_id = if skip_soft_wrap { - let mut map = HashMap::default(); - for group in state.groups.iter() { - if let Some(oldest_id) = group.stack.first() { - if let Some(oldest_selection) = - columnar_selections.iter().find(|s| s.id == *oldest_id) - { - let start_col = display_map.tab_expanded_column(oldest_selection.start); - let end_col = display_map.tab_expanded_column(oldest_selection.end); - let goal_columns = start_col.min(end_col)..start_col.max(end_col); - for id in &group.stack { - map.insert(*id, goal_columns.clone()); - } - } - } + if skip_soft_wrap { + for selection in &mut columnar_selections { + selection.goal = SelectionGoal::None; } - map - } else { - HashMap::default() - }; + } let mut last_added_item_per_group = HashMap::default(); for group in state.groups.iter_mut() { @@ -1996,41 +2323,58 @@ impl Editor { } } + let mut next_logical_selections = HashMap::default(); + if skip_soft_wrap { + let queries = columnar_selections + .iter() + .filter_map(|selection| { + let group = last_added_item_per_group.get_mut(&selection.id)?; + if group.stack.len() == 1 { + group.above = above; + } + (group.above == above).then(|| { + ( + *selection, + goal_columns_by_selection_id[&selection.id].clone(), + ) + }) + }) + .collect::>(); + next_logical_selections.extend(queries.iter().map(|(selection, _)| selection.id).zip( + self.selections.find_next_columnar_selections_by_buffer_row( + &display_map, + &mut columnar_rows, + &queries, + above, + ), + )); + } + for selection in columnar_selections { if let Some(group) = last_added_item_per_group.get_mut(&selection.id) { + if skip_soft_wrap && group.stack.len() == 1 { + group.above = above; + } if above == group.above { - let range = selection.display_range(&display_map).sorted(); - debug_assert_eq!(range.start.row(), range.end.row()); - let row = range.start.row(); - let positions = - if let SelectionGoal::HorizontalRange { start, end } = selection.goal { - Pixels::from(start)..Pixels::from(end) - } else { - let start_x = - display_map.x_for_display_point(range.start, &text_layout_details); - let end_x = - display_map.x_for_display_point(range.end, &text_layout_details); - start_x.min(end_x)..start_x.max(end_x) - }; - let maybe_new_selection = if skip_soft_wrap { - let goal_columns = goal_columns_by_selection_id - .remove(&selection.id) - .unwrap_or_else(|| { - let start_col = display_map.tab_expanded_column(selection.start); - let end_col = display_map.tab_expanded_column(selection.end); - start_col.min(end_col)..start_col.max(end_col) - }); - self.selections.find_next_columnar_selection_by_buffer_row( - &display_map, - row, - end_row, - above, - &goal_columns, - selection.reversed, - &text_layout_details, - ) + next_logical_selections.remove(&selection.id).flatten() } else { + let range = selection.display_range(&display_map).sorted(); + let row = if above { + range.start.row() + } else { + range.end.row() + }; + let positions = + if let SelectionGoal::HorizontalRange { start, end } = selection.goal { + Pixels::from(start)..Pixels::from(end) + } else { + let start_x = display_map + .x_for_display_point(range.start, &text_layout_details); + let end_x = display_map + .x_for_display_point(range.end, &text_layout_details); + start_x.min(end_x)..start_x.max(end_x) + }; self.selections.find_next_columnar_selection_by_display_row( &display_map, row, @@ -2062,23 +2406,17 @@ impl Editor { } } - self.change_selections(Default::default(), window, cx, |s| { - s.select(final_selections); - }); + let unmerged_selections = final_selections.clone(); + self.change_selections_with_history( + SelectionEffects::default(), + history_entry, + window, + cx, + |selections| selections.select(final_selections), + ); - let final_selection_ids: HashSet<_> = self - .selections - .all::(&display_map) - .iter() - .map(|s| s.id) - .collect(); - state.groups.retain_mut(|group| { - // selections might get merged above so we remove invalid items from stacks - group.stack.retain(|id| final_selection_ids.contains(id)); - - // single selection in stack can be treated as initial state - group.stack.len() > 1 - }); + let current = self.selections.all_unexpanded::(&display_map); + Self::reconcile_add_selection_groups(&mut state, &unmerged_selections, ¤t); if !state.groups.is_empty() { self.add_selections_state = Some(state); @@ -2132,11 +2470,11 @@ impl Editor { if !select_next_state.done { let first_selection = selections .iter() - .min_by_key(|s| s.id) + .min_by_key(|selection| selection.id) .context("missing selection for select next action")?; let last_selection = selections .iter() - .max_by_key(|s| s.id) + .max_by_key(|selection| selection.id) .context("missing selection for select next action")?; let mut next_selected_range = None; diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index b2e32fd4c7..f358bb6b78 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -1,5 +1,7 @@ use std::{ - cmp, fmt, iter, mem, + cmp, + collections::BTreeMap, + fmt, iter, mem, ops::{AddAssign, Deref, DerefMut, Range, Sub}, sync::Arc, }; @@ -7,11 +9,11 @@ use std::{ use gpui::Pixels; use itertools::{Either, Itertools as _}; use language::{Bias, Point, Selection, SelectionGoal}; -use multi_buffer::{MultiBufferDimension, MultiBufferOffset, MultiBufferRow, ToPoint}; -use util::post_inc; +use multi_buffer::{MultiBufferDimension, MultiBufferOffset, ToPoint}; use crate::{ Anchor, DisplayPoint, DisplayRow, MultiBufferSnapshot, SelectMode, ToOffset, + columnar_selection::ColumnarSelectionRows, display_map::{DisplaySnapshot, ToDisplayPoint}, movement::TextLayoutDetails, }; @@ -115,6 +117,13 @@ impl SelectionsCollection { resolve_selections_wrapping_blocks(self.pending_anchor(), &snapshot).next() } + pub(crate) fn all_unexpanded(&self, snapshot: &DisplaySnapshot) -> Vec> + where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + { + self.all_iter(snapshot, false).collect() + } + pub(crate) fn pending_mode(&self) -> Option { self.pending.as_ref().map(|pending| pending.mode.clone()) } @@ -123,19 +132,21 @@ impl SelectionsCollection { where D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, { - self.all_iter(snapshot).collect() + self.all_iter(snapshot, true).collect() } fn all_iter<'a, D>( &'a self, snapshot: &'a DisplaySnapshot, + expand_blocks: bool, ) -> impl 'a + Iterator> where D: 'a + MultiBufferDimension + Sub + AddAssign<::Output> + Ord, { let mut disjoint = - resolve_selections_wrapping_blocks::(self.disjoint.iter(), snapshot).peekable(); - let mut pending_opt = self.pending::(snapshot); + resolve_selections::(self.disjoint.iter(), snapshot, expand_blocks).peekable(); + let mut pending_opt = + resolve_selections::(self.pending_anchor(), snapshot, expand_blocks).next(); iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { @@ -347,7 +358,7 @@ impl SelectionsCollection { .next() .unwrap(); } - self.all_iter(snapshot).next().unwrap() + self.all_iter(snapshot, true).next().unwrap() } pub fn last(&self, snapshot: &DisplaySnapshot) -> Selection @@ -361,7 +372,7 @@ impl SelectionsCollection { .next() .unwrap(); } - self.all_iter(snapshot).last().unwrap() + self.all_iter(snapshot, true).last().unwrap() } /// Returns a list of (potentially backwards!) ranges representing the selections. @@ -413,6 +424,9 @@ impl SelectionsCollection { reversed: bool, text_layout_details: &TextLayoutDetails, ) -> Option> { + if display_map.is_block_line(row) { + return None; + } let is_empty = positions.start == positions.end; let line_len = display_map.line_len(row); let line = display_map.layout_row(row, text_layout_details); @@ -432,7 +446,7 @@ impl SelectionsCollection { }; Some(Selection { - id: post_inc(&mut self.next_selection_id), + id: self.allocate_selection_id(), start: start.to_point(display_map), end: end.to_point(display_map), reversed, @@ -443,52 +457,28 @@ impl SelectionsCollection { }) } - /// Attempts to build a selection in the provided buffer row using the - /// same tab-expanded column range as specified. - /// Returns `None` if the range is not empty but it starts past the line's - /// length, meaning that the line isn't long enough to be contained within - /// part of the provided range. - fn build_columnar_selection_from_tab_expanded_columns( + pub(crate) fn build_columnar_selections_from_tab_expanded_columns( &mut self, - display_map: &DisplaySnapshot, - multi_buffer_row: MultiBufferRow, - goal_columns: &Range, - reversed: bool, - text_layout_details: &TextLayoutDetails, - ) -> Option> { - let is_empty = goal_columns.start == goal_columns.end; - let line_len = display_map.tab_expanded_line_len(multi_buffer_row); - - let (start, end) = if is_empty { - let point = - display_map.point_for_tab_expanded_column(multi_buffer_row, goal_columns.start); - (point, point) - } else { - if goal_columns.start >= line_len { - return None; - } - - let start = - display_map.point_for_tab_expanded_column(multi_buffer_row, goal_columns.start); - let end = display_map.point_for_tab_expanded_column(multi_buffer_row, goal_columns.end); - (start, end) - }; - - let start_display_point = start.to_display_point(display_map); - let end_display_point = end.to_display_point(display_map); - let start_x = display_map.x_for_display_point(start_display_point, text_layout_details); - let end_x = display_map.x_for_display_point(end_display_point, text_layout_details); - - Some(Selection { - id: post_inc(&mut self.next_selection_id), - start, - end, - reversed, - goal: SelectionGoal::HorizontalRange { - start: start_x.min(end_x).into(), - end: start_x.max(end_x).into(), - }, - }) + rows: &mut ColumnarSelectionRows<'_>, + queries: &[(u32, Range, bool)], + ) -> Vec>> { + let row_queries = queries + .iter() + .map(|(row, columns, _)| (*row, columns.clone())) + .collect::>(); + rows.points_for_rows(&row_queries) + .into_iter() + .zip(queries) + .map(|(points, &(_, _, reversed))| { + points.map(|(start, end)| Selection { + id: self.allocate_selection_id(), + start, + end, + reversed, + goal: SelectionGoal::None, + }) + }) + .collect() } /// Finds the next columnar selection by walking display rows one at a time @@ -524,37 +514,82 @@ impl SelectionsCollection { None } - /// Finds the next columnar selection by skipping to the next buffer row, - /// ignoring soft-wrapped lines. - pub(crate) fn find_next_columnar_selection_by_buffer_row( + pub(crate) fn find_next_columnar_selections_by_buffer_row( &mut self, display_map: &DisplaySnapshot, - start_row: DisplayRow, - end_row: DisplayRow, + rows: &mut ColumnarSelectionRows<'_>, + queries: &[(Selection, Range)], above: bool, - goal_columns: &Range, - reversed: bool, - text_layout_details: &TextLayoutDetails, - ) -> Option> { - let mut row = start_row; - let direction = if above { -1 } else { 1 }; - while row != end_row { - let new_row = - display_map.start_of_relative_buffer_row(DisplayPoint::new(row, 0), direction); - row = new_row.row(); - let buffer_row = MultiBufferRow(new_row.to_point(display_map).row); - - if let Some(selection) = self.build_columnar_selection_from_tab_expanded_columns( - display_map, - buffer_row, - goal_columns, - reversed, - text_layout_details, - ) { - return Some(selection); + ) -> Vec>> { + let tabs = display_map.tab_snapshot(); + let max_row = tabs.max_point().row(); + let next_row = |row: u32| { + if above { + row.checked_sub(1) + } else { + row.checked_add(1).filter(|row| *row <= max_row) + } + }; + let mut pending = BTreeMap::<_, Vec<_>>::new(); + let mut answers = vec![None; queries.len()]; + for (index, (selection, columns)) in queries.iter().enumerate() { + if columns.start > columns.end { + continue; + } + let point = if above { + selection.start + } else { + selection.end + }; + if let Some(row) = next_row(tabs.point_to_tab_point(point, Bias::Left).row()) { + pending.entry(row).or_default().push(index); } } - None + while let Some((row, query_indices)) = if above { + pending.pop_last() + } else { + pending.pop_first() + } { + if let Some(hidden_rows) = display_map.fully_replaced_tab_rows(row) { + let row = if above { + *hidden_rows.start() + } else { + *hidden_rows.end() + }; + if let Some(row) = next_row(row) { + pending.entry(row).or_default().extend(query_indices); + } + continue; + } + let row_queries = query_indices + .iter() + .map(|&index| (row, queries[index].1.clone())) + .collect::>(); + let next_row = next_row(row); + for (index, points) in query_indices + .into_iter() + .zip(rows.points_for_rows(&row_queries)) + { + if let Some(points) = points { + answers[index] = Some(points); + } else if let Some(row) = next_row { + pending.entry(row).or_default().push(index); + } + } + } + answers + .into_iter() + .zip(queries) + .map(|(points, (selection, _))| { + points.map(|(start, end)| Selection { + id: self.allocate_selection_id(), + start, + end, + reversed: selection.reversed, + goal: SelectionGoal::None, + }) + }) + .collect() } pub fn change_with( @@ -643,6 +678,12 @@ impl SelectionsCollection { pub fn set_is_extending(&mut self, is_extending: bool) { self.is_extending = is_extending; } + + fn allocate_selection_id(&mut self) -> usize { + let id = self.next_selection_id; + self.next_selection_id = id.wrapping_add(1); + id + } } pub struct MutableSelectionsCollection<'snap, 'a> { @@ -714,7 +755,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { .and_then(|excerpt| buffer_snapshot.anchor_in_excerpt(excerpt.context.start)) .unwrap_or_else(|| self.snapshot.anchor_before(MultiBufferOffset(0))); self.collection.disjoint = Arc::from([Selection { - id: post_inc(&mut self.collection.next_selection_id), + id: self.collection.allocate_selection_id(), start: anchor, end: anchor, reversed: false, @@ -746,7 +787,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { false }; Selection { - id: post_inc(&mut self.collection.next_selection_id), + id: self.collection.allocate_selection_id(), start, end, reversed, @@ -806,7 +847,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { false }; selections.push(Selection { - id: post_inc(&mut self.collection.next_selection_id), + id: self.collection.allocate_selection_id(), start, end, reversed, @@ -895,7 +936,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { false }; Selection { - id: post_inc(&mut self.collection.next_selection_id), + id: self.collection.allocate_selection_id(), start, end, reversed, @@ -923,7 +964,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { false }; Selection { - id: post_inc(&mut self.collection.next_selection_id), + id: self.collection.allocate_selection_id(), start, end, reversed, @@ -935,7 +976,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { } pub fn new_selection_id(&mut self) -> usize { - post_inc(&mut self.next_selection_id) + self.collection.allocate_selection_id() } pub fn select_display_ranges(&mut self, ranges: T) @@ -954,7 +995,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { false }; Selection { - id: post_inc(&mut self.collection.next_selection_id), + id: self.collection.allocate_selection_id(), start: start.to_point(self.snapshot), end: end.to_point(self.snapshot), reversed, @@ -1088,7 +1129,7 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { .map(|cursor| { let cursor_point = cursor.to_point(self.snapshot); Selection { - id: post_inc(&mut self.collection.next_selection_id), + id: self.collection.allocate_selection_id(), start: cursor_point, end: cursor_point, reversed: false, @@ -1103,6 +1144,10 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { self.selections_changed = true; self.pending.as_mut().map(|pending| &mut pending.selection) } + + pub(crate) fn select_anchors_unexpanded(&mut self, selections: Vec>) { + self.select(selections); + } } impl Deref for MutableSelectionsCollection<'_, '_> { @@ -1209,13 +1254,25 @@ pub(crate) fn resolve_selections_wrapping_blocks<'a, D, I>( selections: I, map: &'a DisplaySnapshot, ) -> impl 'a + Iterator> +where + D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, + I: 'a + IntoIterator>, +{ + resolve_selections(selections, map, true) +} + +fn resolve_selections<'a, D, I>( + selections: I, + map: &'a DisplaySnapshot, + expand_blocks: bool, +) -> impl 'a + Iterator> where D: MultiBufferDimension + Sub + AddAssign<::Output> + Ord, I: 'a + IntoIterator>, { // Without collapsed content, coalescing in buffer point space is equivalent to coalescing in // display point space, so skip the per-selection display-coordinate round trip. - if !map.has_collapsed_content() { + if !expand_blocks || !map.has_collapsed_content() { Either::Left(resolve_selections_without_display_round_trip( selections, map, )) @@ -1356,7 +1413,9 @@ mod tests { use super::*; use crate::{ MultiBuffer, - display_map::{BlockPlacement, BlockProperties, BlockStyle, DisplayMap, FoldPlaceholder}, + display_map::{ + BlockPlacement, BlockProperties, BlockStyle, Crease, DisplayMap, FoldPlaceholder, + }, test::test_font, }; use gpui::{AppContext as _, IntoElement as _, div, px}; @@ -1366,26 +1425,7 @@ mod tests { use std::sync::Arc; fn row_range_snapshot(cx: &mut gpui::TestAppContext, text: &str) -> DisplaySnapshot { - cx.update(|cx| { - let settings = SettingsStore::test(cx); - cx.set_global(settings); - crate::init(cx); - }); - let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx)); - let display_map = cx.new(|cx| { - DisplayMap::new( - buffer, - test_font(), - px(14.), - None, - 1, - 1, - FoldPlaceholder::test(), - DiagnosticSeverity::Warning, - cx, - ) - }); - display_map.update(cx, |map, cx| map.snapshot(cx)) + selection_test_display_map(cx, text).update(cx, |map, cx| map.snapshot(cx)) } fn row_range_collection( @@ -1552,6 +1592,418 @@ mod tests { assert_eq!(fast_offsets, slow_offsets); } + #[gpui::test] + fn select_anchors_unexpanded_preserves_hidden_buffer_ranges(cx: &mut gpui::TestAppContext) { + let map = selection_test_display_map(cx, "aaaa\nbbbb\ncccc\ndddd\neeee\nffff"); + let snapshot = map.update(cx, |map, cx| { + map.fold( + vec![Crease::simple( + Point::new(1, 0)..Point::new(2, 4), + FoldPlaceholder::test(), + )], + cx, + ); + let buffer = map.snapshot(cx).buffer_snapshot().clone(); + map.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Replace( + buffer.anchor_after(Point::new(4, 0)) + ..=buffer.anchor_before(Point::new(5, 4)), + ), + height: Some(1), + style: BlockStyle::Fixed, + render: Arc::new(|_| div().into_any_element()), + priority: 0, + }], + cx, + ); + map.snapshot(cx) + }); + let saved = [1, 2, 4, 5] + .into_iter() + .map(|row| Selection { + id: row as usize + 10, + start: Point::new(row, 1), + end: Point::new(row, 2), + reversed: row % 2 == 0, + goal: SelectionGoal::None, + }) + .collect::>(); + let anchors = saved + .iter() + .map(|selection| { + selection_to_anchor_selection( + selection.map(|point| point.to_offset(&snapshot)), + &snapshot, + ) + }) + .collect::>(); + let mut collection = SelectionsCollection::new(); + collection.change_with(&snapshot, |selections| { + selections.select_anchors_unexpanded(anchors); + }); + assert_eq!(collection.all_unexpanded::(&snapshot), saved); + assert_eq!(collection.pending_anchor(), None); + + let mut overlapping = saved[0]; + overlapping.id = 30; + overlapping.end.column = 3; + collection.change_with(&snapshot, |selections| { + selections.select_anchors_unexpanded( + [saved[0], overlapping] + .into_iter() + .map(|selection| { + selection_to_anchor_selection( + selection.map(|point| point.to_offset(&snapshot)), + &snapshot, + ) + }) + .collect(), + ); + }); + assert_eq!( + collection.all_unexpanded::(&snapshot), + vec![Selection { + end: overlapping.end, + ..saved[0] + }] + ); + } + + #[gpui::test] + fn columnar_selection_batch_preserves_request_order(cx: &mut gpui::TestAppContext) { + let snapshot = row_range_snapshot(cx, "abcd\n\né🦀z\n\txyz"); + assert_eq!(snapshot.tab_snapshot().text(), "abcd\n\né🦀z\n xyz"); + let mut rows = ColumnarSelectionRows::new(&snapshot); + let mut collection = SelectionsCollection::new(); + let actual = collection.build_columnar_selections_from_tab_expanded_columns( + &mut rows, + &[ + (2, 1..2, true), + (1, 0..1, false), + (0, 2..u32::MAX, false), + (2, 0..3, false), + (u32::MAX, 0..0, true), + (1, u32::MAX..u32::MAX, true), + (0, 4..5, true), + (0, u32::MAX..u32::MAX, false), + (3, 1..3, true), + (3, 0..5, false), + (2, 3..4, false), + (0, Range { start: 3, end: 2 }, false), + ], + ); + assert_eq!( + actual, + vec![ + Some(columnar_test_selection(1, 2, 2..6, true)), + None, + Some(columnar_test_selection(2, 0, 2..4, false)), + Some(columnar_test_selection(3, 2, 0..7, false)), + None, + Some(columnar_test_selection(4, 1, 0..0, true)), + None, + Some(columnar_test_selection(5, 0, 4..4, false)), + Some(columnar_test_selection(6, 3, 0..0, true)), + Some(columnar_test_selection(7, 3, 0..2, false)), + None, + None, + ] + ); + assert_eq!(collection.next_selection_id(), 8); + assert_eq!( + collection.build_columnar_selections_from_tab_expanded_columns(&mut rows, &[]), + Vec::new() + ); + assert_eq!(collection.next_selection_id(), 8); + } + + #[gpui::test] + fn columnar_selection_batch_joins_different_starts_and_skips(cx: &mut gpui::TestAppContext) { + for above in [false, true] { + let mut lines = [ + "abcdefgh", "x", "", "abcdefgh", "x", "", "abcdefgh", "x", "abcdefgh", + ]; + if above { + lines.reverse(); + } + let snapshot = row_range_snapshot(cx, &lines.join("\n")); + let mut rows = ColumnarSelectionRows::new(&snapshot); + let mut collection = SelectionsCollection::new(); + let row = |row| if above { 8 - row } else { row }; + let queries = [ + (5, 2..5, false), + (0, 3..7, true), + (2, 2..4, false), + (1, 1..2, true), + (8, 0..1, false), + (0, 8..9, true), + (3, 0..0, false), + (6, 0..2, true), + (6, u32::MAX..u32::MAX, false), + (5, Range { start: 1, end: 0 }, true), + (7, 0..8, true), + ] + .into_iter() + .enumerate() + .map(|(index, (source_row, columns, reversed))| { + ( + columnar_test_selection(index + 20, row(source_row), 0..0, reversed), + columns, + ) + }) + .collect::>(); + assert_eq!( + collection.find_next_columnar_selections_by_buffer_row( + &snapshot, &mut rows, &queries, above, + ), + vec![ + Some(columnar_test_selection(1, row(6), 2..5, false)), + Some(columnar_test_selection(2, row(3), 3..7, true)), + Some(columnar_test_selection(3, row(3), 2..4, false)), + Some(columnar_test_selection(4, row(3), 1..2, true)), + None, + None, + Some(columnar_test_selection(5, row(4), 0..0, false)), + Some(columnar_test_selection(6, row(7), 0..1, true)), + Some(columnar_test_selection(7, row(7), 1..1, false)), + None, + Some(columnar_test_selection(8, row(8), 0..8, true)), + ], + "above: {above}" + ); + assert_eq!(collection.next_selection_id(), 9); + } + } + + #[gpui::test] + fn columnar_selection_batch_uses_directional_endpoints(cx: &mut gpui::TestAppContext) { + let snapshot = row_range_snapshot(cx, "abcd\nabcd\nabcd\nabcd\nabcd"); + let mut rows = ColumnarSelectionRows::new(&snapshot); + for above in [false, true] { + let mut collection = SelectionsCollection::new(); + let queries = [false, true].map(|reversed| { + ( + Selection { + id: 20, + start: Point::new(1, 1), + end: Point::new(3, 2), + reversed, + goal: SelectionGoal::None, + }, + 1..3, + ) + }); + let destination = if above { 0 } else { 4 }; + assert_eq!( + collection.find_next_columnar_selections_by_buffer_row( + &snapshot, &mut rows, &queries, above, + ), + vec![ + Some(columnar_test_selection(1, destination, 1..3, false)), + Some(columnar_test_selection(2, destination, 1..3, true)), + ] + ); + assert_eq!(collection.next_selection_id(), 3); + } + } + + #[gpui::test] + fn columnar_selection_batch_unavailable_does_not_allocate(cx: &mut gpui::TestAppContext) { + for text in ["", "abcd"] { + let snapshot = row_range_snapshot(cx, text); + let mut rows = ColumnarSelectionRows::new(&snapshot); + let mut collection = SelectionsCollection::new(); + for above in [false, true] { + let queries = [ + (columnar_test_selection(0, 0, 0..0, false), 0..0), + (columnar_test_selection(0, 0, 0..0, true), 0..1), + ]; + assert_eq!( + collection.find_next_columnar_selections_by_buffer_row( + &snapshot, &mut rows, &queries, above, + ), + vec![None, None] + ); + assert_eq!( + collection.find_next_columnar_selections_by_buffer_row( + &snapshot, + &mut rows, + &[], + above, + ), + Vec::new() + ); + assert_eq!(collection.next_selection_id(), 1); + } + } + } + + #[gpui::test] + fn columnar_selection_batch_joins_replacement_skips(cx: &mut gpui::TestAppContext) { + let map = selection_test_display_map( + cx, + &iter::repeat_n("abcdefgh", 9).collect::>().join("\n"), + ); + let snapshot = map.update(cx, |map, cx| { + let buffer = map.snapshot(cx).buffer_snapshot().clone(); + map.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Replace( + buffer.anchor_after(Point::new(3, 0)) + ..=buffer.anchor_before(Point::new(5, 8)), + ), + height: Some(1), + style: BlockStyle::Fixed, + render: Arc::new(|_| div().into_any_element()), + priority: 0, + }], + cx, + ); + map.snapshot(cx) + }); + for above in [false, true] { + let mut rows = ColumnarSelectionRows::new(&snapshot); + let mut collection = SelectionsCollection::new(); + let row = |row| if above { 8 - row } else { row }; + let queries = [ + (6, 1..3, false), + (0, 2..6, true), + (2, 1..2, false), + (4, 0..0, true), + (5, 3..7, true), + (8, 0..0, false), + (1, 8..9, false), + ] + .into_iter() + .enumerate() + .map(|(index, (source_row, columns, reversed))| { + ( + columnar_test_selection(index + 20, row(source_row), 0..0, reversed), + columns, + ) + }) + .collect::>(); + assert_eq!( + collection.find_next_columnar_selections_by_buffer_row( + &snapshot, &mut rows, &queries, above, + ), + vec![ + Some(columnar_test_selection(1, row(7), 1..3, false)), + Some(columnar_test_selection(2, row(1), 2..6, true)), + Some(columnar_test_selection(3, row(6), 1..2, false)), + Some(columnar_test_selection(4, row(6), 0..0, true)), + Some(columnar_test_selection(5, row(6), 3..7, true)), + None, + None, + ], + "above: {above}" + ); + assert_eq!(collection.next_selection_id(), 6); + } + } + + #[gpui::test] + fn columnar_selection_skips_replacement_input_rows(cx: &mut gpui::TestAppContext) { + let text = iter::repeat_n("abcd", 1002).collect::>().join("\n"); + for hidden in [1..=1000, 0..=1000, 1..=1001, 0..=1001] { + let map = selection_test_display_map(cx, &text); + let snapshot = map.update(cx, |map, cx| { + let buffer = map.snapshot(cx).buffer_snapshot().clone(); + map.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Replace( + buffer.anchor_after(Point::new(*hidden.start(), 0)) + ..=buffer.anchor_before(Point::new(*hidden.end(), 4)), + ), + height: Some(1), + style: BlockStyle::Fixed, + render: Arc::new(|_| div().into_any_element()), + priority: 0, + }], + cx, + ); + map.snapshot(cx) + }); + for above in [false, true] { + let source_row = if above { 1001 } else { 0 }; + let source = Selection { + id: 0, + start: Point::new(source_row, 0), + end: Point::new(source_row, 1), + reversed: above, + goal: SelectionGoal::None, + }; + let expected_row = if above { + hidden.start().checked_sub(1) + } else if *hidden.end() == 1001 { + None + } else { + Some(hidden.end() + 1) + }; + let mut collection = SelectionsCollection::new(); + let mut rows = ColumnarSelectionRows::new(&snapshot); + let actual = collection.find_next_columnar_selections_by_buffer_row( + &snapshot, + &mut rows, + &[(source, 0..1)], + above, + ); + assert_eq!( + actual, + vec![expected_row.map(|row| Selection { + id: 1, + start: Point::new(row, 0), + end: Point::new(row, 1), + reversed: above, + goal: SelectionGoal::None, + })], + "hidden: {hidden:?}, above: {above}" + ); + } + } + } + + fn columnar_test_selection( + id: usize, + row: u32, + columns: Range, + reversed: bool, + ) -> Selection { + Selection { + id, + start: Point::new(row, columns.start), + end: Point::new(row, columns.end), + reversed, + goal: SelectionGoal::None, + } + } + + fn selection_test_display_map( + cx: &mut gpui::TestAppContext, + text: &str, + ) -> gpui::Entity { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + crate::init(cx); + }); + let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx)); + cx.new(|cx| { + DisplayMap::new( + buffer, + test_font(), + px(14.), + None, + 1, + 1, + FoldPlaceholder::test(), + DiagnosticSeverity::Warning, + cx, + ) + }) + } + fn random_text(rng: &mut StdRng) -> String { let mut text = String::new(); for line in 0..rng.random_range(8..32) { diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index e217398faa..cda783fbfb 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -3,6 +3,7 @@ pub mod editor_test_context; use std::{rc::Rc, sync::LazyLock}; +pub use crate::emmet_ext::wrap_with_abbreviation; pub use crate::rust_analyzer_ext::expand_macro_recursively; use crate::{ DisplayPoint, Editor, EditorMode, FoldPlaceholder, MultiBuffer, SelectionEffects, Size, diff --git a/crates/encoding_selector/Cargo.toml b/crates/encoding_selector/Cargo.toml index 4903ab56d6..b83ac22456 100644 --- a/crates/encoding_selector/Cargo.toml +++ b/crates/encoding_selector/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/encoding_selector.rs" doctest = false +test = false [dependencies] editor.workspace = true diff --git a/crates/env_var/Cargo.toml b/crates/env_var/Cargo.toml index 3c879a2f49..ea3c180867 100644 --- a/crates/env_var/Cargo.toml +++ b/crates/env_var/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/env_var.rs" +test = false +doctest = false [dependencies] gpui_shared_string.workspace = true diff --git a/crates/etw_tracing/Cargo.toml b/crates/etw_tracing/Cargo.toml index ce57b9e467..fc67fddf77 100644 --- a/crates/etw_tracing/Cargo.toml +++ b/crates/etw_tracing/Cargo.toml @@ -7,6 +7,8 @@ license = "GPL-3.0-or-later" [lib] path = "etw_tracing.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true @@ -20,4 +22,9 @@ workspace.workspace = true [target.'cfg(target_os = "windows")'.dependencies] wprcontrol = { git = "https://github.com/zed-industries/wprcontrol", rev = "cd811f7" } windows-core = "0.61" -windows.workspace = true +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/crates/eval_cli/Dockerfile b/crates/eval_cli/Dockerfile index 69a1646177..e9852fe319 100644 --- a/crates/eval_cli/Dockerfile +++ b/crates/eval_cli/Dockerfile @@ -7,14 +7,14 @@ # Or use the helper script: # crates/eval_cli/script/build-linux -FROM rust:1.97.1@sha256:3382bd20aa942806c533e9a73cd000474fb3ef173f71e684cc9b942675781769 AS builder +FROM rust:1.98.1@sha256:462a9af3c54fb4718850d3c602fc0e54452c20b1c12a4e4080fdb001d4b9acbf AS builder ARG CARGO_ZIGBUILD_VERSION=0.22.3 WORKDIR /app # Pre-install the toolchain specified in rust-toolchain.toml so it is cached. -RUN rustup toolchain install 1.97.1 --profile minimal \ +RUN rustup toolchain install 1.98.1 --profile minimal \ --component rustfmt --component clippy --component rust-analyzer --component rust-src \ --target wasm32-wasip2 --target wasm32-unknown-unknown --target x86_64-unknown-linux-musl --target x86_64-unknown-linux-gnu diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 156c549ac8..7739dcc6e0 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -67,10 +67,7 @@ pub fn init(cx: &mut App) -> Arc { cx.set_global(app_db); let git_binary_path = None; - let fs = Arc::new(RealFs::new( - git_binary_path, - cx.background_executor().clone(), - )); + let fs = RealFs::new(git_binary_path, cx.background_executor().clone()); ::set_global(fs.clone(), cx); let mut languages = LanguageRegistry::new(cx.background_executor().clone()); diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index fc8074914c..e25730dc5e 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -971,7 +971,7 @@ fn log_acp_thread_event( if let Some(acp_thread::AgentThreadEntry::AssistantMessage(message)) = entries.last() { for chunk in &message.chunks { if let acp_thread::AssistantMessageChunk::Message { id: _, block } = chunk { - if let acp_thread::ContentBlock::Markdown { markdown } = block { + for markdown in block.markdowns() { let text = markdown.read(cx).source().to_string(); if !text.is_empty() { eprint!("{text}"); diff --git a/crates/eval_utils/Cargo.toml b/crates/eval_utils/Cargo.toml index de4ef71148..17b93ccd62 100644 --- a/crates/eval_utils/Cargo.toml +++ b/crates/eval_utils/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/eval_utils.rs" doctest = false +test = false [dependencies] gpui_platform.workspace = true diff --git a/crates/explorer_command_injector/Cargo.toml b/crates/explorer_command_injector/Cargo.toml index 8530329358..f5aefe2b61 100644 --- a/crates/explorer_command_injector/Cargo.toml +++ b/crates/explorer_command_injector/Cargo.toml @@ -12,6 +12,7 @@ workspace = true crate-type = ["cdylib"] path = "src/explorer_command_injector.rs" doctest = false +test = false [features] default = ["nightly"] @@ -22,6 +23,4 @@ nightly = [] [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true windows-core.workspace = true -windows-registry = "0.5" - -[dependencies] +windows-registry.workspace = true diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index be58aa535c..98835dcd41 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/extension.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/extension/src/extension.rs b/crates/extension/src/extension.rs index 850da7a69e..ceffef8efe 100644 --- a/crates/extension/src/extension.rs +++ b/crates/extension/src/extension.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use ::lsp::LanguageServerName; use anyhow::{Context as _, Result, bail}; use async_trait::async_trait; -use gpui::{App, Task}; +use gpui::{App, EntityId, Task}; use language::LanguageName; use semver::Version; use task::{SpawnInTerminal, ZedDebugConfig}; @@ -64,6 +64,7 @@ pub trait Extension: Send + Sync + 'static { language_server_id: LanguageServerName, language_name: LanguageName, worktree: Arc, + language_server_status_source: EntityId, ) -> Result; async fn language_server_initialization_options( @@ -71,24 +72,28 @@ pub trait Extension: Send + Sync + 'static { language_server_id: LanguageServerName, language_name: LanguageName, worktree: Arc, + language_server_status_source: EntityId, ) -> Result>; async fn language_server_workspace_configuration( &self, language_server_id: LanguageServerName, worktree: Arc, + language_server_status_source: EntityId, ) -> Result>; async fn language_server_initialization_options_schema( &self, language_server_id: LanguageServerName, worktree: Arc, + language_server_status_source: EntityId, ) -> Result>; async fn language_server_workspace_configuration_schema( &self, language_server_id: LanguageServerName, worktree: Arc, + language_server_status_source: EntityId, ) -> Result>; async fn language_server_additional_initialization_options( @@ -96,6 +101,7 @@ pub trait Extension: Send + Sync + 'static { language_server_id: LanguageServerName, target_language_server_id: LanguageServerName, worktree: Arc, + language_server_status_source: EntityId, ) -> Result>; async fn language_server_additional_workspace_configuration( @@ -103,6 +109,7 @@ pub trait Extension: Send + Sync + 'static { language_server_id: LanguageServerName, target_language_server_id: LanguageServerName, worktree: Arc, + language_server_status_source: EntityId, ) -> Result>; async fn labels_for_completions( diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 3049a785ff..5d7cd5548a 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -2,7 +2,7 @@ use crate::{ ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, parse_wasm_extension_version, }; use ::fs::Fs; -use anyhow::{Context as _, Result, bail}; +use anyhow::{Context as _, Result}; use futures::{ FutureExt, StreamExt, channel::oneshot::{self, Sender}, @@ -28,20 +28,19 @@ use wasmparser::Parser; /// Currently, we compile with Rust's `wasm32-wasip2` target, which works with WASI `preview2` and the component model. const RUST_TARGET: &str = "wasm32-wasip2"; -/// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc -/// and clang's runtime library. The `wasi-sdk` provides these binaries. -/// -/// Once Clang 17 and its wasm target are available via system package managers, we won't need -/// to download this. -const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/"; -const WASI_SDK_ASSET_NAME: Option<&str> = cfg_select! { - all(target_os = "macos", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-macos.tar.gz"), - all(target_os = "macos", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-macos.tar.gz"), - all(target_os = "linux", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"), - all(target_os = "linux", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"), - all(target_os = "freebsd", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"), - all(target_os = "freebsd", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"), - all(target_os = "windows", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-windows.tar.gz"), +/// Compiling Tree-sitter parsers from C to WebAssembly requires a compiler, +/// a WASI sysroot, and compiler runtime libraries, provided by `wasi-sdk`. +const WASI_SDK_VERSION: &str = "34"; +const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/"; +const WASI_SDK_PLATFORM: Option<&str> = cfg_select! { + all(target_os = "macos", target_arch = "x86_64") => Some("x86_64-macos"), + all(target_os = "macos", target_arch = "aarch64") => Some("arm64-macos"), + all(target_os = "linux", target_arch = "x86_64") => Some("x86_64-linux"), + all(target_os = "linux", target_arch = "aarch64") => Some("arm64-linux"), + all(target_os = "freebsd", target_arch = "x86_64") => Some("x86_64-linux"), + all(target_os = "freebsd", target_arch = "aarch64") => Some("arm64-linux"), + all(target_os = "windows", target_arch = "x86_64") => Some("x86_64-windows"), + all(target_os = "windows", target_arch = "aarch64") => Some("arm64-windows"), _ => None }; @@ -100,16 +99,14 @@ impl ExtensionBuilder { populate_defaults(extension_manifest, extension_dir, fs.clone()).await?; - if extension_dir.is_relative() { - bail!( - "extension dir {} is not an absolute path", - extension_dir.display() - ); - } + anyhow::ensure!( + extension_dir.is_absolute(), + "extension dir {extension_dir:?} is not an absolute path" + ); fs.create_dir(&self.cache_dir) .await - .context("failed to create cache dir")?; + .context("creating cache directory")?; let (tx, mut rx) = oneshot::channel(); @@ -129,12 +126,12 @@ impl ExtensionBuilder { let rust_compilation_task = (extension_manifest.lib.kind == Some(ExtensionLibraryKind::Rust)).then(|| { async { - log::info!("compiling Rust extension {}", extension_dir.display()); + log::info!("compiling Rust extension {extension_dir:?}"); self.compile_rust_extension(extension_dir, extension_manifest, tx, &options) .await - .context("failed to compile Rust extension")?; + .context("compiling Rust extension")?; - log::info!("compiled Rust extension {}", extension_dir.display()); + log::info!("compiled Rust extension {extension_dir:?}"); Ok(()) } .boxed() @@ -147,21 +144,15 @@ impl ExtensionBuilder { .map(|((grammar_name, grammar_metadata), clang_path_task)| { async move { let snake_cased_grammar_name = grammar_name.to_snake_case(); - if grammar_name.as_ref() != snake_cased_grammar_name.as_str() { - bail!( - "grammar name '{grammar_name}' must be \ - written in snake_case: {snake_cased_grammar_name}" - ); - } - - log::info!( - "compiling grammar {grammar_name} for extension {}", - extension_dir.display() + anyhow::ensure!( + grammar_name.as_ref() == snake_cased_grammar_name.as_str(), + "grammar name '{grammar_name}' must be \ + written in snake_case: {snake_cased_grammar_name}" ); - let clang_path = clang_path_task - .await - .context("Failed to resolve clang path")?; + log::info!("compiling grammar {grammar_name} for extension {extension_dir:?}"); + + let clang_path = clang_path_task.await.context("resolving clang path")?; self.compile_grammar( extension_dir, @@ -170,11 +161,8 @@ impl ExtensionBuilder { &clang_path, ) .await - .with_context(|| format!("failed to compile grammar '{grammar_name}'"))?; - log::info!( - "compiled grammar {grammar_name} for extension {}", - extension_dir.display() - ); + .with_context(|| format!("compiling grammar '{grammar_name}'"))?; + log::info!("compiled grammar {grammar_name} for extension {extension_dir:?}"); Ok(()) } @@ -204,8 +192,7 @@ impl ExtensionBuilder { } log::info!( - "finished compiling extension {} in {time:.2}s", - extension_dir.display(), + "finished compiling extension {extension_dir:?} in {time:.2}s", time = start.elapsed().as_secs_f64(), ); Ok(()) @@ -223,10 +210,7 @@ impl ExtensionBuilder { let cargo_toml_content = fs::read_to_string(extension_dir.join("Cargo.toml"))?; let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?; - log::info!( - "compiling Rust crate for extension {}", - extension_dir.display() - ); + log::info!("compiling Rust crate for extension {extension_dir:?}"); let output = util::command::new_command("cargo") .args(["build", "--target", RUST_TARGET]) .args(options.release.then_some("--release")) @@ -237,19 +221,15 @@ impl ExtensionBuilder { .current_dir(extension_dir) .output() .await - .context("failed to run `cargo`")?; - if !output.status.success() { - bail!( - "failed to build extension {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - log::info!( - "compiled Rust crate for extension {}", - extension_dir.display() + .context("running `cargo`")?; + anyhow::ensure!( + output.status.success(), + "failed to build extension {}", + String::from_utf8_lossy(&output.stderr) ); + log::info!("compiled Rust crate for extension {extension_dir:?}"); + let mut wasm_path = PathBuf::from(extension_dir); wasm_path.extend([ "target", @@ -263,17 +243,14 @@ impl ExtensionBuilder { ]); wasm_path.set_extension("wasm"); - log::info!( - "encoding wasm component for extension {}", - extension_dir.display() - ); + log::info!("encoding wasm component for extension {extension_dir:?}"); - let component_bytes = fs::read(&wasm_path) - .with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?; + let component_bytes = + fs::read(&wasm_path).with_context(|| format!("reading output module {wasm_path:?}"))?; let component_bytes = self .strip_custom_sections(&component_bytes) - .context("failed to strip debug sections from wasm component")?; + .context("stripping debug sections from wasm component")?; let wasm_extension_api_version = parse_wasm_extension_version(&manifest.id, &component_bytes) @@ -283,14 +260,9 @@ impl ExtensionBuilder { .map_err(|_| anyhow::anyhow!("Failed to send API version"))?; let extension_file = extension_dir.join("extension.wasm"); - fs::write(extension_file.clone(), &component_bytes) - .context("failed to write extension.wasm")?; + fs::write(extension_file.clone(), &component_bytes).context("writing extension.wasm")?; - log::info!( - "extension {} written to {}", - extension_dir.display(), - extension_file.display() - ); + log::info!("extension {extension_dir:?} written to {extension_file:?}"); Ok(()) } @@ -345,15 +317,13 @@ impl ExtensionBuilder { .args(scanner_path.exists().then_some(scanner_path)) .output() .await - .context("failed to run clang")?; + .context("running clang")?; - if !clang_output.status.success() { - bail!( - "failed to compile {} parser with clang: {}", - grammar_name, - String::from_utf8_lossy(&clang_output.stderr), - ); - } + anyhow::ensure!( + clang_output.status.success(), + "failed to compile {grammar_name} parser with clang: {}", + String::from_utf8_lossy(&clang_output.stderr), + ); } Ok(()) @@ -372,28 +342,22 @@ impl ExtensionBuilder { .await?; let has_remote = remotes_output.status.success() && String::from_utf8_lossy(&remotes_output.stdout).trim() == url; - if !has_remote { - bail!( - "grammar directory '{}' already exists, but is not a git clone of '{}'", - directory.display(), - url - ); - } + anyhow::ensure!( + has_remote, + "grammar directory {directory:?} already exists, but is not a git clone of '{url}'" + ); } else { - fs::create_dir_all(directory).with_context(|| { - format!("failed to create grammar directory {}", directory.display(),) - })?; + fs::create_dir_all(directory) + .with_context(|| format!("creating grammar directory {directory:?}"))?; let init_output = util::command::new_command("git") .arg("init") .current_dir(directory) .output() .await?; - if !init_output.status.success() { - bail!( - "failed to run `git init` in directory '{}'", - directory.display() - ); - } + anyhow::ensure!( + init_output.status.success(), + "failed to run `git init` in directory {directory:?}" + ); let remote_add_output = util::command::new_command("git") .arg("--git-dir") @@ -401,13 +365,11 @@ impl ExtensionBuilder { .args(["remote", "add", "origin", url]) .output() .await - .context("failed to execute `git remote add`")?; - if !remote_add_output.status.success() { - bail!( - "failed to add remote {url} for git repository {}", - git_dir.display() - ); - } + .context("executing `git remote add`")?; + anyhow::ensure!( + remote_add_output.status.success(), + "failed to add remote {url} for git repository {git_dir:?}" + ); } let fetch_output = util::command::new_command("git") @@ -416,7 +378,7 @@ impl ExtensionBuilder { .args(["fetch", "--depth", "1", "origin", rev]) .output() .await - .context("failed to execute `git fetch`")?; + .context("executing `git fetch`")?; let checkout_output = util::command::new_command("git") .arg("--git-dir") @@ -425,19 +387,14 @@ impl ExtensionBuilder { .current_dir(directory) .output() .await - .context("failed to execute `git checkout`")?; + .context("executing `git checkout`")?; if !checkout_output.status.success() { - if !fetch_output.status.success() { - bail!( - "failed to fetch revision {} in directory '{}'", - rev, - directory.display() - ); - } - bail!( - "failed to checkout revision {} in directory '{}': {}", - rev, - directory.display(), + anyhow::ensure!( + fetch_output.status.success(), + "failed to fetch revision {rev} in directory {directory:?}" + ); + anyhow::bail!( + "failed to checkout revision {rev} in directory {directory:?}: {}", String::from_utf8_lossy(&checkout_output.stderr) ); } @@ -450,27 +407,25 @@ impl ExtensionBuilder { .args(["--print", "target-libdir", "--target", RUST_TARGET]) .output() .await - .context("failed to run rustc")?; - if !rustc_output.status.success() { - bail!( - "failed to retrieve the `{RUST_TARGET}` target libdir: {}", - String::from_utf8_lossy(&rustc_output.stderr) - ); - } + .context("running rustc")?; + anyhow::ensure!( + rustc_output.status.success(), + "failed to retrieve the `{RUST_TARGET}` target libdir: {}", + String::from_utf8_lossy(&rustc_output.stderr) + ); let target_libdir = PathBuf::from(String::from_utf8(rustc_output.stdout)?.trim()); if target_libdir.exists() { return Ok(()); } - if which::which("rustup").is_err() { - bail!( - "the `{RUST_TARGET}` target is not installed, and `rustup` is not available to \ - install it. Add the target to your Rust toolchain (e.g. `targets = \ - [\"{RUST_TARGET}\"]` for Nix rust-overlay/fenix toolchains) or install it via \ - your package manager" - ); - } + anyhow::ensure!( + which::which("rustup").is_ok(), + "the `{RUST_TARGET}` target is not installed, and `rustup` is not available to \ + install it. Add the target to your Rust toolchain (e.g. `targets = \ + [\"{RUST_TARGET}\"]` for Nix rust-overlay/fenix toolchains) or install it via \ + your package manager" + ); let output = util::command::new_command("rustup") .args(["target", "add", RUST_TARGET]) @@ -478,13 +433,12 @@ impl ExtensionBuilder { .stdout(Stdio::inherit()) .output() .await - .context("failed to run `rustup target add`")?; - if !output.status.success() { - bail!( - "failed to install the `{RUST_TARGET}` target: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + .context("running `rustup target add`")?; + anyhow::ensure!( + output.status.success(), + "failed to install the `{RUST_TARGET}` target: {}", + String::from_utf8_lossy(&output.stderr) + ); Ok(()) } @@ -502,42 +456,69 @@ impl ExtensionBuilder { ); } - let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME { - format!("{WASI_SDK_URL}{asset_name}") - } else { - bail!("wasi-sdk is not available for platform {}", env::consts::OS); - }; - let wasi_sdk_dir = self.cache_dir.join("wasi-sdk"); let clang_path = wasi_sdk_clang_path(&wasi_sdk_dir); - log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display()); - - if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) { - return Ok(clang_path); + match self + .update_wasi_sdk_if_needed(&wasi_sdk_dir, &clang_path) + .await + { + Ok(()) => Ok(clang_path), + Err(error) => { + if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) { + let installed_version = installed_wasi_sdk_version(&wasi_sdk_dir) + .unwrap_or_else(|| "unknown".to_string()); + log::warn!( + "failed to install wasi-sdk {WASI_SDK_VERSION}.0, using cached wasi-sdk {installed_version} from {wasi_sdk_dir:?}: {error:#}" + ); + Ok(clang_path) + } else { + Err(error) + } + } } + } + + async fn update_wasi_sdk_if_needed( + &self, + wasi_sdk_dir: &Path, + clang_path: &Path, + ) -> Result<()> { + if installed_wasi_sdk_version(wasi_sdk_dir) == Some(format!("{WASI_SDK_VERSION}.0")) + && fs::metadata(clang_path).is_ok_and(|metadata| metadata.is_file()) + { + return Ok(()); + } + + let platform = WASI_SDK_PLATFORM.with_context(|| { + format!("wasi-sdk is not available for platform {}", env::consts::OS) + })?; + let url = format!( + "{WASI_SDK_URL}wasi-sdk-{WASI_SDK_VERSION}/wasi-sdk-{WASI_SDK_VERSION}.0-{platform}.tar.gz" + ); + + log::info!("downloading wasi-sdk to {wasi_sdk_dir:?}"); let tar_out_dir = self.cache_dir.join("wasi-sdk-temp"); - fs::remove_dir_all(&wasi_sdk_dir).ok(); fs::remove_dir_all(&tar_out_dir).ok(); - fs::create_dir_all(&tar_out_dir).context("failed to create extraction directory")?; + fs::create_dir_all(&tar_out_dir).context("creating extraction directory")?; let mut response = self.http.get(&url, AsyncBody::default(), true).await?; // Write the response to a temporary file let tar_gz_path = self.cache_dir.join("wasi-sdk.tar.gz"); let tar_gz_file = - fs::File::create(&tar_gz_path).context("failed to create temporary tar.gz file")?; + fs::File::create(&tar_gz_path).context("creating temporary tar.gz file")?; let response_body = response.body_mut(); let mut async_file = io::AllowStdIo::new(tar_gz_file); io::copy(response_body, &mut async_file) .await - .context("failed to stream response to file")?; + .context("streaming response to file")?; drop(async_file); - log::info!("un-tarring wasi-sdk to {}", tar_out_dir.display()); + log::info!("un-tarring wasi-sdk to {tar_out_dir:?}"); // Shell out to tar to extract the archive let tar_output = util::command::new_command("tar") @@ -547,14 +528,13 @@ impl ExtensionBuilder { .arg(&tar_out_dir) .output() .await - .context("failed to run tar")?; + .context("running tar")?; - if !tar_output.status.success() { - bail!( - "failed to extract wasi-sdk archive: {}", - String::from_utf8_lossy(&tar_output.stderr) - ); - } + anyhow::ensure!( + tar_output.status.success(), + "failed to extract wasi-sdk archive: {}", + String::from_utf8_lossy(&tar_output.stderr) + ); log::info!("finished downloading wasi-sdk"); @@ -564,12 +544,17 @@ impl ExtensionBuilder { let inner_dir = fs::read_dir(&tar_out_dir)? .next() .context("no content")? - .context("failed to read contents of extracted wasi archive directory")? + .context("reading contents of extracted wasi archive directory")? .path(); - fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?; + match fs::remove_dir_all(wasi_sdk_dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("removing outdated wasi-sdk"), + } + fs::rename(&inner_dir, wasi_sdk_dir).context("moving extracted wasi directory")?; fs::remove_dir_all(&tar_out_dir).ok(); - Ok(clang_path) + Ok(()) } // This was adapted from: @@ -665,7 +650,7 @@ async fn populate_defaults( let mut language_dir_entries = fs .read_dir(&languages_dir) .await - .context("failed to list languages dir")?; + .context("listing languages directory")?; while let Some(language_dir) = language_dir_entries.next().await { let language_dir = language_dir?; @@ -686,7 +671,7 @@ async fn populate_defaults( let mut theme_dir_entries = fs .read_dir(&themes_dir) .await - .context("failed to list themes dir")?; + .context("listing themes directory")?; while let Some(theme_path) = theme_dir_entries.next().await { let theme_path = theme_path?; @@ -705,7 +690,7 @@ async fn populate_defaults( let mut icon_theme_dir_entries = fs .read_dir(&icon_themes_dir) .await - .context("failed to list icon themes dir")?; + .context("listing icon themes directory")?; while let Some(icon_theme_path) = icon_theme_dir_entries.next().await { let icon_theme_path = icon_theme_path?; @@ -734,7 +719,7 @@ async fn populate_defaults( let mut grammar_dir_entries = fs .read_dir(&grammars_dir) .await - .context("failed to list grammars dir")?; + .context("listing grammars directory")?; while let Some(grammar_path) = grammar_dir_entries.next().await { let grammar_path = grammar_path?; @@ -801,6 +786,20 @@ fn wasi_sdk_clang_path(wasi_sdk_dir: &Path) -> PathBuf { clang_path } +fn installed_wasi_sdk_version(wasi_sdk_dir: &Path) -> Option { + let version_path = wasi_sdk_dir.join("VERSION"); + let contents = match fs::read_to_string(&version_path) { + Ok(contents) => contents, + Err(error) => { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!("failed to read wasi-sdk version from {version_path:?}: {error}"); + } + return None; + } + }; + contents.lines().next().map(str::to_string) +} + #[cfg(test)] mod tests { use std::{ @@ -815,7 +814,7 @@ mod tests { use crate::{ ExtensionManifest, ExtensionSnippets, - extension_builder::{file_newer_than_deps, populate_defaults}, + extension_builder::{file_newer_than_deps, installed_wasi_sdk_version, populate_defaults}, }; #[test] @@ -856,6 +855,29 @@ mod tests { ); } + #[test] + fn test_installed_wasi_sdk_version() { + let tmpdir = tempfile::tempdir().unwrap(); + let version_path = tmpdir.path().join("VERSION"); + + assert_eq!(installed_wasi_sdk_version(tmpdir.path()), None); + + std::fs::write(&version_path, "").unwrap(); + assert_eq!(installed_wasi_sdk_version(tmpdir.path()), None); + + std::fs::write(&version_path, "25.0\nwasi-libc: 574b88da4815\n").unwrap(); + assert_eq!( + installed_wasi_sdk_version(tmpdir.path()), + Some("25.0".to_string()) + ); + + std::fs::write(&version_path, "34.0\r\nwasi-libc: 2e6fb9d8ee0c\r\n").unwrap(); + assert_eq!( + installed_wasi_sdk_version(tmpdir.path()), + Some("34.0".to_string()) + ); + } + #[gpui::test] async fn test_snippet_location_is_kept(cx: &mut TestAppContext) { let fs = fs::FakeFs::new(cx.executor()); diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index 6190e86294..9050017091 100644 --- a/crates/extension/src/extension_host_proxy.rs +++ b/crates/extension/src/extension_host_proxy.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::Result; use fs::Fs; -use gpui::{App, Global, ReadGlobal, SharedString, Task}; +use gpui::{App, EntityId, Global, ReadGlobal, SharedString, Task}; use language::{BinaryStatus, LanguageLoader, LanguageMatcher, LanguageName}; use lsp::LanguageServerName; use parking_lot::RwLock; @@ -301,6 +301,7 @@ pub trait ExtensionLanguageServerProxy: Send + Sync + 'static { fn update_language_server_status( &self, + source: Option, language_server_id: LanguageServerName, status: BinaryStatus, ); @@ -335,6 +336,7 @@ impl ExtensionLanguageServerProxy for ExtensionHostProxy { fn update_language_server_status( &self, + source: Option, language_server_id: LanguageServerName, status: BinaryStatus, ) { @@ -342,7 +344,7 @@ impl ExtensionLanguageServerProxy for ExtensionHostProxy { return; }; - proxy.update_language_server_status(language_server_id, status) + proxy.update_language_server_status(source, language_server_id, status) } } diff --git a/crates/extension_api/Cargo.toml b/crates/extension_api/Cargo.toml index c766954952..377d794469 100644 --- a/crates/extension_api/Cargo.toml +++ b/crates/extension_api/Cargo.toml @@ -15,6 +15,8 @@ workspace = true [lib] path = "src/extension_api.rs" +test = false +doctest = false [dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index bf941a946c..d9c823e370 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -16,6 +16,7 @@ use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ExtensionManifest, ExtensionSnippets}; use http_client::Url; use language::LanguageConfig; +use language::QueryFile; use reqwest_client::ReqwestClient; use settings_content::SemanticTokenRules; use snippet_provider::file_to_snippets; @@ -24,6 +25,28 @@ use task::TaskTemplates; use tokio::process::Command; use tree_sitter::{Language, Query, WasmStore}; +struct TestingContext { + known_resources: BTreeSet, +} + +impl TestingContext { + fn new(manifest: &ExtensionManifest, extension_path: &Path) -> Self { + let known_resources = manifest + .snippets + .as_ref() + .map(ExtensionSnippets::paths) + .into_iter() + .flatten() + .map(|relative_path| extension_path.join(relative_path)) + .collect(); + Self { known_resources } + } + + fn is_known_resource(&self, path: &Path) -> bool { + self.known_resources.contains(path) + } +} + #[derive(Parser, Debug)] #[command(name = "zed-extension")] struct Args { @@ -43,7 +66,7 @@ async fn main() -> Result<()> { env_logger::init(); let args = Args::parse(); - let fs = Arc::new(RealFs::new(None, gpui_platform::background_executor())); + let fs = RealFs::new(None, gpui_platform::background_executor()); let engine = wasmtime::Engine::default(); let mut wasm_store = WasmStore::new(&engine)?; @@ -97,8 +120,9 @@ async fn main() -> Result<()> { let extension_provides = manifest.provides(); validate_extension_features(&extension_provides)?; + let cx = TestingContext::new(&manifest, &extension_path); let grammars = test_grammars(&manifest, &extension_path, &mut wasm_store)?; - test_languages(&manifest, &extension_path, &grammars)?; + test_languages(&manifest, &extension_path, &grammars, &cx)?; test_themes(&manifest, &extension_path, fs.clone()).await?; test_snippets(&manifest, &extension_path, fs.clone()).await?; test_debug_adapter_schemas(&manifest, &extension_path, fs.clone()).await?; @@ -514,6 +538,7 @@ fn test_languages( manifest: &ExtensionManifest, extension_path: &Path, grammars: &HashMap, + context: &TestingContext, ) -> Result<()> { for relative_language_dir in &manifest.languages { let language_dir = extension_path.join(relative_language_dir); @@ -561,19 +586,26 @@ fn test_languages( ) })?; } - _ if file_name.ends_with(".scm") => { - let grammar = grammar.with_context(|| { - format! { - "language {} provides query {} but no grammar", - config.name, - file_path.display() - } - })?; + _ => { + if let Ok(_query) = file_name.parse::() { + let grammar = grammar.with_context(|| { + format! { + "language {} provides query {} but no grammar", + config.name, + file_path.display() + } + })?; - let query_source = fs::read_to_string(&file_path)?; - let _query = Query::new(grammar, &query_source)?; + let query_source = fs::read_to_string(&file_path)?; + let _query = Query::new(grammar, &query_source)?; + } else if file_name.ends_with(".scm") { + bail!("query {file_name} is not supported by Zed and should be removed") + } else if !context.is_known_resource(&file_path) { + bail!( + "'{file_name}' is not a supported file in a language directory and should be removed" + ) + } } - _ => {} } } diff --git a/crates/extension_host/benches/extension_compilation_benchmark.rs b/crates/extension_host/benches/extension_compilation_benchmark.rs index e8abdffbd8..73fe3fd246 100644 --- a/crates/extension_host/benches/extension_compilation_benchmark.rs +++ b/crates/extension_host/benches/extension_compilation_benchmark.rs @@ -24,11 +24,7 @@ fn extension_benchmarks(c: &mut Criterion) { let mut group = c.benchmark_group("load"); let mut manifest = manifest(); - let wasm_bytes = wasm_bytes( - &cx, - &mut manifest, - Arc::new(RealFs::new(None, cx.executor())), - ); + let wasm_bytes = wasm_bytes(&cx, &mut manifest, RealFs::new(None, cx.executor())); let manifest = Arc::new(manifest); let extensions_dir = TempTree::new(json!({ "installed": {}, @@ -105,7 +101,7 @@ fn wasm_host(cx: &TestAppContext, extensions_dir: &TempTree) -> Arc { }); let extensions_dir = extensions_dir.path().canonicalize().unwrap(); let work_dir = extensions_dir.join("work"); - let fs = Arc::new(RealFs::new(None, cx.executor())); + let fs = RealFs::new(None, cx.executor()); cx.update(|cx| { WasmHost::new( diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index a7b377576f..36dd2943c4 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -1797,7 +1797,7 @@ impl ExtensionStore { let config = fs.load(&language_config_path).await.with_context(|| { format!("loading language config from {language_config_path:?}") })?; - ::toml::from_str::(&config).map_err(anyhow::Error::from) + LanguageConfig::from_toml(&config).map_err(anyhow::Error::from) } }; let query_files = async { @@ -2335,7 +2335,7 @@ async fn load_plugin_language( let config_path = language_path.join(LanguageConfig::FILE_NAME); async move { let contents = fs.load(&config_path).await?; - toml::from_str::(&contents).map_err(anyhow::Error::from) + LanguageConfig::from_toml(&contents).map_err(anyhow::Error::from) } }; let context_provider = { diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index e39a57fb1a..6fe0650788 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -22,7 +22,7 @@ use extension::{ }; use fs::{FakeFs, Fs, RealFs, RemoveOptions}; use futures::{AsyncReadExt, FutureExt, StreamExt, io::BufReader}; -use gpui::{AppContext as _, BackgroundExecutor, Entity, TaskExt, TestAppContext}; +use gpui::{AppContext as _, BackgroundExecutor, Entity, EntityId, TaskExt, TestAppContext}; use http_client::{FakeHttpClient, Response}; use language::{ BinaryStatus, LanguageConfig, LanguageMatcher, LanguageName, LanguageRegistry, QueryFiles, @@ -802,7 +802,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { let test_extension_id = "test-extension"; let test_extension_dir = root_dir.join("extensions").join(test_extension_id); - let fs = Arc::new(RealFs::new(None, cx.executor())); + let fs = RealFs::new(None, cx.executor()); let extensions_tree = TempTree::new(json!({ "installed": {}, "work": {} @@ -826,6 +826,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { let theme_registry = Arc::new(ThemeRegistry::new(Box::new(()))); theme_extension::init(proxy.clone(), theme_registry.clone(), cx.executor()); let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let lsp_store_id = project.read_with(cx, |project, _| project.lsp_store().entity_id()); language_extension::init( LspAccess::ViaLspStore( project @@ -837,7 +838,8 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { ); let node_runtime = NodeRuntime::unavailable(); - let mut status_updates = language_registry.language_server_binary_statuses(); + let (mut status_updates, _status_updates_subscription) = + language_registry.language_server_binary_statuses(); struct FakeLanguageServerVersion { version: String, @@ -1093,18 +1095,25 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { ], [ ( + Some(lsp_store_id), LanguageServerName::new_static("gleam"), BinaryStatus::Starting ), ( + Some(lsp_store_id), LanguageServerName::new_static("gleam"), BinaryStatus::CheckingForUpdate ), ( + Some(lsp_store_id), LanguageServerName::new_static("gleam"), BinaryStatus::Downloading ), - (LanguageServerName::new_static("gleam"), BinaryStatus::None) + ( + Some(lsp_store_id), + LanguageServerName::new_static("gleam"), + BinaryStatus::None + ) ] ); @@ -4107,6 +4116,7 @@ impl Extension for FakeExtension { _language_server_id: LanguageServerName, _language_name: LanguageName, _worktree: Arc, + _language_server_status_source: EntityId, ) -> anyhow::Result { anyhow::bail!("not supported by FakeExtension") } @@ -4116,6 +4126,7 @@ impl Extension for FakeExtension { _language_server_id: LanguageServerName, _language_name: LanguageName, _worktree: Arc, + _language_server_status_source: EntityId, ) -> anyhow::Result> { anyhow::bail!("not supported by FakeExtension") } @@ -4124,6 +4135,7 @@ impl Extension for FakeExtension { &self, _language_server_id: LanguageServerName, _worktree: Arc, + _language_server_status_source: EntityId, ) -> anyhow::Result> { anyhow::bail!("not supported by FakeExtension") } @@ -4132,6 +4144,7 @@ impl Extension for FakeExtension { &self, _language_server_id: LanguageServerName, _worktree: Arc, + _language_server_status_source: EntityId, ) -> anyhow::Result> { anyhow::bail!("not supported by FakeExtension") } @@ -4140,6 +4153,7 @@ impl Extension for FakeExtension { &self, _language_server_id: LanguageServerName, _worktree: Arc, + _language_server_status_source: EntityId, ) -> anyhow::Result> { anyhow::bail!("not supported by FakeExtension") } @@ -4149,6 +4163,7 @@ impl Extension for FakeExtension { _language_server_id: LanguageServerName, _target_language_server_id: LanguageServerName, _worktree: Arc, + _language_server_status_source: EntityId, ) -> anyhow::Result> { anyhow::bail!("not supported by FakeExtension") } @@ -4158,6 +4173,7 @@ impl Extension for FakeExtension { _language_server_id: LanguageServerName, _target_language_server_id: LanguageServerName, _worktree: Arc, + _language_server_status_source: EntityId, ) -> anyhow::Result> { anyhow::bail!("not supported by FakeExtension") } diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 381ffc42ae..82fdc75101 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -272,7 +272,7 @@ impl HeadlessExtensionStore { for language_path in &manifest.languages { let config_path = load_dir.join(language_path).join(LanguageConfig::FILE_NAME); let config = fs.load(&config_path).await?; - let mut config = ::toml::from_str::(&config)?; + let mut config = LanguageConfig::from_toml(&config)?; config.grammar = None; languages.push((config.name.clone(), config)); } diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index c04445f4dc..857d33e0de 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -21,7 +21,7 @@ use futures::{ }, future::BoxFuture, }; -use gpui::{App, AsyncApp, BackgroundExecutor, Task}; +use gpui::{App, AsyncApp, BackgroundExecutor, EntityId, Task}; use http_client::HttpClient; use language::LanguageName; use lsp::LanguageServerName; @@ -90,8 +90,9 @@ impl extension::Extension for WasmExtension { language_server_id: LanguageServerName, language_name: LanguageName, worktree: Arc, + status_source: EntityId, ) -> Result { - self.call(|extension, store| { + self.call_with_language_server_status_source(status_source, move |extension, store| { async move { let resource = store.data_mut().table.push(worktree)?; let command = extension @@ -116,8 +117,9 @@ impl extension::Extension for WasmExtension { language_server_id: LanguageServerName, language_name: LanguageName, worktree: Arc, + status_source: EntityId, ) -> Result> { - self.call(|extension, store| { + self.call_with_language_server_status_source(status_source, move |extension, store| { async move { let resource = store.data_mut().table.push(worktree)?; let options = extension @@ -140,8 +142,9 @@ impl extension::Extension for WasmExtension { &self, language_server_id: LanguageServerName, worktree: Arc, + status_source: EntityId, ) -> Result> { - self.call(|extension, store| { + self.call_with_language_server_status_source(status_source, move |extension, store| { async move { let resource = store.data_mut().table.push(worktree)?; let options = extension @@ -163,8 +166,9 @@ impl extension::Extension for WasmExtension { &self, language_server_id: LanguageServerName, worktree: Arc, + status_source: EntityId, ) -> Result> { - self.call(|extension, store| { + self.call_with_language_server_status_source(status_source, move |extension, store| { async move { let resource = store.data_mut().table.push(worktree)?; extension @@ -185,8 +189,9 @@ impl extension::Extension for WasmExtension { &self, language_server_id: LanguageServerName, worktree: Arc, + status_source: EntityId, ) -> Result> { - self.call(|extension, store| { + self.call_with_language_server_status_source(status_source, move |extension, store| { async move { let resource = store.data_mut().table.push(worktree)?; extension @@ -208,8 +213,9 @@ impl extension::Extension for WasmExtension { language_server_id: LanguageServerName, target_language_server_id: LanguageServerName, worktree: Arc, + status_source: EntityId, ) -> Result> { - self.call(|extension, store| { + self.call_with_language_server_status_source(status_source, move |extension, store| { async move { let resource = store.data_mut().table.push(worktree)?; let options = extension @@ -233,8 +239,9 @@ impl extension::Extension for WasmExtension { language_server_id: LanguageServerName, target_language_server_id: LanguageServerName, worktree: Arc, + status_source: EntityId, ) -> Result> { - self.call(|extension, store| { + self.call_with_language_server_status_source(status_source, move |extension, store| { async move { let resource = store.data_mut().table.push(worktree)?; let options = extension @@ -538,6 +545,7 @@ pub struct WasmState { ctx: WasiCtx, pub host: Arc, pub(crate) capability_granter: CapabilityGranter, + pub(crate) language_server_status_source: Option, } type MainThreadCall = Box FnOnce(&'a mut AsyncApp) -> LocalBoxFuture<'a, ()>>; @@ -671,6 +679,7 @@ impl WasmHost { this.granted_capabilities.clone(), manifest.clone(), ), + language_server_status_source: None, }, ); // Store will yield after 1 tick, and get a new deadline of 1 tick after each yield. @@ -872,6 +881,34 @@ impl WasmExtension { .with_context(|| format!("loading wasm extension: {}", manifest.id)) } + async fn call_with_language_server_status_source( + &self, + source: EntityId, + f: Fn, + ) -> Result + where + T: 'static + Send, + Fn: 'static + + Send + + for<'a> FnOnce(&'a mut Extension, &'a mut Store) -> BoxFuture<'a, T>, + { + self.call(move |extension, store| { + async move { + debug_assert!(store.data().language_server_status_source.is_none()); + + // The installation-status WIT methods do not receive a worktree, so expose the + // source through WasmState for the duration of this serialized extension call. + // Clear it before returning so failed calls cannot affect the next invocation. + store.data_mut().language_server_status_source = Some(source); + let result = f(extension, store).await; + store.data_mut().language_server_status_source = None; + result + } + .boxed() + }) + .await + } + pub async fn call(&self, f: Fn) -> Result where T: 'static + Send, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs index 57dfa5b5af..582e78f813 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs @@ -148,9 +148,11 @@ impl ExtensionImports for WasmState { LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; - self.host - .proxy - .update_language_server_status(lsp::LanguageServerName(server_name.into()), status); + self.host.proxy.update_language_server_status( + self.language_server_status_source, + lsp::LanguageServerName(server_name.into()), + status, + ); Ok(()) } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index f2b9adf5cf..d7df044773 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -507,9 +507,11 @@ impl ExtensionImports for WasmState { LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; - self.host - .proxy - .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); + self.host.proxy.update_language_server_status( + self.language_server_status_source, + ::lsp::LanguageServerName(server_name.into()), + status, + ); Ok(()) } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index 4fed1b313a..57d5e808f1 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -1064,9 +1064,11 @@ impl ExtensionImports for WasmState { LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; - self.host - .proxy - .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); + self.host.proxy.update_language_server_status( + self.language_server_status_source, + ::lsp::LanguageServerName(server_name.into()), + status, + ); Ok(()) } diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index c8581ec61c..b574edfa97 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/extensions_ui.rs" +doctest = false [dependencies] anyhow.workspace = true @@ -27,6 +28,7 @@ git.workspace = true gpui.workspace = true language.workspace = true log.workspace = true +markdown.workspace = true num-format.workspace = true picker.workspace = true project.workspace = true @@ -45,7 +47,16 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +paths.workspace = true +project = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +workspace = { workspace = true, features = ["test-support"] } [package.metadata.cargo-shear] ignored = ["component"] diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index 9400a197b2..6829d3e6d1 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -3,14 +3,19 @@ use std::sync::{Arc, OnceLock}; use db::kvp::KeyValueStore; use editor::Editor; -use extension_host::ExtensionStore; -use gpui::{AppContext as _, Context, Entity, SharedString, Window}; -use language::Buffer; +use extension_host::{ExtensionSettings, ExtensionStore}; +use gpui::{App, AppContext as _, Context, Entity, SharedString}; +use language::{Buffer, PLAIN_TEXT}; +use markdown::{Markdown, MarkdownElement}; +use project::lsp_store::LspStoreEvent; +use settings::Settings as _; use ui::prelude::*; use util::ResultExt; use util::rel_path::RelPath; -use workspace::notifications::simple_message_notification::MessageNotification; -use workspace::{Workspace, notifications::NotificationId}; +use workspace::notifications::{ + NotificationId, markdown_style, simple_message_notification::MessageNotification, +}; +use workspace::{AppState, Event as WorkspaceEvent, Workspace}; const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("astro", &["astro"]), @@ -81,6 +86,89 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("zig", &["zig"]), ]; +struct LanguageSuggestion { + extension_id: &'static str, + languages: &'static [&'static str], + title: &'static str, + description: &'static str, + docs_url: &'static str, + install_message: &'static str, +} + +const SUGGESTIONS_BY_LANGUAGE: &[LanguageSuggestion] = &[LanguageSuggestion { + extension_id: "emmet", + languages: &[ + "Angular", + "Blade", + "CSS", + "Django", + "ERB", + "Elixir", + "HEEx", + "HTML", + "HTML+ERB", + "JavaScript", + "Jinja2", + "LESS", + "Liquid", + "Nunjucks", + "PHP", + "SCSS", + "Statamic Antlers", + "TSX", + "Twig", + "Vue.js", + ], + title: "Emmet is available for this file", + description: "Emmet expands abbreviations such as `ul>li*3` into HTML and `m10` into CSS.", + docs_url: "https://zed.dev/docs/languages/emmet", + install_message: "Install Emmet", +}]; + +struct ExtensionSuggestionNotification; + +pub(crate) fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, window, cx| { + if window.is_none() { + return; + } + let lsp_store = workspace.project().read(cx).lsp_store(); + cx.subscribe(&lsp_store, |workspace, _, event, cx| { + if let LspStoreEvent::LanguageDetected { buffer, .. } = event { + suggest_for_buffer(workspace, buffer.clone(), cx); + } + }) + .detach(); + cx.subscribe_self(|workspace, event, cx| { + if let WorkspaceEvent::ItemAdded { item } = event + && let Some(editor) = item.downcast::() + && let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() + { + suggest_for_buffer(workspace, buffer, cx); + } + }) + .detach(); + cx.subscribe( + &ExtensionStore::global(cx), + |workspace, extension_store, event, cx| { + if let extension_host::Event::ExtensionsUpdated = event { + let installed = extension_store + .read(cx) + .installed_extensions() + .keys() + .map(|extension_id| notification_id(extension_id)) + .collect::>(); + for installed in installed { + workspace.dismiss_notification(&installed, cx); + } + } + }, + ) + .detach(); + }) + .detach(); +} + fn suggested_extensions() -> &'static HashMap<&'static str, Arc> { static SUGGESTIONS_BY_PATH_SUFFIX: OnceLock>> = OnceLock::new(); SUGGESTIONS_BY_PATH_SUFFIX.get_or_init(|| { @@ -132,85 +220,198 @@ fn suggested_extension(path: &RelPath) -> Option { } fn language_extension_key(extension_id: &str) -> String { - format!("{}_extension_suggest", extension_id) + format!("{extension_id}_extension_suggest") } -pub(crate) fn suggest(buffer: Entity, window: &mut Window, cx: &mut Context) { - let Some(file) = buffer.read(cx).file().cloned() else { - return; - }; +fn suggestion_dismissed(extension_id: &str, cx: &App) -> bool { + KeyValueStore::global(cx) + .read_kvp(&language_extension_key(extension_id)) + .log_err() + != Some(None) +} - let Some(SuggestedExtension { - extension_id, - file_name_or_extension, - }) = suggested_extension(file.path()) - else { - return; - }; - - let key = language_extension_key(&extension_id); +fn dismiss_suggestion(extension_id: &str, cx: &mut App) { + let key = language_extension_key(extension_id); let kvp = KeyValueStore::global(cx); - let Ok(None) = kvp.read_kvp(&key) else { + db::write_and_log(cx, move || async move { + kvp.write_kvp(key, "dismissed".to_string()).await + }); + + let notification_id = notification_id(extension_id); + let workspaces = AppState::global(cx) + .workspace_store + .read(cx) + .workspaces() + .cloned() + .collect::>(); + for workspace in workspaces { + workspace + .update(cx, |workspace, cx| { + workspace.dismiss_notification(¬ification_id, cx); + }) + .ok(); + } +} + +fn notification_id(extension_id: &str) -> NotificationId { + NotificationId::composite::(SharedString::from(extension_id)) +} + +fn suggest_for_buffer( + workspace: &mut Workspace, + buffer: Entity, + cx: &mut Context, +) { + if !is_active_in_some_pane(workspace, &buffer, cx) { + return; + } + + let buffer = buffer.read(cx); + let Some(file) = buffer.file().cloned() else { return; }; + let language_name = buffer + .language() + .filter(|language| **language != *PLAIN_TEXT) + .map(|language| language.name()); - cx.on_next_frame(window, move |workspace, _, cx| { - let Some(editor) = workspace.active_item_as::(cx) else { - return; - }; - - if editor.read(cx).buffer().read(cx).as_singleton().as_ref() != Some(&buffer) { - return; + match language_name { + Some(language_name) => { + let Some(suggestion) = SUGGESTIONS_BY_LANGUAGE + .iter() + .find(|suggestion| suggestion.languages.contains(&language_name.as_ref())) + else { + return; + }; + show_suggestion(workspace, suggestion.extension_id, cx, |cx| { + let markdown = cx.new(|cx| { + Markdown::new( + SharedString::new_static(suggestion.description), + None, + None, + cx, + ) + }); + MessageNotification::new_from_builder(cx, move |window, cx| { + MarkdownElement::new(markdown.clone(), markdown_style(window, cx)) + .text_size(TextSize::Default.rems(cx)) + .into_any_element() + }) + .with_title(suggestion.title) + .more_info_message("Learn more") + .more_info_url(suggestion.docs_url) + .primary_message(suggestion.install_message) + .secondary_message("Don't show again") + }); } - - struct ExtensionSuggestionNotification; - - let notification_id = NotificationId::composite::( - SharedString::from(extension_id.clone()), - ); - - workspace.show_notification(notification_id, cx, |cx| { - cx.new(move |cx| { + None => { + let language_exists = workspace + .project() + .read(cx) + .languages() + .language_for_file(&file, Some(buffer.as_rope()), cx) + .is_some(); + if language_exists { + return; + } + let Some(SuggestedExtension { + extension_id, + file_name_or_extension, + }) = suggested_extension(file.path()) + else { + return; + }; + show_suggestion(workspace, &extension_id, cx, |cx| { MessageNotification::new( format!( - "Do you want to install the recommended '{}' extension for '{}' files?", - extension_id, file_name_or_extension + "Do you want to install the recommended '{extension_id}' extension for '{file_name_or_extension}' files?" ), cx, ) .primary_message("Yes, install extension") + .secondary_message("No, don't install it") + }); + } + } +} + +fn is_active_in_some_pane(workspace: &Workspace, buffer: &Entity, cx: &App) -> bool { + workspace.panes().iter().any(|pane| { + pane.read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .is_some_and(|editor| { + editor.read(cx).buffer().read(cx).as_singleton().as_ref() == Some(buffer) + }) + }) +} + +fn show_suggestion( + workspace: &mut Workspace, + extension_id: &str, + cx: &mut Context, + build_notification: impl FnOnce(&mut Context) -> MessageNotification, +) { + let notification_id = notification_id(extension_id); + if workspace.has_notification(¬ification_id) { + return; + } + + let extension_store = ExtensionStore::global(cx); + let extension_store = extension_store.read(cx); + if extension_store + .installed_extensions() + .contains_key(extension_id) + || extension_store + .outstanding_operations() + .contains_key(extension_id) + || ExtensionSettings::get_global(cx) + .auto_install_extensions + .get(extension_id) + == Some(&true) + || suggestion_dismissed(extension_id, cx) + { + return; + } + + let extension_id = Arc::::from(extension_id); + workspace.show_notification(notification_id, cx, |cx| { + cx.new(|cx| { + build_notification(cx) .primary_icon(IconName::Check) .primary_icon_color(Color::Success) .primary_on_click({ let extension_id = extension_id.clone(); move |_window, cx| { - let extension_id = extension_id.clone(); - let extension_store = ExtensionStore::global(cx); - extension_store.update(cx, move |store, cx| { - store.install_latest_extension(extension_id, cx); + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(extension_id.clone(), cx); }); } }) - .secondary_message("No, don't install it") .secondary_icon(IconName::Close) .secondary_icon_color(Color::Error) - .secondary_on_click(move |_window, cx| { - let key = language_extension_key(&extension_id); - let kvp = KeyValueStore::global(cx); - cx.background_spawn(async move { - kvp.write_kvp(key, "dismissed".to_string()).await.log_err() - }) - .detach(); - }) - }) - }); - }) + .secondary_on_click(move |_window, cx| dismiss_suggestion(&extension_id, cx)) + }) + }); } #[cfg(test)] mod tests { use super::*; + use extension::ExtensionHostProxy; + use extension_host::RELOAD_DEBOUNCE_DURATION; + use fs::RemoveOptions; + use gpui::{AnyView, TestAppContext, VisualTestContext}; + use http_client::HttpClient as _; + use language::{Language, LanguageConfig, LanguageMatcher}; + use project::{Project, WorktreeId}; + use serde_json::json; + use settings::SettingsStore; + use util::path; use util::rel_path::rel_path; + use workspace::{AppState, SplitDirection}; + + const EMMET_EXTENSION_ID: &str = "emmet"; #[test] pub fn test_suggested_extension() { @@ -250,4 +451,636 @@ mod tests { }) ); } + + #[gpui::test] + async fn test_language_suggestion_for_supported_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_language_suggestion_when_language_is_detected_after_editor( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let (project, buffer) = workspace.read_with(cx, |workspace, cx| { + let editor = workspace.active_item_as::(cx).unwrap(); + let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap(); + (workspace.project().clone(), buffer) + }); + let html = project + .read_with(cx, |project, _| { + project.languages().language_for_name("HTML") + }) + .await + .unwrap(); + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, html, cx); + }); + cx.run_until_parked(); + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_for_buffer_without_active_editor(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + let worktree_id = worktree_id(&workspace, cx); + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("index.html")), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_language_suggestion_for_unsupported_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_suggestion_after_dismissal(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_dismissal_applies_to_already_open_workspace(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + cx.update(|_, cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + workspace.dismiss_notification(¬ification_id(EMMET_EXTENSION_ID), cx) + }); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_suggestion_when_installed(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_dont_show_again_dismisses_card_in_all_workspaces(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let (other_workspace, cx) = open_test_workspace(&app_state, cx).await; + open_file(&workspace, "index.html", cx).await; + open_file(&other_workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + assert_eq!( + notification_ids(&other_workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + cx.update(|_, cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + assert_eq!(notification_ids(&other_workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_visible_suggestion_survives_unrelated_extension_install(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let shown = notification_views(&workspace, cx); + assert_eq!(shown.len(), 1); + + install_extension_on_disk("gleam", &app_state, cx).await; + + let still_shown = notification_views(&workspace, cx); + assert_eq!(still_shown.len(), 1); + assert_eq!(still_shown[0].entity_id(), shown[0].entity_id()); + } + + #[gpui::test] + async fn test_visible_suggestion_is_dismissed_when_extension_gets_installed( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + install_emmet_on_disk(&app_state, cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_suggestion_returns_after_uninstall(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + app_state + .fs + .remove_dir( + &paths::extensions_dir() + .join("installed") + .join(EMMET_EXTENSION_ID), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); + cx.run_until_parked(); + cx.update(|_, cx| { + assert_eq!( + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .keys() + .collect::>(), + Vec::<&Arc>::new() + ); + }); + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_while_install_is_pending(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .client + .http_client() + .as_fake() + .replace_handler(|_, _| std::future::pending()); + cx.update(|cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(Arc::from(EMMET_EXTENSION_ID), cx); + }); + }); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + ExtensionStore::global(cx) + .read(cx) + .outstanding_operations() + .keys() + .collect::>(), + vec![&Arc::from(EMMET_EXTENSION_ID)] + ); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_suggestion_returns_after_failed_install(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(Arc::from(EMMET_EXTENSION_ID), cx); + }); + }); + cx.run_until_parked(); + cx.update(|cx| { + let store = ExtensionStore::global(cx); + let store = store.read(cx); + assert_eq!(store.outstanding_operations().len(), 0); + assert_eq!(store.installed_extensions().len(), 0); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_suggestion_returns_after_uninstall_through_store(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let uninstall = cx.update(|_, cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.uninstall_extension(Arc::from(EMMET_EXTENSION_ID), cx) + }) + }); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION * 4); + uninstall.await.unwrap(); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_when_auto_install_is_enabled(cx: &mut TestAppContext) { + assert_eq!( + suggestions_with_auto_install_setting(true, cx).await, + Vec::new() + ); + } + + #[gpui::test] + async fn test_suggestion_when_auto_install_is_disabled(cx: &mut TestAppContext) { + assert_eq!( + suggestions_with_auto_install_setting(false, cx).await, + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_visible_suggestion_is_not_rebuilt_by_next_candidate(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let shown = notification_views(&workspace, cx); + assert_eq!(shown.len(), 1); + + open_file(&workspace, "other.html", cx).await; + + let still_shown = notification_views(&workspace, cx); + assert_eq!(still_shown.len(), 1); + assert_eq!(still_shown[0].entity_id(), shown[0].entity_id()); + } + + #[gpui::test] + async fn test_closed_suggestion_returns_for_next_candidate(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let notification = notification_views(&workspace, cx) + .pop() + .unwrap() + .downcast::() + .ok() + .unwrap(); + notification.update(cx, |notification, cx| notification.dismiss(cx)); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + assert!(!cx.update(|_, cx| suggestion_dismissed(EMMET_EXTENSION_ID, cx))); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_suggestion_for_item_added_to_inactive_pane(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + let first_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let second_pane = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(first_pane.clone(), SplitDirection::Right, window, cx) + }); + cx.run_until_parked(); + let active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let inactive_pane = if active_pane == first_pane { + second_pane + } else { + first_pane + }; + + let worktree_id = worktree_id(&workspace, cx); + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("index.html")), cx) + }) + .await + .unwrap(); + inactive_pane.update_in(cx, |pane, window, cx| { + let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + pane.add_item(Box::new(editor), false, false, None, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()), + active_pane + ); + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_file_suggestion_for_unknown_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.gleam", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id("gleam")] + ); + } + + #[gpui::test] + async fn test_file_suggestion_for_untitled_buffer_saved_as_unknown_language( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + + let buffer = project + .update(cx, |project, cx| project.create_buffer(None, false, cx)) + .await + .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + let editor = + cx.new(|cx| Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); + }); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let worktree_id = worktree_id(&workspace, cx); + project + .update(cx, |project, cx| { + project.save_buffer_as(buffer, (worktree_id, rel_path("new.gleam")).into(), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id("gleam")] + ); + } + + #[gpui::test] + async fn test_no_file_suggestion_while_language_is_loading(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let gleam = test_language_config("Gleam", "gleam"); + workspace.read_with(cx, |workspace, cx| { + workspace.project().read(cx).languages().register_language( + gleam.name, + None, + gleam.matcher, + false, + None, + Arc::new(|| Box::pin(std::future::pending())), + ); + }); + + open_file(&workspace, "main.gleam", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let app_state = AppState::test(cx); + AppState::set_global(app_state.clone(), cx); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |content| { + content + .extension + .auto_install_extensions + .insert(Arc::from("html"), false); + }); + }); + cx.set_global(db::AppDatabase::test_new()); + release_channel::init(semver::Version::new(0, 0, 0), cx); + extension::init(cx); + extension_host::init( + Arc::new(ExtensionHostProxy::new()), + app_state.fs.clone(), + app_state.client.clone(), + app_state.node_runtime.clone(), + cx, + ); + editor::init(cx); + crate::init(cx); + app_state + }) + } + + async fn install_emmet_on_disk(app_state: &Arc, cx: &mut TestAppContext) { + install_extension_on_disk(EMMET_EXTENSION_ID, app_state, cx).await; + } + + async fn install_extension_on_disk( + extension_id: &str, + app_state: &Arc, + cx: &mut TestAppContext, + ) { + app_state + .fs + .as_fake() + .insert_tree( + paths::extensions_dir().join("installed").join(extension_id), + json!({ + "extension.toml": format!( + "id = \"{extension_id}\"\nname = \"{extension_id}\"\nversion = \"0.0.1\"\nschema_version = 1\n" + ), + }), + ) + .await; + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); + cx.run_until_parked(); + cx.update(|cx| { + assert!( + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .contains_key(extension_id) + ); + }); + } + + async fn open_test_workspace<'a>( + app_state: &Arc, + cx: &'a mut TestAppContext, + ) -> (Entity, &'a mut VisualTestContext) { + let fs = app_state.fs.as_fake(); + fs.insert_tree( + path!("/root"), + json!({ + "index.html": "
", + "other.html": "", + "main.rs": "fn main() {}", + "main.gleam": "pub fn main() {}", + }), + ) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + project.read_with(cx, |project, _| { + project + .languages() + .add(Arc::new(test_language("HTML", "html"))); + project + .languages() + .add(Arc::new(test_language("Rust", "rs"))); + }); + cx.add_window_view(|window, cx| { + Workspace::new(None, project, app_state.clone(), window, cx) + }) + } + + async fn open_file(workspace: &Entity, file_name: &str, cx: &mut VisualTestContext) { + let worktree_id = worktree_id(workspace, cx); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path(file_name)), None, true, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + } + + fn worktree_id(workspace: &Entity, cx: &VisualTestContext) -> WorktreeId { + workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id() + }) + } + + async fn suggestions_with_auto_install_setting( + auto_install: bool, + cx: &mut TestAppContext, + ) -> Vec { + let app_state = init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |content| { + content + .extension + .auto_install_extensions + .insert(Arc::from(EMMET_EXTENSION_ID), auto_install); + }); + }); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + notification_ids(&workspace, cx) + } + + fn notification_ids( + workspace: &Entity, + cx: &VisualTestContext, + ) -> Vec { + workspace.read_with(cx, |workspace, _| workspace.notification_ids()) + } + + fn notification_views(workspace: &Entity, cx: &VisualTestContext) -> Vec { + workspace.read_with(cx, |workspace, _| workspace.notification_views()) + } + + fn test_language(name: &'static str, path_suffix: &str) -> Language { + Language::new(test_language_config(name, path_suffix), None) + } + + fn test_language_config(name: &'static str, path_suffix: &str) -> LanguageConfig { + LanguageConfig { + name: name.into(), + matcher: LanguageMatcher { + path_suffixes: vec![path_suffix.to_string()], + ..LanguageMatcher::default() + } + .into(), + ..LanguageConfig::default() + } + } } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index dbae10cbdd..f7b5744e20 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -109,11 +109,12 @@ pub fn init(cx: &mut App) { update_rebuild_dev_extension_visibility(&store, cx); }) .detach(); + extension_suggest::init(cx); - cx.observe_new(move |workspace: &mut Workspace, window, cx| { - let Some(window) = window else { + cx.observe_new(move |workspace: &mut Workspace, window, _cx| { + if window.is_none() { return; - }; + } workspace .register_action( move |workspace, action: &zed_actions::Extensions, window, cx| { @@ -281,13 +282,6 @@ pub fn init(cx: &mut App) { } } }); - - cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| { - if let project::Event::LanguageNotFound(buffer) = event { - extension_suggest::suggest(buffer.clone(), window, cx); - } - }) - .detach(); }) .detach(); } diff --git a/crates/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml index 31ec5ec0da..b6f564f9bd 100644 --- a/crates/feature_flags/Cargo.toml +++ b/crates/feature_flags/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/feature_flags.rs" +doctest = false [dependencies] collections.workspace = true diff --git a/crates/feature_flags_macros/Cargo.toml b/crates/feature_flags_macros/Cargo.toml index 794160e683..aebfa5f7c1 100644 --- a/crates/feature_flags_macros/Cargo.toml +++ b/crates/feature_flags_macros/Cargo.toml @@ -8,6 +8,7 @@ license = "GPL-3.0-or-later" [lib] path = "src/feature_flags_macros.rs" proc-macro = true +doctest = false [lints] workspace = true diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index 981fc4ea41..0938c07435 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/feedback.rs" +test = false +doctest = false [features] test-support = [] diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 6d0184325d..18d0728d46 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -7,7 +7,7 @@ use menu::{Cancel, Confirm, SelectNext, SelectPrevious}; use pretty_assertions::{assert_eq, assert_matches}; use project::{FS_WATCH_LATENCY, RemoveOptions}; use serde_json::json; -use settings::SettingsStore; +use settings::{SettingsStore, SplicingVec}; use util::{path, rel_path::rel_path}; use workspace::{ AppState, CloseActiveItem, Item, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, @@ -1003,10 +1003,10 @@ async fn test_ignored_root_with_file_inclusions(cx: &mut TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_inclusions = Some(vec![ - "height_demo/**/hi_bonjour".to_string(), + settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![ "**/height_1".to_string(), - ]); + "height_demo/**/hi_bonjour".to_string(), + ])); }); }) }); @@ -1097,7 +1097,8 @@ async fn test_ignored_root_with_file_inclusions_repro(cx: &mut TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.file_scan_inclusions = Some(vec!["**/.env".to_string()]); + settings.project.worktree.file_scan_inclusions = + Some(SplicingVec::from(vec!["**/.env".to_string()])); }); }) }); diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 73567cf45d..502c4601d7 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [lib] path = "src/fs.rs" -test = false +doctest = false [[test]] name = "integration" diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 6bdffbed71..fae26d0a28 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -5,6 +5,7 @@ use anyhow::{Context as _, Result, bail}; use async_channel::Sender; use collections::{HashMap, HashSet}; use futures::FutureExt as _; +use futures::channel::oneshot; use futures::future::{self, BoxFuture, join_all}; use git::repository::GitCommitTemplate; use git::{ @@ -81,12 +82,14 @@ pub struct FakeGitRepositoryState { pub commit_data: HashMap, pub stash_entries: GitStash, pub commit_template: Option, + pub blob_read_gate: Option, } impl FakeGitRepositoryState { pub fn new(event_emitter: async_channel::Sender) -> Self { FakeGitRepositoryState { event_emitter, + blob_read_gate: None, head_contents: Default::default(), index_contents: Default::default(), unmerged_paths: Default::default(), @@ -112,6 +115,96 @@ impl FakeGitRepositoryState { } } +#[derive(Clone, Default, Debug)] +pub struct FakeBlobReadGate(Arc>); + +#[derive(Default, Debug)] +struct BlobReadGateState { + open: bool, + peak: usize, + next_id: u64, + waiters: Vec, +} + +#[derive(Debug)] +struct Waiter { + id: u64, + oid: Oid, + sender: oneshot::Sender<()>, +} + +impl FakeBlobReadGate { + async fn wait(&self, oid: Oid) { + let (_guard, receiver) = { + let mut inner = self.0.lock(); + if inner.open { + return; + } + let id = inner.next_id; + inner.next_id += 1; + let (sender, receiver) = oneshot::channel(); + inner.waiters.push(Waiter { id, oid, sender }); + inner.peak = inner.peak.max(inner.waiters.len()); + ( + WaiterGuard { + state: self.0.clone(), + id, + }, + receiver, + ) + }; + receiver.await.ok(); + } + + pub fn peak_concurrent(&self) -> usize { + self.0.lock().peak + } + + pub fn waiting(&self) -> usize { + self.0.lock().waiters.len() + } + + pub fn is_waiting(&self, oid: Oid) -> bool { + self.0.lock().waiters.iter().any(|waiter| waiter.oid == oid) + } + + pub fn release(&self, oid: Oid) -> bool { + let mut inner = self.0.lock(); + let Some(position) = inner.waiters.iter().position(|waiter| waiter.oid == oid) else { + return false; + }; + let waiter = inner.waiters.remove(position); + drop(inner); + waiter.sender.send(()).ok(); + true + } + + pub fn open(&self) { + let waiters = { + let mut inner = self.0.lock(); + inner.open = true; + std::mem::take(&mut inner.waiters) + }; + for waiter in waiters { + waiter.sender.send(()).ok(); + } + } +} + +struct WaiterGuard { + state: Arc>, + id: u64, +} + +impl Drop for WaiterGuard { + fn drop(&mut self) { + self.state + .lock() + .waiters + .retain(|waiter| waiter.id != self.id); + } +} + impl FakeGitRepository { fn with_state_async(&self, write: bool, f: F) -> BoxFuture<'static, Result> where @@ -171,9 +264,16 @@ impl GitRepository for FakeGitRepository { } fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result>> { - self.with_state_async(false, move |state| { - state.oids.get(&oid).cloned().context("oid does not exist") - }) + let content_and_gate = self.with_state_async(false, move |state| { + Ok((state.oids.get(&oid).cloned(), state.blob_read_gate.clone())) + }); + async move { + let (content, gate) = content_and_gate.await?; + if let Some(gate) = gate { + gate.wait(oid).await; + } + content.context("oid does not exist") + } .boxed() } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 0cc7d66584..167e63c98e 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1,6 +1,5 @@ pub mod fs_watcher; - -pub use fs_watcher::requires_poll_watcher; +mod git_clone_progress; use parking_lot::Mutex; use slotmap::{KeyData, SlotMap}; @@ -18,7 +17,7 @@ use gpui::ReadGlobal as _; use gpui::SharedString; #[cfg(unix)] use std::ffi::CString; -use util::command::new_command; +use util::command::{Stdio, new_command}; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; @@ -26,7 +25,7 @@ use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::ffi::OsStrExt; #[cfg(unix)] -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; #[cfg(any(target_os = "macos", target_os = "freebsd"))] use std::mem::MaybeUninit; @@ -34,6 +33,7 @@ use std::mem::MaybeUninit; use async_tar::Archive; use futures::{AsyncRead, Stream, StreamExt, future::BoxFuture}; use git::repository::{GitRepository, RealGitRepository}; +#[cfg(windows)] use is_executable::IsExecutable; use rope::Rope; use serde::{Deserialize, Serialize}; @@ -55,6 +55,8 @@ mod fake_git_repo; #[cfg(feature = "test-support")] use collections::{BTreeMap, btree_map}; #[cfg(feature = "test-support")] +pub use fake_git_repo::FakeBlobReadGate; +#[cfg(feature = "test-support")] use fake_git_repo::{FakeCommitDataEntry, FakeGitRepositoryState}; #[cfg(feature = "test-support")] use git::{ @@ -146,6 +148,26 @@ pub trait Fs: Send + Sync { path: &Path, ) -> Result>>>>; + /// Creates the native file watcher now rather than on the first `watch`, so a + /// failure to start it (e.g. inotify instance limits) can be reported at startup. + fn start_native_watcher(&self) -> Result<()> { + Ok(()) + } + + /// Records raw local watcher notifications until the returned recording is dropped. + fn record_watcher_diagnostics(&self) -> Option { + None + } + + /// Whether `path` exists, without following a final symlink. Synchronous + /// because watches are registered synchronously by the worktree scanner. + fn path_exists(&self, path: &Path) -> bool; + /// Whether the volume holding `path` compares file names case-sensitively. + fn is_path_case_sensitive(&self, path: &Path) -> bool; + /// Whether `path` sits on a filesystem where native file watching does not + /// deliver events (network mounts, some FUSE and WSL mounts), so it must be polled. + fn requires_poll_watcher(&self, path: &Path) -> bool; + async fn watch( &self, path: &Path, @@ -326,6 +348,7 @@ pub struct JobInfo { #[derive(Debug, Clone)] pub enum JobEvent { Started { info: JobInfo }, + Updated { id: JobId, message: SharedString }, Completed { id: JobId }, } @@ -350,6 +373,18 @@ impl JobTracker { } Self { id, subscribers } } + + fn update(&self, message: SharedString) { + let mut subscribers = self.subscribers.lock(); + subscribers.retain(|sender| { + sender + .unbounded_send(JobEvent::Updated { + id: self.id, + message: message.clone(), + }) + .is_ok() + }); + } } impl Drop for JobTracker { @@ -415,8 +450,11 @@ impl TrashId { } pub struct RealFs { + this: std::sync::Weak, bundled_git_binary_path: Option, executor: BackgroundExecutor, + native_watcher: Arc, + poll_watcher: Arc, next_job_id: Arc, job_event_subscribers: Arc>>, trash: Arc>>, @@ -522,15 +560,24 @@ impl FileHandle for std::fs::File { pub struct RealWatcher {} impl RealFs { - pub fn new(git_binary_path: Option, executor: BackgroundExecutor) -> Self { - Self { + pub fn new(git_binary_path: Option, executor: BackgroundExecutor) -> Arc { + Arc::new_cyclic(|this| Self { + this: this.clone(), bundled_git_binary_path: git_binary_path, + native_watcher: fs_watcher::OsWatcher::new( + fs_watcher::OsWatcherKind::Native, + executor.clone(), + ), + poll_watcher: fs_watcher::OsWatcher::new( + fs_watcher::OsWatcherKind::Poll, + executor.clone(), + ), executor, next_job_id: Arc::new(AtomicUsize::new(0)), job_event_subscribers: Arc::new(Mutex::new(Vec::new())), trash: Arc::new(Mutex::new(SlotMap::with_key())), is_case_sensitive: Default::default(), - } + }) } #[cfg(target_os = "windows")] @@ -1087,7 +1134,12 @@ impl Fs for RealFs { #[cfg(unix)] let is_fifo = metadata.file_type().is_fifo(); + #[cfg(unix)] + let is_executable = metadata.is_file() && metadata.permissions().mode() & 0o111 != 0; + + #[cfg(windows)] let path_buf = path.to_path_buf(); + #[cfg(windows)] let is_executable = self .executor .spawn(async move { path_buf.is_executable() }) @@ -1126,6 +1178,29 @@ impl Fs for RealFs { Ok(Box::pin(iter(entries))) } + fn start_native_watcher(&self) -> Result<()> { + self.native_watcher.ensure_backend() + } + + fn record_watcher_diagnostics(&self) -> Option { + Some(fs_watcher::WatchRecording::new([ + self.native_watcher.clone(), + self.poll_watcher.clone(), + ])) + } + + fn path_exists(&self, path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok() + } + + fn is_path_case_sensitive(&self, path: &Path) -> bool { + !fs_watcher::case_insensitive_path(path) + } + + fn requires_poll_watcher(&self, path: &Path) -> bool { + fs_watcher::requires_poll_watcher(path) + } + async fn watch( &self, path: &Path, @@ -1134,62 +1209,19 @@ impl Fs for RealFs { Pin>>>, Arc, ) { - use util::{ResultExt as _, paths::SanitizedPath}; - let executor = self.executor.clone(); - - let (tx, rx) = async_channel::unbounded(); - let pending_paths: Arc>> = Default::default(); - - let watcher: Arc = Arc::new(fs_watcher::FsWatcher::new( - executor.clone(), - tx.clone(), - pending_paths.clone(), - )); - - if let Err(e) = watcher.add(path) { - log::warn!("Failed to watch {}:\n{e}", path.display()); - } - - // Check if path is a symlink and follow the target parent - if let Some(mut target) = self.read_link(path).await.ok() { - log::trace!("watch symlink {path:?} -> {target:?}"); - // Check if symlink target is relative path, if so make it absolute - if target.is_relative() - && let Some(parent) = path.parent() - { - target = parent.join(target); - if let Ok(canonical) = self.canonicalize(&target).await { - target = SanitizedPath::new(&canonical).as_path().to_path_buf(); - } - } - watcher.add(&target).ok(); - // Skipped for poll watchers: PollWatcher::watch() recursively scans - // at registration, blocking on large virtual filesystem mounts - if let Some(parent) = target.parent() - && !fs_watcher::requires_poll_watcher(parent) - { - watcher.add(parent).log_err(); - } - } - - ( - Box::pin(rx.filter_map({ - let watcher = watcher.clone(); - let executor = executor.clone(); - move |_| { - let _ = watcher.clone(); - let pending_paths = pending_paths.clone(); - let executor = executor.clone(); - async move { - executor.timer(latency).await; - let paths = std::mem::take(&mut *pending_paths.lock()); - log::debug!("pending path events: {:?}", paths); - (!paths.is_empty()).then_some(paths) - } - } - })), - watcher, + let this = self + .this + .upgrade() + .expect("RealFs is only constructed inside an Arc"); + fs_watcher::watch( + this, + self.native_watcher.clone(), + self.poll_watcher.clone(), + self.executor.clone(), + path, + latency, ) + .await } fn open_repo( @@ -1242,18 +1274,28 @@ impl Fs for RealFs { message: SharedString::from(format!("Cloning {}", repo_url)), }; - let _job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone()); - - let output = new_command("git") + let job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone()); + let mut child = new_command("git") .current_dir(abs_work_directory) - .args(&["clone", repo_url]) - .output() - .await?; + .args(["clone", "--progress", repo_url]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let stderr = child + .stderr + .take() + .context("failed to read git clone progress")?; + let stderr_output = git_clone_progress::read(stderr, |message| { + job_tracker.update(message.into()); + }) + .await?; + let status = child.status().await?; - if !output.status.success() { + if !status.success() { anyhow::bail!( "git clone failed: {}", - String::from_utf8_lossy(&output.stderr).trim_end() + git_clone_progress::failure_message(&stderr_output) ); } @@ -1400,6 +1442,8 @@ pub struct FakeFs { // Use an unfair lock to ensure tests are deterministic. state: Arc>, executor: gpui::BackgroundExecutor, + native_watcher: Arc, + poll_watcher: Arc, } #[cfg(feature = "test-support")] @@ -1408,50 +1452,28 @@ struct FakeFsState { next_inode: u64, next_mtime: SystemTime, git_event_tx: async_channel::Sender, - event_txs: Vec<(PathBuf, async_channel::Sender>)>, + watch_roots: Vec<(PathBuf, std::sync::Weak)>, + watches: FakeWatches, events_paused: bool, buffered_events: Vec, metadata_call_count: usize, read_dir_call_count: usize, path_write_counts: std::collections::HashMap, - moves: std::collections::HashMap, job_event_subscribers: Arc>>, trash: Mutex>, - file_to_create_before_watch_add: Option<(PathBuf, PathBuf)>, remove_dir_errors: std::collections::HashMap, case_sensitive: bool, } +/// The kernel's side of file watching, as far as the real watcher code above +/// notify can tell: the paths the backend has registered and the callback that +/// delivers events for them into the native `OsWatcher`. #[cfg(feature = "test-support")] -impl FakeFsState { - fn create_file_before_watch_add(&mut self, watch_path: &Path) -> Result<()> { - let Some((pending_watch_path, file_path)) = self.file_to_create_before_watch_add.take() - else { - return Ok(()); - }; - if pending_watch_path != watch_path { - self.file_to_create_before_watch_add = Some((pending_watch_path, file_path)); - return Ok(()); - } - - let inode = self.get_and_increment_inode(); - let mtime = self.get_and_increment_mtime(); - self.write_path(&file_path, |entry| { - let btree_map::Entry::Vacant(entry) = entry else { - anyhow::bail!("file already exists: {}", file_path.display()); - }; - entry.insert(FakeFsEntry::File { - inode, - mtime, - len: 0, - content: Vec::new(), - git_dir_path: None, - }); - Ok(()) - })?; - self.emit_event([(file_path, Some(PathEventKind::Created))]); - Ok(()) - } +#[derive(Default)] +struct FakeWatches { + registered_paths: Vec, + watch_calls: Vec, + event_sink: Option) + Send + Sync>>, } #[cfg(feature = "test-support")] @@ -1706,12 +1728,73 @@ impl FakeFsState { } fn flush_events(&mut self, mut count: usize) { + use notify::event::{CreateKind, Flag, ModifyKind, RemoveKind}; + count = count.min(self.buffered_events.len()); let events = self.buffered_events.drain(0..count).collect::>(); - self.event_txs.retain(|(_, tx)| { - let _ = tx.try_send(events.clone()); - !tx.is_closed() - }); + let Some(event_sink) = &self.watches.event_sink else { + return; + }; + for event in events { + let is_registered = self.watches.registered_paths.iter().any(|registered_path| { + if self.case_sensitive { + event.path.starts_with(registered_path) + } else { + let event_path = event.path.to_string_lossy().to_lowercase(); + let registered_path = registered_path.to_string_lossy().to_lowercase(); + Path::new(&event_path).starts_with(Path::new(®istered_path)) + } + }); + if !is_registered { + continue; + } + let notify_event = match event.kind { + Some(PathEventKind::Created) => { + notify::Event::new(notify::EventKind::Create(CreateKind::Any)) + } + Some(PathEventKind::Changed) => { + notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)) + } + Some(PathEventKind::Removed) => { + notify::Event::new(notify::EventKind::Remove(RemoveKind::Any)) + } + Some(PathEventKind::Rescan) => { + notify::Event::new(notify::EventKind::Other).set_flag(Flag::Rescan) + } + None => notify::Event::new(notify::EventKind::Any), + }; + event_sink(Ok(notify_event.add_path(event.path))); + } + } +} + +/// Stands in for notify at the boundary the real watcher code talks to: the +/// fake filesystem's own mutations are the "kernel" events. +#[cfg(feature = "test-support")] +struct FakeWatchBackend { + state: Arc>, +} + +#[cfg(feature = "test-support")] +impl fs_watcher::WatchBackend for FakeWatchBackend { + fn watch(&mut self, path: &Path, _mode: notify::RecursiveMode) -> notify::Result<()> { + let path = normalize_path(path); + let mut state = self.state.try_lock().expect( + "fake filesystem state is locked; this execution would have caused a test hang", + ); + state.watches.watch_calls.push(path.clone()); + state.watches.registered_paths.push(path); + Ok(()) + } + + fn unwatch(&mut self, path: &Path) -> notify::Result<()> { + let path = normalize_path(path); + self.state + .lock() + .watches + .registered_paths + .retain(|registered_path| *registered_path != path); + Ok(()) } } @@ -1728,33 +1811,46 @@ impl FakeFs { pub fn new(executor: gpui::BackgroundExecutor) -> Arc { let (tx, rx) = async_channel::bounded::(10); + let state = Arc::new(Mutex::new(FakeFsState { + root: FakeFsEntry::Dir { + inode: 0, + mtime: MTime(UNIX_EPOCH), + len: 0, + entries: Default::default(), + git_repo_state: None, + }, + git_event_tx: tx, + next_mtime: UNIX_EPOCH + Self::SYSTEMTIME_INTERVAL, + next_inode: 1, + watch_roots: Vec::new(), + watches: FakeWatches::default(), + buffered_events: Vec::new(), + events_paused: false, + read_dir_call_count: 0, + metadata_call_count: 0, + path_write_counts: Default::default(), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), + trash: Mutex::new(SlotMap::with_key()), + remove_dir_errors: Default::default(), + case_sensitive: true, + })); + let native_watcher = fs_watcher::OsWatcher::with_backend( + fs_watcher::OsWatcherKind::Native, + executor.clone(), + Some(Box::new(FakeWatchBackend { + state: state.clone(), + })), + ); + state.lock().watches.event_sink = Some(Box::new(native_watcher.event_sink())); + let poll_watcher = + fs_watcher::OsWatcher::new(fs_watcher::OsWatcherKind::Poll, executor.clone()); + let this = Arc::new_cyclic(|this| Self { this: this.clone(), executor: executor.clone(), - state: Arc::new(Mutex::new(FakeFsState { - root: FakeFsEntry::Dir { - inode: 0, - mtime: MTime(UNIX_EPOCH), - len: 0, - entries: Default::default(), - git_repo_state: None, - }, - git_event_tx: tx, - next_mtime: UNIX_EPOCH + Self::SYSTEMTIME_INTERVAL, - next_inode: 1, - event_txs: Default::default(), - buffered_events: Vec::new(), - events_paused: false, - read_dir_call_count: 0, - metadata_call_count: 0, - path_write_counts: Default::default(), - moves: Default::default(), - job_event_subscribers: Arc::new(Mutex::new(Vec::new())), - trash: Mutex::new(SlotMap::with_key()), - file_to_create_before_watch_add: None, - remove_dir_errors: Default::default(), - case_sensitive: true, - })), + state, + native_watcher, + poll_watcher, }); executor.spawn({ @@ -1948,15 +2044,10 @@ impl FakeFs { state.emit_event([(root, Some(PathEventKind::Rescan))]); } - pub fn create_file_before_next_watch_add( - &self, - watch_path: impl AsRef, - path: impl AsRef, - ) { - self.state.lock().file_to_create_before_watch_add = Some(( - normalize_path(watch_path.as_ref()), - normalize_path(path.as_ref()), - )); + /// Every path the watcher backend has been asked to watch, in order, + /// including paths that were later unwatched. + pub fn watch_calls(&self) -> Vec { + self.state.lock().watches.watch_calls.clone() } pub fn flush_events(&self, count: usize) { @@ -2355,20 +2446,32 @@ impl FakeFs { &self, dot_git: &Path, contents_by_path: &[(&str, String)], - ) { + ) -> Vec { self.with_git_state(dot_git, true, |state| { use git::Oid; state.merge_base_contents.clear(); - let oids = (1..) - .map(|n| n.to_string()) - .map(|n| Oid::from_bytes(n.repeat(20).as_bytes()).unwrap()); - for ((path, content), oid) in contents_by_path.iter().zip(oids) { + let mut assigned = Vec::with_capacity(contents_by_path.len()); + for (index, (path, content)) in contents_by_path.iter().enumerate() { + let mut bytes = [0u8; 20]; + bytes[..4].copy_from_slice(&((index as u32) + 1).to_be_bytes()); + let oid = Oid::from_bytes(&bytes).unwrap(); state.merge_base_contents.insert(repo_path(path), oid); state.oids.insert(oid, content.as_bytes().to_vec()); + assigned.push(oid); } + assigned + }) + .unwrap() + } + + pub fn install_blob_read_gate_for_repo(&self, dot_git: &Path) -> FakeBlobReadGate { + let gate = FakeBlobReadGate::default(); + self.with_git_state(dot_git, false, |state| { + state.blob_read_gate = Some(gate.clone()); }) .unwrap(); + gate } pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(RepoPath, git::blame::Blame)>) { @@ -2636,12 +2739,13 @@ impl FakeFs { self.state.lock().read_dir_call_count } + /// The roots passed to `Fs::watch` whose watchers are still alive. pub fn watched_paths(&self) -> Vec { let state = self.state.lock(); state - .event_txs + .watch_roots .iter() - .filter_map(|(path, tx)| (!tx.is_closed()).then_some(path.clone())) + .filter_map(|(path, watcher)| (watcher.strong_count() > 0).then_some(path.clone())) .collect() } @@ -2793,48 +2897,6 @@ impl FakeFsEntry { } } -#[cfg(feature = "test-support")] -struct FakeWatcher { - tx: async_channel::Sender>, - fs_state: Arc>, - prefixes: Mutex>, -} - -#[cfg(feature = "test-support")] -impl Watcher for FakeWatcher { - fn add(&self, path: &Path) -> Result<()> { - let path = normalize_path(path); - self.fs_state - .try_lock() - .unwrap() - .create_file_before_watch_add(&path)?; - - let mut prefixes = self.prefixes.lock(); - if prefixes.iter().any(|prefix| path.starts_with(prefix)) { - return Ok(()); - } - - self.fs_state - .try_lock() - .unwrap() - .event_txs - .push((path.clone(), self.tx.clone())); - prefixes.push(path); - Ok(()) - } - - fn remove(&self, path: &Path) -> Result<()> { - let path = normalize_path(path); - self.prefixes.lock().retain(|prefix| prefix != &path); - self.fs_state - .try_lock() - .unwrap() - .event_txs - .retain(|(watched_path, _)| watched_path != &path); - Ok(()) - } -} - #[cfg(feature = "test-support")] #[derive(Debug)] struct FakeHandle { @@ -2845,13 +2907,23 @@ struct FakeHandle { impl FileHandle for FakeHandle { fn current_path(&self, fs: &Arc) -> Result { let fs = fs.as_fake(); - let mut state = fs.state.lock(); - let Some(target) = state.moves.get(&self.inode).cloned() else { - anyhow::bail!("fake fd not moved") - }; - - if state.try_entry(&target, false).is_some() { - return Ok(target); + let state = fs.state.lock(); + let mut queue = collections::VecDeque::new(); + queue.push_back((PathBuf::from(util::path!("/")), &state.root)); + while let Some((path, entry)) = queue.pop_front() { + match entry { + FakeFsEntry::File { inode, .. } | FakeFsEntry::Dir { inode, .. } + if *inode == self.inode => + { + return Ok(path); + } + FakeFsEntry::Dir { entries, .. } => { + for (name, entry) in entries { + queue.push_back((path.join(name), entry)); + } + } + _ => {} + } } anyhow::bail!("fake fd target not found") } @@ -3006,12 +3078,6 @@ impl Fs for FakeFs { return Ok(()); } - let inode = match moved_entry { - FakeFsEntry::File { inode, .. } => inode, - FakeFsEntry::Dir { inode, .. } => inode, - _ => 0, - }; - let mut moved = true; state.write_path(&new_path, |e| { match e { @@ -3038,8 +3104,6 @@ impl Fs for FakeFs { return Ok(()); } - state.moves.insert(inode, new_path.clone()); - state .write_path(&old_path, |e| { if let btree_map::Entry::Occupied(e) = e { @@ -3303,35 +3367,22 @@ impl Fs for FakeFs { Arc, ) { self.simulate_random_delay().await; - let (tx, rx) = async_channel::unbounded(); - let path = path.to_path_buf(); - self.state.lock().event_txs.push((path.clone(), tx.clone())); - let executor = self.executor.clone(); - let watcher = Arc::new(FakeWatcher { - tx, - fs_state: self.state.clone(), - prefixes: Mutex::new(vec![path]), - }); - ( - Box::pin(futures::StreamExt::filter(rx, { - let watcher = watcher.clone(); - move |events| { - let result = events.iter().any(|evt_path| { - watcher - .prefixes - .lock() - .iter() - .any(|prefix| evt_path.path.starts_with(prefix)) - }); - let executor = executor.clone(); - async move { - executor.simulate_random_delay().await; - result - } - } - })), - watcher, + // Zero latency: the deterministic executor doesn't advance time on its + // own, so a real debounce would stall every test until `advance_clock`. + let (events, watcher) = fs_watcher::watch( + self.this.upgrade().unwrap(), + self.native_watcher.clone(), + self.poll_watcher.clone(), + self.executor.clone(), + path, + Duration::ZERO, ) + .await; + self.state + .lock() + .watch_roots + .push((normalize_path(path), Arc::downgrade(&watcher))); + (events, watcher) } fn open_repo( @@ -3372,6 +3423,28 @@ impl Fs for FakeFs { anyhow::bail!("Git config is not supported in fake Fs") } + fn record_watcher_diagnostics(&self) -> Option { + Some(fs_watcher::WatchRecording::new([ + self.native_watcher.clone(), + self.poll_watcher.clone(), + ])) + } + + fn path_exists(&self, path: &Path) -> bool { + self.state + .lock() + .try_entry(&normalize_path(path), false) + .is_some() + } + + fn is_path_case_sensitive(&self, _path: &Path) -> bool { + self.state.lock().case_sensitive + } + + fn requires_poll_watcher(&self, _path: &Path) -> bool { + false + } + fn is_fake(&self) -> bool { true } diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 59ee6ac984..5354d79d5b 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -1,26 +1,112 @@ +use futures::{Stream, StreamExt as _}; use gpui::{BackgroundExecutor, Task}; use notify::{Event, EventKind}; use parking_lot::Mutex; use std::{ collections::HashMap, - fs, ops::DerefMut, path::Path, - sync::{Arc, LazyLock, OnceLock}, + pin::Pin, + sync::{Arc, LazyLock}, time::{Duration, Instant}, }; use util::{ResultExt, paths::SanitizedPath}; -use crate::{PathEvent, PathEventKind, Watcher}; +use crate::{Fs, PathEvent, PathEventKind, Watcher}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum WatcherMode { - #[default] +mod diagnostics; +pub use diagnostics::{ + WatchDiagnosticEvent, WatchRecording, WatchRoot, WatchSnapshot, WatcherSnapshot, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum OsWatcherKind { Native, Poll, } +impl OsWatcherKind { + /// Poll watches and native macOS/Windows watches cover their whole subtree; + /// native Linux (inotify) watches cover one directory. + fn is_recursive(self) -> bool { + self == Self::Poll || cfg!(any(target_os = "windows", target_os = "macos")) + } +} + +pub(crate) async fn watch( + fs: Arc, + native_watcher: Arc, + poll_watcher: Arc, + executor: BackgroundExecutor, + path: &Path, + latency: Duration, +) -> ( + Pin>>>, + Arc, +) { + let (tx, rx) = async_channel::unbounded(); + let pending_paths: Arc>> = Default::default(); + + let watcher: Arc = Arc::new(FsWatcher::new( + native_watcher, + poll_watcher, + fs.clone(), + executor.clone(), + tx, + pending_paths.clone(), + )); + + if let Err(e) = watcher.add(path) { + log::warn!("Failed to watch {}:\n{e}", path.display()); + } + + // Check if path is a symlink and follow the target parent + if let Some(mut target) = fs.read_link(path).await.ok() { + log::trace!("watch symlink {path:?} -> {target:?}"); + // Check if symlink target is relative path, if so make it absolute + if target.is_relative() + && let Some(parent) = path.parent() + { + target = parent.join(target); + if let Ok(canonical) = fs.canonicalize(&target).await { + target = SanitizedPath::new(&canonical).as_path().to_path_buf(); + } + } + watcher.add(&target).ok(); + // Skipped for poll watchers: PollWatcher::watch() recursively scans + // at registration, blocking on large virtual filesystem mounts + if let Some(parent) = target.parent() + && !fs.requires_poll_watcher(parent) + { + watcher.add(parent).log_err(); + } + } + + ( + Box::pin(rx.filter_map({ + let watcher = watcher.clone(); + move |_| { + let _ = watcher.clone(); + let pending_paths = pending_paths.clone(); + let executor = executor.clone(); + async move { + if !latency.is_zero() { + executor.timer(latency).await; + } + let paths = std::mem::take(&mut *pending_paths.lock()); + log::debug!("pending path events: {:?}", paths); + (!paths.is_empty()).then_some(paths) + } + } + })), + watcher, + ) +} + pub struct FsWatcher { + native_watcher: Arc, + poll_watcher: Arc, + fs: Arc, executor: BackgroundExecutor, tx: async_channel::Sender<()>, pending_path_events: Arc>>, @@ -28,19 +114,25 @@ pub struct FsWatcher { pending_registrations: Arc, Task<()>>>>, } -#[derive(Clone, Copy)] +#[derive(Clone)] struct FsWatcherRegistration { id: WatcherRegistrationId, - mode: WatcherMode, + os_watcher: Arc, } impl FsWatcher { - pub fn new( + pub(crate) fn new( + native_watcher: Arc, + poll_watcher: Arc, + fs: Arc, executor: BackgroundExecutor, tx: async_channel::Sender<()>, pending_path_events: Arc>>, ) -> Self { Self { + native_watcher, + poll_watcher, + fs, executor, tx, pending_path_events, @@ -50,13 +142,16 @@ impl FsWatcher { } fn add_existing_path(&self, path: Arc) -> anyhow::Result<()> { - let case_insensitive = case_insensitive_path(&path); + let case_insensitive = !self.fs.is_path_case_sensitive(&path); let key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive); if self.registrations.lock().contains_key(&key) { log::trace!("path to watch is already watched: {path:?}"); return Ok(()); } match register_existing_path( + &self.native_watcher, + &self.poll_watcher, + self.fs.as_ref(), path.clone(), case_insensitive, self.tx.clone(), @@ -83,6 +178,9 @@ impl FsWatcher { } let task = self.executor.spawn(poll_path_until_created( + self.native_watcher.clone(), + self.poll_watcher.clone(), + self.fs.clone(), self.executor.clone(), path.clone(), self.tx.clone(), @@ -104,9 +202,8 @@ impl Drop for FsWatcher { std::mem::swap(old.deref_mut(), &mut registrations); } - let global_watcher = global_watcher(); for (_, registration) in registrations { - global_watcher.remove(registration.id); + registration.os_watcher.remove(registration.id); } } } @@ -133,7 +230,7 @@ impl Watcher for FsWatcher { return Ok(()); } - if fs::symlink_metadata(path.as_ref()).is_err() { + if !self.fs.path_exists(&path) { self.add_pending_path(path); return Ok(()); } @@ -153,7 +250,7 @@ impl Watcher for FsWatcher { .or_else(|| registrations.remove(&WatchKey::folded(sanitized))) }; if let Some(registration) = registration { - global_watcher().remove(registration.id); + registration.os_watcher.remove(registration.id); } Ok(()) } @@ -171,10 +268,9 @@ fn path_covered_by_recursive_registration( [WatchKey::exact(ancestor), WatchKey::folded(ancestor)] .iter() .any(|key| { - registrations.get(key).is_some_and(|registration| { - registration.mode == WatcherMode::Poll - || cfg!(any(target_os = "windows", target_os = "macos")) - }) + registrations + .get(key) + .is_some_and(|registration| registration.os_watcher.is_recursive()) }) }) } @@ -211,29 +307,29 @@ pub fn requires_poll_watcher(path: &Path) -> bool { } fn register_existing_path( + native_watcher: &Arc, + poll_watcher: &Arc, + fs: &dyn Fs, path: Arc, case_insensitive: bool, tx: async_channel::Sender<()>, pending_path_events: Arc>>, ) -> anyhow::Result> { - let mode = if requires_poll_watcher(path.as_ref()) { + let os_watcher = if fs.requires_poll_watcher(path.as_ref()) { log::info!( "Using poll watcher ({}ms interval) for {}", poll_interval().as_millis(), path.display() ); telemetry::event!("fs_watcher_poll", path = path.display().to_string()); - WatcherMode::Poll + poll_watcher } else { - WatcherMode::Native + native_watcher }; let root_path = SanitizedPath::new_arc(path.as_ref()); let path_for_callback = path.clone(); - let Some(registration_id) = global_watcher().add( - path, - mode, - case_insensitive, - move |event: ¬ify::Event| { + let Some(registration_id) = + os_watcher.add(path, case_insensitive, move |event: ¬ify::Event| { log::trace!("watcher received event: {event:?}"); push_notify_event( &tx, @@ -243,14 +339,13 @@ fn register_existing_path( path_for_callback.as_ref(), event, ); - }, - )? + })? else { return Ok(None); }; Ok(Some(FsWatcherRegistration { id: registration_id, - mode, + os_watcher: os_watcher.clone(), })) } @@ -365,7 +460,7 @@ fn is_wsl_drvfs_path(path: &Path) -> bool { /// Whether the volume backing `path` does case-insensitive name lookups, used to /// pick exact vs. folded matching. #[cfg(target_os = "macos")] -fn case_insensitive_path(path: &Path) -> bool { +pub(crate) fn case_insensitive_path(path: &Path) -> bool { use std::os::unix::ffi::OsStrExt as _; // `pathconf(_PC_CASE_SENSITIVE)` returns 1 (sensitive), 0 (insensitive), or -1 @@ -378,7 +473,7 @@ fn case_insensitive_path(path: &Path) -> bool { } #[cfg(target_os = "linux")] -fn case_insensitive_path(_path: &Path) -> bool { +pub(crate) fn case_insensitive_path(_path: &Path) -> bool { // use std::os::unix::ffi::OsStrExt as _; // // Only ext4/f2fs casefold (`+F`) dirs are insensitive, reported by `statx` via @@ -403,7 +498,7 @@ fn case_insensitive_path(_path: &Path) -> bool { } #[cfg(target_os = "windows")] -fn case_insensitive_path(_path: &Path) -> bool { +pub(crate) fn case_insensitive_path(_path: &Path) -> bool { // todo(windows): Windows defaults to case in sensitive, but // they can mark specific directories as case sensitive. Mainly // for WSL use cases @@ -411,7 +506,7 @@ fn case_insensitive_path(_path: &Path) -> bool { } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn case_insensitive_path(_path: &Path) -> bool { +pub(crate) fn case_insensitive_path(_path: &Path) -> bool { // Other BSDs default to case-sensitive local filesystems. false } @@ -480,6 +575,9 @@ impl WatchKey { } async fn poll_path_until_created( + native_watcher: Arc, + poll_watcher: Arc, + fs: Arc, executor: BackgroundExecutor, path: Arc, tx: async_channel::Sender<()>, @@ -494,13 +592,13 @@ async fn poll_path_until_created( return; } - if smol::fs::symlink_metadata(path.as_ref()).await.is_err() { + if !fs.path_exists(&path) { continue; } // Probe case sensitivity now that the path exists, rather than at add // time when it didn't. - let case_insensitive = case_insensitive_path(path.as_ref()); + let case_insensitive = !fs.is_path_case_sensitive(&path); let key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive); if registrations.lock().contains_key(&key) { @@ -509,6 +607,9 @@ async fn poll_path_until_created( } match register_existing_path( + &native_watcher, + &poll_watcher, + fs.as_ref(), path.clone(), case_insensitive, tx.clone(), @@ -518,7 +619,7 @@ async fn poll_path_until_created( { let mut pending_registrations = pending_registrations.lock(); if pending_registrations.remove(path.as_ref()).is_none() { - global_watcher().remove(registration.id); + registration.os_watcher.remove(registration.id); return; } registrations.lock().insert(key, registration); @@ -594,42 +695,24 @@ fn push_notify_event( .collect::>(); if event.need_rescan() { - if !watcher_logging_rate_limited() { - log::warn!("filesystem watcher lost sync for {watched_root:?}; scheduling rescan"); + // A rescan event names the subtrees that lost sync (FSEvents + // `MustScanSubDirs`); only a pathless one (inotify queue overflow) means + // the whole watched root is suspect. + if path_events.is_empty() { + path_events.push(PathEvent { + path: watched_root.to_path_buf(), + kind: Some(PathEventKind::Rescan), + }); + } else { + for path_event in &mut path_events { + path_event.kind = Some(PathEventKind::Rescan); + } } - - path_events.retain(|path_event| path_event.path != watched_root); - path_events.push(PathEvent { - path: watched_root.to_path_buf(), - kind: Some(PathEventKind::Rescan), - }); } log::trace!("path_events: {:?}", path_events); enqueue_path_events(tx, pending_path_events, path_events); } -fn watcher_logging_rate_limited() -> bool { - static LAST_WARN: Mutex> = Mutex::new(None); - let Some((ref mut started, ref mut emitted)) = *LAST_WARN.lock() else { - *LAST_WARN.lock() = Some((Instant::now(), 0)); - return false; - }; - - if started.elapsed().as_secs() < 1 { - if *emitted < 20 { - log::warn!("filesystem watcher lost sync for many files, not logging more"); - return true; - } else { - *emitted += 1; - } - } else { - *emitted = 0; - *started = Instant::now() - } - - true -} - fn coalesce_pending_rescans(pending_paths: &mut Vec, path_events: &mut Vec) { if !path_events .iter() @@ -687,7 +770,6 @@ struct WatcherRegistrationState { callback: Arc, key: WatchKey, path: Arc, - mode: WatcherMode, } struct PathRegistrationState { @@ -695,7 +777,7 @@ struct PathRegistrationState { has_os_watcher: bool, } -/// The registered watch paths for one watcher mode, keyed by [`WatchKey`] so that +/// The registered watch paths for one backend, keyed by [`WatchKey`] so that /// add (dedup), remove, and dispatch share a single notion of path identity. #[derive(Default)] struct WatchPaths(HashMap); @@ -722,8 +804,8 @@ impl WatchPaths { /// True if a recursive registration on a strict ancestor already covers /// `path`. Only poll watches and native macOS/Windows watches are recursive. - fn covered_by_recursive_ancestor(&self, path: &SanitizedPath, mode: WatcherMode) -> bool { - if mode != WatcherMode::Poll && !cfg!(any(target_os = "windows", target_os = "macos")) { + fn covered_by_recursive_ancestor(&self, path: &SanitizedPath, recursive: bool) -> bool { + if !recursive { return false; } path.as_path().ancestors().skip(1).any(|ancestor| { @@ -751,8 +833,7 @@ impl WatchPaths { struct WatcherState { watchers: HashMap, - native_path_registrations: WatchPaths, - poll_path_registrations: WatchPaths, + paths: WatchPaths, cooldown_until: Option, last_registration: WatcherRegistrationId, } @@ -763,33 +844,22 @@ impl WatcherState { .is_some_and(|cooldown_until| cooldown_until > Instant::now()) } - fn path_registrations(&mut self, mode: WatcherMode) -> &mut WatchPaths { - match mode { - WatcherMode::Native => &mut self.native_path_registrations, - WatcherMode::Poll => &mut self.poll_path_registrations, - } - } - - fn remove_registration( - &mut self, - id: WatcherRegistrationId, - ) -> Option<(Arc, WatcherMode)> { + fn remove_registration(&mut self, id: WatcherRegistrationId) -> Option> { let registration_state = self.watchers.remove(&id)?; - let path_registrations = self.path_registrations(registration_state.mode); - let path_state = path_registrations.get_mut(®istration_state.key)?; + let path_state = self.paths.get_mut(®istration_state.key)?; path_state.watcher_ids.retain(|&existing| existing != id); if !path_state.watcher_ids.is_empty() { return None; } let was_actually_watched = path_state.has_os_watcher; - path_registrations.remove(®istration_state.key); + self.paths.remove(®istration_state.key); - was_actually_watched.then_some((registration_state.path, registration_state.mode)) + was_actually_watched.then_some(registration_state.path) } } -trait WatchBackend: Send { +pub(crate) trait WatchBackend: Send { fn watch(&mut self, path: &Path, mode: notify::RecursiveMode) -> notify::Result<()>; fn unwatch(&mut self, path: &Path) -> notify::Result<()>; } @@ -804,47 +874,125 @@ impl WatchBackend for T { } } -type DispatchEvent = (WatcherMode, Result); +pub struct OsWatcher { + kind: OsWatcherKind, + recursive: bool, + state: Arc>, -pub struct GlobalWatcher { - state: Mutex, - - // DANGER: never keep state lock while holding watcher lock - // two mutexes because calling watcher.add triggers watcher.event, which needs watchers. - native_watcher: Mutex>>, - poll_watcher: Mutex>>, - event_tx: async_channel::Sender, + // Never hold the state lock while calling the backend: a backend call can be + // slow (a poll watch scans its whole tree up front, an FSEvents watch rebuilds + // the stream) and dispatch needs the state to route every other registration's + // events. Whoever re-locks the state afterwards must re-check what they assumed. + backend: Mutex>>, + event_tx: async_channel::Sender>, + _dispatch_task: Task<()>, + diagnostics: Arc, } -impl GlobalWatcher { +impl OsWatcher { + /// Watches with notify's backend of `kind`, created lazily on first use so + /// that a failure to start it can be reported by `ensure_backend`. + pub fn new(kind: OsWatcherKind, executor: BackgroundExecutor) -> Arc { + Self::with_backend(kind, executor, None) + } + + /// Watches with the given backend in place of notify's; `event_sink` is how + /// that backend reports events, standing in for notify's `EventHandler`. + pub(crate) fn with_backend( + kind: OsWatcherKind, + executor: BackgroundExecutor, + backend: Option>, + ) -> Arc { + let (event_tx, event_rx) = async_channel::unbounded(); + let state = Arc::new(Mutex::new(WatcherState { + watchers: Default::default(), + paths: Default::default(), + cooldown_until: None, + last_registration: Default::default(), + })); + let dispatch_task = executor.spawn({ + let state = state.clone(); + async move { + let mut rescan_history = diagnostics::RescanHistory::default(); + while let Ok(first) = event_rx.recv().await { + dispatch_batch(kind, &state, &mut rescan_history, first, &event_rx); + } + } + }); + Arc::new(Self { + kind, + recursive: kind.is_recursive(), + state, + backend: Mutex::new(backend), + event_tx, + _dispatch_task: dispatch_task, + diagnostics: Default::default(), + }) + } + + /// The callback a substitute backend uses to report events, standing + /// in for the `EventHandler` notify would have been constructed with. + pub(crate) fn event_sink( + &self, + ) -> impl Fn(notify::Result) + Send + Sync + 'static { + let event_tx = self.event_tx.clone(); + let diagnostics = self.diagnostics.clone(); + let kind = self.kind; + move |event| { + diagnostics.record(|| match &event { + Ok(event) => WatchDiagnosticEvent::from_notify_event(kind, event), + Err(error) => WatchDiagnosticEvent::new( + kind, + "error", + &error.paths, + format!("{error:?}"), + false, + ), + }); + enqueue(&event_tx, event); + } + } + + pub(crate) fn is_recursive(&self) -> bool { + self.recursive + } + #[must_use] fn add( &self, path: Arc, - mode: WatcherMode, case_insensitive: bool, cb: impl Fn(¬ify::Event) + Send + Sync + 'static, ) -> anyhow::Result> { let path = SanitizedPath::from_arc(path); let key = WatchKey::for_registration(&path, case_insensitive); let mut state = self.state.lock(); - let (path_already_covered, path_already_registered) = { - let registrations_for_mode = state.path_registrations(mode); - ( - registrations_for_mode.covered_by_recursive_ancestor(&path, mode), - registrations_for_mode.contains(&key), - ) - }; + let path_already_covered = state + .paths + .covered_by_recursive_ancestor(&path, self.recursive); + let path_already_registered = state.paths.contains(&key); if !path_already_covered && !path_already_registered { - if mode == WatcherMode::Native && state.is_native_watch_limit_cooldown_active() { + if self.kind == OsWatcherKind::Native && state.is_native_watch_limit_cooldown_active() { + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + "watch_skipped", + &[path.as_path().to_owned()], + "Native watch limit cooldown is active; registration will be retried" + .into(), + false, + ) + }); return Ok(None); } drop(state); - match self.watch(path.as_path(), mode) { + match self.watch(path.as_path()) { Ok(()) => {} - Err(error) if mode == WatcherMode::Native && is_max_files_watch_error(&error) => { + Err(error) + if self.kind == OsWatcherKind::Native && is_max_files_watch_error(&error) => + { self.start_native_watch_limit_cooldown(path.as_path()); return Ok(None); } @@ -860,11 +1008,10 @@ impl GlobalWatcher { callback: Arc::new(cb), key: key.clone(), path, - mode, }; state.watchers.insert(id, registration_state); state - .path_registrations(mode) + .paths .entry(key) .and_modify(|registration| registration.watcher_ids.push(id)) .or_insert_with(|| PathRegistrationState { @@ -875,97 +1022,24 @@ impl GlobalWatcher { Ok(Some(id)) } - fn enqueue(&self, mode: WatcherMode, event: Result) { - if matches!( - event, - Ok(Event { - kind: EventKind::Access(_), - .. - }) - ) { - return; - } - - // A failed send only happens once the dispatch thread has shut down, at - // which point there's nothing left to dispatch to. - self.event_tx.try_send((mode, event)).ok(); - } - - fn dispatch(&self, mode: WatcherMode, event: Result) { - let event = match event { - Ok(event) => event, - Err(error) => { - log::warn!("watcher error for {mode:?}: {error}"); - return; - } - }; - - log::trace!("global handle event for {mode:?}: {event:?}"); - - let callbacks = { - let state = self.state.lock(); - if event.need_rescan() { - let callbacks = state - .watchers - .values() - .filter(|registration| registration.mode == mode) - .map(|registration| registration.callback.clone()) - .collect::>(); - log::warn!( - "filesystem watcher lost sync for {mode:?}; scheduling rescans for {} registrations", - callbacks.len() - ); - callbacks - } else { - let path_registrations = match mode { - WatcherMode::Native => &state.native_path_registrations, - WatcherMode::Poll => &state.poll_path_registrations, - }; - let mut ids = Vec::new(); - for path in &event.paths { - let sanitized = SanitizedPath::new(path); - path_registrations.watcher_ids_covering(sanitized, &mut ids); - } - ids.sort_unstable_by_key(|id| id.0); - ids.dedup(); - ids.into_iter() - .filter_map(|id| state.watchers.get(&id)) - .map(|registration| registration.callback.clone()) - .collect::>() - } - }; - - for callback in callbacks { - callback(&event); - } + #[cfg(test)] + fn dispatch(&self, event: notify::Result) { + dispatch(self.kind, &self.state, event); } + #[cfg(test)] fn dispatch_batch( &self, - first: DispatchEvent, - event_rx: &async_channel::Receiver, + first: notify::Result, + event_rx: &async_channel::Receiver>, ) { - // A single backend overflow can enqueue many rescan markers. One rescan - // per mode covers the entire drained batch; ordinary events still run. - let mut native_rescan_dispatched = false; - let mut poll_rescan_dispatched = false; - - for (mode, event) in - std::iter::once(first).chain(std::iter::from_fn(|| event_rx.try_recv().ok())) - { - let rescan_dispatched = match mode { - WatcherMode::Native => &mut native_rescan_dispatched, - WatcherMode::Poll => &mut poll_rescan_dispatched, - }; - if event.as_ref().is_ok_and(notify::Event::need_rescan) { - if *rescan_dispatched { - continue; - } - *rescan_dispatched = true; - } - - self.dispatch(mode, event); - } + dispatch_batch( + self.kind, + &self.state, + &mut diagnostics::RescanHistory::default(), + first, + event_rx, + ); } fn start_native_watch_limit_cooldown(&self, path: &Path) { @@ -983,57 +1057,71 @@ impl GlobalWatcher { pub fn remove(&self, id: WatcherRegistrationId) { let mut state = self.state.lock(); - let Some((path, mode)) = state.remove_registration(id) else { + let Some(path) = state.remove_registration(id) else { return; }; drop(state); - self.unwatch(path.as_path(), mode).log_err(); + self.unwatch(path.as_path()).log_err(); } - fn watch(&self, path: &Path, mode: WatcherMode) -> anyhow::Result<()> { - match mode { - WatcherMode::Native => { - self.ensure_native_watcher()?; - self.native_watcher - .lock() - .as_mut() - .expect("native watcher initialized") - .watch( - path, - if cfg!(any(target_os = "windows", target_os = "macos")) { - notify::RecursiveMode::Recursive - } else { - notify::RecursiveMode::NonRecursive - }, - )?; - } - WatcherMode::Poll => { - self.ensure_poll_watcher()?; - self.poll_watcher - .lock() - .as_mut() - .expect("poll watcher initialized") - .watch(path, notify::RecursiveMode::Recursive)?; - } - } - + fn watch(&self, path: &Path) -> anyhow::Result<()> { + self.ensure_backend()?; + let result = self + .backend + .lock() + .as_mut() + .expect("watcher backend initialized") + .watch( + path, + if self.recursive { + notify::RecursiveMode::Recursive + } else { + notify::RecursiveMode::NonRecursive + }, + ); + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + if result.is_ok() { + "watch" + } else { + "watch_error" + }, + &[path.to_owned()], + match &result { + Ok(()) => format!("recursive={}", self.recursive), + Err(error) => format!("{error:?}"), + }, + false, + ) + }); + result?; Ok(()) } - fn unwatch(&self, path: &Path, mode: WatcherMode) -> anyhow::Result<()> { - let watcher = match mode { - WatcherMode::Native => self - .native_watcher - .lock() - .as_mut() - .map(|watcher| watcher.unwatch(path)), - WatcherMode::Poll => self - .poll_watcher - .lock() - .as_mut() - .map(|watcher| watcher.unwatch(path)), - }; + fn unwatch(&self, path: &Path) -> anyhow::Result<()> { + let watcher = self + .backend + .lock() + .as_mut() + .map(|watcher| watcher.unwatch(path)); + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + if matches!(&watcher, Some(Err(_))) { + "unwatch_error" + } else { + "unwatch" + }, + &[path.to_owned()], + match &watcher { + Some(Err(error)) => format!("{error:?}"), + _ => String::new(), + }, + false, + ) + }); match watcher { // inotify auto-removes a watch when its directory is deleted, so a // later unwatch races that and fails with a benign error. Either way @@ -1045,42 +1133,151 @@ impl GlobalWatcher { } } - fn ensure_native_watcher(&self) -> anyhow::Result<()> { + pub(crate) fn ensure_backend(&self) -> anyhow::Result<()> { // The lock is held across creation: with a check-then-insert under two // separate lock acquisitions, concurrent callers could each create a // watcher and the loser's insert would silently drop the winner's // watcher along with every path registered on it. - let mut native_watcher = self.native_watcher.lock(); - if native_watcher.is_none() { - // CORE excludes Access events, which Zed discards anyway. Without this, - // the default mask subscribes to inotify OPEN/CLOSE_* on Linux, so every - // file read in a watched directory would queue events, increasing the - // risk of queue overflows (and thus full rescans) under read-heavy - // workloads like grep or language server indexing. - let config = notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); - let watcher = ::new( - |event| global_watcher().enqueue(WatcherMode::Native, event), - config, - )?; - *native_watcher = Some(Box::new(watcher)); + let mut backend = self.backend.lock(); + if backend.is_none() { + *backend = Some(match self.kind { + OsWatcherKind::Native => { + // CORE excludes Access events, which Zed discards anyway. Without this, + // the default mask subscribes to inotify OPEN/CLOSE_* on Linux, so every + // file read in a watched directory would queue events, increasing the + // risk of queue overflows (and thus full rescans) under read-heavy + // workloads like grep or language server indexing. + let config = + notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); + Box::new( + ::new( + self.event_sink(), + config, + ) + .inspect_err(|error| self.record_backend_error(error))?, + ) + } + OsWatcherKind::Poll => { + let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL); + Box::new( + notify::PollWatcher::new(self.event_sink(), config) + .inspect_err(|error| self.record_backend_error(error))?, + ) + } + }); } Ok(()) } - fn ensure_poll_watcher(&self) -> anyhow::Result<()> { - let mut poll_watcher = self.poll_watcher.lock(); - if poll_watcher.is_none() { - let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL); - let watcher = notify::PollWatcher::new( - |event| global_watcher().enqueue(WatcherMode::Poll, event), - config, - )?; - *poll_watcher = Some(Box::new(watcher)); - } - Ok(()) + fn record_backend_error(&self, error: ¬ify::Error) { + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + "backend_error", + &error.paths, + format!("{error:?}"), + false, + ) + }); } } +fn dispatch( + kind: OsWatcherKind, + state: &Mutex, + event: notify::Result, +) { + let event = match event { + Ok(event) => event, + Err(error) => { + log::warn!("{kind:?} watcher error: {error}"); + return; + } + }; + + log::trace!("handle {kind:?} watcher event: {event:?}"); + + let callbacks = { + let state = state.lock(); + if event.need_rescan() { + state + .watchers + .values() + .map(|registration| registration.callback.clone()) + .collect::>() + } else { + let mut ids = Vec::new(); + for path in &event.paths { + let sanitized = SanitizedPath::new(path); + state.paths.watcher_ids_covering(sanitized, &mut ids); + } + ids.sort_unstable_by_key(|id| id.0); + ids.dedup(); + ids.into_iter() + .filter_map(|id| state.watchers.get(&id)) + .map(|registration| registration.callback.clone()) + .collect::>() + } + }; + + for callback in callbacks { + callback(&event); + } +} + +fn dispatch_batch( + kind: OsWatcherKind, + state: &Mutex, + rescan_history: &mut diagnostics::RescanHistory, + first: notify::Result, + event_rx: &async_channel::Receiver>, +) { + // A single backend overflow can enqueue many pathless rescan markers, each + // of which rescans every watched root, so one covers the entire + // drained batch. Rescans that name paths only cover those subtrees and are + // all dispatched; ordinary events still run. + let mut rescan_dispatched = false; + + for event in std::iter::once(first).chain(std::iter::from_fn(|| event_rx.try_recv().ok())) { + if event + .as_ref() + .is_ok_and(|event| event.need_rescan() && event.paths.is_empty()) + { + if rescan_dispatched { + continue; + } + rescan_dispatched = true; + } + + // Log writes can block, so keep them off the native watcher reader. + if let Ok(event) = &event + && let Some(report) = rescan_history.record(event) + { + log::error!("{kind:?} filesystem watcher requested rescan: {report}"); + } + dispatch(kind, state, event); + } +} + +fn enqueue( + event_tx: &async_channel::Sender>, + event: notify::Result, +) { + if matches!( + event, + Ok(Event { + kind: EventKind::Access(_), + .. + }) + ) { + return; + } + + // A failed send only happens once the dispatch thread has shut down, at + // which point there's nothing left to dispatch to. + event_tx.try_send(event).ok(); +} + fn is_max_files_watch_error(error: &anyhow::Error) -> bool { error .downcast_ref::() @@ -1109,34 +1306,6 @@ pub fn poll_interval() -> Duration { *POLL_INTERVAL } -static FS_WATCHER_INSTANCE: OnceLock = OnceLock::new(); - -fn global_watcher() -> &'static GlobalWatcher { - FS_WATCHER_INSTANCE.get_or_init(|| { - let (event_tx, event_rx) = async_channel::unbounded::(); - std::thread::Builder::new() - .name("fs-watcher-dispatch".to_owned()) - .spawn(move || { - while let Ok(first) = event_rx.recv_blocking() { - global_watcher().dispatch_batch(first, &event_rx); - } - }) - .expect("failed to spawn fs watcher dispatch thread"); - GlobalWatcher { - state: Mutex::new(WatcherState { - watchers: Default::default(), - native_path_registrations: Default::default(), - poll_path_registrations: Default::default(), - cooldown_until: None, - last_registration: Default::default(), - }), - native_watcher: Mutex::new(None), - poll_watcher: Mutex::new(None), - event_tx, - } - }) -} - #[cfg(test)] mod tests { use super::*; @@ -1190,37 +1359,31 @@ mod tests { } } - fn test_watcher(poll_watcher: Arc>) -> GlobalWatcher { - test_watcher_with_backends(None, Some(poll_watcher)) - } - - fn test_watcher_with_backends( - native_watcher: Option>>, - poll_watcher: Option>>, - ) -> GlobalWatcher { - // Tests call `handle_event` directly to exercise dispatch synchronously, + fn test_os_watcher( + kind: OsWatcherKind, + backend: Option>>, + ) -> OsWatcher { + // Tests call `dispatch` directly to exercise dispatch synchronously, // rather than going through the OS watcher callbacks, so nothing is ever // sent on this channel; the receiver can just be dropped. let (event_tx, _event_rx) = async_channel::unbounded(); - GlobalWatcher { - state: Mutex::new(WatcherState { + OsWatcher { + kind, + recursive: kind.is_recursive(), + state: Arc::new(Mutex::new(WatcherState { watchers: Default::default(), - native_path_registrations: Default::default(), - poll_path_registrations: Default::default(), + paths: Default::default(), cooldown_until: None, last_registration: Default::default(), - }), - native_watcher: Mutex::new( - native_watcher.map(|watcher| { - Box::new(SharedFakeWatchBackend(watcher)) as Box - }), - ), - poll_watcher: Mutex::new( - poll_watcher.map(|watcher| { + })), + backend: Mutex::new( + backend.map(|watcher| { Box::new(SharedFakeWatchBackend(watcher)) as Box }), ), event_tx, + _dispatch_task: Task::ready(()), + diagnostics: Default::default(), } } @@ -1235,16 +1398,16 @@ mod tests { #[test] fn covered_child_registration_is_not_unwatched_after_parent_is_removed() { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher(backend.clone()); + let watcher = test_os_watcher(OsWatcherKind::Poll, Some(backend.clone())); let parent = Arc::::from(Path::new("/repo")); let child = Arc::::from(Path::new("/repo/foo.csproj")); let parent_registration = watcher - .add(parent.as_ref().into(), WatcherMode::Poll, false, |_| {}) + .add(parent.as_ref().into(), false, |_| {}) .expect("add parent watch") .expect("parent watch registered"); let child_registration = watcher - .add(child.as_ref().into(), WatcherMode::Poll, false, |_| {}) + .add(child.as_ref().into(), false, |_| {}) .expect("add covered child watch") .expect("child watch registered"); @@ -1263,7 +1426,14 @@ mod tests { let (tx, rx) = async_channel::unbounded(); let pending_path_events: Arc>> = Default::default(); - let watcher = FsWatcher::new(cx.executor(), tx, pending_path_events.clone()); + let watcher = FsWatcher::new( + OsWatcher::new(OsWatcherKind::Native, cx.executor()), + OsWatcher::new(OsWatcherKind::Poll, cx.executor()), + crate::RealFs::new(None, cx.executor()), + cx.executor(), + tx, + pending_path_events.clone(), + ); watcher .add(&path) @@ -1312,16 +1482,15 @@ mod tests { fail_with_watch_limit: true, ..Default::default() })); - let poll_backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(native_backend.clone()), Some(poll_backend)); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(native_backend.clone())); let first_path = Arc::::from(Path::new("/repo/first")); let second_path = Arc::::from(Path::new("/repo/second")); let first_registration = watcher - .add(first_path.clone(), WatcherMode::Native, false, |_| {}) + .add(first_path.clone(), false, |_| {}) .expect("native watch limit is handled"); let second_registration = watcher - .add(second_path, WatcherMode::Native, false, |_| {}) + .add(second_path, false, |_| {}) .expect("native watch limit backoff is handled"); assert!(first_registration.is_none()); @@ -1338,22 +1507,17 @@ mod tests { } } - fn recording_watcher() -> (GlobalWatcher, Arc>>) { + fn recording_watcher() -> (OsWatcher, Arc>>) { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(backend), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(backend)); let fired = Arc::new(Mutex::new(Vec::new())); for dir in ["/repo/a", "/repo/a/nested", "/repo/b"] { let fired = fired.clone(); let label = dir.to_owned(); watcher - .add( - Arc::::from(Path::new(dir)), - WatcherMode::Native, - false, - move |_| { - fired.lock().push(label.clone()); - }, - ) + .add(Arc::::from(Path::new(dir)), false, move |_| { + fired.lock().push(label.clone()); + }) .expect("add watch") .expect("watch registered"); } @@ -1364,7 +1528,7 @@ mod tests { fn event_dispatches_only_to_registrations_covering_its_path() { let (watcher, fired) = recording_watcher(); - watcher.dispatch(WatcherMode::Native, Ok(modify_event("/repo/a/file.txt"))); + watcher.dispatch(Ok(modify_event("/repo/a/file.txt"))); // Only the directory containing the file resolves; siblings stay untouched. assert_eq!(*fired.lock(), vec!["/repo/a".to_owned()]); @@ -1374,10 +1538,7 @@ mod tests { fn event_dispatches_to_every_ancestor_registration() { let (watcher, fired) = recording_watcher(); - watcher.dispatch( - WatcherMode::Native, - Ok(modify_event("/repo/a/nested/file.txt")), - ); + watcher.dispatch(Ok(modify_event("/repo/a/nested/file.txt"))); // Both the directory containing the file and the ancestor watching it // resync, each exactly once, matching the previous broadcast-and-filter @@ -1428,84 +1589,56 @@ mod tests { #[test] fn case_insensitive_registration_matches_differently_cased_event() { let (fired, cb) = fired_count(); - let watcher = test_watcher_with_backends(Some(Default::default()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(Default::default())); watcher - .add( - Path::new("/Repo/Project").into(), - WatcherMode::Native, - true, - cb, - ) + .add(Path::new("/Repo/Project").into(), true, cb) .expect("add") .expect("registered"); // Event arrives lowercased (as TSGO/macOS may report it). - watcher.dispatch( - WatcherMode::Native, - Ok(modify_event("/repo/project/file.txt")), - ); + watcher.dispatch(Ok(modify_event("/repo/project/file.txt"))); assert_eq!(*fired.lock(), 1); } #[test] fn case_insensitive_registration_survives_case_only_rename() { let (fired, cb) = fired_count(); - let watcher = test_watcher_with_backends(Some(Default::default()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(Default::default())); watcher - .add( - Path::new("/Repo/Proj").into(), - WatcherMode::Native, - true, - cb, - ) + .add(Path::new("/Repo/Proj").into(), true, cb) .expect("add") .expect("registered"); // The watched directory was renamed to a different casing; events now // arrive under the new spelling. - watcher.dispatch(WatcherMode::Native, Ok(modify_event("/Repo/PROJ/file.txt"))); + watcher.dispatch(Ok(modify_event("/Repo/PROJ/file.txt"))); assert_eq!(*fired.lock(), 1); } #[test] fn case_sensitive_registration_ignores_differently_cased_event() { let (fired, cb) = fired_count(); - let watcher = test_watcher_with_backends(Some(Default::default()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(Default::default())); watcher - .add( - Path::new("/Repo/proj").into(), - WatcherMode::Native, - false, - cb, - ) + .add(Path::new("/Repo/proj").into(), false, cb) .expect("add") .expect("registered"); // On a case-sensitive volume these are genuinely different directories. - watcher.dispatch(WatcherMode::Native, Ok(modify_event("/Repo/PROJ/file.txt"))); + watcher.dispatch(Ok(modify_event("/Repo/PROJ/file.txt"))); assert_eq!(*fired.lock(), 0); } #[test] fn differently_cased_adds_dedupe_on_case_insensitive_volume() { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(backend.clone()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(backend.clone())); watcher - .add( - Path::new("/Repo/Proj").into(), - WatcherMode::Native, - true, - |_| {}, - ) + .add(Path::new("/Repo/Proj").into(), true, |_| {}) .expect("add") .expect("registered"); watcher - .add( - Path::new("/repo/proj").into(), - WatcherMode::Native, - true, - |_| {}, - ) + .add(Path::new("/repo/proj").into(), true, |_| {}) .expect("add") .expect("registered"); @@ -1516,18 +1649,13 @@ mod tests { #[test] fn recursive_parent_covers_differently_cased_child() { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher(backend.clone()); + let watcher = test_os_watcher(OsWatcherKind::Poll, Some(backend.clone())); watcher - .add(Path::new("/Repo").into(), WatcherMode::Poll, true, |_| {}) + .add(Path::new("/Repo").into(), true, |_| {}) .expect("add") .expect("registered"); watcher - .add( - Path::new("/repo/child").into(), - WatcherMode::Poll, - true, - |_| {}, - ) + .add(Path::new("/repo/child").into(), true, |_| {}) .expect("add"); // The child is covered by the recursive parent despite the case mismatch. @@ -1535,14 +1663,14 @@ mod tests { } #[test] - fn rescan_event_broadcasts_to_all_registrations_of_the_same_mode() { + fn rescan_event_broadcasts_to_all_registrations_of_the_same_backend() { let (watcher, fired) = recording_watcher(); let rescan = notify::Event::new(EventKind::Other).set_flag(notify::event::Flag::Rescan); - watcher.dispatch(WatcherMode::Native, Ok(rescan)); + watcher.dispatch(Ok(rescan)); // A pathless rescan may have missed events anywhere, so every registration - // of that mode resyncs, regardless of which directory it watches. + // of that backend resyncs, regardless of which directory it watches. let mut got = fired.lock().clone(); got.sort(); assert_eq!( @@ -1561,13 +1689,11 @@ mod tests { let (event_tx, event_rx) = async_channel::unbounded(); let rescan = || notify::Event::new(EventKind::Other).set_flag(notify::event::Flag::Rescan); + event_tx.try_send(Ok(rescan())).unwrap(); event_tx - .try_send((WatcherMode::Native, Ok(rescan()))) + .try_send(Ok(modify_event("/repo/a/file.txt"))) .unwrap(); - event_tx - .try_send((WatcherMode::Native, Ok(modify_event("/repo/a/file.txt")))) - .unwrap(); - watcher.dispatch_batch((WatcherMode::Native, Ok(rescan())), &event_rx); + watcher.dispatch_batch(Ok(rescan()), &event_rx); let mut got = fired.lock().clone(); got.sort(); @@ -1582,6 +1708,37 @@ mod tests { ); } + #[test] + fn queued_rescans_naming_distinct_paths_are_all_dispatched() { + let (watcher, fired) = recording_watcher(); + let (event_tx, event_rx) = async_channel::unbounded(); + let rescan_of = |path: &str| { + notify::Event::new(EventKind::Other) + .set_flag(notify::event::Flag::Rescan) + .add_path(PathBuf::from(path)) + }; + + event_tx.try_send(Ok(rescan_of("/repo/b"))).unwrap(); + watcher.dispatch_batch(Ok(rescan_of("/repo/a/nested")), &event_rx); + + // Each named rescan only covers its own subtree once it reaches the + // callbacks, so collapsing the second one would lose /repo/b's resync. + // Both are broadcast to every registration of the backend. + let mut got = fired.lock().clone(); + got.sort(); + assert_eq!( + got, + vec![ + "/repo/a".to_owned(), + "/repo/a".to_owned(), + "/repo/a/nested".to_owned(), + "/repo/a/nested".to_owned(), + "/repo/b".to_owned(), + "/repo/b".to_owned(), + ] + ); + } + #[test] #[cfg(target_os = "windows")] fn event_dispatches_when_reported_path_has_verbatim_prefix() { @@ -1589,14 +1746,13 @@ mod tests { // verbatim `\\?\` long-path form even when the directory was registered // without it. let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(backend), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(backend)); let fired = Arc::new(Mutex::new(Vec::new())); { let fired = fired.clone(); watcher .add( Arc::::from(Path::new("C:\\repo\\src")), - WatcherMode::Native, false, move |_| fired.lock().push(()), ) @@ -1604,10 +1760,7 @@ mod tests { .expect("watch registered"); } - watcher.dispatch( - WatcherMode::Native, - Ok(modify_event("\\\\?\\C:\\repo\\src\\main.rs")), - ); + watcher.dispatch(Ok(modify_event("\\\\?\\C:\\repo\\src\\main.rs"))); assert_eq!(fired.lock().len(), 1); } @@ -1675,9 +1828,3 @@ mod tests { } } } - -pub fn global(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result { - let global_watcher = global_watcher(); - global_watcher.ensure_native_watcher()?; - Ok(f(global_watcher)) -} diff --git a/crates/fs/src/fs_watcher/diagnostics.rs b/crates/fs/src/fs_watcher/diagnostics.rs new file mode 100644 index 0000000000..aae768c935 --- /dev/null +++ b/crates/fs/src/fs_watcher/diagnostics.rs @@ -0,0 +1,395 @@ +use super::{OsWatcher, OsWatcherKind}; +use parking_lot::Mutex; +use serde::Serialize; +use std::{ + cell::LazyCell, + collections::VecDeque, + path::PathBuf, + sync::{Arc, Weak}, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; + +const EVENT_CAPACITY: usize = 10_000; +const RESCAN_PATH_HISTORY_CAPACITY: usize = 10; + +/// An opt-in recording. Watchers hold only weak references to its buffer, so +/// dropping this value stops collection without changing any watches. +pub struct WatchRecording { + state: Arc>, + watchers: Vec>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchSnapshot { + pub started_at_unix_millis: u128, + pub captured_at_unix_millis: u128, + pub capacity: usize, + pub dropped_events: u64, + pub watchers: Vec, + pub events: Vec>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatcherSnapshot { + pub backend: OsWatcherKind, + pub recursive: bool, + pub roots: Vec, + pub cooldown_remaining_millis: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchRoot { + pub path: String, + pub registrations: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchDiagnosticEvent { + pub timestamp_unix_millis: u128, + pub backend: OsWatcherKind, + pub operation: String, + pub event_kind: Option, + /// Paths are displayed lossily so a non-UTF-8 filename cannot prevent export. + pub paths: Vec, + pub detail: String, + pub rescan: bool, +} + +impl WatchDiagnosticEvent { + pub(super) fn new( + backend: OsWatcherKind, + operation: &str, + paths: &[PathBuf], + detail: String, + rescan: bool, + ) -> Self { + Self { + timestamp_unix_millis: unix_millis(), + backend, + operation: operation.into(), + event_kind: None, + paths: paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + detail, + rescan, + } + } + + pub(super) fn from_notify_event(backend: OsWatcherKind, event: ¬ify::Event) -> Self { + Self { + event_kind: Some(format!("{:?}", event.kind)), + ..Self::new( + backend, + "event", + &event.paths, + format!("{:?} {:?}", event.kind, event.attrs), + event.need_rescan(), + ) + } + } +} + +struct RecordingState { + started_at_unix_millis: u128, + dropped_events: u64, + events: VecDeque>, +} + +#[derive(Default)] +pub(super) struct DiagnosticRecorder { + recordings: Mutex>>>, +} + +impl DiagnosticRecorder { + pub(super) fn record(&self, event: impl FnOnce() -> WatchDiagnosticEvent) { + let mut recordings = self.recordings.lock(); + // Formatting raw events and allocating paths must not happen when + // there is no debug window recording them. + let event = LazyCell::new(|| Arc::new(event())); + recordings.retain(|recording| { + let Some(recording) = recording.upgrade() else { + return false; + }; + let mut recording = recording.lock(); + if recording.events.len() == EVENT_CAPACITY { + recording.events.pop_front(); + recording.dropped_events += 1; + } + recording.events.push_back((*event).clone()); + true + }); + } +} + +#[derive(Default)] +pub(super) struct RescanHistory { + paths: VecDeque, +} + +impl RescanHistory { + pub(super) fn record(&mut self, event: ¬ify::Event) -> Option { + let report = event.need_rescan().then(|| { + // Capture before inserting the rescan so the report includes all ten + // preceding paths, even when this is the start of an overflow burst. + format!( + "reason={:?}, paths={:?}, recent_paths={:?}", + event.info().unwrap_or("unspecified"), + event.paths, + self.paths, + ) + }); + let first_path = event + .paths + .len() + .saturating_sub(RESCAN_PATH_HISTORY_CAPACITY); + for path in event.paths.iter().skip(first_path) { + if self.paths.len() == RESCAN_PATH_HISTORY_CAPACITY { + self.paths.pop_front(); + } + self.paths.push_back(path.clone()); + } + report + } +} + +impl WatchRecording { + pub(crate) fn new(watchers: impl IntoIterator>) -> Self { + let state = Arc::new(Mutex::new(RecordingState { + started_at_unix_millis: unix_millis(), + dropped_events: 0, + events: VecDeque::new(), + })); + let watchers: Vec<_> = watchers.into_iter().collect(); + for watcher in &watchers { + let mut recordings = watcher.diagnostics.recordings.lock(); + recordings.retain(|recording| recording.strong_count() > 0); + recordings.push(Arc::downgrade(&state)); + } + Self { state, watchers } + } + + pub fn snapshot(&self) -> WatchSnapshot { + let (started_at_unix_millis, dropped_events, events) = { + let state = self.state.lock(); + ( + state.started_at_unix_millis, + state.dropped_events, + state.events.iter().cloned().collect(), + ) + }; + let watchers = self + .watchers + .iter() + .map(|watcher| { + let state = watcher.state.lock(); + let mut roots: Vec<_> = state + .paths + .0 + .values() + .filter(|path| path.has_os_watcher) + .filter_map(|path| { + let registration = state.watchers.get(path.watcher_ids.first()?)?; + Some(WatchRoot { + path: registration.path.as_path().to_string_lossy().into_owned(), + registrations: path.watcher_ids.len(), + }) + }) + .collect(); + roots.sort_by(|left, right| left.path.cmp(&right.path)); + WatcherSnapshot { + backend: watcher.kind, + recursive: watcher.recursive, + roots, + cooldown_remaining_millis: state + .cooldown_until + .and_then(|until| until.checked_duration_since(Instant::now())) + .map(|remaining| remaining.as_millis()), + } + }) + .collect(); + WatchSnapshot { + started_at_unix_millis, + captured_at_unix_millis: unix_millis(), + capacity: EVENT_CAPACITY, + dropped_events, + watchers, + events, + } + } +} + +fn unix_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs_watcher::WatchBackend; + use gpui::TestAppContext; + use notify::{Event, EventKind, event::Flag}; + use std::path::Path; + + #[test] + fn rescan_report_contains_ten_preceding_paths_and_reason() { + let mut history = RescanHistory::default(); + for range in [0..12, 12..13] { + let mut event = Event::new(EventKind::Create(notify::event::CreateKind::File)); + event.paths = range + .map(|index| PathBuf::from(format!("file-{index}"))) + .collect(); + assert!(history.record(&event).is_none()); + } + let rescan = Event::new(EventKind::Other) + .set_flag(Flag::Rescan) + .set_info("rescan: kernel dropped") + .add_path("root".into()); + let report = history + .record(&rescan) + .expect("the first rescan should produce a report"); + + assert_eq!( + report, + r#"reason="rescan: kernel dropped", paths=["root"], recent_paths=["file-3", "file-4", "file-5", "file-6", "file-7", "file-8", "file-9", "file-10", "file-11", "file-12"]"# + ); + assert_eq!(history.paths.len(), RESCAN_PATH_HISTORY_CAPACITY); + } + + struct Backend; + + impl WatchBackend for Backend { + fn watch(&mut self, path: &Path, _: notify::RecursiveMode) -> notify::Result<()> { + if path.ends_with("fail") { + Err(notify::Error::generic("watch failed").add_path(path.to_owned())) + } else { + Ok(()) + } + } + + fn unwatch(&mut self, path: &Path) -> notify::Result<()> { + Err(notify::Error::generic("unwatch failed").add_path(path.to_owned())) + } + } + + fn watcher(kind: OsWatcherKind, cx: &TestAppContext) -> Arc { + OsWatcher::with_backend( + kind, + cx.background_executor.clone(), + Some(Box::new(Backend)), + ) + } + + #[gpui::test] + fn recording_is_opt_in_and_bounded(cx: &TestAppContext) { + let watcher = watcher(OsWatcherKind::Native, cx); + watcher.diagnostics.record(|| panic!("not recording")); + let recording = WatchRecording::new([watcher.clone()]); + let state = Arc::downgrade(&recording.state); + for index in 0..EVENT_CAPACITY + 3 { + watcher.diagnostics.record(|| { + WatchDiagnosticEvent::new( + OsWatcherKind::Native, + "event", + &[], + index.to_string(), + false, + ) + }); + } + let snapshot = recording.snapshot(); + assert_eq!(snapshot.events.len(), EVENT_CAPACITY); + assert_eq!(snapshot.dropped_events, 3); + assert_eq!(snapshot.events.first().unwrap().detail, "3"); + assert_eq!( + snapshot.events.last().unwrap().detail, + (EVENT_CAPACITY + 2).to_string() + ); + + drop(recording); + assert!(state.upgrade().is_none()); + watcher + .diagnostics + .record(|| panic!("recording was dropped")); + let recording = WatchRecording::new([watcher]); + assert!(recording.snapshot().events.is_empty()); + } + + #[gpui::test] + fn recording_keeps_raw_events_errors_and_rescans(cx: &TestAppContext) { + let native = watcher(OsWatcherKind::Native, cx); + let poll = watcher(OsWatcherKind::Poll, cx); + let recording = WatchRecording::new([native.clone(), poll.clone()]); + let native_sink = native.event_sink(); + let poll_sink = poll.event_sink(); + + // Access events and duplicate overflows are filtered on their way to + // worktrees, but diagnostics must preserve what the backend reported. + native_sink(Ok(Event::new(EventKind::Access( + notify::event::AccessKind::Any, + )))); + for _ in 0..2 { + native_sink(Ok(Event::new(EventKind::Other).set_flag(Flag::Rescan))); + } + poll_sink(Err( + notify::Error::generic("read failed").add_path(util::path!("/root/file").into()) + )); + + let snapshot = recording.snapshot(); + assert_eq!(snapshot.events.len(), 4); + assert!(snapshot.events[0].detail.contains("Access")); + assert_eq!( + snapshot.events[0].event_kind.as_deref(), + Some("Access(Any)") + ); + assert!(snapshot.events[1].rescan); + assert!(snapshot.events[2].rescan); + assert_eq!(snapshot.events[3].backend, OsWatcherKind::Poll); + assert_eq!(snapshot.events[3].operation, "error"); + assert_eq!(snapshot.events[3].event_kind, None); + assert!(snapshot.events[3].detail.contains("read failed")); + assert_eq!(snapshot.events[3].paths, [util::path!("/root/file")]); + let json = serde_json::to_value(snapshot).unwrap(); + assert_eq!(json["events"][2]["rescan"], true); + assert_eq!(json["events"][3]["paths"][0], util::path!("/root/file")); + } + + #[gpui::test] + fn recording_snapshots_existing_roots_and_registration_errors(cx: &TestAppContext) { + let watcher = watcher(OsWatcherKind::Native, cx); + let root = util::path!("/root"); + let registration = watcher + .add(Path::new(root).into(), false, |_| {}) + .unwrap() + .unwrap(); + let duplicate = watcher + .add(Path::new(root).into(), false, |_| {}) + .unwrap() + .unwrap(); + let recording = WatchRecording::new([watcher.clone()]); + let snapshot = recording.snapshot(); + assert!(snapshot.events.is_empty()); + assert_eq!(snapshot.watchers[0].roots.len(), 1); + assert_eq!(snapshot.watchers[0].roots[0].path, root); + assert_eq!(snapshot.watchers[0].roots[0].registrations, 2); + + assert!( + watcher + .add(Path::new(util::path!("/fail")).into(), false, |_| {}) + .is_err() + ); + watcher.remove(duplicate); + assert_eq!(recording.snapshot().watchers[0].roots[0].registrations, 1); + watcher.remove(registration); + let snapshot = recording.snapshot(); + assert!(snapshot.watchers[0].roots.is_empty()); + assert_eq!(snapshot.events[0].operation, "watch_error"); + assert!(snapshot.events[0].detail.contains("watch failed")); + assert_eq!(snapshot.events[1].operation, "unwatch_error"); + assert!(snapshot.events[1].detail.contains("unwatch failed")); + } +} diff --git a/crates/fs/src/git_clone_progress.rs b/crates/fs/src/git_clone_progress.rs new file mode 100644 index 0000000000..4adf8fe10e --- /dev/null +++ b/crates/fs/src/git_clone_progress.rs @@ -0,0 +1,141 @@ +use futures::AsyncRead; +use smol::io::AsyncReadExt; +use std::io; + +pub(crate) async fn read( + mut stderr: impl AsyncRead + Unpin, + mut on_progress: impl FnMut(String), +) -> io::Result> { + let mut stderr_output = Vec::new(); + let mut read_buffer = [0; 8192]; + let mut progress = GitCloneProgress::new(); + + loop { + let bytes_read = stderr.read(&mut read_buffer).await?; + if bytes_read == 0 { + break; + } + let bytes = &read_buffer[..bytes_read]; + stderr_output.extend_from_slice(bytes); + progress.push(bytes, &mut on_progress); + } + progress.finish(on_progress); + Ok(stderr_output) +} + +pub(crate) fn failure_message(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .split(['\r', '\n']) + .rev() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("unknown error") + .to_owned() +} + +struct GitCloneProgress { + pending: Vec, + last_message: Option, +} + +impl GitCloneProgress { + fn new() -> Self { + Self { + pending: Vec::new(), + last_message: None, + } + } + + fn push(&mut self, bytes: &[u8], mut on_progress: impl FnMut(String)) { + for byte in bytes { + if matches!(*byte, b'\r' | b'\n') { + self.flush(&mut on_progress); + } else { + self.pending.push(*byte); + } + } + } + + fn finish(&mut self, mut on_progress: impl FnMut(String)) { + self.flush(&mut on_progress); + } + + fn flush(&mut self, on_progress: &mut impl FnMut(String)) { + if self.pending.is_empty() { + return; + } + + let message = String::from_utf8_lossy(&self.pending).trim().to_owned(); + if !message.is_empty() && self.last_message.as_ref() != Some(&message) { + self.last_message = Some(message.clone()); + on_progress(message); + } + self.pending.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_fragmented_progress_and_suppresses_duplicates() { + let mut progress = GitCloneProgress::new(); + let mut messages = Vec::new(); + + progress.push( + b"remote: Enumerating objects: 100, done.\rremote: Count", + |message| messages.push(message), + ); + progress.push( + b"ing objects: 42% (42/100)\rReceiving objects: 100% (100/100)\rReceiving objects: 100% (100/100)\n", + |message| messages.push(message), + ); + progress.push(b"Updating files: 75% (3/4)", |message| { + messages.push(message) + }); + progress.finish(|message| messages.push(message)); + + assert_eq!( + messages, + [ + "remote: Enumerating objects: 100, done.", + "remote: Counting objects: 42% (42/100)", + "Receiving objects: 100% (100/100)", + "Updating files: 75% (3/4)", + ] + ); + } + + #[test] + fn forwards_unrecognized_git_output() { + let mut progress = GitCloneProgress::new(); + let mut messages = Vec::new(); + + progress.push(b"Cloning into 'repository'...\n", |message| { + messages.push(message) + }); + progress.push(b"A future Git progress phase: 12%\r", |message| { + messages.push(message) + }); + + assert_eq!( + messages, + [ + "Cloning into 'repository'...", + "A future Git progress phase: 12%" + ] + ); + } + + #[test] + fn extracts_the_last_failure_line() { + assert_eq!( + failure_message( + b"Receiving objects: 42% (42/100)\rfatal: unable to access repository\n" + ), + "fatal: unable to access repository" + ); + assert_eq!(failure_message(b""), "unknown error"); + } +} diff --git a/crates/fs/tests/integration/fs_tests.rs b/crates/fs/tests/integration/fs_tests.rs index a18aef6edd..5bd132d4e3 100644 --- a/crates/fs/tests/integration/fs_tests.rs +++ b/crates/fs/tests/integration/fs_tests.rs @@ -17,6 +17,56 @@ use serde_json::json; use tempfile::TempDir; use util::path; +#[gpui::test] +async fn test_watcher_diagnostics_do_not_change_event_delivery(executor: BackgroundExecutor) { + let fs = FakeFs::new(executor); + let root = Path::new(path!("/root")); + let file = root.join("file"); + fs.create_dir(root).await.unwrap(); + let (mut events, watcher) = fs.watch(root, Duration::ZERO).await; + let recording = fs.record_watcher_diagnostics().unwrap(); + assert_eq!( + recording.snapshot().watchers[0].roots[0].path, + root.to_string_lossy() + ); + assert!(recording.snapshot().events.is_empty()); + + fs.write(&file, b"first").await.unwrap(); + let batch = events.next().await.unwrap(); + assert!(batch.iter().any(|event| event.path == file)); + assert!(recording.snapshot().events.iter().any(|event| { + event.operation == "event" && event.paths.contains(&file.to_string_lossy().into_owned()) + })); + + fs.simulate_watcher_overflow(root); + let batch = events.next().await.unwrap(); + assert!( + batch + .iter() + .any(|event| event.kind == Some(PathEventKind::Rescan)) + ); + assert!(recording.snapshot().events.iter().any(|event| event.rescan)); + + drop(recording); + fs.write(&file, b"second").await.unwrap(); + let batch = events.next().await.unwrap(); + assert!(batch.iter().any(|event| event.path == file)); + assert!( + fs.record_watcher_diagnostics() + .unwrap() + .snapshot() + .events + .is_empty() + ); + drop(events); + drop(watcher); + assert!( + fs.record_watcher_diagnostics().unwrap().snapshot().watchers[0] + .roots + .is_empty() + ); +} + #[gpui::test] async fn test_fake_fs(executor: BackgroundExecutor) { let fs = FakeFs::new(executor.clone()); @@ -631,11 +681,11 @@ async fn test_fake_fs_rename_ignore_if_exists_leaves_source_and_target_unchanged "from target" ); - // An ignored rename must not be recorded as a move either, or a handle held - // across it reports a path its file never went to. - assert!( - handle.current_path(&(fs.clone() as Arc)).is_err(), - "an ignored rename should not record a move" + // A handle held across an ignored rename must keep reporting the path the + // file is actually at, not the one it never went to. + assert_eq!( + handle.current_path(&(fs.clone() as Arc)).unwrap(), + PathBuf::from(path!("/root/source.txt")) ); } @@ -666,6 +716,49 @@ async fn test_fake_fs_rename_onto_itself_keeps_the_file(executor: BackgroundExec assert_eq!(fs.load(path).await.unwrap(), "content"); } +#[gpui::test] +#[cfg(unix)] +async fn test_realfs_executable_metadata(executor: BackgroundExecutor) { + use std::os::unix::fs::PermissionsExt as _; + + let tempdir = TempDir::new().unwrap(); + let path = tempdir.path(); + let non_executable_path = path.join("non-executable.sh"); + let executable_path = path.join("executable.sh"); + let symlink_path = path.join("executable-symlink.sh"); + + std::fs::write(&non_executable_path, "#!/bin/sh\n").unwrap(); + std::fs::write(&executable_path, "#!/bin/sh\n").unwrap(); + let mut permissions = std::fs::metadata(&executable_path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable_path, permissions).unwrap(); + + let fs = RealFs::new(None, executor); + gpui::block_on(fs.create_symlink(&symlink_path, PathBuf::from("executable.sh"))).unwrap(); + + let non_executable_metadata = fs + .metadata(&non_executable_path) + .await + .expect("metadata call succeeds") + .expect("metadata returned"); + assert!(!non_executable_metadata.is_executable); + + let executable_metadata = fs + .metadata(&executable_path) + .await + .expect("metadata call succeeds") + .expect("metadata returned"); + assert!(executable_metadata.is_executable); + + let symlink_metadata = fs + .metadata(&symlink_path) + .await + .expect("metadata call succeeds") + .expect("metadata returned"); + assert!(symlink_metadata.is_symlink); + assert!(symlink_metadata.is_executable); +} + #[gpui::test] #[cfg(unix)] async fn test_realfs_broken_symlink_metadata(executor: BackgroundExecutor) { @@ -918,6 +1011,59 @@ async fn watcher_delivered_event( } } +#[gpui::test] +async fn test_realfs_watcher_diagnostics(executor: BackgroundExecutor, cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let fs = RealFs::new(None, executor.clone()); + let directory = TempDir::new().unwrap(); + let root = std::fs::canonicalize(directory.path()).unwrap(); + // Windows canonicalization adds a verbatim prefix that watcher paths omit. + let root = util::paths::SanitizedPath::new(&root) + .as_path() + .to_path_buf(); + let recording = fs.record_watcher_diagnostics().unwrap(); + let (mut events, watcher) = fs.watch(&root, Duration::from_millis(10)).await; + let file = root.join("watcher-diagnostics.txt"); + fs.write(&file, b"first").await.unwrap(); + assert!( + watcher_delivered_event(&mut events, &executor, Duration::from_secs(5), &|path| { + path == file + }) + .await, + "no watcher event matched {file:?}: {:#?}", + recording.snapshot() + ); + let snapshot = recording.snapshot(); + assert!( + snapshot + .events + .iter() + .any(|event| event.operation == "watch") + ); + assert!(snapshot.events.iter().any(|event| { + event.operation == "event" + && (event.rescan || event.paths.contains(&file.to_string_lossy().into_owned())) + })); + assert!(snapshot.watchers.iter().any(|watcher| { + watcher + .roots + .iter() + .any(|entry| entry.path == root.to_string_lossy()) + })); + serde_json::to_string_pretty(&snapshot).unwrap(); + + drop(recording); + fs.write(&file, b"second").await.unwrap(); + assert!( + watcher_delivered_event(&mut events, &executor, Duration::from_secs(5), &|path| { + path == file + }) + .await + ); + drop(events); + drop(watcher); +} + /// Exercises a spread of real watchers whose registered watch path is spelled /// differently from the path the OS reports events under. Each scenario watches /// some directory and then mutates the on-disk file; a correct watcher must diff --git a/crates/fuzzy/src/strings.rs b/crates/fuzzy/src/strings.rs index e8921950c5..b48c1d7ffc 100644 --- a/crates/fuzzy/src/strings.rs +++ b/crates/fuzzy/src/strings.rs @@ -195,6 +195,11 @@ where } let mut results = segment_results.concat(); - gpui_util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a)); + rank_matches(&mut results, max_results); results } + +#[inline(never)] +fn rank_matches(results: &mut Vec, max_results: usize) { + gpui_util::truncate_to_bottom_n_sorted_by(results, max_results, &|a, b| b.cmp(a)); +} diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 7ecb8fa18e..72db89537e 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/git.rs" +doctest = false [features] test-support = ["rand", "gpui/test-support"] diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 72ecb59f90..7962bd6cc9 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -738,7 +738,7 @@ pub enum LogSource { impl LogSource { fn get_args(&self) -> Vec> { - match self { + let mut args = match self { LogSource::All => vec![ Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD Cow::Borrowed("--branches"), @@ -753,7 +753,14 @@ impl LogSource { Cow::Borrowed("--"), Cow::Borrowed(path.as_unix_str()), ], + }; + // Without a terminator git cannot tell a branch named `docs/rewrite` from a + // `docs/rewrite` directory in the working tree, and refuses the argument as + // ambiguous. `Path` states its own separator before the path it passes. + if !matches!(self, LogSource::Path(_)) { + args.push(Cow::Borrowed("--")); } + args } } @@ -1411,6 +1418,9 @@ impl GitRepository for RealGitRepository { "--no-patch", "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", &commit, + // `commit` reaches here as whatever the user typed, so it can name + // a branch that also names a path in the working tree. + "--", ]) .output() .await?; @@ -1464,6 +1474,7 @@ impl GitRepository for RealGitRepository { "--first-parent", ]) .arg(&commit) + .arg("--") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1925,6 +1936,7 @@ impl GitRepository for RealGitRepository { "--merge-base", base.as_str(), head.as_str(), + "--", ] .map(OsString::from) .to_vec(), @@ -1936,6 +1948,7 @@ impl GitRepository for RealGitRepository { "--no-renames", "--merge-base", base.as_str(), + "--", ] .map(OsString::from) .to_vec(), @@ -1947,6 +1960,7 @@ impl GitRepository for RealGitRepository { "--no-renames", base.as_str(), head.as_str(), + "--", ] .map(OsString::from) .to_vec(), @@ -2494,7 +2508,7 @@ impl GitRepository for RealGitRepository { } DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?, DiffType::MergeBase { base_ref } => { - git.build_command(&["diff", "--merge-base", base_ref.as_ref()]) + git.build_command(&["diff", "--merge-base", base_ref.as_ref(), "--"]) .output() .await? } @@ -3984,13 +3998,20 @@ impl GitBinary { command.args(["-c", "protocol.ext.allow=never"]); command.args(["-c", "diff.external="]); } - command.args(args); - // If the `diff` command is being used, we'll want to add the // `--no-ext-diff` flag when working on an untrusted repository, - // preventing any external diff programs from being invoked. - if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") { - command.arg("--no-ext-diff"); + // preventing any external diff programs from being invoked. It goes + // directly after the subcommand: callers end their revisions with `--`, + // and anything after that separator is read as a pathspec rather than as + // an option. + let mut args = args.iter(); + if let Some(subcommand) = args.next() { + let is_diff = subcommand.as_ref() == "diff"; + command.arg(subcommand); + if !self.is_trusted && is_diff { + command.arg("--no-ext-diff"); + } + command.args(args); } if let Some(index_file_path) = self.index_file_path.as_ref() { @@ -5292,6 +5313,164 @@ mod tests { assert_eq!(graph_data[0].sha, commit_sha); } + /// A branch whose name also names a path in the working tree - `docs/rewrite` in a + /// repository that also has a `docs/rewrite` directory - made git reject the revision + /// as ambiguous, so the git panel's History tab reported "Failed to load commit + /// history" and commit search silently returned nothing. + #[gpui::test] + async fn test_initial_graph_data_with_branch_named_after_a_path(cx: &mut TestAppContext) { + disable_git_global_config(); + + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + + git_init_repo(repo_dir.path()); + fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); + fs::write(repo_dir.path().join("docs/rewrite/notes.md"), "notes").unwrap(); + git_command(repo_dir.path(), ["add", "."]); + git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); + git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); + + let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]) + .parse() + .unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + let (request_tx, request_rx) = async_channel::unbounded(); + + repo.initial_graph_data( + LogSource::Branch("docs/rewrite".into()), + LogOrder::DateOrder, + request_tx, + ) + .await + .unwrap(); + + let graph_data = request_rx.recv().await.unwrap(); + assert_eq!(graph_data.len(), 1); + assert_eq!(graph_data[0].sha, commit_sha); + } + + /// The branch diff passes the base ref straight to `git diff`, so a branch named after + /// a path in the working tree broke it the same way. `git diff` takes the same `--` + /// terminator, so the base ref is terminated like the log sources are. + #[gpui::test] + async fn test_merge_base_worktree_diff_with_branch_named_after_a_path(cx: &mut TestAppContext) { + disable_git_global_config(); + + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + + git_init_repo(repo_dir.path()); + fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); + let notes_path = repo_dir.path().join("docs/rewrite/notes.md"); + fs::write(¬es_path, "notes\n").unwrap(); + git_command(repo_dir.path(), ["add", "."]); + git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); + git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); + + let base_oid: Oid = + git_command_output(repo_dir.path(), ["rev-parse", "HEAD:docs/rewrite/notes.md"]) + .parse() + .unwrap(); + + fs::write(¬es_path, "edited\n").unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + let diff = repo + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "docs/rewrite".into(), + }) + .await + .unwrap(); + + assert_eq!( + diff, + TreeDiff { + entries: HashMap::from_iter([( + RepoPath::new("docs/rewrite/notes.md").unwrap(), + TreeDiffStatus::Modified { old: base_oid }, + )]), + } + ); + } + + /// `show` takes the revision the user typed in the "open commit by ref" input, so a + /// branch named after a path in the working tree reaches git unqualified there too. + #[gpui::test] + async fn test_show_with_branch_named_after_a_path(cx: &mut TestAppContext) { + disable_git_global_config(); + + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + + git_init_repo(repo_dir.path()); + fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); + fs::write(repo_dir.path().join("docs/rewrite/notes.md"), "notes").unwrap(); + git_command(repo_dir.path(), ["add", "."]); + git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); + git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); + + let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]) + .parse() + .unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + let details = repo.show("docs/rewrite".to_string()).await.unwrap(); + assert_eq!(details.sha.as_ref(), commit_sha.to_string()); + } + + #[test] + fn test_log_source_terminates_revisions() { + // Every revision-taking source has to end the revision list, or git cannot tell a + // branch name from an identically named path. `Path` is the exception: it states + // the separator itself, before the path it is passing. + for source in [ + LogSource::All, + LogSource::Branch("docs/rewrite".into()), + LogSource::Sha(Oid::from_str("0000000000000000000000000000000000000000").unwrap()), + ] { + let args = source.get_args(); + assert_eq!( + args.last().map(|arg| arg.as_ref()), + Some("--"), + "{source:?} must terminate its revisions" + ); + } + + let path_source = LogSource::Path(RepoPath::new("docs/rewrite").unwrap()); + let path_args = path_source.get_args(); + assert_eq!( + path_args.iter().filter(|arg| arg.as_ref() == "--").count(), + 1, + "Path states the separator itself and must not gain a second one" + ); + } + #[gpui::test] async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) { cx.executor().allow_parking(); diff --git a/crates/git_hosting_providers/Cargo.toml b/crates/git_hosting_providers/Cargo.toml index 9480e0ec28..beb210cd4d 100644 --- a/crates/git_hosting_providers/Cargo.toml +++ b/crates/git_hosting_providers/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/git_hosting_providers.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 1dd2868db0..727d4601ed 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] name = "git_ui" path = "src/git_ui.rs" +doctest = false [features] test-support = ["multi_buffer/test-support", "remote_connection/test-support"] @@ -43,7 +44,6 @@ markdown.workspace = true menu.workspace = true multi_buffer.workspace = true notifications.workspace = true -panel.workspace = true picker.workspace = true project.workspace = true prompt_store.workspace = true diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index d9272e5fdf..a7989c422c 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -883,7 +883,7 @@ impl Render for DeleteBranchTooltip { Tooltip::with_meta_in( "Delete Branch", Some(&branch_picker::DeleteBranch), - "Hold alt to force delete", + concat!("Hold ", ui::alt_key_name!(), " to force delete"), &self.focus_handle, cx, ) diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs index b20ed28403..6c9aeb35ca 100644 --- a/crates/git_ui/src/diff_multibuffer.rs +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -10,6 +10,7 @@ use editor::{ EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::GoToHunk, multibuffer_context_lines, scroll::Autoscroll, }; +use futures::{FutureExt as _, StreamExt as _, stream}; use futures_lite::future::yield_now; use git::{repository::RepoPath, status::FileStatus}; use gpui::{ @@ -36,6 +37,10 @@ use workspace::{ }; use ztracing::instrument; +/// Loading every changed file at once makes the first one land no sooner than the +/// last, leaving a large diff on a spinner. Throughput flattens past this point. +const MAX_CONCURRENT_BUFFER_LOADS: usize = 16; + struct BufferSubscriptions { _diff: Entity, display_buffer: Entity, @@ -402,6 +407,7 @@ impl DiffMultibuffer { if !editor.focus_handle(cx).contains_focused(window, cx) { return; } + cx.emit(event.clone()); let Some(project_path) = self.active_project_path(cx) else { return; }; @@ -678,17 +684,28 @@ impl DiffMultibuffer { let mut buffers_to_fold = Vec::new(); - for (path_key, entry) in entries { - if let Some(loaded_buffer) = entry.load.await.log_err() { - // We might be lagging behind enough that all future entry.load futures are no longer pending. - // If that is the case, this task will never yield, starving the foreground thread of execution time. - yield_now().await; + // Ordered, so excerpts don't shift under the reader as later files arrive. + let mut loads = stream::iter(entries.into_iter().map(|(path_key, entry)| { + let diff_buffer_list::DiffBuffer { + repo_path, + file_status, + load, + } = entry; + load.map(move |loaded| (path_key, repo_path, file_status, loaded)) + })) + .buffered(MAX_CONCURRENT_BUFFER_LOADS); + + while let Some((path_key, repo_path, file_status, loaded)) = loads.next().await { + // Buffered loads can already be complete when we poll them, in which + // case this loop never awaits and starves the foreground thread. + yield_now().await; + if let Some(loaded_buffer) = loaded.log_err() { cx.update(|window, cx| { this.update(cx, |this, cx| { if let Some(buffer_id) = this.register_buffer( - entry.repo_path, + repo_path, path_key, - entry.file_status, + file_status, loaded_buffer.display_buffer, loaded_buffer.main_buffer, loaded_buffer.diff, @@ -720,12 +737,17 @@ impl DiffMultibuffer { pub(crate) fn active_project_path(&self, cx: &App) -> Option { let editor = self.editor.read(cx).focused_editor().read(cx); - let multibuffer = editor.buffer().read(cx); + let focused_multibuffer = editor.buffer().read(cx); let position = editor.selections.newest_anchor().head(); + let focused_snapshot = focused_multibuffer.snapshot(cx); + let (focused_anchor, _) = focused_snapshot.anchor_to_buffer_anchor(position)?; + let path = focused_snapshot.path_for_buffer(focused_anchor.buffer_id)?; + + let multibuffer = self.multibuffer.read(cx); + let position = multibuffer.location_for_path(path, cx)?; let snapshot = multibuffer.snapshot(cx); let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; let buffer = multibuffer.buffer(text_anchor.buffer_id)?; - let file = buffer.read(cx).file()?; Some(ProjectPath { worktree_id: file.worktree_id(cx), diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index d304a9db4d..97b10ea3aa 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -58,7 +58,7 @@ use ui::{ }; use util::{ResultExt, debug_panic}; use workspace::{ - ModalView, Workspace, + ItemNavHistory, ModalView, Workspace, item::{Item, ItemEvent, TabTooltipContent}, }; @@ -1329,6 +1329,7 @@ pub struct GitGraph { changed_files_view_mode: ChangedFilesViewMode, changed_files_expanded_dirs: HashMap, pending_select_sha: Option, + nav_history: Option, } impl GitGraph { @@ -1575,6 +1576,7 @@ impl GitGraph { changed_files_view_mode: ChangedFilesViewMode::default(), changed_files_expanded_dirs: HashMap::default(), pending_select_sha: None, + nav_history: None, }; this.fetch_initial_graph_data(cx); @@ -2738,18 +2740,7 @@ impl GitGraph { CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None), }; - let date_string = commit_timestamp - .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok()) - .map(|datetime| { - let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); - let local_datetime = datetime.to_offset(local_offset); - let format = - time::format_description::parse("[month repr:short] [day], [year]").ok(); - format - .and_then(|f| local_datetime.format(&f).ok()) - .unwrap_or_default() - }) - .unwrap_or_default(); + let date_string = commit_timestamp.map(format_timestamp).unwrap_or_default(); let remote = repository.update(cx, |repo, cx| { let remote_url = repo.default_remote_url()?; @@ -4178,6 +4169,21 @@ impl Item for GitGraph { fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) { f(*event) } + + fn deactivated(&mut self, _window: &mut Window, cx: &mut Context) { + if let Some(nav_history) = self.nav_history.as_mut() { + nav_history.push::<()>(None, None, cx); + } + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _window: &mut Window, + _cx: &mut Context, + ) { + self.nav_history = Some(nav_history); + } } impl workspace::SerializableItem for GitGraph { @@ -5843,6 +5849,67 @@ mod tests { }); } + #[gpui::test] + async fn test_file_history_action_resolves_through_project_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new(util::path!("/project")), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new(util::path!("/project/.git")), + &[("file.txt", "tracked".to_owned())], + ); + + let project = Project::test(fs.clone(), [Path::new(util::path!("/project"))], cx).await; + cx.run_until_parked(); + + let tracked_repo_path = RepoPath::new(&"file.txt").unwrap(); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |multi, _| multi.workspace().clone()); + + // A project diff is a `ProjectDiff` item wrapping a `SplittableEditor`, not + // an `Editor` itself, so resolving the file-history target must go through + // `act_as` rather than a direct downcast of the active item. + cx.update(|window, cx| { + window.dispatch_action(Box::new(crate::project_diff::Diff), cx); + }); + cx.run_until_parked(); + + workspace.read_with(cx, |workspace, cx| { + workspace + .active_item_as::(cx) + .expect("project diff should be the active item"); + }); + + cx.update(|window, cx| { + window.dispatch_action(Box::new(git::FileHistory), cx); + }); + cx.run_until_parked(); + + workspace.read_with(cx, |workspace, cx| { + let graphs = workspace.items_of_type::(cx).collect::>(); + assert_eq!( + graphs.len(), + 1, + "dispatching FileHistory from a project diff should open a git graph" + ); + assert_eq!( + graphs[0].read(cx).log_source, + LogSource::Path(tracked_repo_path) + ); + }); + } + #[gpui::test] fn test_serialized_state_roundtrip(_cx: &mut TestAppContext) { use persistence::SerializedGitGraphState; @@ -6852,6 +6919,130 @@ mod tests { }); } + #[gpui::test] + async fn test_go_back_from_commit_view_returns_to_git_graph(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA"); + fs.set_graph_commits( + Path::new("/project/.git"), + vec![Arc::new(InitialGraphCommitData { + sha: first_sha, + parents: smallvec![], + ref_names: vec!["HEAD -> main".into()], + })], + ); + fs.set_commit_data( + Path::new("/project/.git"), + [( + CommitData { + sha: first_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Commit subject".into(), + message: "Commit message".into(), + }, + false, + )], + ); + + let project = Project::test(fs, [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone()); + + // Open a file first, so there's something in nav history before the Git Graph tab. + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(Path::new("/project/file.txt"), cx) + }) + .await + .expect("file should open"); + let buffer_editor = cx.new_window_entity(|window, cx| { + Editor::for_buffer(buffer, Some(project.clone()), window, cx) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(buffer_editor.clone()), + None, + true, + window, + cx, + ); + }); + cx.run_until_parked(); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace.downgrade(), + None, + window, + cx, + ) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx); + }); + cx.run_until_parked(); + + git_graph.update(cx, |graph, cx| { + graph.select_commit_by_sha(first_sha, cx); + }); + cx.run_until_parked(); + + git_graph.update_in(cx, |graph, window, cx| { + graph.open_selected_commit_view(window, cx); + }); + cx.run_until_parked(); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + pane.read_with(cx, |pane, _cx| { + assert!( + pane.active_item() + .and_then(|item| item.downcast::()) + .is_some(), + "expected the commit diff view to be active after opening a commit" + ); + }); + + pane.update_in(cx, |pane, window, cx| { + pane.navigate_backward(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + pane.read_with(cx, |pane, _cx| { + let active_git_graph = pane + .active_item() + .and_then(|item| item.downcast::()); + assert_eq!( + active_git_graph, + Some(git_graph.clone()), + "Go Back from the commit diff view should return to the Git Graph view" + ); + }); + } + #[gpui::test] async fn test_git_graph_navigation(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index f4a15545a6..54582fcb0e 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -62,7 +62,6 @@ use language_model::{ use menu; use multi_buffer::ExcerptBoundaryInfo; use notifications::status_toast::StatusToast; -use panel::PanelHeader; use project::git_store::GitAccess; use project::{ Fs, Project, ProjectPath, @@ -1223,6 +1222,17 @@ pub(crate) fn commit_message_editor( commit_editor.set_show_indent_guides(false, cx); let placeholder = placeholder.unwrap_or("Enter commit message".into()); commit_editor.set_placeholder_text(&placeholder, window, cx); + commit_editor.set_custom_context_menu(|editor, _point, window, cx| { + let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx)); + let focus_handle = editor.focus_handle(cx); + + Some(ContextMenu::build(window, cx, |menu, _, _| { + menu.context(focus_handle) + .action_disabled_when(!has_selection, "Cut", Box::new(editor::actions::Cut)) + .action_disabled_when(!has_selection, "Copy", Box::new(editor::actions::Copy)) + .action("Paste", Box::new(editor::actions::Paste)) + })) + }); commit_editor } @@ -1821,7 +1831,13 @@ impl GitPanel { } if let Some(work_directory_abs_path) = active_work_directory_abs_path { let text = self.commit_message_buffer(cx).read(cx).text(); - let message = (!text.trim().is_empty()).then_some(text); + let trimmed_text = text.trim(); + let use_buffer_text = !trimmed_text.is_empty() + && self + .commit_template + .as_ref() + .is_none_or(|commit_template| commit_template.template.trim() != trimmed_text); + let message = use_buffer_text.then_some(text); let original_message = self.original_commit_message.clone(); let amend_pending = self.amend_pending; if message.is_some() || original_message.is_some() || amend_pending { @@ -1852,12 +1868,15 @@ impl GitPanel { if self.commit_editor.read(cx).is_focused(window) { dispatch_context.add("CommitEditor"); } else if self.focus_handle.contains_focused(window, cx) || self.context_menu.is_some() { - // Preserve the panel's `ChangesList` context while a context menu - // is open. Its focus handle may not appear as a descendant of the - // panel until the next frame, so `FocusHandle::contains_focused` - // would return `false`. + // Preserve the panel's list context while a context menu is open. + // Its focus handle may not appear as a descendant of the panel + // until the next frame, so `FocusHandle::contains_focused` would + // return `false`. dispatch_context.add("menu"); - dispatch_context.add("ChangesList"); + match self.active_tab { + GitPanelTab::Changes => dispatch_context.add("ChangesList"), + GitPanelTab::History => dispatch_context.add("HistoryList"), + } } dispatch_context @@ -4131,6 +4150,7 @@ impl GitPanel { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: Some(CompletionIntent::GenerateGitCommitMessage), messages: vec![LanguageModelRequestMessage { @@ -4147,6 +4167,7 @@ impl GitPanel { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let stream = model.stream_completion_text(request, cx); @@ -9164,8 +9185,6 @@ impl Panel for GitPanel { } } -impl PanelHeader for GitPanel {} - pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div { v_flex() .size_full() @@ -9706,8 +9725,8 @@ mod tests { use util::rel_path::rel_path; use workspace::{ - ActivatePaneLeft, ActivatePaneRight, MultiWorkspace, ToolbarItemEvent, ToolbarItemLocation, - item::test::TestItem, + ActivatePaneLeft, ActivatePaneRight, ItemHandle as _, MultiWorkspace, ToolbarItemEvent, + ToolbarItemLocation, item::test::TestItem, }; use super::*; @@ -11363,6 +11382,11 @@ mod tests { .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + let project_path = project.read_with(&cx, |project, cx| { + project + .find_project_path(path!("/project/partial.rs"), cx) + .expect("partial.rs should have a project path") + }); cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { @@ -11398,7 +11422,10 @@ mod tests { cx.run_until_parked(); workspace.read_with(&cx, |workspace, cx| { - assert!(workspace.active_item_as::(cx).is_some()); + let staged_diff = workspace + .active_item_as::(cx) + .expect("StagedDiff should be active"); + assert_eq!(staged_diff.project_path(cx), Some(project_path.clone())); assert_eq!(workspace.items_of_type::(cx).count(), 1); assert_eq!(workspace.items_of_type::(cx).count(), 0); assert_eq!(workspace.items_of_type::(cx).count(), 0); @@ -11423,6 +11450,7 @@ mod tests { let solo_diff = workspace .active_item_as::(cx) .expect("SoloDiffView should be active"); + assert_eq!(solo_diff.project_path(cx), Some(project_path.clone())); let searchable = solo_diff .read(cx) .as_searchable(&solo_diff, cx) @@ -11467,7 +11495,10 @@ mod tests { cx.run_until_parked(); workspace.read_with(&cx, |workspace, cx| { - assert!(workspace.active_item_as::(cx).is_some()); + let unstaged_diff = workspace + .active_item_as::(cx) + .expect("UnstagedDiff should be active"); + assert_eq!(unstaged_diff.project_path(cx), Some(project_path)); assert_eq!(workspace.items_of_type::(cx).count(), 1); assert_eq!(workspace.items_of_type::(cx).count(), 1); assert_eq!(workspace.items_of_type::(cx).count(), 0); @@ -12145,6 +12176,198 @@ mod tests { }); } + #[gpui::test] + async fn test_commit_template_applied_fresh_after_template_file_change( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { "main.rs": "fn main() {}" } + } + }), + ) + .await; + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[("src/main.rs", StatusCode::Modified.worktree())], + ); + fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| { + state.commit_template = Some(GitCommitTemplate { + template: "Template1\n".to_string(), + }) + }) + .unwrap(); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let repository = project.read_with(cx, |project, cx| { + project + .git_store() + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }); + repository.update(cx, |repository, cx| repository.set_as_active_repository(cx)); + + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + register_git_commit_language(&project, cx); + + let panel = workspace.update_in(cx, GitPanel::new); + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "Template1\n", + "template should be applied initially" + ); + }); + + // Update the commit template returned by the fake git repository and + // simulate restart: empty the active commit buffer and reload the panel + // from the same serialized state the previous session would have written. + fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| { + state.commit_template = Some(GitCommitTemplate { + template: "Template2\n".to_string(), + }) + }) + .unwrap(); + let serialized_panel = panel.update(cx, |panel, cx| SerializedGitPanel { + signoff_enabled: false, + commit_messages: panel.serialized_commit_messages(cx), + }); + let buffer = repository.read_with(cx, |repository, _| { + repository.commit_message_buffer().unwrap().clone() + }); + buffer.update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, "")], None, cx); + }); + + let restored_panel = workspace.update_in(cx, |workspace, window, cx| { + GitPanel::new_with_serialized_panel(workspace, Some(serialized_panel), window, cx) + }); + cx.run_until_parked(); + + restored_panel.read_with(cx, |panel, cx| { + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "Template2\n", + "updated commit template should be re-applied across restarts" + ); + }); + } + + #[gpui::test] + async fn test_user_draft_preserved_when_template_changes(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { "main.rs": "fn main() {}" } + } + }), + ) + .await; + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[("src/main.rs", StatusCode::Modified.worktree())], + ); + fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| { + state.commit_template = Some(GitCommitTemplate { + template: "Template1\n".to_string(), + }) + }) + .unwrap(); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let repository = project.read_with(cx, |project, cx| { + project + .git_store() + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }); + repository.update(cx, |repository, cx| repository.set_as_active_repository(cx)); + + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + register_git_commit_language(&project, cx); + + let panel = workspace.update_in(cx, GitPanel::new); + cx.run_until_parked(); + + // User edits the buffer after the template was applied. + let user_message = "fix: my actual commit\n"; + panel.update(cx, |panel, cx| { + panel.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, user_message)], None, cx); + }); + }); + + // The commit template returned by the fake git repository changes + // after the user has already started typing. We must not clobber the + // user's draft. + fs.with_git_state(Path::new(path!("/root/project/.git")), false, |state| { + state.commit_template = Some(GitCommitTemplate { + template: "Template2\n".to_string(), + }) + }) + .unwrap(); + let serialized_panel = panel.update(cx, |panel, cx| SerializedGitPanel { + signoff_enabled: false, + commit_messages: panel.serialized_commit_messages(cx), + }); + + // Simulate a restart and restore from the serialized state. + let buffer = repository.read_with(cx, |repository, _| { + repository.commit_message_buffer().unwrap().clone() + }); + buffer.update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, "")], None, cx); + }); + + let restored_panel = workspace.update_in(cx, |workspace, window, cx| { + GitPanel::new_with_serialized_panel(workspace, Some(serialized_panel), window, cx) + }); + cx.run_until_parked(); + + restored_panel.read_with(cx, |panel, cx| { + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + user_message, + "user-typed draft must be restored verbatim, never overwritten by a later template" + ); + }); + } + #[gpui::test] async fn test_pending_commit_state_is_per_repository(cx: &mut TestAppContext) { init_test(cx); @@ -13308,6 +13531,10 @@ mod tests { !context.contains("ChangesList"), "should not have ChangesList context when commit editor is focused" ); + assert!( + !context.contains("HistoryList"), + "should not have HistoryList context when commit editor is focused" + ); }); // Case 2: Focus the panel's focus handle directly — should have "menu" and "ChangesList". @@ -13332,13 +13559,36 @@ mod tests { context.contains("ChangesList"), "should have ChangesList context when changes list is focused" ); + assert!( + !context.contains("HistoryList"), + "should not have HistoryList context when changes list is focused" + ); assert!( !context.contains("CommitEditor"), "should not have CommitEditor context when changes list is focused" ); }); - // Case 3: Switch back to commit editor and verify context switches correctly + // Case 3: Switch to the History tab and verify its list context. + panel.update_in(cx, |panel, window, cx| { + panel.active_tab = GitPanelTab::History; + let context = panel.dispatch_context(window, cx); + assert!( + context.contains("menu"), + "should have menu context when history list is focused" + ); + assert!( + context.contains("HistoryList"), + "should have HistoryList context when history list is focused" + ); + assert!( + !context.contains("ChangesList"), + "should not have ChangesList context when history list is focused" + ); + panel.active_tab = GitPanelTab::Changes; + }); + + // Case 4: Switch back to commit editor and verify context switches correctly panel.update_in(cx, |panel, window, cx| { panel.focus_editor(&FocusEditor, window, cx); }); @@ -13355,7 +13605,7 @@ mod tests { ); }); - // Case 4: Re-focus changes list and verify it transitions back correctly + // Case 5: Re-focus changes list and verify it transitions back correctly panel.update_in(cx, |panel, window, cx| { panel.focus_handle.focus(window, cx); }); @@ -13421,6 +13671,104 @@ mod tests { }) } + #[gpui::test] + async fn test_commit_editor_context_menu_clipboard_actions(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + + for in_panel in [true, false] { + let window_handle = cx.add_window(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("commit message", cx)); + commit_message_editor(buffer, None, project.clone(), in_panel, window, cx) + }); + let editor = window_handle.root(cx).expect("commit editor should exist"); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + editor.update_in(cx, |editor, window, cx| { + editor.focus_handle(cx).focus(window, cx); + editor.select_all(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + let position = editor.read_with(cx, |editor, _| { + editor + .last_bounds() + .expect("editor should be rendered") + .origin + + gpui::point(px(10.), px(10.)) + }); + let open_menu = |cx: &mut VisualTestContext| { + cx.simulate_mouse_down(position, gpui::MouseButton::Right, Modifiers::none()); + // MouseContextMenu waits two frames before focusing its deferred element. + for _ in 0..2 { + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + } + editor.update_in(cx, |editor, window, cx| { + assert!(editor.has_mouse_context_menu()); + assert!(editor.mouse_menu_is_focused(window, cx)); + }); + }; + open_menu(cx); + editor.update_in(cx, |editor, window, cx| { + assert!( + editor.mouse_menu_is_focused(window, cx), + "menu should have focus" + ); + assert!( + editor.has_non_empty_selection(&editor.display_snapshot(cx)), + "selection should remain" + ); + }); + cx.dispatch_action(menu::SelectFirst); + cx.dispatch_action(menu::SelectNext); + cx.dispatch_action(menu::Confirm); + assert!( + editor.read_with(cx, |editor, _| !editor.has_mouse_context_menu()), + "menu should close after Copy" + ); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("commit message".to_string()) + ); + + cx.write_to_clipboard(gpui::ClipboardItem::new_string("replacement".to_string())); + open_menu(cx); + cx.dispatch_action(menu::SelectLast); + cx.dispatch_action(menu::Confirm); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "replacement" + ); + + cx.write_to_clipboard(gpui::ClipboardItem::new_string(" suffix".to_string())); + open_menu(cx); + cx.dispatch_action(menu::SelectFirst); + cx.dispatch_action(menu::Confirm); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "replacement suffix", + "with no selection, menu navigation should skip disabled Cut and Copy and select Paste" + ); + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&Default::default(), window, cx); + }); + open_menu(cx); + cx.dispatch_action(menu::SelectFirst); + cx.dispatch_action(menu::Confirm); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("replacement suffix".to_string()) + ); + assert_eq!(editor.read_with(cx, |editor, cx| editor.text(cx)), ""); + assert!(editor.read_with(cx, |editor, _| !editor.has_mouse_context_menu())); + editor.update_in(cx, |editor, window, _| { + assert!(editor.is_focused(window)); + }); + } + } + #[gpui::test] async fn test_fill_commit_editor_toggle(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index 92129897ae..71042ba1b1 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -6,7 +6,7 @@ use editor::{ use git_ui_core::file_diff_view::build_buffer_diff; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, - IntoElement, Render, SharedString, Task, Window, + IntoElement, Render, SharedString, Subscription, Task, Window, }; use language::{Buffer, Capability, HighlightedText, OffsetRangeExt}; use multi_buffer::PathKey; @@ -28,6 +28,7 @@ use workspace::{ pub struct MultiDiffView { editor: Entity, file_count: usize, + _editor_event_subscription: Subscription, } struct Entry { @@ -204,7 +205,17 @@ impl MultiDiffView { editor }); - Self { editor, file_count } + let editor_event_subscription = cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| { + if event == &(EditorEvent::SelectionsChanged { local: true }) { + cx.emit(event.clone()) + } + }); + + Self { + editor, + file_count, + _editor_event_subscription: editor_event_subscription, + } } fn title(&self) -> SharedString { diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 42c0896a31..75a28b0d9a 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -70,6 +70,7 @@ pub struct ProjectDiff { project: Entity, workspace: WeakEntity, diff: Entity, + _diff_event_subscription: Subscription, _diff_observation: Subscription, } @@ -266,12 +267,19 @@ impl ProjectDiff { workspace: Entity, cx: &mut Context, ) -> Self { - let observation = cx.observe(&diff, |_, _, cx| cx.notify()); + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + if event == &(EditorEvent::SelectionsChanged { local: true }) { + cx.emit(event.clone()) + } + }); + let diff_observation = cx.observe(&diff, |_, _, cx| cx.notify()); + Self { project, workspace: workspace.downgrade(), diff, - _diff_observation: observation, + _diff_event_subscription: diff_event_subscription, + _diff_observation: diff_observation, } } @@ -989,9 +997,9 @@ mod tests { use buffer_diff::DiffHunkSecondaryStatus; use db::indoc; use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use multi_buffer::PathKey; - use project::FakeFs; + use project::{FakeFs, git_store::MAX_CONCURRENT_BLOB_READS}; use serde_json::json; use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore}; use std::path::Path; @@ -1024,6 +1032,7 @@ mod tests { use zed_actions::git as git_actions; + use crate::branch_diff::BranchDiff; use crate::project_diff::{self, ProjectDiff}; #[test] @@ -1359,6 +1368,18 @@ mod tests { let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); assert_eq!(paths_b.len(), 1); assert_eq!(*paths_b[0], *"b.txt"); + + let active_repository_path = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .map(|repository| repository.read(cx).work_directory_abs_path.clone()) + }); + + assert_eq!( + active_repository_path.as_deref(), + Some(Path::new(path!("/project_b"))), + "Project B should remain the active repository" + ); } #[gpui::test] @@ -1534,6 +1555,138 @@ mod tests { ); } + #[gpui::test(iterations = 10)] + async fn test_excerpts_are_ordered_by_path(cx: &mut TestAppContext) { + init_test(cx); + + const FILE_COUNT: usize = 20; + + let names = (0..FILE_COUNT) + .map(|index| format!("f{index:02}.txt")) + .collect::>(); + + let fs = FakeFs::new(cx.executor()); + let mut tree = serde_json::Map::new(); + tree.insert(".git".to_owned(), json!({})); + for (index, name) in names.iter().enumerate() { + tree.insert(name.clone(), json!(format!("new-{index:02}\n"))); + } + fs.insert_tree(path!("/project"), serde_json::Value::Object(tree)) + .await; + + let head = names + .iter() + .enumerate() + .map(|(index, name)| (name.as_str(), format!("old-{index:02}\n"))) + .collect::>(); + fs.set_head_and_index_for_repo(Path::new(path!("/project/.git")), &head); + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone()); + let text = editor.update(cx, |editor, cx| { + editor.buffer().read(cx).snapshot(cx).text() + }); + + // Unique per-file content, so appearance order is excerpt order. + let actual = text + .lines() + .filter(|line| line.starts_with("new-")) + .collect::>(); + let expected = (0..FILE_COUNT) + .map(|index| format!("new-{index:02}")) + .collect::>(); + assert_eq!(actual, expected); + } + + #[gpui::test] + async fn test_merge_base_loading_is_incremental(cx: &mut TestAppContext) { + init_test(cx); + + const FILE_COUNT: usize = MAX_CONCURRENT_BLOB_READS + 4; + + let names = (0..FILE_COUNT) + .map(|index| format!("f{index:02}.txt")) + .collect::>(); + + let project_root = Path::new(path!("/project")); + let dot_git = project_root.join(".git"); + + let fs = FakeFs::new(cx.executor()); + let mut tree = serde_json::Map::new(); + tree.insert(".git".to_owned(), json!({})); + for (index, name) in names.iter().enumerate() { + tree.insert(name.clone(), json!(format!("new-{index:02}\n"))); + } + fs.insert_tree(project_root, serde_json::Value::Object(tree)) + .await; + + let merge_base = names + .iter() + .enumerate() + .map(|(index, name)| (name.as_str(), format!("old-{index:02}\n"))) + .collect::>(); + let oids = fs.set_merge_base_content_for_repo(&dot_git, &merge_base); + let gate = fs.install_blob_read_gate_for_repo(&dot_git); + + let project = Project::test(fs, [project_root], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let branch_diff = cx + .update(|window, cx| { + BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert!(gate.waiting() > 0 && gate.waiting() < FILE_COUNT); + + let editor = + branch_diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + let shown = |cx: &mut VisualTestContext| { + editor + .update(cx, |editor, cx| { + editor.buffer().read(cx).snapshot(cx).text() + }) + .lines() + .filter(|line| line.starts_with("new-")) + .map(str::to_owned) + .collect::>() + }; + + assert!(shown(cx).is_empty()); + + assert!(gate.release(oids[0])); + cx.run_until_parked(); + assert_eq!(shown(cx), vec!["new-00".to_owned()]); + + gate.open(); + cx.run_until_parked(); + let expected = (0..FILE_COUNT) + .map(|index| format!("new-{index:02}")) + .collect::>(); + assert_eq!(shown(cx), expected); + } + #[gpui::test] async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { init_test(cx); @@ -1560,8 +1713,20 @@ mod tests { ); let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; + let (created_entry_id, changed_entry_id) = project.read_with(cx, |project, cx| { + let entry_id = |path| { + let project_path = project + .find_project_path(path, cx) + .expect("diff path should resolve"); + project + .entry_for_path(&project_path, cx) + .expect("diff path should have a project entry") + .id + }; + (entry_id(path!("/a/a.txt")), entry_id(path!("/a/b.txt"))) + }); let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.run_until_parked(); @@ -1591,6 +1756,10 @@ mod tests { ˇcreated " )); + assert_eq!( + project.read_with(&cx.cx, |project, _| project.active_entry()), + Some(created_entry_id) + ); cx.dispatch_action(editor::actions::GoToPreviousHunk); @@ -1605,6 +1774,10 @@ mod tests { created " )); + assert_eq!( + project.read_with(&cx.cx, |project, _| project.active_entry()), + None + ); cx.dispatch_action(editor::actions::GoToPreviousHunk); @@ -1619,6 +1792,42 @@ mod tests { created " )); + assert_eq!( + project.read_with(&cx.cx, |project, _| project.active_entry()), + Some(changed_entry_id) + ); + + cx.cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Split); + }); + }); + }); + cx.cx.run_until_parked(); + + let lhs_editor = item.read_with(&cx.cx, |item, cx| { + item.editor(cx) + .read(cx) + .lhs_editor() + .cloned() + .expect("split diff should have a left editor") + }); + let mut lhs_cx = EditorTestContext::for_editor_in(lhs_editor, &mut cx.cx).await; + lhs_cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([multi_buffer::Anchor::Max..multi_buffer::Anchor::Max]); + }); + }); + lhs_cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); + }); + }); + assert_eq!( + project.read_with(&lhs_cx.cx, |project, _| project.active_entry()), + Some(changed_entry_id) + ); } #[gpui::test] diff --git a/crates/git_ui/src/solo_diff_view.rs b/crates/git_ui/src/solo_diff_view.rs index bc54d6e4ac..6d4696efe0 100644 --- a/crates/git_ui/src/solo_diff_view.rs +++ b/crates/git_ui/src/solo_diff_view.rs @@ -18,7 +18,7 @@ use gpui::{ use language::{Anchor, Buffer, HighlightedText, OffsetRangeExt as _, Point}; use multi_buffer::{MultiBuffer, PathKey, excerpt_context_lines}; use project::{ - Project, + Project, ProjectPath, git_store::{Repository, RepositoryId}, }; use settings::{Settings, SettingsStore, StatusStyle}; @@ -453,6 +453,10 @@ impl Item for SoloDiffView { self.editor.for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs index 0aadd1c118..d86034961b 100644 --- a/crates/git_ui/src/staged_diff.rs +++ b/crates/git_ui/src/staged_diff.rs @@ -326,6 +326,10 @@ impl Item for StagedDiff { self.diff.read(cx).for_each_project_item(cx, f); } + fn active_project_path(&self, cx: &App) -> Option { + self.diff.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, @@ -687,7 +691,7 @@ impl Render for StagedDiffToolbar { mod tests { use crate::project_diff::{self, ProjectDiff}; use git::repository::RepoPath; - use gpui::{Action as _, TestAppContext}; + use gpui::TestAppContext; use language::Point; use project::{FakeFs, Fs as _}; use serde_json::json; diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs index b51271fb2c..bc6e1d3096 100644 --- a/crates/git_ui/src/unstaged_diff.rs +++ b/crates/git_ui/src/unstaged_diff.rs @@ -370,6 +370,10 @@ impl Item for UnstagedDiff { self.diff.read(cx).for_each_project_item(cx, f); } + fn active_project_path(&self, cx: &App) -> Option { + self.diff.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui_core/Cargo.toml b/crates/git_ui_core/Cargo.toml index 9538fec421..76815af5f2 100644 --- a/crates/git_ui_core/Cargo.toml +++ b/crates/git_ui_core/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] name = "git_ui_core" path = "src/git_ui_core.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/git_ui_core/src/file_diff_view.rs b/crates/git_ui_core/src/file_diff_view.rs index 555fca0a4d..d5fa761b8f 100644 --- a/crates/git_ui_core/src/file_diff_view.rs +++ b/crates/git_ui_core/src/file_diff_view.rs @@ -3,13 +3,13 @@ use anyhow::Result; use buffer_diff::BufferDiff; use editor::{ - Editor, EditorEvent, EditorSettings, HiddenUnstagedDiffHunkRenderer, MultiBuffer, - SplittableEditor, + DiffStyleControls, Editor, EditorEvent, EditorSettings, HiddenUnstagedDiffHunkRenderer, + MultiBuffer, SplittableEditor, }; use futures::{FutureExt, select_biased}; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, - IntoElement, Render, Task, WeakEntity, Window, + App, AppContext as _, AsyncApp, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, + Font, IntoElement, Render, Task, WeakEntity, Window, }; use language::{Buffer, HighlightedText, Point}; use project::{Project, ProjectPath}; @@ -21,10 +21,11 @@ use std::{ sync::Arc, time::Duration, }; -use ui::{Color, Icon, IconName, SharedString}; +use ui::{Color, Icon, IconName, SharedString, prelude::*}; use util::paths::PathExt as _; use workspace::{ - Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, + Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, item::{ItemEvent, SaveOptions}, searchable::SearchableItemHandle, }; @@ -128,6 +129,22 @@ impl FileDiffView { splittable }); + let include_root = project.read(cx).visible_worktrees(cx).count() > 1; + let breadcrumb_path = |buffer: &Entity| { + buffer + .read(cx) + .snapshot() + .resolve_file_path(include_root, cx) + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.to_string()) + }; + let old_path = breadcrumb_path(&old_buffer); + let new_path = breadcrumb_path(&new_buffer); + editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, _| { + editor.set_breadcrumb_header(format!("{old_path} ↔ {new_path}")); + }); + }); + let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(()); // The buffers' languages may load after the diff was built, e.g. when @@ -394,6 +411,57 @@ impl Render for FileDiffView { } } +pub struct FileDiffStyleToolbar { + file_diff: Option>, +} + +impl FileDiffStyleToolbar { + pub fn new(_: &mut Context) -> Self { + Self { file_diff: None } + } + + fn file_diff(&self) -> Option> { + self.file_diff.as_ref()?.upgrade() + } +} + +impl EventEmitter for FileDiffStyleToolbar {} + +impl ToolbarItemView for FileDiffStyleToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.file_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + + if self.file_diff.is_some() { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } + } +} + +impl Render for FileDiffStyleToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(file_diff) = self.file_diff() else { + return Empty.into_any_element(); + }; + + let editor = file_diff.read(cx).editor.clone(); + + h_flex() + .pl_0p5() + .gap_1() + .child(DiffStyleControls::new(editor)) + .into_any_element() + } +} + #[cfg(test)] mod tests { use super::*; @@ -560,7 +628,34 @@ mod tests { path!("test/new_file.txt") ) ); - }) + let (breadcrumbs, _) = diff_view.breadcrumbs(cx).unwrap(); + assert_eq!( + breadcrumbs + .iter() + .map(|crumb| crumb.text.as_ref()) + .collect::>(), + vec!["old_file.txt ↔ new_file.txt"] + ); + }); + + let toolbar = cx.new(FileDiffStyleToolbar::new); + let location = toolbar.update_in(cx, |toolbar, window, cx| { + toolbar.set_active_pane_item(Some(&diff_view), window, cx) + }); + assert_eq!(location, ToolbarItemLocation::PrimaryLeft); + assert_eq!( + toolbar.read_with(cx, |toolbar, _| toolbar.file_diff()), + Some(diff_view.clone()) + ); + + let location = toolbar.update_in(cx, |toolbar, window, cx| { + toolbar.set_active_pane_item(None, window, cx) + }); + assert_eq!(location, ToolbarItemLocation::Hidden); + assert_eq!( + toolbar.read_with(cx, |toolbar, _| toolbar.file_diff()), + None + ); } #[gpui::test] diff --git a/crates/git_ui_core/src/worktree_picker.rs b/crates/git_ui_core/src/worktree_picker.rs index 5a4b278bbc..d518bee626 100644 --- a/crates/git_ui_core/src/worktree_picker.rs +++ b/crates/git_ui_core/src/worktree_picker.rs @@ -413,7 +413,7 @@ impl Render for DeleteWorktreeTooltip { Tooltip::with_meta_in( "Delete Worktree", Some(&DeleteWorktree), - "Hold alt to force delete", + concat!("Hold ", ui::alt_key_name!(), " to force delete"), &self.focus_handle, cx, ) diff --git a/crates/google_ai/Cargo.toml b/crates/google_ai/Cargo.toml index 3848ed5f87..4bbff2959d 100644 --- a/crates/google_ai/Cargo.toml +++ b/crates/google_ai/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/google_ai.rs" +doctest = false [features] schemars = ["dep:schemars"] diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs index bc28b5b9ed..67f44b3835 100644 --- a/crates/google_ai/src/completion.rs +++ b/crates/google_ai/src/completion.rs @@ -1,10 +1,10 @@ use anyhow::Result; -use futures::{Stream, StreamExt}; +use futures::{Stream, StreamExt, TryStreamExt}; use language_model_core::{ - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest, - LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolUse, - LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason, - TokenUsage, + GOOGLE_PROVIDER_NAME, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRequest, LanguageModelRequestToolInput, LanguageModelToolChoice, + LanguageModelToolUse, LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, + StopReason, TokenUsage, }; use std::pin::Pin; use std::sync::Arc; @@ -113,6 +113,10 @@ pub fn into_google( } let thinking_config = thinking_config_for_request(&request, &model_id, mode); + let max_output_tokens = request + .effective_max_output_tokens(None) + .map(usize::try_from) + .transpose()?; let system_instructions = if request .messages @@ -179,7 +183,7 @@ pub fn into_google( generation_config: Some(GenerationConfig { candidate_count: Some(1), stop_sequences: Some(request.stop), - max_output_tokens: None, + max_output_tokens, temperature: request.temperature.map(|t| t as f64), thinking_config, top_p: None, @@ -250,10 +254,11 @@ fn is_google_thinking_model(model_id: &str) -> bool { fn disabled_thinking_level(model_id: &str) -> Option { match model_id { - // `gemini-3.7-flash` rejects `MINIMAL` with a validation error, so `LOW` is - // the lowest level available to it. + // Gemini 3.7 and 3.8 Flash reject `MINIMAL` with a validation error, so + // `LOW` is the lowest level available to them. model_id if model_id.starts_with("gemini-3.7-flash") + || model_id.starts_with("gemini-3.8-flash") || (model_id.starts_with("gemini-3") && model_id.contains("-pro")) => { Some(ThinkingLevel::Low) @@ -277,34 +282,38 @@ fn supports_thinking_budget_disable(model_id: &str) -> bool { pub struct GoogleEventMapper { usage: UsageMetadata, - stop_reason: StopReason, + stop_emitted: bool, } impl GoogleEventMapper { pub fn new() -> Self { Self { usage: UsageMetadata::default(), - stop_reason: StopReason::EndTurn, + stop_emitted: false, } } pub fn map_stream( - mut self, + self, events: Pin>>>, ) -> impl Stream> { - events - .map(Some) - .chain(futures::stream::once(async { None })) - .flat_map(move |event| { - futures::stream::iter(match event { - Some(Ok(event)) => self.map_event(event), - Some(Err(error)) => { - vec![Err(LanguageModelCompletionError::from(error))] - } - None => vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))], - }) - }) + futures::stream::try_unfold((self, events), |(mut mapper, mut events)| async move { + match events.next().await { + Some(event) => { + let event = event.map_err(LanguageModelCompletionError::from)?; + Ok(Some(( + futures::stream::iter(mapper.map_event(event)), + (mapper, events), + ))) + } + None if mapper.stop_emitted => Ok(None), + None => Err(LanguageModelCompletionError::StreamEndedUnexpectedly { + provider: GOOGLE_PROVIDER_NAME, + }), + } + }) + .try_flatten() } pub fn map_event( @@ -315,6 +324,7 @@ impl GoogleEventMapper { let mut events: Vec<_> = Vec::new(); let mut wants_to_use_tool = false; + let mut stop_reason = None; if let Some(usage_metadata) = event.usage_metadata { update_usage(&mut self.usage, &usage_metadata); events.push(Ok(LanguageModelCompletionEvent::UsageUpdate( @@ -325,7 +335,7 @@ impl GoogleEventMapper { if let Some(prompt_feedback) = event.prompt_feedback && let Some(block_reason) = prompt_feedback.block_reason.as_deref() { - self.stop_reason = match block_reason { + let stop_reason = match block_reason { "SAFETY" | "OTHER" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "IMAGE_SAFETY" => { StopReason::Refusal } @@ -334,7 +344,10 @@ impl GoogleEventMapper { StopReason::Refusal } }; - events.push(Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))); + if !self.stop_emitted { + self.stop_emitted = true; + events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason))); + } return events; } @@ -342,7 +355,7 @@ impl GoogleEventMapper { if let Some(candidates) = event.candidates { for candidate in candidates { if let Some(finish_reason) = candidate.finish_reason.as_deref() { - self.stop_reason = match finish_reason { + stop_reason = Some(match finish_reason { "STOP" => StopReason::EndTurn, "MAX_TOKENS" => StopReason::MaxTokens, "SAFETY" @@ -366,7 +379,7 @@ impl GoogleEventMapper { log::error!("Unexpected google finish_reason: {finish_reason}"); StopReason::EndTurn } - }; + }); } candidate .content @@ -436,8 +449,13 @@ impl GoogleEventMapper { // Even when Gemini wants to use a Tool, the API // responds with `finish_reason: STOP` if wants_to_use_tool { - self.stop_reason = StopReason::ToolUse; - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); + stop_reason = Some(StopReason::ToolUse); + } + if let Some(stop_reason) = stop_reason + && !self.stop_emitted + { + self.stop_emitted = true; + events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason))); } events } @@ -488,6 +506,109 @@ mod tests { use language_model_core::{LanguageModelRequestMessage, LanguageModelRequestTool}; use serde_json::json; + #[test] + fn completed_stream_emits_stop_once() { + for (response, expected_stop) in [ + ( + json!({"candidates": [{ + "content": {"role": "model", "parts": [{"text": "Answer"}]}, + "finishReason": "STOP" + }]}), + StopReason::EndTurn, + ), + ( + json!({"candidates": [{ + "content": {"role": "model", "parts": [{ + "functionCall": {"name": "list_directory", "args": {}} + }]}, + "finishReason": "STOP" + }]}), + StopReason::ToolUse, + ), + ( + json!({"promptFeedback": {"blockReason": "SAFETY"}}), + StopReason::Refusal, + ), + ] { + let responses = vec![ + Ok(serde_json::from_value(response).unwrap()), + Ok(serde_json::from_value(json!({"candidates": [{ + "content": {"role": "model", "parts": []}, + "finishReason": "STOP" + }]})) + .unwrap()), + ]; + let events = futures::executor::block_on( + GoogleEventMapper::new() + .map_stream(Box::pin(futures::stream::iter(responses))) + .collect::>(), + ); + let stops = events + .into_iter() + .filter_map(|event| match event.unwrap() { + LanguageModelCompletionEvent::Stop(reason) => Some(reason), + _ => None, + }) + .collect::>(); + assert_eq!(stops, vec![expected_stop]); + } + } + + #[test] + fn incomplete_stream_reports_unexpected_end() { + for has_content in [false, true] { + let mut responses = Vec::new(); + if has_content { + responses.push(Ok(serde_json::from_value(json!({"candidates": [{ + "content": {"role": "model", "parts": [{"text": "Partial answer"}]} + }]})) + .unwrap())); + } + futures::executor::block_on(async { + let mut stream = GoogleEventMapper::new() + .map_stream(Box::pin(futures::stream::iter(responses))) + .boxed(); + if has_content { + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::Text(text))) if text == "Partial answer" + )); + } + assert!(matches!( + stream.next().await, + Some(Err(LanguageModelCompletionError::StreamEndedUnexpectedly { provider })) + if provider == language_model_core::GOOGLE_PROVIDER_NAME + )); + assert!(stream.next().await.is_none()); + }); + } + } + + #[test] + fn stream_read_error_is_not_followed_by_completion() { + let responses = vec![ + Ok(serde_json::from_value(json!({"candidates": [{ + "content": {"role": "model", "parts": [{"text": "Partial answer"}]} + }]})) + .unwrap()), + Err(std::io::Error::new(std::io::ErrorKind::ConnectionReset, "read failed").into()), + ]; + let events = futures::executor::block_on( + GoogleEventMapper::new() + .map_stream(Box::pin(futures::stream::iter(responses))) + .collect::>(), + ); + assert!(matches!( + events.as_slice(), + [ + Ok(LanguageModelCompletionEvent::Text(text)), + Err(LanguageModelCompletionError::Other(error)), + ] if text == "Partial answer" + && error.downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::ConnectionReset) + )); + } + fn text_request() -> LanguageModelRequest { LanguageModelRequest { messages: vec![LanguageModelRequestMessage { @@ -515,6 +636,11 @@ mod tests { ) .unwrap(); + assert!( + serde_json::to_value(&request).unwrap()["generationConfig"] + .get("maxOutputTokens") + .is_none() + ); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.include_thoughts, Some(true)); assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Low)); @@ -541,6 +667,7 @@ mod tests { } }); let mut request = text_request(); + request.max_output_tokens = Some(1024); request.tools = vec![LanguageModelRequestTool::function( "grep".to_string(), "Search files".to_string(), @@ -555,6 +682,7 @@ mod tests { ) .unwrap(); let serialized = serde_json::to_value(request).unwrap(); + assert_eq!(serialized["generationConfig"]["maxOutputTokens"], 1024); let declaration = &serialized["tools"][0]["functionDeclarations"][0]; assert_eq!(declaration["parametersJsonSchema"], input_schema); @@ -565,6 +693,7 @@ mod tests { fn into_google_turns_off_budget_thinking_when_supported() { let mut request = text_request(); request.thinking_allowed = false; + request.max_output_tokens = Some(0); let request = into_google( request, @@ -575,6 +704,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&request).unwrap()["generationConfig"]["maxOutputTokens"], + 0 + ); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.thinking_budget, Some(0)); assert_eq!(thinking_config.include_thoughts, None); @@ -743,12 +876,18 @@ mod tests { usage_metadata: None, }; - mapper.map_event(response); - assert_eq!(mapper.stop_reason, StopReason::Refusal); + assert!(matches!( + mapper.map_event(response).as_slice(), + [Ok(LanguageModelCompletionEvent::Stop(StopReason::Refusal))] + )); } #[test] fn test_disabled_thinking_level_per_model() { + assert_eq!( + disabled_thinking_level("gemini-3.8-flash"), + Some(ThinkingLevel::Low) + ); assert_eq!( disabled_thinking_level("gemini-3.7-flash"), Some(ThinkingLevel::Low) diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 5734bb2c15..00a195b43d 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -517,6 +517,8 @@ pub enum Model { Gemini36Flash, #[serde(rename = "gemini-3.7-flash")] Gemini37Flash, + #[serde(rename = "gemini-3.8-flash")] + Gemini38Flash, #[serde(rename = "gemini-3.1-pro-preview")] Gemini31Pro, #[serde(rename = "custom")] @@ -542,6 +544,7 @@ impl Model { Self::Gemini35Flash => "gemini-3.5-flash", Self::Gemini36Flash => "gemini-3.6-flash", Self::Gemini37Flash => "gemini-3.7-flash", + Self::Gemini38Flash => "gemini-3.8-flash", Self::Gemini31Pro => "gemini-3.1-pro-preview", Self::Custom { name, .. } => name, } @@ -553,6 +556,7 @@ impl Model { Self::Gemini35Flash => "gemini-3.5-flash", Self::Gemini36Flash => "gemini-3.6-flash", Self::Gemini37Flash => "gemini-3.7-flash", + Self::Gemini38Flash => "gemini-3.8-flash", Self::Gemini31Pro => "gemini-3.1-pro-preview", Self::Custom { name, .. } => name, } @@ -565,6 +569,7 @@ impl Model { Self::Gemini35Flash => "Gemini 3.5 Flash", Self::Gemini36Flash => "Gemini 3.6 Flash", Self::Gemini37Flash => "Gemini 3.7 Flash", + Self::Gemini38Flash => "Gemini 3.8 Flash", Self::Gemini31Pro => "Gemini 3.1 Pro", Self::Custom { name, display_name, .. @@ -579,6 +584,7 @@ impl Model { | Self::Gemini35Flash | Self::Gemini36Flash | Self::Gemini37Flash + | Self::Gemini38Flash | Self::Gemini31Pro => 1_048_576, Self::Custom { max_tokens, .. } => *max_tokens, } @@ -591,6 +597,7 @@ impl Model { | Model::Gemini35Flash | Model::Gemini36Flash | Model::Gemini37Flash + | Model::Gemini38Flash | Model::Gemini31Pro => Some(65_536), Model::Custom { .. } => None, } @@ -612,6 +619,7 @@ impl Model { | Self::Gemini35Flash | Self::Gemini36Flash | Self::Gemini37Flash + | Self::Gemini38Flash | Self::Gemini31Pro | Self::Custom { mode: GoogleModelMode::Thinking { .. }, @@ -631,7 +639,7 @@ impl Model { ThinkingLevel::Medium, ThinkingLevel::High, ], - Self::Gemini37Flash | Self::Gemini31Pro => &[ + Self::Gemini37Flash | Self::Gemini38Flash | Self::Gemini31Pro => &[ ThinkingLevel::Low, ThinkingLevel::Medium, ThinkingLevel::High, @@ -647,6 +655,7 @@ impl Model { Self::Gemini35Flash => Some(ThinkingLevel::Medium), Self::Gemini36Flash => Some(ThinkingLevel::Medium), Self::Gemini37Flash => Some(ThinkingLevel::Medium), + Self::Gemini38Flash => Some(ThinkingLevel::Medium), Self::Gemini31Pro => Some(ThinkingLevel::High), _ => None, } @@ -659,6 +668,7 @@ impl Model { | Self::Gemini35Flash | Self::Gemini36Flash | Self::Gemini37Flash + | Self::Gemini38Flash | Self::Gemini31Pro => GoogleModelMode::Thinking { budget_tokens: None, }, @@ -735,6 +745,31 @@ mod tests { assert_eq!(deserialized, Model::Gemini37Flash); } + #[test] + fn test_gemini_3_8_flash_model_metadata() { + let model = Model::Gemini38Flash; + assert_eq!(model.id(), "gemini-3.8-flash"); + assert_eq!(model.request_id(), "gemini-3.8-flash"); + assert_eq!(model.display_name(), "Gemini 3.8 Flash"); + assert_eq!(model.max_token_count(), 1_048_576); + assert_eq!(model.max_output_tokens(), Some(65_536)); + assert!(model.supports_thinking()); + assert_eq!( + model.supported_thinking_levels(), + &[ + ThinkingLevel::Low, + ThinkingLevel::Medium, + ThinkingLevel::High + ] + ); + assert_eq!(model.default_thinking_level(), Some(ThinkingLevel::Medium)); + + let serialized = serde_json::to_value(&model).unwrap(); + assert_eq!(serialized, json!("gemini-3.8-flash")); + let deserialized: Model = serde_json::from_value(json!("gemini-3.8-flash")).unwrap(); + assert_eq!(deserialized, Model::Gemini38Flash); + } + #[test] fn test_function_call_part_with_signature_serializes_correctly() { let part = FunctionCallPart { diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 025107fe53..e767de582f 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -44,7 +44,6 @@ stacker = ["dep:stacksafe"] [lib] path = "src/gpui.rs" -doctest = false [dependencies] accesskit.workspace = true @@ -89,13 +88,11 @@ serde.workspace = true serde_json.workspace = true slotmap.workspace = true smallvec.workspace = true -async-channel.workspace = true stacksafe = { workspace = true, optional = true } strum.workspace = true sum_tree.workspace = true taffy = "=0.13.0" thiserror.workspace = true -tracing.workspace = true gpui_util.workspace = true hdrhistogram = { workspace = true, optional = true } waker-fn = "1.2.0" @@ -107,7 +104,13 @@ url.workspace = true uuid.workspace = true web-time.workspace = true heapless.workspace = true -ztracing.workspace = true + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Workspace inheritance would re-enable defaults, including blocking channel synchronization. +async-channel = { version = "2.5.0", default-features = false } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +async-channel.workspace = true [target.'cfg(target_family = "wasm")'.dependencies] getrandom = { version = "0.3.4", features = ["wasm_js"] } @@ -116,6 +119,8 @@ uuid = { workspace = true, features = ["js"] } [target.'cfg(target_os = "macos")'.dependencies] core-video.workspace = true +objc2-core-foundation.workspace = true +objc2-core-video.workspace = true # WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } log.workspace = true @@ -155,7 +160,7 @@ web-sys = { version = "0.3", features = ["console"] } embed-resource = { version = "3.0", optional = true } [target.'cfg(target_os = "macos")'.build-dependencies] -bindgen = "0.71" +bindgen = "0.72" [package.metadata.cargo-shear] ignored = [ @@ -164,7 +169,6 @@ ignored = [ "getrandom", "objc2", "objc2-metal", - "tracing", ] ignored-paths = ["src/_ownership_and_data_flow.rs"] diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index b1bfd2194f..79ca3f96a7 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -13,11 +13,12 @@ fn main() { #[cfg(feature = "windows-manifest")] fn embed_resource() { - let manifest = std::path::Path::new("resources/windows/gpui.manifest.xml"); - let rc_file = std::path::Path::new("resources/windows/gpui.rc"); + let resource_dir = std::path::Path::new("resources/windows"); + let manifest = resource_dir.join("gpui.manifest.xml"); + let rc_file = resource_dir.join("gpui.rc"); println!("cargo:rerun-if-changed={}", manifest.display()); println!("cargo:rerun-if-changed={}", rc_file.display()); - embed_resource::compile(rc_file, embed_resource::NONE) + embed_resource::compile(rc_file, embed_resource::ParamsIncludeDirs([resource_dir])) .manifest_required() .unwrap(); } diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index dab1c302c9..f4a017cda0 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -6,6 +6,84 @@ Examples can be run from the Zed repository root: cargo run -p gpui --example hello_world ``` +## Running in a browser + +A selection of these examples is also served by the standalone web gallery: + +```sh +cd crates/gpui_web/examples/hello_web +trunk serve +``` + +Open to choose an example: + +| Path | Example | +| --- | --- | +| `/hello-world` | Hello world | +| `/text` | Styled text | +| `/text-layout` | Alignment and decorations | +| `/text-wrapper` | Wrapping and truncation | +| `/input` | Text input and selection | +| `/dynamic-fonts` | Download a font on missing-glyph notifications | +| `/prime-sieve` | Background task demo | + +The server supports direct links and reloads at each path. Only the selected +example's WASM module is loaded. Follow **All examples** to return to the gallery; +navigation reloads the page so the previous app and its workers are released. + +Install [Trunk](https://trunk-rs.github.io/trunk/) if needed. The gallery's toolchain file selects +nightly Rust, the WASM target, and `rust-src`. Its Trunk configuration supplies the +cross-origin-isolation headers needed for shared memory and watches the GPUI +example and renderer sources for automatic rebuilds and browser reloads. Use a +browser with WebGPU or WebGL2 support. + +The gallery builds separate Cargo binaries directly from the existing example +sources. To add another browser-compatible example, register its binary in the +gallery's `Cargo.toml`, add an auxiliary Rust asset in `index.html`, and add its +gallery link with the corresponding `data-module` name. + +### Dynamic font loading + +Open `/dynamic-fonts` to see `App::on_missing_glyphs` fetch a versioned +[Noto Sans Devanagari TTF](https://fonts.gstatic.com/s/notosansdevanagari/v30/TuGoUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv3NaV_SNmI0b8QQCQmHn6B2OHjbL_08AlXQly-A.ttf) +from Google Fonts' CDN. The 219 KB font is licensed under the +[SIL Open Font License](https://github.com/google/fonts/blob/main/ofl/notosansdevanagari/OFL.txt). +The CDN permits cross-origin requests and caches the font for one year. +No sample text is sent to the host. + +The example bundles only IBM Plex Sans, downloads the fallback once on demand, +calls `add_fonts`, and refreshes the windows to reshape the text. Emoji continues +to use the default Canvas fallback. The page displays loading and failure states; +use browser network throttling or block `fonts.gstatic.com` and reload to +exercise them. Reload to retry a failed request. + +This is a small script-to-font mapping, not a general font discovery service. +For production, host versioned font assets on your own CDN and choose an explicit +coverage, caching, and retry policy. Use raw TTF/OTF assets rather than passing +Google Fonts CSS or WOFF2 responses directly to `add_fonts`. + +### Canvas font fallback + +The web text system uses browser fonts for eligible missing CJK and emoji +graphemes. It renders each complete grapheme independently, retaining the primary +font's baseline and line-spacing metrics. Contextual CJK spacing and arbitrary +OpenType features are not reproduced. Other scripts remain on Cosmic Text. +Browser font availability is assumed stable for the application's lifetime. + +With the gallery server running, a browser smoke test checks native-font +preservation, whole-grapheme rendering, emoji atlas reuse, and input/deletion: + +```sh +node crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs +``` + +This requires Node.js 22 or newer and Chrome/Chromium. On macOS it uses the +standard Google Chrome application path; elsewhere it uses `chromium`. Set +`CHROME` to override the executable, and pass an optional server URL as the first +argument. Screenshots and logs are saved under the gallery's ignored `target` +directory. The test intercepts clipboard writes rather than modifying the system +clipboard. + ## Where to start - `hello_world` shows the basic shape of a GPUI application: create an diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index 16b9b17a3f..d1cb919698 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -3,12 +3,11 @@ #[path = "example_support/fonts.rs"] mod example_support; -use futures::FutureExt; use gpui::{ - App, AppContext, Asset as _, AssetLogger, Bounds, ClickEvent, Context, ElementId, Entity, - ImageAssetLoader, ImageCache, ImageCacheProvider, KeyBinding, Menu, MenuItem, - RetainAllImageCache, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions, - actions, div, hash, image_cache, img, prelude::*, px, rgb, size, + App, AppContext, Bounds, ClickEvent, Context, ElementId, Entity, ImageCache, + ImageCacheProvider, KeyBinding, Menu, MenuItem, RetainAllImageCache, SharedString, + TitlebarOptions, Window, WindowBounds, WindowOptions, actions, div, hash, image_cache, img, + prelude::*, px, rgb, size, }; #[cfg(not(target_family = "wasm"))] use reqwest_client::ReqwestClient; @@ -176,7 +175,7 @@ struct SimpleLruCache { impl SimpleLruCache { fn new(max_items: usize, cx: &mut Context) -> Self { cx.on_release(|simple_cache, cx| { - for (_, mut item) in std::mem::take(&mut simple_cache.cache) { + for (_, item) in std::mem::take(&mut simple_cache.cache) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, None); } @@ -213,14 +212,12 @@ impl ImageCache for SimpleLruCache { self.usages.remove(current_ix); self.usages.insert(0, hash); - return item.get(); + return item.use_image(window); } - let fut = AssetLogger::::load(resource.clone(), cx); - let task = cx.background_executor().spawn(fut).shared(); if self.usages.len() == self.max_items { let oldest = self.usages.pop().unwrap(); - let mut image = self + let image = self .cache .remove(&oldest) .expect("cache and usages must be in sync"); @@ -228,23 +225,12 @@ impl ImageCache for SimpleLruCache { cx.drop_image(image, Some(window)); } } - self.cache - .insert(hash, gpui::ImageCacheItem::Loading(task.clone())); + let item = gpui::ImageCacheItem::new(resource, cx); + let result = item.use_image(window); + self.cache.insert(hash, item); self.usages.insert(0, hash); - let entity = window.current_view(); - window - .spawn(cx, { - async move |cx| { - _ = task.await; - cx.on_next_frame(move |_, cx| { - cx.notify(entity); - }); - } - }) - .detach(); - - None + result } } diff --git a/crates/gpui/examples/text.rs b/crates/gpui/examples/text.rs index a1244eba37..7fa2a15760 100644 --- a/crates/gpui/examples/text.rs +++ b/crates/gpui/examples/text.rs @@ -282,6 +282,8 @@ impl Render for TextExample { let tcx = cx.text_context(); let colors = cx.default_colors().clone(); + let font_size = tcx.font_size; + let line_height = tcx.line_height; let type_scale = tcx.type_scale; let step_down_2 = 1.0 / (type_scale * type_scale); @@ -322,6 +324,21 @@ impl Render for TextExample { .overflow_x_hidden() .bg(rgb(0xffffff)) .size_full() + .child( + div() + .flex() + .flex_col() + .p_3() + .text_size(px(font_size)) + .line_height(relative(line_height)) + .children([ + div().child("CJK: 中文「日本語」한글"), + div().child( + "Kana: が / か\u{3099}; Hangul: 각 / \u{1100}\u{1161}\u{11a8}", + ), + div().child("Emoji: 😀 ❤️ ❤\u{fe0e} 👍🏽 🇯🇵 1\u{fe0f}\u{20e3} 👩‍💻 👨‍👩‍👧‍👦"), + ]), + ) .child(div().child(CharacterGrid::new().scale(base))) .child( div() diff --git a/crates/gpui/resources/windows/gpui.rc b/crates/gpui/resources/windows/gpui.rc index a6f37877e8..ab209572f8 100644 --- a/crates/gpui/resources/windows/gpui.rc +++ b/crates/gpui/resources/windows/gpui.rc @@ -1,2 +1,2 @@ #define RT_MANIFEST 24 -1 RT_MANIFEST "resources/windows/gpui.manifest.xml" \ No newline at end of file +1 RT_MANIFEST "gpui.manifest.xml" diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 9811fc376a..f4a23b921d 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -14,11 +14,7 @@ use std::{ use anyhow::{Context as _, Result, anyhow}; use derive_more::{Deref, DerefMut}; -use futures::{ - Future, FutureExt, - channel::oneshot, - future::{LocalBoxFuture, Shared}, -}; +use futures::{Future, FutureExt, channel::oneshot, future::LocalBoxFuture}; use itertools::Itertools; use parking_lot::RwLock; use slotmap::SlotMap; @@ -43,18 +39,19 @@ pub use visual_test_context::*; #[cfg(any(feature = "inspector", debug_assertions))] use crate::InspectorElementRegistry; +use crate::asset_cache::CachedLoad; use crate::{ - Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Arena, - ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, ClipboardReadError, - CursorStyle, DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload, FocusHandle, - FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, - Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, - PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, - ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, - SystemNotification, SystemNotificationResponse, Task, TextRenderingMode, TextSystem, - ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, - WindowInvalidator, + Action, ActionBuildError, ActionRegistry, ActivityGuard, Any, AnyView, AnyWindowHandle, + AppContext, Arena, ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, + ClipboardReadError, CursorStyle, DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload, + FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, + LayoutId, Menu, MenuItem, MissingGlyph, OwnedMenu, PathPromptOptions, Pixels, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, + PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, + RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, + Subscription, SvgRenderer, SystemNotification, SystemNotificationResponse, Task, + TextRenderingMode, TextSystem, ThermalState, Window, WindowAppearance, WindowButtonLayout, + WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -231,6 +228,9 @@ impl Application { /// Start the application. The provided callback will be called once the /// app is fully launched. + /// + /// On WebAssembly, this returns immediately and retains the app for the lifetime + /// of the Wasm instance. Use [`Self::run_embedded`] to control its lifetime explicitly. pub fn run(self, on_finish_launching: F) where F: 'static + FnOnce(&mut App), @@ -241,6 +241,9 @@ impl Application { let cx = &mut *this.borrow_mut(); on_finish_launching(cx); })); + + #[cfg(target_family = "wasm")] + std::mem::forget(self); } /// Start the application for an embedder that drives the run loop itself. @@ -313,6 +316,7 @@ impl Application { type Handler = Box bool + 'static>; type Listener = Box bool + 'static>; +type MissingGlyphCallback = Box; pub(crate) type KeystrokeObserver = Box bool + 'static>; type QuitHandler = Box LocalBoxFuture<'static, ()> + 'static>; @@ -320,6 +324,63 @@ type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; +struct MissingGlyphCallbackEntry { + registration: Rc<()>, + callback: Option, +} + +#[derive(Default)] +struct MissingGlyphCallbackSlot { + entry: RefCell>, +} + +impl MissingGlyphCallbackSlot { + fn replace(&self, callback: MissingGlyphCallback) -> Rc<()> { + let registration = Rc::new(()); + self.entry.borrow_mut().replace(MissingGlyphCallbackEntry { + registration: registration.clone(), + callback: Some(callback), + }); + registration + } + + fn invoke(&self, missing_glyphs: &[MissingGlyph], cx: &mut App) { + let Some((registration, mut callback)) = + self.entry.borrow_mut().as_mut().and_then(|entry| { + entry + .callback + .take() + .map(|callback| (entry.registration.clone(), callback)) + }) + else { + return; + }; + callback(missing_glyphs, cx); + + let mut entry = self.entry.borrow_mut(); + let is_current = entry + .as_ref() + .is_some_and(|entry| Rc::ptr_eq(&entry.registration, ®istration)); + if is_current { + let Some(entry) = entry.as_mut() else { + return; + }; + entry.callback = Some(callback); + } + } + + fn remove(&self, registration: &Rc<()>) -> bool { + let mut entry = self.entry.borrow_mut(); + let is_current = entry + .as_ref() + .is_some_and(|entry| Rc::ptr_eq(&entry.registration, registration)); + if is_current { + entry.take(); + } + is_current + } +} + /// Defines when the application should automatically quit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum QuitMode { @@ -709,7 +770,9 @@ pub struct App { pub(crate) keystroke_observers: SubscriberSet<(), KeystrokeObserver>, pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, + missing_glyph_callback: Rc, pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, + pub(crate) system_sleep_observers: SubscriberSet<(), Handler>, pub(crate) system_wake_observers: SubscriberSet<(), Handler>, pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, @@ -843,7 +906,9 @@ impl App { keystroke_observers: SubscriberSet::new(), keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), + missing_glyph_callback: Rc::default(), thermal_state_observers: SubscriberSet::new(), + system_sleep_observers: SubscriberSet::new(), system_wake_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), @@ -877,6 +942,8 @@ impl App { init_app_menus(platform.as_ref(), &app.borrow()); SystemWindowTabController::init(&mut app.borrow_mut()); + #[cfg(feature = "profiler")] + crate::profiler::journal::observe_power(&app.borrow()); platform.on_keyboard_layout_change(Box::new({ let app = Rc::downgrade(&app); @@ -904,6 +971,18 @@ impl App { } })); + platform.on_system_sleep(Box::new({ + let app = Rc::downgrade(&app); + move || { + if let Some(app) = app.upgrade() { + let cx = &mut app.borrow_mut(); + cx.system_sleep_observers + .clone() + .retain(&(), move |callback| (callback)(cx)); + } + } + })); + platform.on_system_wake(Box::new({ let app = Rc::downgrade(&app); move || { @@ -1075,6 +1154,7 @@ impl App { self.pending_effects.push_back(Effect::RefreshWindows); } + #[inline(always)] pub(crate) fn update(&mut self, update: impl FnOnce(&mut Self) -> R) -> R { self.start_update(); let result = update(self); @@ -1086,6 +1166,7 @@ impl App { self.pending_updates += 1; } + #[inline(never)] pub(crate) fn finish_update(&mut self) { if !self.flushing_effects && self.pending_updates == 1 { self.flushing_effects = true; @@ -1353,6 +1434,11 @@ impl App { self.platform.thermal_state() } + /// Prevents idle sleep while the returned guard is held. + pub fn prevent_idle_sleep(&self, reason: &str) -> Task> { + self.platform.prevent_idle_sleep(reason) + } + /// Invokes a handler when the thermal state changes pub fn on_thermal_state_change(&self, mut callback: F) -> Subscription where @@ -1369,6 +1455,25 @@ impl App { subscription } + /// Invokes a handler when the system is about to sleep. + /// + /// The platform gives the process only a short time before suspending, so + /// handlers should record state or cancel work rather than start it. + pub fn on_system_sleep(&self, mut callback: F) -> Subscription + where + F: 'static + FnMut(&mut App), + { + let (subscription, activate) = self.system_sleep_observers.insert( + (), + Box::new(move |cx| { + callback(cx); + true + }), + ); + activate(); + subscription + } + /// Invokes a handler when the system wakes from sleep. pub fn on_system_wake(&self, mut callback: F) -> Subscription where @@ -1709,18 +1814,20 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support", feature = "bench-support"))] - for window in self - .windows - .values() - .filter_map(|window| { - let window = window.as_deref()?; - window.invalidator.is_dirty().then_some(window.handle) - }) - .collect::>() - { - self.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) - .unwrap(); + #[cfg(any(test, feature = "test-support"))] + if matches!(self.mode, GpuiMode::Test { .. }) { + for window in self + .windows + .values() + .filter_map(|window| { + let window = window.as_deref()?; + window.invalidator.is_dirty().then_some(window.handle) + }) + .collect::>() + { + self.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + } } if self.pending_effects.is_empty() { @@ -1857,6 +1964,7 @@ impl App { /// it has yet to be rendered. Returns `None` if the entity has no /// current window, or if that window has been closed, or if it is /// already on the update stack. + #[inline(always)] pub fn with_window( &mut self, entity_id: EntityId, @@ -1873,61 +1981,22 @@ impl App { .or_insert(window); } + #[inline(always)] pub(crate) fn update_window_id(&mut self, id: WindowId, update: F) -> Result where F: FnOnce(AnyView, &mut Window, &mut App) -> T, { - self.update(|cx| { - let mut window = cx.windows.get_mut(id)?.take()?; - - let root_view = window.root.clone().unwrap(); - - cx.window_update_stack.push(window.handle.id); - let result = update(root_view, &mut window, cx); - fn trail(id: WindowId, window: Box, cx: &mut App) -> Option<()> { - cx.window_update_stack.pop(); - - if window.removed { - cx.end_platform_drag(id); - cx.window_handles.remove(&id); - cx.windows.remove(id); - if let Some(tracked) = cx.tracked_entities.remove(&id) { - for entity_id in tracked { - if let Some(windows) = - cx.window_invalidators_by_entity.get_mut(&entity_id) - { - windows.remove(&id); - } - if cx.current_window_by_entity.get(&entity_id) == Some(&id) { - cx.current_window_by_entity.remove(&entity_id); - } - } - } - - cx.window_closed_observers.clone().retain(&(), |callback| { - callback(cx, id); - true - }); - - let quit_on_empty = match cx.quit_mode { - QuitMode::Explicit => false, - QuitMode::LastWindowClosed => true, - QuitMode::Default => cfg!(not(target_os = "macos")), - }; - - if quit_on_empty && cx.windows.is_empty() { - cx.quit(); - } - } else { - cx.windows.get_mut(id)?.replace(window); - } - Some(()) + let mut update = Some(update); + let mut result = None; + self.update_window_erased(id, &mut |arguments| { + if let Some((root_view, window, cx)) = arguments { + result = Some(update.take().unwrap()(root_view, window, cx)); + } else { + drop(update.take()); + drop(result.take()); } - trail(id, window, cx)?; - - Some(result) - }) - .context("window not found") + }); + result.context("window not found") } /// Creates an `AsyncApp`, which can be cloned and has a static lifetime @@ -1963,16 +2032,13 @@ impl App { /// Spawns the future returned by the given function on the main thread. The closure will be invoked /// with [AsyncApp], which allows the application state to be accessed across await points. #[track_caller] + #[inline(always)] pub fn spawn(&self, f: AsyncFn) -> Task where AsyncFn: AsyncFnOnce(&mut AsyncApp) -> R + 'static, R: 'static, { - if self.quitting { - debug_panic!("Can't spawn on main thread after on_app_quit") - }; - - let mut cx = self.to_async(); + let mut cx = self.prepare_spawn(); self.foreground_executor .spawn(async move { f(&mut cx).await }.boxed_local()) @@ -2014,6 +2080,42 @@ impl App { &self.text_system } + /// Invokes a callback with grapheme clusters that exhausted font fallback. + /// + /// Registering a callback replaces the previous callback and enables missing-glyph + /// detection. The callback runs on the foreground executor after shaping has + /// released its internal locks. Dropping its subscription disables detection until + /// another callback is registered. + /// + /// Reports are queued without blocking shaping, then deduplicated before delivery. + /// The bounded queue can drop reports on overflow. Dropped reports may be reported + /// again when the text is reshaped; no retry is scheduled automatically. + pub fn on_missing_glyphs( + &self, + callback: impl FnMut(&[MissingGlyph], &mut App) + 'static, + ) -> Subscription { + let registration = self.missing_glyph_callback.replace(Box::new(callback)); + + if let Some(mut receiver) = self.text_system.take_missing_glyph_receiver() { + let callback = self.missing_glyph_callback.clone(); + self.spawn(async move |cx| { + while let Ok(missing_glyphs) = receiver.recv().await { + cx.update(|cx| callback.invoke(&missing_glyphs, cx)); + } + }) + .detach(); + } + self.text_system.enable_missing_glyph_reporting(); + + let callback = self.missing_glyph_callback.clone(); + let text_system = self.text_system.clone(); + Subscription::new(move || { + if callback.remove(®istration) { + text_system.disable_missing_glyph_reporting(); + } + }) + } + /// Check whether a global of the given type has been assigned. pub fn has_global(&self) -> bool { self.globals_by_type.contains_key(&TypeId::of::()) @@ -2656,27 +2758,25 @@ impl App { self.loading_assets.contains_key(&asset_id) } - /// Asynchronously load an asset, if the asset hasn't finished loading this will return None. + /// Starts loading an uncached asset and returns its result once available. /// - /// Note that the multiple calls to this method will only result in one `Asset::load` call at a - /// time, and the results of this call will be cached - pub fn fetch_asset(&mut self, source: &A::Source) -> (Shared>, bool) { + /// Pending loads and completed results are cached until [`Self::remove_asset`]. + /// This method does not subscribe a view to completion notifications. + pub fn fetch_asset(&mut self, source: &A::Source) -> Option { + self.asset_entry::(source).get() + } + + pub(crate) fn asset_entry(&mut self, source: &A::Source) -> &CachedLoad { let asset_id = (TypeId::of::(), hash(source)); - let mut is_first = false; - let task = self - .loading_assets - .remove(&asset_id) - .map(|boxed_task| *boxed_task.downcast::>>().unwrap()) - .unwrap_or_else(|| { - is_first = true; - let future = A::load(source.clone(), self); - - self.background_executor().spawn(future).shared() - }); - - self.loading_assets.insert(asset_id, Box::new(task.clone())); - - (task, is_first) + if !self.loading_assets.contains_key(&asset_id) { + let future = A::load(source.clone(), self); + let entry = CachedLoad::new(future, self); + self.loading_assets.insert(asset_id, Box::new(entry)); + } + self.loading_assets + .get(&asset_id) + .and_then(|entry| entry.downcast_ref()) + .expect("asset cache entries are keyed by their asset type") } /// Obtain a new [`FocusHandle`], which allows you to track and manipulate the keyboard focus @@ -2758,11 +2858,13 @@ impl App { /// Registers a renderer specific to an inspector state. #[cfg(any(feature = "inspector", debug_assertions))] - pub fn register_inspector_element( + pub fn register_inspector_element( &mut self, - f: impl 'static + Fn(crate::InspectorElementId, &T, &mut Window, &mut App) -> R, - ) { - self.inspector_element_registry.register(f); + factory: impl 'static + Fn(&mut Window, &mut App) -> F, + ) where + F: 'static + FnMut(crate::InspectorElementId, &T, &mut Window, &mut App) -> R, + { + self.inspector_element_registry.register(factory); } /// Initializes gpui's default colors for the application. @@ -2771,6 +2873,90 @@ impl App { pub fn init_colors(&mut self) { self.set_global(GlobalColors(Arc::new(Colors::default()))); } + + #[inline(never)] + fn update_window_erased( + &mut self, + window_id: WindowId, + update: &mut dyn FnMut(Option<(AnyView, &mut Window, &mut App)>), + ) { + self.update(|cx| { + let Some(mut window) = cx.windows.get_mut(window_id).and_then(Option::take) else { + update(None); + return; + }; + + let root_view = window.root.clone().unwrap(); + + cx.window_update_stack.push(window.handle.id); + update(Some((root_view, &mut window, cx))); + fn trail(window_id: WindowId, window: Box, cx: &mut App) -> Option<()> { + cx.window_update_stack.pop(); + + if window.removed { + cx.end_platform_drag(window_id); + cx.window_handles.remove(&window_id); + cx.windows.remove(window_id); + if let Some(tracked) = cx.tracked_entities.remove(&window_id) { + for entity_id in tracked { + if let Some(windows) = + cx.window_invalidators_by_entity.get_mut(&entity_id) + { + windows.remove(&window_id); + } + if cx.current_window_by_entity.get(&entity_id) == Some(&window_id) { + cx.current_window_by_entity.remove(&entity_id); + } + } + } + + cx.window_closed_observers.clone().retain(&(), |callback| { + callback(cx, window_id); + true + }); + + let quit_on_empty = match cx.quit_mode { + QuitMode::Explicit => false, + QuitMode::LastWindowClosed => true, + QuitMode::Default => cfg!(not(target_os = "macos")), + }; + + if quit_on_empty && cx.windows.is_empty() { + cx.quit(); + } + } else { + cx.windows.get_mut(window_id)?.replace(window); + } + Some(()) + } + if trail(window_id, window, cx).is_none() { + update(None); + } + }); + } + + #[inline(never)] + fn update_entity_erased( + &mut self, + handle: &AnyEntity, + entity_type: &str, + update: &mut dyn FnMut(&mut dyn Any, &mut App), + ) { + self.update(|cx| { + let mut lease = cx.entities.lease_erased(handle, entity_type); + update(lease.entity.as_deref_mut().unwrap(), cx); + cx.entities.end_lease_erased(handle.entity_id, lease); + }); + } + + #[inline(never)] + #[track_caller] + fn prepare_spawn(&self) -> AsyncApp { + if self.quitting { + debug_panic!("Can't spawn on main thread after on_app_quit") + }; + self.to_async() + } } impl AppContext for App { @@ -2812,20 +2998,22 @@ impl AppContext for App { /// Updates the entity referenced by the given handle. The function is passed a mutable reference to the /// entity along with a `Context` for the entity. + #[inline(always)] fn update_entity( &mut self, handle: &Entity, update: impl FnOnce(&mut T, &mut Context) -> R, ) -> R { - self.update(|cx| { - let mut entity = cx.entities.lease(handle); - let result = update( - &mut entity, + let mut update = Some(update); + let mut result = None; + self.update_entity_erased(handle, type_name::(), &mut |entity, cx| { + let value = update.take().unwrap()( + entity.downcast_mut::().unwrap(), &mut Context::new_context(cx, handle.downgrade()), ); - cx.entities.end_lease(entity); - result - }) + result = Some(value); + }); + result.unwrap() } fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> GpuiBorrow<'a, T> @@ -2835,6 +3023,7 @@ impl AppContext for App { GpuiBorrow::new(handle.clone(), self) } + #[inline(always)] fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R where T: 'static, @@ -3109,7 +3298,10 @@ mod test { #[cfg(unix)] use std::os::unix::ffi::OsStringExt; - use crate::{AppContext, Context, Empty, IntoElement, Render, TestAppContext, Window}; + use crate::{ + AppContext, Context, Empty, FallbackFontClass, IntoElement, MissingGlyph, Render, + TestAppContext, Window, + }; struct RenderCounter(Rc>); @@ -3137,6 +3329,65 @@ mod test { assert_eq!(render_count.get(), render_count_before_refresh + 1); } + #[gpui::test] + fn missing_glyph_callbacks_follow_subscription_lifetime(cx: &mut TestAppContext) { + let observed = Rc::new(RefCell::new(Vec::new())); + let subscription = cx.update(|cx| { + let observed = observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + observed.borrow_mut().extend_from_slice(missing_glyphs); + }) + }); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("active")]); + }); + cx.run_until_parked(); + assert_eq!(observed.borrow().as_slice(), &[missing_glyph("active")]); + + let second_observed = Rc::new(RefCell::new(Vec::new())); + let second_subscription = cx.update(|cx| { + let second_observed = second_observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + second_observed + .borrow_mut() + .extend_from_slice(missing_glyphs); + }) + }); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("replacement")]); + }); + cx.run_until_parked(); + assert_eq!(observed.borrow().as_slice(), &[missing_glyph("active")]); + assert_eq!( + second_observed.borrow().as_slice(), + &[missing_glyph("replacement")] + ); + + drop(subscription); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("after old drop")]); + }); + cx.run_until_parked(); + assert_eq!( + second_observed.borrow().as_slice(), + &[ + missing_glyph("replacement"), + missing_glyph("after old drop") + ] + ); + + drop(second_subscription); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("inactive")]); + }); + cx.run_until_parked(); + assert_eq!(second_observed.borrow().len(), 2); + } + #[test] fn test_gpui_borrow() { let cx = TestAppContext::single(); @@ -3189,4 +3440,8 @@ mod test { assert_eq!(path, Some(restart_path)); assert_eq!(restart_arguments, arguments); } + + fn missing_glyph(grapheme: &'static str) -> MissingGlyph { + MissingGlyph::new(grapheme.into(), FallbackFontClass::Proportional) + } } diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 48c084a66f..c3af6028be 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -56,6 +56,7 @@ impl AppContext for AsyncApp { app.insert_entity(reservation, build_entity) } + #[inline(always)] fn update_entity( &mut self, handle: &Entity, @@ -73,6 +74,7 @@ impl AppContext for AsyncApp { panic!("Cannot as_mut with an async context. Try calling update() first") } + #[inline(always)] fn read_entity(&self, handle: &Entity, callback: impl FnOnce(&T, &App) -> R) -> R where T: 'static, @@ -82,6 +84,7 @@ impl AppContext for AsyncApp { lock.read_entity(handle, callback) } + #[inline(always)] fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result where F: FnOnce(AnyView, &mut Window, &mut App) -> T, @@ -94,6 +97,7 @@ impl AppContext for AsyncApp { lock.update_window(window, f) } + #[inline(always)] fn with_window( &mut self, entity_id: EntityId, @@ -162,6 +166,7 @@ impl AsyncApp { } /// Invoke the given function in the context of the app, then flush any effects produced during its invocation. + #[inline(always)] pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { let app = self.app(); let mut lock = app.borrow_mut(); @@ -203,6 +208,7 @@ impl AsyncApp { /// Schedule a future to be polled in the foreground. #[track_caller] + #[inline(always)] pub fn spawn(&self, f: AsyncFn) -> Task where AsyncFn: AsyncFnOnce(&mut AsyncApp) -> R + 'static, @@ -223,6 +229,7 @@ impl AsyncApp { /// Reads the global state of the specified type, passing it to the given callback. /// /// Panics if no global state of the specified type has been assigned. + #[inline(always)] pub fn read_global(&self, read: impl FnOnce(&G, &App) -> R) -> R { let app = self.app(); let app = app.borrow_mut(); @@ -298,12 +305,14 @@ impl AsyncWindowContext { } /// A convenience method for [`App::update_window`]. + #[inline(always)] pub fn update(&mut self, update: impl FnOnce(&mut Window, &mut App) -> R) -> Result { self.app .update_window(self.window, |_, window, cx| update(window, cx)) } /// A convenience method for [`App::update_window`]. + #[inline(always)] pub fn update_root( &mut self, update: impl FnOnce(AnyView, &mut Window, &mut App) -> R, @@ -344,6 +353,7 @@ impl AsyncWindowContext { /// Schedule a future to be executed on the main thread. This is used for collecting /// the results of background tasks and updating the UI. #[track_caller] + #[inline(always)] pub fn spawn(&self, f: AsyncFn) -> Task where AsyncFn: AsyncFnOnce(&mut AsyncWindowContext) -> R + 'static, @@ -421,6 +431,7 @@ impl AppContext for AsyncWindowContext { } } + #[inline(always)] fn update_entity( &mut self, handle: &Entity, @@ -436,6 +447,7 @@ impl AppContext for AsyncWindowContext { panic!("Cannot use as_mut() from an async context, call `update`") } + #[inline(always)] fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R where T: 'static, @@ -443,6 +455,7 @@ impl AppContext for AsyncWindowContext { self.app.read_entity(handle, read) } + #[inline(always)] fn update_window(&mut self, window: AnyWindowHandle, update: F) -> Result where F: FnOnce(AnyView, &mut Window, &mut App) -> T, @@ -450,6 +463,7 @@ impl AppContext for AsyncWindowContext { self.app.update_window(window, update) } + #[inline(always)] fn with_window( &mut self, entity_id: EntityId, diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 278f26cb31..a8b269b3f4 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -2,8 +2,11 @@ use std::{ cell::{OnceCell, RefCell}, future::Future, rc::Rc, - sync::Arc, - time::Duration, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, }; use anyhow::{Result, anyhow}; @@ -116,6 +119,10 @@ impl BenchReport { /// Creates a report that treats `frame_budget_nanos` as the per-frame budget /// when counting frame budget overruns. pub fn with_frame_budget_nanos(frame_budget_nanos: u128) -> Self { + assert!( + frame_budget_nanos > 0, + "frame budget must be at least one nanosecond" + ); Self { frame_snapshot: Rc::new(RefCell::new(WindowFrameSnapshot::new())), frame_budget_nanos, @@ -442,6 +449,7 @@ impl Drop for MeasuredTaskOutput { } } +#[cfg(test)] fn run_task_to_completion( foreground_executor: &ForegroundExecutor, task: Task, @@ -573,13 +581,27 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { /// Runs queued foreground tasks on this thread and waits for in flight /// background work to finish. Timers that aren't due yet are not waited - /// for (see [`ThreadedDispatcher::run_until_idle`]). + /// for (see [`ThreadedDispatcher::run_until_idle`]). Scheduled frames are + /// delivered after each task poll and when there are no ready tasks, but + /// animations alone do not keep this method running. pub fn run_until_idle(&self) { - self.background_executor + let dispatcher = self + .background_executor .dispatcher() .as_threaded() - .expect("validated in BenchAppContext::build") - .run_until_idle(); + .expect("validated in BenchAppContext::build"); + dispatcher.run_until_idle_with(|| { + let ran_tasks = dispatcher.run_ready_main_tasks_with( + || true, + || { + self.dispatch_pending_frames(|| true); + }, + ); + if !ran_tasks { + self.dispatch_pending_frames(|| true); + } + ran_tasks + }); } /// Alternates draining queued work with GPUI update cycles until neither @@ -600,6 +622,7 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { loop { self.run_until_idle(); self.update(|_| ()); + self.dispatch_pending_frames(|| true); if dispatcher.is_idle() { return; } @@ -608,14 +631,15 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { /// Runs main-thread tasks until `ready` returns a value. /// - /// Unlike [`Self::run_until_idle`], this returns as soon as `ready` - /// reports completion, leaving any remaining queued work pending. + /// While incomplete, scheduled frames run after each task poll and while + /// waiting for work. Readiness is checked before frame delivery, so completion + /// does not wait for a final presentation; renderer sessions do that explicitly. pub fn run_until(&self, ready: impl FnMut() -> Option) -> R { self.background_executor .dispatcher() .as_threaded() .expect("validated in BenchAppContext::build") - .run_until(ready) + .run_until_with_frames(ready, || self.dispatch_pending_frames(|| true)) } /// Creates a collector observing foreground journal entries recorded @@ -627,14 +651,19 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { /// Measures a generic benchmark workload using Criterion's iteration loop. /// /// The closure is invoked once per Criterion iteration with this - /// benchmark app context so it can update GPUI state. + /// benchmark app context so it can update GPUI state. Each iteration then + /// delivers the scheduled frame batch, without draining foreground tasks. /// /// Any window draws triggered by the workload are recorded into the /// benchmark's frame report through the GPUI frame profiler. pub fn bench_iter(&mut self, mut benchmark: impl FnMut(&mut Self)) { let bencher = self.take_bencher("bench_iter"); + self.dispatch_pending_frames(|| true); let collector = TraceScope::start(self.foreground_journal_collector()); - let mut benchmark = || benchmark(self); + let mut benchmark = || { + benchmark(self); + self.dispatch_pending_frames(|| true); + }; bencher.iter(&mut benchmark); let events = collector.finish(); self.report.record_frame_timings(events.frame_events.iter()); @@ -699,8 +728,10 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { // next setup, so per-iteration state cannot accumulate // across a measurement. setup_context.settle(); + let input = setup(&mut setup_context); + setup_context.dispatch_pending_frames(|| true); MeasuredTaskInput { - input: setup(&mut setup_context), + input, trace_scope: Some(TraceScope::start( setup_context.foreground_journal_collector(), )), @@ -708,7 +739,16 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { }, |measured_input| { let task = benchmark(&mut measured_input.input, &mut benchmark_context); - let output = run_task_to_completion(&foreground_executor, task); + benchmark_context.dispatch_pending_frames(|| true); + let output = Rc::new(RefCell::new(None)); + let completion = foreground_executor.spawn({ + let output = output.clone(); + async move { + *output.borrow_mut() = Some(task.await); + } + }); + let output = benchmark_context.run_until(|| output.borrow_mut().take()); + drop(completion); MeasuredTaskOutput { trace_scope: measured_input.trace_scope.take(), report: report.clone(), @@ -720,13 +760,19 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { self.replace_bencher(bencher); } - /// Measures frame latency after updating a GPUI entity in its current window. + /// Measures unpaced update and rendering work for an entity in its current window. /// /// Each iteration runs `update` against the entity in its current window. In - /// bench builds, flushing the update's effects synchronously draws dirty - /// windows. The entity should be part of the window's render tree, such as the + /// renderer measurements, effects flush without drawing until the platform + /// frame callback. The entity should be part of the window's render tree, such as the /// root view or a child of it. /// + /// Each iteration first pumps at most the initial ready task count, delivering + /// pending frames after every poll. It then runs `update` and delivers pending + /// frames again. Nested actions and effects finish before frame delivery. + /// This measures work and rendering cost, not display latency: there is no + /// pacing, and the report's frame budget does not control execution. + /// /// Frame events are collected through the GPUI frame profiler /// ([`crate::profiler::record_frame_event`]), which is enabled for the /// duration of the measurement. @@ -738,52 +784,198 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { V: 'static + Render, { let bencher = self.take_bencher("bench_renderer"); - let window_id = self - .with_window(view.entity_id(), |window, _| { - window.window_handle().window_id() - }) - .expect("cannot benchmark renderer for entity without a current window"); - let dispatcher = self.background_executor.dispatcher().clone(); + self.dispatch_pending_frames(|| true); let collector = TraceScope::start(self.foreground_journal_collector()); - let mut benchmark = || { - // Work already queued at frame start delays the frame in - // production too, so run it inside the measured interval. dispatcher .as_threaded() .expect("validated in BenchAppContext::build") - .run_ready_main_tasks(); + .run_ready_main_tasks_with( + || true, + || { + self.dispatch_pending_frames(|| true); + }, + ); self.with_window(view.entity_id(), |window, cx| { view.update(cx, |view, cx| update(view, window, cx)); }) .expect("cannot benchmark renderer for entity without a current window"); - // Submit the frame drawn by the update's effect flush, mirroring - // production where every drawn frame is presented. With a headless - // renderer this includes scene submission to the GPU. - self.with_window(view.entity_id(), |window, _| { - window.present_if_needed(); - }) - .expect("cannot benchmark renderer for entity without a current window"); + self.dispatch_pending_frames(|| true); }; bencher.iter(&mut benchmark); let events = collector.finish(); - self.report - .record_frame_timings(events.frame_events.iter().filter(|event| match event { - FrameEvent::Draw(timing) => timing.window_id == window_id, - FrameEvent::Present(timing) => timing.window_id == window_id, - })); - // Foreground work isn't attributed to a window, so unlike frame - // timings above it isn't filtered by `window_id`. A benchmark app - // hosts one window at a time, so this cannot pick up unrelated - // windows' work. + self.report.record_frame_timings(events.frame_events.iter()); self.report .record_foreground_events(events.foreground_events()); self.replace_bencher(bencher); } - /// Adds a window with an empty root view for benchmark setup. + /// Measures finite rendering sessions with fresh, untimed setup for each iteration. + /// + /// `setup` returns session state, its window, and a shared stop flag. + /// Loop turns pump at most the initial ready foreground task count, then call + /// `input` with a zero-based turn number unless stopped. Pending frames are + /// delivered after each task poll and input callback. Between polls, the stop + /// flag and session deadline are checked. The ready batch snapshots a count, + /// not task identities; rendering after a poll does not inject another input. + /// This is an unpaced work-and-render policy, not OS event-loop emulation. + /// Once stopped, only scheduled platform frame callbacks run until the window + /// is clean and its final changes have been submitted for presentation. + /// Pending animation callbacks alone do not delay completion. + /// Turns run without pacing or explicit OS-thread yields. A clean turn need not draw. + /// + /// Store `true` with release ordering to stop. Stopping does not imply success: + /// validate the workload in `Input::drop` or retained fixture state afterward. + /// Setup, session state destruction, and report aggregation are outside both + /// timing and tracing. Own outstanding tasks in `Input` so dropping it cancels + /// them; do not detach session work that could leak into subsequent iterations. + /// + /// # Panics + /// + /// Panics if the window is removed or stopping and final presentation exceed `timeout`. + /// The deadline is checked between polls and frames and cannot preempt a blocking task + /// poll, input callback, draw, or present. + pub fn bench_renderer_session( + &mut self, + timeout: Duration, + mut setup: impl FnMut(&mut Self) -> (Input, AnyWindowHandle, Arc), + mut input: impl FnMut(&mut Input, u64, &mut Window, &mut App), + ) { + let bencher = self.take_bencher("bench_renderer_session"); + let mut setup_context = self.clone(); + let mut benchmark_context = self.clone(); + let dispatcher = self.background_executor.dispatcher().clone(); + let dispatcher = dispatcher + .as_threaded() + .expect("validated in BenchAppContext::build"); + let report = self.report.clone(); + + bencher.iter_batched_ref( + || { + setup_context.settle(); + let input = setup(&mut setup_context); + setup_context.dispatch_pending_frames(|| true); + MeasuredTaskInput { + input, + trace_scope: Some(TraceScope::start( + setup_context.foreground_journal_collector(), + )), + } + }, + |measured_input| { + let (state, window, stopped) = &mut measured_input.input; + let started = Instant::now(); + let check_deadline = || { + assert!( + started.elapsed() < timeout, + "renderer session did not stop within {timeout:?} with final changes presented" + ); + }; + let can_poll = || { + check_deadline(); + !stopped.load(Ordering::Acquire) + }; + let is_finished = |cx: &Self| { + if !stopped.load(Ordering::Acquire) { + return false; + } + // Animation callbacks alone must not keep a stopped session alive. + let app = cx.app.borrow(); + let window = app + .windows + .get(window.window_id()) + .and_then(Option::as_deref) + .expect("renderer session window must remain open"); + !window.invalidator.is_dirty() && !window.needs_present.get() + }; + let dispatch_frames = |cx: &Self| { + check_deadline(); + cx.dispatch_pending_frames(|| { + check_deadline(); + !is_finished(cx) + }); + check_deadline(); + }; + let mut frame = 0; + loop { + check_deadline(); + if is_finished(&benchmark_context) { + break; + } + if can_poll() { + dispatcher.run_ready_main_tasks_with(&can_poll, || { + dispatch_frames(&benchmark_context); + }); + if !can_poll() { + continue; + } + benchmark_context + .update_window(*window, |_, window, cx| { + if can_poll() { + input(state, frame, window, cx); + } + }) + .expect("renderer session window must remain open"); + } + dispatch_frames(&benchmark_context); + frame += 1; + } + MeasuredTaskOutput { + trace_scope: measured_input.trace_scope.take(), + report: report.clone(), + _output: (), + } + }, + criterion::BatchSize::PerIteration, + ); + self.replace_bencher(bencher); + } + + fn dispatch_pending_frames(&self, mut should_continue: impl FnMut() -> bool) -> bool { + let pending: Vec<_> = { + let mut app = self.app.borrow_mut(); + app.windows + .values_mut() + .filter_map(|window| { + let window = window.as_deref_mut()?; + let handle = window.window_handle(); + let platform_window = window + .platform_window + .as_test() + .expect("benchmark platform window"); + platform_window + .frame_scheduled() + .then(|| (handle, platform_window.clone())) + }) + .collect() + }; + + let mut dispatched = false; + for (handle, window) in pending { + if !should_continue() { + break; + } + // An earlier callback may have closed another window in the batch. + let is_open = self + .app + .borrow() + .windows + .get(handle.window_id()) + .and_then(Option::as_deref) + .is_some(); + if is_open { + dispatched |= window.simulate_scheduled_frame(); + } + } + dispatched + } + + /// Adds an active window with an empty root view for benchmark setup. + /// + /// Activation is settled before returning so renderer measurements exercise + /// foreground animation rather than the inactive-window frame throttle. pub fn add_empty_window(&mut self) -> BenchWindowContext<'a, 'measurement> { let bounds = { let app = self.app.borrow(); @@ -801,6 +993,10 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { ) .expect("failed to open benchmark window") .into(); + // An active renderer workload must not inherit the platform + // callback's inactive-window animation throttle. + app.update_window(window, |_, window, _| window.activate_window()) + .expect("failed to activate newly created benchmark window"); window }; diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 0d1ee47ce4..c982abefec 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -2,6 +2,7 @@ use crate::{ AnyView, AnyWindowHandle, AppContext, AsyncApp, DispatchPhase, Effect, EntityId, EventEmitter, FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Priority, Reservation, SubscriberSet, Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, + WindowVisibility, }; use anyhow::Result; use futures::FutureExt; @@ -69,14 +70,9 @@ impl<'a, T: 'static> Context<'a, T> { T: 'static, W: 'static, { - let this = self.weak_entity(); - self.app.observe_internal(entity, move |e, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| on_notify(this, e, cx)); - true - } else { - false - } + let observer = self.weak_entity(); + self.app.observe_internal(entity, move |entity, cx| { + invoke_observer(&observer, entity, cx, &mut on_notify) }) } @@ -105,15 +101,11 @@ impl<'a, T: 'static> Context<'a, T> { T2: 'static + EventEmitter, Evt: 'static, { - let this = self.weak_entity(); - self.app.subscribe_internal(entity, move |e, event, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| on_event(this, e, event, cx)); - true - } else { - false - } - }) + let subscriber = self.weak_entity(); + self.app + .subscribe_internal(entity, move |entity, event, cx| { + invoke_subscriber(&subscriber, entity, event, cx, &mut on_event) + }) } /// Subscribe to an event type from ourself @@ -234,6 +226,7 @@ impl<'a, T: 'static> Context<'a, T> { /// The function is provided a weak handle to the entity owned by this context and a context that can be held across await points. /// The returned task must be held or detached. #[track_caller] + #[inline(always)] pub fn spawn(&self, f: AsyncFn) -> Task where T: 'static, @@ -249,13 +242,16 @@ impl<'a, T: 'static> Context<'a, T> { /// Many GPUI callbacks take the form of `Fn(&E, &mut Window, &mut App)`, /// but it's often useful to be able to access view state in these /// callbacks. This method provides a convenient way to do so. + #[inline(always)] pub fn listener( &self, - f: impl Fn(&mut T, &E, &mut Window, &mut Context) + 'static, + listener: impl Fn(&mut T, &E, &mut Window, &mut Context) + 'static, ) -> impl Fn(&E, &mut Window, &mut App) + 'static { let view = self.entity().downgrade(); - move |e: &E, window: &mut Window, cx: &mut App| { - view.update(cx, |view, cx| f(view, e, window, cx)).ok(); + move |event: &E, window: &mut Window, cx: &mut App| { + invoke_listener(&view, window, cx, &|view, window, cx| { + listener(view, event, window, cx); + }); } } @@ -335,17 +331,7 @@ impl<'a, T: 'static> Context<'a, T> { self.ensure_window(observer_id, window.handle.id); self.new_observer( observed_id, - Box::new(move |cx| { - let Some((observer, observed)) = observer.upgrade().zip(observed.upgrade()) else { - return false; - }; - cx.with_window(observer_id, |window, cx| { - observer.update(cx, |observer, cx| { - on_notify(observer, observed, window, cx); - }); - }); - true - }), + Box::new(move |cx| invoke_observer_in(&observer, &observed, cx, &mut on_notify)), ) } @@ -371,17 +357,7 @@ impl<'a, T: 'static> Context<'a, T> { ( TypeId::of::(), Box::new(move |event, cx| { - let Some((subscriber, emitter)) = subscriber.upgrade().zip(emitter.upgrade()) - else { - return false; - }; - let event = event.downcast_ref().expect("invalid event type"); - cx.with_window(subscriber_id, |window, cx| { - subscriber.update(cx, |subscriber, cx| { - on_event(subscriber, &emitter, event, window, cx); - }); - }); - true + invoke_subscriber_in(&subscriber, &emitter, event, cx, &mut on_event) }), ), ) @@ -458,6 +434,25 @@ impl<'a, T: 'static> Context<'a, T> { subscription } + /// Registers a callback to be invoked when the window's visibility changes + /// (see [`WindowVisibility`]). + pub fn observe_window_visibility( + &self, + window: &mut Window, + mut callback: impl FnMut(&mut T, WindowVisibility, &mut Window, &mut Context) + 'static, + ) -> Subscription { + let view = self.weak_entity(); + let (subscription, activate) = window.visibility_observers.insert( + (), + Box::new(move |visibility, window, cx| { + view.update(cx, |view, cx| callback(view, visibility, window, cx)) + .is_ok() + }), + ); + activate(); + subscription + } + /// Registers a callback to be invoked when the window appearance changes. pub fn observe_window_appearance( &self, @@ -673,6 +668,7 @@ impl<'a, T: 'static> Context<'a, T> { /// It's also given an [`AsyncWindowContext`], which can be used to access the state of the entity across await points. /// The returned future will be polled on the main thread. #[track_caller] + #[inline(always)] pub fn spawn_in(&self, window: &Window, f: AsyncFn) -> Task where R: 'static, @@ -687,6 +683,7 @@ impl<'a, T: 'static> Context<'a, T> { /// It's also given an [`AsyncWindowContext`], which can be used to access the state of the entity across await points. /// The returned future will be polled on the main thread. #[track_caller] + #[inline(always)] pub fn spawn_in_with_priority( &self, priority: Priority, @@ -881,3 +878,84 @@ impl BorrowMut for Context<'_, T> { self.app } } + +#[inline(never)] +fn invoke_observer( + observer: &WeakEntity, + observed: Entity, + cx: &mut App, + on_notify: &mut dyn FnMut(&mut T, Entity, &mut Context), +) -> bool { + if let Some(observer) = observer.upgrade() { + observer.update(cx, |observer, cx| on_notify(observer, observed, cx)); + true + } else { + false + } +} + +#[inline(never)] +fn invoke_subscriber( + subscriber: &WeakEntity, + emitter: Entity, + event: &Event, + cx: &mut App, + on_event: &mut dyn FnMut(&mut T, Entity, &Event, &mut Context), +) -> bool { + if let Some(subscriber) = subscriber.upgrade() { + subscriber.update(cx, |subscriber, cx| { + on_event(subscriber, emitter, event, cx) + }); + true + } else { + false + } +} + +#[inline(never)] +fn invoke_observer_in( + observer: &WeakEntity, + observed: &WeakEntity, + cx: &mut App, + on_notify: &mut dyn FnMut(&mut T, Entity, &mut Window, &mut Context), +) -> bool { + let Some((observer, observed)) = observer.upgrade().zip(observed.upgrade()) else { + return false; + }; + cx.with_window(observer.entity_id(), |window, cx| { + observer.update(cx, |observer, cx| { + on_notify(observer, observed, window, cx); + }); + }); + true +} + +#[inline(never)] +fn invoke_subscriber_in( + subscriber: &WeakEntity, + emitter: &WeakEntity, + event: &dyn Any, + cx: &mut App, + on_event: &mut dyn FnMut(&mut T, &Entity, &Event, &mut Window, &mut Context), +) -> bool { + let Some((subscriber, emitter)) = subscriber.upgrade().zip(emitter.upgrade()) else { + return false; + }; + let event = event.downcast_ref().expect("invalid event type"); + cx.with_window(subscriber.entity_id(), |window, cx| { + subscriber.update(cx, |subscriber, cx| { + on_event(subscriber, &emitter, event, window, cx); + }); + }); + true +} + +#[inline(never)] +fn invoke_listener( + view: &WeakEntity, + window: &mut Window, + cx: &mut App, + listener: &dyn Fn(&mut T, &mut Window, &mut Context), +) { + view.update(cx, |view, cx| listener(view, window, cx)).ok(); +} diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index e4e9f3b58a..4184503ba3 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -67,6 +67,10 @@ pub(crate) struct EntityRefCounts { leak_detector: LeakDetector, } +pub(super) struct LeaseInner { + pub(super) entity: Option>, +} + impl EntityMap { pub fn new() -> Self { Self { @@ -132,36 +136,38 @@ impl EntityMap { /// Move an entity to the stack. #[track_caller] pub fn lease(&mut self, pointer: &Entity) -> Lease { - self.assert_valid_context(pointer); - let mut accessed_entities = self.accessed_entities.get_mut(); - accessed_entities.insert(pointer.entity_id); - - let entity = Some( - self.entities - .remove(pointer.entity_id) - .unwrap_or_else(|| double_lease_panic::("update")), - ); Lease { - entity, + inner: self.lease_erased(pointer, type_name::()), id: pointer.entity_id, entity_type: PhantomData, } } /// Returns an entity after moving it to the stack. - pub fn end_lease(&mut self, mut lease: Lease) { - self.entities.insert(lease.id, lease.entity.take().unwrap()); + pub fn end_lease(&mut self, lease: Lease) { + self.end_lease_erased(lease.id, lease.inner); } + #[inline(always)] pub fn read(&self, entity: &Entity) -> &T { self.assert_valid_context(entity); - let mut accessed_entities = self.accessed_entities.borrow_mut(); - accessed_entities.insert(entity.entity_id); - - self.entities - .get(entity.entity_id) + self.read_inner(entity.entity_id) .and_then(|entity| entity.downcast_ref()) - .unwrap_or_else(|| double_lease_panic::("read")) + .unwrap_or_else(|| double_lease_panic("read", type_name::())) + } + + #[track_caller] + pub(super) fn lease_erased(&mut self, pointer: &AnyEntity, entity_type: &str) -> LeaseInner { + self.assert_valid_context(pointer); + let entity = Some( + self.lease_inner(pointer.entity_id) + .unwrap_or_else(|| double_lease_panic("update", entity_type)), + ); + LeaseInner { entity } + } + + pub(super) fn end_lease_erased(&mut self, entity_id: EntityId, mut lease: LeaseInner) { + self.end_lease_inner(entity_id, lease.entity.take().unwrap()); } fn assert_valid_context(&self, entity: &AnyEntity) { @@ -201,19 +207,34 @@ impl EntityMap { }) .collect() } + + #[inline(never)] + fn read_inner(&self, entity_id: EntityId) -> Option<&dyn Any> { + let mut accessed_entities = self.accessed_entities.borrow_mut(); + accessed_entities.insert(entity_id); + self.entities.get(entity_id).map(Box::as_ref) + } + + #[inline(never)] + fn lease_inner(&mut self, entity_id: EntityId) -> Option> { + self.accessed_entities.get_mut().insert(entity_id); + self.entities.remove(entity_id) + } + + #[inline(never)] + fn end_lease_inner(&mut self, entity_id: EntityId, entity: Box) { + self.entities.insert(entity_id, entity); + } } #[track_caller] -fn double_lease_panic(operation: &str) -> ! { - panic!( - "cannot {operation} {} while it is already being updated", - std::any::type_name::() - ) +fn double_lease_panic(operation: &str, entity_type: &str) -> ! { + panic!("cannot {operation} {entity_type} while it is already being updated") } pub(crate) struct Lease { - entity: Option>, pub id: EntityId, + inner: LeaseInner, entity_type: PhantomData, } @@ -221,17 +242,17 @@ impl core::ops::Deref for Lease { type Target = T; fn deref(&self) -> &Self::Target { - self.entity.as_ref().unwrap().downcast_ref().unwrap() + self.inner.entity.as_ref().unwrap().downcast_ref().unwrap() } } impl core::ops::DerefMut for Lease { fn deref_mut(&mut self) -> &mut Self::Target { - self.entity.as_mut().unwrap().downcast_mut().unwrap() + self.inner.entity.as_mut().unwrap().downcast_mut().unwrap() } } -impl Drop for Lease { +impl Drop for LeaseInner { fn drop(&mut self) { if self.entity.is_some() && !panicking() { panic!("Leases must be ended with EntityMap::end_lease") @@ -774,6 +795,7 @@ impl WeakEntity { /// Updates the entity referenced by this handle with the given function if /// the referenced entity still exists. Returns an error if the entity has /// been released. + #[inline(always)] pub fn update( &self, cx: &mut C, @@ -789,6 +811,7 @@ impl WeakEntity { /// Updates the entity referenced by this handle with the given function if /// the referenced entity still exists, within a visual context that has a window. /// Returns an error if the entity has been released. + #[inline(always)] pub fn update_in( &self, cx: &mut C, @@ -807,6 +830,7 @@ impl WeakEntity { /// Reads the entity referenced by this handle with the given function if /// the referenced entity still exists. Returns an error if the entity has /// been released. + #[inline(always)] pub fn read_with(&self, cx: &C, read: impl FnOnce(&T, &App) -> R) -> Result where C: AppContext, diff --git a/crates/gpui/src/app/test_app.rs b/crates/gpui/src/app/test_app.rs index d241863883..bf265fd7bf 100644 --- a/crates/gpui/src/app/test_app.rs +++ b/crates/gpui/src/app/test_app.rs @@ -481,6 +481,24 @@ impl TestAppWindow { self.background_executor.run_until_parked(); } + /// Simulate the window moving to a display with a different scale factor. + pub fn simulate_scale_factor_change(&mut self, scale_factor: f32) { + let window_id = self.handle.window_id(); + let test_window = { + let mut app = self.app.borrow_mut(); + app.windows + .get_mut(window_id) + .and_then(|window| window.as_mut()) + .and_then(|window| window.platform_window.as_test()) + .cloned() + }; + // The resize callback needs to borrow the app again synchronously. + if let Some(mut test_window) = test_window { + test_window.simulate_scale_factor_change(scale_factor); + } + self.background_executor.run_until_parked(); + } + /// Force a redraw of the window. pub fn draw(&mut self) { let mut app = self.app.borrow_mut(); @@ -557,6 +575,26 @@ mod tests { app.update(|cx| cx.shutdown()); } + #[test] + fn test_simulate_scale_factor_change() { + let mut app = TestApp::new(); + let mut window = app.open_window(|_, _| crate::EmptyView); + let viewport_size = window.update(|_, window, _| { + assert_eq!(window.scale_factor(), 2.0); + window.viewport_size() + }); + + window.simulate_scale_factor_change(1.0); + + window.update(|_, window, _| { + assert_eq!(window.scale_factor(), 1.0); + assert_eq!(window.viewport_size(), viewport_size); + }); + + drop(window); + app.update(|cx| cx.shutdown()); + } + #[test] fn test_entity_creation() { let mut app = TestApp::new(); diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 1dceee09d7..b2b8179e8f 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -3,10 +3,10 @@ use crate::{ BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable, Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Platform, Point, Render, Result, SharedString, Size, SystemNotification, - SystemNotificationResponse, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, - TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - app::GpuiMode, window::ElementArenaScope, + Pixels, Platform, PlatformTextSystem, Point, Render, Result, SharedString, Size, + SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform, + TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, + WindowHandle, WindowOptions, WindowVisibility, app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -129,6 +129,45 @@ impl TestAppContext { let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(arc_dispatcher); let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); + Self::build_with_platform( + dispatcher, + fn_name, + background_executor, + foreground_executor, + platform, + ) + } + + /// Creates a test context backed by the provided platform text system. + pub fn build_with_text_system( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + platform_text_system: Arc, + ) -> Self { + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + let platform = TestPlatform::with_text_system( + background_executor.clone(), + foreground_executor.clone(), + platform_text_system, + ); + Self::build_with_platform( + dispatcher, + fn_name, + background_executor, + foreground_executor, + platform, + ) + } + + fn build_with_platform( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + platform: Rc, + ) -> Self { let asset_source = Arc::new(()); let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); @@ -169,6 +208,21 @@ impl TestAppContext { self.test_platform.did_prompt_for_new_path() } + /// Returns the number of active idle sleep prevention tokens. + pub fn active_idle_sleep_preventions(&self) -> usize { + self.test_platform.active_idle_sleep_preventions() + } + + /// Sets the delay for subsequent idle sleep prevention acquisitions. + pub fn set_idle_sleep_prevention_delay(&self, delay: Duration) { + self.test_platform.set_idle_sleep_prevention_delay(delay); + } + + /// Makes subsequent idle sleep prevention acquisitions fail or succeed. + pub fn set_idle_sleep_prevention_fails(&self, fails: bool) { + self.test_platform.set_idle_sleep_prevention_fails(fails); + } + /// returns a new `TestAppContext` re-using the same executors to interleave tasks. pub fn new_app(&self) -> TestAppContext { Self::build(self.dispatcher.clone(), self.fn_name) @@ -403,6 +457,36 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Simulates a change in whether the platform is presenting the window. + pub fn simulate_window_visibility_change( + &self, + window_handle: AnyWindowHandle, + visibility: WindowVisibility, + ) { + self.test_window(window_handle) + .simulate_visibility_change(visibility); + } + + /// Simulates visible viewport changes without resizing the window's layout area. + pub fn simulate_window_visual_viewport_change( + &self, + window_handle: AnyWindowHandle, + bounds: Bounds, + ) { + self.test_window(window_handle) + .simulate_visual_viewport_change(bounds); + } + + /// Simulates the window moving to a display with a different scale factor. + pub fn simulate_window_scale_factor_change( + &self, + window_handle: AnyWindowHandle, + scale_factor: f32, + ) { + self.test_window(window_handle) + .simulate_scale_factor_change(scale_factor); + } + /// Returns true if there's an alert dialog open. pub fn expect_restart(&self) -> oneshot::Receiver<(Option, Vec)> { let (tx, rx) = futures::channel::oneshot::channel(); @@ -884,6 +968,16 @@ impl VisualTestContext { self.simulate_window_resize(self.window, size) } + /// Simulates a change in whether the platform is presenting this window. + pub fn simulate_visibility_change(&self, visibility: WindowVisibility) { + self.simulate_window_visibility_change(self.window, visibility); + } + + /// Simulates the window moving to a display with a different scale factor. + pub fn simulate_scale_factor_change(&self, scale_factor: f32) { + self.simulate_window_scale_factor_change(self.window, scale_factor) + } + /// debug_bounds returns the bounds of the element with the given selector. pub fn debug_bounds(&mut self, selector: &'static str) -> Option> { self.update(|window, _| window.rendered_frame.debug_bounds.get(selector).copied()) diff --git a/crates/gpui/src/arena.rs b/crates/gpui/src/arena.rs index 2ba7a2f605..ea5968f84e 100644 --- a/crates/gpui/src/arena.rs +++ b/crates/gpui/src/arena.rs @@ -172,29 +172,11 @@ impl Arena { } let layout = alloc::Layout::new::(); - let mut current_chunk = &mut self.chunks[self.current_chunk_index]; + let current_chunk = &mut self.chunks[self.current_chunk_index]; let ptr = if let Some(ptr) = current_chunk.allocate(layout) { ptr.as_ptr() } else { - self.current_chunk_index += 1; - if self.current_chunk_index >= self.chunks.len() { - self.chunks.push(Chunk::new(self.chunk_size)); - assert_eq!(self.current_chunk_index, self.chunks.len() - 1); - log::trace!( - "increased element arena capacity to {}kb", - self.capacity() / 1024, - ); - } - current_chunk = &mut self.chunks[self.current_chunk_index]; - if let Some(ptr) = current_chunk.allocate(layout) { - ptr.as_ptr() - } else { - panic!( - "Arena chunk_size of {} is too small to allocate {} bytes", - self.chunk_size, - layout.size() - ); - } + self.allocate_next_chunk(layout) }; unsafe { inner_writer(ptr.cast(), f) }; @@ -208,6 +190,29 @@ impl Arena { valid: self.valid.clone(), } } + + #[inline(never)] + fn allocate_next_chunk(&mut self, layout: alloc::Layout) -> *mut u8 { + self.current_chunk_index += 1; + if self.current_chunk_index >= self.chunks.len() { + self.chunks.push(Chunk::new(self.chunk_size)); + assert_eq!(self.current_chunk_index, self.chunks.len() - 1); + log::trace!( + "increased element arena capacity to {}kb", + self.capacity() / 1024, + ); + } + let current_chunk = &mut self.chunks[self.current_chunk_index]; + if let Some(pointer) = current_chunk.allocate(layout) { + pointer.as_ptr() + } else { + panic!( + "Arena chunk_size of {} is too small to allocate {} bytes", + self.chunk_size, + layout.size() + ); + } + } } pub struct ArenaBox { diff --git a/crates/gpui/src/asset_cache.rs b/crates/gpui/src/asset_cache.rs index bd1271541a..4c7bee1207 100644 --- a/crates/gpui/src/asset_cache.rs +++ b/crates/gpui/src/asset_cache.rs @@ -1,12 +1,71 @@ -use crate::{App, SharedString, SharedUri}; +use crate::{App, EntityId, SharedString, SharedUri, Task}; +use collections::FxHashSet; use futures::{Future, TryFutureExt}; +use std::cell::RefCell; use std::fmt::Debug; use std::hash::{BuildHasher, Hash}; use std::marker::PhantomData; +use std::mem; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::sync::Arc; +pub(crate) struct CachedLoad { + state: Rc>>, + // Keep the owner outside the state so publishing a result cannot cancel its own task. + _task: Task<()>, +} + +enum CachedLoadState { + Loading(FxHashSet), + Loaded(T), +} + +impl CachedLoad { + pub(crate) fn new(future: impl Future + Send + 'static, cx: &App) -> Self { + let state = Rc::new(RefCell::new(CachedLoadState::Loading(FxHashSet::default()))); + let task = cx.background_executor().spawn(future); + let task = cx.spawn({ + let state = Rc::downgrade(&state); + async move |cx| { + let result = task.await; + let Some(state) = state.upgrade() else { + return; + }; + let previous = + mem::replace(&mut *state.borrow_mut(), CachedLoadState::Loaded(result)); + let CachedLoadState::Loading(views) = previous else { + unreachable!("a cached load completes only once"); + }; + cx.update(|cx| { + for view in views { + cx.notify(view); + } + }); + } + }); + Self { state, _task: task } + } + + pub(crate) fn get(&self) -> Option { + match &*self.state.borrow() { + CachedLoadState::Loading(_) => None, + CachedLoadState::Loaded(result) => Some(result.clone()), + } + } + + pub(crate) fn use_by(&self, view: EntityId) -> Option { + match &mut *self.state.borrow_mut() { + CachedLoadState::Loading(views) => { + views.insert(view); + None + } + CachedLoadState::Loaded(result) => Some(result.clone()), + } + } +} + /// An enum representing #[derive(Debug, PartialEq, Eq, Hash, Clone)] pub enum Resource { @@ -80,3 +139,220 @@ where pub fn hash(data: &T) -> u64 { collections::FxBuildHasher.hash_one(data) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AppContext, TestAppContext}; + use futures::channel::oneshot; + use std::{ + cell::Cell, + collections::VecDeque, + hash::{Hash, Hasher}, + rc::Rc, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + }; + + #[gpui::test] + fn cached_load_caches_success_and_error(cx: &mut TestAppContext) { + let successful_load = + cx.update(|cx| CachedLoad::new(async { Ok::<_, &'static str>(42) }, cx)); + let failed_load = + cx.update(|cx| CachedLoad::new(async { Err::("load failed") }, cx)); + + assert_eq!(successful_load.get(), None); + assert_eq!(failed_load.get(), None); + + cx.run_until_parked(); + + assert_eq!(successful_load.get(), Some(Ok(42))); + assert_eq!(successful_load.get(), Some(Ok(42))); + assert_eq!(failed_load.get(), Some(Err("load failed"))); + assert_eq!(failed_load.get(), Some(Err("load failed"))); + } + + #[gpui::test] + fn completed_load_without_subscribers_is_cached_and_deduplicated(cx: &mut TestAppContext) { + let (sender, receiver) = oneshot::channel(); + let source = TestAssetSource::new(1, [receiver]); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(source.load_count(), 1); + + assert!(sender.send(Ok(42)).is_ok()); + cx.run_until_parked(); + + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Ok(42)) + ); + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Ok(42)) + ); + assert_eq!(source.load_count(), 1); + } + + #[gpui::test] + fn load_completion_notifies_each_observing_entity_once(cx: &mut TestAppContext) { + let (sender, receiver) = oneshot::channel::(); + let load = cx.update(|cx| { + CachedLoad::new( + async move { receiver.await.expect("test sends a result") }, + cx, + ) + }); + let first_notification_count = Rc::new(Cell::new(0)); + let second_notification_count = Rc::new(Cell::new(0)); + let (first_entity, second_entity) = cx.update(|cx| { + let first_entity = cx.new(|_| ()); + let second_entity = cx.new(|_| ()); + cx.observe(&first_entity, { + let first_notification_count = first_notification_count.clone(); + move |_, _| first_notification_count.set(first_notification_count.get() + 1) + }) + .detach(); + cx.observe(&second_entity, { + let second_notification_count = second_notification_count.clone(); + move |_, _| second_notification_count.set(second_notification_count.get() + 1) + }) + .detach(); + (first_entity, second_entity) + }); + + assert_eq!(load.use_by(first_entity.entity_id()), None); + assert_eq!(load.use_by(first_entity.entity_id()), None); + assert_eq!(load.use_by(second_entity.entity_id()), None); + + assert!(sender.send(42).is_ok()); + cx.run_until_parked(); + + assert_eq!(first_notification_count.get(), 1); + assert_eq!(second_notification_count.get(), 1); + assert_eq!(load.use_by(first_entity.entity_id()), Some(42)); + assert_eq!(load.use_by(second_entity.entity_id()), Some(42)); + } + + #[gpui::test] + fn removing_pending_asset_cancels_it_and_replacement_ignores_stale_completion( + cx: &mut TestAppContext, + ) { + let (stale_sender, stale_receiver) = oneshot::channel(); + let (replacement_sender, replacement_receiver) = oneshot::channel(); + let source = TestAssetSource::new(2, [stale_receiver, replacement_receiver]); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(source.load_count(), 1); + cx.run_until_parked(); + + cx.update(|cx| cx.remove_asset::(&source)); + assert!(!cx.update(|cx| cx.has_asset::(&source))); + cx.run_until_parked(); + assert!(stale_sender.send(Ok(1)).is_err()); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(source.load_count(), 2); + + assert!(replacement_sender.send(Err("replacement failed")).is_ok()); + cx.run_until_parked(); + + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Err("replacement failed")) + ); + assert_eq!(source.load_count(), 2); + } + + #[gpui::test] + fn eviction_after_worker_completion_does_not_publish_into_replacement(cx: &mut TestAppContext) { + let (sender, receiver) = oneshot::channel(); + let (replacement_sender, replacement_receiver) = oneshot::channel(); + let source = TestAssetSource::new(3, [receiver, replacement_receiver]); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert!(sender.send(Ok(1)).is_ok()); + while source.completion_count.load(Ordering::SeqCst) == 0 { + assert!(cx.background_executor.tick()); + } + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + + cx.update(|cx| { + cx.remove_asset::(&source); + assert_eq!(cx.fetch_asset::(&source), None); + }); + cx.run_until_parked(); + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + + assert!(replacement_sender.send(Ok(2)).is_ok()); + cx.run_until_parked(); + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Ok(2)) + ); + assert_eq!(source.load_count(), 2); + } + + struct TestAsset; + + #[derive(Clone)] + struct TestAssetSource { + id: usize, + load_count: Arc, + completion_count: Arc, + receivers: Arc>>>>, + } + + impl TestAssetSource { + fn new( + id: usize, + receivers: impl IntoIterator>>, + ) -> Self { + Self { + id, + load_count: Arc::new(AtomicUsize::new(0)), + completion_count: Arc::new(AtomicUsize::new(0)), + receivers: Arc::new(Mutex::new(receivers.into_iter().collect())), + } + } + + fn load_count(&self) -> usize { + self.load_count.load(Ordering::SeqCst) + } + } + + impl Hash for TestAssetSource { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } + } + + impl Asset for TestAsset { + type Source = TestAssetSource; + type Output = Result; + + fn load( + source: Self::Source, + _cx: &mut App, + ) -> impl Future + Send + 'static { + source.load_count.fetch_add(1, Ordering::SeqCst); + let receiver = source + .receivers + .lock() + .expect("test receiver mutex should not be poisoned") + .pop_front() + .expect("each test load should have a receiver"); + async move { + let result = match receiver.await { + Ok(result) => result, + Err(_) => Err("cancelled"), + }; + source.completion_count.fetch_add(1, Ordering::SeqCst); + result + } + } + } +} diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index c817492b94..d6556258e1 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -31,6 +31,8 @@ //! elements when you need to take manual control of the layout and painting process, such as when using //! your own custom layout algorithm or rendering a code editor. +#[cfg(any(feature = "inspector", debug_assertions))] +use crate::InspectorElementPath; use crate::{ A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window, @@ -194,7 +196,7 @@ pub trait ParentElement { where Self: Sized, { - self.extend(std::iter::once(child.into_element().into_any())); + self.extend(std::iter::once(child.into_any_element())); self } @@ -297,21 +299,21 @@ impl Drawable { fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> LayoutId { match mem::take(&mut self.phase) { ElementDrawPhase::Start => { - let global_id = self.element.id().map(|element_id| { - window.element_id_stack.push(element_id); - GlobalElementId(Arc::from(&*window.element_id_stack)) - }); + let global_id = self + .element + .id() + .map(|element_id| prepare_element_id(element_id, window)); let inspector_id; #[cfg(any(feature = "inspector", debug_assertions))] { - inspector_id = self.element.source_location().map(|source| { - let path = crate::InspectorElementPath { - global_id: GlobalElementId(Arc::from(&*window.element_id_stack)), - source_location: source, - }; - window.build_inspector_element_id(path) - }); + inspector_id = if window.inspector_enabled() { + self.element + .source_location() + .map(|source| prepare_inspector_id(source, window)) + } else { + None + }; } #[cfg(not(any(feature = "inspector", debug_assertions)))] { @@ -790,3 +792,22 @@ impl Element for Empty { ) { } } + +#[inline(never)] +fn prepare_element_id(element_id: ElementId, window: &mut Window) -> GlobalElementId { + window.element_id_stack.push(element_id); + GlobalElementId(Arc::from(&*window.element_id_stack)) +} + +#[cfg(any(feature = "inspector", debug_assertions))] +#[inline(never)] +fn prepare_inspector_id( + source: &'static panic::Location<'static>, + window: &mut Window, +) -> InspectorElementId { + let path = InspectorElementPath { + global_id: GlobalElementId(Arc::from(&*window.element_id_stack)), + source_location: source, + }; + window.build_inspector_element_id(path) +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 98275cd189..c32ac995ec 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -18,13 +18,13 @@ use crate::{ Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, Display, Element, ElementId, Entity, EntityId, ExternalDragPayload, ExternalDragPayloadSource, - FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, - IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, - LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseExitEvent, - MouseMoveEvent, MousePressureEvent, MouseUpEvent, OngoingScroll, Overflow, ParentElement, - PinchEvent, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, - StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, - size, + FileDropEvent, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, + InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, + KeyboardClickEvent, LayoutId, LongPressEvent, ModifiersChangedEvent, MouseButton, + MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, + MouseUpEvent, OngoingScroll, Overflow, ParentElement, PinchEvent, Pixels, Point, Render, + ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, + TouchPhase, Visibility, Window, WindowControlArea, point, px, size, }; use collections::HashMap; use gpui_util::ResultExt; @@ -32,7 +32,7 @@ use refineable::Refineable; use smallvec::SmallVec; use std::{ any::{Any, TypeId}, - cell::RefCell, + cell::{Cell, RefCell}, cmp::Ordering, fmt::Debug, marker::PhantomData, @@ -328,6 +328,28 @@ impl Interactivity { })); } + /// Bind the given callback to [`FileDropEvent::Exited`] when a platform file drag + /// leaves this element's window while the element is hovered. + /// + /// This is a window-local exit event, not notification that the platform drag session ended. + /// The imperative API equivalent to [`InteractiveElement::on_file_drop_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_file_drop_exit( + &mut self, + listener: impl Fn(&FileDropEvent, &mut Window, &mut App) + 'static, + ) { + self.file_drop_exit_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble + && matches!(event, FileDropEvent::Exited) + && hitbox.id.is_hovered_ignoring_last_input(window) + { + (listener)(event, window, cx); + } + })); + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -432,6 +454,7 @@ impl Interactivity { /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. #[track_caller] + #[inline(always)] pub fn on_action(&mut self, listener: impl Fn(&A, &mut Window, &mut App) + 'static) { self.action_listeners.push(( TypeId::of::(), @@ -535,10 +558,7 @@ impl Interactivity { &mut self, listener: impl Fn(&ModifiersChangedEvent, &mut Window, &mut App) + 'static, ) { - self.modifiers_changed_listeners - .push(Box::new(move |event, window, cx| { - listener(event, window, cx) - })); + self.modifiers_changed_listeners.push(Box::new(listener)); } /// Bind the given callback to drop events of the given type, whether or not the drag started on this element. @@ -567,13 +587,12 @@ impl Interactivity { /// The imperative API equivalent to [`StatefulInteractiveElement::on_click`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[inline(always)] pub fn on_click(&mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) where Self: Sized, { - self.click_listeners.push(Rc::new(move |event, window, cx| { - listener(event, window, cx) - })); + self.click_listeners.push(Rc::new(listener)); } /// Bind the given callback to non-primary click events of this element. @@ -584,10 +603,7 @@ impl Interactivity { where Self: Sized, { - self.aux_click_listeners - .push(Rc::new(move |event, window, cx| { - listener(event, window, cx) - })); + self.aux_click_listeners.push(Rc::new(listener)); } /// On drag initiation, this callback will be used to create a new view to render the dragged value for a @@ -649,6 +665,10 @@ impl Interactivity { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The imperative API equivalent to [`StatefulInteractiveElement::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -663,7 +683,17 @@ impl Interactivity { self.hover_listener = Some(Box::new(listener)); } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The imperative API + /// equivalent to [`StatefulInteractiveElement::hover_listener_mode`]. + pub fn hover_listener_mode(&mut self, mode: HoverListenerMode) + where + Self: Sized, + { + self.hover_listener_mode = mode; + } + + /// Constructs a tooltip when the element is hovered or long-pressed. /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`]. pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) where @@ -679,7 +709,7 @@ impl Interactivity { }); } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Constructs a tooltip when the element is hovered or long-pressed. /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into /// the tooltip. The imperative API equivalent to [`StatefulInteractiveElement::hoverable_tooltip`]. pub fn hoverable_tooltip( @@ -698,7 +728,9 @@ impl Interactivity { }); } - /// Set the delay before this element's tooltip is shown. + /// Sets the delay before this element's tooltip is shown on hover. + /// + /// Touch long presses show the tooltip immediately once the gesture is recognized. /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip_show_delay`]. pub fn tooltip_show_delay(&mut self, delay: Duration) { self.tooltip_show_delay = Some(delay); @@ -833,6 +865,7 @@ pub trait InteractiveElement: Sized { /// The fluent API equivalent to [`Interactivity::on_mouse_down`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to the view state from this callback. + #[inline(always)] fn on_mouse_down( mut self, button: MouseButton, @@ -984,6 +1017,21 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to [`FileDropEvent::Exited`] when a platform file drag + /// leaves this element's window while the element is hovered. + /// + /// This is a window-local exit event, not notification that the platform drag session ended. + /// The fluent API equivalent to [`Interactivity::on_file_drop_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_file_drop_exit( + mut self, + listener: impl Fn(&FileDropEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_file_drop_exit(listener); + self + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -1048,6 +1096,7 @@ pub trait InteractiveElement: Sized { /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. #[track_caller] + #[inline(always)] fn on_action( mut self, listener: impl Fn(&A, &mut Window, &mut App) + 'static, @@ -1123,6 +1172,7 @@ pub trait InteractiveElement: Sized { /// The fluent API equivalent to [`Interactivity::on_modifiers_changed`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[inline(always)] fn on_modifiers_changed( mut self, listener: impl Fn(&ModifiersChangedEvent, &mut Window, &mut App) + 'static, @@ -1443,6 +1493,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// The handler is called when a screen reader requests the given action. /// /// See the [accessibility guide](crate::_accessibility) for an overview. + #[inline(always)] fn on_a11y_action( mut self, action: accesskit::Action, @@ -1529,6 +1580,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// The fluent API equivalent to [`Interactivity::on_click`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[inline(always)] fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self where Self: Sized, @@ -1593,6 +1645,10 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The fluent API equivalent to [`Interactivity::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -1604,7 +1660,18 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The fluent API + /// equivalent to [`Interactivity::hover_listener_mode`]. + fn hover_listener_mode(mut self, mode: HoverListenerMode) -> Self + where + Self: Sized, + { + self.interactivity().hover_listener_mode(mode); + self + } + + /// Constructs a tooltip when the element is hovered or long-pressed. /// The fluent API equivalent to [`Interactivity::tooltip`]. fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self where @@ -1614,7 +1681,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Constructs a tooltip when the element is hovered or long-pressed. /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into /// the tooltip. The fluent API equivalent to [`Interactivity::hoverable_tooltip`]. fn hoverable_tooltip( @@ -1628,7 +1695,9 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Set the delay before this element's tooltip is shown. + /// Sets the delay before this element's tooltip is shown on hover. + /// + /// Touch long presses show the tooltip immediately once the gesture is recognized. /// The fluent API equivalent to [`Interactivity::tooltip_show_delay`]. fn tooltip_show_delay(mut self, delay: Duration) -> Self where @@ -1650,6 +1719,9 @@ pub(crate) type MouseMoveListener = pub(crate) type MouseExitListener = Box; +pub(crate) type FileDropExitListener = + Box; + pub(crate) type ScrollWheelListener = Box; @@ -1658,6 +1730,29 @@ pub(crate) type PinchListener = pub(crate) type ClickListener = Rc; +/// Controls how [`StatefulInteractiveElement::on_hover`] responds to key presses while the mouse +/// is stationary. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum HoverListenerMode { + /// Use input-modality-aware hit testing. Keyboard input suppresses hover until the mouse moves + /// again, unless pointer capture or an active mouse-down interaction keeps the listener hovered. + #[default] + InputModalityAware, + /// Use hit testing even when the last input was from the keyboard. This changes only + /// keyboard-modality filtering; all other [`StatefulInteractiveElement::on_hover`] behavior + /// remains unchanged. + InputModalityIndependent, +} + +impl HoverListenerMode { + fn is_hovered(self, hitbox: &Hitbox, window: &Window) -> bool { + match self { + Self::InputModalityAware => hitbox.is_hovered(window), + Self::InputModalityIndependent => hitbox.id.is_hovered_ignoring_last_input(window), + } + } +} + pub(crate) struct DragListener { value: Arc, render: Box, &mut Window, &mut App) -> AnyView + 'static>, @@ -2067,6 +2162,7 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) mouse_exit_listeners: Vec, + pub(crate) file_drop_exit_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -2079,6 +2175,7 @@ pub struct Interactivity { pub(crate) aux_click_listeners: Vec, pub(crate) drag_listener: Option, pub(crate) hover_listener: Option>, + pub(crate) hover_listener_mode: HoverListenerMode, pub(crate) tooltip_builder: Option, pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, @@ -2270,6 +2367,11 @@ impl Interactivity { self.tooltip_id = set_tooltip_on_window(active_tooltip, window); } else { // If there is no longer a tooltip builder, remove the active tooltip. + if let Some(long_press_tooltip_active) = + element_state.long_press_tooltip_active.as_ref() + { + long_press_tooltip_active.set(false); + } element_state.active_tooltip.take(); } } @@ -2311,6 +2413,7 @@ impl Interactivity { || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.mouse_exit_listeners.is_empty() + || !self.file_drop_exit_listeners.is_empty() || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() @@ -2346,7 +2449,14 @@ impl Interactivity { } let rem_size = window.rem_size(); - let padding = style.padding.to_pixels(bounds.size.into(), rem_size); + // Taffy lays the box out with the padding snapped to the device pixel + // grid (`to_taffy`); recomputed unsnapped, e.g. py_1 at a fractional + // rem size, it exceeds `bounds` and leaves the box scrollable by the + // sub-pixel difference. + let padding = style + .padding + .to_pixels(bounds.size.into(), rem_size) + .map(|edge| window.pixel_snap(*edge)); let padding_size = size(padding.left + padding.right, padding.top + padding.bottom); // The floating point values produced by Taffy and ours often vary // slightly after ~5 decimal places. This can lead to cases where after @@ -2411,8 +2521,7 @@ impl Interactivity { if let Some(debug_selector) = &self.debug_selector { window .next_frame - .debug_bounds - .insert(debug_selector.clone(), bounds); + .record_debug_bounds(debug_selector.clone(), bounds); } self.paint_hover_group_handler(window, cx); @@ -2706,6 +2815,13 @@ impl Interactivity { }) } + for listener in self.file_drop_exit_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &FileDropEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.scroll_wheel_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { @@ -3027,9 +3143,11 @@ impl Interactivity { hover_listener(&is_hovered, window, cx); } }; + let hover_listener_mode = self.hover_listener_mode; if has_mouse_down.borrow().is_none() { - let is_hovered = !cx.has_active_drag() && hitbox.is_hovered(window); + let is_hovered = + !cx.has_active_drag() && hover_listener_mode.is_hovered(hitbox, window); if is_hovered != *was_hovered.borrow() { let update_hover = update_hover.clone(); window.defer(cx, move |window, cx| { @@ -3045,7 +3163,7 @@ impl Interactivity { if phase == DispatchPhase::Bubble { let is_hovered = has_mouse_down.borrow().is_none() && !cx.has_active_drag() - && hitbox.is_hovered(window); + && hover_listener_mode.is_hovered(&hitbox, window); update_hover(is_hovered, window, cx); } } @@ -3069,6 +3187,10 @@ impl Interactivity { .pending_mouse_down .get_or_insert_with(Default::default) .clone(); + let long_press_tooltip_active = element_state + .long_press_tooltip_active + .get_or_insert_with(Default::default) + .clone(); let tooltip_is_hoverable = tooltip_builder.hoverable; let build_tooltip = Rc::new(move |window: &mut Window, cx: &mut App| { @@ -3096,6 +3218,7 @@ impl Interactivity { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + long_press_tooltip_active, self.tooltip_show_delay, window, ); @@ -3494,6 +3617,7 @@ pub struct InteractiveElementState { pub(crate) scroll_offset: Option>>>, ongoing_scroll: Option>>, pub(crate) active_tooltip: Option>>>, + long_press_tooltip_active: Option>>, } /// Whether or not the element or a group that contains it is clicked by the mouse. @@ -3585,6 +3709,7 @@ pub(crate) fn register_tooltip_mouse_handlers( build_tooltip: Rc Option<(AnyView, bool)>>, check_is_hovered: Rc bool>, check_is_hovered_during_prepaint: Rc bool>, + long_press_tooltip_active: Rc>, show_delay: Option, window: &mut Window, ) { @@ -3595,6 +3720,7 @@ pub(crate) fn register_tooltip_mouse_handlers( let active_tooltip = active_tooltip.clone(); let build_tooltip = build_tooltip.clone(); let check_is_hovered = check_is_hovered.clone(); + let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); move |_: &MouseMoveEvent, phase, window, cx| { handle_tooltip_mouse_move( &active_tooltip, @@ -3620,6 +3746,44 @@ pub(crate) fn register_tooltip_mouse_handlers( } }); + window.on_mouse_event({ + let active_tooltip = active_tooltip.clone(); + let build_tooltip = build_tooltip.clone(); + let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); + move |event: &LongPressEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + + match event.phase { + TouchPhase::Started if !window.default_prevented() && check_is_hovered(window) => { + if show_tooltip( + &active_tooltip, + &build_tooltip, + &check_is_hovered_during_prepaint, + Some(long_press_tooltip_active.clone()), + window, + cx, + ) { + long_press_tooltip_active.set(true); + window.prevent_default(); + cx.stop_propagation(); + } + } + TouchPhase::Ended | TouchPhase::Cancelled + if long_press_tooltip_active.replace(false) => + { + clear_active_tooltip(&active_tooltip, window); + cx.stop_propagation(); + } + TouchPhase::Started + | TouchPhase::Moved + | TouchPhase::Ended + | TouchPhase::Cancelled => {} + } + } + }); + window.on_mouse_event({ let active_tooltip = active_tooltip.clone(); move |_: &ScrollWheelEvent, _phase, window: &mut Window, _cx| { @@ -3719,36 +3883,14 @@ fn handle_tooltip_mouse_move( return; }; cx.update(|window, cx| { - let new_tooltip = - build_tooltip(window, cx).map(|(view, tooltip_is_hoverable)| { - let weak_active_tooltip = Rc::downgrade(&active_tooltip); - ActiveTooltip::Visible { - tooltip: AnyTooltip { - view, - mouse_position: window.mouse_position(), - check_visible_and_update: Rc::new( - move |tooltip_bounds, window, cx| { - let Some(active_tooltip) = - weak_active_tooltip.upgrade() - else { - return false; - }; - handle_tooltip_check_visible_and_update( - &active_tooltip, - tooltip_is_hoverable, - &check_is_hovered_during_prepaint, - tooltip_bounds, - window, - cx, - ) - }, - ), - }, - is_hoverable: tooltip_is_hoverable, - } - }); - *active_tooltip.borrow_mut() = new_tooltip; - window.refresh(); + show_tooltip( + &active_tooltip, + &build_tooltip, + &check_is_hovered_during_prepaint, + None, + window, + cx, + ); }) .ok(); } @@ -3763,6 +3905,50 @@ fn handle_tooltip_mouse_move( } } +fn show_tooltip( + active_tooltip: &Rc>>, + build_tooltip: &Rc Option<(AnyView, bool)>>, + check_is_hovered_during_prepaint: &Rc bool>, + long_press_tooltip_active: Option>>, + window: &mut Window, + cx: &mut App, +) -> bool { + let new_tooltip = build_tooltip(window, cx).map(|(view, tooltip_is_hoverable)| { + let weak_active_tooltip = Rc::downgrade(active_tooltip); + let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); + ActiveTooltip::Visible { + tooltip: AnyTooltip { + view, + mouse_position: window.mouse_position(), + check_visible_and_update: Rc::new(move |tooltip_bounds, window, cx| { + if long_press_tooltip_active + .as_ref() + .is_some_and(|active| active.get()) + { + return true; + } + let Some(active_tooltip) = weak_active_tooltip.upgrade() else { + return false; + }; + handle_tooltip_check_visible_and_update( + &active_tooltip, + tooltip_is_hoverable, + &check_is_hovered_during_prepaint, + tooltip_bounds, + window, + cx, + ) + }), + }, + is_hoverable: tooltip_is_hoverable, + } + }); + let did_show = new_tooltip.is_some(); + *active_tooltip.borrow_mut() = new_tooltip; + window.refresh(); + did_show +} + /// Returns a callback which will be called by window prepaint to update tooltip visibility. The /// purpose of doing this logic here instead of the mouse move handler is that the mouse move /// handler won't get called when the element is not painted (e.g. via use of `visible_on_hover`). @@ -4250,8 +4436,8 @@ impl ScrollHandle { mod tests { use super::*; use crate::{ - AnyWindowHandle, AppContext as _, Context, InputEvent, Keystroke, MouseMoveEvent, - TestAppContext, canvas, util::FluentBuilder as _, + AnyWindowHandle, AppContext as _, Context, GestureTuning, InputEvent, Keystroke, + MouseMoveEvent, TestAppContext, TouchEvent, TouchId, canvas, util::FluentBuilder as _, }; use std::{cell::Cell, rc::Weak}; @@ -4378,7 +4564,9 @@ mod tests { } #[gpui::test] - fn hover_listeners_update_when_layout_changes_under_stationary_mouse(cx: &mut TestAppContext) { + fn default_hover_listener_updates_when_layout_changes_under_stationary_mouse( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); @@ -4418,7 +4606,114 @@ mod tests { } #[gpui::test] - fn hover_listeners_remain_hovered_during_stationary_mouse_press(cx: &mut TestAppContext) { + fn default_hover_listener_ends_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerLayoutTestView { + target_left: px(0.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(point(px(10.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "a"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + } + + struct HoverListenerModeLayoutTestView { + target_left: Pixels, + hover_transitions: Rc>>, + } + + impl Render for HoverListenerModeLayoutTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let hover_transitions = self.hover_transitions.clone(); + div().relative().size_full().child( + div() + .id("hover-target") + .absolute() + .left(self.target_left) + .top_0() + .size(px(20.)) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .on_hover(move |is_hovered, _, _| { + hover_transitions.borrow_mut().push(*is_hovered); + }), + ) + } + } + + #[gpui::test] + fn input_modality_independent_hover_listener_updates_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerModeLayoutTestView { + target_left: px(40.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + let pointer_position = point(px(10.), px(10.)); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(pointer_position, cx); + }) + .unwrap(); + assert!(hover_transitions.borrow().is_empty()); + + key_down(cx, any_window, "a"); + window + .update(cx, |view, _, cx| { + view.target_left = px(0.); + cx.notify(); + }) + .unwrap(); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "b"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(point(px(30.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(pointer_position, cx); + window.dispatch_event( + MouseExitEvent { + position: pointer_position, + ..Default::default() + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false, true, false]); + } + + #[gpui::test] + fn default_hover_listener_remains_hovered_during_stationary_mouse_press( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); @@ -4617,12 +4912,123 @@ mod tests { assert_eq!(handle.offset().y, px(-25.)); } + #[test] + fn long_press_shows_tooltip_until_touch_ends() { + let (mut test_app, any_window, captured_active_tooltip) = create_tooltip_owner_test(None); + let active_tooltip = captured_active_tooltip + .borrow() + .clone() + .unwrap() + .upgrade() + .unwrap(); + let touch_id = TouchId(1); + let touch_position = point(px(49.), px(10.)); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id: touch_id, + phase: TouchPhase::Started, + position: touch_position, + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + test_app + .dispatcher + .advance_clock(GestureTuning::default().long_press_duration + Duration::from_millis(1)); + test_app.run_until_parked(); + + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + + let moved_position = point(px(55.), px(10.)); + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id: touch_id, + phase: TouchPhase::Moved, + position: moved_position, + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + window.draw(cx).clear(cx); + }) + .unwrap(); + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id: touch_id, + phase: TouchPhase::Ended, + position: moved_position, + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + assert!(active_tooltip.borrow().is_none()); + } + fn setup_tooltip_owner_test( show_delay_override: Option, ) -> ( TestAppContext, crate::AnyWindowHandle, CapturedActiveTooltip, + ) { + let (mut test_app, any_window, captured_active_tooltip) = + create_tooltip_owner_test(show_delay_override); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + MouseMoveEvent { + position: point(px(10.), px(10.)), + modifiers: Default::default(), + pressed_button: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + test_app + .update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + + (test_app, any_window, captured_active_tooltip) + } + + fn create_tooltip_owner_test( + show_delay_override: Option, + ) -> ( + TestAppContext, + crate::AnyWindowHandle, + CapturedActiveTooltip, ) { let mut test_app = TestAppContext::single(); let captured_active_tooltip: CapturedActiveTooltip = Rc::new(RefCell::new(None)); @@ -4635,26 +5041,6 @@ mod tests { }); let any_window = window.into(); - test_app - .update_window(any_window, |_, window, cx| { - window.draw(cx).clear(cx); - }) - .unwrap(); - - test_app - .update_window(any_window, |_, window, cx| { - window.dispatch_event( - MouseMoveEvent { - position: point(px(10.), px(10.)), - modifiers: Default::default(), - pressed_button: None, - } - .to_platform_input(), - cx, - ); - }) - .unwrap(); - test_app .update_window(any_window, |_, window, cx| { window.draw(cx).clear(cx); @@ -5165,6 +5551,48 @@ mod tests { assert_eq!(focused, Some(item_b.id)); } + #[gpui::test] + fn test_fractional_padding_does_not_make_a_fitting_container_scrollable( + cx: &mut TestAppContext, + ) { + struct PaddedContainer { + scroll_handle: ScrollHandle, + } + + impl Render for PaddedContainer { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + // 4.25px of padding snaps to 4px in layout, so a 42px child + // fits the 50px box exactly. + div().size_full().child( + div() + .id("container") + .h(px(50.)) + .w(px(100.)) + .py(px(4.25)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .child(div().w_full().h(px(42.))), + ) + } + } + + let scroll_handle = ScrollHandle::new(); + let window: AnyWindowHandle = cx + .add_window({ + let scroll_handle = scroll_handle.clone(); + move |_, _| PaddedContainer { scroll_handle } + }) + .into(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + + assert_eq!(scroll_handle.max_offset().y, px(0.)); + } + struct ContentSizedGrid; impl Render for ContentSizedGrid { diff --git a/crates/gpui/src/elements/image_cache.rs b/crates/gpui/src/elements/image_cache.rs index ee1436134a..84948145aa 100644 --- a/crates/gpui/src/elements/image_cache.rs +++ b/crates/gpui/src/elements/image_cache.rs @@ -1,11 +1,10 @@ use crate::{ AnyElement, AnyEntity, App, AppContext, Asset, AssetLogger, Bounds, Element, ElementId, Entity, GlobalElementId, ImageAssetLoader, ImageCacheError, InspectorElementId, IntoElement, LayoutId, - ParentElement, Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Task, Window, - hash, + ParentElement, Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Window, hash, }; -use futures::{FutureExt, future::Shared}; +use crate::asset_cache::CachedLoad; use refineable::Refineable; use smallvec::SmallVec; use std::{collections::HashMap, fmt, sync::Arc}; @@ -161,40 +160,35 @@ impl Element for ImageCacheElement { } } -/// An image loading task associated with an image cache. -pub type ImageLoadingTask = Shared, ImageCacheError>>>; - -/// An image cache item -pub enum ImageCacheItem { - /// The associated image is currently loading - Loading(ImageLoadingTask), - /// This item has loaded an image. - Loaded(Result, ImageCacheError>), -} +/// Owns an image load and its cached result. +/// +/// Dropping the entry cancels a pending load. Image caches must remove loaded +/// images from windows before discarding the entry. +pub struct ImageCacheItem(CachedLoad, ImageCacheError>>); impl std::fmt::Debug for ImageCacheItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let status = match self { - ImageCacheItem::Loading(_) => &"Loading...".to_string(), - ImageCacheItem::Loaded(render_image) => &format!("{:?}", render_image), - }; f.debug_struct("ImageCacheItem") - .field("status", status) + .field("result", &self.get()) .finish() } } impl ImageCacheItem { - /// Attempt to get the image from the cache item. - pub fn get(&mut self) -> Option, ImageCacheError>> { - match self { - ImageCacheItem::Loading(task) => { - let res = task.now_or_never()?; - *self = ImageCacheItem::Loaded(res.clone()); - Some(res) - } - ImageCacheItem::Loaded(res) => Some(res.clone()), - } + /// Starts loading an image into a new cache entry. + pub fn new(source: &Resource, cx: &mut App) -> Self { + let future = AssetLogger::::load(source.clone(), cx); + Self(CachedLoad::new(future, cx)) + } + + /// Returns the cached result without subscribing to completion notifications. + pub fn get(&self) -> Option, ImageCacheError>> { + self.0.get() + } + + /// Returns the cached result or subscribes the current view to completion. + pub fn use_image(&self, window: &Window) -> Option, ImageCacheError>> { + self.0.use_by(window.current_view()) } } @@ -241,7 +235,7 @@ impl RetainAllImageCache { pub fn new(cx: &mut App) -> Entity { let e = cx.new(|_cx| RetainAllImageCache(HashMap::new())); cx.observe_release(&e, |image_cache, cx| { - for (_, mut item) in std::mem::replace(&mut image_cache.0, HashMap::new()) { + for (_, item) in std::mem::replace(&mut image_cache.0, HashMap::new()) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, None); } @@ -262,32 +256,15 @@ impl RetainAllImageCache { ) -> Option, ImageCacheError>> { let hash = hash(source); - if let Some(item) = self.0.get_mut(&hash) { - return item.get(); - } - - let fut = AssetLogger::::load(source.clone(), cx); - let task = cx.background_executor().spawn(fut).shared(); - self.0.insert(hash, ImageCacheItem::Loading(task.clone())); - - let entity = window.current_view(); - window - .spawn(cx, { - async move |cx| { - _ = task.await; - cx.on_next_frame(move |_, cx| { - cx.notify(entity); - }); - } - }) - .detach(); - - None + self.0 + .entry(hash) + .or_insert_with(|| ImageCacheItem::new(source, cx)) + .use_image(window) } /// Clear the image cache. pub fn clear(&mut self, window: &mut Window, cx: &mut App) { - for (_, mut item) in std::mem::replace(&mut self.0, HashMap::new()) { + for (_, item) in std::mem::replace(&mut self.0, HashMap::new()) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, Some(window)); } @@ -297,7 +274,7 @@ impl RetainAllImageCache { /// Remove the image from the cache by the given source. pub fn remove(&mut self, source: &Resource, window: &mut Window, cx: &mut App) { let hash = hash(source); - if let Some(mut item) = self.0.remove(&hash) + if let Some(item) = self.0.remove(&hash) && let Some(Ok(image)) = item.get() { cx.drop_image(image, Some(window)); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 3470ac94e2..b3e5a35175 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -1000,6 +1000,7 @@ pub struct InteractiveTextState { mouse_down_index: Rc>>, hovered_index: Rc>>, active_tooltip: Rc>>, + long_press_tooltip_active: Rc>, } /// InteractiveTest is a wrapper around StyledText that adds mouse interactions. @@ -1107,6 +1108,7 @@ impl Element for InteractiveText { set_tooltip_on_window(&interactive_state.active_tooltip, window); } else { // If there is no longer a tooltip builder, remove the active tooltip. + interactive_state.long_press_tooltip_active.set(false); interactive_state.active_tooltip.take(); } } @@ -1255,6 +1257,7 @@ impl Element for InteractiveText { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + interactive_state.long_press_tooltip_active.clone(), None, window, ); diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 444bb07180..b4348e940d 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,4 +1,4 @@ -use crate::{App, PlatformDispatcher, PlatformScheduler}; +use crate::{ActivityGuard, App, PlatformDispatcher, PlatformScheduler}; #[cfg(not(target_family = "wasm"))] use futures::channel::mpsc; use futures::prelude::*; @@ -91,25 +91,11 @@ impl BackgroundExecutor { self.inner.clone() } - /// Spawn a closure on a fresh session pinned to its own [`SchedulerLocalExecutor`]. - /// The closure runs on a new OS thread under the platform scheduler, or on - /// the test scheduler's loop in tests. + /// Prevents App Nap-style throttling while the returned guard is held. /// - /// Prefer this over [`Self::spawn`] for futures whose polls need more stack - /// than shared background threads guarantee. Dedicated threads get the - /// standard library's default 2 MiB, while `spawn` polls futures on - /// whatever threads the platform dispatcher provides — on macOS those are - /// GCD workers whose stacks are fixed at 512 KiB by the kernel (see `PTH_DEFAULT_STACKSIZE` in - /// ), - /// the tightest background-stack budget of any platform. - #[track_caller] - pub fn spawn_dedicated(&self, f: F) -> Task - where - F: FnOnce(SchedulerLocalExecutor) -> Fut + Send + 'static, - Fut: Future + 'static, - Fut::Output: Send + Sync + 'static, - { - self.inner.spawn_dedicated(f) + /// This does not prevent the system from entering idle sleep. + pub fn prevent_app_nap(&self, reason: &str) -> ActivityGuard { + self.dispatcher.prevent_app_nap(reason) } /// Enqueues the given future to be run to completion on a background thread. diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 05dafbf91e..1c87031c7a 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -743,7 +743,10 @@ impl Bounds { .or_else(|| cx.primary_display()); display - .map(|display| Bounds::centered_at(display.bounds().center(), size)) + .map(|display| { + let visible_bounds = display.visible_bounds(); + Bounds::centered_at(visible_bounds.center(), size.min(&visible_bounds.size)) + }) .unwrap_or_else(|| Bounds { origin: point(px(0.), px(0.)), size, @@ -2239,7 +2242,7 @@ impl Anchor { } } - /// Returns true if at the center. + /// Returns whether the anchor is center-positioned. #[inline] pub fn is_center(&self) -> bool { matches!( @@ -2247,6 +2250,15 @@ impl Anchor { Self::TopCenter | Self::BottomCenter | Self::LeftCenter | Self::RightCenter ) } + + /// Returns whether the anchor is bottom-positioned. + #[inline] + pub fn is_bottom(&self) -> bool { + matches!( + self, + Self::BottomCenter | Self::BottomLeft | Self::BottomRight + ) + } } /// Represents the corners of a box in a 2D space, such as border radius. diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index f11b1a7dd6..ec611576ef 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -40,6 +40,10 @@ fn lock_delta_to_axis(delta: &mut Point, axis: Axis) { } } +fn movements_oppose(left: Point, right: Point) -> bool { + f32::from(left.x) * f32::from(right.x) + f32::from(left.y) * f32::from(right.y) < 0. +} + /// Tracks the dominant axis across the events in a scroll gesture. #[derive(Clone, Copy, Debug, Default)] pub struct OngoingScroll { @@ -376,6 +380,27 @@ impl GestureKinds { }; } +/// A direct touch drag claimed by an element before touch input becomes a tap, +/// long press, or scrolling gesture. +#[derive(Clone, Debug)] +pub struct TouchDragEvent { + /// The phase of the touch drag. + pub phase: TouchPhase, + /// The position where the touch started. + pub start_position: Point, + /// The touch's current position. + pub position: Point, +} + +impl Sealed for TouchDragEvent {} +impl InputEvent for TouchDragEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::TouchDrag(self) + } +} +impl GestureEvent for TouchDragEvent {} +impl MouseEvent for TouchDragEvent {} + /// A phased long-press gesture recognized from a touch. #[derive(Clone, Debug)] pub struct LongPressEvent { @@ -481,6 +506,7 @@ pub(crate) enum RecognizedTouchGesture { down: MouseDownEvent, up: MouseUpEvent, }, + TouchDrag(TouchDragEvent), LongPress(LongPressEvent), } @@ -491,7 +517,8 @@ enum TouchGestureState { Pending { touch: ActiveTouch, deadline: Instant, - offered: bool, + long_press_offered: bool, + touch_drag_offered: bool, }, /// The touch exceeded `touch_slop`: it is a pan until it ends, and its /// movement flows out as scroll events. @@ -500,6 +527,7 @@ enum TouchGestureState { axis: Axis, }, LongPressing(ActiveTouch), + TouchDragging(ActiveTouch), } struct ActiveTouch { @@ -512,6 +540,9 @@ struct ActiveTouch { /// the release event targets the raw position again, so the total /// scrolled distance always converges to the finger's actual travel. emitted_position: Point, + /// Retained across stationary samples so prediction corrections cannot + /// reverse a pan when integer browser coordinates repeat. + last_movement: Point, velocity_tracker: VelocityTracker, } @@ -583,6 +614,7 @@ impl TouchGestureRecognizer { start_position: event.position, last_position: event.position, emitted_position: event.position, + last_movement: Point::default(), velocity_tracker, }; if let Some(axis) = caught_fling { @@ -601,7 +633,8 @@ impl TouchGestureRecognizer { self.state = TouchGestureState::Pending { touch, deadline: now + self.tuning.long_press_duration, - offered: false, + long_press_offered: false, + touch_drag_offered: false, }; } } @@ -610,7 +643,8 @@ impl TouchGestureRecognizer { TouchGestureState::Pending { mut touch, deadline, - offered, + long_press_offered, + touch_drag_offered, } if touch.id == event.id => { touch.velocity_tracker.push(now, event.position); touch.last_position = event.position; @@ -619,10 +653,17 @@ impl TouchGestureRecognizer { // Carry the full movement so far into the first scroll // step: the content catches up to the finger instead // of losing the slop distance. - let target = event.predicted_position.unwrap_or(event.position); + let mut target = event.predicted_position.unwrap_or(event.position); let axis = dominant_axis(accumulated); let mut delta = target - touch.start_position; lock_delta_to_axis(&mut delta, axis); + touch.last_movement = accumulated; + lock_delta_to_axis(&mut touch.last_movement, axis); + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = accumulated; + lock_delta_to_axis(&mut delta, axis); + } touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, @@ -634,16 +675,34 @@ impl TouchGestureRecognizer { self.state = TouchGestureState::Pending { touch, deadline, - offered, + long_press_offered, + touch_drag_offered, }; } } TouchGestureState::Panning { mut touch, axis } if touch.id == event.id => { + let mut raw_delta = event.position - touch.last_position; + lock_delta_to_axis(&mut raw_delta, axis); + if raw_delta != Point::default() { + touch.last_movement = raw_delta; + } touch.velocity_tracker.push(now, event.position); touch.last_position = event.position; - let target = event.predicted_position.unwrap_or(event.position); + let mut target = event.predicted_position.unwrap_or(event.position); let mut delta = target - touch.emitted_position; lock_delta_to_axis(&mut delta, axis); + // Prediction error must not reverse content while the raw + // touch still advances. Fall back to the raw position so a + // real finger reversal remains responsive. + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = target - touch.emitted_position; + lock_delta_to_axis(&mut delta, axis); + if movements_oppose(delta, touch.last_movement) { + target = touch.emitted_position; + delta = Point::default(); + } + } touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, @@ -661,6 +720,15 @@ impl TouchGestureRecognizer { })); self.state = TouchGestureState::LongPressing(touch); } + TouchGestureState::TouchDragging(mut touch) if touch.id == event.id => { + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Moved, + start_position: touch.start_position, + position: event.position, + })); + self.state = TouchGestureState::TouchDragging(touch); + } other => self.state = other, }, TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -769,6 +837,13 @@ impl TouchGestureRecognizer { position: event.position, })); } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Ended, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -787,6 +862,13 @@ impl TouchGestureRecognizer { position: event.position, })); } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Cancelled, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, } @@ -797,7 +879,8 @@ impl TouchGestureRecognizer { let TouchGestureState::Pending { touch, deadline, - offered: false, + long_press_offered: false, + .. } = &self.state else { return None; @@ -806,13 +889,18 @@ impl TouchGestureRecognizer { } pub(crate) fn offer_long_press(&mut self, id: TouchId) -> Option { - let TouchGestureState::Pending { touch, offered, .. } = &mut self.state else { + let TouchGestureState::Pending { + touch, + long_press_offered, + .. + } = &mut self.state + else { return None; }; - if touch.id != id || *offered { + if touch.id != id || *long_press_offered { return None; } - *offered = true; + *long_press_offered = true; Some(RecognizedTouchGesture::LongPress(LongPressEvent { phase: TouchPhase::Started, start_position: touch.start_position, @@ -828,13 +916,48 @@ impl TouchGestureRecognizer { self.state = match state { TouchGestureState::Pending { touch, - offered: true, + long_press_offered: true, .. } => TouchGestureState::LongPressing(touch), other => other, }; } + pub(crate) fn offer_touch_drag(&mut self, id: TouchId) -> Option { + let TouchGestureState::Pending { + touch, + touch_drag_offered, + .. + } = &mut self.state + else { + return None; + }; + if touch.id != id || *touch_drag_offered { + return None; + } + *touch_drag_offered = true; + Some(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Started, + start_position: touch.start_position, + position: touch.last_position, + })) + } + + pub(crate) fn resolve_touch_drag(&mut self, claimed: bool) { + if !claimed { + return; + } + let state = mem::replace(&mut self.state, TouchGestureState::Idle); + self.state = match state { + TouchGestureState::Pending { + touch, + touch_drag_offered: true, + .. + } => TouchGestureState::TouchDragging(touch), + other => other, + }; + } + pub(crate) fn has_momentum(&self) -> bool { self.momentum.is_some() } @@ -853,8 +976,10 @@ impl TouchGestureRecognizer { .tuning .scroll_physics .fling_distance(momentum.speed, elapsed); - let step = distance - momentum.emitted_distance; - momentum.emitted_distance = distance; + // Prediction overshoot can start momentum ahead of its curve. Hold + // that position until the curve catches up instead of stepping back. + let step = (distance - momentum.emitted_distance).max(0.); + momentum.emitted_distance = momentum.emitted_distance.max(distance); let delta = point( px(momentum.direction.x * step), px(momentum.direction.y * step), @@ -1302,6 +1427,61 @@ mod tests { assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-4.))); } + #[test] + fn predicted_positions_do_not_emit_false_reversals() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); + moved.predicted_position = Some(point(px(100.), px(130.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(30.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(127.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(126.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(40)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 132.); + moved.predicted_position = Some(point(px(100.), px(136.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(48)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 124.); + moved.predicted_position = Some(point(px(100.), px(140.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(64)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-12.))); + } + #[test] fn predicted_overshoot_folds_into_the_fling_without_scrolling_backwards() { let now = Instant::now(); @@ -1348,12 +1528,12 @@ mod tests { ); drain(&recognized, use_prediction); assert!(recognizer.has_momentum()); - let mut tick = now + Duration::from_millis(90); + let mut tick = now + Duration::from_millis(91); while recognizer.has_momentum() { - tick += Duration::from_millis(16); if let Some(gesture) = recognizer.tick_momentum_at(tick) { drain(&[gesture], use_prediction); } + tick += Duration::from_millis(16); } if use_prediction { @@ -1805,6 +1985,63 @@ mod tests { ); } + #[test] + fn claimed_touch_drag_emits_phased_stream_without_pan_or_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + let Some(RecognizedTouchGesture::TouchDrag(started)) = recognizer.offer_touch_drag(touch) + else { + panic!("expected touch drag"); + }; + assert_eq!(started.phase, TouchPhase::Started); + assert_eq!(started.start_position, point(px(10.), px(20.))); + recognizer.resolve_touch_drag(true); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 40., 50.), + now + Duration::from_millis(10), + ); + let [RecognizedTouchGesture::TouchDrag(moved)] = moved.as_slice() else { + panic!("expected moved touch drag, got {moved:?}"); + }; + assert_eq!(moved.phase, TouchPhase::Moved); + assert_eq!(moved.position, point(px(40.), px(50.))); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 45., 55.), + now + Duration::from_millis(20), + ); + let [RecognizedTouchGesture::TouchDrag(ended)] = ended.as_slice() else { + panic!("expected ended touch drag, got {ended:?}"); + }; + assert_eq!(ended.phase, TouchPhase::Ended); + assert_eq!(ended.position, point(px(45.), px(55.))); + } + + #[test] + fn unclaimed_touch_drag_remains_a_pan_candidate() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + assert!(recognizer.offer_touch_drag(touch).is_some()); + recognizer.resolve_touch_drag(false); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 20., 0.), + now + Duration::from_millis(10), + ); + assert!(matches!( + moved.as_slice(), + [RecognizedTouchGesture::Scroll(ScrollWheelEvent { + touch_phase: TouchPhase::Started, + .. + })] + )); + } + #[test] fn claimed_long_press_emits_phased_stream_without_tap() { let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index cbf6e405cf..4167aa0990 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -288,11 +288,12 @@ mod tests { use super::*; use crate::{ AnyWindowHandle, AppContext as _, FocusHandle, InteractiveElement as _, IntoElement, - ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, canvas, div, + ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, + TextInputStateChange, canvas, div, }; #[gpui::test] - fn text_input_configuration_forwarded_only_on_change(cx: &mut TestAppContext) { + fn text_input_configuration_and_focus_state_are_forwarded_on_change(cx: &mut TestAppContext) { let custom = TextInputConfiguration { autocorrect: true, input_action: TextInputAction::Send, @@ -319,6 +320,7 @@ mod tests { test_window.text_input_configurations(), vec![TextInputConfiguration::default()] ); + assert!(test_window.text_input_state_changes().is_empty()); // Focusing the view routes its configuration to the platform. cx.update_window(window, |_, window, cx| { @@ -331,10 +333,15 @@ mod tests { test_window.text_input_configurations(), vec![TextInputConfiguration::default(), custom.clone()] ); + assert_eq!( + test_window.text_input_state_changes(), + vec![TextInputStateChange::FocusGained] + ); // Redrawing without a change forwards nothing. draw(cx); assert_eq!(test_window.text_input_configurations().len(), 2); + assert_eq!(test_window.text_input_state_changes().len(), 1); // Changing the configuration forwards the new value. let updated = TextInputConfiguration { @@ -354,6 +361,7 @@ mod tests { Some(&updated) ); assert_eq!(test_window.text_input_configurations().len(), 3); + assert_eq!(test_window.text_input_state_changes().len(), 1); // Losing focus reverts the platform to the default configuration. cx.update_window(window, |_, window, cx| window.blur(cx)) @@ -364,6 +372,13 @@ mod tests { Some(&TextInputConfiguration::default()) ); assert_eq!(test_window.text_input_configurations().len(), 4); + assert_eq!( + test_window.text_input_state_changes(), + vec![ + TextInputStateChange::FocusGained, + TextInputStateChange::FocusLost + ] + ); } struct ConfigurationTestView { diff --git a/crates/gpui/src/inspector.rs b/crates/gpui/src/inspector.rs index 12995f03a8..d353690e31 100644 --- a/crates/gpui/src/inspector.rs +++ b/crates/gpui/src/inspector.rs @@ -22,7 +22,7 @@ pub use conditional::*; mod conditional { use super::*; use crate::{AnyElement, App, Context, Empty, IntoElement, Render, Window}; - use collections::{FxHashMap, TypeIdHashMap}; + use collections::{FxHashMap, TypeIdHashMap, hash_map::Entry}; use std::any::{Any, TypeId}; /// `GlobalElementId` qualified by source location of element construction. @@ -60,6 +60,7 @@ mod conditional { pub struct Inspector { active_element: Option, pub(crate) pick_depth: Option, + renderers: TypeIdHashMap, } struct InspectedElement { @@ -81,6 +82,7 @@ mod conditional { Self { active_element: None, pick_depth: Some(0.0), + renderers: TypeIdHashMap::default(), } } @@ -161,22 +163,29 @@ mod conditional { let mut elements = Vec::new(); if let Some(active_element) = self.active_element.take() { for (type_id, state) in &active_element.states { - if let Some(render_inspector) = cx - .inspector_element_registry - .renderers_by_type_id - .remove(type_id) - { - let mut element = (render_inspector)( - active_element.id.clone(), - state.as_ref(), - window, - cx, - ); - elements.push(element); - cx.inspector_element_registry - .renderers_by_type_id - .insert(*type_id, render_inspector); - } + let renderer = match self.renderers.entry(*type_id) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let Some(factory) = cx + .inspector_element_registry + .factories_by_type_id + .remove(type_id) + else { + continue; + }; + let renderer = factory(window, cx); + cx.inspector_element_registry + .factories_by_type_id + .insert(*type_id, factory); + entry.insert(renderer) + } + }; + elements.push(renderer( + active_element.id.clone(), + state.as_ref(), + window, + cx, + )); } self.active_element = Some(active_element); @@ -200,26 +209,34 @@ mod conditional { #[derive(Default)] pub(crate) struct InspectorElementRegistry { - renderers_by_type_id: FxHashMap< - TypeId, - Box AnyElement>, - >, + factories_by_type_id: + FxHashMap InspectorElementRenderer>>, } impl InspectorElementRegistry { - pub fn register( + pub fn register( &mut self, - f: impl 'static + Fn(InspectorElementId, &T, &mut Window, &mut App) -> R, - ) { - self.renderers_by_type_id.insert( + factory: impl 'static + Fn(&mut Window, &mut App) -> F, + ) where + F: 'static + FnMut(InspectorElementId, &T, &mut Window, &mut App) -> R, + { + self.factories_by_type_id.insert( TypeId::of::(), - Box::new(move |id, value, window, cx| { - let value = value.downcast_ref().unwrap(); - f(id, value, window, cx).into_any_element() + Box::new(move |window, cx| { + let mut renderer = factory(window, cx); + Box::new(move |id, value, window, cx| { + let value = value + .downcast_ref() + .expect("registered inspector state type"); + renderer(id, value, window, cx).into_any_element() + }) }), ); } } + + type InspectorElementRenderer = + Box AnyElement>; } /// Provides definitions used by `#[derive_inspector_reflection]`. diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index cfbf2bc82f..913cf1ba79 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -1,6 +1,6 @@ use crate::{ Bounds, Capslock, Context, Empty, IntoElement, Keystroke, LongPressEvent, Modifiers, Pixels, - Point, Render, Window, point, seal::Sealed, + Point, Render, TouchDragEvent, Window, point, seal::Sealed, }; use smallvec::SmallVec; use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; @@ -791,6 +791,8 @@ pub enum PlatformInput { Pinch(PinchEvent), /// A long-press gesture recognized from touch input. LongPress(LongPressEvent), + /// A direct touch drag claimed by an element. + TouchDrag(TouchDragEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), /// A raw touch event on a touch screen. @@ -811,6 +813,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::Pinch(event) => Some(event), PlatformInput::LongPress(event) => Some(event), + PlatformInput::TouchDrag(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), PlatformInput::Touch(_) => None, } @@ -829,6 +832,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => None, PlatformInput::Pinch(_) => None, PlatformInput::LongPress(_) => None, + PlatformInput::TouchDrag(_) => None, PlatformInput::FileDrop(_) => None, PlatformInput::Touch(_) => None, } @@ -849,6 +853,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => "scroll_wheel", PlatformInput::Pinch(_) => "pinch", PlatformInput::LongPress(_) => "long_press", + PlatformInput::TouchDrag(_) => "touch_drag", PlatformInput::FileDrop(_) => "file_drop", PlatformInput::Touch(_) => "touch", } diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 5a037eb1a6..f18bd0e92a 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -650,6 +650,248 @@ mod tests { ) } + struct PendingInputTestView { + focus_handle: FocusHandle, + action_count: Rc>, + secondary_action_count: Rc>, + } + + #[derive(Clone)] + struct PendingTextInputTestView { + focus_handle: FocusHandle, + text: Rc>, + action_count: Rc>, + } + + impl PendingTextInputTestView { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + action_count: Rc::default(), + } + } + } + + impl Element for PendingTextInputTestView { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some("pending-text-input-test".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + let action_count = self.action_count.clone(); + window.on_action( + std::any::TypeId::of::(), + move |_, phase, _, _| { + if phase == DispatchPhase::Bubble { + action_count.set(action_count.get() + 1); + } + }, + ); + } + } + + impl IntoElement for PendingTextInputTestView { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for PendingTextInputTestView { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { + true + } + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for PendingTextInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + struct PendingInputTimeoutPauseOwner; + + impl Render for PendingInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + use crate::{InteractiveElement as _, Styled as _}; + let action_count = self.action_count.clone(); + let secondary_action_count = self.secondary_action_count.clone(); + crate::div() + .key_context("Terminal") + .track_focus(&self.focus_handle) + .size_full() + .on_action(move |_: &TestAction, _, _| { + action_count.set(action_count.get() + 1); + }) + .on_action(move |_: &SecondaryTestAction, _, _| { + secondary_action_count.set(secondary_action_count.get() + 1); + }) + } + } + + fn setup_pending_input_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + cx.update(|cx| cx.bind_keys(bindings)); + + let action_count = Rc::new(Cell::new(0)); + let secondary_action_count = Rc::new(Cell::new(0)); + let (view, cx) = cx.add_window_view(|_, cx| PendingInputTestView { + focus_handle: cx.focus_handle(), + action_count: action_count.clone(), + secondary_action_count: secondary_action_count.clone(), + }); + let focus_handle = cx.update(|_, cx| view.read(cx).focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (cx, action_count, secondary_action_count) + } + + fn setup_pending_input_timeout_test( + cx: &mut TestAppContext, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ) + } + + fn query_prefers_ime_for_printable_keys(cx: &mut VisualTestContext) -> Option { + let mut platform_window = cx.test_window(cx.window_handle()); + let mut input_handler = platform_window.take_input_handler()?; + let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); + platform_window.set_input_handler(input_handler); + Some(prefers_ime) + } + + fn simulate_pending_binding(cx: &mut VisualTestContext) { + cx.simulate_modifiers_change(crate::Modifiers::control()); + cx.simulate_keystrokes("ctrl-b"); + cx.simulate_modifiers_change(crate::Modifiers::default()); + } + #[test] fn test_keybinding_for_action_bounds() { let tree = test_dispatch_tree(vec![KeyBinding::new( @@ -1063,186 +1305,351 @@ mod tests { } #[crate::test] - fn test_input_handler_pending(cx: &mut TestAppContext) { - #[derive(Clone)] - struct CustomElement { - focus_handle: FocusHandle, - text: Rc>, - action_count: Rc>, - } - impl CustomElement { - fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - text: Rc::default(), - action_count: Rc::default(), - } - } - } - impl Element for CustomElement { - type RequestLayoutState = (); - - type PrepaintState = (); - - fn id(&self) -> Option { - Some("custom".into()) - } - fn source_location(&self) -> Option<&'static panic::Location<'static>> { - None - } - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - (window.request_layout(Style::default(), [], cx), ()) - } - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - window.set_focus_handle(&self.focus_handle, cx); - } - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let mut key_context = KeyContext::default(); - key_context.add("Terminal"); - window.set_key_context(key_context); - window.handle_input(&self.focus_handle, self.clone(), cx); - let action_count = self.action_count.clone(); - window.on_action( - std::any::TypeId::of::(), - move |_, phase, _, _| { - if phase == DispatchPhase::Bubble { - action_count.set(action_count.get() + 1); - } - }, - ); - } - } - impl IntoElement for CustomElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } - } - - impl InputHandler for CustomElement { - fn selected_text_range( - &mut self, - _: bool, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - - fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { - None - } - - fn text_for_range( - &mut self, - _: Range, - _: &mut Option>, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - - fn replace_text_in_range( - &mut self, - replacement_range: Option>, - text: &str, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(text) - } - - fn replace_and_mark_text_in_range( - &mut self, - replacement_range: Option>, - new_text: &str, - _: Option>, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(new_text) - } - - fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} - - fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { - true - } - - fn bounds_for_range( - &mut self, - _: Range, - _: &mut Window, - _: &mut App, - ) -> Option> { - None - } - - fn character_index_for_point( - &mut self, - _: Point, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - } - impl Render for CustomElement { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.clone() - } - } - + fn test_printable_pending_input_replays_on_timeout(cx: &mut TestAppContext) { cx.update(|cx| { - cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); - cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); - cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("j k", TestAction, Some("Terminal"))]); }); - let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { window.focus(&focus_handle, cx); window.activate_window(); }); - let query_prefers_ime_for_printable_keys = |cx: &mut VisualTestContext| { - let mut platform_window = cx.test_window(cx.window_handle()); - let mut input_handler = platform_window.take_input_handler()?; - let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); - platform_window.set_input_handler(input_handler); - Some(prefers_ime) - }; + cx.simulate_keystrokes("j"); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_some()); + }); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), ""); + }); + + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), "j"); + }); + } + + #[crate::test] + fn test_pending_input_timeout_dispatches_shorter_binding(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.update(|window, _| { + assert_eq!( + window + .pending_input() + .map(|pending_input| pending_input.keystrokes().len()), + Some(1) + ); + assert_eq!( + window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .map(|timeout| timeout.duration()), + Some(crate::PENDING_INPUT_TIMEOUT) + ); + }); + assert_eq!(action_count.get(), 0); + + // Emulate a countdown indicator re-rendering the window while waiting for the timeout. + for _ in 0..10 { + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 10); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_running_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_starts_when_binding_becomes_ambiguous(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_none()); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_invalid_continuation_while_timeout_paused_replays_pending_input( + cx: &mut TestAppContext, + ) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("x", SecondaryTestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + cx.simulate_keystrokes("x"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_pauses_and_resumes(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + let other_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&other_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resumes_when_owner_is_released(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 2); + cx.run_until_parked(); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT / 2); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_clearing_pending_input_invalidates_timeout_pause(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + window.focus(&cx.focus_handle(), cx); + assert!(!window.has_pending_keystrokes()); + }); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_input_handler_pending(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); + }); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); assert_eq!(query_prefers_ime_for_printable_keys(cx), Some(true)); cx.simulate_keystrokes("ctrl-x"); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index eeba999dac..c03653ed94 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -32,20 +32,23 @@ pub(crate) type PlatformScreenCaptureFrame = scap::frame::Frame; #[cfg(not(feature = "screen-capture"))] pub(crate) type PlatformScreenCaptureFrame = (); #[cfg(all(target_os = "macos", feature = "screen-capture"))] -pub(crate) type PlatformScreenCaptureFrame = core_video::image_buffer::CVImageBuffer; +pub(crate) type PlatformScreenCaptureFrame = + objc2_core_foundation::CFRetained; use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Edges, ExternalDragPayload, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, - LineLayout, Pixels, PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, - RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, - Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, + LineLayout, MissingGlyphSink, Pixels, PlatformGestures, PlatformInput, Point, Priority, + RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, + ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, + hash, point, px, size, }; #[cfg(any(target_os = "linux", target_os = "freebsd"))] use anyhow::bail; use anyhow::{Context as _, Result}; use async_task::Runnable; +use collections::FxHashMap; use futures::channel::oneshot; #[cfg(any(test, feature = "test-support", feature = "bench-support"))] use image::RgbaImage; @@ -59,6 +62,7 @@ use seahash::SeaHasher; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use std::borrow::Cow; +use std::collections::hash_map::Entry; use std::hash::{Hash, Hasher}; use std::io::Cursor; use std::ops; @@ -78,6 +82,43 @@ pub use app_menu::*; pub use keyboard::*; pub use keystroke::*; +/// Whether the platform is presenting a window's frames. +/// +/// This is about presentation, not the window's shown/hidden state: a shown +/// window that is fully covered by other windows, minimized, on another +/// Space or virtual desktop, or on a display that is asleep is `Hidden`. A +/// window only partly covered by other windows is `Visible`. +/// +/// Each platform reports from a single source, and what that source can see +/// differs: +/// +/// * macOS: `NSWindow.occlusionState`. Covers all of the cases above. +/// * Windows: `WS_VISIBLE` and the minimized state. Windows keeps compositing +/// covered windows for thumbnails and Alt-Tab and offers no occlusion +/// notification, so a fully covered window stays `Visible`. Display sleep is +/// not reported either. +/// * Wayland: the `xdg_toplevel` `suspended` state (xdg-shell v6). Compositors +/// that don't support it never report `Hidden`. +/// * X11: mapped state plus `VisibilityNotify`. Compositing window managers +/// generally never report a window as fully obscured, so covering is only +/// detected without compositing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowVisibility { + /// At least part of the window is being presented; frames drawn for it + /// will be shown. + Visible, + /// No part of the window is being presented. The platform will not + /// request frames for it until it becomes visible again. + Hidden, +} + +impl WindowVisibility { + /// Whether frames drawn for the window will be shown. + pub fn is_visible(self) -> bool { + self == Self::Visible + } +} + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub(crate) use test::*; @@ -90,6 +131,25 @@ pub use threaded_dispatcher::ThreadedDispatcher; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test::VisualTestPlatform; +/// Keeps an operating system activity, such as an idle sleep inhibitor, alive until dropped. +pub struct ActivityGuard { + _release: gpui_util::Deferred>, +} + +impl ActivityGuard { + /// Runs `release` when the guard is dropped. + pub fn new(release: impl FnOnce() + Send + 'static) -> Self { + Self { + _release: gpui_util::defer(Box::new(release)), + } + } + + /// A guard for platforms without a corresponding activity. + pub fn noop() -> Self { + Self::new(|| {}) + } +} + // TODO(jk): return an enum instead of a string /// Return which compositor we're guessing we'll use. /// Does not attempt to connect to the given compositor. @@ -202,6 +262,9 @@ pub trait Platform: 'static { fn on_quit(&self, callback: Box bool>); fn on_reopen(&self, callback: Box); + /// Registers the callback invoked when the system is about to sleep. + fn on_system_sleep(&self, callback: Box); + /// Registers the callback invoked when the system resumes from sleep. fn on_system_wake(&self, callback: Box); // Mobile platform methods. On mobile the OS owns the application @@ -249,6 +312,7 @@ pub trait Platform: 'static { fn thermal_state(&self) -> ThermalState; fn on_thermal_state_change(&self, callback: Box); + fn prevent_idle_sleep(&self, reason: &str) -> Task>; /// Sets the application's process-wide identity and user-visible name. /// @@ -802,9 +866,9 @@ impl WindowInsets { /// A change in the state of the focused text input. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum TextInputStateChange { - /// An editable element gained focus. + /// The window changed from having no active text input to having one. FocusGained, - /// The focused editable element lost focus. + /// The window no longer has an active text input. FocusLost, /// The selection or caret moved SelectionChanged, @@ -818,6 +882,26 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn is_maximized(&self) -> bool; fn window_bounds(&self) -> WindowBounds; fn content_size(&self) -> Size; + /// Returns the visible viewport in logical pixels relative to the content origin. + /// + /// This may be smaller or offset when a keyboard or zoom obscures content; + /// it must not change the full layout size returned by `content_size`. + /// Implementations should return a frame snapshot, not query platform layout here. + fn visual_viewport_bounds(&self) -> Bounds { + Bounds::new(Point::default(), self.content_size()) + } + /// Registers a callback when visible geometry may have changed. + /// + /// This requests a frame; backends can sample the new viewport and safe-area + /// geometry in `prepare_frame` rather than updating it inside the callback. + fn on_visual_viewport_changed(&self, _callback: Box) {} + /// Samples platform geometry before a draw, returning whether view caches must be invalidated. + /// + /// Geometry getters must remain consistent throughout the ensuing draw. + /// Do not invoke callbacks here: GPUI is already updating this window. + fn prepare_frame(&self) -> bool { + false + } fn resize(&mut self, size: Size); fn scale_factor(&self) -> f32; fn appearance(&self) -> WindowAppearance; @@ -843,6 +927,9 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { /// Requests that the operating system draw attention to this window. fn request_attention(&self) {} fn is_active(&self) -> bool; + /// The current [`WindowVisibility`]. Read once when the window is created; + /// afterwards changes arrive through [`Self::on_visibility_change`]. + fn visibility(&self) -> WindowVisibility; fn is_hovered(&self) -> bool; fn background_appearance(&self) -> WindowBackgroundAppearance; fn set_title(&mut self, title: &str); @@ -857,6 +944,10 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn on_request_frame(&self, callback: Box); fn on_input(&self, callback: Box DispatchEventResult>); fn on_active_status_change(&self, callback: Box); + /// Registers the callback invoked when [`Self::visibility`] changes. Only + /// transitions are reported; the callback runs on the main thread outside + /// of any window update. + fn on_visibility_change(&self, callback: Box); fn on_hover_status_change(&self, callback: Box); fn on_resize(&self, callback: Box, f32)>); fn on_moved(&self, callback: Box); @@ -900,6 +991,11 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn toggle_window_tab_overview(&self) {} fn set_tabbing_identifier(&self, _identifier: Option) {} + fn native_window_state(&self) -> Option> { + None + } + fn restore_native_window_state(&self, _state: &[u8]) {} + #[cfg(target_os = "windows")] fn get_raw_handle(&self) -> windows::Win32::Foundation::HWND; @@ -1060,6 +1156,10 @@ pub trait PlatformDispatcher: Send + Sync { gpui_util::defer(Box::new(|| {})) } + fn prevent_app_nap(&self, _reason: &str) -> ActivityGuard { + ActivityGuard::noop() + } + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_test(&self) -> Option<&TestDispatcher> { None @@ -1076,6 +1176,8 @@ pub trait PlatformDispatcher: Send + Sync { #[expect(missing_docs)] pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: Vec>) -> Result<()>; + /// Installs a nonblocking sink for unresolved grapheme clusters. + fn set_missing_glyph_sink(&self, _sink: Option>) {} /// Get all available font names. fn all_font_names(&self) -> Vec; /// Get the font ID for a font descriptor. @@ -1284,13 +1386,6 @@ pub enum AtlasKey { } impl AtlasKey { - #[cfg_attr( - all( - any(target_os = "linux", target_os = "freebsd"), - not(any(feature = "x11", feature = "wayland")) - ), - allow(dead_code) - )] /// Returns the texture kind for this atlas key. pub fn texture_kind(&self) -> AtlasTextureKind { match self { @@ -1329,9 +1424,10 @@ impl From for AtlasKey { #[expect(missing_docs)] pub trait PlatformAtlas { + /// The builder runs with the atlas locked and must not re-enter the same atlas. fn get_or_insert_with<'a>( &self, - key: &AtlasKey, + key: AtlasKey, build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, ) -> Result>; fn remove(&self, key: &AtlasKey); @@ -1342,6 +1438,136 @@ pub trait PlatformAtlas { } } +#[doc(hidden)] +pub trait AtlasBackend { + fn insert( + &mut self, + kind: AtlasTextureKind, + size: Size, + bytes: &[u8], + ) -> Result; + + fn remove(&mut self, tile: AtlasTile); +} + +#[doc(hidden)] +pub struct AtlasState { + tiles_by_key: FxHashMap, + pub backend: Backend, +} + +impl AtlasState { + pub fn new(backend: Backend) -> Self { + Self { + tiles_by_key: FxHashMap::default(), + backend, + } + } + + pub fn contains(&self, key: &AtlasKey) -> bool { + self.tiles_by_key.contains_key(key) + } + + pub fn clear(&mut self, reset_backend: impl FnOnce(&mut Backend)) { + self.tiles_by_key.clear(); + reset_backend(&mut self.backend); + } +} + +impl Default for AtlasState { + fn default() -> Self { + Self::new(Backend::default()) + } +} + +impl AtlasState { + pub fn get_or_insert_with<'a>( + &mut self, + key: AtlasKey, + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result> { + match self.tiles_by_key.entry(key) { + Entry::Occupied(entry) => Ok(Some(*entry.get())), + Entry::Vacant(entry) => { + profiling::scope!("new tile"); + let Some((size, bytes)) = build()? else { + return Ok(None); + }; + let tile = self + .backend + .insert(entry.key().texture_kind(), size, &bytes)?; + entry.insert(tile); + Ok(Some(tile)) + } + } + } + + pub fn remove(&mut self, key: &AtlasKey) { + if let Some(tile) = self.tiles_by_key.remove(key) { + self.backend.remove(tile); + } + } +} + +/// A sprite atlas for windows without a GPU. It hands out uniquely identified +/// tiles without uploading any pixels, so glyph, SVG, and image painting can +/// run to completion in tests and headless platforms. +#[derive(Default)] +pub struct HeadlessAtlas(parking_lot::Mutex>); + +#[doc(hidden)] +#[derive(Default)] +pub struct HeadlessAtlasBackend { + next_id: u32, +} + +impl AtlasBackend for HeadlessAtlasBackend { + fn insert( + &mut self, + kind: AtlasTextureKind, + size: Size, + _bytes: &[u8], + ) -> Result { + self.next_id += 1; + let texture_id = self.next_id; + self.next_id += 1; + let tile_id = self.next_id; + Ok(AtlasTile { + texture_id: AtlasTextureId { + index: texture_id, + kind, + }, + tile_id: TileId(tile_id), + padding: 0, + bounds: Bounds { + origin: Point::default(), + size, + }, + }) + } + + fn remove(&mut self, _tile: AtlasTile) {} +} + +impl PlatformAtlas for HeadlessAtlas { + fn get_or_insert_with<'a>( + &self, + key: AtlasKey, + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result> { + self.0.lock().get_or_insert_with(key, build) + } + + fn remove(&self, key: &AtlasKey) { + self.0.lock().remove(key); + } + + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] + fn contains(&self, key: &AtlasKey) -> bool { + self.0.lock().contains(key) + } +} + #[doc(hidden)] pub struct AtlasTextureList { pub textures: Vec>, @@ -2930,6 +3156,124 @@ mod image_tests { } } +#[cfg(test)] +mod atlas_tests { + use super::*; + + const TILE_SIZE: Size = Size { + width: DevicePixels(1), + height: DevicePixels(1), + }; + + #[derive(Default)] + struct RecordingAtlasBackend { + insert_calls: u32, + fail_next_insert: bool, + removed_tiles: Vec, + } + + impl AtlasBackend for RecordingAtlasBackend { + fn insert( + &mut self, + kind: AtlasTextureKind, + size: Size, + _bytes: &[u8], + ) -> Result { + self.insert_calls += 1; + if std::mem::take(&mut self.fail_next_insert) { + anyhow::bail!("backend failed"); + } + Ok(AtlasTile { + texture_id: AtlasTextureId { index: 0, kind }, + tile_id: TileId(self.insert_calls), + padding: 0, + bounds: Bounds { + origin: Point::default(), + size, + }, + }) + } + + fn remove(&mut self, tile: AtlasTile) { + self.removed_tiles.push(tile); + } + } + + fn image_key(image_id: usize) -> AtlasKey { + AtlasKey::Image(RenderImageParams { + image_id: crate::ImageId(image_id), + frame_index: 0, + }) + } + + fn build_tile() -> Result, Cow<'static, [u8]>)>> { + Ok(Some((TILE_SIZE, Cow::Borrowed(&[0, 0, 0, 255])))) + } + + #[test] + fn only_successful_inserts_are_cached() -> Result<()> { + let mut state = AtlasState::new(RecordingAtlasBackend::default()); + let key = image_key(1); + + assert_eq!( + state.get_or_insert_with(key.clone(), &mut || Ok(None))?, + None + ); + state + .get_or_insert_with(key.clone(), &mut || anyhow::bail!("builder failed")) + .expect_err("builder error should propagate"); + assert!(!state.contains(&key)); + assert_eq!(state.backend.insert_calls, 0); + + state.backend.fail_next_insert = true; + state + .get_or_insert_with(key.clone(), &mut build_tile) + .expect_err("backend error should propagate"); + assert!(!state.contains(&key)); + assert_eq!(state.backend.insert_calls, 1); + + let tile = state + .get_or_insert_with(key.clone(), &mut build_tile)? + .context("builder should produce a tile")?; + assert_eq!(tile.texture_id.kind, key.texture_kind()); + assert_eq!( + state.get_or_insert_with(key.clone(), &mut || { + anyhow::bail!("cache hit must not call the builder") + })?, + Some(tile) + ); + assert!(state.contains(&key)); + assert_eq!(state.backend.insert_calls, 2); + Ok(()) + } + + #[test] + fn remove_and_clear_invalidate_keys() -> Result<()> { + let mut state = AtlasState::new(RecordingAtlasBackend::default()); + let key = image_key(1); + let other_key = image_key(2); + let tile = state + .get_or_insert_with(key.clone(), &mut build_tile)? + .context("builder should produce a tile")?; + state + .get_or_insert_with(other_key.clone(), &mut build_tile)? + .context("builder should produce another tile")?; + + state.remove(&key); + state.remove(&key); + assert!(!state.contains(&key)); + assert!(state.contains(&other_key)); + assert_eq!(state.backend.removed_tiles, vec![tile]); + + let mut reset_calls = 0; + state.clear(|_| reset_calls += 1); + assert_eq!(reset_calls, 1); + assert!(!state.contains(&other_key)); + assert_eq!(state.backend.removed_tiles, vec![tile]); + Ok(()) + } +} + #[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] mod tests { use super::*; diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 5e82fbe3d8..77a644114f 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -3,8 +3,8 @@ use crate::NoopTextSystem; #[cfg(any(test, feature = "test-support"))] use crate::PathPromptOptions; use crate::{ - AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, Platform, PlatformDisplay, + ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, + DummyKeyboardMapper, ForegroundExecutor, Keymap, OwnedMenu, Platform, PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task, TestDisplay, TestWindow, @@ -16,10 +16,14 @@ use collections::VecDeque; use futures::channel::oneshot; use parking_lot::Mutex; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, path::{Path, PathBuf}, rc::{Rc, Weak}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, }; /// TestPlatform implements the Platform trait for use in tests. @@ -43,8 +47,12 @@ pub(crate) struct TestPlatform { pub text_system: Arc, pub expect_restart: RefCell, Vec)>>>, + idle_sleep_prevention_count: Arc, + idle_sleep_prevention_delay: Cell, + idle_sleep_prevention_fails: Cell, headless_renderer_factory: Option Option>>>, weak: Weak, + menus: RefCell>, } #[derive(Clone)] @@ -154,11 +162,15 @@ impl TestPlatform { current_primary_item: Mutex::new(None), #[cfg(target_os = "macos")] current_find_pasteboard_item: Mutex::new(None), + idle_sleep_prevention_count: Arc::new(AtomicUsize::new(0)), + idle_sleep_prevention_delay: Cell::new(Duration::ZERO), + idle_sleep_prevention_fails: Cell::new(false), weak: weak.clone(), opened_url: Default::default(), system_notifications: Default::default(), text_system, headless_renderer_factory, + menus: Default::default(), }) } @@ -306,6 +318,21 @@ impl TestPlatform { !self.prompts.borrow().new_path.is_empty() } + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn active_idle_sleep_preventions(&self) -> usize { + self.idle_sleep_prevention_count.load(Ordering::SeqCst) + } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn set_idle_sleep_prevention_delay(&self, delay: Duration) { + self.idle_sleep_prevention_delay.set(delay); + } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn set_idle_sleep_prevention_fails(&self, fails: bool) { + self.idle_sleep_prevention_fails.set(fails); + } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn app_identity(&self) -> Option<(SharedString, SharedString)> { self.system_notifications.borrow().app_identity.clone() @@ -375,6 +402,32 @@ impl Platform for TestPlatform { ThermalState::Nominal } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + let count = self.idle_sleep_prevention_count.clone(); + let fails = self.idle_sleep_prevention_fails.get(); + let reason = reason.to_owned(); + let acquire = move || { + if fails { + anyhow::bail!("Idle sleep prevention for {reason:?} is set to fail in this test"); + } + count.fetch_add(1, Ordering::SeqCst); + Ok(ActivityGuard::new(move || { + count.fetch_sub(1, Ordering::SeqCst); + })) + }; + + let delay = self.idle_sleep_prevention_delay.get(); + if delay.is_zero() { + Task::ready(acquire()) + } else { + let delay = self.background_executor.timer(delay); + self.foreground_executor.spawn(async move { + delay.await; + acquire() + }) + } + } + fn run(&self, _on_finish_launching: Box) { unimplemented!() } @@ -531,6 +584,8 @@ impl Platform for TestPlatform { unimplemented!() } + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_app_identity(&self, identifier: &str, name: &str) { @@ -573,7 +628,14 @@ impl Platform for TestPlatform { self.system_notifications.borrow_mut().response_callback = Some(callback); } - fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + fn set_menus(&self, menus: Vec, _keymap: &Keymap) { + *self.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect() + } + + fn get_menus(&self) -> Option> { + Some(self.menus.borrow().clone()) + } + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} fn add_recent_document(&self, _paths: &Path) {} diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 223a2370e4..14fb8896b2 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,11 +1,10 @@ use crate::{ - AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, - DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, - PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TileId, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, + AnyWindowHandle, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, HeadlessAtlas, Pixels, + PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, + PlatformWindow, Point, PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, + TextInputConfiguration, TextInputStateChange, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControlArea, WindowInsets, WindowParams, WindowVisibility, }; -use collections::HashMap; use gpui_util::ResultExt as _; #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; @@ -33,8 +32,16 @@ pub(crate) struct TestWindowState { hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, active_status_change_callback: Option>, + visibility: WindowVisibility, + visibility_callback: Option>, hover_status_change_callback: Option>, resize_callback: Option, f32)>>, + visual_viewport: Option>, + visual_viewport_callback: Option>, + insets: WindowInsets, + insets_callback: Option>, + virtual_keyboard_requests: usize, + virtual_keyboard_dismissals: usize, moved_callback: Option>, appearance_change_callback: Option>, request_frame_callback: Option>, @@ -43,7 +50,9 @@ pub(crate) struct TestWindowState { frame_callback_pending: bool, input_handler: Option, text_input_configurations: Vec, + text_input_state_changes: Vec, is_fullscreen: bool, + scale_factor: f32, appearance: WindowAppearance, external_drag_files: Vec<(PathBuf, bool)>, start_external_drag_result: bool, @@ -80,7 +89,7 @@ impl TestWindow { ) -> Self { let sprite_atlas: Arc = match &renderer { Some(r) => r.sprite_atlas(), - None => Arc::new(TestAtlas::new()), + None => Arc::new(HeadlessAtlas::default()), }; Self(Rc::new(Mutex::new(TestWindowState { bounds: params.bounds, @@ -96,8 +105,16 @@ impl TestWindow { hit_test_window_control_callback: None, input_callback: None, active_status_change_callback: None, + visibility: WindowVisibility::Visible, + visibility_callback: None, hover_status_change_callback: None, resize_callback: None, + visual_viewport: None, + visual_viewport_callback: None, + insets: WindowInsets::default(), + insets_callback: None, + virtual_keyboard_requests: 0, + virtual_keyboard_dismissals: 0, moved_callback: None, appearance_change_callback: None, request_frame_callback: None, @@ -106,7 +123,10 @@ impl TestWindow { frame_callback_pending: false, input_handler: None, text_input_configurations: Vec::new(), + text_input_state_changes: Vec::new(), is_fullscreen: false, + // Preserve the test platform's historical 2x default. + scale_factor: 2.0, appearance: WindowAppearance::Light, external_drag_files: Vec::new(), start_external_drag_result: false, @@ -135,11 +155,59 @@ impl TestWindow { self.0.lock().frame_scheduled } + pub fn simulate_visibility_change(&self, visibility: WindowVisibility) { + let callback = { + let mut state = self.0.lock(); + state.visibility = visibility; + state.visibility_callback.take() + }; + if let Some(mut callback) = callback { + callback(visibility); + self.0.lock().visibility_callback = Some(callback); + } + } + + pub fn simulate_visual_viewport_change(&self, bounds: Bounds) { + let callback = { + let mut state = self.0.lock(); + state.visual_viewport = Some(bounds); + state.visual_viewport_callback.take() + }; + if let Some(mut callback) = callback { + callback(); + self.0.lock().visual_viewport_callback = Some(callback); + } + } + + pub fn simulate_insets_change(&self, insets: WindowInsets) { + let callback = { + let mut state = self.0.lock(); + state.insets = insets.clone(); + state.insets_callback.take() + }; + if let Some(mut callback) = callback { + callback(insets); + self.0.lock().insets_callback = Some(callback); + } + } + + pub fn virtual_keyboard_requests(&self) -> usize { + self.0.lock().virtual_keyboard_requests + } + + pub fn virtual_keyboard_dismissals(&self) -> usize { + self.0.lock().virtual_keyboard_dismissals + } + /// Every [`TextInputConfiguration`] forwarded to this window, in order. pub fn text_input_configurations(&self) -> Vec { self.0.lock().text_input_configurations.clone() } + pub fn text_input_state_changes(&self) -> Vec { + self.0.lock().text_input_state_changes.clone() + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -153,6 +221,16 @@ impl TestWindow { self.0.lock().resize_callback = Some(callback); } + /// Simulates a display scale change through the resize callback, preserving logical bounds. + pub fn simulate_scale_factor_change(&mut self, scale_factor: f32) { + let size = { + let mut lock = self.0.lock(); + lock.scale_factor = scale_factor; + lock.bounds.size + }; + self.simulate_resize(size); + } + pub(crate) fn simulate_active_status_change(&self, active: bool) { let mut lock = self.0.lock(); let Some(mut callback) = lock.active_status_change_callback.take() else { @@ -212,6 +290,33 @@ impl TestWindow { } impl PlatformWindow for TestWindow { + fn visual_viewport_bounds(&self) -> Bounds { + let state = self.0.lock(); + state + .visual_viewport + .unwrap_or_else(|| Bounds::new(Point::default(), state.bounds.size)) + } + + fn on_visual_viewport_changed(&self, callback: Box) { + self.0.lock().visual_viewport_callback = Some(callback); + } + + fn insets(&self) -> WindowInsets { + self.0.lock().insets.clone() + } + + fn on_insets_changed(&self, callback: Box) { + self.0.lock().insets_callback = Some(callback); + } + + fn show_soft_keyboard(&self) { + self.0.lock().virtual_keyboard_requests += 1; + } + + fn hide_soft_keyboard(&self) { + self.0.lock().virtual_keyboard_dismissals += 1; + } + fn bounds(&self) -> Bounds { self.0.lock().bounds } @@ -234,7 +339,7 @@ impl PlatformWindow for TestWindow { } fn scale_factor(&self) -> f32 { - 2.0 + self.0.lock().scale_factor } fn appearance(&self) -> WindowAppearance { @@ -269,6 +374,10 @@ impl PlatformWindow for TestWindow { self.0.lock().text_input_configurations.push(configuration); } + fn text_input_state_changed(&self, change: TextInputStateChange) { + self.0.lock().text_input_state_changes.push(change); + } + fn prompt( &self, _level: crate::PromptLevel, @@ -299,6 +408,10 @@ impl PlatformWindow for TestWindow { false } + fn visibility(&self) -> WindowVisibility { + self.0.lock().visibility + } + fn is_hovered(&self) -> bool { false } @@ -349,12 +462,16 @@ impl PlatformWindow for TestWindow { } fn frame_waker(&self) -> Option> { - // Recording invocations (rather than delivering a frame) lets tests - // assert the wake protocol without coupling to frame timing; tests - // deliver frames explicitly via `simulate_frame_request`. + // Tests can inspect wakes without delivering a frame synchronously. let frame_wake_count = self.0.lock().frame_wake_count.clone(); + #[cfg(feature = "bench-support")] + let window = Rc::downgrade(&self.0); Some(Rc::new(move || { frame_wake_count.set(frame_wake_count.get() + 1); + #[cfg(feature = "bench-support")] + if let Some(window) = window.upgrade() { + TestWindow(window).schedule_frame(); + } })) } @@ -377,6 +494,10 @@ impl PlatformWindow for TestWindow { self.0.lock().active_status_change_callback = Some(callback) } + fn on_visibility_change(&self, callback: Box) { + self.0.lock().visibility_callback = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.lock().hover_status_change_callback = Some(callback) } @@ -468,72 +589,3 @@ impl PlatformWindow for TestWindow { None } } - -pub(crate) struct TestAtlasState { - next_id: u32, - tiles: HashMap, -} - -pub(crate) struct TestAtlas(Mutex); - -impl TestAtlas { - pub fn new() -> Self { - TestAtlas(Mutex::new(TestAtlasState { - next_id: 0, - tiles: HashMap::default(), - })) - } -} - -impl PlatformAtlas for TestAtlas { - fn get_or_insert_with<'a>( - &self, - key: &crate::AtlasKey, - build: &mut dyn FnMut() -> anyhow::Result< - Option<(Size, std::borrow::Cow<'a, [u8]>)>, - >, - ) -> anyhow::Result> { - let mut state = self.0.lock(); - if let Some(&tile) = state.tiles.get(key) { - return Ok(Some(tile)); - } - drop(state); - - let Some((size, _)) = build()? else { - return Ok(None); - }; - - let mut state = self.0.lock(); - state.next_id += 1; - let texture_id = state.next_id; - state.next_id += 1; - let tile_id = state.next_id; - - state.tiles.insert( - key.clone(), - crate::AtlasTile { - texture_id: AtlasTextureId { - index: texture_id, - kind: crate::AtlasTextureKind::Monochrome, - }, - tile_id: TileId(tile_id), - padding: 0, - bounds: crate::Bounds { - origin: Point::default(), - size, - }, - }, - ); - - Ok(Some(state.tiles[key])) - } - - fn remove(&self, key: &AtlasKey) { - let mut state = self.0.lock(); - state.tiles.remove(key); - } - - fn contains(&self, key: &AtlasKey) -> bool { - self.0.lock().tiles.contains_key(key) - } -} diff --git a/crates/gpui/src/platform/threaded_dispatcher.rs b/crates/gpui/src/platform/threaded_dispatcher.rs index 762464a12e..b1fb4974df 100644 --- a/crates/gpui/src/platform/threaded_dispatcher.rs +++ b/crates/gpui/src/platform/threaded_dispatcher.rs @@ -218,12 +218,16 @@ impl ThreadedDispatcher { /// considered idle. Must be called on the thread that created this /// dispatcher. pub fn run_until_idle(&self) { + self.run_until_idle_with(|| self.drain_main_queue()); + } + + pub(crate) fn run_until_idle_with(&self, mut run_turn: impl FnMut() -> bool) { assert!( self.is_main_thread(), "run_until_idle must be called on the threaded dispatcher's main thread" ); loop { - if self.drain_main_queue() { + if run_turn() { continue; } @@ -268,36 +272,20 @@ impl ThreadedDispatcher { /// drains — deferred work that re-queues itself (idle sweeps, pollers) /// must not extend a benchmark's measured interval past the completion it /// awaits. - #[cfg(any(test, feature = "bench-support"))] - pub(crate) fn run_until(&self, mut ready: impl FnMut() -> Option) -> R { - assert!( - self.is_main_thread(), - "run_until must be called on the threaded dispatcher's main thread" - ); - loop { - if let Some(result) = ready() { - return result; - } - if self.run_one_main_task() { - continue; - } - - let mut inflight = self.idle.inflight.lock(); - if self.main_queue_has_work() { - continue; - } - self.idle.condvar.wait(&mut inflight); - } + #[cfg(test)] + pub(crate) fn run_until(&self, ready: impl FnMut() -> Option) -> R { + self.run_until_with_frames(ready, || false) } /// Runs at most one queued main-thread task, returning whether one ran. /// - /// [`Self::run_until`] steps tasks one at a time so it can observe - /// readiness between them: a task that perpetually re-queues itself (like - /// an idle-time sweep) would otherwise keep [`Self::drain_main_queue`] - /// looping past the completion the caller is waiting for. - #[cfg(any(test, feature = "bench-support"))] + /// Stepping tasks individually lets callers check completion between polls + /// even when work continually re-queues itself. fn run_one_main_task(&self) -> bool { + assert!( + self.is_main_thread(), + "main tasks must run on the main thread" + ); let runnable = self.main_receiver.lock().try_pop(); match runnable { Ok(Some(runnable)) => { @@ -312,11 +300,21 @@ impl ThreadedDispatcher { } } - /// Runs the main-thread tasks that were queued when the call began, - /// returning whether any ran. Tasks dispatched while running (e.g. a task - /// re-queuing itself after yielding) are left for the next call, as on - /// the platform run loops. + /// Runs up to the initial queue length of main-thread tasks. + /// + /// Returns whether any ran. This snapshots the count, not task identities: + /// newly dispatched higher-priority tasks can overtake the original tasks. pub fn run_ready_main_tasks(&self) -> bool { + self.run_ready_main_tasks_with(|| true, || {}) + } + + /// Runs a count-bounded ready turn, checking `should_continue` before every poll. + /// Calls `after_poll` after each runnable returns, outside the queue lock. + pub(crate) fn run_ready_main_tasks_with( + &self, + mut should_continue: impl FnMut() -> bool, + mut after_poll: impl FnMut(), + ) -> bool { assert!( self.is_main_thread(), "run_ready_main_tasks must be called on the threaded dispatcher's main thread" @@ -324,22 +322,48 @@ impl ThreadedDispatcher { let pending = self.main_receiver.lock().len(); let mut ran_any = false; for _ in 0..pending { - let runnable = self.main_receiver.lock().try_pop(); - match runnable { - Ok(Some(runnable)) => { - let location = runnable.metadata().location; - let spawned = runnable.metadata().spawned; - profiler::update_running_task(spawned, location); - runnable.run(); - profiler::save_task_timing(); - ran_any = true; - } - Ok(None) | Err(_) => break, + if !should_continue() || !self.run_one_main_task() { + break; } + ran_any = true; + after_poll(); } ran_any } + #[cfg(any(test, feature = "bench-support"))] + pub(crate) fn run_until_with_frames( + &self, + mut ready: impl FnMut() -> Option, + mut dispatch_frames: impl FnMut() -> bool, + ) -> R { + assert!( + self.is_main_thread(), + "main tasks must run on the main thread" + ); + loop { + if let Some(result) = ready() { + return result; + } + let ran_task = self.run_one_main_task(); + // A completion poll must not pull an unrelated animation frame + // into the measured operation. + if let Some(result) = ready() { + return result; + } + let dispatched_frames = dispatch_frames(); + if ran_task || dispatched_frames { + continue; + } + + let mut inflight = self.idle.inflight.lock(); + if self.main_queue_has_work() { + continue; + } + self.idle.condvar.wait(&mut inflight); + } + } + /// Cancels all pending timers so timers armed by one workload can't fire /// during a later workload sharing this process-lifetime dispatcher. /// diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 042a757f22..0454c31014 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -7,10 +7,10 @@ use crate::ScreenCaptureSource; use crate::{ - AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, Keymap, - Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, - TestDispatcher, WindowAppearance, WindowParams, + ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, + ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Task, TestDispatcher, WindowAppearance, WindowParams, }; use anyhow::Result; use futures::channel::oneshot; @@ -176,6 +176,8 @@ impl Platform for VisualTestPlatform { fn on_reopen(&self, _callback: Box) {} + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} @@ -261,4 +263,10 @@ impl Platform for VisualTestPlatform { } fn on_thermal_state_change(&self, _callback: Box) {} + + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "Idle sleep prevention for {reason:?} is not supported in visual tests" + ))) + } } diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 340f054b9c..e8108c106b 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -961,17 +961,26 @@ impl WindowProfiler { return; }; - journal::record_input(journal::InputTiming { - kind, - start: started_at, - end: Instant::now(), - caused_invalidation, - }); + if !journal::power_interrupted_since(started_at) { + journal::record_input(journal::InputTiming { + kind, + start: started_at, + end: Instant::now(), + caused_invalidation, + }); + } journal::end_foreground_turn(); - if !caused_invalidation { + if !caused_invalidation || !journal::frame_sample_is_valid(self.window_id, started_at) { return; } + if self + .first_input_at + .is_some_and(|at| !journal::frame_sample_is_valid(self.window_id, at)) + { + self.first_input_at = None; + self.pending_input_count = 0; + } let arrived_during_draw = self .active_activities @@ -1003,11 +1012,13 @@ impl WindowProfiler { journal::end_foreground_turn(); return; }; - journal::record_action(ActionTiming { - name, - start, - end: Instant::now(), - }); + if !journal::power_interrupted_since(start) { + journal::record_action(ActionTiming { + name, + start, + end: Instant::now(), + }); + } journal::end_foreground_turn(); } @@ -1034,13 +1045,15 @@ impl WindowProfiler { let draw_end = Instant::now(); let frame_timing = FrameTiming { window_id: self.window_id, - dirty_at, + dirty_at: dirty_at.filter(|at| journal::frame_sample_is_valid(self.window_id, *at)), invalidations, draw_start, draw_end, }; let draw_duration = frame_timing.draw_duration(); - self.record_draw_timing(frame_timing); + if !journal::power_interrupted_since(draw_start) { + self.record_draw_timing(frame_timing); + } journal::end_foreground_turn(); draw_duration } @@ -1089,21 +1102,33 @@ impl WindowProfiler { window_active: bool, next_frame_scheduled: bool, ) { - if let Some(first_input_at) = self.first_input_at.take() { + if let Some(first_input_at) = self.first_input_at.take() + && journal::frame_sample_is_valid(self.window_id, first_input_at) + { let latency_nanos = present_end.duration_since(first_input_at).as_nanos() as u64; self.input_latency_histogram.record(latency_nanos).ok(); + if self.pending_input_count > 0 { + self.events_per_frame_histogram + .record(self.pending_input_count) + .ok(); + } } - if self.pending_input_count > 0 { - self.events_per_frame_histogram - .record(self.pending_input_count) - .ok(); - self.pending_input_count = 0; - } + self.pending_input_count = 0; - let frame = self.pending_frame.take(); + let frame = self + .pending_frame + .take() + .filter(|frame| journal::frame_sample_is_valid(self.window_id, frame.draw_start)) + .map(|mut frame| { + frame.dirty_at = frame + .dirty_at + .filter(|at| journal::frame_sample_is_valid(self.window_id, *at)); + frame + }); let animation_interval = if frame.is_some() && self.animating_at_last_present && window_active { self.last_present_at + .filter(|at| journal::frame_sample_is_valid(self.window_id, *at)) .map(|last_present_at| present_end.duration_since(last_present_at)) } else { None @@ -1241,6 +1266,43 @@ mod tests { use super::*; use std::sync::{Mutex, MutexGuard}; + #[test] + fn interruptions_drop_latency_samples_but_hiding_keeps_draw_work() { + for sleep in [false, true] { + let (_journal, _guard) = journal::install_test_foreground_journal(64, 4); + let id = WindowId::from(1); + let mut profiler = WindowProfiler::new(id).expect("valid histograms"); + let old = Instant::now() - Duration::from_secs(1); + record_test_draw(&mut profiler, old); + profiler.record_present_at(old, old, true, true); + profiler.first_input_at = Some(old); + profiler.pending_input_count = 1; + profiler.begin_draw(); + if sleep { + journal::record_power_transition(journal::PowerState::Suspended); + journal::record_power_transition(journal::PowerState::Awake); + } else { + journal::record_window_visibility(id, crate::WindowVisibility::Hidden); + journal::record_window_visibility(id, crate::WindowVisibility::Visible); + } + profiler.end_draw(Some(old), 1); + let now = Instant::now(); + profiler.record_present_at(now, now, true, true); + assert_eq!( + profiler.draw_duration_histogram.len(), + if sleep { 1 } else { 2 } + ); + assert_eq!(profiler.input_latency_histogram.len(), 0); + assert_eq!(profiler.dirty_to_present_histogram.len(), 1); + assert_eq!(profiler.present_interval_histogram.len(), 0); + profiler.begin_draw(); + profiler.end_draw(Some(Instant::now()), 1); + let now = Instant::now(); + profiler.record_present_at(now, now, true, true); + assert_eq!(profiler.dirty_to_present_histogram.len(), 2); + } + } + #[test] fn records_draw_events_only_while_tracing() { let _trace_test_guard = TraceTestGuard::new(); diff --git a/crates/gpui/src/profiler/hang.rs b/crates/gpui/src/profiler/hang.rs index 9514931225..23d63aa658 100644 --- a/crates/gpui/src/profiler/hang.rs +++ b/crates/gpui/src/profiler/hang.rs @@ -15,6 +15,9 @@ use std::time::Duration; use scheduler::Instant; use serde::Serialize; +/// Version of the power/visibility-aware measurement rules. +pub const MEASUREMENT_VERSION: u32 = 2; + use super::SerializedLocation; use super::journal::{ ForegroundEvent, ForegroundJournal, ForegroundJournalCollector, ForegroundJournalEntry, @@ -40,13 +43,30 @@ pub struct HangIncident { /// The interval the hangs occurred in, including all non-hang foreground /// work recorded alongside them. pub snapshot: FrameSnapshot, - /// The events that blocked the foreground for at least the detector's - /// threshold, longest first. When the incident was triggered by the - /// frame budget alone, no event crossed the threshold and this instead + /// Which detection rule qualified the interval. + pub trigger: HangTrigger, + /// For [`HangTrigger::Threshold`], the events that blocked the foreground + /// for at least the detector's threshold, longest first. For + /// [`HangTrigger::Budget`], no event crossed the threshold and this instead /// holds every event in the interval, longest first. pub contributors: Vec, } +/// The detection rule that qualified an interval as a [`HangIncident`]. +/// +/// Recorded explicitly so consumers can separate the two classes without +/// re-deriving them from `stall_ms`, which stops working whenever the +/// detector's thresholds change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HangTrigger { + /// A single event blocked the foreground for at least the hang threshold. + Threshold, + /// No single event crossed the threshold, but the interval's total + /// foreground spend reached the frame budget. + Budget, +} + impl HangDetector { /// Creates a detector reporting single events at or above `threshold` /// and intervals whose total foreground spend reached `frame_budget`. @@ -94,10 +114,14 @@ impl HangDetector { /// locations as plain data, contributor count capped by the converter. #[derive(Debug, Clone, Serialize)] pub struct SerializedHangIncident { + /// Identifies the rules used to exclude interrupted measurements. + pub measurement_version: u32, /// `"startup"` when the active window began before the first observed /// newly drawn frame finished platform submission (see /// [`HangDetector::first_present_at`]), otherwise `"steady"`. pub phase: &'static str, + /// `"threshold"` or `"budget"` (see [`HangTrigger`]). + pub trigger: HangTrigger, /// When the incident's active window started, in milliseconds since app /// startup: the sealing frame's first invalidation, or the earliest /// contributor's start when nothing was pending a repaint. Foreground @@ -114,7 +138,7 @@ pub struct SerializedHangIncident { /// For presentation-sealed incidents, how long the submitted frame had /// been dirty, in milliseconds. pub dirty_to_present_ms: Option, - /// What closed the incident: `"present"` or `"idle"`. This labels the + /// What closed the incident: `"present"`, `"idle"`, or `"power_transition"`. This labels the /// boundary, not the hang's cause — the cause is the first contributor. pub sealed_by: &'static str, /// Fraction of the active window the foreground spent working, @@ -250,6 +274,8 @@ impl SerializedHangIncident { Some(first_present_at) if active_start >= first_present_at => "steady", _ => "startup", }, + measurement_version: MEASUREMENT_VERSION, + trigger: incident.trigger, start_ms: since_startup(active_start), active_ms: as_millis(active), stall_ms: incident @@ -261,11 +287,12 @@ impl SerializedHangIncident { IntervalBoundary::Presented(presented) => { presented.dirty_to_present_duration().map(as_millis) } - IntervalBoundary::Idle { .. } => None, + IntervalBoundary::Idle { .. } | IntervalBoundary::PowerTransition { .. } => None, }, sealed_by: match snapshot.boundary { IntervalBoundary::Presented(_) => "present", IntervalBoundary::Idle { .. } => "idle", + IntervalBoundary::PowerTransition { .. } => "power_transition", }, busy_fraction: (busy_fraction * 1000.0).round() / 1000.0, event_count: snapshot.events.len(), @@ -407,7 +434,7 @@ impl HangIncident { .filter(|event| event.duration() >= threshold) .copied() .collect(); - if contributors.is_empty() { + let trigger = if contributors.is_empty() { if snapshot.journal_discontinuous { return None; } @@ -416,10 +443,14 @@ impl HangIncident { return None; } contributors = snapshot.events.clone(); - } + HangTrigger::Budget + } else { + HangTrigger::Threshold + }; contributors.sort_by_key(|event| std::cmp::Reverse(event.duration())); Some(Self { snapshot, + trigger, contributors, }) } @@ -449,7 +480,9 @@ mod tests { InputTiming, IntervalBoundary, PollSummary, PresentedFrame, SmallPollFlush, install_test_foreground_journal, record_present, }; - use super::{HangDetector, HangIncident, SerializedHangContributor, SerializedHangIncident}; + use super::{ + HangDetector, HangIncident, HangTrigger, SerializedHangContributor, SerializedHangIncident, + }; actions!(hang_test, [HangyAction]); @@ -567,6 +600,7 @@ mod tests { let serialized = SerializedHangIncident::convert(startup, &incident, 1, Some(at(50))); assert_eq!(serialized.phase, "steady"); + assert_eq!(serialized.trigger, HangTrigger::Threshold); // The frame's first invalidation anchors the active window, not the // interval start or the first contributor. assert_eq!(serialized.start_ms, 100.0); @@ -755,12 +789,14 @@ mod tests { let incident = HangIncident::detect(snapshot, HANG_THRESHOLD, FRAME_BUDGET) .expect("foreground spend exceeded the frame budget"); + assert_eq!(incident.trigger, HangTrigger::Budget); assert_eq!(incident.contributors.len(), 3); assert_eq!( incident.contributors[0].duration(), Duration::from_millis(8) ); let serialized = SerializedHangIncident::convert(startup, &incident, 8, Some(startup)); + assert_eq!(serialized.trigger, HangTrigger::Budget); assert_eq!(serialized.stall_ms, 8.0); assert_eq!(serialized.dirty_to_present_ms, Some(150.0)); assert_eq!(serialized.sealed_by, "present"); diff --git a/crates/gpui/src/profiler/journal.rs b/crates/gpui/src/profiler/journal.rs index 761181a0ad..ed6ac6c588 100644 --- a/crates/gpui/src/profiler/journal.rs +++ b/crates/gpui/src/profiler/journal.rs @@ -21,10 +21,17 @@ use std::sync::{ }; use std::time::Duration; +use gpui_util::ResultExt; use scheduler::Instant; use super::{ActionTiming, FrameTiming, PresentTiming, TaskTiming}; -use crate::WindowId; +use crate::{App, WindowId, WindowVisibility}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PowerState { + Awake, + Suspended, +} /// Task polls shorter than this are folded into a [`PollSummary`] instead of /// being recorded individually. This keeps the stream bounded by the number @@ -182,6 +189,11 @@ pub enum IntervalBoundary { /// When the foreground went idle. ended_at: Instant, }, + /// A power notification interrupted the measurement interval. + PowerTransition { + /// When the foreground received the notification. + ended_at: Instant, + }, } impl IntervalBoundary { @@ -189,7 +201,7 @@ impl IntervalBoundary { pub fn end_time(&self) -> Instant { match self { Self::Presented(presented) => presented.presentation.present_end, - Self::Idle { ended_at } => *ended_at, + Self::Idle { ended_at } | Self::PowerTransition { ended_at } => *ended_at, } } @@ -197,7 +209,7 @@ impl IntervalBoundary { pub fn dirty_at(&self) -> Option { match self { Self::Presented(presented) => presented.frame.dirty_at, - Self::Idle { .. } => None, + Self::Idle { .. } | Self::PowerTransition { .. } => None, } } } @@ -379,11 +391,31 @@ impl ForegroundRunnableCounter { } } +// Visibility and power changes clear `dirty_at`; `Window::refresh_visibility` +// re-records the frame from the window's actual dirty state afterwards. +struct WindowFrameState { + visibility: WindowVisibility, + dirty_at: Option, + visibility_changed_at: Option, +} + +impl WindowFrameState { + fn new(visibility: WindowVisibility) -> Self { + Self { + visibility, + dirty_at: None, + visibility_changed_at: None, + } + } +} + struct ForegroundJournalWriter { foreground_runnables: ForegroundRunnableCounter, publisher: JournalPublisher, turn_depth: usize, - pending_frames: HashMap, + windows: HashMap, + power: PowerState, + power_changed_at: Option, retained_since_boundary: bool, small_polls: Option, } @@ -394,7 +426,9 @@ impl ForegroundJournalWriter { foreground_runnables, publisher, turn_depth: 0, - pending_frames: HashMap::new(), + windows: HashMap::new(), + power: PowerState::Awake, + power_changed_at: None, retained_since_boundary: false, small_polls: None, } @@ -434,12 +468,51 @@ impl ForegroundJournalWriter { } fn has_unexpired_pending_frame(&mut self, now: Instant) -> bool { - if self.pending_frames.is_empty() { - return false; + for window in self.windows.values_mut() { + if window + .dirty_at + .is_some_and(|at| now.saturating_duration_since(at) >= FRAME_DEADLINE) + { + window.dirty_at = None; + } + } + self.power == PowerState::Awake + && self + .windows + .values() + .any(|window| window.dirty_at.is_some()) + } + + fn power_transition(&mut self, power: PowerState, at: Instant) { + if self.power == power { + return; + } + self.record_entry(ForegroundJournalEntry::Boundary( + IntervalBoundary::PowerTransition { ended_at: at }, + )); + self.power = power; + self.power_changed_at = Some(at); + for window in self.windows.values_mut() { + window.dirty_at = None; + } + } + + /// Whether work that started at `start` spans a sleep or a wake, and so + /// must not be measured as uninterrupted foreground work. + fn power_interrupted_since(&self, start: Instant) -> bool { + self.power == PowerState::Suspended || self.power_changed_at.is_some_and(|at| start <= at) + } + + fn set_visibility(&mut self, id: WindowId, visibility: WindowVisibility, at: Instant) { + let window = self + .windows + .entry(id) + .or_insert_with(|| WindowFrameState::new(visibility)); + if window.visibility != visibility { + window.visibility = visibility; + window.visibility_changed_at = Some(at); + window.dirty_at = None; } - self.pending_frames - .retain(|_, dirty_at| now.saturating_duration_since(*dirty_at) < FRAME_DEADLINE); - !self.pending_frames.is_empty() } fn fold_small_poll(&mut self, timing: TaskTiming) { @@ -480,17 +553,20 @@ impl ForegroundJournalWriter { } fn record_frame_pending(&mut self, window_id: WindowId, dirty_at: Instant) { - let should_record = match self.pending_frames.get(&window_id) { - Some(previous_dirty_at) => { - dirty_at.saturating_duration_since(*previous_dirty_at) >= FRAME_DEADLINE - } - None => true, - }; - if !should_record { + let window = self + .windows + .entry(window_id) + .or_insert_with(|| WindowFrameState::new(WindowVisibility::Visible)); + if self.power == PowerState::Suspended + || !window.visibility.is_visible() + || window + .dirty_at + .is_some_and(|at| dirty_at.saturating_duration_since(at) < FRAME_DEADLINE) + { return; } - self.pending_frames.insert(window_id, dirty_at); + window.dirty_at = Some(dirty_at); self.record_frame_state(FrameStateChange::Pending { window_id, dirty_at, @@ -498,14 +574,19 @@ impl ForegroundJournalWriter { } fn record_window_closed(&mut self, window_id: WindowId, at: Instant) { - self.pending_frames.remove(&window_id); + self.windows.remove(&window_id); self.record_frame_state(FrameStateChange::Closed { window_id, at }); } fn record_present(&mut self, timing: PresentTiming, frame: Option) { + if let Some(window) = self.windows.get_mut(&timing.window_id) { + window.dirty_at = None; + } + if self.power_interrupted_since(timing.present_start) { + return; + } match frame { Some(frame) => { - self.pending_frames.remove(&frame.window_id); self.record_entry(ForegroundJournalEntry::Boundary( IntervalBoundary::Presented(PresentedFrame { frame, @@ -514,7 +595,6 @@ impl ForegroundJournalWriter { )); } None => { - self.pending_frames.remove(&timing.window_id); self.record_event(ForegroundEvent::Present(timing)); } } @@ -605,6 +685,58 @@ fn with_journal(f: impl FnOnce(&mut ForegroundJournalWriter)) { }); } +pub(crate) fn observe_power(cx: &App) { + cx.on_system_sleep(|_| { + record_power_transition(PowerState::Suspended); + }) + .detach(); + // Visibility notifications delivered during sleep may have been missed. + cx.on_system_wake(|cx| { + record_power_transition(PowerState::Awake); + cx.spawn(async |cx| { + cx.update(|cx| { + for handle in cx.windows() { + handle + .update(cx, |_, window, cx| window.refresh_visibility(cx)) + .log_err(); + } + }); + }) + .detach(); + }) + .detach(); +} + +pub(crate) fn record_power_transition(state: PowerState) { + with_journal(|journal| journal.power_transition(state, Instant::now())); +} + +/// Whether work that started at `start` spans a sleep or a wake. Such work +/// is dropped rather than measured. +pub(crate) fn power_interrupted_since(start: Instant) -> bool { + let mut interrupted = false; + with_journal(|journal| interrupted = journal.power_interrupted_since(start)); + interrupted +} + +pub(crate) fn record_window_visibility(id: WindowId, visibility: WindowVisibility) { + with_journal(|journal| journal.set_visibility(id, visibility, Instant::now())); +} + +/// Whether a frame sample that started at `start` was measured while the +/// window stayed visible and the system stayed awake. +pub(crate) fn frame_sample_is_valid(id: WindowId, start: Instant) -> bool { + let mut valid = true; + with_journal(|journal| { + valid = !journal.power_interrupted_since(start) + && journal.windows.get(&id).is_none_or(|window| { + window.visibility.is_visible() + && window.visibility_changed_at.is_none_or(|at| start > at) + }); + }); + valid +} + // TODO(gpui-profiler): the turn brackets in the dispatchers and // WindowProfiler are bare begin/end call pairs rather than uses of this // guard. A caught unwind between a pair leaves `turn_depth` (and potentially @@ -636,6 +768,10 @@ pub(crate) fn end_foreground_turn() { pub(crate) fn record_task_poll(timing: TaskTiming) { FOREGROUND_RUNNABLES.with(ForegroundRunnableCounter::finished); with_journal(|journal| { + if journal.power_interrupted_since(timing.start) { + journal.end_turn(timing.end.0); + return; + } if timing.poll_duration() >= TASK_POLL_FLOOR { journal.record_event(ForegroundEvent::TaskPoll(timing)); } else { @@ -1100,6 +1236,96 @@ mod tests { use super::*; use crate::{WindowId, profiler::YieldTime}; + #[gpui::test] + fn wake_rechecks_native_visibility(cx: &mut crate::TestAppContext) { + use crate::PlatformWindow as _; + let handle = cx.add_window(|_, _| crate::Empty); + let platform = cx.test_window(handle.into()); + platform.on_visibility_change(Box::new(|_| {})); + platform.simulate_visibility_change(WindowVisibility::Hidden); + handle + .update(cx, |_, window, _| assert!(window.is_visible())) + .expect("window"); + cx.update(|cx| { + cx.system_wake_observers + .clone() + .retain(&(), |callback| callback(cx)) + }); + cx.run_until_parked(); + handle + .update(cx, |_, window, _| assert!(!window.is_visible())) + .expect("window"); + } + + #[test] + fn power_interruption_drops_the_poll_before_small_poll_folding() { + let (journal, _guard) = install_test_foreground_journal(32, 4); + let mut collector = journal.collector(); + let start = Instant::now(); + begin_foreground_turn(); + record_power_transition(PowerState::Suspended); + record_power_transition(PowerState::Awake); + assert!(power_interrupted_since(start)); + record_task_poll(task_timing(start, start + Duration::from_micros(10))); + assert!( + collector + .collect_unseen() + .entries + .iter() + .all(|entry| matches!( + entry, + ForegroundJournalEntry::Boundary(IntervalBoundary::PowerTransition { .. }) + )) + ); + with_journal(|writer| assert!(writer.small_polls.is_none())); + begin_foreground_turn(); + let start = Instant::now(); + assert!(!power_interrupted_since(start)); + record_task_poll(task_timing(start, start + TASK_POLL_FLOOR)); + assert!( + collector + .collect_unseen() + .entries + .iter() + .any(|entry| matches!( + entry, + ForegroundJournalEntry::Event(ForegroundEvent::TaskPoll(_)) + )) + ); + } + + #[test] + fn visibility_and_power_changes_clear_pending_frames() { + let (mut writer, _) = test_journal(ForegroundRunnableCounter::new()); + let start = Instant::now(); + let first = WindowId::from(1); + let second = WindowId::from(2); + writer.set_visibility(first, WindowVisibility::Hidden, start); + writer.record_frame_pending(first, start); + assert!(!writer.has_unexpired_pending_frame(start)); + writer.record_frame_pending(second, start); + assert!(writer.has_unexpired_pending_frame(start)); + writer.set_visibility(second, WindowVisibility::Hidden, start); + assert!(!writer.has_unexpired_pending_frame(start)); + writer.set_visibility(first, WindowVisibility::Visible, start); + writer.record_frame_pending(first, start); + assert!(writer.has_unexpired_pending_frame(start)); + writer.power_transition(PowerState::Suspended, start); + assert_eq!(writer.windows[&first].dirty_at, None); + writer.record_frame_pending(first, start); + assert!(!writer.has_unexpired_pending_frame(start)); + // A repeated notification is not a transition. + writer.power_transition(PowerState::Suspended, start + FRAME_DEADLINE); + assert_eq!(writer.power_changed_at, Some(start)); + writer.power_transition(PowerState::Awake, start); + writer.record_frame_pending(first, start); + assert!(writer.has_unexpired_pending_frame(start)); + writer.record_present(presentation_timing(first, start + FRAME_DEADLINE), None); + assert_eq!(writer.windows[&first].dirty_at, None); + writer.record_window_closed(first, start); + assert!(!writer.windows.contains_key(&first)); + } + #[test] fn draw_waits_for_its_presentation_boundary() { let start = Instant::now(); @@ -1152,7 +1378,7 @@ mod tests { IntervalBoundary::Presented(presented) => { presented.dirty_to_present_duration() } - IntervalBoundary::Idle { .. } => None, + IntervalBoundary::Idle { .. } | IntervalBoundary::PowerTransition { .. } => None, }, Some(Duration::from_millis(5)) ); @@ -1978,6 +2204,7 @@ mod tests { boundary_kind: match snapshot.boundary { IntervalBoundary::Idle { .. } => 0, IntervalBoundary::Presented(_) => 1, + IntervalBoundary::PowerTransition { .. } => 2, }, interval_end: snapshot.interval_end().duration_since(origin).as_micros() as u64, events: snapshot @@ -2076,6 +2303,7 @@ mod tests { boundary_kind: match boundary { IntervalBoundary::Idle { .. } => 0, IntervalBoundary::Presented(_) => 1, + IntervalBoundary::PowerTransition { .. } => 2, }, interval_end, events: std::mem::take(&mut events), @@ -2687,7 +2915,14 @@ mod tests { ); prop_assert_eq!(writer.turn_depth, model.turn_depth); prop_assert_eq!(writer.retained_since_boundary, model.retained_since_boundary); - prop_assert_eq!(writer.pending_frames.len(), model.pending_frames.len()); + prop_assert_eq!( + writer + .windows + .values() + .filter(|window| window.dirty_at.is_some()) + .count(), + model.pending_frames.len() + ); } } } diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 76f6d67a01..3e02b1359d 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -2,7 +2,7 @@ use std::{ collections::VecDeque, fmt, iter::FusedIterator, - sync::{Arc, atomic::AtomicUsize}, + sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError, TryLockError, atomic::AtomicUsize}, }; use rand::{Rng, SeedableRng, rngs::SmallRng}; @@ -24,8 +24,10 @@ impl PriorityQueues { } struct PriorityQueueState { - queues: parking_lot::Mutex>, - condvar: parking_lot::Condvar, + // std's Wasm atomic backend unlocks and notifies without acquiring another + // lock. parking_lot can park during those operations, even after try_lock. + queues: Mutex>, + condvar: Condvar, receiver_count: AtomicUsize, sender_count: AtomicUsize, } @@ -40,7 +42,7 @@ impl PriorityQueueState { return Err(SendError(item)); } - let mut queues = self.queues.lock(); + let mut queues = self.queues.lock().unwrap_or_else(PoisonError::into_inner); Self::push(&mut queues, priority, item); self.condvar.notify_one(); Ok(()) @@ -56,10 +58,11 @@ impl PriorityQueueState { } let mut queues = loop { - if let Some(guard) = self.queues.try_lock() { - break guard; + match self.queues.try_lock() { + Ok(guard) => break guard, + Err(TryLockError::Poisoned(error)) => break error.into_inner(), + Err(TryLockError::WouldBlock) => std::hint::spin_loop(), } - std::hint::spin_loop(); }; Self::push(&mut queues, priority, item); self.condvar.notify_one(); @@ -77,8 +80,8 @@ impl PriorityQueueState { }; } - fn recv<'a>(&'a self) -> Result>, RecvError> { - let mut queues = self.queues.lock(); + fn recv<'a>(&'a self) -> Result>, RecvError> { + let mut queues = self.queues.lock().unwrap_or_else(PoisonError::into_inner); let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); if queues.is_empty() && sender_count == 0 { @@ -86,16 +89,17 @@ impl PriorityQueueState { } while queues.is_empty() { - self.condvar.wait(&mut queues); + queues = self + .condvar + .wait(queues) + .unwrap_or_else(PoisonError::into_inner); } Ok(queues) } - fn try_recv<'a>( - &'a self, - ) -> Result>>, RecvError> { - let mut queues = self.queues.lock(); + fn try_recv<'a>(&'a self) -> Result>>, RecvError> { + let queues = self.queues.lock().unwrap_or_else(PoisonError::into_inner); let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); if queues.is_empty() && sender_count == 0 { @@ -109,14 +113,13 @@ impl PriorityQueueState { } } - fn spin_try_recv<'a>( - &'a self, - ) -> Result>>, RecvError> { + fn spin_try_recv<'a>(&'a self) -> Result>>, RecvError> { let queues = loop { - if let Some(guard) = self.queues.try_lock() { - break guard; + match self.queues.try_lock() { + Ok(guard) => break guard, + Err(TryLockError::Poisoned(error)) => break error.into_inner(), + Err(TryLockError::WouldBlock) => std::hint::spin_loop(), } - std::hint::spin_loop(); }; let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); @@ -198,12 +201,12 @@ pub struct RecvError; impl PriorityQueueReceiver { pub fn new() -> (PriorityQueueSender, Self) { let state = PriorityQueueState { - queues: parking_lot::Mutex::new(PriorityQueues { + queues: Mutex::new(PriorityQueues { high_priority: VecDeque::new(), medium_priority: VecDeque::new(), low_priority: VecDeque::new(), }), - condvar: parking_lot::Condvar::new(), + condvar: Condvar::new(), receiver_count: AtomicUsize::new(1), sender_count: AtomicUsize::new(1), }; @@ -222,12 +225,20 @@ impl PriorityQueueReceiver { /// Returns whether the queue currently contains no elements. pub fn is_empty(&self) -> bool { - self.state.queues.lock().is_empty() + self.state + .queues + .lock() + .unwrap_or_else(PoisonError::into_inner) + .is_empty() } /// Returns the number of queued elements across all priorities. pub(crate) fn len(&self) -> usize { - let queues = self.state.queues.lock(); + let queues = self + .state + .queues + .lock() + .unwrap_or_else(PoisonError::into_inner); queues.high_priority.len() + queues.medium_priority.len() + queues.low_priority.len() } diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index d06a4b0a13..798fb2a965 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -187,13 +187,11 @@ impl SvgRenderer { } /// Parses SVG data into a [`ParsedSvg`] that can be rasterized at any scale. - #[ztracing::instrument(skip_all)] pub fn parse_svg(&self, bytes: &[u8]) -> Result { usvg::Tree::from_data(bytes, &self.usvg_options).map(ParsedSvg) } /// Rasterizes a previously parsed SVG into an image buffer. - #[ztracing::instrument(skip_all)] pub fn render_parsed( &self, svg: &ParsedSvg, diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 3346389319..cf6bb10293 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -17,7 +17,7 @@ use crate::{ StrikethroughStyle, TextRenderingMode, UnderlineStyle, px, }; use anyhow::{Context as _, anyhow}; -use collections::FxHashMap; +use collections::{FxHashMap, FxHashSet}; use core::fmt; use derive_more::{Add, Deref, FromStr, Sub}; use itertools::Itertools; @@ -26,10 +26,14 @@ use smallvec::{SmallVec, smallvec}; use std::{ borrow::Cow, cmp, + collections::VecDeque, fmt::{Debug, Display, Formatter}, hash::{Hash, Hasher}, ops::{Deref, DerefMut, Range}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; /// An opaque identifier for a specific font. @@ -47,6 +51,181 @@ pub const SUBPIXEL_VARIANTS_X: u8 = 4; /// Number of subpixel glyph variants along the Y axis. pub const SUBPIXEL_VARIANTS_Y: u8 = 1; +// Leave enough room below the underline for its stroke while keeping it below the baseline. +const UNDERLINE_DESCENT_OFFSET_FACTOR: f32 = 0.618; + +/// Returns the vertical offset used to paint an underline within a line. +pub fn underline_y_offset(line_height: Pixels, ascent: Pixels, descent: Pixels) -> Pixels { + let padding_top = (line_height - ascent - descent) / 2.; + padding_top + ascent + descent * UNDERLINE_DESCENT_OFFSET_FACTOR +} + +const MAX_REPORTED_MISSING_GLYPHS: usize = 1024; + +/// The spacing behavior required of a fallback font. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum FallbackFontClass { + /// A proportionally spaced fallback font. + Proportional, + /// A fixed-width fallback font. + Monospace, +} + +/// A grapheme cluster that could not be represented by any available font. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MissingGlyph { + grapheme: SharedString, + font_class: FallbackFontClass, +} + +impl MissingGlyph { + /// Creates a missing-glyph report. + pub fn new(grapheme: SharedString, font_class: FallbackFontClass) -> Self { + Self { + grapheme, + font_class, + } + } + + /// Returns the unresolved grapheme cluster. + pub fn grapheme(&self) -> &str { + &self.grapheme + } + + /// Returns the spacing behavior required of a fallback font. + pub fn font_class(&self) -> FallbackFontClass { + self.font_class + } +} + +/// Accepts missing glyphs detected by a platform text system. +pub trait MissingGlyphSink: Send + Sync { + /// Reports grapheme clusters that exhausted font fallback. + fn report(&self, missing_glyphs: Vec); +} + +#[derive(Default)] +struct MissingGlyphState { + reported: FxHashSet, + reported_order: VecDeque, + generation: usize, +} + +impl MissingGlyphState { + fn reset(&mut self, generation: usize) { + self.reported.clear(); + self.reported_order.clear(); + self.generation = generation; + } +} + +struct QueuedMissingGlyph { + generation: usize, + missing_glyph: MissingGlyph, +} + +/// Collects missing-glyph reports without invoking application code during layout. +struct MissingGlyphReporter { + generation: Arc, + sender: async_channel::Sender, +} + +impl MissingGlyphSink for MissingGlyphReporter { + fn report(&self, missing_glyphs: Vec) { + if self.sender.is_closed() { + return; + } + + let generation = self.generation.load(Ordering::Acquire); + // Repetitions within a line must not fill the queue before its other + // missing glyphs. Cross-report deduplication belongs to the receiver. + for missing_glyph in missing_glyphs.into_iter().unique() { + let queued = QueuedMissingGlyph { + generation, + missing_glyph, + }; + if self.sender.try_send(queued).is_err() { + break; + } + } + } +} + +impl MissingGlyphReporter { + fn reset(&self) { + self.generation.fetch_add(1, Ordering::AcqRel); + } +} + +/// Receives batches of grapheme clusters that exhausted font fallback. +pub(crate) struct MissingGlyphReceiver { + state: MissingGlyphState, + generation: Arc, + receiver: async_channel::Receiver, +} + +impl MissingGlyphReceiver { + /// Waits until at least one new missing glyph has been observed. + /// + /// # Errors + /// + /// Returns [`async_channel::RecvError`] if the reporting channel is closed. + pub(crate) async fn recv( + &mut self, + ) -> std::result::Result, async_channel::RecvError> { + loop { + let queued = self.receiver.recv().await?; + let mut missing_glyphs = Vec::new(); + for queued in std::iter::once(queued) + .chain(std::iter::from_fn(|| self.receiver.try_recv().ok())) + .take(MAX_REPORTED_MISSING_GLYPHS) + { + let generation = self.generation.load(Ordering::Acquire); + if self.state.generation != generation { + self.state.reset(generation); + missing_glyphs.clear(); + } + if queued.generation != generation + || !self.state.reported.insert(queued.missing_glyph.clone()) + { + continue; + } + self.state + .reported_order + .push_back(queued.missing_glyph.clone()); + missing_glyphs.push(queued.missing_glyph); + if self.state.reported.len() > MAX_REPORTED_MISSING_GLYPHS + && let Some(expired) = self.state.reported_order.pop_front() + { + self.state.reported.remove(&expired); + } + } + if !missing_glyphs.is_empty() { + return Ok(missing_glyphs); + } + // A producer can keep refilling the queue with already-reported + // glyphs. Bound work per poll even when every report is filtered out. + let mut yielded = false; + std::future::poll_fn(|cx| { + if std::mem::replace(&mut yielded, true) { + std::task::Poll::Ready(()) + } else { + cx.waker().wake_by_ref(); + std::task::Poll::Pending + } + }) + .await; + } + } +} + +impl Drop for MissingGlyphReceiver { + fn drop(&mut self) { + self.receiver.close(); + while self.receiver.try_recv().is_ok() {} + } +} + /// The GPUI text rendering sub system. pub struct TextSystem { platform_text_system: Arc, @@ -56,11 +235,16 @@ pub struct TextSystem { wrapper_pool: Mutex>>, font_runs_pool: Mutex>>, fallback_font_stack: SmallVec<[Font; 2]>, + font_generation: Arc, + missing_glyph_reporter: Arc, + missing_glyph_receiver: Mutex>, } impl TextSystem { /// Create a new TextSystem with the given platform text system. pub fn new(platform_text_system: Arc) -> Self { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let missing_glyph_generation = Arc::::default(); TextSystem { platform_text_system, font_metrics: RwLock::default(), @@ -81,26 +265,64 @@ impl TextSystem { font("DejaVu Sans"), font("Arial"), // macOS, Windows ], + font_generation: Arc::default(), + missing_glyph_reporter: Arc::new(MissingGlyphReporter { + generation: missing_glyph_generation.clone(), + sender, + }), + missing_glyph_receiver: Mutex::new(Some(MissingGlyphReceiver { + state: MissingGlyphState::default(), + generation: missing_glyph_generation, + receiver, + })), } } - /// Get a list of all available font names from the operating system. + /// Get sorted, unique font family names available to the platform text system. + /// + /// Includes fonts registered with [`Self::add_fonts`]. pub fn all_font_names(&self) -> Vec { let mut names = self.platform_text_system.all_font_names(); - names.extend( - self.fallback_font_stack - .iter() - .map(|font| font.family.to_string()), - ); - names.push(".SystemUIFont".to_string()); names.sort_unstable(); names.dedup(); names } /// Add a font's data to the text system. + /// + /// Cached font resolution and line layouts are invalidated after installation. + /// Layouts already in progress may complete against the previous font set. pub fn add_fonts(&self, fonts: Vec>) -> Result<()> { - self.platform_text_system.add_fonts(fonts) + self.platform_text_system.add_fonts(fonts)?; + self.font_ids_by_font.write().clear(); + self.missing_glyph_reporter.reset(); + self.font_generation.fetch_add(1, Ordering::Release); + Ok(()) + } + + /// Takes the receiver for missing-glyph reports. + /// + /// Only one receiver is available for each text system. Returns `None` when + /// the receiver was already taken or another caller is taking it. + pub(crate) fn take_missing_glyph_receiver(&self) -> Option { + self.missing_glyph_receiver + .try_lock() + .and_then(|mut receiver| receiver.take()) + } + + pub(crate) fn enable_missing_glyph_reporting(&self) { + self.platform_text_system + .set_missing_glyph_sink(Some(self.missing_glyph_reporter.clone())); + } + + pub(crate) fn disable_missing_glyph_reporting(&self) { + self.platform_text_system.set_missing_glyph_sink(None); + self.missing_glyph_reporter.reset(); + } + + #[cfg(test)] + pub(crate) fn report_missing_glyphs_in_test(&self, missing_glyphs: Vec) { + self.missing_glyph_reporter.report(missing_glyphs); } /// Get the FontId for the configure font family and style. @@ -387,7 +609,10 @@ impl WindowTextSystem { /// Create a new WindowTextSystem with the given TextSystem. pub fn new(text_system: Arc) -> Self { Self { - line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()), + line_layout_cache: LineLayoutCache::new( + text_system.platform_text_system.clone(), + text_system.font_generation.clone(), + ), text_system, } } @@ -1220,3 +1445,61 @@ pub fn font_name_with_fallbacks_shared<'a>( _ => name, } } + +#[cfg(test)] +mod missing_glyph_tests { + use super::*; + use futures::FutureExt as _; + + #[test] + fn bounds_retained_missing_glyphs() { + let (reporter, mut receiver) = missing_glyph_channel(); + reporter.report( + (0..MAX_REPORTED_MISSING_GLYPHS) + .map(|index| { + MissingGlyph::new(index.to_string().into(), FallbackFontClass::Proportional) + }) + .collect(), + ); + assert!(receiver.recv().now_or_never().unwrap().is_ok()); + + let newest = MissingGlyph::new("newest".into(), FallbackFontClass::Monospace); + reporter.report(vec![newest.clone()]); + assert!(receiver.recv().now_or_never().unwrap().is_ok()); + + let state = &receiver.state; + assert_eq!(state.reported.len(), MAX_REPORTED_MISSING_GLYPHS); + assert_eq!(state.reported_order.len(), MAX_REPORTED_MISSING_GLYPHS); + assert!(state.reported.contains(&newest)); + } + + #[test] + fn dropping_receiver_closes_and_clears_reports() { + let (reporter, receiver) = missing_glyph_channel(); + reporter.report(vec![missing_glyph("missing")]); + + drop(receiver); + + assert!(reporter.sender.is_closed()); + assert!(reporter.sender.is_empty()); + } + + fn missing_glyph_channel() -> (MissingGlyphReporter, MissingGlyphReceiver) { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let generation = Arc::::default(); + let reporter = MissingGlyphReporter { + generation: generation.clone(), + sender, + }; + let receiver = MissingGlyphReceiver { + state: MissingGlyphState::default(), + generation, + receiver, + }; + (reporter, receiver) + } + + fn missing_glyph(grapheme: &'static str) -> MissingGlyph { + MissingGlyph::new(grapheme.into(), FallbackFontClass::Proportional) + } +} diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index 15101e6a2f..780d1a6422 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -1,11 +1,11 @@ use crate::{ App, Bounds, DevicePixels, Half, Hsla, LineLayout, Pixels, Point, RenderGlyphParams, Result, SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window, WrapBoundary, - WrappedLineLayout, black, fill, point, px, size, + WrappedLineLayout, black, fill, point, px, size, underline_y_offset, }; use derive_more::{Deref, DerefMut}; use smallvec::SmallVec; -use std::sync::Arc; +use std::{ops::Range, sync::Arc}; /// Pre-computed glyph data for efficient painting without per-glyph cache lookups. /// @@ -50,6 +50,31 @@ pub struct ShapedLine { } impl ShapedLine { + /// Returns a forward-only cursor for this shaped line. + pub fn cursor(&self) -> ShapedLineCursor<'_> { + assert_eq!( + self.len(), + self.text.len(), + "cannot split a shaped line with an adjusted length" + ); + let byte_ordered = self + .layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter().map(|glyph| glyph.index)) + .is_sorted(); + ShapedLineCursor { + line: self, + unordered_remainder: (!byte_ordered).then(|| self.clone()), + byte_index: 0, + run_index: 0, + glyph_index: 0, + decoration_index: 0, + decoration_offset: 0, + x_offset: px(0.), + } + } + /// The length of the line in utf-8 bytes. #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { @@ -88,6 +113,34 @@ impl ShapedLine { align_width: Option, window: &mut Window, cx: &mut App, + ) -> Result<()> { + self.paint_with_underline_handler( + origin, + line_height, + align, + align_width, + window, + cx, + |_, origin, width, style, window| window.paint_underline(origin, width, style), + ) + } + + /// Paint the line with a handler for each underline. + pub fn paint_with_underline_handler( + &self, + origin: Point, + line_height: Pixels, + align: TextAlign, + align_width: Option, + window: &mut Window, + cx: &mut App, + mut paint_underline: impl FnMut( + Range, + Point, + Pixels, + &UnderlineStyle, + &mut Window, + ), ) -> Result<()> { paint_line( origin, @@ -99,9 +152,8 @@ impl ShapedLine { &[], window, cx, - )?; - - Ok(()) + &mut paint_underline, + ) } /// Paint the background of the line to the window. @@ -204,6 +256,129 @@ impl ShapedLine { } } +/// Incrementally splits a [`ShapedLine`] at increasing UTF-8 byte boundaries. +/// +/// Each piece preserves the original glyphs and decorations, rebased as in +/// [`ShapedLine::split_at`]. Byte-ordered glyphs are advanced in linear time, +/// copying each glyph and byte at most once; visually reordered glyphs fall +/// back to the existing split operation. +pub struct ShapedLineCursor<'a> { + line: &'a ShapedLine, + /// Bidirectional shaping can put glyphs out of byte order. + unordered_remainder: Option, + byte_index: usize, + run_index: usize, + glyph_index: usize, + decoration_index: usize, + decoration_offset: u32, + x_offset: Pixels, +} + +impl<'a> ShapedLineCursor<'a> { + /// Takes the bytes since the previous boundary. + /// + /// Panics if the boundary precedes the previous one, exceeds the line's + /// length, or falls inside a UTF-8 character. + pub fn take_until(&mut self, byte_index: usize) -> ShapedLine { + assert!( + byte_index >= self.byte_index, + "split boundary moved backwards" + ); + assert!( + byte_index <= self.line.len(), + "split boundary exceeds line length" + ); + assert!( + self.line.text.is_char_boundary(byte_index), + "split boundary is not a UTF-8 character boundary" + ); + let previous_index = self.byte_index; + let previous_x = self.x_offset; + if let Some(remainder) = &mut self.unordered_remainder { + let (piece, rest) = remainder.split_at(byte_index - previous_index); + *remainder = rest; + self.byte_index = byte_index; + self.x_offset = self.line.layout.x_for_index(byte_index); + return piece; + } + let mut runs = Vec::new(); + let mut next_x = self.line.layout.width; + while let Some(run) = self.line.layout.runs.get(self.run_index) { + let start = self.glyph_index; + while let Some(glyph) = run.glyphs.get(self.glyph_index) { + if glyph.index >= byte_index { + break; + } + self.glyph_index += 1; + } + let end = self.glyph_index; + if start < end { + runs.push(crate::ShapedRun { + font_id: run.font_id, + glyphs: run.glyphs[start..end] + .iter() + .map(|glyph| crate::ShapedGlyph { + id: glyph.id, + position: point(glyph.position.x - previous_x, glyph.position.y), + index: glyph.index - previous_index, + is_emoji: glyph.is_emoji, + }) + .collect(), + }); + } + if let Some(glyph) = run.glyphs.get(self.glyph_index) { + next_x = glyph.position.x; + break; + } + self.run_index += 1; + self.glyph_index = 0; + } + let mut decorations = SmallVec::new(); + while let Some(decoration) = self.line.decoration_runs.get(self.decoration_index) + && (self.decoration_offset < byte_index as u32 + || (decoration.len == 0 && self.decoration_offset == byte_index as u32)) + { + let end = self.decoration_offset + decoration.len; + let start = self.decoration_offset.max(previous_index as u32); + let len = end.min(byte_index as u32) - start; + if len > 0 || decoration.len == 0 { + decorations.push(DecorationRun { + len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + } + if end <= byte_index as u32 { + self.decoration_index += 1; + self.decoration_offset = end; + } else { + break; + } + } + self.byte_index = byte_index; + self.x_offset = next_x; + ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.line.layout.font_size, + width: next_x - previous_x, + ascent: self.line.layout.ascent, + descent: self.line.layout.descent, + runs, + len: byte_index - previous_index, + }), + text: SharedString::new(&self.line.text[previous_index..byte_index]), + decoration_runs: decorations, + } + } + + /// Returns the original line's x position at the current boundary. + pub fn x_offset(&self) -> Pixels { + self.x_offset + } +} + impl LineLayout { /// Paint this layout to the window, using the given decoration runs to color /// glyphs and draw underlines and strikethroughs. @@ -230,6 +405,7 @@ impl LineLayout { &[], window, cx, + &mut |_, origin, width, style, window| window.paint_underline(origin, width, style), ) } @@ -305,6 +481,7 @@ impl WrappedLine { &self.wrap_boundaries, window, cx, + &mut |_, origin, width, style, window| window.paint_underline(origin, width, style), )?; Ok(()) @@ -351,6 +528,13 @@ fn paint_line( wrap_boundaries: &[WrapBoundary], window: &mut Window, cx: &mut App, + paint_underline: &mut dyn FnMut( + Range, + Point, + Pixels, + &UnderlineStyle, + &mut Window, + ), ) -> Result<()> { let line_bounds = Bounds::new( origin, @@ -362,11 +546,12 @@ fn paint_line( window.paint_layer(line_bounds, |window| { let padding_top = (line_height - layout.ascent - layout.descent) / 2.; let baseline_offset = point(px(0.), padding_top + layout.ascent); + let underline_y_offset = underline_y_offset(line_height, layout.ascent, layout.descent); let mut decoration_runs = decoration_runs.iter(); let mut wraps = wrap_boundaries.iter().peekable(); let mut run_end = 0; let mut color = black(); - let mut current_underline: Option<(Point, UnderlineStyle)> = None; + let mut current_underline: Option<(Point, UnderlineStyle, Range)> = None; let mut current_strikethrough: Option<(Point, StrikethroughStyle)> = None; let text_system = cx.text_system().clone(); let mut glyph_origin = point( @@ -394,14 +579,18 @@ fn paint_line( if wraps.peek() == Some(&&WrapBoundary { run_ix, glyph_ix }) { wraps.next(); - if let Some((underline_origin, underline_style)) = current_underline.as_mut() { + if let Some((underline_origin, underline_style, underline_range)) = + current_underline.as_mut() + { if glyph_origin.x == underline_origin.x { underline_origin.x -= max_glyph_size.width.half(); }; - window.paint_underline( + paint_underline( + underline_range.clone(), *underline_origin, glyph_origin.x - underline_origin.x, underline_style, + window, ); if glyph.index < run_end { underline_origin.x = origin.x; @@ -441,7 +630,8 @@ fn paint_line( } prev_glyph_position = glyph.position; - let mut finished_underline: Option<(Point, UnderlineStyle)> = None; + let mut finished_underline: Option<(Point, UnderlineStyle, Range)> = + None; let mut finished_strikethrough: Option<(Point, StrikethroughStyle)> = None; if glyph.index >= run_end { let mut style_run = decoration_runs.next(); @@ -456,22 +646,24 @@ fn paint_line( } if let Some(style_run) = style_run { - if let Some((_, underline_style)) = &mut current_underline - && style_run.underline.as_ref() != Some(underline_style) + let style_run_start = run_end; + if let Some((_, underline_style, underline_range)) = &mut current_underline { - finished_underline = current_underline.take(); + if style_run.underline.as_ref() != Some(underline_style) { + finished_underline = current_underline.take(); + } else { + underline_range.end = style_run_start + style_run.len as usize; + } } if let Some(run_underline) = style_run.underline.as_ref() { current_underline.get_or_insert(( - point( - glyph_origin.x, - glyph_origin.y + baseline_offset.y + (layout.descent * 0.618), - ), + point(glyph_origin.x, glyph_origin.y + underline_y_offset), UnderlineStyle { color: Some(run_underline.color.unwrap_or(style_run.color)), thickness: run_underline.thickness, wavy: run_underline.wavy, }, + style_run_start..style_run_start + style_run.len as usize, )); } if let Some((_, strikethrough_style)) = &mut current_strikethrough @@ -502,14 +694,18 @@ fn paint_line( } } - if let Some((mut underline_origin, underline_style)) = finished_underline { + if let Some((mut underline_origin, underline_style, underline_range)) = + finished_underline + { if underline_origin.x == glyph_origin.x { underline_origin.x -= max_glyph_size.width.half(); }; - window.paint_underline( + paint_underline( + underline_range, underline_origin, glyph_origin.x - underline_origin.x, &underline_style, + window, ); } @@ -561,14 +757,18 @@ fn paint_line( last_line_end_x -= glyph.position.x; } - if let Some((mut underline_start, underline_style)) = current_underline.take() { + if let Some((mut underline_start, underline_style, underline_range)) = + current_underline.take() + { if last_line_end_x == underline_start.x { underline_start.x -= max_glyph_size.width.half() }; - window.paint_underline( + paint_underline( + underline_range, underline_start, last_line_end_x - underline_start.x, &underline_style, + window, ); } @@ -762,7 +962,11 @@ fn aligned_origin_x( #[cfg(test)] mod tests { use super::*; - use crate::{FontId, GlyphId, ShapedGlyph, ShapedRun}; + use crate::{ + AppContext as _, Context, FontId, GlyphId, IntoElement, Render, ShapedGlyph, ShapedRun, + Styled, TestAppContext, TextRun, Underline, canvas, font, hsla, + }; + use std::rc::Rc; /// Helper: build a ShapedLine from glyph descriptors without the platform text system. /// Each glyph is described as (byte_index, x_position). @@ -799,6 +1003,316 @@ mod tests { } } + #[gpui::test] + fn test_underline_handler_matches_default_paint(cx: &mut TestAppContext) { + test_underline_handler_at_scales(cx, |window, cx| { + let first_style = UnderlineStyle { + thickness: px(1.), + color: Some(hsla(0., 1., 0.5, 1.)), + wavy: true, + }; + let last_style = UnderlineStyle { + color: Some(hsla(0.5, 1., 0.5, 1.)), + wavy: false, + ..first_style + }; + let fallback_style = UnderlineStyle { + color: Some(black()), + ..first_style + }; + let decoration = DecorationRun { + len: 1, + color: black(), + background_color: None, + underline: Some(first_style), + strikethrough: None, + }; + let line = underline_test_line( + "aébcde", + &[ + decoration.clone(), + DecorationRun { + len: 2, + ..decoration.clone() + }, + DecorationRun { + underline: None, + ..decoration.clone() + }, + DecorationRun { + underline: Some(UnderlineStyle { + color: None, + ..first_style + }), + ..decoration.clone() + }, + DecorationRun { + underline: Some(last_style), + ..decoration.clone() + }, + DecorationRun { + underline: Some(last_style), + ..decoration + }, + ], + false, + window, + ); + assert_eq!(line.width(), px(48.)); + for origin_x in [-3.25, 0., 4.25] { + for (align, align_width, offset) in [ + (TextAlign::Left, None, 0.), + (TextAlign::Center, None, 0.), + (TextAlign::Right, None, 0.), + (TextAlign::Left, Some(px(96.)), 0.), + (TextAlign::Center, Some(px(96.)), 24.), + (TextAlign::Right, Some(px(96.)), 48.), + ] { + let origin = point(px(origin_x), px(12.25)); + let line_height = px(20.); + window.next_frame.scene.clear(); + line.paint(origin, line_height, align, align_width, window, cx) + .unwrap(); + let original = window.next_frame.scene.underlines.clone(); + window.next_frame.scene.clear(); + line.layout + .paint( + origin, + line_height, + align, + align_width, + &line.decoration_runs, + window, + cx, + ) + .unwrap(); + assert_underline_primitives_eq(&window.next_frame.scene.underlines, &original); + + window.next_frame.scene.clear(); + let mut strokes = Vec::new(); + line.paint_with_underline_handler( + origin, + line_height, + align, + align_width, + window, + cx, + |range, origin, width, style, window| { + strokes.push((range, origin, width, *style)); + window.paint_underline(origin, width, style); + }, + ) + .unwrap(); + let start = px(origin_x + offset); + let y = origin.y + underline_y_offset(line_height, line.ascent, line.descent); + assert_eq!( + strokes, + [ + (0..3, point(start, y), px(16.), first_style), + (4..5, point(start + px(24.), y), px(8.), fallback_style), + (5..7, point(start + px(32.), y), px(16.), last_style), + ] + ); + assert_underline_primitives_eq(&window.next_frame.scene.underlines, &original); + + window.next_frame.scene.clear(); + let mut captured = Vec::new(); + line.paint_with_underline_handler( + origin, + line_height, + align, + align_width, + window, + cx, + |range, origin, width, style, _| { + captured.push((range, origin, width, *style)) + }, + ) + .unwrap(); + assert_eq!(captured, strokes); + assert_eq!(window.next_frame.scene.underlines.len(), 0); + } + } + for text in ["", "abc"] { + let line = underline_test_line(text, &[], false, window); + let mut calls = 0; + line.paint_with_underline_handler( + point(px(4.), px(10.)), + px(20.), + TextAlign::Left, + None, + window, + cx, + |_, _, _, _, _| calls += 1, + ) + .unwrap(); + assert_eq!(calls, 0); + } + }); + } + + #[gpui::test] + fn test_underline_handler_reports_zero_advance_geometry(cx: &mut TestAppContext) { + test_underline_handler_at_scales(cx, |window, cx| { + let first_style = UnderlineStyle { + thickness: px(1.), + color: Some(black()), + wavy: true, + }; + let last_style = UnderlineStyle { + wavy: false, + ..first_style + }; + let decoration = DecorationRun { + len: 1, + color: black(), + background_color: None, + underline: Some(first_style), + strikethrough: None, + }; + let line = underline_test_line( + "ab", + &[ + decoration.clone(), + DecorationRun { + underline: Some(last_style), + ..decoration + }, + ], + true, + window, + ); + let half_width = cx + .text_system() + .bounding_box(line.runs[0].font_id, line.font_size) + .size + .width + / 2.; + let origin = point(px(40.25), px(10.25)); + let line_height = px(20.); + let y = origin.y + underline_y_offset(line_height, line.ascent, line.descent); + for (align, offset) in [ + (TextAlign::Left, 0.), + (TextAlign::Center, 16.), + (TextAlign::Right, 32.), + ] { + window.next_frame.scene.clear(); + line.paint(origin, line_height, align, Some(px(32.)), window, cx) + .unwrap(); + let original = window.next_frame.scene.underlines.clone(); + window.next_frame.scene.clear(); + let mut strokes = Vec::new(); + line.paint_with_underline_handler( + origin, + line_height, + align, + Some(px(32.)), + window, + cx, + |range, origin, width, style, window| { + strokes.push((range, origin, width, *style)); + window.paint_underline(origin, width, style); + }, + ) + .unwrap(); + let end = origin.x + px(offset); + let start = point(end - half_width, y); + let width = end - start.x; + assert_eq!( + strokes, + [ + (0..1, start, width, first_style), + (1..2, start, width, last_style), + ] + ); + assert_underline_primitives_eq(&window.next_frame.scene.underlines, &original); + } + }); + } + + #[gpui::test] + fn test_underline_handler_matches_wrapped_paint(cx: &mut TestAppContext) { + test_underline_handler_at_scales(cx, |window, cx| { + let style = UnderlineStyle { + thickness: px(1.), + color: Some(black()), + wavy: true, + }; + for zero_advance in [false, true] { + let line = underline_test_line( + "abcd", + &[DecorationRun { + len: 4, + color: black(), + background_color: None, + underline: Some(style), + strikethrough: None, + }], + zero_advance, + window, + ); + let half_width = cx + .text_system() + .bounding_box(line.runs[0].font_id, line.font_size) + .size + .width + / 2.; + let origin = point(px(40.25), px(10.25)); + let line_height = px(20.); + let y = origin.y + underline_y_offset(line_height, line.ascent, line.descent); + let wrapped = WrappedLine { + layout: Arc::new(WrappedLineLayout { + unwrapped_layout: line.layout, + wrap_boundaries: SmallVec::from_buf([WrapBoundary { + run_ix: 0, + glyph_ix: 2, + }]), + wrap_width: Some(px(16.)), + }), + text: line.text, + decoration_runs: line.decoration_runs.into_vec(), + }; + window.next_frame.scene.clear(); + wrapped + .paint(origin, line_height, TextAlign::Left, None, window, cx) + .unwrap(); + let original = window.next_frame.scene.underlines.clone(); + window.next_frame.scene.clear(); + let mut strokes = Vec::new(); + paint_line( + origin, + &wrapped.unwrapped_layout, + line_height, + TextAlign::Left, + Some(px(16.)), + &wrapped.decoration_runs, + &wrapped.wrap_boundaries, + window, + cx, + &mut |range, origin, width, style, window| { + strokes.push((range, origin, width, *style)); + window.paint_underline(origin, width, style); + }, + ) + .unwrap(); + let (start, width) = if zero_advance { + let start = origin.x - half_width; + (start, origin.x - start) + } else { + (origin.x, px(16.)) + }; + assert_eq!( + strokes, + [ + (0..4, point(start, y), width, style), + (0..4, point(start, y + line_height), width, style), + ] + ); + assert_underline_primitives_eq(&window.next_frame.scene.underlines, &original); + } + }); + } + #[test] fn test_split_at_invariants() { // Split "abcdef" at every possible byte index and verify structural invariants. @@ -1022,4 +1536,335 @@ mod tests { assert_eq!(right.decoration_runs[1].len, 1); assert_eq!(right.decoration_runs[1].color, blue); } + + struct UnderlineHandlerTestView(Rc); + + impl Render for UnderlineHandlerTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let paint = self.0.clone(); + canvas( + |_, _, _| {}, + move |_, _, window, cx| { + window.with_element_opacity(Some(0.5), |window| paint(window, cx)); + }, + ) + .size_full() + } + } + + fn test_underline_handler_at_scales( + cx: &mut TestAppContext, + paint: impl Fn(&mut Window, &mut App) + 'static, + ) { + let window = cx.add_window(move |_, _| UnderlineHandlerTestView(Rc::new(paint))); + for scale in [1., 1.25, 1.5, 2., 3.] { + cx.simulate_window_scale_factor_change(window.into(), scale); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + } + } + + fn underline_test_line( + text: &str, + decorations: &[DecorationRun], + zero_advance: bool, + window: &Window, + ) -> ShapedLine { + let mut line = window.text_system().shape_line( + SharedString::new(text), + px(16.), + &[TextRun { + len: text.len(), + font: font(".ZedMono"), + color: black(), + ..TextRun::default() + }], + None, + ); + line.decoration_runs = SmallVec::from(decorations.to_vec()); + let layout = &line.layout; + let mut runs = layout.runs.clone(); + let advance = if zero_advance { px(0.) } else { px(8.) }; + let mut width = px(0.); + for glyph in runs.iter_mut().flat_map(|run| &mut run.glyphs) { + glyph.position.x = width; + width += advance; + } + line.layout = Arc::new(LineLayout { + font_size: layout.font_size, + width, + ascent: layout.ascent, + descent: layout.descent, + runs, + len: layout.len, + }); + line + } + + fn assert_underline_primitives_eq(actual: &[Underline], expected: &[Underline]) { + assert_eq!(actual.len(), expected.len()); + for (actual, expected) in actual.iter().zip(expected) { + assert_eq!(actual.bounds, expected.bounds); + assert_eq!(actual.content_mask, expected.content_mask); + assert_eq!(actual.color, expected.color); + assert_eq!(actual.thickness, expected.thickness); + assert_eq!(actual.wavy, expected.wavy); + assert_eq!(actual.order, expected.order); + assert_eq!(actual.pad, expected.pad); + } + } + + #[test] + fn test_cursor_preserves_shaping_metadata_across_runs() { + let line = ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(50.0), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ + ShapedRun { + font_id: FontId(3), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(11), + position: point(px(0.0), px(1.0)), + index: 0, + is_emoji: true, + }, + ShapedGlyph { + id: GlyphId(12), + position: point(px(17.0), px(1.0)), + index: 1, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(13), + position: point(px(19.0), px(-1.0)), + index: 1, + is_emoji: false, + }, + ], + }, + ShapedRun { + font_id: FontId(8), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(21), + position: point(px(25.0), px(1.0)), + index: 5, + is_emoji: true, + }, + ShapedGlyph { + id: GlyphId(22), + position: point(px(41.0), px(1.0)), + index: 7, + is_emoji: false, + }, + ], + }, + ], + len: 10, + }), + text: "a😀bcdef".into(), + decoration_runs: SmallVec::new(), + }; + let mut cursor = line.cursor(); + let first = cursor.take_until(5); + assert_eq!(first.text.as_ref(), "a😀"); + assert_eq!(first.runs[0].font_id, FontId(3)); + assert_eq!(first.runs[0].glyphs[0].id, GlyphId(11)); + assert!(first.runs[0].glyphs[0].is_emoji); + assert_eq!(first.runs[0].glyphs[1].index, 1); + assert_eq!(first.runs[0].glyphs[1].position, point(px(17.0), px(1.0))); + assert_eq!(first.runs[0].glyphs.len(), 3); + assert_eq!(first.runs[0].glyphs[2].index, 1); + assert_eq!(first.runs[0].glyphs[2].position, point(px(19.0), px(-1.0))); + assert_eq!(cursor.x_offset(), px(25.0)); + + let second = cursor.take_until(7); + assert_eq!(second.text.as_ref(), "bc"); + assert_eq!(second.runs[0].font_id, FontId(8)); + assert_eq!(second.runs[0].glyphs[0].id, GlyphId(21)); + assert_eq!(second.runs[0].glyphs[0].index, 0); + assert_eq!(second.runs[0].glyphs[0].position, point(px(0.0), px(1.0))); + assert_eq!(cursor.x_offset(), px(41.0)); + + let final_part = cursor.take_until(10); + assert_eq!(final_part.text.as_ref(), "def"); + assert_eq!(final_part.runs[0].font_id, FontId(8)); + assert_eq!(final_part.runs[0].glyphs[0].id, GlyphId(22)); + assert_eq!(final_part.runs[0].glyphs[0].index, 0); + assert_eq!( + final_part.runs[0].glyphs[0].position, + point(px(0.0), px(1.0)) + ); + } + + #[test] + fn test_cursor_preserves_existing_visual_order_splitting() { + let line = make_shaped_line("abc", &[(0, 0.0), (2, 10.0), (1, 20.0)], 30.0, &[]); + let mut cursor = line.cursor(); + let mut remainder = line.clone(); + let mut previous_boundary = 0; + for boundary in [0, 1, 2, 3] { + let (expected, rest) = remainder.split_at(boundary - previous_boundary); + let actual = cursor.take_until(boundary); + assert_eq!(actual.text, expected.text); + assert_eq!(actual.width(), expected.width()); + assert_eq!(actual.runs.len(), expected.runs.len()); + for (actual, expected) in actual.runs.iter().zip(&expected.runs) { + assert_eq!(actual.font_id, expected.font_id); + assert_eq!(actual.glyphs.len(), expected.glyphs.len()); + for (actual, expected) in actual.glyphs.iter().zip(&expected.glyphs) { + assert_eq!(actual.id, expected.id); + assert_eq!(actual.index, expected.index); + assert_eq!(actual.position, expected.position); + } + } + assert_eq!(cursor.x_offset(), line.x_for_index(boundary)); + remainder = rest; + previous_boundary = boundary; + } + } + + #[test] + fn test_cursor_partitions_one_decoration_across_three_chunks() { + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[DecorationRun { + len: 6, + color: Hsla { + h: 0.2, + s: 0.4, + l: 0.6, + a: 1.0, + }, + background_color: None, + underline: None, + strikethrough: None, + }], + ); + let mut cursor = line.cursor(); + assert_eq!(cursor.take_until(2).decoration_runs[0].len, 2); + assert_eq!(cursor.take_until(4).decoration_runs[0].len, 2); + assert_eq!(cursor.take_until(6).decoration_runs[0].len, 2); + } + + #[test] + fn test_cursor_matches_successive_splits_at_ordered_boundaries() { + let decorations: Vec<_> = [2, 0, 3, 1] + .into_iter() + .map(|len| DecorationRun { + len, + color: Hsla { + h: len as f32 / 10.0, + s: 0.5, + l: 0.5, + a: 1.0, + }, + background_color: Some(black()), + underline: None, + strikethrough: None, + }) + .collect(); + let line = make_shaped_line( + "abcdef", + &[(0, 5.0), (0, 5.0), (2, 15.0), (4, 25.0), (5, 35.0)], + 45.0, + &decorations, + ); + for first in 0..=line.len() { + for second in first..=line.len() { + let mut cursor = line.cursor(); + let mut remainder = line.clone(); + let mut previous_boundary = 0; + let mut total_width = px(0.0); + let mut text = String::new(); + for boundary in [first, second, line.len(), line.len()] { + let (expected, rest) = remainder.split_at(boundary - previous_boundary); + let actual = cursor.take_until(boundary); + assert_eq!(actual.text, expected.text); + assert_eq!(actual.len(), expected.len()); + assert_eq!(actual.width(), expected.width()); + assert_eq!(actual.runs.len(), expected.runs.len()); + for (actual, expected) in actual.runs.iter().zip(&expected.runs) { + assert_eq!(actual.font_id, expected.font_id); + assert_eq!(actual.glyphs.len(), expected.glyphs.len()); + for (actual, expected) in actual.glyphs.iter().zip(&expected.glyphs) { + assert_eq!(actual.id, expected.id); + assert_eq!(actual.index, expected.index); + assert_eq!(actual.position, expected.position); + } + } + assert_eq!(actual.decoration_runs.len(), expected.decoration_runs.len()); + for (actual, expected) in + actual.decoration_runs.iter().zip(&expected.decoration_runs) + { + assert_eq!(actual.len, expected.len); + assert_eq!(actual.color, expected.color); + assert_eq!(actual.background_color, expected.background_color); + } + total_width += actual.width(); + text.push_str(&actual.text); + remainder = rest; + previous_boundary = boundary; + } + assert_eq!(total_width, line.width()); + assert_eq!(text, line.text.as_ref()); + } + } + } + + #[test] + fn test_cursor_empty_chunks_and_repeated_boundaries() { + let line = make_shaped_line("ab", &[(0, 5.0), (1, 15.0)], 20.0, &[]); + let mut cursor = line.cursor(); + assert_eq!(cursor.take_until(0).text.as_ref(), ""); + assert_eq!(cursor.take_until(0).text.as_ref(), ""); + assert_eq!(cursor.take_until(1).text.as_ref(), "a"); + assert_eq!(cursor.take_until(2).text.as_ref(), "b"); + assert_eq!(cursor.take_until(2).text.as_ref(), ""); + let empty = make_shaped_line("", &[], 0.0, &[]); + let piece = empty.cursor().take_until(0); + assert!(piece.text.is_empty()); + assert!(piece.runs.is_empty()); + assert_eq!(piece.width(), px(0.0)); + } + + #[test] + fn test_cursor_rejects_invalid_boundaries() { + let line = make_shaped_line("é", &[(0, 0.0)], 10.0, &[]); + let mut cursor = line.cursor(); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cursor.take_until(1); + })) + .is_err() + ); + let mut cursor = line.cursor(); + cursor.take_until(2); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cursor.take_until(0); + })) + .is_err() + ); + let mut cursor = line.cursor(); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cursor.take_until(3); + })) + .is_err() + ); + } } diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index e7362f71e5..7799b36dc8 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -6,7 +6,10 @@ use std::{ borrow::Borrow, hash::{Hash, Hasher}, ops::Range, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; use super::LineWrapper; @@ -455,6 +458,10 @@ pub(crate) struct LineLayoutCache { previous_frame: Mutex, current_frame: RwLock, platform_text_system: Arc, + /// Advances when [`TextSystem::add_fonts`] successfully changes the font database. + font_generation: Arc, + /// Records the generation represented by both frame caches. + cached_font_generation: AtomicUsize, } #[derive(Default)] @@ -478,6 +485,7 @@ struct FrameCache { #[derive(Clone, Default)] pub(crate) struct LineLayoutIndex { + font_generation: usize, lines_index: usize, wrapped_lines_index: usize, lines_by_hash_index: usize, @@ -485,17 +493,25 @@ pub(crate) struct LineLayoutIndex { } impl LineLayoutCache { - pub fn new(platform_text_system: Arc) -> Self { + pub fn new( + platform_text_system: Arc, + font_generation: Arc, + ) -> Self { + let cached_font_generation = font_generation.load(Ordering::Acquire); Self { previous_frame: Mutex::default(), current_frame: RwLock::default(), platform_text_system, + font_generation, + cached_font_generation: AtomicUsize::new(cached_font_generation), } } pub fn layout_index(&self) -> LineLayoutIndex { + let font_generation = self.clear_if_font_generation_changed(); let frame = self.current_frame.read(); LineLayoutIndex { + font_generation, lines_index: frame.used_lines.len(), wrapped_lines_index: frame.used_wrapped_lines.len(), lines_by_hash_index: frame.used_lines_by_hash.len(), @@ -504,8 +520,14 @@ impl LineLayoutCache { } pub fn reuse_layouts(&self, range: Range) { - let mut previous_frame = &mut *self.previous_frame.lock(); + let font_generation = self.clear_if_font_generation_changed(); + if range.start.font_generation != font_generation + || range.end.font_generation != font_generation + { + return; + } let mut current_frame = &mut *self.current_frame.write(); + let mut previous_frame = &mut *self.previous_frame.lock(); for key in &previous_frame.used_lines[range.start.lines_index..range.end.lines_index] { if let Some((key, line)) = previous_frame.lines.remove_entry(key) { @@ -543,6 +565,10 @@ impl LineLayoutCache { } pub fn truncate_layouts(&self, index: LineLayoutIndex) { + let font_generation = self.clear_if_font_generation_changed(); + if index.font_generation != font_generation { + return; + } let mut current_frame = &mut *self.current_frame.write(); current_frame.used_lines.truncate(index.lines_index); current_frame @@ -557,8 +583,9 @@ impl LineLayoutCache { } pub fn finish_frame(&self) { - let mut prev_frame = self.previous_frame.lock(); + let _font_generation = self.clear_if_font_generation_changed(); let mut curr_frame = self.current_frame.write(); + let mut prev_frame = self.previous_frame.lock(); std::mem::swap(&mut *prev_frame, &mut *curr_frame); curr_frame.lines.clear(); curr_frame.wrapped_lines.clear(); @@ -583,6 +610,7 @@ impl LineLayoutCache { Text: AsRef, SharedString: From, { + let _font_generation = self.clear_if_font_generation_changed(); let key = &CacheKeyRef { text: text.as_ref(), font_size, @@ -647,6 +675,7 @@ impl LineLayoutCache { Text: AsRef, SharedString: From, { + let _font_generation = self.clear_if_font_generation_changed(); let key = &CacheKeyRef { text: text.as_ref(), font_size, @@ -705,6 +734,7 @@ impl LineLayoutCache { runs: &[FontRun], force_width: Option, ) -> Option> { + let _font_generation = self.clear_if_font_generation_changed(); let key_ref = HashedCacheKeyRef { text_hash, text_len, @@ -762,6 +792,7 @@ impl LineLayoutCache { force_width: Option, materialize_text: impl FnOnce() -> SharedString, ) -> Arc { + let _font_generation = self.clear_if_font_generation_changed(); let key_ref = HashedCacheKeyRef { text_hash, text_len, @@ -839,6 +870,24 @@ impl LineLayoutCache { current_frame.used_lines_by_hash.push(key); layout } + + fn clear_if_font_generation_changed(&self) -> usize { + let font_generation = self.font_generation.load(Ordering::Acquire); + if self.cached_font_generation.load(Ordering::Acquire) == font_generation { + return font_generation; + } + + let mut current_frame = self.current_frame.write(); + if self.cached_font_generation.load(Ordering::Acquire) == font_generation { + return font_generation; + } + + *current_frame = FrameCache::default(); + *self.previous_frame.lock() = FrameCache::default(); + self.cached_font_generation + .store(font_generation, Ordering::Release); + font_generation + } } // Combining marks (e.g. Thai vowel signs, Arabic diacritics) are shaped by diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index f8343be29a..f24a4da9b7 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,14 +1,19 @@ use crate::{ - AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId, - Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex, - Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, WeakEntity, + AnyElement, AnyEntity, AnyWeakEntity, App, AvailableSpace, Bounds, ContentMask, Context, + Element, ElementId, Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, + LayoutId, PaintIndex, Pixels, PrepaintStateIndex, Render, RenderOnce, Size, Style, + StyleRefinement, TextStyle, WeakEntity, }; use crate::{Empty, Window}; use anyhow::Result; use collections::FxHashSet; use refineable::Refineable; use std::mem; -use std::{any::TypeId, fmt, ops::Range}; +use std::{ + any::{TypeId, type_name}, + fmt, + ops::Range, +}; /// A dynamically-typed view handle that can be downcast to a specific `Entity`. /// @@ -98,6 +103,11 @@ impl IntoElement for Entity { fn into_element(self) -> Self::Element { ViewElement::new(self) } + + #[inline(never)] + fn into_any_element(self) -> AnyElement { + self.into_element().into_any() + } } impl IntoElement for AnyView { @@ -320,42 +330,23 @@ impl Element for ViewElement { ) -> (LayoutId, Self::RequestLayoutState) { if let Some(entity_id) = self.entity_id { // Stateful path: create a reactive boundary. - window.with_rendered_view(entity_id, |window| { - let caching_disabled = window.is_inspector_picking(cx); - match self.cached_style.as_ref() { - Some(style) if !caching_disabled => { - let mut root_style = Style::default(); - root_style.refine(style); - let layout_id = window.request_layout(root_style, None, cx); - (layout_id, None) - } - _ => { - let mut element = self - .view - .take() - .unwrap() - .render(window, cx) - .into_any_element(); - let layout_id = element.request_layout(window, cx); - (layout_id, Some(element)) - } - } - }) + let view = &mut self.view; + request_layout_view( + entity_id, + self.cached_style.as_ref(), + window, + cx, + &mut |window, cx| view.take().unwrap().render(window, cx).into_any_element(), + ) } else { // Stateless path: isolate subtree via type name (no entity identity). - window.with_id( - ElementId::Name(std::any::type_name::().into()), - |window| { - let mut element = self - .view - .take() - .unwrap() - .render(window, cx) - .into_any_element(); - let layout_id = element.request_layout(window, cx); - (layout_id, Some(element)) - }, - ) + request_layout_component(type_name::(), window, cx, &mut |window, cx| { + self.view + .take() + .unwrap() + .render(window, cx) + .into_any_element() + }) } } @@ -370,78 +361,24 @@ impl Element for ViewElement { ) -> Option { if let Some(entity_id) = self.entity_id { // Stateful path. - window.set_view_id(entity_id); - window.with_rendered_view(entity_id, |window| { - if let Some(mut element) = element.take() { - element.prepaint(window, cx); - return Some(element); - } - - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let content_mask = window.content_mask(); - let text_style = window.text_style(); - - if let Some(mut element_state) = element_state - && element_state.cache_key.bounds == bounds - && element_state.cache_key.content_mask == content_mask - && element_state.cache_key.text_style == text_style - && !window.dirty_views.contains(&entity_id) - && !window.refreshing - { - let prepaint_start = window.prepaint_index(); - window.reuse_prepaint(element_state.prepaint_range.clone()); - cx.entities - .extend_accessed(&element_state.accessed_entities); - let prepaint_end = window.prepaint_index(); - element_state.prepaint_range = prepaint_start..prepaint_end; - - return (None, element_state); - } - - let refreshing = mem::replace(&mut window.refreshing, true); - let prepaint_start = window.prepaint_index(); - let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { - let mut element = self - .view - .take() - .unwrap() - .render(window, cx) - .into_any_element(); - element.layout_as_root(bounds.size.into(), window, cx); - element.prepaint_at(bounds.origin, window, cx); - element - }); - - let prepaint_end = window.prepaint_index(); - window.refreshing = refreshing; - - ( - Some(element), - ViewElementState { - accessed_entities, - prepaint_range: prepaint_start..prepaint_end, - paint_range: PaintIndex::default()..PaintIndex::default(), - cache_key: ViewElementCacheKey { - bounds, - content_mask, - text_style, - }, - }, - ) - }, - ) - }) + prepaint_view( + entity_id, + global_id, + bounds, + element, + window, + cx, + &mut |window, cx| { + self.view + .take() + .unwrap() + .render(window, cx) + .into_any_element() + }, + ) } else { // Stateless path: just prepaint the element. - window.with_id( - ElementId::Name(std::any::type_name::().into()), - |window| { - element.as_mut().unwrap().prepaint(window, cx); - }, - ); - Some(element.take().unwrap()) + prepaint_component(type_name::(), element, window, cx) } } @@ -457,42 +394,17 @@ impl Element for ViewElement { ) { if let Some(entity_id) = self.entity_id { // Stateful path. - window.with_rendered_view(entity_id, |window| { - let caching_disabled = window.is_inspector_picking(cx); - if self.cached_style.is_some() && !caching_disabled { - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let mut element_state = element_state.unwrap(); - - let paint_start = window.paint_index(); - - if let Some(element) = element { - let refreshing = mem::replace(&mut window.refreshing, true); - element.paint(window, cx); - window.refreshing = refreshing; - } else { - window.reuse_paint(element_state.paint_range.clone()); - } - - let paint_end = window.paint_index(); - element_state.paint_range = paint_start..paint_end; - - ((), element_state) - }, - ) - } else { - element.as_mut().unwrap().paint(window, cx); - } - }); + paint_view( + entity_id, + self.cached_style.is_some(), + global_id, + element, + window, + cx, + ); } else { // Stateless path: just paint the element. - window.with_id( - ElementId::Name(std::any::type_name::().into()), - |window| { - element.as_mut().unwrap().paint(window, cx); - }, - ); + paint_component(std::any::type_name::(), element, window, cx); } } } @@ -505,3 +417,177 @@ impl Render for EmptyView { Empty } } + +#[inline(never)] +fn request_layout_view( + entity_id: EntityId, + cached_style: Option<&StyleRefinement>, + window: &mut Window, + cx: &mut App, + render: &mut dyn FnMut(&mut Window, &mut App) -> AnyElement, +) -> (LayoutId, Option) { + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + match cached_style { + Some(style) if !caching_disabled => { + let mut root_style = Style::default(); + root_style.refine(style); + let layout_id = window.request_layout(root_style, None, cx); + (layout_id, None) + } + _ => { + let mut element = render(window, cx); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + } + } + }) +} + +#[inline(never)] +fn request_layout_component( + name: &'static str, + window: &mut Window, + cx: &mut App, + render: &mut dyn FnMut(&mut Window, &mut App) -> AnyElement, +) -> (LayoutId, Option) { + window.with_id(ElementId::from(name), |window| { + let mut element = render(window, cx); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + }) +} + +#[inline(never)] +fn prepaint_view( + entity_id: EntityId, + global_id: Option<&GlobalElementId>, + bounds: Bounds, + element: &mut Option, + window: &mut Window, + cx: &mut App, + render: &mut dyn FnMut(&mut Window, &mut App) -> AnyElement, +) -> Option { + window.set_view_id(entity_id); + window.with_rendered_view(entity_id, |window| { + if let Some(mut element) = element.take() { + element.prepaint(window, cx); + return Some(element); + } + + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let content_mask = window.content_mask(); + let text_style = window.text_style(); + + if let Some(mut element_state) = element_state + && element_state.cache_key.bounds == bounds + && element_state.cache_key.content_mask == content_mask + && element_state.cache_key.text_style == text_style + && !window.dirty_views.contains(&entity_id) + && !window.refreshing + { + let prepaint_start = window.prepaint_index(); + window.reuse_prepaint(element_state.prepaint_range.clone()); + cx.entities + .extend_accessed(&element_state.accessed_entities); + let prepaint_end = window.prepaint_index(); + element_state.prepaint_range = prepaint_start..prepaint_end; + + return (None, element_state); + } + + let refreshing = mem::replace(&mut window.refreshing, true); + let prepaint_start = window.prepaint_index(); + let (element, accessed_entities) = cx.detect_accessed_entities(|cx| { + let mut element = render(window, cx); + element.layout_as_root(Size::::from(bounds.size), window, cx); + element.prepaint_at(bounds.origin, window, cx); + element + }); + + let prepaint_end = window.prepaint_index(); + window.refreshing = refreshing; + + ( + Some(element), + ViewElementState { + accessed_entities, + prepaint_range: prepaint_start..prepaint_end, + paint_range: PaintIndex::default()..PaintIndex::default(), + cache_key: ViewElementCacheKey { + bounds, + content_mask, + text_style, + }, + }, + ) + }, + ) + }) +} + +#[inline(never)] +fn prepaint_component( + name: &'static str, + element: &mut Option, + window: &mut Window, + cx: &mut App, +) -> Option { + window.with_id(ElementId::from(name), |window| { + element.as_mut().unwrap().prepaint(window, cx); + }); + Some(element.take().unwrap()) +} + +#[inline(never)] +fn paint_view( + entity_id: EntityId, + cached: bool, + global_id: Option<&GlobalElementId>, + element: &mut Option, + window: &mut Window, + cx: &mut App, +) { + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + if cached && !caching_disabled { + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let mut element_state = element_state.unwrap(); + + let paint_start = window.paint_index(); + + if let Some(element) = element { + let refreshing = mem::replace(&mut window.refreshing, true); + element.paint(window, cx); + window.refreshing = refreshing; + } else { + window.reuse_paint(element_state.paint_range.clone()); + } + + let paint_end = window.paint_index(); + element_state.paint_range = paint_start..paint_end; + + ((), element_state) + }, + ) + } else { + element.as_mut().unwrap().paint(window, cx); + } + }); +} + +#[inline(never)] +fn paint_component( + name: &'static str, + element: &mut Option, + window: &mut Window, + cx: &mut App, +) { + window.with_id(ElementId::Name(name.into()), |window| { + element.as_mut().unwrap().paint(window, cx); + }); +} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 63b11d95b5..636cb6da21 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -19,10 +19,10 @@ use crate::{ SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextInputConfiguration, - TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, - Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, - prelude::*, px, rems, size, transparent_black, + TextInputStateChange, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, + TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, + WindowVisibility, point, prelude::*, px, rems, size, transparent_black, }; use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}; @@ -32,7 +32,6 @@ use collections::{FxHashMap, FxHashSet}; #[cfg(target_os = "macos")] use core_video::pixel_buffer::CVPixelBuffer; use derive_more::{Deref, DerefMut}; -use futures::FutureExt; use futures::channel::oneshot; use gpui_util::post_inc; use gpui_util::{ResultExt, measure}; @@ -59,6 +58,7 @@ use std::{ Arc, Weak, atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, }, + thread::AccessError, time::Duration, }; use uuid::Uuid; @@ -355,17 +355,16 @@ fn draw_in_progress() -> bool { /// Allocates an element in the current arena. Uses the app-specific arena if one /// is active (during draw), otherwise falls back to the thread-local ELEMENT_ARENA. -pub(crate) fn with_element_arena(f: impl FnOnce(&mut Arena) -> R) -> R { - CURRENT_ELEMENT_ARENA.with(|current| { - if let Some(arena_ptr) = current.get() { - // SAFETY: The pointer is valid for the duration of the draw operation - // that set it, and we're being called during that same draw. - let arena_cell = unsafe { &*arena_ptr }; - f(&mut arena_cell.borrow_mut()) - } else { - ELEMENT_ARENA.with_borrow_mut(f) - } - }) +#[inline(always)] +pub(crate) fn with_element_arena(callback: impl FnOnce(&mut Arena) -> R) -> R { + let mut callback = Some(callback); + let mut result = None; + let access = with_element_arena_erased(&mut |arena| { + result = Some(callback.take().expect("arena callback runs once")(arena)); + }); + drop(callback); + access.expect("cannot access a Thread Local Storage value during or after destruction"); + result.expect("arena callback produces a result") } /// Scope guard that sets CURRENT_ELEMENT_ARENA for the duration of a draw @@ -853,6 +852,17 @@ impl Hitbox { self.id.is_hovered(window) } + /// Checks whether this hitbox would be hovered at `position`, regardless of the current input + /// modality or mouse position. + pub fn is_hovered_at(&self, position: Point, window: &Window) -> bool { + let hit_test = window.rendered_frame.hit_test(position); + hit_test + .ids + .iter() + .take(hit_test.hover_hitbox_count) + .any(|id| self.id == *id) + } + /// Checks if the hitbox contains the mouse and should handle scroll events. Typically this /// should only be used when handling `ScrollWheelEvent`, and otherwise `is_hovered` should be /// used. See the documentation of `Hitbox::is_hovered` for details about this distinction. @@ -981,6 +991,8 @@ pub(crate) struct Frame { pub(crate) cursor_styles: Vec, #[cfg(any(test, feature = "test-support"))] pub(crate) debug_bounds: FxHashMap>, + #[cfg(any(test, feature = "test-support"))] + debug_bounds_records: Vec<(String, Bounds)>, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) next_inspector_instance_ids: FxHashMap, usize>, #[cfg(any(feature = "inspector", debug_assertions))] @@ -1001,6 +1013,8 @@ pub(crate) struct PrepaintStateIndex { #[derive(Clone, Default)] pub(crate) struct PaintIndex { scene_index: usize, + #[cfg(any(test, feature = "test-support"))] + debug_bounds_index: usize, mouse_listeners_index: usize, input_handlers_index: usize, cursor_styles_index: usize, @@ -1010,6 +1024,12 @@ pub(crate) struct PaintIndex { } impl Frame { + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn record_debug_bounds(&mut self, selector: String, bounds: Bounds) { + self.debug_bounds.insert(selector.clone(), bounds); + self.debug_bounds_records.push((selector, bounds)); + } + pub(crate) fn new(dispatch_tree: DispatchTree) -> Self { Frame { focus: None, @@ -1028,6 +1048,8 @@ impl Frame { #[cfg(any(test, feature = "test-support"))] debug_bounds: FxHashMap::default(), + #[cfg(any(test, feature = "test-support"))] + debug_bounds_records: Vec::new(), #[cfg(any(feature = "inspector", debug_assertions))] next_inspector_instance_ids: FxHashMap::default(), @@ -1056,6 +1078,7 @@ impl Frame { #[cfg(any(test, feature = "test-support"))] { self.debug_bounds.clear(); + self.debug_bounds_records.clear(); } #[cfg(any(feature = "inspector", debug_assertions))] @@ -1162,6 +1185,7 @@ pub struct Window { /// window, so that only actual changes are forwarded (reconfiguring a live /// input session can restart the IME connection). last_text_input_configuration: Option, + focused_text_input_active: bool, pub(crate) image_cache_stack: Vec, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, @@ -1184,6 +1208,9 @@ pub struct Window { pub(crate) appearance_observers: SubscriberSet<(), AnyObserver>, pub(crate) button_layout_observers: SubscriberSet<(), AnyObserver>, active: Rc>, + visibility: WindowVisibility, + pub(crate) visibility_observers: + SubscriberSet<(), Box bool>>, hovered: Rc>, pub(crate) needs_present: Rc>, /// Tracks recent input event timestamps to determine if input is arriving at a high rate. @@ -1277,12 +1304,139 @@ pub(crate) enum DrawPhase { Focus, } +pub(crate) const PENDING_INPUT_TIMEOUT: Duration = Duration::from_secs(1); + +/// Pending input for a potential multi-stroke key binding. +pub struct PendingInputStatus<'a> { + keystrokes: &'a [Keystroke], + timeout: Option, +} + +impl<'a> PendingInputStatus<'a> { + /// Returns the keystrokes entered so far. + pub fn keystrokes(&self) -> &'a [Keystroke] { + self.keystrokes + } + + /// Returns the timeout state for flushing this input, if it needs a timeout. + pub fn timeout(&self) -> Option { + self.timeout + } +} + +/// The timeout state for pending input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PendingInputTimeoutStatus { + duration: Duration, + remaining: Duration, + started_at: Option, + paused: bool, +} + +impl PendingInputTimeoutStatus { + /// Returns the full timeout duration. + pub fn duration(&self) -> Duration { + self.duration + } + + /// Returns the duration remaining before pending input is flushed. + pub fn remaining(&self, cx: &App) -> Duration { + self.started_at + .map(|started_at| { + self.remaining + .saturating_sub(cx.background_executor().now() - started_at) + }) + .unwrap_or(self.remaining) + } + + /// Returns whether the timeout is paused. + pub fn is_paused(&self) -> bool { + self.paused + } +} + +#[derive(Debug)] +struct PendingInputTimeout { + duration: Duration, + remaining: Duration, + state: PendingInputTimeoutState, +} + +#[derive(Debug)] +enum PendingInputTimeoutState { + Running { started_at: Instant, task: Task<()> }, + Paused { pause: PendingInputTimeoutPause }, +} + +#[derive(Debug)] +struct PendingInputTimeoutPause { + owner_id: EntityId, + _release_subscription: Subscription, +} + +impl PendingInputTimeout { + fn is_paused(&self) -> bool { + matches!(&self.state, PendingInputTimeoutState::Paused { .. }) + } + + fn pause(&mut self, pause: PendingInputTimeoutPause, now: Instant) -> bool { + match std::mem::replace(&mut self.state, PendingInputTimeoutState::Paused { pause }) { + PendingInputTimeoutState::Running { started_at, task } => { + self.remaining = self.remaining.saturating_sub(now - started_at); + drop(task); + true + } + previous_state @ PendingInputTimeoutState::Paused { .. } => { + self.state = previous_state; + false + } + } + } + + fn pause_owner_id(&self) -> Option { + match &self.state { + PendingInputTimeoutState::Running { .. } => None, + PendingInputTimeoutState::Paused { pause } => Some(pause.owner_id), + } + } + + fn resume(&mut self, owner_id: EntityId, started_at: Instant, task: Task<()>) -> bool { + match std::mem::replace( + &mut self.state, + PendingInputTimeoutState::Running { started_at, task }, + ) { + PendingInputTimeoutState::Paused { pause } if pause.owner_id == owner_id => true, + previous_state => { + self.state = previous_state; + false + } + } + } + + fn reset_duration(&mut self, duration: Duration) { + self.duration = duration; + self.remaining = duration; + } + + fn status(&self) -> PendingInputTimeoutStatus { + let (started_at, paused) = match &self.state { + PendingInputTimeoutState::Running { started_at, .. } => (Some(*started_at), false), + PendingInputTimeoutState::Paused { .. } => (None, true), + }; + PendingInputTimeoutStatus { + duration: self.duration, + remaining: self.remaining, + started_at, + paused, + } + } +} + #[derive(Default, Debug)] struct PendingInput { keystrokes: SmallVec<[Keystroke; 1]>, focus: Option, - timer: Option>, - needs_timeout: bool, + timeout: Option, } pub(crate) struct ElementStateBox { @@ -1430,6 +1584,9 @@ impl Window { let text_system = Arc::new(WindowTextSystem::new(cx.text_system().clone())); let invalidator = WindowInvalidator::new(handle.window_id()); let active = Rc::new(Cell::new(platform_window.is_active())); + let visibility = platform_window.visibility(); + #[cfg(feature = "profiler")] + profiler::journal::record_window_visibility(handle.window_id(), visibility); let hovered = Rc::new(Cell::new(platform_window.is_hovered())); let needs_present = Rc::new(Cell::new(false)); let next_frame_callbacks: Rc>> = Default::default(); @@ -1680,6 +1837,22 @@ impl Window { } })); invalidator.set_platform_waker(platform_window.frame_waker()); + platform_window.on_visual_viewport_changed(Box::new({ + let mut cx = cx.to_async(); + move || { + handle + .update(&mut cx, |_, window, _| window.refresh()) + .log_err(); + } + })); + platform_window.on_insets_changed(Box::new({ + let mut cx = cx.to_async(); + move |_| { + handle + .update(&mut cx, |_, window, _| window.refresh()) + .log_err(); + } + })); platform_window.on_resize(Box::new({ let mut cx = cx.to_async(); move |_, _| { @@ -1741,6 +1914,16 @@ impl Window { .log_err(); } })); + platform_window.on_visibility_change(Box::new({ + let mut cx = cx.to_async(); + move |_| { + handle + .update(&mut cx, |_, window, cx| { + window.refresh_visibility(cx); + }) + .log_err(); + } + })); platform_window.on_hover_status_change(Box::new({ let mut cx = cx.to_async(); move |active| { @@ -1859,6 +2042,7 @@ impl Window { element_opacity: 1.0, requested_autoscroll: None, last_text_input_configuration: None, + focused_text_input_active: false, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, @@ -1880,6 +2064,8 @@ impl Window { appearance_observers: SubscriberSet::new(), button_layout_observers: SubscriberSet::new(), active, + visibility, + visibility_observers: SubscriberSet::new(), hovered, needs_present, input_rate_tracker, @@ -1973,6 +2159,53 @@ impl Window { } } + pub(crate) fn refresh_visibility(&mut self, cx: &mut App) { + let visibility = self.platform_window.visibility(); + if self.visibility != visibility { + self.visibility = visibility; + #[cfg(feature = "profiler")] + profiler::journal::record_window_visibility(self.handle.window_id(), visibility); + self.visibility_observers + .clone() + .retain(&(), |callback| callback(visibility, self, cx)); + } + #[cfg(feature = "profiler")] + if self.invalidator.is_dirty() || self.needs_present.get() { + profiler::journal::record_frame_pending(self.handle.window_id(), Instant::now()); + } + } + + /// Whether the platform is presenting this window's frames (see + /// [`WindowVisibility`]). + pub fn visibility(&self) -> WindowVisibility { + self.visibility + } + + /// Whether frames drawn for this window will be shown. + /// + /// This is not the window's shown/hidden state: a shown window that is + /// fully behind another window, minimized, or on a sleeping display is not + /// visible here. + pub fn is_visible(&self) -> bool { + self.visibility.is_visible() + } + + /// Registers a callback to be invoked when the window's visibility changes. + pub fn observe_window_visibility( + &self, + mut callback: impl FnMut(WindowVisibility, &mut Window, &mut App) + 'static, + ) -> Subscription { + let (subscription, activate) = self.visibility_observers.insert( + (), + Box::new(move |visibility, window, cx| { + callback(visibility, window, cx); + true + }), + ); + activate(); + subscription + } + /// Registers a callback to be invoked when the window appearance changes. pub fn observe_window_appearance( &self, @@ -2193,6 +2426,18 @@ impl Window { self.platform_window.inner_window_bounds() } + /// Encode the window's native restorable state into an opaque blob. + /// Returns `None` on platforms without native state restoration or if encoding fails. + pub fn native_window_state(&self) -> Option> { + self.platform_window.native_window_state() + } + + /// Restore the window's native state from a blob previously produced + /// by [`Window::native_window_state`]. A no-op on platforms without native state restoration. + pub fn restore_native_window_state(&self, state: &[u8]) { + self.platform_window.restore_native_window_state(state); + } + /// Dispatch the given action on the currently focused element. pub fn dispatch_action(&mut self, action: Box, cx: &mut App) { let focus_id = self.focused(cx).map(|handle| handle.id); @@ -2479,6 +2724,12 @@ impl Window { self.rendered_frame.scene.quads.clone() } + /// Returns the underlines in the most recently rendered frame's scene. + #[cfg(any(test, feature = "test-support"))] + pub fn painted_underlines(&self) -> Vec { + self.rendered_frame.scene.underlines.clone() + } + /// Set the content size of the window. pub fn resize(&mut self, size: Size) { self.platform_window.resize(size); @@ -2520,6 +2771,46 @@ impl Window { self.viewport_size } + /// Returns the platform's visible viewport in window-local logical pixels. + /// + /// Unlike `viewport_size`, this can shrink or move when the keyboard opens. + /// During drawing this is a consistent frame snapshot. Outside drawing it + /// reflects the latest platform sample, not a synchronous geometry query. + pub fn visual_viewport_bounds(&self) -> Bounds { + self.platform_window.visual_viewport_bounds() + } + + /// Returns a conservative rectangle avoiding platform-known obscured content. + /// + /// Intersects the visual viewport with the full layout area inset by system + /// safe areas and keyboard occlusion. Unknown overlays cannot be excluded. + pub fn fully_visible_bounds(&self) -> Bounds { + let insets = self.platform_window.insets().effective(); + let viewport = self.viewport_size(); + let left = insets.left.max(Pixels::ZERO).min(viewport.width); + let top = insets.top.max(Pixels::ZERO).min(viewport.height); + let right = (viewport.width - insets.right.max(Pixels::ZERO)).max(left); + let bottom = (viewport.height - insets.bottom.max(Pixels::ZERO)).max(top); + let safe_bounds = Bounds::from_corners(point(left, top), point(right, bottom)); + let mut visible = safe_bounds.intersect(&self.visual_viewport_bounds()); + visible.size.width = visible.size.width.max(Pixels::ZERO); + visible.size.height = visible.size.height.max(Pixels::ZERO); + visible + } + + /// Requests the virtual keyboard for the currently focused text input. + /// + /// Call from a user gesture on platforms that require one. The platform may + /// decline the request; this does not change focus or the layout viewport. + pub fn request_virtual_keyboard(&self) { + self.platform_window.show_soft_keyboard(); + } + + /// Requests dismissal of the virtual keyboard without changing GPUI focus. + pub fn dismiss_virtual_keyboard(&self) { + self.platform_window.hide_soft_keyboard(); + } + /// Returns whether this window is focused by the operating system (receiving key events). pub fn is_window_active(&self) -> bool { self.active.get() @@ -2744,6 +3035,98 @@ impl Window { position.map(|c| self.pixel_snap(c)) } + /// Returns the snapped device-space bounds used to paint an underline. + pub fn underline_bounds( + &self, + origin: Point, + width: Pixels, + style: &UnderlineStyle, + ) -> Bounds { + let scale_factor = self.scale_factor(); + let thickness = self.snap_stroke(style.thickness); + let height = if style.wavy { + ScaledPixels(thickness.0 * 3.) + } else { + thickness + }; + Bounds { + origin: origin.map(|c| ScaledPixels(round_to_device_pixel(c.0, scale_factor))), + size: size(self.snap_stroke(width), height), + } + } + + /// Paints an underline excluding absolute device-space horizontal spans. + pub fn paint_underline_with_exclusions( + &mut self, + origin: Point, + width: Pixels, + style: &UnderlineStyle, + exclusions: &[Range], + ) { + self.invalidator.debug_assert_paint(); + let underline = self.underline(origin, width, style); + if exclusions.is_empty() { + self.next_frame.scene.insert_primitive(underline); + return; + } + + let bounds = underline.bounds.intersect(&underline.content_mask.bounds); + if bounds.is_empty() { + return; + } + + let mut exclusions = exclusions + .iter() + .filter_map(|span| { + let start = span.start.max(bounds.left()); + let end = span.end.min(bounds.right()); + (start < end).then_some(start..end) + }) + .collect::>(); + if exclusions.is_empty() { + self.next_frame.scene.insert_primitive(underline); + return; + } + exclusions.sort_unstable_by_key(|span| span.start); + + let mut paint_span = |start, end| { + self.next_frame.scene.insert_primitive(Underline { + content_mask: ContentMask { + bounds: Bounds::from_corners( + point(start, bounds.top()), + point(end, bounds.bottom()), + ), + }, + ..underline + }); + }; + let mut start = bounds.left(); + for exclusion in exclusions { + if start < exclusion.start { + paint_span(start, exclusion.start); + } + start = start.max(exclusion.end); + } + if start < bounds.right() { + paint_span(start, bounds.right()); + } + } + + fn underline(&self, origin: Point, width: Pixels, style: &UnderlineStyle) -> Underline { + Underline { + order: 0, + pad: 0, + bounds: self.underline_bounds(origin, width, style), + content_mask: self.snapped_content_mask(), + color: style + .color + .unwrap_or_default() + .opacity(self.element_opacity()), + thickness: self.snap_stroke(style.thickness), + wavy: style.wavy.into(), + } + } + #[inline] fn snap_bounds(&self, bounds: Bounds) -> Bounds { let scale_factor = self.scale_factor(); @@ -2891,6 +3274,9 @@ impl Window { // This ensures that multiple test Apps have isolated arenas. let arena_scope = ElementArenaScope::enter(&cx.element_arena); + if self.platform_window.prepare_frame() { + self.refresh(); + } self.invalidate_entities(); cx.entities.clear_accessed(); debug_assert!(self.rendered_entity_stack.is_empty()); @@ -2935,16 +3321,29 @@ impl Window { // paint_range indices remain valid for reuse_paint on the next frame. // Search backwards to find the last Some entry, since reuse_paint may // have copied None slots from the previous frame. (Fixes #50456) - if let Some(input_handler) = self + let focused_text_input_active = if let Some(mut input_handler) = self .next_frame .input_handlers .iter_mut() .rev() .find_map(|h| h.take()) { + let accepts_text_input = input_handler.accepts_text_input(self, cx); self.platform_window.set_input_handler(input_handler); - } + accepts_text_input + } else { + false + }; self.apply_text_input_configuration(cx); + if focused_text_input_active != self.focused_text_input_active { + self.focused_text_input_active = focused_text_input_active; + self.platform_window + .text_input_state_changed(if focused_text_input_active { + TextInputStateChange::FocusGained + } else { + TextInputStateChange::FocusLost + }); + } self.layout_engine.as_mut().unwrap().clear(); self.text_system().finish_frame(); @@ -3060,11 +3459,7 @@ impl Window { } /// Presents the most recently drawn frame if it hasn't been presented yet. - /// - /// Benchmarks drive drawing synchronously rather than through a platform - /// frame-request loop, so they call this after each measured update to - /// submit the frame like production presentation would. - #[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] + #[cfg(all(test, feature = "profiler"))] pub fn present_if_needed(&mut self) { if self.needs_present.get() { self.present(); @@ -3478,6 +3873,8 @@ impl Window { pub(crate) fn paint_index(&self) -> PaintIndex { PaintIndex { scene_index: self.next_frame.scene.len(), + #[cfg(any(test, feature = "test-support"))] + debug_bounds_index: self.next_frame.debug_bounds_records.len(), mouse_listeners_index: self.next_frame.mouse_listeners.len(), input_handlers_index: self.next_frame.input_handlers.len(), cursor_styles_index: self.next_frame.cursor_styles.len(), @@ -3488,6 +3885,14 @@ impl Window { } pub(crate) fn reuse_paint(&mut self, range: Range) { + // Cached elements still exist in the frame even when their paint methods don't run. + #[cfg(any(test, feature = "test-support"))] + for (selector, bounds) in &self.rendered_frame.debug_bounds_records + [range.start.debug_bounds_index..range.end.debug_bounds_index] + { + self.next_frame + .record_debug_bounds(selector.clone(), *bounds); + } self.next_frame.cursor_styles.extend( self.rendered_frame.cursor_styles [range.start.cursor_styles_index..range.end.cursor_styles_index] @@ -3699,25 +4104,7 @@ impl Window { /// Note that the multiple calls to this method will only result in one `Asset::load` call at a /// time. pub fn use_asset(&mut self, source: &A::Source, cx: &mut App) -> Option { - let (task, is_first) = cx.fetch_asset::(source); - task.clone().now_or_never().or_else(|| { - if is_first { - let entity_id = self.current_view(); - self.spawn(cx, { - let task = task.clone(); - async move |cx| { - task.await; - - cx.on_next_frame(move |_, cx| { - cx.notify(entity_id); - }); - } - }) - .detach(); - } - - None - }) + cx.asset_entry::(source).use_by(self.current_view()) } /// Asynchronously load an asset, if the asset hasn't finished loading or doesn't exist this will return None. @@ -3726,8 +4113,7 @@ impl Window { /// Note that the multiple calls to this method will only result in one `Asset::load` call at a /// time. pub fn get_asset(&mut self, source: &A::Source, cx: &mut App) -> Option { - let (task, _) = cx.fetch_asset::(source); - task.now_or_never() + cx.fetch_asset::(source) } /// Obtain the current element offset. This method should only be called during the /// prepaint phase of element drawing. @@ -3788,10 +4174,7 @@ impl Window { (state.clone(), state) } else { let new_state = cx.new(|cx| init(window, cx)); - cx.observe(&new_state, move |_, cx| { - cx.notify(current_view); - }) - .detach(); + Self::observe_keyed_state(&new_state, current_view, cx); (new_state.clone(), new_state) } }) @@ -3820,6 +4203,7 @@ impl Window { /// frames. If an element with this ID existed in the rendered frame, its state will be passed /// to the given closure. The state returned by the closure will be stored so it can be referenced /// when drawing the next frame. This method should only be called as part of element drawing. + #[inline(always)] pub fn with_element_state( &mut self, global_id: &GlobalElementId, @@ -3830,15 +4214,9 @@ impl Window { { self.invalidator.debug_assert_paint_or_prepaint(); - let key = (global_id.clone(), TypeId::of::()); - self.next_frame.accessed_element_states.push(key.clone()); + let (key, state) = self.take_element_state(global_id, TypeId::of::()); - if let Some(any) = self - .next_frame - .element_states - .remove(&key) - .or_else(|| self.rendered_frame.element_states.remove(&key)) - { + if let Some(any) = state { let ElementStateBox { inner, #[cfg(debug_assertions)] @@ -3872,7 +4250,7 @@ impl Window { ); let (result, state) = f(Some(state), self); state_box.replace(state); - self.next_frame.element_states.insert( + self.insert_element_state( key, ElementStateBox { inner: state_box, @@ -3883,7 +4261,7 @@ impl Window { result } else { let (result, state) = f(None, self); - self.next_frame.element_states.insert( + self.insert_element_state( key, ElementStateBox { inner: Box::new(Some(state)), @@ -4224,29 +4602,8 @@ impl Window { style: &UnderlineStyle, ) { self.invalidator.debug_assert_paint(); - - let scale_factor = self.scale_factor(); - let thickness = self.snap_stroke(style.thickness); - let height = if style.wavy { - ScaledPixels(thickness.0 * 3.) - } else { - thickness - }; - let bounds = Bounds { - origin: origin.map(|c| ScaledPixels(round_to_device_pixel(c.0, scale_factor))), - size: size(self.snap_stroke(width), height), - }; - let element_opacity = self.element_opacity(); - - self.next_frame.scene.insert_primitive(Underline { - order: 0, - pad: 0, - bounds, - content_mask: self.snapped_content_mask(), - color: style.color.unwrap_or_default().opacity(element_opacity), - thickness, - wavy: style.wavy.into(), - }); + let underline = self.underline(origin, width, style); + self.next_frame.scene.insert_primitive(underline); } /// Paint a strikethrough into the scene for the next frame at the current z-index. @@ -4329,7 +4686,7 @@ impl Window { if !raster_bounds.is_zero() { let tile = self .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { + .get_or_insert_with(params.clone().into(), &mut || { let (size, bytes) = self.text_system().rasterize_glyph(¶ms)?; Ok(Some((size, Cow::Owned(bytes)))) })? @@ -4419,7 +4776,7 @@ impl Window { if !raster_bounds.is_zero() { let tile = self .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { + .get_or_insert_with(params.clone().into(), &mut || { let (size, bytes) = self.text_system().rasterize_glyph(¶ms)?; Ok(Some((size, Cow::Owned(bytes)))) })? @@ -4472,7 +4829,7 @@ impl Window { let Some(tile) = self.sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { + .get_or_insert_with(params.clone().into(), &mut || { let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(¶ms, data)? else { return Ok(None); @@ -4545,7 +4902,7 @@ impl Window { let tile = self .sprite_atlas - .get_or_insert_with(¶ms.into(), &mut || { + .get_or_insert_with(params.into(), &mut || { Ok(Some(( data.size(frame_index), Cow::Borrowed( @@ -5182,6 +5539,10 @@ impl Window { } PlatformInput::LongPress(long_press) } + PlatformInput::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + PlatformInput::TouchDrag(touch_drag) + } PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; @@ -5274,6 +5635,11 @@ impl Window { event.predicted_position = None; } let recognized_gestures = self.touch_gestures.handle_event(&event); + if event.phase == crate::TouchPhase::Started + && let Some(touch_drag) = self.touch_gestures.offer_touch_drag(event.id) + { + self.dispatch_recognized_touch_gesture(touch_drag, cx); + } if event.phase == crate::TouchPhase::Started && self.touch_gestures.pending_long_press().is_some() { @@ -5316,6 +5682,17 @@ impl Window { cx.propagate_event = true; self.dispatch_mouse_event(&up, cx); } + RecognizedTouchGesture::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + cx.propagate_event = true; + self.default_prevented = false; + let started = touch_drag.phase == crate::TouchPhase::Started; + self.dispatch_mouse_event(&touch_drag, cx); + if started { + self.touch_gestures + .resolve_touch_drag(self.default_prevented); + } + } RecognizedTouchGesture::LongPress(long_press) => { self.mouse_position = if long_press.phase == crate::TouchPhase::Started { long_press.start_position @@ -5512,7 +5889,7 @@ impl Window { } if !match_result.pending.is_empty() { - currently_pending.timer.take(); + let previous_timeout = currently_pending.timeout.take(); currently_pending.keystrokes = match_result.pending; currently_pending.focus = self.focus; @@ -5526,38 +5903,23 @@ impl Window { accepts }); - currently_pending.needs_timeout |= - match_result.pending_has_binding || text_input_requires_timeout; - - if currently_pending.needs_timeout { - currently_pending.timer = Some(self.spawn(cx, async move |cx| { - cx.background_executor.timer(Duration::from_secs(1)).await; - cx.update(move |window, cx| { - let Some(currently_pending) = window - .pending_input - .take() - .filter(|pending| pending.focus == window.focus) - else { - return; - }; - - let node_id = window.focus_node_id_in_rendered_frame(window.focus); - let dispatch_path = - window.rendered_frame.dispatch_tree.dispatch_path(node_id); - - let to_replay = window - .rendered_frame - .dispatch_tree - .flush_dispatch(currently_pending.keystrokes, &dispatch_path); - - window.pending_input_changed(cx); - window.replay_pending_input(to_replay, cx) - }) - .log_err(); - })); + let needs_timeout = previous_timeout.is_some() + || match_result.pending_has_binding + || text_input_requires_timeout; + currently_pending.timeout = if needs_timeout { + match previous_timeout { + Some(mut timeout) if timeout.is_paused() => { + timeout.reset_duration(PENDING_INPUT_TIMEOUT); + Some(timeout) + } + previous_timeout => { + drop(previous_timeout); + Some(self.new_pending_input_timeout(PENDING_INPUT_TIMEOUT, cx)) + } + } } else { - currently_pending.timer = None; - } + None + }; self.pending_input = Some(currently_pending); self.pending_input_changed(cx); cx.propagate_event = false; @@ -5600,6 +5962,44 @@ impl Window { self.pending_input_changed(cx); } + fn new_pending_input_timeout(&self, duration: Duration, cx: &App) -> PendingInputTimeout { + let (started_at, task) = self.start_pending_input_timeout(duration, cx); + PendingInputTimeout { + duration, + remaining: duration, + state: PendingInputTimeoutState::Running { started_at, task }, + } + } + + fn start_pending_input_timeout(&self, remaining: Duration, cx: &App) -> (Instant, Task<()>) { + let started_at = cx.background_executor().now(); + let task = self.spawn(cx, async move |cx| { + cx.background_executor.timer(remaining).await; + cx.update(move |window, cx| { + let Some(currently_pending) = window + .pending_input + .take() + .filter(|pending| pending.focus == window.focus) + else { + return; + }; + + let node_id = window.focus_node_id_in_rendered_frame(window.focus); + let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id); + + let to_replay = window + .rendered_frame + .dispatch_tree + .flush_dispatch(currently_pending.keystrokes, &dispatch_path); + + window.pending_input_changed(cx); + window.replay_pending_input(to_replay, cx) + }) + .log_err(); + }); + (started_at, task) + } + fn finish_dispatch_key_event( &mut self, event: &dyn Any, @@ -5690,17 +6090,9 @@ impl Window { } } - /// Pending input that can still complete a binding. Input left over from a previous focus can - /// never complete one. - fn active_pending_input(&self) -> Option<&PendingInput> { - self.pending_input - .as_ref() - .filter(|pending_input| pending_input.focus == self.focus) - } - /// Determine whether a potential multi-stroke key binding is in progress on this window. pub fn has_pending_keystrokes(&self) -> bool { - self.active_pending_input().is_some() + self.pending_input().is_some() } #[cfg(test)] @@ -5714,10 +6106,101 @@ impl Window { } } + /// Returns pending input that can still complete a multi-stroke key binding. Input left over + /// from a previous focus can never complete one. + pub fn pending_input(&self) -> Option> { + self.pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .map(|pending_input| PendingInputStatus { + keystrokes: pending_input.keystrokes.as_slice(), + timeout: pending_input + .timeout + .as_ref() + .map(PendingInputTimeout::status), + }) + } + + /// Pauses or resumes the current pending input timeout on behalf of `owner`. + /// + /// A paused timeout resumes automatically if `owner` is released. Returns whether the timeout + /// state changed. A timeout paused by one owner cannot be resumed by another. + pub fn set_pending_input_timeout_paused( + &mut self, + owner: &Entity, + paused: bool, + cx: &mut App, + ) -> bool { + let owner_id = owner.entity_id(); + if !paused { + return self.resume_pending_input_timeout(owner_id, cx); + } + + let timeout = self + .pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_ref()); + let Some(timeout) = timeout else { + return false; + }; + if timeout.is_paused() { + return false; + } + + let release_subscription = self.observe_release(owner, cx, move |_, window, cx| { + window.resume_pending_input_timeout(owner_id, cx); + }); + let now = cx.background_executor().now(); + let changed = self + .pending_input + .as_mut() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| { + timeout.pause( + PendingInputTimeoutPause { + owner_id, + _release_subscription: release_subscription, + }, + now, + ) + }); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + + fn resume_pending_input_timeout(&mut self, owner_id: EntityId, cx: &mut App) -> bool { + let Some(remaining) = self + .pending_input + .as_ref() + .and_then(|pending_input| pending_input.timeout.as_ref()) + .filter(|timeout| timeout.pause_owner_id() == Some(owner_id)) + .map(|timeout| timeout.remaining) + else { + return false; + }; + + let (started_at, task) = self.start_pending_input_timeout(remaining, cx); + let changed = self + .pending_input + .as_mut() + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| timeout.resume(owner_id, started_at, task)); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + /// Returns the currently pending input keystrokes that might result in a multi-stroke key binding. pub fn pending_input_keystrokes(&self) -> Option<&[Keystroke]> { - self.active_pending_input() - .map(|pending_input| pending_input.keystrokes.as_slice()) + self.pending_input() + .map(|pending_input| pending_input.keystrokes()) } fn replay_pending_input(&mut self, replays: SmallVec<[Replay; 1]>, cx: &mut App) { @@ -6386,7 +6869,13 @@ impl Window { pub fn toggle_inspector(&mut self, cx: &mut App) { self.inspector = match self.inspector { None => Some(cx.new(|_| Inspector::new())), - Some(_) => None, + Some(_) => { + self.rendered_frame.next_inspector_instance_ids = FxHashMap::default(); + self.rendered_frame.inspector_hitboxes = FxHashMap::default(); + self.next_frame.next_inspector_instance_ids = FxHashMap::default(); + self.next_frame.inspector_hitboxes = FxHashMap::default(); + None + } }; self.refresh(); } @@ -6406,22 +6895,24 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] pub fn with_inspector_state( &mut self, - _inspector_id: Option<&crate::InspectorElementId>, + inspector_id: Option<&crate::InspectorElementId>, cx: &mut App, f: impl FnOnce(&mut Option, &mut Self) -> R, - ) -> R { - if let Some(inspector_id) = _inspector_id - && let Some(inspector) = &self.inspector - { - let inspector = inspector.clone(); - let active_element_id = inspector.read(cx).active_element_id(); - if Some(inspector_id) == active_element_id { - return inspector.update(cx, |inspector, _cx| { - inspector.with_active_element_state(self, f) - }); - } + ) -> Option { + let inspector_id = inspector_id?; + let inspector = self.inspector.as_ref()?; + if inspector.read(cx).active_element_id() != Some(inspector_id) { + return None; } - f(&mut None, self) + let inspector = inspector.clone(); + Some(inspector.update(cx, |inspector, _cx| { + inspector.with_active_element_state(self, f) + })) + } + + #[cfg(any(feature = "inspector", debug_assertions))] + pub(crate) fn inspector_enabled(&self) -> bool { + self.inspector.is_some() } #[cfg(any(feature = "inspector", debug_assertions))] @@ -6589,6 +7080,39 @@ impl Window { }); let _ = self.dispatch_event(event, cx); } + + #[inline(never)] + fn take_element_state( + &mut self, + global_id: &GlobalElementId, + state_type: TypeId, + ) -> ((GlobalElementId, TypeId), Option) { + let key = (global_id.clone(), state_type); + self.next_frame.accessed_element_states.push(key.clone()); + let state = self + .next_frame + .element_states + .remove(&key) + .or_else(|| self.rendered_frame.element_states.remove(&key)); + (key, state) + } + + #[inline(never)] + fn insert_element_state( + &mut self, + key: (GlobalElementId, TypeId), + state: ElementStateBox, + ) -> Option { + self.next_frame.element_states.insert(key, state) + } + + #[inline(never)] + fn observe_keyed_state(state: &Entity, current_view: EntityId, cx: &mut App) { + cx.observe(state, move |_, cx| { + cx.notify(current_view); + }) + .detach(); + } } // #[derive(Clone, Copy, Eq, PartialEq, Hash)] @@ -7097,6 +7621,19 @@ pub fn outline( } } +#[inline(never)] +fn with_element_arena_erased(callback: &mut dyn FnMut(&mut Arena)) -> Result<(), AccessError> { + if let Some(arena_pointer) = CURRENT_ELEMENT_ARENA.try_with(Cell::get)? { + // SAFETY: The pointer is valid for the duration of the draw operation + // that set it, and we're being called during that same draw. + let arena_cell = unsafe { &*arena_pointer }; + callback(&mut arena_cell.borrow_mut()); + Ok(()) + } else { + ELEMENT_ARENA.try_with(|arena| callback(&mut arena.borrow_mut())) + } +} + #[cfg(test)] mod tests { use std::{ @@ -7107,14 +7644,134 @@ mod tests { }; use crate::{ - AnyWindowHandle, AppContext as _, Bounds, Context, DispatchPhase, DragMoveEvent, Empty, - ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, - InputEvent as _, InteractiveElement as _, IntoElement, LongPressEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, - StatefulInteractiveElement as _, Styled, TestAppContext, TouchEvent, TouchId, TouchPhase, - Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, + AnyWindowHandle, AppContext as _, Bounds, ContentMask, Context, DispatchPhase, + DragMoveEvent, Empty, ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, + FocusHandle, InputEvent as _, InteractiveElement as _, IntoElement, KeyDownEvent, + Keystroke, LongPressEvent, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, + Pixels, PlatformInput, Point, Render, RequestFrameOptions, ScaledPixels, + StatefulInteractiveElement as _, Styled, TestAppContext, TouchDragEvent, TouchEvent, + TouchId, TouchPhase, Underline, UnderlineStyle, Window, WindowAppearance, WindowOptions, + canvas, div, hsla, point, px, size, }; + /// Visibility transitions reach observers exactly once each, with the new + /// state already stored on the window, and never wake the platform for a + /// frame: the platform requests one itself when it resumes presenting. + #[gpui::test] + fn test_window_visibility(cx: &mut TestAppContext) { + use crate::WindowVisibility; + + let window = cx.add_window(|_, _| EmptyView); + let observed = Rc::new(RefCell::new(Vec::new())); + let _subscription = window + .update(cx, { + let observed = observed.clone(); + move |_, window, _| { + assert_eq!(window.visibility(), WindowVisibility::Visible); + assert!(window.is_visible()); + window.observe_window_visibility(move |visibility, window, _| { + assert_eq!(window.visibility(), visibility); + observed.borrow_mut().push(visibility); + }) + } + }) + .unwrap(); + let test_window = cx.test_window(window.into()); + let frame_wake_count = test_window.frame_wake_count(); + + test_window.simulate_visibility_change(WindowVisibility::Hidden); + assert_eq!(*observed.borrow(), [WindowVisibility::Hidden]); + window + .update(cx, |_, window, _| assert!(!window.is_visible())) + .unwrap(); + + // Platforms may report the same state again; observers only see changes. + test_window.simulate_visibility_change(WindowVisibility::Hidden); + assert_eq!(observed.borrow().len(), 1); + + test_window.simulate_visibility_change(WindowVisibility::Visible); + assert_eq!( + *observed.borrow(), + [WindowVisibility::Hidden, WindowVisibility::Visible] + ); + window + .update(cx, |_, window, _| assert!(window.is_visible())) + .unwrap(); + assert_eq!(test_window.frame_wake_count(), frame_wake_count); + } + + #[gpui::test] + fn test_fully_visible_bounds_preserve_layout_viewport(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| EmptyView); + let mut platform_window = cx.test_window(window.into()); + platform_window.simulate_resize(size(px(400.), px(800.))); + window + .update(cx, |_, window, _| { + assert_eq!( + window.visual_viewport_bounds(), + Bounds::new(Point::default(), size(px(400.), px(800.))) + ); + assert_eq!( + window.fully_visible_bounds(), + window.visual_viewport_bounds() + ); + }) + .unwrap(); + + platform_window.simulate_frame_request(RequestFrameOptions::default()); + let wakes = platform_window.frame_wake_count(); + let visual_bounds = Bounds::new(point(px(10.), px(40.)), size(px(380.), px(460.))); + platform_window.simulate_visual_viewport_change(visual_bounds); + assert!(platform_window.frame_wake_count() > wakes); + platform_window.simulate_frame_request(RequestFrameOptions::default()); + let wakes = platform_window.frame_wake_count(); + platform_window.simulate_insets_change(crate::WindowInsets { + safe_area: crate::Edges { + top: px(60.), + right: px(20.), + bottom: px(30.), + left: px(-10.), + }, + ime: crate::Edges { + bottom: px(350.), + ..Default::default() + }, + }); + assert!(platform_window.frame_wake_count() > wakes); + window + .update(cx, |_, window, _| { + assert_eq!(window.viewport_size(), size(px(400.), px(800.))); + assert_eq!(window.visual_viewport_bounds(), visual_bounds); + assert_eq!( + window.fully_visible_bounds(), + Bounds::new(point(px(10.), px(60.)), size(px(370.), px(390.))) + ); + window.request_virtual_keyboard(); + window.dismiss_virtual_keyboard(); + assert_eq!(window.viewport_size(), size(px(400.), px(800.))); + }) + .unwrap(); + assert_eq!(platform_window.virtual_keyboard_requests(), 1); + assert_eq!(platform_window.virtual_keyboard_dismissals(), 1); + + platform_window.simulate_insets_change(crate::WindowInsets { + safe_area: crate::Edges { + top: px(900.), + left: px(500.), + ..Default::default() + }, + ..Default::default() + }); + window + .update(cx, |_, window, _| { + assert_eq!( + window.fully_visible_bounds().size, + size(Pixels::ZERO, Pixels::ZERO) + ); + }) + .unwrap(); + } + struct EmptyView; impl Render for EmptyView { @@ -7173,6 +7830,45 @@ mod tests { .unwrap(); } + #[test] + fn test_scale_factor_change_preserves_bounds_and_survives_resize() { + let mut cx = TestAppContext::single(); + let window = cx.add_window(|_, _| EmptyView); + let handle: AnyWindowHandle = window.into(); + let window_state = |cx: &mut TestAppContext| { + cx.update_window(handle, |_, window, _| { + ( + window.scale_factor(), + window.bounds(), + window.viewport_size(), + ) + }) + .unwrap() + }; + + let (scale_factor, mut expected_bounds, _) = window_state(&mut cx); + assert_eq!(scale_factor, 2.0); + + for (scale_factor, resized_size) in [ + (1.0, size(px(800.), px(600.))), + (1.25, size(px(640.), px(480.))), + (2.0, size(px(1024.), px(768.))), + ] { + cx.simulate_window_scale_factor_change(handle, scale_factor); + assert_eq!( + window_state(&mut cx), + (scale_factor, expected_bounds, expected_bounds.size) + ); + + cx.simulate_window_resize(handle, resized_size); + expected_bounds.size = resized_size; + assert_eq!( + window_state(&mut cx), + (scale_factor, expected_bounds, resized_size) + ); + } + } + /// Platforms that stop requesting frames for idle windows (currently web) /// rely on the frame waker firing whenever frame demand arises; a demand /// source that skips the waker shows up there as a window that silently @@ -7456,6 +8152,17 @@ mod tests { observed_drops: Rc>>, } + struct FileDropExitView(Rc>); + + impl Render for FileDropExitView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().on_file_drop_exit({ + let observed_file_drop_exit = self.0.clone(); + move |_, _, _| observed_file_drop_exit.set(observed_file_drop_exit.get() + 1) + }) + } + } + impl Render for FileDragView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() @@ -7573,10 +8280,24 @@ mod tests { Some(&outside_position) ); - let destination: AnyWindowHandle = cx.add_window(|_, _| EmptyView).into(); + let first_destination_exit_count = Rc::new(Cell::new(0)); + let first_destination: AnyWindowHandle = cx + .add_window({ + let first_destination_exit_count = first_destination_exit_count.clone(); + move |_, _| FileDropExitView(first_destination_exit_count) + }) + .into(); + let second_destination_exit_count = Rc::new(Cell::new(0)); + let second_destination: AnyWindowHandle = cx + .add_window({ + let second_destination_exit_count = second_destination_exit_count.clone(); + move |_, _| FileDropExitView(second_destination_exit_count) + }) + .into(); let reentry_position = point(px(30.), px(30.)); let external_paths = || ExternalPaths([successful_path.clone()].into_iter().collect()); - let update_result = cx.update_window(destination, |_, window, cx| { + let update_result = cx.update_window(first_destination, |_, window, cx| { + window.draw(cx).clear(cx); window.dispatch_event( FileDropEvent::Entered { position: reentry_position, @@ -7592,10 +8313,48 @@ mod tests { ); window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); assert!(cx.active_drag.is_none()); + assert_eq!(first_destination_exit_count.get(), 1); + assert_eq!(second_destination_exit_count.get(), 0); }); assert!( update_result.is_ok(), - "failed to handle drag in destination window: {update_result:?}" + "failed to handle drag in first destination window: {update_result:?}" + ); + + let update_result = cx.update_window(second_destination, |_, window, cx| { + window.draw(cx).clear(cx); + window.dispatch_event( + PlatformInput::KeyDown(KeyDownEvent { + keystroke: Keystroke::parse("down").expect("valid keystroke"), + is_held: false, + prefer_character_input: false, + }), + cx, + ); + window.dispatch_event( + FileDropEvent::Entered { + position: reentry_position, + paths: external_paths(), + } + .to_platform_input(), + cx, + ); + assert!( + cx.active_drag + .as_ref() + .is_some_and(|drag| drag.value.downcast_ref::().is_some()) + ); + assert_eq!(first_destination_exit_count.get(), 1); + assert_eq!(second_destination_exit_count.get(), 0); + + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + assert!(cx.active_drag.is_none()); + assert_eq!(first_destination_exit_count.get(), 1); + assert_eq!(second_destination_exit_count.get(), 1); + }); + assert!( + update_result.is_ok(), + "failed to handle drag in second destination window: {update_result:?}" ); let update_result = cx.update_window(successful.window, |_, window, cx| { @@ -7815,6 +8574,53 @@ mod tests { assert_eq!(b_focus_count.get(), 1); } + #[gpui::test] + fn claimed_touch_drag_receives_movement_and_release(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let events = events.clone(); + move |_, _| TouchDragListener { events } + }); + let touch = TouchId(1); + + dispatch_touch(window, cx, touch, TouchPhase::Started, 10.); + dispatch_touch(window, cx, touch, TouchPhase::Moved, 30.); + dispatch_touch(window, cx, touch, TouchPhase::Ended, 40.); + + assert_eq!( + events.borrow().as_slice(), + [ + (TouchPhase::Started, px(10.)), + (TouchPhase::Moved, px(30.)), + (TouchPhase::Ended, px(40.)), + ] + ); + } + + struct TouchDragListener { + events: Rc>>, + } + + impl Render for TouchDragListener { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let events = self.events.clone(); + canvas( + |_, _, _| {}, + move |_, _, window, _| { + window.on_mouse_event(move |event: &TouchDragEvent, phase, window, _cx| { + if phase != DispatchPhase::Bubble { + return; + } + events.borrow_mut().push((event.phase, event.position.x)); + if event.phase == TouchPhase::Started { + window.prevent_default(); + } + }); + }, + ) + } + } + #[gpui::test] fn long_press_is_claimed_only_when_started_prevents_default(cx: &mut TestAppContext) { for response in [ @@ -7931,6 +8737,258 @@ mod tests { assert_eq!(phases.borrow().as_slice(), [TouchPhase::Started]); } + #[gpui::test] + fn test_underline_exclusions_preserve_device_geometry(cx: &mut TestAppContext) { + test_underline_paint_at_scales(cx, |window| { + let (numerator, denominator) = match window.scale_factor() { + 1.0 => (1, 1), + 1.25 => (5, 4), + 1.5 => (3, 2), + 2.0 => (2, 1), + 3.0 => (3, 1), + scale => panic!("unexpected scale {scale}"), + }; + let snap = |quarters: i32| { + let scaled = quarters * numerator; + let divisor = 4 * denominator; + let rounded = + scaled.abs() / divisor + i32::from(2 * (scaled.abs() % divisor) > divisor); + ScaledPixels((scaled.signum() * rounded) as f32) + }; + let stroke = |quarters| { + if quarters == 0 { + ScaledPixels(0.) + } else { + snap(quarters).max(ScaledPixels(1.)) + } + }; + + for origin in [-101, -3, -2, -1, 0, 1, 2, 3, 101] { + for width in [0, 1, 3, 84] { + for thickness in [0, 1, 4, 7] { + for wavy in [false, true] { + let style = UnderlineStyle { + thickness: px(thickness as f32 / 4.), + color: Some(hsla(0.25, 0.5, 0.75, 0.5)), + wavy, + }; + let bounds = Bounds::new( + point(snap(origin), snap(-origin)), + size( + stroke(width), + stroke(thickness) * if wavy { 3. } else { 1. }, + ), + ); + let origin = point(px(origin as f32 / 4.), px(-origin as f32 / 4.)); + let width = px(width as f32 / 4.); + assert_eq!(window.underline_bounds(origin, width, &style), bounds); + let original = paint_test_underlines(window, |window| { + window.paint_underline(origin, width, &style); + }) + .to_vec(); + assert_eq!(original.len(), usize::from(!bounds.is_empty())); + if let Some(underline) = original.first() { + assert_eq!(underline.bounds, bounds); + assert_eq!(underline.thickness, stroke(thickness)); + assert_eq!(underline.wavy, wavy.into()); + assert_eq!(underline.color, hsla(0.25, 0.5, 0.75, 0.5)); + } + + let underlines = paint_test_underlines(window, |window| { + window.paint_underline_with_exclusions(origin, width, &style, &[]); + }); + assert_eq!(underlines.len(), original.len()); + for (actual, original) in underlines.iter().zip(&original) { + assert_same_underline_geometry(actual, original); + assert_eq!(actual.content_mask, original.content_mask); + } + + let left = bounds.left().0 as i32; + let right = bounds.right().0 as i32; + let exclusions = [ + (15, 25), + (3, 7), + (5, 10), + (10, 12), + (0, 0), + (4, 2), + (-50, -1), + (70, 100), + ] + .map(|(start, end)| { + ScaledPixels((left + start) as f32) + ..ScaledPixels((left + end) as f32) + }); + let underlines = paint_test_underlines(window, |window| { + window.paint_underline_with_exclusions( + origin, + width, + &style, + &exclusions, + ); + }); + let expected = (left..right) + .filter(|column| { + !bounds.is_empty() + && !exclusions.iter().any(|span| { + span.contains(&ScaledPixels(*column as f32)) + }) + }) + .collect::>(); + assert_eq!(underline_device_columns(underlines), expected); + for underline in underlines { + assert_same_underline_geometry(underline, &original[0]); + } + } + } + } + } + }); + } + + #[gpui::test] + fn test_underline_exclusions_preserve_helvetica_warning_edge(cx: &mut TestAppContext) { + test_underline_paint_at_scales(cx, |window| { + if window.scale_factor() != 2.0 { + return; + } + let style = UnderlineStyle { + thickness: px(1.), + color: None, + wavy: true, + }; + let origin = point(px(2.888_183_8), px(15.357_917)); + let width = px(23.848_145) - origin.x; + let original = paint_test_underlines(window, |window| { + window.paint_underline(origin, width, &style); + })[0]; + assert_eq!(original.bounds.left(), ScaledPixels(6.)); + assert_eq!(original.bounds.right(), ScaledPixels(48.)); + let point_bounds = window.underline_bounds(origin, px(10.829_102), &style); + assert_eq!(point_bounds.left(), ScaledPixels(6.)); + assert_eq!(point_bounds.right(), ScaledPixels(28.)); + + let underlines = paint_test_underlines(window, |window| { + window.paint_underline_with_exclusions( + origin, + width, + &style, + &[point_bounds.left()..point_bounds.right()], + ); + }); + assert_eq!(underlines.len(), 1); + assert_same_underline_geometry(&underlines[0], &original); + assert_eq!( + underline_device_columns(underlines), + (28..48).collect::>() + ); + }); + } + + #[gpui::test] + fn test_underline_exclusions_preserve_content_mask_and_opacity(cx: &mut TestAppContext) { + test_underline_paint_at_scales(cx, |window| { + let scale = window.scale_factor(); + let original_mask = window.content_mask(); + let original_opacity = window.element_opacity(); + let mask = ContentMask { + bounds: Bounds::from_corners(point(px(4.), px(10.)), point(px(16.), px(12.))), + }; + let style = UnderlineStyle { + thickness: px(2.), + color: Some(hsla(0.25, 0.5, 0.75, 0.5)), + wavy: true, + }; + let origin = point(px(-1.25), px(9.25)); + let width = px(24.); + window.with_content_mask(Some(mask), |window| { + window.with_element_opacity(Some(0.5), |window| { + window.with_element_opacity(Some(0.5), |window| { + let original = paint_test_underlines(window, |window| { + window.paint_underline(origin, width, &style); + })[0]; + assert_eq!(original.color, hsla(0.25, 0.5, 0.75, 0.125)); + let underlines = paint_test_underlines(window, |window| { + window.paint_underline_with_exclusions( + origin, + width, + &style, + &[ScaledPixels(6. * scale)..ScaledPixels(8. * scale)], + ); + }); + assert_eq!(underlines.len(), 2); + let expected = [(4. * scale, 6. * scale), (8. * scale, 16. * scale)].map( + |(left, right)| { + Bounds::from_corners( + point(ScaledPixels(left), ScaledPixels((10. * scale).floor())), + point(ScaledPixels(right), ScaledPixels((12. * scale).ceil())), + ) + }, + ); + assert_eq!( + underlines + .iter() + .map(|underline| underline.content_mask.bounds) + .collect::>(), + expected + ); + for underline in underlines { + assert_same_underline_geometry(underline, &original); + } + }); + }); + }); + assert_eq!(window.content_mask(), original_mask); + assert_eq!(window.element_opacity(), original_opacity); + }); + } + + #[gpui::test] + fn test_underline_exclusions_adversarial_permutations(cx: &mut TestAppContext) { + test_underline_paint_at_scales(cx, |window| { + let style = UnderlineStyle { + thickness: px(1.), + color: None, + wavy: true, + }; + let origin = point(px(-4.), px(-1.)); + let width = px(8.); + let left = (-4. * window.scale_factor()) as i32; + let right = (4. * window.scale_factor()) as i32; + let endpoints = [-16, -8, -1, 0, 1, 8, 16]; + for first_start in endpoints { + for first_end in endpoints { + for second_start in endpoints { + for second_end in endpoints { + let exclusions = [first_start..first_end, second_start..second_end]; + let expected = (left..right) + .filter(|column| { + !exclusions.iter().any(|span| span.contains(column)) + }) + .collect::>(); + let exclusions = exclusions.map(|span| { + ScaledPixels(span.start as f32)..ScaledPixels(span.end as f32) + }); + let underlines = paint_test_underlines(window, |window| { + window.paint_underline_with_exclusions( + origin, + width, + &style, + &exclusions, + ); + }); + assert_eq!( + underline_device_columns(underlines), + expected, + "{exclusions:?}" + ); + } + } + } + } + }); + } + #[derive(Clone, Copy, PartialEq)] enum LongPressResponse { PreventDefault, @@ -7970,8 +9028,66 @@ mod tests { } } - fn dispatch_touch( - window: crate::WindowHandle, + struct UnderlineTestView(Rc); + + impl Render for UnderlineTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let paint = self.0.clone(); + canvas( + |_, _, _| {}, + move |_, _, window, _| { + window.content_mask_stack.push(ContentMask { + bounds: Bounds::from_corners( + point(px(-1000.), px(-1000.)), + point(px(1000.), px(1000.)), + ), + }); + paint(window); + window.content_mask_stack.pop(); + }, + ) + .size_full() + } + } + + fn test_underline_paint_at_scales( + cx: &mut TestAppContext, + paint: impl Fn(&mut Window) + 'static, + ) { + let window = cx.add_window(move |_, _| UnderlineTestView(Rc::new(paint))); + for scale in [1., 1.25, 1.5, 2., 3.] { + cx.simulate_window_scale_factor_change(window.into(), scale); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + } + } + + fn paint_test_underlines(window: &mut Window, paint: impl FnOnce(&mut Window)) -> &[Underline] { + window.next_frame.scene.clear(); + paint(window); + &window.next_frame.scene.underlines + } + + fn assert_same_underline_geometry(actual: &Underline, expected: &Underline) { + assert_eq!(actual.bounds, expected.bounds); + assert_eq!(actual.thickness, expected.thickness); + assert_eq!(actual.wavy, expected.wavy); + assert_eq!(actual.color, expected.color); + assert_eq!(actual.pad, expected.pad); + } + + fn underline_device_columns(underlines: &[Underline]) -> Vec { + underlines + .iter() + .flat_map(|underline| { + let bounds = underline.bounds.intersect(&underline.content_mask.bounds); + bounds.left().0 as i32..bounds.right().0 as i32 + }) + .collect() + } + + fn dispatch_touch( + window: crate::WindowHandle, cx: &mut TestAppContext, id: TouchId, phase: TouchPhase, @@ -7994,3 +9110,165 @@ mod tests { .unwrap(); } } + +#[cfg(all(test, any(feature = "inspector", debug_assertions)))] +mod inspector_tests { + use super::*; + use crate::{ + DivInspectorState, InspectorElementId, MouseDownEvent, StyleRefinement, TestAppContext, div, + }; + + #[gpui::test] + fn inspector_only_tracks_its_open_window(cx: &mut TestAppContext) { + let windows = [ + cx.add_window(|_, cx| InspectorTestRoot { + child: cx.new(|_| InspectorTestView::default()), + }), + cx.add_window(|_, cx| InspectorTestRoot { + child: cx.new(|_| InspectorTestView::default()), + }), + ]; + for window in windows { + assert_closed_inspector(window.into(), cx); + } + for _ in 0..2 { + cx.update_window(windows[0].into(), |root, window, cx| { + let root = root.downcast::().expect("test root"); + let child_widths = root.read(cx).child.read(cx).child_widths.clone(); + window.toggle_inspector(cx); + window.draw(cx).clear(cx); + assert_eq!(child_widths.borrow().as_slice(), &[px(10.); 3]); + let path = window + .rendered_frame + .next_inspector_instance_ids + .iter() + .find_map(|(path, count)| (*count == 3).then(|| path.clone())) + .expect("anonymous siblings share an inspector path"); + let selected_id = InspectorElementId { + path, + instance_id: 1, + }; + let position = window + .rendered_frame + .hitboxes + .iter() + .find(|hitbox| { + window.rendered_frame.inspector_hitboxes.get(&hitbox.id) + == Some(&selected_id) + }) + .expect("middle sibling is pickable") + .bounds + .center(); + window.simulate_mouse_move(position, cx); + window.dispatch_event( + PlatformInput::MouseDown(MouseDownEvent { + position, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }), + cx, + ); + window.dispatch_event( + PlatformInput::MouseUp(MouseUpEvent { + position, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + }), + cx, + ); + assert!(!window.is_inspector_picking(cx)); + assert_eq!( + window + .inspector + .as_ref() + .expect("open inspector") + .read(cx) + .active_element_id(), + Some(&selected_id) + ); + window.draw(cx).clear(cx); + window.with_inspector_state::( + Some(&selected_id), + cx, + |state, _| { + let state = state.as_mut().expect("selected div has style state"); + assert_eq!( + state.base_style.size.width, + Some(crate::Length::from(px(10.))) + ); + state.base_style.size.width = Some(crate::Length::from(px(25.))); + }, + ); + window.refresh(); + window.draw(cx).clear(cx); + assert_eq!( + child_widths.borrow().as_slice(), + &[px(10.), px(25.), px(10.)] + ); + }) + .expect("pick and edit a cached child"); + assert_closed_inspector(windows[1].into(), cx); + windows[0] + .update(cx, |_, window, cx| window.toggle_inspector(cx)) + .expect("close inspector"); + assert_closed_inspector(windows[0].into(), cx); + } + } + + struct InspectorTestRoot { + child: Entity, + } + + impl Render for InspectorTestRoot { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.child + .clone() + .cached(StyleRefinement::default().size(px(100.))) + } + } + + #[derive(Default)] + struct InspectorTestView { + child_widths: Rc>>, + } + + impl Render for InspectorTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let child_widths = self.child_widths.clone(); + div() + .on_children_prepainted(move |bounds, _, _| { + *child_widths.borrow_mut() = + bounds.iter().map(|bounds| bounds.size.width).collect(); + }) + .with_dynamic_prepaint_order(|_, _| SmallVec::from_iter([2, 0, 1])) + .id("inspector-root") + .flex() + .children((0..3).map(|_| div().size(px(10.)).flex_shrink_0())) + } + } + + fn assert_closed_inspector(window: AnyWindowHandle, cx: &mut TestAppContext) { + cx.update_window(window, |root, window, cx| { + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + let root = root.downcast::().expect("test root"); + assert_eq!( + root.read(cx) + .child + .read(cx) + .child_widths + .borrow() + .as_slice(), + &[px(10.); 3] + ); + for frame in [&window.rendered_frame, &window.next_frame] { + assert_eq!(frame.next_inspector_instance_ids.capacity(), 0); + assert_eq!(frame.inspector_hitboxes.capacity(), 0); + } + }) + .expect("closed inspector has no bookkeeping and no style overrides"); + } +} diff --git a/crates/gpui_apple/Cargo.toml b/crates/gpui_apple/Cargo.toml index fc04bccf64..ead06aa2c8 100644 --- a/crates/gpui_apple/Cargo.toml +++ b/crates/gpui_apple/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/gpui_apple.rs" +doctest = false [features] default = [] @@ -24,7 +25,6 @@ gpui.workspace = true anyhow.workspace = true block.workspace = true cocoa.workspace = true -collections.workspace = true core-foundation.workspace = true core-video.workspace = true derive_more.workspace = true @@ -34,6 +34,7 @@ image.workspace = true log.workspace = true metal.workspace = true objc.workspace = true +objc2.workspace = true parking_lot.workspace = true [target.'cfg(target_os = "macos")'.build-dependencies] diff --git a/crates/gpui_apple/src/metal_atlas.rs b/crates/gpui_apple/src/metal_atlas.rs index 9cfd445ba8..faba138809 100644 --- a/crates/gpui_apple/src/metal_atlas.rs +++ b/crates/gpui_apple/src/metal_atlas.rs @@ -1,74 +1,71 @@ use anyhow::{Context as _, Result}; -use collections::FxHashMap; use derive_more::{Deref, DerefMut}; use etagere::BucketedAtlasAllocator; use gpui::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, - PlatformAtlas, Point, Size, + AtlasBackend, AtlasKey, AtlasState, AtlasTextureId, AtlasTextureKind, AtlasTextureList, + AtlasTile, Bounds, DevicePixels, PlatformAtlas, Point, Size, }; use metal::Device; use parking_lot::Mutex; use std::borrow::Cow; -pub struct MetalAtlas(Mutex); +pub struct MetalAtlas(Mutex>); impl MetalAtlas { pub(crate) fn new(device: Device, is_apple_gpu: bool) -> Self { - MetalAtlas(Mutex::new(MetalAtlasState { + MetalAtlas(Mutex::new(AtlasState::new(MetalAtlasTextures { device: AssertSend(device), is_apple_gpu, monochrome_textures: Default::default(), polychrome_textures: Default::default(), - tiles_by_key: Default::default(), - })) + }))) } pub(crate) fn metal_texture(&self, id: AtlasTextureId) -> metal::Texture { - self.0.lock().texture(id).metal_texture.clone() + self.0.lock().backend.texture(id).metal_texture.clone() } } -struct MetalAtlasState { +struct MetalAtlasTextures { device: AssertSend, is_apple_gpu: bool, monochrome_textures: AtlasTextureList, polychrome_textures: AtlasTextureList, - tiles_by_key: FxHashMap, } impl PlatformAtlas for MetalAtlas { fn get_or_insert_with<'a>( &self, - key: &AtlasKey, + key: AtlasKey, build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, ) -> Result> { - let mut lock = self.0.lock(); - if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(*tile)) - } else { - let Some((size, bytes)) = build()? else { - return Ok(None); - }; - let tile = lock - .allocate(size, key.texture_kind()) - .context("failed to allocate")?; - let texture = lock.texture(tile.texture_id); - texture.upload(tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile); - Ok(Some(tile)) - } + self.0.lock().get_or_insert_with(key, build) } fn remove(&self, key: &AtlasKey) { - let mut lock = self.0.lock(); - let Some(tile) = lock.tiles_by_key.remove(key) else { - return; - }; + self.0.lock().remove(key); + } +} + +impl AtlasBackend for MetalAtlasTextures { + fn insert( + &mut self, + kind: AtlasTextureKind, + size: Size, + bytes: &[u8], + ) -> Result { + let tile = self.allocate(size, kind).context("failed to allocate")?; + let texture = self.texture(tile.texture_id); + texture.upload(tile.bounds, bytes); + Ok(tile) + } + + fn remove(&mut self, tile: AtlasTile) { let id = tile.texture_id; let textures = match id.kind { - AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, - AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, AtlasTextureKind::Subpixel => unreachable!(), }; @@ -92,7 +89,7 @@ impl PlatformAtlas for MetalAtlas { } } -impl MetalAtlasState { +impl MetalAtlasTextures { fn allocate( &mut self, size: Size, @@ -290,7 +287,7 @@ mod tests { }) } - fn insert_tile(atlas: &MetalAtlas, key: &AtlasKey, size: Size) -> AtlasTile { + fn insert_tile(atlas: &MetalAtlas, key: AtlasKey, size: Size) -> AtlasTile { atlas .get_or_insert_with(key, &mut || { let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; @@ -315,9 +312,9 @@ mod tests { let key_b = make_image_key(2, 0); let key_c = make_image_key(3, 0); - let tile_a = insert_tile(&atlas, &key_a, small); - let tile_b = insert_tile(&atlas, &key_b, small); - let tile_c = insert_tile(&atlas, &key_c, small); + let tile_a = insert_tile(&atlas, key_a.clone(), small); + let tile_b = insert_tile(&atlas, key_b.clone(), small); + let tile_c = insert_tile(&atlas, key_c.clone(), small); assert_eq!(tile_a.texture_id, tile_b.texture_id); assert_eq!(tile_b.texture_id, tile_c.texture_id); @@ -334,7 +331,7 @@ mod tests { // Re-inserting A must allocate a fresh tile on a new texture, // NOT return a stale tile referencing the deleted texture. - let tile_a2 = insert_tile(&atlas, &key_a, small); + let tile_a2 = insert_tile(&atlas, key_a, small); // The texture must actually exist — this would panic before the fix. let _texture = atlas.metal_texture(tile_a2.texture_id); @@ -359,12 +356,12 @@ mod tests { let big_key_a = make_image_key(2, 0); let big_key_b = make_image_key(3, 0); - let keeper_tile = insert_tile(&atlas, &keeper_key, small); - let tile_a = insert_tile(&atlas, &big_key_a, big); + let keeper_tile = insert_tile(&atlas, keeper_key, small); + let tile_a = insert_tile(&atlas, big_key_a.clone(), big); assert_eq!(keeper_tile.texture_id, tile_a.texture_id); atlas.remove(&big_key_a); - let tile_b = insert_tile(&atlas, &big_key_b, big); + let tile_b = insert_tile(&atlas, big_key_b, big); assert_eq!(tile_b.texture_id, keeper_tile.texture_id); } diff --git a/crates/gpui_apple/src/metal_renderer.rs b/crates/gpui_apple/src/metal_renderer.rs index 112717b234..2350718d1b 100644 --- a/crates/gpui_apple/src/metal_renderer.rs +++ b/crates/gpui_apple/src/metal_renderer.rs @@ -575,35 +575,40 @@ impl MetalRenderer { anyhow::bail!("Invalid size for render_scene_to_image: {:?}", size); } - // Update path intermediate textures for this size - self.update_path_intermediate_textures(size); + // Headless callers do not have a Cocoa event-loop pool to release + // autoreleased command buffers and render-pass descriptors. + objc2::rc::autoreleasepool(|_| { + // Update path intermediate textures for this size + self.update_path_intermediate_textures(size); - // Create an offscreen texture as render target - let texture_descriptor = metal::TextureDescriptor::new(); - texture_descriptor.set_width(size.width.0 as u64); - texture_descriptor.set_height(size.height.0 as u64); - texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); - texture_descriptor - .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); - texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed); - let target_texture = self.device.new_texture(&texture_descriptor); + // Create an offscreen texture as render target + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_usage( + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed); + let target_texture = self.device.new_texture(&texture_descriptor); - let command_buffer = self.render_frame(scene, &target_texture, size)?; + let command_buffer = self.render_frame(scene, &target_texture, size)?; - // On discrete GPUs (non-unified memory), Managed textures require an - // explicit blit synchronize before the CPU can read back the rendered - // data. Without this, get_bytes returns stale zeros. - if !self.is_unified_memory { - let blit = command_buffer.new_blit_command_encoder(); - blit.synchronize_resource(&target_texture); - blit.end_encoding(); - } + // On discrete GPUs (non-unified memory), Managed textures require an + // explicit blit synchronize before the CPU can read back the rendered + // data. Without this, get_bytes returns stale zeros. + if !self.is_unified_memory { + let blit = command_buffer.new_blit_command_encoder(); + blit.synchronize_resource(&target_texture); + blit.end_encoding(); + } - // Commit and wait for completion - command_buffer.commit(); - command_buffer.wait_until_completed(); + // Commit and wait for completion + command_buffer.commit(); + command_buffer.wait_until_completed(); - read_texture_to_image(&target_texture) + read_texture_to_image(&target_texture) + }) } /// Renders a scene to a reused offscreen texture without reading pixels @@ -619,33 +624,35 @@ impl MetalRenderer { anyhow::bail!("Invalid size for render_scene: {:?}", size); } - self.update_path_intermediate_textures(size); + objc2::rc::autoreleasepool(|_| { + self.update_path_intermediate_textures(size); - let needs_new_target = self.headless_render_target.as_ref().is_none_or(|texture| { - texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 - }); - if needs_new_target { - let texture_descriptor = metal::TextureDescriptor::new(); - texture_descriptor.set_width(size.width.0 as u64); - texture_descriptor.set_height(size.height.0 as u64); - texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); - texture_descriptor.set_usage( - metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, - ); - texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); - self.headless_render_target = Some(self.device.new_texture(&texture_descriptor)); - } - let target_texture = self - .headless_render_target - .clone() - .expect("just ensured the render target exists"); + let needs_new_target = self.headless_render_target.as_ref().is_none_or(|texture| { + texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 + }); + if needs_new_target { + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_usage( + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); + self.headless_render_target = Some(self.device.new_texture(&texture_descriptor)); + } + let target_texture = self + .headless_render_target + .clone() + .expect("just ensured the render target exists"); - let command_buffer = self.render_frame(scene, &target_texture, size)?; + let command_buffer = self.render_frame(scene, &target_texture, size)?; - // Commit without waiting, mirroring presentation to a real window where - // the CPU doesn't block on the GPU. - command_buffer.commit(); - Ok(()) + // Commit without waiting, mirroring presentation to a real window where + // the CPU doesn't block on the GPU. + command_buffer.commit(); + Ok(()) + }) } fn draw_primitives_to_texture( diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 583ad38972..c4040af022 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/gpui_linux.rs" +doctest = false [features] default = ["wayland", "x11"] @@ -129,5 +130,8 @@ xim = { git = "https://github.com/zed-industries/xim-rs.git", rev = "16f35a2c881 ], package = "zed-xim", version = "0.4.0-zed", optional = true } x11-clipboard = { version = "0.9.3", optional = true } +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + [package.metadata.cargo-shear] ignored = ["bitflags", "scap", "x11-clipboard"] diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index 06da3cbd94..0e94a06007 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -25,7 +25,7 @@ impl HeadlessClient { pub(crate) fn new() -> Self { let event_loop = EventLoop::try_new().unwrap(); - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -38,9 +38,9 @@ impl HeadlessClient { .ok(); handle - .insert_source(wake_receiver, |event, _, client: &mut HeadlessClient| { - if let calloop::channel::Event::Msg(()) = event { - client.with_common(|common| common.handle_system_wake()); + .insert_source(power_receiver, |event, _, client: &mut HeadlessClient| { + if let calloop::channel::Event::Msg(event) = event { + client.with_common(|common| common.handle_system_power_event(event)); } }) .ok(); diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs index e41b09723d..35096d922b 100644 --- a/crates/gpui_linux/src/linux/headless/window.rs +++ b/crates/gpui_linux/src/linux/headless/window.rs @@ -2,24 +2,22 @@ //! //! A headless window has no compositor surface and no GPU: layout, text //! shaping, and entity plumbing run normally, `draw` discards the scene, and -//! the sprite atlas hands out tiles without uploading pixels (mirroring -//! GPUI's `TestWindow`/`TestAtlas`). This lets command-line tools drive real -//! `Window`-based code paths without a display server. +//! the sprite atlas hands out tiles without uploading pixels (the same +//! `HeadlessAtlas` GPUI's `TestWindow` uses). This lets command-line tools +//! drive real `Window`-based code paths without a display server. use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; -use collections::HashMap; -use parking_lot::Mutex; use uuid::Uuid; use gpui::{ - AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult, - DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, - PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowParams, px, + Bounds, Capslock, DispatchEventResult, DisplayId, GpuSpecs, HeadlessAtlas, Modifiers, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PromptButton, PromptLevel, RequestFrameOptions, Scene, Size, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, WindowVisibility, + px, }; #[derive(Debug)] @@ -159,6 +157,11 @@ impl PlatformWindow for HeadlessWindow { false } + fn visibility(&self) -> WindowVisibility { + // There is no display server: `draw` discards every scene. + WindowVisibility::Hidden + } + fn is_hovered(&self) -> bool { false } @@ -198,6 +201,8 @@ impl PlatformWindow for HeadlessWindow { fn on_active_status_change(&self, _callback: Box) {} + fn on_visibility_change(&self, _callback: Box) {} + fn on_hover_status_change(&self, _callback: Box) {} fn on_resize(&self, _callback: Box, f32)>) {} @@ -229,59 +234,3 @@ impl PlatformWindow for HeadlessWindow { None } } - -/// Allocates atlas tiles without uploading pixels, so glyph and sprite -/// painting completes headlessly. -#[derive(Default)] -struct HeadlessAtlas(Mutex); - -#[derive(Default)] -struct HeadlessAtlasState { - next_id: u32, - tiles: HashMap, -} - -impl PlatformAtlas for HeadlessAtlas { - fn get_or_insert_with<'a>( - &self, - key: &AtlasKey, - build: &mut dyn FnMut() -> anyhow::Result< - Option<(Size, std::borrow::Cow<'a, [u8]>)>, - >, - ) -> anyhow::Result> { - { - let state = self.0.lock(); - if let Some(&tile) = state.tiles.get(key) { - return Ok(Some(tile)); - } - } - - let Some((size, _)) = build()? else { - return Ok(None); - }; - - let mut state = self.0.lock(); - state.next_id += 1; - let texture_id = state.next_id; - state.next_id += 1; - let tile_id = state.next_id; - let tile = AtlasTile { - texture_id: AtlasTextureId { - index: texture_id, - kind: key.texture_kind(), - }, - tile_id: TileId(tile_id), - padding: 0, - bounds: Bounds { - origin: Point::default(), - size, - }, - }; - state.tiles.insert(key.clone(), tile); - Ok(Some(tile)) - } - - fn remove(&self, key: &AtlasKey) { - self.0.lock().tiles.remove(key); - } -} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 53dec5dca9..7bf77a1bc3 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -16,6 +16,14 @@ use std::{ #[cfg(any(feature = "wayland", feature = "x11"))] use anyhow::ensure; use anyhow::{Context as _, anyhow}; +#[cfg(any(feature = "wayland", feature = "x11"))] +use ashpd::{ + desktop::{ + Request, + inhibit::{InhibitFlags, InhibitOptions, InhibitProxy}, + }, + enumflags2::BitFlags, +}; use calloop::{LoopSignal, channel::Sender}; use futures::channel::oneshot; use gpui_util::{ResultExt as _, new_std_command}; @@ -24,8 +32,8 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State}; use crate::linux::{LinuxDispatcher, PriorityQueueCalloopReceiver}; use gpui::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, - ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, + Action, ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, + DisplayId, ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, WindowButtonLayout, WindowParams, @@ -112,9 +120,22 @@ pub(crate) struct PlatformHandlers { pub(crate) will_open_app_menu: Option>, pub(crate) validate_app_menu_command: Option bool>>, pub(crate) keyboard_layout_change: Option>, + pub(crate) system_sleep: Option>, pub(crate) system_wake: Option>, } +/// A logind `PrepareForSleep` signal, forwarded from the D-Bus listener to +/// the client's event loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), + allow(dead_code) +)] +pub(crate) enum SystemPowerEvent { + Sleep, + Wake, +} + pub(crate) struct LinuxCommon { pub(crate) background_executor: BackgroundExecutor, pub(crate) foreground_executor: ForegroundExecutor, @@ -131,8 +152,8 @@ pub(crate) struct LinuxCommon { not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), allow(dead_code) )] - wake_sender: Sender<()>, - wake_listener_started: bool, + power_sender: Sender, + power_listener_started: bool, } impl LinuxCommon { @@ -141,10 +162,10 @@ impl LinuxCommon { ) -> ( Self, PriorityQueueCalloopReceiver, - calloop::channel::Channel<()>, + calloop::channel::Channel, ) { let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); - let (wake_sender, wake_receiver) = calloop::channel::channel(); + let (power_sender, power_receiver) = calloop::channel::channel(); #[cfg(any(feature = "wayland", feature = "x11"))] let text_system = Arc::new(crate::linux::CosmicTextSystem::new("IBM Plex Sans")); @@ -170,40 +191,45 @@ impl LinuxCommon { app_name: None, system_notifications: crate::linux::system_notifications::SystemNotificationState::new( ), - wake_sender, - wake_listener_started: false, + power_sender, + power_listener_started: false, }; - (common, main_receiver, wake_receiver) + (common, main_receiver, power_receiver) } - pub(crate) fn start_wake_listener(&mut self) { - if !self.wake_listener_started { + pub(crate) fn start_power_listener(&mut self) { + if !self.power_listener_started { #[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] smol::spawn({ - let wake_sender = self.wake_sender.clone(); + let power_sender = self.power_sender.clone(); async move { - if let Err(error) = listen_for_system_wake(wake_sender).await { - log::debug!("failed to listen for system wake events: {error:?}"); + if let Err(error) = listen_for_system_power_events(power_sender).await { + log::debug!("failed to listen for system sleep/wake events: {error:?}"); } } }) .detach(); - self.wake_listener_started = true; + self.power_listener_started = true; } } - pub(crate) fn handle_system_wake(&mut self) { - if let Some(mut callback) = self.callbacks.system_wake.take() { + pub(crate) fn handle_system_power_event(&mut self, event: SystemPowerEvent) { + let callback = match event { + SystemPowerEvent::Sleep => &mut self.callbacks.system_sleep, + SystemPowerEvent::Wake => &mut self.callbacks.system_wake, + }; + if let Some(callback) = callback.as_mut() { callback(); - self.callbacks.system_wake = Some(callback); } } } #[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] -async fn listen_for_system_wake(wake_sender: Sender<()>) -> anyhow::Result<()> { +async fn listen_for_system_power_events( + power_sender: Sender, +) -> anyhow::Result<()> { use futures::StreamExt as _; let connection = ashpd::zbus::Connection::system().await?; @@ -217,10 +243,12 @@ async fn listen_for_system_wake(wake_sender: Sender<()>) -> anyhow::Result<()> { let mut sleep_events = proxy.receive_signal("PrepareForSleep").await?; while let Some(message) = sleep_events.next().await { - let sleeping = message.body().deserialize::()?; - if !sleeping { - wake_sender.send(()).ok(); - } + let event = if message.body().deserialize::()? { + SystemPowerEvent::Sleep + } else { + SystemPowerEvent::Wake + }; + power_sender.send(event).ok(); } Ok(()) @@ -264,6 +292,33 @@ impl Platform for LinuxPlatform

{ ThermalState::Nominal } + #[cfg(not(any(feature = "wayland", feature = "x11")))] + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Err(anyhow!( + "Idle sleep prevention for {reason:?} requires a Linux windowing backend" + ))) + } + + #[cfg(any(feature = "wayland", feature = "x11"))] + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + let executor = self.background_executor(); + let (guard_tx, guard_rx) = oneshot::channel(); + executor + .spawn({ + let executor = executor.clone(); + let reason = reason.to_owned(); + async move { + guard_tx + .send(inhibit_idle_sleep(reason, executor).await) + .ok(); + } + }) + .detach(); + executor + .clone() + .spawn(async move { await_idle_sleep_prevention(guard_rx, &executor).await }) + } + fn run(&self, on_finish_launching: Box) { on_finish_launching(); @@ -559,10 +614,17 @@ impl Platform for LinuxPlatform

{ }); } + fn on_system_sleep(&self, callback: Box) { + self.inner.with_common(|common| { + common.callbacks.system_sleep = Some(callback); + common.start_power_listener(); + }); + } + fn on_system_wake(&self, callback: Box) { self.inner.with_common(|common| { common.callbacks.system_wake = Some(callback); - common.start_wake_listener(); + common.start_power_listener(); }); } @@ -837,9 +899,13 @@ pub(super) fn is_within_click_distance(a: Point, b: Point) -> bo diff.x.abs() <= DOUBLE_CLICK_DISTANCE && diff.y.abs() <= DOUBLE_CLICK_DISTANCE } +/// Creates an XKB context for keymaps supplied by Wayland or X11. +/// +/// Server keymaps are already resolved and need no local keyboard definitions. +/// Loading default include paths can fail on systems without those files. #[cfg(any(feature = "wayland", feature = "x11"))] pub(super) fn new_xkb_context() -> anyhow::Result { - validate_xkb_context(xkb::Context::new(xkb::CONTEXT_NO_FLAGS)) + validate_xkb_context(xkb::Context::new(xkb::CONTEXT_NO_DEFAULT_INCLUDES)) } #[cfg(any(feature = "wayland", feature = "x11"))] @@ -1258,6 +1324,56 @@ pub(super) fn compositor_gpu_hint_from_dev_t(dev: u64) -> Option Result { + let proxy = InhibitProxy::new() + .await + .context("Idle sleep prevention portal is unavailable")?; + let request = proxy + .inhibit( + None, + BitFlags::from(InhibitFlags::Suspend), + InhibitOptions::default().set_reason(reason.as_str()), + ) + .await + .context("Failed to request idle sleep prevention")?; + request + .response() + .context("Idle sleep prevention request was rejected")?; + Ok(release_on_drop(request, executor)) +} + +#[cfg(any(feature = "wayland", feature = "x11"))] +fn release_on_drop(request: Request<()>, executor: BackgroundExecutor) -> ActivityGuard { + ActivityGuard::new(move || { + executor + .spawn(async move { + request + .close() + .await + .context("Failed to release idle sleep prevention") + .log_err(); + }) + .detach(); + }) +} + +#[cfg(any(feature = "wayland", feature = "x11"))] +async fn await_idle_sleep_prevention( + guard_rx: oneshot::Receiver>, + executor: &BackgroundExecutor, +) -> Result { + match futures::future::select(guard_rx, executor.timer(Duration::from_secs(10))).await { + futures::future::Either::Left((Ok(result), _)) => result, + futures::future::Either::Left((Err(_), _)) => { + Err(anyhow!("Idle sleep prevention request was abandoned")) + } + futures::future::Either::Right(_) => Err(anyhow!( + "Idle sleep prevention acquisition timed out after 10 seconds" + )), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1298,6 +1414,98 @@ mod tests { ),); } + #[cfg(any(feature = "wayland", feature = "x11"))] + mod idle_sleep_prevention { + use super::super::await_idle_sleep_prevention; + use anyhow::{Result, anyhow}; + use futures::channel::oneshot; + use gpui::{ActivityGuard, TestAppContext}; + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering::SeqCst}, + }, + time::Duration, + }; + + #[gpui::test] + async fn guard_passes_through_and_releases_on_drop(cx: &mut TestAppContext) { + let released = Arc::new(AtomicUsize::new(0)); + let (guard_tx, guard_rx) = oneshot::channel(); + assert!(guard_tx.send(Ok(release_guard(released.clone()))).is_ok()); + let guard = await_idle_sleep_prevention(guard_rx, &cx.background_executor) + .await + .expect("acquisition should succeed"); + + assert_eq!(released.load(SeqCst), 0); + drop(guard); + assert_eq!(released.load(SeqCst), 1); + } + + #[gpui::test] + async fn acquisition_error_passes_through(cx: &mut TestAppContext) { + let (guard_tx, guard_rx) = oneshot::channel::>(); + assert!(guard_tx.send(Err(anyhow!("inhibition rejected"))).is_ok()); + assert_eq!( + await_idle_sleep_prevention(guard_rx, &cx.background_executor) + .await + .err() + .expect("acquisition should fail") + .to_string(), + "inhibition rejected" + ); + } + + #[gpui::test] + async fn abandoned_acquisition_is_an_error(cx: &mut TestAppContext) { + let (guard_tx, guard_rx) = oneshot::channel::>(); + drop(guard_tx); + assert_eq!( + await_idle_sleep_prevention(guard_rx, &cx.background_executor) + .await + .err() + .expect("acquisition should fail") + .to_string(), + "Idle sleep prevention request was abandoned" + ); + } + + #[gpui::test] + async fn late_guard_after_timeout_is_released(cx: &mut TestAppContext) { + let released = Arc::new(AtomicUsize::new(0)); + let (guard_tx, guard_rx) = oneshot::channel(); + let task = cx.background_executor.spawn({ + let executor = cx.background_executor.clone(); + async move { await_idle_sleep_prevention(guard_rx, &executor).await } + }); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(9)); + cx.run_until_parked(); + assert!(!guard_tx.is_canceled()); + cx.executor().advance_clock(Duration::from_secs(1)); + assert_eq!( + task.await + .err() + .expect("acquisition should time out") + .to_string(), + "Idle sleep prevention acquisition timed out after 10 seconds" + ); + + assert!(guard_tx.is_canceled()); + if let Err(Ok(guard)) = guard_tx.send(Ok(release_guard(released.clone()))) { + assert_eq!(released.load(SeqCst), 0); + drop(guard); + } + assert_eq!(released.load(SeqCst), 1); + } + + fn release_guard(released: Arc) -> ActivityGuard { + ActivityGuard::new(move || { + released.fetch_add(1, SeqCst); + }) + } + } + #[cfg(any(feature = "wayland", feature = "x11"))] mod read_fd_with_timeout { use super::super::{PIPE_READ_TIMEOUT, read_fd_with_timeout}; diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index a5c6dc8925..469eb44471 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -260,7 +260,9 @@ impl Globals { primary_selection_manager: globals.bind(&qh, 1..=1, ()).ok(), shm: globals.bind(&qh, 1..=1, ()).unwrap(), seat, - wm_base: globals.bind(&qh, 1..=5, ()).unwrap(), + // Accept any xdg_wm_base version up to 6, which added the `suspended` + // toplevel state; older compositors bind at their own version. + wm_base: globals.bind(&qh, 1..=6, ()).unwrap(), viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), @@ -368,10 +370,95 @@ pub(crate) struct WaylandClientState { ime_enabled: Option, } -pub struct DragState { - data_offer: Option, - window: Option, +struct DragState { + data_offer: Option, + window: Option, position: Point, + uri_read_generation: u64, +} + +impl DragState { + fn begin_uri_read(&mut self) -> u64 { + self.invalidate_uri_read(); + self.uri_read_generation + } + + fn invalidate_uri_read(&mut self) { + self.uri_read_generation = self.uri_read_generation.wrapping_add(1); + } + + fn is_uri_read_current(&self, generation: u64) -> bool { + self.uri_read_generation == generation + } +} + +trait FileDragDataOffer { + fn finish(&self); + fn destroy(&self); +} + +impl FileDragDataOffer for wl_data_offer::WlDataOffer { + fn finish(&self) { + wl_data_offer::WlDataOffer::finish(self); + } + + fn destroy(&self) { + wl_data_offer::WlDataOffer::destroy(self); + } +} + +impl DragState +where + DataOffer: FileDragDataOffer + Clone, + Window: Clone, +{ + fn handle_leave(&mut self) -> Option<(Window, PlatformInput)> { + self.invalidate_uri_read(); + let window = self.window.clone()?; + let data_offer = self.data_offer.clone()?; + data_offer.destroy(); + self.data_offer = None; + self.window = None; + Some((window, PlatformInput::FileDrop(FileDropEvent::Exited {}))) + } + + fn handle_drop(&mut self) -> Option<(Window, PlatformInput)> { + self.invalidate_uri_read(); + let window = self.window.clone()?; + let data_offer = self.data_offer.clone()?; + data_offer.finish(); + data_offer.destroy(); + self.data_offer = None; + self.window = None; + Some(( + window, + PlatformInput::FileDrop(FileDropEvent::Submit { + position: self.position, + }), + )) + } + + fn complete_uri_read( + &mut self, + generation: u64, + data_offer: DataOffer, + window: Window, + position: Point, + paths: gpui::ExternalPaths, + ) -> Option<(Window, PlatformInput)> { + if !self.is_uri_read_current(generation) { + data_offer.destroy(); + return None; + } + + self.data_offer = Some(data_offer); + self.window = Some(window.clone()); + self.position = position; + Some(( + window, + PlatformInput::FileDrop(FileDropEvent::Entered { position, paths }), + )) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -787,7 +874,7 @@ impl WaylandClient { let event_loop = EventLoop::::try_new().unwrap(); - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); handle @@ -809,10 +896,14 @@ impl WaylandClient { handle .insert_source( - wake_receiver, + power_receiver, |event, _, client: &mut WaylandClientStatePtr| { - if let calloop::channel::Event::Msg(()) = event { - client.get_client().borrow_mut().common.handle_system_wake(); + if let calloop::channel::Event::Msg(event) = event { + client + .get_client() + .borrow_mut() + .common + .handle_system_power_event(event); } }, ) @@ -923,6 +1014,7 @@ impl WaylandClient { data_offer: None, window: None, position: Point::default(), + uri_read_generation: 0, }, external_drag: None, click: ClickState { @@ -2595,6 +2687,7 @@ impl Dispatch for WaylandClientStatePtr { let Some(drag_window) = get_window(&mut state, &surface.id()) else { return; }; + let uri_read_generation = state.drag.begin_uri_read(); const ACTIONS: DndAction = DndAction::Copy; data_offer.set_actions(ACTIONS, ACTIONS); @@ -2643,20 +2736,20 @@ impl Dispatch for WaylandClientStatePtr { data_offer.destroy(); return; } - - let input = PlatformInput::FileDrop(FileDropEvent::Entered { - position, - paths: gpui::ExternalPaths(paths), - }); - let client = this.get_client(); let mut state = client.borrow_mut(); - state.drag.data_offer = Some(data_offer); - state.drag.window = Some(drag_window.clone()); - state.drag.position = position; + let input = state.drag.complete_uri_read( + uri_read_generation, + data_offer, + drag_window, + position, + gpui::ExternalPaths(paths), + ); drop(state); - drag_window.handle_input(input); + if let Some((drag_window, input)) = input { + drag_window.handle_input(input); + } }) .detach(); } @@ -2673,35 +2766,18 @@ impl Dispatch for WaylandClientStatePtr { drag_window.handle_input(input); } wl_data_device::Event::Leave => { - let Some(drag_window) = state.drag.window.clone() else { - return; - }; - let data_offer = state.drag.data_offer.clone().unwrap(); - data_offer.destroy(); - - state.drag.data_offer = None; - state.drag.window = None; - - let input = PlatformInput::FileDrop(FileDropEvent::Exited {}); + let input = state.drag.handle_leave(); drop(state); - drag_window.handle_input(input); + if let Some((drag_window, input)) = input { + drag_window.handle_input(input); + } } wl_data_device::Event::Drop => { - let Some(drag_window) = state.drag.window.clone() else { - return; - }; - let data_offer = state.drag.data_offer.clone().unwrap(); - data_offer.finish(); - data_offer.destroy(); - - state.drag.data_offer = None; - state.drag.window = None; - - let input = PlatformInput::FileDrop(FileDropEvent::Submit { - position: state.drag.position, - }); + let input = state.drag.handle_drop(); drop(state); - drag_window.handle_input(input); + if let Some((drag_window, input)) = input { + drag_window.handle_input(input); + } } _ => {} } @@ -2904,6 +2980,133 @@ mod tests { use super::*; + #[derive(Clone)] + struct FakeDataOffer { + name: &'static str, + finished: Rc>, + destroyed: Rc>, + } + + impl FakeDataOffer { + fn new(name: &'static str) -> Self { + Self { + name, + finished: Rc::new(Cell::new(false)), + destroyed: Rc::new(Cell::new(false)), + } + } + } + + impl FileDragDataOffer for FakeDataOffer { + fn finish(&self) { + self.finished.set(true); + } + + fn destroy(&self) { + self.destroyed.set(true); + } + } + + fn drag_state() -> DragState { + DragState { + data_offer: None, + window: None, + position: Point::default(), + uri_read_generation: 0, + } + } + + fn complete_drag_uri_read( + drag: &mut DragState, + generation: u64, + data_offer: FakeDataOffer, + window: &'static str, + ) -> Option<(&'static str, PlatformInput)> { + drag.complete_uri_read( + generation, + data_offer, + window, + Point::default(), + gpui::ExternalPaths(SmallVec::from_vec(vec![PathBuf::from("/tmp/file")])), + ) + } + + fn record_entered( + input: Option<(&'static str, PlatformInput)>, + entered_windows: &mut Vec<&'static str>, + ) { + if let Some((window, PlatformInput::FileDrop(FileDropEvent::Entered { .. }))) = input { + entered_windows.push(window); + } + } + + #[test] + fn leave_discards_pending_drag_uri_read() { + let mut drag = drag_state(); + let data_offer = FakeDataOffer::new("A"); + let generation = drag.begin_uri_read(); + let mut entered_windows = Vec::new(); + + record_entered(drag.handle_leave(), &mut entered_windows); + record_entered( + complete_drag_uri_read(&mut drag, generation, data_offer.clone(), "window A"), + &mut entered_windows, + ); + + assert!(entered_windows.is_empty()); + assert!(data_offer.destroyed.get()); + assert!(drag.data_offer.is_none()); + assert!(drag.window.is_none()); + } + + #[test] + fn drop_then_leave_discards_pending_drag_uri_read() { + let mut drag = drag_state(); + let data_offer = FakeDataOffer::new("A"); + let generation = drag.begin_uri_read(); + let mut entered_windows = Vec::new(); + + record_entered(drag.handle_drop(), &mut entered_windows); + assert_ne!(drag.uri_read_generation, generation); + record_entered(drag.handle_leave(), &mut entered_windows); + record_entered( + complete_drag_uri_read(&mut drag, generation, data_offer.clone(), "window A"), + &mut entered_windows, + ); + + assert!(entered_windows.is_empty()); + assert!(!data_offer.finished.get()); + assert!(data_offer.destroyed.get()); + assert!(drag.data_offer.is_none()); + assert!(drag.window.is_none()); + } + + #[test] + fn stale_completion_preserves_newer_drag_destination() { + let mut drag = drag_state(); + let data_offer_a = FakeDataOffer::new("A"); + let generation_a = drag.begin_uri_read(); + let mut entered_windows = Vec::new(); + record_entered(drag.handle_leave(), &mut entered_windows); + + let data_offer_b = FakeDataOffer::new("B"); + let generation_b = drag.begin_uri_read(); + record_entered( + complete_drag_uri_read(&mut drag, generation_b, data_offer_b.clone(), "window B"), + &mut entered_windows, + ); + record_entered( + complete_drag_uri_read(&mut drag, generation_a, data_offer_a.clone(), "window A"), + &mut entered_windows, + ); + + assert_eq!(entered_windows, ["window B"]); + assert!(data_offer_a.destroyed.get()); + assert!(!data_offer_b.destroyed.get()); + assert_eq!(drag.data_offer.as_ref().map(|offer| offer.name), Some("B")); + assert_eq!(drag.window, Some("window B")); + } + #[derive(Default)] struct FakeImeCursorRectangleSink { cursor_rectangles: RefCell>, diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index eb84b7f7fd..a66547aa0c 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -37,7 +37,7 @@ use gpui::{ Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowControls, WindowDecorations, WindowKind, WindowParams, + WindowControls, WindowDecorations, WindowKind, WindowParams, WindowVisibility, layer_shell::{Anchor, LayerShellNotSupportedError}, popup::PopupOptions, px, size, @@ -49,6 +49,7 @@ pub(crate) struct Callbacks { request_frame: Option>, input: Option gpui::DispatchEventResult>>, active_status_change: Option>, + visibility_change: Option>, hover_status_change: Option>, resize: Option, f32)>>, moved: Option>, @@ -91,6 +92,7 @@ struct InProgressConfigure { fullscreen: bool, maximized: bool, resizing: bool, + visibility: WindowVisibility, tiling: Tiling, } @@ -116,6 +118,8 @@ pub struct WaylandWindowState { background_appearance: WindowBackgroundAppearance, fullscreen: bool, maximized: bool, + /// `Hidden` while the `xdg_toplevel` `suspended` state (xdg-shell v6) is set. + visibility: WindowVisibility, tiling: Tiling, window_bounds: Bounds, client: WaylandClientStatePtr, @@ -613,6 +617,7 @@ impl WaylandWindowState { background_appearance: WindowBackgroundAppearance::Opaque, fullscreen: false, maximized: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), window_bounds: options.bounds, in_progress_configure: None, @@ -1043,7 +1048,7 @@ impl WaylandWindowStatePtr { accepts_text_input } else { drop(state); - true + false }; if Some(ime_enabled) == client.ime_enabled() { return; @@ -1078,27 +1083,37 @@ impl WaylandWindowStatePtr { state.fullscreen = configure.fullscreen; state.maximized = configure.maximized; state.tiling = configure.tiling; + let visibility_changed = state.visibility != configure.visibility; + state.visibility = configure.visibility; // Limit interactive resizes to once per vblank - if configure.resizing && state.resize_throttle { + let throttled = configure.resizing && state.resize_throttle; + if throttled { state.surface_state.ack_configure(serial); - return; - } else if configure.resizing { - state.resize_throttle = true; - } - if !configure.fullscreen && !configure.maximized { - configure.size = if got_unmaximized { - Some(state.window_bounds.size) - } else { - compute_outer_size(state.inset(), configure.size, state.tiling) - }; - if let Some(size) = configure.size { - state.window_bounds = Bounds { - origin: Point::default(), - size, + } else { + if configure.resizing { + state.resize_throttle = true; + } + if !configure.fullscreen && !configure.maximized { + configure.size = if got_unmaximized { + Some(state.window_bounds.size) + } else { + compute_outer_size(state.inset(), configure.size, state.tiling) }; + if let Some(size) = configure.size { + state.window_bounds = Bounds { + origin: Point::default(), + size, + }; + } } } drop(state); + if visibility_changed { + self.report_visibility(configure.visibility); + } + if throttled { + return; + } if let Some(size) = configure.size { self.resize(size); } @@ -1192,6 +1207,7 @@ impl WaylandWindowStatePtr { let mut fullscreen = false; let mut maximized = false; let mut resizing = false; + let mut visibility = WindowVisibility::Visible; for state in states { match state { @@ -1202,6 +1218,7 @@ impl WaylandWindowStatePtr { fullscreen = true; } xdg_toplevel::State::Resizing => resizing = true, + xdg_toplevel::State::Suspended => visibility = WindowVisibility::Hidden, xdg_toplevel::State::TiledTop => { tiling.top = true; } @@ -1230,6 +1247,7 @@ impl WaylandWindowStatePtr { fullscreen, maximized, resizing, + visibility, tiling, }); @@ -1304,6 +1322,7 @@ impl WaylandWindowStatePtr { fullscreen: false, maximized: false, resizing: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), }); drop(state); @@ -1338,6 +1357,7 @@ impl WaylandWindowStatePtr { fullscreen: false, maximized: false, resizing: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), }); @@ -1556,6 +1576,14 @@ impl WaylandWindowStatePtr { } } + fn report_visibility(&self, visibility: WindowVisibility) { + let callback = self.callbacks.borrow_mut().visibility_change.take(); + if let Some(mut callback) = callback { + callback(visibility); + self.callbacks.borrow_mut().visibility_change = Some(callback); + } + } + pub fn set_appearance(&mut self, appearance: WindowAppearance) { self.state.borrow_mut().appearance = appearance; @@ -1781,6 +1809,10 @@ impl PlatformWindow for WaylandWindow { self.borrow().active } + fn visibility(&self) -> WindowVisibility { + self.borrow().visibility + } + fn is_hovered(&self) -> bool { self.borrow().hovered } @@ -1867,6 +1899,10 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.callbacks.borrow_mut().hover_status_change = Some(callback); } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 9b35125da1..23a9a91a97 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -12,7 +12,7 @@ use http_client::Url; use log::Level; use smallvec::SmallVec; use std::{ - cell::RefCell, + cell::{RefCell, RefMut}, collections::{BTreeMap, HashSet}, ops::Deref, path::PathBuf, @@ -63,7 +63,7 @@ use gpui::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, - TouchPhase, WindowButtonLayout, WindowParams, point, px, + TouchPhase, WindowButtonLayout, WindowParams, WindowVisibility, point, px, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -90,6 +90,17 @@ impl WindowRef { pub fn handle(&self) -> AnyWindowHandle { self.window.state.borrow().handle } + + /// Whether the X server is presenting this window. Compositing window + /// managers rarely report `FULLY_OBSCURED`, so under them this is the + /// mapped state alone. + fn visibility(&self) -> WindowVisibility { + if self.is_mapped && !matches!(self.last_visibility, Visibility::FULLY_OBSCURED) { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } + } } impl Deref for WindowRef { @@ -309,7 +320,7 @@ impl X11Client { pub(crate) fn new() -> anyhow::Result { let event_loop = EventLoop::try_new()?; - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -339,13 +350,17 @@ impl X11Client { })?; handle - .insert_source(wake_receiver, |event, _, client: &mut X11Client| { - if let calloop::channel::Event::Msg(()) = event { - client.0.borrow_mut().common.handle_system_wake(); + .insert_source(power_receiver, |event, _, client: &mut X11Client| { + if let calloop::channel::Event::Msg(event) = event { + client + .0 + .borrow_mut() + .common + .handle_system_power_event(event); } }) .map_err(|err| { - anyhow!("Failed to initialize event loop handling of wake events: {err:?}") + anyhow!("Failed to initialize event loop handling of sleep/wake events: {err:?}") })?; let (xcb_connection, x_root_index) = XCBConnection::connect(None)?; @@ -801,21 +816,21 @@ impl X11Client { if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.is_mapped = false; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::MapNotify(event) => { let mut state = self.0.borrow_mut(); if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.is_mapped = true; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::VisibilityNotify(event) => { let mut state = self.0.borrow_mut(); if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.last_visibility = event.state; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::ClientMessage(event) => { let window = self.get_window(event.window)?; @@ -1903,8 +1918,7 @@ impl X11ClientState { let Some(window_ref) = self.windows.get_mut(&x_window) else { return; }; - let is_visible = window_ref.is_mapped - && !matches!(window_ref.last_visibility, Visibility::FULLY_OBSCURED); + let is_visible = window_ref.visibility().is_visible(); match (is_visible, window_ref.refresh_state.take()) { (false, refresh_state @ Some(RefreshState::Hidden { .. })) | (false, refresh_state @ None) @@ -1993,14 +2007,11 @@ impl X11ClientState { let mut state = client.0.borrow_mut(); let xcb_connection = state.xcb_connection.clone(); if let Some(window) = state.windows.get_mut(&x_window) { - let force_render = std::mem::take( - &mut window.window.state.borrow_mut().force_render_after_recovery, - ); let window = window.window.clone(); drop(state); window.refresh(RequestFrameOptions { require_presentation: false, - force_render, + force_render: false, }); } xcb_connection @@ -2163,6 +2174,20 @@ pub fn mode_refresh_rate(mode: &randr::ModeInfo) -> Duration { Duration::from_micros(micros) } +/// Applies a mapped/obscured change to the refresh loop and reports the +/// resulting visibility to the window. Consumes the client borrow because the +/// visibility callback re-enters GPUI. +fn handle_visibility_changed(mut state: RefMut<'_, X11ClientState>, x_window: xproto::Window) { + state.update_refresh_loop(x_window); + let Some(window_ref) = state.windows.get(&x_window) else { + return; + }; + let visibility = window_ref.visibility(); + let window = window_ref.window.clone(); + drop(state); + window.set_visibility(visibility); +} + fn fp3232_to_f32(value: xinput::Fp3232) -> f32 { value.integral as f32 + value.frac as f32 / u32::MAX as f32 } @@ -2819,7 +2844,9 @@ mod tests { } fn test_keymap_with_variant(layouts: &str, variant: &str) -> xkbc::Keymap { - let context = new_xkb_context().expect("test XKB context should initialize"); + // These fixtures compile layout names from local files, unlike server keymaps. + let context = xkbc::Context::new(xkbc::CONTEXT_NO_FLAGS); + assert!(!context.get_raw_ptr().is_null()); xkbc::Keymap::new_from_names( &context, "", diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index d6db24e2e0..3d33ab0c11 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -7,7 +7,8 @@ use gpui::{ Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, popup::PopupNotSupportedError, px, + WindowDecorations, WindowKind, WindowParams, WindowVisibility, popup::PopupNotSupportedError, + px, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; @@ -245,6 +246,7 @@ pub struct Callbacks { request_frame: Option>, input: Option gpui::DispatchEventResult>>, active_status_change: Option>, + visibility_change: Option>, hovered_status_change: Option>, resize: Option, f32)>>, moved: Option>, @@ -277,8 +279,11 @@ pub struct X11WindowState { maximized_horizontal: bool, hidden: bool, active: bool, + /// Owned by the client's `WindowRef`, which combines the mapped state with + /// `VisibilityNotify`; this is the last value it reported. + visibility: WindowVisibility, hovered: bool, - pub(crate) force_render_after_recovery: bool, + force_render_after_recovery: bool, fullscreen: bool, client_side_decorations_supported: bool, decorations: WindowDecorations, @@ -827,6 +832,8 @@ impl X11WindowState { atoms: *atoms, input_handler: None, active: false, + // The window is not mapped until the client sees `MapNotify`. + visibility: WindowVisibility::Hidden, hovered: false, force_render_after_recovery: false, fullscreen: false, @@ -1174,9 +1181,13 @@ impl X11WindowStatePtr { } } - pub fn refresh(&self, request_frame_options: RequestFrameOptions) { + pub fn refresh(&self, mut request_frame_options: RequestFrameOptions) { let callback = self.callbacks.borrow_mut().request_frame.take(); if let Some(mut fun) = callback { + // Expose events can present a frame before the refresh timer runs, + // so every frame request must rebuild stale atlas references after recovery. + request_frame_options.force_render |= + std::mem::take(&mut self.state.borrow_mut().force_render_after_recovery); fun(request_frame_options); self.callbacks.borrow_mut().request_frame = Some(fun); } @@ -1338,6 +1349,17 @@ impl X11WindowStatePtr { } } + pub fn set_visibility(&self, visibility: WindowVisibility) { + if std::mem::replace(&mut self.state.borrow_mut().visibility, visibility) == visibility { + return; + } + let callback = self.callbacks.borrow_mut().visibility_change.take(); + if let Some(mut fun) = callback { + fun(visibility); + self.callbacks.borrow_mut().visibility_change = Some(fun); + } + } + pub fn set_appearance(&mut self, appearance: WindowAppearance) { let mut state = self.state.borrow_mut(); state.appearance = appearance; @@ -1536,6 +1558,10 @@ impl PlatformWindow for X11Window { self.0.state.borrow().active } + fn visibility(&self) -> WindowVisibility { + self.0.state.borrow().visibility + } + fn is_hovered(&self) -> bool { self.0.state.borrow().hovered } @@ -1683,6 +1709,10 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.callbacks.borrow_mut().hovered_status_change = Some(callback); } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 22bc2ab685..3f57a8ccf8 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/gpui_macos.rs" +doctest = false [features] default = ["gpui/default"] @@ -27,7 +28,6 @@ accesskit.workspace = true accesskit_macos.workspace = true anyhow.workspace = true async-task.workspace = true -block.workspace = true block2.workspace = true cocoa.workspace = true collections.workspace = true @@ -48,12 +48,13 @@ itertools.workspace = true libc.workspace = true log.workspace = true mach2.workspace = true -media.workspace = true -metal.workspace = true objc.workspace = true objc2.workspace = true objc2-app-kit.workspace = true +objc2-core-graphics.workspace = true +objc2-core-media.workspace = true objc2-foundation.workspace = true +objc2-screen-capture-kit.workspace = true objc2-user-notifications.workspace = true parking_lot.workspace = true pathfinder_geometry.workspace = true diff --git a/crates/gpui_macos/src/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs index 0e5ebdcaf3..a50288cd64 100644 --- a/crates/gpui_macos/src/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -1,5 +1,5 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; -use gpui::{PlatformDispatcher, Priority, RunnableMeta, RunnableVariant}; +use gpui::{ActivityGuard, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant}; use gpui_util::ResultExt; use mach2::{ kern_return::KERN_SUCCESS, @@ -18,6 +18,8 @@ use objc::{ runtime::{BOOL, YES}, sel, sel_impl, }; +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString}; use std::{ffi::c_void, ptr::NonNull, time::Duration}; pub(crate) struct MacDispatcher; @@ -76,6 +78,36 @@ impl PlatformDispatcher for MacDispatcher { f(); }); } + + fn prevent_app_nap(&self, reason: &str) -> ActivityGuard { + MacActivity::begin( + reason, + NSActivityOptions::UserInitiatedAllowingIdleSystemSleep, + ) + } +} + +pub(crate) struct MacActivity { + activity: Retained>, +} + +// The activity token returned by NSProcessInfo is thread-safe +unsafe impl Send for MacActivity {} + +impl MacActivity { + pub(crate) fn begin(reason: &str, options: NSActivityOptions) -> ActivityGuard { + let activity = Self { + activity: NSProcessInfo::processInfo() + .beginActivityWithOptions_reason(options, &NSString::from_str(reason)), + }; + ActivityGuard::new(move || drop(activity)) + } +} + +impl Drop for MacActivity { + fn drop(&mut self) { + unsafe { NSProcessInfo::processInfo().endActivity(&self.activity) }; + } } fn set_audio_thread_priority() -> anyhow::Result<()> { diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index c6f75a85bb..bda04696c2 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1,16 +1,16 @@ use crate::{ - BoolExt, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, MacWindow, - events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, + BoolExt, MacActivity, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, + MacWindow, events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, set_active_window_cursor_style, }; use anyhow::{Context as _, anyhow}; -use block::ConcreteBlock; +use block2::RcBlock; use cocoa::{ appkit::{ NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight, NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, NSControl as _, - NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel, - NSVisualEffectState, NSVisualEffectView, NSWindow, + NSEventModifierFlags, NSMenu, NSMenuItem, NSVisualEffectState, NSVisualEffectView, + NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ @@ -29,9 +29,9 @@ use ctor::ctor; use dispatch2::DispatchQueue; use futures::channel::oneshot; use gpui::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, - KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + Action, ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, + ForegroundExecutor, KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, + Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, }; @@ -44,7 +44,12 @@ use objc::{ runtime::{Class, Object, Sel}, sel, sel_impl, }; -use objc2::MainThreadMarker; +use objc2::{MainThreadMarker, runtime::AnyObject}; +use objc2_app_kit::{ + NSModalResponse, NSModalResponseOK, NSOpenPanel, NSSavePanel, NSWorkspace, + NSWorkspaceDidWakeNotification, NSWorkspaceWillSleepNotification, +}; +use objc2_foundation::NSActivityOptions; use parking_lot::Mutex; use ptr::null_mut; use semver::Version; @@ -154,6 +159,11 @@ unsafe fn build_classes() { on_thermal_state_change as extern "C" fn(&mut Object, Sel, id), ); + decl.add_method( + sel!(onSystemSleep:), + on_system_sleep as extern "C" fn(&mut Object, Sel, id), + ); + decl.add_method( sel!(onSystemWake:), on_system_wake as extern "C" fn(&mut Object, Sel, id), @@ -177,8 +187,9 @@ pub(crate) struct MacPlatformState { reopen: Option>, on_keyboard_layout_change: Option>, on_thermal_state_change: Option>, + on_system_sleep: Option>, on_system_wake: Option>, - system_wake_observer_registered: bool, + system_power_observers_registered: bool, quit: Option bool>>, menu_command: Option>, validate_menu_command: Option bool>>, @@ -234,8 +245,9 @@ impl MacPlatform { dock_menu: None, on_keyboard_layout_change: None, on_thermal_state_change: None, + on_system_sleep: None, on_system_wake: None, - system_wake_observer_registered: false, + system_power_observers_registered: false, menus: None, keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), @@ -476,6 +488,28 @@ impl MacPlatform { version.patchVersion, ) } + + // Before the app finishes launching there is no delegate yet; + // `did_finish_launching` registers the observers then. + fn ensure_system_power_observers(&self) { + if self.0.lock().system_power_observers_registered { + return; + } + + // The shared application must be created through `APP_CLASS`, or `run` + // finds a plain `NSApplication` without the `platform` ivar; only the + // delegate lookup goes through the typed binding. + // SAFETY: APP_CLASS is registered during startup and `sharedApplication` + // returns a live NSApplication instance. + let delegate = unsafe { + let app: id = msg_send![APP_CLASS, sharedApplication]; + (*(app as *const objc2_app_kit::NSApplication)).delegate() + }; + if let Some(delegate) = delegate { + register_system_power_observers(delegate.as_ref()); + self.0.lock().system_power_observers_registered = true; + } + } } impl Platform for MacPlatform { @@ -636,7 +670,7 @@ impl Platform for MacPlatform { fn screen_capture_sources( &self, ) -> oneshot::Receiver>>> { - crate::screen_capture::get_sources() + crate::screen_capture::get_sources(self.1) } fn active_window(&self) -> Option { @@ -726,6 +760,8 @@ impl Platform for MacPlatform { } fn register_url_scheme(&self, scheme: &str) -> Task> { + use objc2_foundation::{NSBundle, NSError, NSString}; + // API only available post Monterey // https://developer.apple.com/documentation/appkit/nsworkspace/3753004-setdefaultapplicationaturl let (done_tx, done_rx) = oneshot::channel(); @@ -735,43 +771,42 @@ impl Platform for MacPlatform { ))); } - let bundle_id = unsafe { - let bundle: id = msg_send![class!(NSBundle), mainBundle]; - let bundle_id: id = msg_send![bundle, bundleIdentifier]; - if bundle_id == nil { - return Task::ready(Err(anyhow!("Can only register URL scheme in bundled apps"))); - } - bundle_id + let Some(bundle_id) = NSBundle::mainBundle().bundleIdentifier() else { + return Task::ready(Err(anyhow!("Can only register URL scheme in bundled apps"))); }; - unsafe { - let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; - let scheme: id = ns_string(scheme); - let app: id = msg_send![workspace, URLForApplicationWithBundleIdentifier: bundle_id]; - if app == nil { - return Task::ready(Err(anyhow!( - "Cannot register URL scheme until app is installed" - ))); + let workspace = NSWorkspace::sharedWorkspace(); + let Some(app) = workspace.URLForApplicationWithBundleIdentifier(&bundle_id) else { + return Task::ready(Err(anyhow!( + "Cannot register URL scheme until app is installed" + ))); + }; + + let scheme = NSString::from_str(scheme); + + let done_tx = Cell::new(Some(done_tx)); + let handler = RcBlock::new(move |error: *mut NSError| { + let result = if let Some(error) = unsafe { error.as_ref() } { + Err(anyhow!( + "Failed to register: {}", + error.localizedDescription() + )) + } else { + Ok(()) + }; + + if let Some(done_tx) = done_tx.take() { + _ = done_tx.send(result); } - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |error: id| { - let result = if error == nil { - Ok(()) - } else { - let msg: id = msg_send![error, localizedDescription]; - Err(anyhow!("Failed to register: {msg:?}")) - }; + }); - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(result); - } - }); - let block = block.copy(); - let _: () = msg_send![workspace, setDefaultApplicationAtURL: app toOpenURLsWithScheme: scheme completionHandler: block]; - } + workspace.setDefaultApplicationAtURL_toOpenURLsWithScheme_completionHandler( + &app, + &scheme, + Some(&handler), + ); - self.background_executor() - .spawn(async { done_rx.await.map_err(|e| anyhow!(e))? }) + self.background_executor().spawn(async { done_rx.await? }) } fn on_open_urls(&self, callback: Box)>) { @@ -782,47 +817,45 @@ impl Platform for MacPlatform { &self, options: PathPromptOptions, ) -> oneshot::Receiver>>> { + use objc2_foundation::NSString; + + let marker = self.1; let (done_tx, done_rx) = oneshot::channel(); self.foreground_executor() .spawn(async move { - unsafe { - let panel = NSOpenPanel::openPanel(nil); - panel.setCanChooseDirectories_(options.directories.to_objc()); - panel.setCanChooseFiles_(options.files.to_objc()); - panel.setAllowsMultipleSelection_(options.multiple.to_objc()); + let panel = NSOpenPanel::openPanel(marker); + panel.setCanChooseDirectories(options.directories); + panel.setCanChooseFiles(options.files); + panel.setAllowsMultipleSelection(options.multiple); - panel.setCanCreateDirectories(true.to_objc()); - panel.setResolvesAliases_(false.to_objc()); - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |response: NSModalResponse| { - let result = if response == NSModalResponse::NSModalResponseOk { - let mut result = Vec::new(); - let urls = panel.URLs(); - for i in 0..urls.count() { - let url = urls.objectAtIndex(i); - if url.isFileURL() == YES - && let Ok(path) = ns_url_to_path(url) - { - result.push(path) - } - } - Some(result) - } else { - None + panel.setCanCreateDirectories(true); + panel.setResolvesAliases(false); + + let done_tx = Cell::new(Some(done_tx)); + let handler = RcBlock::new({ + let panel = panel.clone(); + move |response: NSModalResponse| { + let Some(done_tx) = done_tx.take() else { + return; }; - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(Ok(result)); - } - }); - let block = block.copy(); - - if let Some(prompt) = options.prompt { - let _: () = msg_send![panel, setPrompt: ns_string(&prompt)]; + let result = (response == NSModalResponseOK).then(|| { + panel + .URLs() + .iter() + .filter(|url| url.isFileURL()) + .filter_map(|url| url.to_file_path()) + .collect::>() + }); + _ = done_tx.send(Ok(result)); } + }); - let _: () = msg_send![panel, beginWithCompletionHandler: block]; + if let Some(prompt) = options.prompt { + panel.setPrompt(Some(&NSString::from_str(prompt.as_str()))); } + + panel.beginWithCompletionHandler(&handler); }) .detach(); done_rx @@ -833,31 +866,37 @@ impl Platform for MacPlatform { directory: &Path, suggested_name: Option<&str>, ) -> oneshot::Receiver>> { - let directory = directory.to_owned(); - let suggested_name = suggested_name.map(|s| s.to_owned()); + use objc2_foundation::{NSString, NSURL}; + + let url = NSURL::from_directory_path(directory); + let suggested_name = suggested_name.map(NSString::from_str); let (done_tx, done_rx) = oneshot::channel(); + let marker = self.1; self.foreground_executor() .spawn(async move { - unsafe { - let panel = NSSavePanel::savePanel(nil); - let path = ns_string(directory.to_string_lossy().as_ref()); - let url = NSURL::fileURLWithPath_isDirectory_(nil, path, true.to_objc()); - panel.setDirectoryURL(url); + let panel = NSSavePanel::savePanel(marker); + panel.setDirectoryURL(url.as_deref()); - if let Some(suggested_name) = suggested_name { - let name_string = ns_string(&suggested_name); - let _: () = msg_send![panel, setNameFieldStringValue: name_string]; - } + if let Some(suggested_name) = suggested_name { + panel.setNameFieldStringValue(&suggested_name); + } - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |response: NSModalResponse| { - let mut result = None; - if response == NSModalResponse::NSModalResponseOk { - let url = panel.URL(); - if url.isFileURL() == YES { - result = ns_url_to_path(panel.URL()).ok().map(|mut result| { - let Some(filename) = result.file_name() else { - return result; + let done_tx = Cell::new(Some(done_tx)); + let handler = RcBlock::new({ + let panel = panel.clone(); + move |response: NSModalResponse| { + let Some(done_tx) = done_tx.take() else { + return; + }; + + let result = if response == NSModalResponseOK { + panel + .URL() + .filter(|url| url.isFileURL()) + .and_then(|url| url.to_file_path()) + .map(|mut path| { + let Some(filename) = path.file_name() else { + return path; }; let chunks = filename .as_bytes() @@ -871,29 +910,24 @@ impl Platform for MacPlatform { // This is conditional on OS version because I'd like to get rid of it, so that // you can manually create a file called `a.sql.s`. That said it seems better // to break that use-case than breaking `a.sql`. - if chunks.len() == 3 - && chunks[1].starts_with(chunks[2]) + if let &[_, second, third] = chunks.as_slice() + && second.starts_with(third) && Self::os_version() >= Version::new(15, 0, 0) { - let new_filename = OsStr::from_bytes( - &filename.as_bytes() - [..chunks[0].len() + 1 + chunks[1].len()], - ) - .to_owned(); - result.set_file_name(&new_filename); + path.set_extension(""); } - result - }) - } - } - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(Ok(result)); - } - }); - let block = block.copy(); - let _: () = msg_send![panel, beginWithCompletionHandler: block]; - } + path + }) + } else { + None + }; + + _ = done_tx.send(Ok(result)); + } + }); + + panel.beginWithCompletionHandler(&handler); }) .detach(); @@ -972,23 +1006,14 @@ impl Platform for MacPlatform { self.0.lock().on_thermal_state_change = Some(callback); } - fn on_system_wake(&self, callback: Box) { - let mut state = self.0.lock(); - state.on_system_wake = Some(callback); - if state.system_wake_observer_registered { - return; - } - drop(state); + fn on_system_sleep(&self, callback: Box) { + self.0.lock().on_system_sleep = Some(callback); + self.ensure_system_power_observers(); + } - // SAFETY: APP_CLASS is registered during startup and returns the shared NSApplication. - unsafe { - let app: id = msg_send![APP_CLASS, sharedApplication]; - let delegate: id = msg_send![app, delegate]; - if delegate != nil { - register_system_wake_observer(delegate); - self.0.lock().system_wake_observer_registered = true; - } - } + fn on_system_wake(&self, callback: Box) { + self.0.lock().on_system_wake = Some(callback); + self.ensure_system_power_observers(); } fn thermal_state(&self) -> ThermalState { @@ -1005,6 +1030,13 @@ impl Platform for MacPlatform { } } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Ok(MacActivity::begin( + reason, + NSActivityOptions::UserInitiated, + ))) + } + fn show_system_notification(&self, notification: gpui::SystemNotification) { let mut state = self.0.lock(); let executor = state.foreground_executor.clone(); @@ -1304,13 +1336,16 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { object: process_info ]; - let observer = this as *mut Object as id; + // SAFETY: `this` is a live Objective-C object; only the pointer's type changes. + let observer = &*(this as *mut Object as *const AnyObject); let platform = get_mac_platform(this); let callback = { let mut state = platform.0.lock(); - if state.on_system_wake.is_some() && !state.system_wake_observer_registered { - register_system_wake_observer(observer); - state.system_wake_observer_registered = true; + if (state.on_system_sleep.is_some() || state.on_system_wake.is_some()) + && !state.system_power_observers_registered + { + register_system_power_observers(observer); + state.system_power_observers_registered = true; } state.finish_launching.take() }; @@ -1320,17 +1355,25 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { } } -unsafe fn register_system_wake_observer(observer: id) { - // SAFETY: observer is an Objective-C object implementing onSystemWake:. +/// `observer` must be the app delegate, whose class declares `onSystemSleep:` +/// and `onSystemWake:` (see `build_classes`). +fn register_system_power_observers(observer: &AnyObject) { + let center = NSWorkspace::sharedWorkspace().notificationCenter(); + // SAFETY: both selectors are declared on the delegate class with the + // notification-handler signature these observers are invoked with. unsafe { - let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; - let workspace_center: *mut Object = msg_send![workspace, notificationCenter]; - let wake_name = ns_string("NSWorkspaceDidWakeNotification"); - let _: () = msg_send![workspace_center, addObserver: observer - selector: sel!(onSystemWake:) - name: wake_name - object: nil - ]; + center.addObserver_selector_name_object( + observer, + objc2::sel!(onSystemSleep:), + Some(NSWorkspaceWillSleepNotification), + None, + ); + center.addObserver_selector_name_object( + observer, + objc2::sel!(onSystemWake:), + Some(NSWorkspaceDidWakeNotification), + None, + ); } } @@ -1397,6 +1440,30 @@ extern "C" fn on_thermal_state_change(this: &mut Object, _: Sel, _: id) { } } +// Deferred to the main queue like the wake and thermal notifications. The +// machine keeps running the main run loop for well over a frame after the +// observers return, so the block runs before it suspends. +extern "C" fn on_system_sleep(this: &mut Object, _: Sel, _: id) { + // SAFETY: this is the registered app delegate carrying MAC_PLATFORM_IVAR. + let platform = unsafe { get_mac_platform(this) }; + let platform_ptr = platform as *const MacPlatform as *mut c_void; + // SAFETY: platform lives for the process lifetime while callbacks are registered. + unsafe { + DispatchQueue::main().exec_async_f(platform_ptr, on_system_sleep); + } + + extern "C" fn on_system_sleep(context: *mut c_void) { + // SAFETY: context is the MacPlatform pointer queued above. + let platform = unsafe { &*(context as *const MacPlatform) }; + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.on_system_sleep.take() { + drop(lock); + callback(); + platform.0.lock().on_system_sleep.get_or_insert(callback); + } + } +} + extern "C" fn on_system_wake(this: &mut Object, _: Sel, _: id) { // SAFETY: this is the registered app delegate carrying MAC_PLATFORM_IVAR. let platform = unsafe { get_mac_platform(this) }; diff --git a/crates/gpui_macos/src/screen_capture.rs b/crates/gpui_macos/src/screen_capture.rs index 5ece21a34c..6545d473e7 100644 --- a/crates/gpui_macos/src/screen_capture.rs +++ b/crates/gpui_macos/src/screen_capture.rs @@ -1,80 +1,63 @@ -use crate::ns_string; use anyhow::{Result, anyhow}; -use block::ConcreteBlock; -use cocoa::{ - base::{YES, id, nil}, - foundation::NSArray, -}; +use block2::RcBlock; use collections::HashMap; -use core_foundation::base::TCFType; -use core_graphics::display::{ - CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayModeGetPixelHeight, - CGDisplayModeGetPixelWidth, CGDisplayModeRelease, -}; -use ctor::ctor; use futures::channel::oneshot; use gpui::{ DevicePixels, ForegroundExecutor, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, SourceMetadata, size, }; -use media::core_media::{CMSampleBuffer, CMSampleBufferRef}; -use metal::NSInteger; -use objc::{ - class, - declare::ClassDecl, - msg_send, - runtime::{Class, Object, Sel}, - sel, sel_impl, +use objc2::{ + AnyThread, DefinedClass, MainThreadMarker, define_class, msg_send, + rc::Retained, + runtime::{NSObject, ProtocolObject}, }; -use std::{cell::RefCell, ffi::c_void, mem, ptr, rc::Rc}; - -use crate::NSStringExt; +use objc2_app_kit::NSScreen; +use objc2_core_graphics::{CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayMode}; +use objc2_core_media::CMSampleBuffer; +use objc2_foundation::{NSArray, NSError, NSNumber, NSObjectProtocol, NSString}; +use objc2_screen_capture_kit::{ + SCContentFilter, SCDisplay, SCShareableContent, SCStream, SCStreamConfiguration, + SCStreamOutput, SCStreamOutputType, SCWindow, +}; +use std::{cell::RefCell, rc::Rc}; #[derive(Clone)] pub struct MacScreenCaptureSource { - sc_display: id, + sc_display: Retained, meta: Option, } pub struct MacScreenCaptureStream { - sc_stream: id, - sc_stream_output: id, + sc_stream: Retained, + sc_stream_output: Retained, meta: SourceMetadata, } -static mut DELEGATE_CLASS: *const Class = ptr::null(); -static mut OUTPUT_CLASS: *const Class = ptr::null(); -const FRAME_CALLBACK_IVAR: &str = "frame_callback"; - -#[allow(non_upper_case_globals)] -const SCStreamOutputTypeScreen: NSInteger = 0; - -impl ScreenCaptureSource for MacScreenCaptureSource { - fn metadata(&self) -> Result { - let (display_id, size) = unsafe { - let display_id: CGDirectDisplayID = msg_send![self.sc_display, displayID]; - let display_mode_ref = CGDisplayCopyDisplayMode(display_id); - let width = CGDisplayModeGetPixelWidth(display_mode_ref); - let height = CGDisplayModeGetPixelHeight(display_mode_ref); - CGDisplayModeRelease(display_mode_ref); - - ( - display_id, - size(DevicePixels(width as i32), DevicePixels(height as i32)), - ) - }; +impl MacScreenCaptureSource { + fn metadata(&self) -> SourceMetadata { + let display_id = unsafe { self.sc_display.displayID() }; + let display_mode = CGDisplayCopyDisplayMode(display_id); + let width = CGDisplayMode::pixel_width(display_mode.as_deref()); + let height = CGDisplayMode::pixel_height(display_mode.as_deref()); + let size = size(DevicePixels(width as i32), DevicePixels(height as i32)); let (label, is_main) = self .meta .clone() .map(|meta| (meta.label, meta.is_main)) .unzip(); - Ok(SourceMetadata { + SourceMetadata { id: display_id as u64, label, is_main, resolution: size, - }) + } + } +} + +impl ScreenCaptureSource for MacScreenCaptureSource { + fn metadata(&self) -> Result { + Ok(self.metadata()) } fn stream( @@ -82,85 +65,68 @@ impl ScreenCaptureSource for MacScreenCaptureSource { _foreground_executor: &ForegroundExecutor, frame_callback: Box, ) -> oneshot::Receiver>> { + let (sender, receiver) = oneshot::channel(); + let metadata = self.metadata(); + unsafe { - let stream: id = msg_send![class!(SCStream), alloc]; - let filter: id = msg_send![class!(SCContentFilter), alloc]; - let configuration: id = msg_send![class!(SCStreamConfiguration), alloc]; - let delegate: id = msg_send![DELEGATE_CLASS, alloc]; - let output: id = msg_send![OUTPUT_CLASS, alloc]; - - let excluded_windows = NSArray::array(nil); - let filter: id = msg_send![filter, initWithDisplay:self.sc_display excludingWindows:excluded_windows]; - let configuration: id = msg_send![configuration, init]; - let _: id = msg_send![configuration, setScalesToFit: true]; - let _: id = msg_send![configuration, setPixelFormat: 0x42475241]; - // let _: id = msg_send![configuration, setShowsCursor: false]; - // let _: id = msg_send![configuration, setCaptureResolution: 3]; - let delegate: id = msg_send![delegate, init]; - let output: id = msg_send![output, init]; - - output.as_mut().unwrap().set_ivar( - FRAME_CALLBACK_IVAR, - Box::into_raw(Box::new(frame_callback)) as *mut c_void, + let excluded_windows = NSArray::::new(); + let filter = SCContentFilter::initWithDisplay_excludingWindows( + SCContentFilter::alloc(), + &self.sc_display, + &excluded_windows, ); + let configuration = SCStreamConfiguration::new(); + configuration.setScalesToFit(true); + configuration.setPixelFormat(0x42475241); + configuration.setWidth(metadata.resolution.width.0 as usize); + configuration.setHeight(metadata.resolution.height.0 as usize); - let meta = self.metadata().unwrap(); - let _: id = msg_send![configuration, setWidth: meta.resolution.width.0 as i64]; - let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64]; - let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate]; + let stream = SCStream::initWithFilter_configuration_delegate( + SCStream::alloc(), + &filter, + &configuration, + None, + ); + let output = StreamOutput::new(frame_callback); + let output_protocol = ProtocolObject::from_ref(&*output); - // Stream contains filter, configuration, and delegate internally so we release them here - // to prevent a memory leak when steam is dropped - let _: () = msg_send![filter, release]; - let _: () = msg_send![configuration, release]; - let _: () = msg_send![delegate, release]; + if let Err(error) = stream.addStreamOutput_type_sampleHandlerQueue_error( + output_protocol, + SCStreamOutputType::Screen, + None, + ) { + _ = sender.send(Err(anyhow!( + "failed to add stream output: {}", + error.localizedDescription() + ))); - let (tx, rx) = oneshot::channel(); - - let mut error: id = nil; - let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id]; - if error != nil { - let message: id = msg_send![error, localizedDescription]; - let _: () = msg_send![stream, release]; - let _: () = msg_send![output, release]; - tx.send(Err(anyhow!("failed to add stream output {message:?}"))) - .ok(); - return rx; + return receiver; } - let tx = Rc::new(RefCell::new(Some(tx))); - let handler = ConcreteBlock::new({ - move |error: id| { - let result = if error == nil { - let stream = MacScreenCaptureStream { - meta: meta.clone(), - sc_stream: stream, - sc_stream_output: output, - }; - Ok(Box::new(stream) as Box) - } else { - let _: () = msg_send![stream, release]; - let _: () = msg_send![output, release]; - let message: id = msg_send![error, localizedDescription]; - Err(anyhow!("failed to start screen capture stream {message:?}")) - }; - if let Some(tx) = tx.borrow_mut().take() { - tx.send(result).ok(); - } - } - }); - let handler = handler.copy(); - let _: () = msg_send![stream, startCaptureWithCompletionHandler:handler]; - rx - } - } -} + let state = Rc::new(RefCell::new(Some((sender, stream.clone(), output)))); + let handler = RcBlock::new(move |error: *mut NSError| { + let Some((sender, stream, output)) = state.borrow_mut().take() else { + return; + }; + let result = if let Some(error) = error.as_ref() { + Err(anyhow!( + "failed to start screen capture stream: {}", + error.localizedDescription() + )) + } else { + Ok(Box::new(MacScreenCaptureStream { + meta: metadata.clone(), + sc_stream: stream, + sc_stream_output: output, + }) as Box) + }; -impl Drop for MacScreenCaptureSource { - fn drop(&mut self) { - unsafe { - let _: () = msg_send![self.sc_display, release]; + _ = sender.send(result); + }); + stream.startCaptureWithCompletionHandler(Some(&handler)); } + + receiver } } @@ -173,23 +139,27 @@ impl ScreenCaptureStream for MacScreenCaptureStream { impl Drop for MacScreenCaptureStream { fn drop(&mut self) { unsafe { - let mut error: id = nil; - let _: () = msg_send![self.sc_stream, removeStreamOutput:self.sc_stream_output type:SCStreamOutputTypeScreen error:&mut error as *mut _]; - if error != nil { - let message: id = msg_send![error, localizedDescription]; - log::error!("failed to add stream output {message:?}"); + let output = ProtocolObject::from_ref(&*self.sc_stream_output); + if let Err(error) = self + .sc_stream + .removeStreamOutput_type_error(output, SCStreamOutputType::Screen) + { + log::error!( + "failed to remove screen capture stream output: {}", + error.localizedDescription() + ); } - let handler = ConcreteBlock::new(move |error: id| { - if error != nil { - let message: id = msg_send![error, localizedDescription]; - log::error!("failed to stop screen capture stream {message:?}"); + let handler = RcBlock::new(move |error: *mut NSError| { + if let Some(error) = error.as_ref() { + log::error!( + "failed to stop screen capture stream: {}", + error.localizedDescription() + ); } }); - let block = handler.copy(); - let _: () = msg_send![self.sc_stream, stopCaptureWithCompletionHandler:block]; - let _: () = msg_send![self.sc_stream, release]; - let _: () = msg_send![self.sc_stream_output, release]; + self.sc_stream + .stopCaptureWithCompletionHandler(Some(&handler)); } } } @@ -201,144 +171,121 @@ struct ScreenMeta { is_main: bool, } -unsafe fn screen_id_to_human_label() -> HashMap { - let screens: id = msg_send![class!(NSScreen), screens]; - let count: usize = msg_send![screens, count]; +fn screen_id_to_human_label(marker: MainThreadMarker) -> HashMap { + let screens = NSScreen::screens(marker); let mut map = HashMap::default(); - let screen_number_key = unsafe { ns_string("NSScreenNumber") }; - for i in 0..count { - let screen: id = msg_send![screens, objectAtIndex: i]; - let device_desc: id = msg_send![screen, deviceDescription]; - if device_desc == nil { + + let screen_number_key = NSString::from_str("NSScreenNumber"); + + for (i, screen) in screens.iter().enumerate() { + let description = screen.deviceDescription(); + let Some(obj) = description.objectForKey(&screen_number_key) else { continue; - } - - let nsnumber: id = msg_send![device_desc, objectForKey: screen_number_key]; - if nsnumber == nil { + }; + let Some(screen_id) = obj.downcast_ref::() else { continue; - } + }; + let name = screen.localizedName().to_string(); - let screen_id: u32 = msg_send![nsnumber, unsignedIntValue]; - - let name: id = msg_send![screen, localizedName]; - if name != nil { - let cstr: *const std::os::raw::c_char = msg_send![name, UTF8String]; - let rust_str = unsafe { - std::ffi::CStr::from_ptr(cstr) - .to_string_lossy() - .into_owned() - }; - map.insert( - screen_id, - ScreenMeta { - label: rust_str.into(), - is_main: i == 0, - }, - ); - } + map.insert( + screen_id.as_u32(), + ScreenMeta { + label: name.into(), + is_main: i == 0, + }, + ); } + map } -pub(crate) fn get_sources() -> oneshot::Receiver>>> { - unsafe { - let (tx, rx) = oneshot::channel(); - let tx = Rc::new(RefCell::new(Some(tx))); - let screen_id_to_label = screen_id_to_human_label(); - let block = ConcreteBlock::new(move |shareable_content: id, error: id| { +pub(crate) fn get_sources( + marker: MainThreadMarker, +) -> oneshot::Receiver>>> { + let (tx, rx) = oneshot::channel(); + let tx = Rc::new(RefCell::new(Some(tx))); + let screen_id_to_label = screen_id_to_human_label(marker); + + let handler = RcBlock::new( + move |content: *mut SCShareableContent, error: *mut NSError| { let Some(tx) = tx.borrow_mut().take() else { return; }; - let result = if error == nil { - let displays: id = msg_send![shareable_content, displays]; - let mut result = Vec::new(); - for i in 0..displays.count() { - let display = displays.objectAtIndex(i); - let id: CGDirectDisplayID = msg_send![display, displayID]; - let meta = screen_id_to_label.get(&id).cloned(); - let source = MacScreenCaptureSource { - sc_display: msg_send![display, retain], - meta, - }; - result.push(Rc::new(source) as Rc); - } + let result = if let Some(error) = unsafe { error.as_ref() } { + Err(anyhow!( + "Screen share failed: {}", + error.localizedDescription() + )) + } else if let Some(content) = unsafe { content.as_ref() } { + // SAFETY: Marked unsafe conservatively by objc2 + let result = unsafe { content.displays() } + .into_iter() + .map(|display| { + // SAFETY: Marked unsafe conservatively by objc2 + let id = unsafe { display.displayID() }; + let metadata = screen_id_to_label.get(&id).cloned(); + let source = MacScreenCaptureSource { + sc_display: display, + meta: metadata, + }; + Rc::new(source) as Rc + }) + .collect::>(); + Ok(result) } else { - let msg: id = msg_send![error, localizedDescription]; - Err(anyhow!( - "Screen share failed: {:?}", - NSStringExt::to_str(&msg) - )) + // The two pointers are mutually exclusive, this should never happen + Err(anyhow!("Screen share failed")) }; - tx.send(result).ok(); - }); - let block = block.copy(); - let _: () = msg_send![ - class!(SCShareableContent), - getShareableContentExcludingDesktopWindows:YES - onScreenWindowsOnly:YES - completionHandler:block]; - rx - } -} - -#[ctor(unsafe)] -unsafe fn build_classes() { - let mut decl = ClassDecl::new("GPUIStreamDelegate", class!(NSObject)).unwrap(); - unsafe { - decl.add_method( - sel!(outputVideoEffectDidStartForStream:), - output_video_effect_did_start_for_stream as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(outputVideoEffectDidStopForStream:), - output_video_effect_did_stop_for_stream as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(stream:didStopWithError:), - stream_did_stop_with_error as extern "C" fn(&Object, Sel, id, id), - ); - DELEGATE_CLASS = decl.register(); - - let mut decl = ClassDecl::new("GPUIStreamOutput", class!(NSObject)).unwrap(); - decl.add_method( - sel!(stream:didOutputSampleBuffer:ofType:), - stream_did_output_sample_buffer_of_type - as extern "C" fn(&Object, Sel, id, id, NSInteger), - ); - decl.add_ivar::<*mut c_void>(FRAME_CALLBACK_IVAR); - - OUTPUT_CLASS = decl.register(); - } -} - -extern "C" fn output_video_effect_did_start_for_stream(_this: &Object, _: Sel, _stream: id) {} - -extern "C" fn output_video_effect_did_stop_for_stream(_this: &Object, _: Sel, _stream: id) {} - -extern "C" fn stream_did_stop_with_error(_this: &Object, _: Sel, _stream: id, _error: id) {} - -extern "C" fn stream_did_output_sample_buffer_of_type( - this: &Object, - _: Sel, - _stream: id, - sample_buffer: id, - buffer_type: NSInteger, -) { - if buffer_type != SCStreamOutputTypeScreen { - return; - } + _ = tx.send(result); + }, + ); unsafe { - let sample_buffer = sample_buffer as CMSampleBufferRef; - let sample_buffer = CMSampleBuffer::wrap_under_get_rule(sample_buffer); - if let Some(buffer) = sample_buffer.image_buffer() { - let callback: Box> = - Box::from_raw(*this.get_ivar::<*mut c_void>(FRAME_CALLBACK_IVAR) as *mut _); - callback(ScreenCaptureFrame(buffer)); - mem::forget(callback); + SCShareableContent::getShareableContentExcludingDesktopWindows_onScreenWindowsOnly_completionHandler(true, true, &handler); + } + + rx +} + +struct StreamOutputIvars { + frame_callback: Box, +} + +define_class!( + // SAFETY: `NSObject` has no subclassing requirements, and `StreamOutput` does not + // implement `Drop`; `define_class!` manages destruction of its ivars. + #[unsafe(super(NSObject))] + #[ivars = StreamOutputIvars] + struct StreamOutput; + + unsafe impl NSObjectProtocol for StreamOutput {} + + unsafe impl SCStreamOutput for StreamOutput { + #[unsafe(method(stream:didOutputSampleBuffer:ofType:))] + fn stream_did_output_sample_buffer_of_type( + &self, + _stream: &SCStream, + sample_buffer: &CMSampleBuffer, + buffer_type: SCStreamOutputType, + ) { + if buffer_type != SCStreamOutputType::Screen { + return; + } + + let Some(image_buffer) = (unsafe { sample_buffer.image_buffer() }) else { + return; + }; + (self.ivars().frame_callback)(ScreenCaptureFrame(image_buffer)); } } +); + +impl StreamOutput { + fn new(frame_callback: Box) -> Retained { + let this = Self::alloc().set_ivars(StreamOutputIvars { frame_callback }); + unsafe { msg_send![super(this), init] } + } } diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 88a694c971..4156294e5c 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -6,7 +6,6 @@ use crate::{ }; #[cfg(any(test, feature = "test-support"))] use anyhow::Result; -use block::ConcreteBlock; use block2::RcBlock; use cocoa::{ appkit::{ @@ -18,7 +17,7 @@ use cocoa::{ }, base::{id, nil}, foundation::{ - NSArray, NSAutoreleasePool, NSDictionary, NSFastEnumeration, NSInteger, NSNotFound, + NSArray, NSAutoreleasePool, NSData, NSDictionary, NSFastEnumeration, NSInteger, NSNotFound, NSOperatingSystemVersion, NSPoint, NSProcessInfo, NSRect, NSSize, NSString, NSUInteger, NSUserDefaults, }, @@ -31,7 +30,7 @@ use gpui::{ PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, - WindowParams, point, px, size, + WindowParams, WindowVisibility, point, px, size, }; #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; @@ -50,12 +49,18 @@ use objc::{ runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES}, sel, sel_impl, }; -use objc2::{MainThreadMarker, rc::Retained, runtime::AnyObject as Objc2Object}; -use objc2_app_kit::{ - NSAlert, NSAlertStyle, NSBeep, NSButton as Objc2NSButton, NSView as Objc2NSView, - NSWindow as Objc2NSWindow, NSWindowButton as Objc2NSWindowButton, +use objc2::{ + AnyThread, MainThreadMarker, + rc::Retained, + runtime::{AnyObject as Objc2Object, ProtocolObject}, }; -use objc2_foundation::{NSPoint as Objc2NSPoint, NSRect as Objc2NSRect}; +use objc2_app_kit::{ + NSAlert, NSAlertStyle, NSBeep, NSButton as Objc2NSButton, NSDraggingImageComponent, + NSDraggingImageComponentIconKey, NSDraggingItem, NSPasteboardWriting, NSTrackingArea, + NSTrackingAreaOptions, NSView as Objc2NSView, NSWindow as Objc2NSWindow, + NSWindowButton as Objc2NSWindowButton, NSWorkspace, +}; +use objc2_foundation::{NSPoint as Objc2NSPoint, NSRect as Objc2NSRect, NSURL}; use parking_lot::Mutex; use raw_window_handle as rwh; use smallvec::SmallVec; @@ -69,7 +74,7 @@ use std::{ ptr::{self, NonNull}, rc::Rc, sync::{ - Arc, Weak, + Arc, Once, Weak, atomic::{AtomicBool, Ordering}, }, time::Duration, @@ -77,10 +82,14 @@ use std::{ const WINDOW_STATE_IVAR: &str = "windowState"; +static RESTORES_WORKSPACE_AT_LAUNCH_DEFAULT: Once = Once::new(); + static mut WINDOW_CLASS: *const Class = ptr::null(); static mut PANEL_CLASS: *const Class = ptr::null(); static mut VIEW_CLASS: *const Class = ptr::null(); static mut BLURRED_VIEW_CLASS: *const Class = ptr::null(); +static mut WINDOW_STATE_ARCHIVER_DELEGATE_CLASS: *const Class = ptr::null(); +static mut WINDOW_STATE_UNARCHIVER_CLASS: *const Class = ptr::null(); #[allow(non_upper_case_globals)] const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask = @@ -93,14 +102,6 @@ const NSFloatingWindowLevel: NSInteger = 3; #[allow(non_upper_case_globals)] const NSPopUpWindowLevel: NSInteger = 101; #[allow(non_upper_case_globals)] -const NSTrackingMouseEnteredAndExited: NSUInteger = 0x01; -#[allow(non_upper_case_globals)] -const NSTrackingMouseMoved: NSUInteger = 0x02; -#[allow(non_upper_case_globals)] -const NSTrackingActiveAlways: NSUInteger = 0x80; -#[allow(non_upper_case_globals)] -const NSTrackingInVisibleRect: NSUInteger = 0x200; -#[allow(non_upper_case_globals)] const NSWindowAnimationBehaviorUtilityWindow: NSInteger = 4; #[allow(non_upper_case_globals)] const NSViewLayerContentsRedrawDuringViewResize: NSInteger = 2; @@ -121,12 +122,6 @@ pub enum UserTabbingPreference { InFullScreen, } -#[link(name = "AppKit", kind = "framework")] -unsafe extern "C" { - // AppKit constant naming the icon component of an NSDraggingImageComponent. - #[allow(non_upper_case_globals)] - static NSDraggingImageComponentIconKey: id; -} #[ctor(unsafe)] unsafe fn build_classes() { unsafe { @@ -313,6 +308,74 @@ unsafe fn build_classes() { ); decl.register() }; + WINDOW_STATE_ARCHIVER_DELEGATE_CLASS = { + let mut decl = + ClassDecl::new("GPUIWindowStateArchiverDelegate", class!(NSObject)).unwrap(); + decl.add_method( + sel!(archiver:willEncodeObject:), + window_state_archiver_will_encode_object + as extern "C" fn(&Object, Sel, id, id) -> id, + ); + decl.register() + }; + WINDOW_STATE_UNARCHIVER_CLASS = { + let mut decl = + ClassDecl::new("GPUIWindowStateKeyedUnarchiver", class!(NSKeyedUnarchiver)) + .unwrap(); + decl.add_method( + sel!(_windowRestorationOptions), + window_state_unarchiver_restoration_options as extern "C" fn(&Object, Sel) -> id, + ); + decl.register() + }; + } +} + +// NSKeyedArchiverDelegate callback that skips objects which don't adopt `NSSecureCoding` +// (the window itself and its NSView hierarchy), so encoding the window's restorable state +// succeeds. AppKit still encodes the window frame and its persistent window-management +// identifier, which is what the Space restoration on relaunch keys off. +extern "C" fn window_state_archiver_will_encode_object( + _this: &Object, + _sel: Sel, + _archiver: id, + object: id, +) -> id { + // SAFETY: `object` is whatever AppKit hands the delegate during archiving; we only send it + // `isKindOfClass:` with valid class arguments, which is safe for any Objective-C object. + unsafe { + if object.is_null() { + return object; + } + let is_view: BOOL = msg_send![object, isKindOfClass: class!(NSView)]; + let is_window: BOOL = msg_send![object, isKindOfClass: class!(NSWindow)]; + if is_view == YES || is_window == YES { + nil + } else { + object + } + } +} + +// Override of the private `_windowRestorationOptions` on our NSKeyedUnarchiver subclass. +// Returning a default-initialized `NSWindowRestorationOptions` tells AppKit to restore the +// window to its original Space. This is the macOS 15+ path (FB15644170: the +// `NSWindowRestoresWorkspaceAtLaunch` user default no longer works there). +extern "C" fn window_state_unarchiver_restoration_options(_this: &Object, _sel: Sel) -> id { + if !is_macos_version_at_least(NSOperatingSystemVersion::new(15, 0, 0)) { + return nil; + } + // SAFETY: we look the class up by name and only send it `alloc`/`init`/`autorelease`, all of + // which have the standard `-> id` signature. Returning `nil` when the class is absent is valid. + unsafe { + match Class::get("NSWindowRestorationOptions") { + Some(class) => { + let options: id = msg_send![class, alloc]; + let options: id = msg_send![options, init]; + msg_send![options, autorelease] + } + None => nil, + } } } @@ -392,6 +455,10 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C sel!(windowWillExitFullScreen:), window_will_exit_fullscreen as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(windowDidFailToExitFullScreen:), + window_did_fail_to_exit_fullscreen as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(windowDidExitFullScreen:), window_did_exit_fullscreen as extern "C" fn(&Object, Sel, id), @@ -485,6 +552,7 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C } } +#[derive(Clone, Copy)] struct TrafficLightFrames { titlebar: Objc2NSRect, close: Objc2NSRect, @@ -601,6 +669,10 @@ struct MacWindowState { request_frame_callback: Option>, event_callback: Option gpui::DispatchEventResult>>, activate_callback: Option>, + visibility_callback: Option>, + // `None` until a callback is registered, so notifications during + // construction are not queued for delivery to a callback registered later. + last_visibility: Option, resize_callback: Option, f32)>>, moved_callback: Option>, should_close_callback: Option bool>>, @@ -612,6 +684,7 @@ struct MacWindowState { synthetic_drag_counter: usize, traffic_light_position: Option>, traffic_light_frames: Option, + pre_fullscreen_traffic_light_frames: Option, transparent_titlebar: bool, previous_modifiers_changed_event: Option, keystroke_for_do_command: Option, @@ -625,6 +698,7 @@ struct MacWindowState { // windows draw their own titlebar and move the window via `start_window_move`. app_owns_titlebar_drag: bool, fullscreen_restore_bounds: Bounds, + is_exiting_fullscreen: bool, simple_fullscreen_state: Option, move_tab_to_new_window_callback: Option>, merge_all_windows_callback: Option>, @@ -641,7 +715,7 @@ struct MacWindowState { impl MacWindowState { fn move_traffic_light(&mut self) { if let Some(traffic_light_position) = self.traffic_light_position { - if self.is_fullscreen() { + if self.is_fullscreen() && !self.is_exiting_fullscreen { self.restore_traffic_light(); return; } @@ -1034,6 +1108,8 @@ impl MacWindow { request_frame_callback: None, event_callback: None, activate_callback: None, + visibility_callback: None, + last_visibility: None, resize_callback: None, moved_callback: None, should_close_callback: None, @@ -1047,6 +1123,7 @@ impl MacWindow { .as_ref() .and_then(|titlebar| titlebar.traffic_light_position), traffic_light_frames: None, + pre_fullscreen_traffic_light_frames: None, transparent_titlebar: titlebar .as_ref() .is_none_or(|titlebar| titlebar.appears_transparent), @@ -1057,6 +1134,7 @@ impl MacWindow { first_mouse: false, app_owns_titlebar_drag, fullscreen_restore_bounds: Bounds::default(), + is_exiting_fullscreen: false, simple_fullscreen_state: None, move_tab_to_new_window_callback: None, merge_all_windows_callback: None, @@ -1122,15 +1200,21 @@ impl MacWindow { let main_window: id = msg_send![app, mainWindow]; let mut sheet_parent = None; + // SAFETY: This is the live GPUIView (an NSView subclass) added to the + // content view above, and window creation runs on the main thread. + let tracking_view = &*native_view.cast::(); + match kind { WindowKind::Normal | WindowKind::Floating => { if kind == WindowKind::Floating { // Let the window float keep above normal windows. native_window.setLevel_(NSFloatingWindowLevel); + native_window.setAcceptsMouseMovedEvents_(YES); } else { native_window.setLevel_(NSNormalWindowLevel); + native_window.setAcceptsMouseMovedEvents_(NO); + add_mouse_tracking_area(tracking_view); } - native_window.setAcceptsMouseMovedEvents_(YES); if let Some(tabbing_identifier) = tabbing_identifier { let tabbing_id = ns_string(tabbing_identifier.as_str()); @@ -1142,19 +1226,7 @@ impl MacWindow { // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only // for exhaustiveness. WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { - // Use a tracking area to allow receiving MouseMoved events even when - // the window or application aren't active, which is often the case - // e.g. for notification windows. - let tracking_area: id = msg_send![class!(NSTrackingArea), alloc]; - let _: () = msg_send![ - tracking_area, - initWithRect: NSRect::new(NSPoint::new(0., 0.), NSSize::new(0., 0.)) - options: NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | NSTrackingActiveAlways | NSTrackingInVisibleRect - owner: native_view - userInfo: nil - ]; - let _: () = - msg_send![native_view, addTrackingArea: tracking_area.autorelease()]; + add_mouse_tracking_area(tracking_view); native_window.setLevel_(NSPopUpWindowLevel); let _: () = msg_send![ @@ -1303,6 +1375,15 @@ impl MacWindow { impl Drop for MacWindow { fn drop(&mut self) { let mut this = self.0.lock(); + // `accesskit_macos::SubclassingAdapter::for_window` strong-retains the + // window's content view, and that content view keeps the `GPUIView` it + // hosts alive. Together with the `Arc>` parked in + // both views' `windowState` ivar, that forms + // `MacWindowState -> adapter -> content view -> GPUIView -> MacWindowState`, + // a cycle the delegate/`frame_source` teardown below cannot break. Drop + // the adapter here so the native view, its `CAMetalLayer` and the + // renderer's command queue are actually released with the window. + drop(this.accesskit_adapter.take()); this.renderer.destroy(); let window = this.native_window; let sheet_parent = this.sheet_parent.take(); @@ -1311,6 +1392,9 @@ impl Drop for MacWindow { this.native_window.setDelegate_(nil); } this.input_handler.take(); + // A delivery task queued by `report_visibility` may still run after the + // GPUI window is gone; without a callback it has nothing to notify. + this.visibility_callback.take(); this.foreground_executor .spawn(async move { unsafe { @@ -1426,6 +1510,116 @@ impl PlatformWindow for MacWindow { } } + fn native_window_state(&self) -> Option> { + let native_window = { + let state = self.0.lock(); + if state.is_fullscreen() || state.simple_fullscreen_state.is_some() { + return None; + } + state.native_window + }; + // SAFETY: `native_window` is a live `NSWindow` retained by this window's state, and the + // selectors below are AppKit/Foundation methods sent with their documented signatures. The + // archived bytes are copied into an owned `Vec` before the objects we allocated are + // released, so no pointer into Objective-C memory escapes this block. + unsafe { + let archiver: id = msg_send![class!(NSKeyedArchiver), alloc]; + let archiver: id = msg_send![archiver, initRequiringSecureCoding: YES]; + if archiver.is_null() { + log::warn!("failed to create an archiver for the native window state"); + return None; + } + let delegate: id = msg_send![WINDOW_STATE_ARCHIVER_DELEGATE_CLASS, new]; + let _: () = msg_send![archiver, setDelegate: delegate]; + let _: () = msg_send![native_window, encodeRestorableStateWithCoder: archiver]; + let _: () = msg_send![archiver, finishEncoding]; + // The archiver holds a weak reference to its delegate; clear it before the delegate + // is released below. + let _: () = msg_send![archiver, setDelegate: nil]; + + let data: id = msg_send![archiver, encodedData]; + let bytes = if data.is_null() { + ptr::null() + } else { + data.bytes() as *const u8 + }; + let state = if bytes.is_null() { + log::warn!("the archiver produced no data for the native window state"); + None + } else { + Some(std::slice::from_raw_parts(bytes, data.length() as usize).to_vec()) + }; + + let _: () = msg_send![delegate, release]; + let _: () = msg_send![archiver, release]; + state + } + } + + fn restore_native_window_state(&self, state: &[u8]) { + if state.is_empty() { + return; + } + let native_window = self.0.lock().native_window; + // SAFETY: `native_window` is a live `NSWindow` retained by this window's state. The NSData, + // NSKeyedUnarchiver and `restoreStateWithCoder:` selectors are sent with their documented + // signatures, and the `NSData` only borrows `state` for the duration of this synchronous + // call (it is consumed before `state` could be freed). + unsafe { + let data = NSData::dataWithBytes_length_( + nil, + state.as_ptr() as *const c_void, + state.len() as u64, + ); + if data.is_null() { + log::warn!( + "failed to wrap {} bytes of native window state", + state.len() + ); + return; + } + + // On macOS < 15 the `NSWindowRestoresWorkspaceAtLaunch` user default controls whether + // the window is restored to its original Space. On macOS 15+ that default is broken + // (FB15644170), and the `_windowRestorationOptions` override on our unarchiver subclass + // handles it instead. + if !is_macos_version_at_least(NSOperatingSystemVersion::new(15, 0, 0)) { + RESTORES_WORKSPACE_AT_LAUNCH_DEFAULT.call_once(|| { + let defaults: id = NSUserDefaults::standardUserDefaults(); + let key = ns_string("NSWindowRestoresWorkspaceAtLaunch"); + let yes_value: id = msg_send![class!(NSNumber), numberWithBool: YES]; + let dict: id = msg_send![ + class!(NSDictionary), + dictionaryWithObject: yes_value + forKey: key + ]; + let _: () = msg_send![defaults, registerDefaults: dict]; + }); + } + + let unarchiver: id = msg_send![WINDOW_STATE_UNARCHIVER_CLASS, alloc]; + let mut error: id = nil; + let unarchiver: id = + msg_send![unarchiver, initForReadingFromData: data error: &mut error]; + if unarchiver.is_null() { + log::warn!( + "failed to unarchive the native window state: {}", + ns_error_description(error) + ); + return; + } + let _: () = msg_send![native_window, restoreStateWithCoder: unarchiver]; + let error: id = msg_send![unarchiver, error]; + if !error.is_null() { + log::warn!( + "failed to restore the native window state: {}", + ns_error_description(error) + ); + } + let _: () = msg_send![unarchiver, release]; + } + } + fn set_traffic_light_position(&self, position: Point) { let mut state = self.0.lock(); state.traffic_light_position = Some(position); @@ -1627,6 +1821,10 @@ impl PlatformWindow for MacWindow { unsafe { self.0.lock().native_window.isKeyWindow() == YES } } + fn visibility(&self) -> WindowVisibility { + visibility(&self.0.lock()) + } + // is_hovered is unused on macOS. See Window::is_window_hovered. fn is_hovered(&self) -> bool { false @@ -1825,6 +2023,12 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().activate_callback = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + let mut state = self.0.lock(); + state.last_visibility = Some(visibility(&state)); + state.visibility_callback = Some(callback); + } + fn on_hover_status_change(&self, _: Box) {} fn on_resize(&self, callback: Box, f32)>) { @@ -1978,9 +2182,16 @@ impl PlatformWindow for MacWindow { } } "Fill" => { - // There is no documented API for "Fill" action, so we'll just zoom the window if is_resizable { - window.zoom_(nil); + // Unlike `zoom:`, AppKit's private Fill action honors the system's + // "Tiled windows have margins" setting. + let responds_to_zoom_fill: BOOL = + msg_send![window, respondsToSelector: sel!(_zoomFill:)]; + if responds_to_zoom_fill == YES { + let _: () = msg_send![window, _zoomFill: nil]; + } else { + window.zoom_(nil); + } } } _ => { @@ -2014,6 +2225,8 @@ impl PlatformWindow for MacWindow { } fn start_external_drag(&self, payload: &ExternalDragPayload) -> bool { + use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize, NSString}; + let ExternalDragPayload::Files(paths) = payload; if paths.entries().is_empty() { log::warn!("start_external_drag declined: no paths"); @@ -2036,7 +2249,7 @@ impl PlatformWindow for MacWindow { // SAFETY: This method runs on the AppKit/foreground path during drag initiation. The // native view/window are retained by MacWindowState, copied out under a short lock above, - // and Objective-C results that may be nil are checked before use. + // and all pointers passed to Objective-C remain valid for their respective calls. unsafe { let event: id = Retained::as_ptr(&last_left_mouse_down_event) .cast_mut() @@ -2044,7 +2257,7 @@ impl PlatformWindow for MacWindow { let dragging_items: id = msg_send![class!(NSMutableArray), array]; // AppKit keeps this frame's distance from the event's location as the drag image's // offset from the cursor, so it has to stay anchored on `event`. - let location: NSPoint = msg_send![event, locationInWindow]; + let location: cocoa::foundation::NSPoint = msg_send![event, locationInWindow]; let frame = NSRect::new( NSPoint::new(location.x - 16., location.y - 16.), NSSize::new(32., 32.), @@ -2057,24 +2270,18 @@ impl PlatformWindow for MacWindow { continue; }; - let url: id = msg_send![ - class!(NSURL), - fileURLWithFileSystemRepresentation: path_bytes.as_ptr() - isDirectory: is_directory.to_objc() - relativeToURL: nil - ]; + let path_bytes = NonNull::new_unchecked(path_bytes.as_ptr().cast_mut()); + let url = NSURL::fileURLWithFileSystemRepresentation_isDirectory_relativeToURL( + path_bytes, + *is_directory, + None, + ); - if url.is_null() { - log::warn!("start_external_drag skipped path with nil NSURL"); - continue; - } - - let item: id = msg_send![class!(NSDraggingItem), alloc]; - let item: id = msg_send![item, initWithPasteboardWriter: url]; - if item.is_null() { - log::warn!("start_external_drag declined: NSDraggingItem allocation failed"); - continue; - } + let pasteboard_writer = ProtocolObject::::from_ref(&*url); + let item = NSDraggingItem::initWithPasteboardWriter( + NSDraggingItem::alloc(), + pasteboard_writer, + ); // Resolve drag images lazily via `imageComponentsProvider` (Apple's // recommendation for large item counts), and by file *type* rather than @@ -2090,26 +2297,24 @@ impl PlatformWindow for MacWindow { .map(|extension| extension.to_string()) .unwrap_or_else(|| "public.data".to_string()) }; - let provider = ConcreteBlock::new(move || -> id { - let component: id = msg_send![ - class!(NSDraggingImageComponent), - draggingImageComponentWithKey: NSDraggingImageComponentIconKey - ]; - let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; - let icon: id = msg_send![workspace, iconForFileType: ns_string(&file_type)]; - let _: () = msg_send![component, setContents: icon]; + let provider = RcBlock::new(move || { + let component = NSDraggingImageComponent::draggingImageComponentWithKey( + NSDraggingImageComponentIconKey, + ); + let workspace = NSWorkspace::sharedWorkspace(); + let file_type = NSString::from_str(&file_type); + // TODO: Replace with `iconForContentType` once Zed no longer supports MacOS 10.15 + #[expect(deprecated, reason = "Support for MacOS 10.15")] + let icon = workspace.iconForFileType(&file_type); + component.setContents(Some(&icon)); // Component frames are relative to the item's dragging frame. - let _: () = msg_send![ - component, - setFrame: NSRect::new(NSPoint::new(0., 0.), NSSize::new(32., 32.)) - ]; - msg_send![class!(NSArray), arrayWithObject: component] + component.setFrame(NSRect::new(NSPoint::new(0., 0.), NSSize::new(32., 32.))); + let components = NSArray::from_slice(&[&*component]); + NonNull::new_unchecked(Retained::autorelease_return(components)) }); - let provider = provider.copy(); - let _: () = msg_send![item, setDraggingFrame: frame]; - let _: () = msg_send![item, setImageComponentsProvider: provider]; - let _: () = msg_send![dragging_items, addObject: item]; - let _: () = msg_send![item, release]; + item.setDraggingFrame(frame); + item.setImageComponentsProvider(Some(&provider)); + let _: () = msg_send![dragging_items, addObject: Retained::as_ptr(&item)]; } let count: NSUInteger = msg_send![dragging_items, count]; @@ -2280,6 +2485,28 @@ extern "C" fn dealloc_view(this: &Object, _: Sel) { } } +fn add_mouse_tracking_area(native_view: &Objc2NSView) { + let options = NSTrackingAreaOptions::MouseEnteredAndExited + | NSTrackingAreaOptions::MouseMoved + // Track even when another application is active so visible + // windows can respond to hover without being focused. + | NSTrackingAreaOptions::ActiveAlways + | NSTrackingAreaOptions::InVisibleRect; + + // SAFETY: NSView provides the tracking-event callbacks, and the owner is + // the same view that retains the tracking area. No user info is supplied. + let tracking_area = unsafe { + NSTrackingArea::initWithRect_options_owner_userInfo( + NSTrackingArea::alloc(), + Objc2NSRect::ZERO, + options, + Some(native_view), + None, + ) + }; + native_view.addTrackingArea(&tracking_area); +} + extern "C" fn reset_cursor_rects(this: &Object, _: Sel) { // SAFETY: AppKit invokes cursor-rect updates on the main thread for GPUIView instances, // whose WINDOW_STATE_IVAR is initialized when the view is created. The cursor registered @@ -2706,9 +2933,55 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { } } +fn visibility(state: &MacWindowState) -> WindowVisibility { + let is_visible = unsafe { + state + .native_window + .occlusionState() + .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) + }; + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } +} + +fn report_visibility(window_state: &Arc>) { + let state = window_state.lock(); + if state.last_visibility.is_none() { + return; + } + let executor = state.foreground_executor.clone(); + drop(state); + + // AppKit can notify while GPUI is updating a window. Deliver observers + // after that update completes, as activation notifications do. The state + // is read at delivery rather than captured here so a burst of + // notifications collapses to the final value. + executor + .spawn({ + let window_state = window_state.clone(); + async move { + let mut state = window_state.lock(); + let visibility = visibility(&state); + if state.last_visibility == Some(visibility) { + return; + } + state.last_visibility = Some(visibility); + if let Some(mut callback) = state.visibility_callback.take() { + drop(state); + callback(visibility); + window_state.lock().visibility_callback = Some(callback); + } + } + }) + .detach(); +} + extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let lock = &mut *window_state.lock(); + let mut lock = window_state.lock(); unsafe { if lock .native_window @@ -2721,6 +2994,10 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { lock.stop_display_link(); } } + drop(lock); + // The only visibility source: AppKit posts this for covering, minimizing, + // hiding, Space switches, and display sleep alike. + report_visibility(&window_state); } extern "C" fn window_did_resize(this: &Object, _: Sel, _: id) { @@ -2732,6 +3009,8 @@ extern "C" fn window_will_enter_fullscreen(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); lock.fullscreen_restore_bounds = lock.bounds(); + lock.is_exiting_fullscreen = false; + lock.pre_fullscreen_traffic_light_frames = lock.traffic_light_frames; lock.restore_traffic_light(); let min_version = NSOperatingSystemVersion::new(15, 3, 0); @@ -2745,7 +3024,8 @@ extern "C" fn window_will_enter_fullscreen(this: &Object, _: Sel, _: id) { extern "C" fn window_will_exit_fullscreen(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let lock = window_state.as_ref().lock(); + let mut lock = window_state.as_ref().lock(); + lock.is_exiting_fullscreen = true; let min_version = NSOperatingSystemVersion::new(15, 3, 0); @@ -2754,19 +3034,43 @@ extern "C" fn window_will_exit_fullscreen(this: &Object, _: Sel, _: id) { lock.native_window.setTitlebarAppearsTransparent_(YES); } } + + lock.move_traffic_light(); +} + +extern "C" fn window_did_fail_to_exit_fullscreen(this: &Object, _: Sel, _: id) { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.as_ref().lock(); + lock.is_exiting_fullscreen = false; + lock.restore_traffic_light(); } extern "C" fn window_did_exit_fullscreen(this: &Object, _: Sel, _: id) { // SAFETY: This method is registered only on GPUI window classes, which initialize // WINDOW_STATE_IVAR with an Arc> during window creation. let window_state = unsafe { get_window_state(this) }; - window_state.as_ref().lock().move_traffic_light(); + let mut lock = window_state.as_ref().lock(); + lock.is_exiting_fullscreen = false; + // Moving the buttons during the transition captures their fullscreen frames. + // Keep using the native windowed frames when restoring them on the next entry. + lock.traffic_light_frames = lock.pre_fullscreen_traffic_light_frames.take(); + lock.move_traffic_light(); } pub(crate) fn is_macos_version_at_least(version: NSOperatingSystemVersion) -> bool { unsafe { NSProcessInfo::processInfo(nil).isOperatingSystemAtLeastVersion(version) } } +fn ns_error_description(error: id) -> String { + if error.is_null() { + return "unknown error".to_owned(); + } + unsafe { + let description: id = msg_send![error, localizedDescription]; + description.to_str().to_owned() + } +} + extern "C" fn window_did_move(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); diff --git a/crates/gpui_macros/Cargo.toml b/crates/gpui_macros/Cargo.toml index 513dd61d7b..7e705a17b8 100644 --- a/crates/gpui_macros/Cargo.toml +++ b/crates/gpui_macros/Cargo.toml @@ -16,6 +16,7 @@ inspector = [] path = "src/gpui_macros.rs" proc-macro = true doctest = true +test = false [dependencies] heck.workspace = true @@ -24,4 +25,4 @@ quote.workspace = true syn.workspace = true [dev-dependencies] -gpui = { workspace = true, features = ["inspector"] } \ No newline at end of file +gpui = { workspace = true, features = ["inspector"] } diff --git a/crates/gpui_macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs index 51d2a8ab3f..33ff658b68 100644 --- a/crates/gpui_macros/src/derive_into_element.rs +++ b/crates/gpui_macros/src/derive_into_element.rs @@ -17,6 +17,12 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream { fn into_element(self) -> Self::Element { gpui::ViewElement::new(self) } + + #[track_caller] + #[inline(never)] + fn into_any_element(self) -> gpui::AnyElement { + gpui::Element::into_any(self.into_element()) + } } }; diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index 57629a7993..61be065c9d 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -10,20 +10,30 @@ workspace = true [lib] path = "src/gpui_platform.rs" +doctest = false [features] default = [] bench-support = ["gpui/bench-support", "gpui_macos/bench-support"] font-kit = ["gpui_macos/font-kit"] +inspector = ["gpui/inspector"] test-support = ["gpui/test-support", "gpui_macos/test-support", "gpui_windows/test-support"] screen-capture = ["gpui/screen-capture", "gpui_macos/screen-capture", "gpui_windows/screen-capture", "gpui_linux/screen-capture"] runtime_shaders = ["gpui_macos/runtime_shaders"] wayland = ["gpui_linux/wayland"] x11 = ["gpui_linux/x11"] +[[bench]] +name = "inspector_render" +harness = false +required-features = ["bench-support"] + [dependencies] gpui.workspace = true +[dev-dependencies] +criterion.workspace = true + [target.'cfg(target_os = "macos")'.dependencies] gpui_macos.workspace = true diff --git a/crates/gpui_platform/benches/inspector_render.rs b/crates/gpui_platform/benches/inspector_render.rs new file mode 100644 index 0000000000..811155ffb4 --- /dev/null +++ b/crates/gpui_platform/benches/inspector_render.rs @@ -0,0 +1,345 @@ +use std::{cell::Cell, fmt, rc::Rc, time::Duration}; + +use gpui::{ + BenchAppContext, Bounds, Context, InteractiveElement as _, IntoElement, ParentElement as _, + Pixels, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, + prelude::FluentBuilder as _, px, rgb, size, +}; + +const COLUMN_COUNT: usize = 100; +const NODE_SIZE: f32 = 6.0; +const ROW_HEIGHT: f32 = 24.0; +const PANEL_WIDTH: f32 = 320.0; + +#[derive(Default)] +struct RenderWork { + rendered_frames: Cell, + prepainted_children: Cell, + invalid_children: Cell, +} + +impl RenderWork { + fn record_children(&self, bounds: &[Bounds], valid: impl Fn(&Bounds) -> bool) { + self.prepainted_children + .set(self.prepainted_children.get() + bounds.len()); + self.invalid_children.set( + self.invalid_children.get() + bounds.iter().filter(|bounds| !valid(bounds)).count(), + ); + } +} + +struct ClosedInspectorInput { + scale: usize, + unit: &'static str, + open_close_cycles: usize, + other_window_inspected: bool, +} + +impl fmt::Display for ClosedInspectorInput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{}-{}-{}-cycles", + self.scale, self.unit, self.open_close_cycles + )?; + if self.other_window_inspected { + write!(formatter, "-other-window")?; + } + Ok(()) + } +} + +fn closed_inspector_inputs( + unit: &'static str, + scales: impl IntoIterator, +) -> Vec { + let lifecycles = if cfg!(feature = "inspector") { + vec![(0, false), (2, false), (0, true), (2, true)] + } else { + vec![(0, false)] + }; + scales + .into_iter() + .flat_map(|scale| { + lifecycles + .iter() + .map( + move |&(open_close_cycles, other_window_inspected)| ClosedInspectorInput { + scale, + unit, + open_close_cycles, + other_window_inspected, + }, + ) + }) + .collect() +} + +#[gpui::bench( + inputs = closed_inspector_inputs("nodes", [100, 1_000, 10_000]), + input_name = "case", + group = "closed_inspector_grid", + fps = 120 +)] +fn bare_grid(input: &ClosedInspectorInput, cx: &mut BenchAppContext) { + assert_eq!(input.scale % COLUMN_COUNT, 0); + measure_closed_inspector( + input, + cx, + |work| BareGrid { + node_count: input.scale, + alternate: false, + work, + }, + |grid| grid.alternate = !grid.alternate, + ); +} + +#[gpui::bench( + inputs = closed_inspector_inputs("rows", [50, 500]), + input_name = "case", + group = "closed_inspector_panel", + fps = 120 +)] +fn panel(input: &ClosedInspectorInput, cx: &mut BenchAppContext) { + measure_closed_inspector( + input, + cx, + |work| Panel::new(input.scale, work), + |panel| panel.selected_row = (panel.selected_row + 1) % panel.labels.len(), + ); +} + +fn measure_closed_inspector( + input: &ClosedInspectorInput, + cx: &mut BenchAppContext, + build_view: impl FnOnce(Rc) -> V, + mut update_view: impl FnMut(&mut V), +) { + assert!(!cfg!(debug_assertions), "use --profile release-fast"); + let expected_children = input.scale; + + let work = Rc::new(RenderWork::default()); + let mut window = cx.add_empty_window(); + let view = window.update(|window, cx| { + assert!(!window.is_inspector_picking(cx)); + window.replace_root(cx, |_, _| build_view(work.clone())) + }); + cx.run_until_idle(); + for _ in 0..input.open_close_cycles { + for open in [true, false] { + window.update(|window, cx| { + #[cfg(feature = "inspector")] + window.toggle_inspector(cx); + assert_eq!(window.is_inspector_picking(cx), open); + window.refresh(); + }); + cx.run_until_idle(); + } + } + let mut other_window = input.other_window_inspected.then(|| { + let mut window = cx.add_empty_window(); + window.update(|window, cx| { + #[cfg(feature = "inspector")] + window.toggle_inspector(cx); + assert!(window.is_inspector_picking(cx)); + }); + window + }); + cx.run_until_idle(); + let other_frames_before = other_window.as_mut().map(|window| { + window.update(|window, _| { + window + .frame_duration_snapshot() + .draw_duration_histogram + .len() + }) + }); + assert!(work.rendered_frames.get() > 0); + assert_eq!( + work.prepainted_children.get(), + work.rendered_frames.get() * expected_children + ); + assert_eq!(work.invalid_children.get(), 0); + work.rendered_frames.set(0); + work.prepainted_children.set(0); + + let frames_before = window.update(|window, _| window.frame_duration_snapshot()); + let mut updated_frames = 0; + cx.bench_renderer(view, |view, _, cx| { + update_view(view); + updated_frames += 1; + cx.notify(); + }); + let frames_after = window.update(|window, cx| { + assert!(!window.is_inspector_picking(cx)); + window.frame_duration_snapshot() + }); + + let other_frames_after = other_window.as_mut().map(|window| { + window.update(|window, cx| { + assert!(window.is_inspector_picking(cx)); + window + .frame_duration_snapshot() + .draw_duration_histogram + .len() + }) + }); + assert_eq!(other_frames_after, other_frames_before); + assert!(updated_frames > 0); + assert_eq!(work.rendered_frames.get(), updated_frames); + assert_eq!( + work.prepainted_children.get(), + updated_frames * expected_children + ); + assert_eq!(work.invalid_children.get(), 0); + assert_eq!( + frames_after.draw_duration_histogram.len() - frames_before.draw_duration_histogram.len(), + updated_frames as u64 + ); + assert_eq!( + frames_after.dirty_to_present_histogram.len() + - frames_before.dirty_to_present_histogram.len(), + updated_frames as u64 + ); +} + +struct BareGrid { + node_count: usize, + alternate: bool, + work: Rc, +} + +impl Render for BareGrid { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.work + .rendered_frames + .set(self.work.rendered_frames.get() + 1); + let background = rgb(if self.alternate { 0x334455 } else { 0x445566 }); + + div() + .id("bare-grid") + .flex() + .flex_col() + .size(px(COLUMN_COUNT as f32 * NODE_SIZE)) + .children((0..self.node_count / COLUMN_COUNT).map(|row_index| { + let work = self.work.clone(); + div() + .on_children_prepainted(move |bounds, _, _| { + work.record_children(&bounds, |bounds| { + bounds.size == size(px(NODE_SIZE), px(NODE_SIZE)) + }); + }) + .id(("row", row_index)) + .flex() + .h(px(NODE_SIZE)) + .flex_shrink_0() + .children((0..COLUMN_COUNT).map(|column_index| { + div() + .id(("node", column_index)) + .size(px(NODE_SIZE)) + .flex_shrink_0() + .bg(background) + .border_1() + .border_color(rgb(0x8899aa)) + .rounded(px(2.0)) + .when(column_index % 2 == 0, |element| element.cursor_pointer()) + })) + })) + } +} + +struct Panel { + labels: Vec, + counts: Vec, + selected_row: usize, + work: Rc, +} + +impl Panel { + fn new(row_count: usize, work: Rc) -> Self { + Panel { + labels: (0..row_count) + .map(|index| { + SharedString::from(format!("crates/module_{}/src/file_{index}.rs", index % 17)) + }) + .collect(), + counts: (0..row_count) + .map(|index| SharedString::from(((index * 7) % 100).to_string())) + .collect(), + selected_row: 0, + work, + } + } +} + +impl Render for Panel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.work + .rendered_frames + .set(self.work.rendered_frames.get() + 1); + let work = self.work.clone(); + let selected_row = self.selected_row; + + div() + .on_children_prepainted(move |bounds, _, _| { + work.record_children(&bounds, |bounds| bounds.size.height == px(ROW_HEIGHT)); + }) + .id("panel") + .flex() + .flex_col() + .w(px(PANEL_WIDTH)) + .h(px(ROW_HEIGHT * self.labels.len() as f32)) + .bg(rgb(0x1b1f24)) + .children(self.labels.iter().zip(&self.counts).enumerate().map( + |(index, (label, count))| { + div() + .id(("row", index)) + .flex() + .items_center() + .gap_2() + .px_2() + .h(px(ROW_HEIGHT)) + .flex_shrink_0() + .rounded_sm() + .cursor_pointer() + .hover(|style| style.bg(rgb(0x2a3038))) + .active(|style| style.bg(rgb(0x343c46))) + .when(index == selected_row, |row| row.bg(rgb(0x1f4f82))) + .on_click(|_, _, _| {}) + .child( + div() + .size_4() + .flex_shrink_0() + .rounded_sm() + .bg(rgb(if index % 3 == 0 { 0x6aa1ff } else { 0x8fbf6a })), + ) + .child( + div() + .flex_1() + .overflow_hidden() + .text_sm() + .text_color(rgb(0xd0d4da)) + .child(label.clone()), + ) + .child( + div() + .text_xs() + .text_color(rgb(0x8a919c)) + .child(count.clone()), + ) + }, + )) + } +} + +gpui::bench_group! { + name = benches; + config = criterion::Criterion::default() + .sample_size(30) + .warm_up_time(Duration::from_secs(1)) + .measurement_time(Duration::from_secs(5)) + .without_plots(); + targets = bare_grid, panel +} +gpui::bench_main!(benches); diff --git a/crates/gpui_shared_string/Cargo.toml b/crates/gpui_shared_string/Cargo.toml index 36ce41ac7f..c3ba873621 100644 --- a/crates/gpui_shared_string/Cargo.toml +++ b/crates/gpui_shared_string/Cargo.toml @@ -7,6 +7,8 @@ license = "Apache-2.0" [lib] path = "gpui_shared_string.rs" +test = false +doctest = false [dependencies] schemars.workspace = true diff --git a/crates/gpui_tokio/Cargo.toml b/crates/gpui_tokio/Cargo.toml index 5ea343862f..3ad3cd249a 100644 --- a/crates/gpui_tokio/Cargo.toml +++ b/crates/gpui_tokio/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/gpui_tokio.rs" doctest = false +test = false [dependencies] anyhow.workspace = true diff --git a/crates/gpui_util/Cargo.toml b/crates/gpui_util/Cargo.toml index 06810be206..86843d64c0 100644 --- a/crates/gpui_util/Cargo.toml +++ b/crates/gpui_util/Cargo.toml @@ -5,6 +5,9 @@ publish.workspace = true edition.workspace = true license = "Apache-2.0" +[lib] +doctest = false + [dependencies] log.workspace = true anyhow.workspace = true diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index 109ef6f16b..37279de56e 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -4,6 +4,7 @@ use std::{ env, ffi::OsStr, + fmt, ops::AddAssign, panic::Location, pin::Pin, @@ -94,8 +95,12 @@ pub fn get_powershell() -> Option { } fn find_pwsh_in_scoop() -> Option { - let pwsh_exe = - PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe"); + // Scoop can be installed to a custom location; $SCOOP points at the + // scoop root in that case and defaults to %USERPROFILE%\scoop otherwise. + let scoop_dir = std::env::var_os("SCOOP").map(PathBuf::from).or_else(|| { + std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join("scoop")) + })?; + let pwsh_exe = scoop_dir.join("shims").join("pwsh.exe"); pwsh_exe.is_file().then_some(pwsh_exe) } @@ -251,7 +256,7 @@ where Err(error) => { log_error_with_caller( *Location::caller(), - DebugAsDisplay(&error), + format_args!("{:#}", DebugAsDisplay(&error)), log::Level::Error, ); None @@ -277,7 +282,7 @@ where match self { Ok(value) => Some(value), Err(error) => { - log_error_with_caller(*Location::caller(), error, level); + log_error_with_caller(*Location::caller(), format_args!("{error:#}"), level); None } } @@ -291,10 +296,12 @@ where } } -fn log_error_with_caller(caller: core::panic::Location<'_>, error: E, level: log::Level) -where - E: std::fmt::Display, -{ +#[inline(never)] +fn log_error_with_caller( + caller: core::panic::Location<'_>, + arguments: fmt::Arguments<'_>, + level: log::Level, +) { #[cfg(not(windows))] let file = caller.file(); #[cfg(windows)] @@ -316,7 +323,7 @@ where &log::Record::builder() .target(module_path.as_deref().unwrap_or("")) .module_path(file.as_deref()) - .args(format_args!("{:#}", error)) + .args(arguments) .file(Some(caller.file())) .line(Some(caller.line())) .level(level) @@ -326,7 +333,11 @@ where #[track_caller] pub fn log_err(error: &E) { - log_error_with_caller(*Location::caller(), error, log::Level::Error); + log_error_with_caller( + *Location::caller(), + format_args!("{error:#}"), + log::Level::Error, + ); } // Forces `{:?}` formatting through a `Display`-bounded logging helper so `anyhow::Error` emits a @@ -452,7 +463,7 @@ where Poll::Ready(output) => Poll::Ready(match output { Ok(output) => Some(output), Err(error) => { - log_error_with_caller(location, error, level); + log_error_with_caller(location, format_args!("{error:#}"), level); None } }), @@ -479,7 +490,11 @@ where Poll::Ready(output) => Poll::Ready(match output { Ok(output) => Some(output), Err(error) => { - log_error_with_caller(location, DebugAsDisplay(&error), level); + log_error_with_caller( + location, + format_args!("{:#}", DebugAsDisplay(&error)), + level, + ); None } }), @@ -605,3 +620,68 @@ where items.truncate(limit); items.sort_by(compare); } + +#[cfg(test)] +mod logging_tests { + use super::{ResultExt, TryFutureExt, TryFutureExtBacktrace}; + use log::{Level, Log, Metadata, Record}; + use std::{ + cell::RefCell, + future::ready, + pin::pin, + task::{Context, Poll, Waker}, + }; + + #[test] + fn logging_preserves_diagnostics_and_callers() { + log::set_logger(&TestLogger).expect("failed to install test logger"); + let error = anyhow::anyhow!("root failure") + .context("inner context") + .context("outer context"); + let display = "outer context: inner context: root failure"; + let debug = format!("{error:?}"); + let line = line!() + 1; + assert_eq!(Err::<(), _>(&error).log_err(), None); + assert_logged(line, display); + let line = line!() + 1; + assert_eq!(Err::<(), _>(&error).log_err_with_backtrace(), None); + assert_logged(line, &debug); + let mut context = Context::from_waker(Waker::noop()); + let line = line!() + 1; + let mut future = pin!(ready(Err::<(), _>(&error)).log_err()); + assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(None)); + assert_logged(line, display); + let line = line!() + 1; + let mut future = pin!(ready(Err::<(), _>(&error)).log_err_with_backtrace()); + assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(None)); + assert_logged(line, &debug); + } + + thread_local! { + static RECORDS: RefCell, String)>> = const { RefCell::new(Vec::new()) }; + } + + struct TestLogger; + + impl Log for TestLogger { + fn enabled(&self, _: &Metadata<'_>) -> bool { + true + } + + fn log(&self, record: &Record<'_>) { + assert_eq!(record.target(), "gpui_util::lib"); + assert_eq!(record.module_path(), Some("crates/gpui_util/src/lib.rs")); + assert_eq!(record.file(), Some(file!())); + assert_eq!(record.level(), Level::Error); + RECORDS.with_borrow_mut(|records| { + records.push((record.line(), record.args().to_string())); + }); + } + + fn flush(&self) {} + } + + fn assert_logged(line: u32, message: &str) { + assert_eq!(RECORDS.take(), [(Some(line), message.to_owned())]); + } +} diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index 613fbb55bb..80c4ca0dec 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -15,6 +15,12 @@ multithreaded = ["dep:wasm_thread", "scheduler/wasm-threads"] [lib] path = "src/gpui_web.rs" +doctest = false + +[dependencies] +unicode-properties = "=0.1.3" +unicode-script = "0.5.7" +unicode-segmentation = "1.10" [target.'cfg(target_family = "wasm")'.dependencies] gpui.workspace = true @@ -32,6 +38,7 @@ web-time.workspace = true console_error_panic_hook.workspace = true js-sys.workspace = true raw-window-handle.workspace = true +smallvec.workspace = true # Forked because upstream is unmaintained and its worker bootstrap still calls # the wasm-bindgen init function with the positional arguments deprecated in 0.2.93. wasm_thread = { git = "https://github.com/zed-industries/wasm_thread", rev = "0cf96c7708dfb97ccf3da50347e25edcf75d6937", version = "0.3", features = [ @@ -54,11 +61,13 @@ web-sys = { version = "0.3", features = [ "Element", "EventTarget", "File", + "FocusOptions", "HtmlCanvasElement", "HtmlElement", "HtmlTextAreaElement", "IdleDeadline", "IdleRequestOptions", + "ImageData", "InputEvent", "KeyboardEvent", "Location", @@ -66,6 +75,8 @@ web-sys = { version = "0.3", features = [ "MediaQueryListEvent", "MouseEvent", "Navigator", + "OffscreenCanvas", + "OffscreenCanvasRenderingContext2d", "PointerEvent", "ReadableStream", "ReadableStreamDefaultReader", @@ -77,6 +88,7 @@ web-sys = { version = "0.3", features = [ "ResizeObserverOptions", "Screen", "Storage", + "TextMetrics", "VisualViewport", "Headers", "Request", @@ -88,5 +100,10 @@ web-sys = { version = "0.3", features = [ "Window", ] } +[dev-dependencies] +anyhow.workspace = true +gpui.workspace = true +smallvec.workspace = true + [package.metadata.cargo-shear] ignored = ["scheduler"] diff --git a/crates/gpui_web/examples/hello_web/Cargo.toml b/crates/gpui_web/examples/hello_web/Cargo.toml index 92a11dbd71..85450a97f5 100644 --- a/crates/gpui_web/examples/hello_web/Cargo.toml +++ b/crates/gpui_web/examples/hello_web/Cargo.toml @@ -10,8 +10,46 @@ publish = false name = "hello_web" path = "main.rs" +[[bin]] +name = "hello_world" +path = "../../../gpui/examples/hello_world.rs" + +[[bin]] +name = "text" +path = "../../../gpui/examples/text.rs" + +[[bin]] +name = "text_layout" +path = "../../../gpui/examples/text_layout.rs" + +[[bin]] +name = "text_wrapper" +path = "../../../gpui/examples/text_wrapper.rs" + +[[bin]] +name = "input" +path = "../../../gpui/examples/input.rs" + +[[bin]] +name = "font_fallback_test" +path = "font_fallback_test.rs" + +[[bin]] +name = "queue_test" +path = "queue_test.rs" + +[[bin]] +name = "dynamic_fonts" +path = "dynamic_fonts.rs" + [dependencies] +anyhow = "1" +futures = "0.3" gpui = { path = "../../../gpui" } gpui_platform = { path = "../../../gpui_platform" } +gpui_util = { path = "../../../gpui_util" } +unicode-segmentation = "1" +wasm-bindgen = "0.2" +wasm_thread = { git = "https://github.com/zed-industries/wasm_thread", rev = "0cf96c7708dfb97ccf3da50347e25edcf75d6937", features = ["es_modules"] } web-sys = { version = "0.3", features = ["console", "Location", "Window"] } web-time = "1" diff --git a/crates/gpui_web/examples/hello_web/dynamic_fonts.rs b/crates/gpui_web/examples/hello_web/dynamic_fonts.rs new file mode 100644 index 0000000000..33d4d6cc17 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/dynamic_fonts.rs @@ -0,0 +1,130 @@ +use anyhow::{Result, ensure}; +use futures::AsyncReadExt; +use gpui::{ + App, Bounds, Context, Render, Subscription, Task, Window, WindowBounds, WindowOptions, div, + prelude::*, px, rgb, size, +}; +use gpui_util::ResultExt; +use std::borrow::Cow; + +// Pin the raw TTF rather than using Google Fonts CSS, which may supply WOFF2 +// subsets that cannot be passed directly to Cosmic Text. +const FONT_URL: &str = "https://fonts.gstatic.com/s/notosansdevanagari/v30/TuGoUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv3NaV_SNmI0b8QQCQmHn6B2OHjbL_08AlXQly-A.ttf"; +const SAMPLE: &str = "नमस्ते 😀 दुनिया 👨‍👩‍👧‍👦 नमस्ते ❤️"; + +struct DynamicFonts { + status: String, + download: Option>, + _missing_glyphs: Subscription, +} + +impl DynamicFonts { + fn new(cx: &mut Context) -> Self { + let view = cx.weak_entity(); + let subscription = cx.on_missing_glyphs(move |missing, cx| { + if missing.iter().any(|glyph| { + glyph + .grapheme() + .chars() + .any(|character| ('\u{0900}'..='\u{097f}').contains(&character)) + }) { + view.update(cx, |view, cx| view.load_font(cx)).log_err(); + } + }); + Self { + status: "Waiting for a missing Devanagari glyph…".into(), + download: None, + _missing_glyphs: subscription, + } + } + + fn load_font(&mut self, cx: &mut Context) { + // Many missing graphemes can request the same font, including after a failed + // download. Keep the task so this demo makes only one attempt per page load. + if self.download.is_some() { + return; + } + self.status = "Downloading Noto Sans Devanagari (219 KB)…".into(); + cx.notify(); + let http_client = cx.http_client(); + self.download = Some(cx.spawn(async move |view, cx| { + let result: Result> = async { + let mut response = http_client.get(FONT_URL, Default::default(), true).await?; + ensure!( + response.status().is_success(), + "Font request failed: HTTP {}", + response.status() + ); + let mut bytes = Vec::new(); + response.body_mut().read_to_end(&mut bytes).await?; + ensure!(!bytes.is_empty(), "Font response was empty"); + Ok(bytes) + } + .await; + view.update(cx, |view, cx| { + let result = + result.and_then(|bytes| cx.text_system().add_fonts(vec![Cow::Owned(bytes)])); + view.status = match result { + Ok(()) => "Font loaded. The same text is now shaped by Cosmic Text.".into(), + Err(error) => format!("Font loading failed: {error:#}. Reload to retry."), + }; + cx.refresh_windows(); + cx.notify(); + }) + .log_err(); + })); + } +} + +impl Render for DynamicFonts { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_4() + .p_8() + .bg(rgb(0x202030)) + .text_color(rgb(0xffffff)) + .font_family("IBM Plex Sans") + .child(div().text_2xl().child("On-demand font loading")) + .child("Only IBM Plex Sans is bundled. Missing Devanagari triggers the font download.") + .child(div().text_size(px(40.)).child(SAMPLE)) + .child( + div() + .text_size(px(32.)) + .child("Emoji still uses Canvas: 😀 👨‍👩‍👧‍👦"), + ) + .child(self.status.clone()) + .child("Source: Google Fonts CDN · Noto Sans Devanagari · SIL Open Font License") + .child( + "Use the browser Network panel to throttle or block the font request, then reload.", + ) + } +} + +fn main() { + gpui_platform::web_init(); + gpui_platform::application().run(|cx: &mut App| { + if cx + .text_system() + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" + ))]) + .log_err() + .is_none() + { + return; + } + let bounds = Bounds::centered(None, size(px(900.), px(500.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(DynamicFonts::new), + ) + .log_err(); + cx.activate(true); + }); +} diff --git a/crates/gpui_web/examples/hello_web/font_fallback_test.rs b/crates/gpui_web/examples/hello_web/font_fallback_test.rs new file mode 100644 index 0000000000..039da244a5 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/font_fallback_test.rs @@ -0,0 +1,117 @@ +use gpui::{FallbackFontClass, FontRun, MissingGlyph, MissingGlyphSink, font, px}; +use std::{ + borrow::Cow, + sync::{Arc, Mutex}, +}; +use wasm_bindgen::prelude::*; + +fn main() {} + +#[derive(Default)] +struct Reports(Mutex>); + +impl MissingGlyphSink for Reports { + fn report(&self, missing: Vec) { + self.0.lock().expect("report lock").extend(missing); + } +} + +#[wasm_bindgen] +pub fn test_missing_glyph_notifications() -> Result<(), JsValue> { + let platform = gpui_platform::current_platform(false); + let text_system = platform.text_system(); + text_system + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/lilex/Lilex-Regular.ttf" + ))]) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let font_id = text_system + .font_id(&font("Lilex")) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let reports = Arc::new(Reports::default()); + text_system.set_missing_glyph_sink(Some(reports.clone())); + let text = "A界😀👨‍👩‍👧‍👦か\u{3099}"; + let runs = [FontRun { + len: text.len(), + font_id, + }]; + let layout = text_system.layout_line(text, px(24.), &runs); + assert!( + layout + .runs + .iter() + .any(|run| run.glyphs.iter().any(|glyph| glyph.is_emoji)) + ); + let missing = reports.0.lock().expect("report lock").clone(); + assert_eq!( + missing + .iter() + .map(|report| report.grapheme()) + .collect::>(), + ["界", "か\u{3099}"], + ); + assert!( + missing + .iter() + .all(|report| report.font_class() == FallbackFontClass::Monospace) + ); + + for graphemes in [ + vec!["🕸", "🕸", "🕸"], + vec!["🕸", "🕸\u{fe0f}", "🕸\u{fe0e}", "🕸"], + ] { + reports.0.lock().expect("report lock").clear(); + let text = graphemes.concat(); + let layout = text_system.layout_line( + &text, + px(24.), + &[FontRun { + len: text.len(), + font_id, + }], + ); + let glyphs = layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter().map(move |glyph| (run.font_id, glyph))) + .collect::>(); + assert_eq!(glyphs.len(), graphemes.len()); + let mut source_index = 0; + for ((resolved_font, glyph), grapheme) in glyphs.iter().zip(&graphemes) { + assert_ne!( + *resolved_font, font_id, + "Missing Lilex glyph must fall back" + ); + assert_eq!(glyph.index, source_index, "Preserve each source grapheme"); + assert_eq!(glyph.is_emoji, grapheme.ends_with('\u{fe0f}')); + source_index += grapheme.len(); + } + assert_eq!(source_index, text.len()); + assert!( + reports.0.lock().expect("report lock").is_empty(), + "Every spider-web glyph must resolve, not just the first cached raster" + ); + } + + reports.0.lock().expect("report lock").clear(); + let bundled = "Hello ©"; + let layout = text_system.layout_line( + bundled, + px(24.), + &[FontRun { + len: bundled.len(), + font_id, + }], + ); + for run in &layout.runs { + assert_eq!(run.font_id, font_id, "Retain existing bundled text glyphs"); + assert!(run.glyphs.iter().all(|glyph| !glyph.is_emoji)); + } + assert!(reports.0.lock().expect("report lock").is_empty()); + + reports.0.lock().expect("report lock").clear(); + text_system.set_missing_glyph_sink(None); + text_system.layout_line(text, px(24.), &runs); + assert!(reports.0.lock().expect("report lock").is_empty()); + Ok(()) +} diff --git a/crates/gpui_web/examples/hello_web/gallery.js b/crates/gpui_web/examples/hello_web/gallery.js new file mode 100644 index 0000000000..57b40f491f --- /dev/null +++ b/crates/gpui_web/examples/hello_web/gallery.js @@ -0,0 +1,42 @@ +const route = window.location.pathname.replace(/\/+$/, "") || "/"; +const gallery = document.getElementById("gallery"); +const back = document.getElementById("back"); +const status = document.getElementById("status"); +const example = [...gallery.querySelectorAll("a[data-module]")].find( + (link) => new URL(link.href).pathname === route, +); + +function showError(error) { + console.error(error); + status.hidden = false; + status.textContent = `Unable to start example: ${error.message ?? error}`; + status.setAttribute("role", "alert"); +} + +if (route !== "/") { + gallery.hidden = true; + back.hidden = false; + status.hidden = false; + + if (!example) { + showError(new Error(`Unknown example path "${route}". Choose an example from the gallery.`)); + } else { + const title = example.querySelector("strong").textContent; + document.title = `${title} — GPUI Web Examples`; + status.textContent = `Loading ${title}…`; + + try { + if (!window.crossOriginIsolated) { + throw new Error("Shared-memory WASM requires cross-origin isolation. Serve this page with trunk serve."); + } + + const bindings = await import(`/${example.dataset.module}.js`); + await bindings.default(); + window.wasmBindings = bindings; + // GPUI chooses WebGPU or WebGL2 and reports initialization failures itself. + status.hidden = true; + } catch (error) { + showError(error); + } + } +} diff --git a/crates/gpui_web/examples/hello_web/index.html b/crates/gpui_web/examples/hello_web/index.html index 692f3edf06..62680135da 100644 --- a/crates/gpui_web/examples/hello_web/index.html +++ b/crates/gpui_web/examples/hello_web/index.html @@ -3,8 +3,19 @@ - GPUI Web: hello_web + GPUI Web Examples + + + + + + + + + + - + +

+ + + diff --git a/crates/gpui_web/examples/hello_web/main.rs b/crates/gpui_web/examples/hello_web/main.rs index abda6895cb..31f30431e2 100644 --- a/crates/gpui_web/examples/hello_web/main.rs +++ b/crates/gpui_web/examples/hello_web/main.rs @@ -1,9 +1,10 @@ use std::borrow::Cow; +use std::cell::RefCell; use gpui::prelude::*; use gpui::{ - App, Bounds, Context, ElementId, SharedString, Task, Window, WindowBounds, WindowOptions, div, - px, rgb, size, + App, ApplicationHandle, Bounds, Context, ElementId, SharedString, Task, Window, WindowBounds, + WindowOptions, div, px, rgb, size, }; // --------------------------------------------------------------------------- @@ -428,29 +429,36 @@ fn requested_backend() -> gpui_platform::WebBackendPreference { } } +thread_local! { + static APPLICATION: RefCell> = RefCell::new(None); +} + fn main() { gpui_platform::web_init(); - gpui_platform::application_with_web_backend(requested_backend()).run(|cx: &mut App| { - if let Err(error) = cx - .text_system() - .add_fonts(vec![Cow::Borrowed(include_bytes!( - "../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" - ))]) - { - web_sys::console::error_1( - &format!("failed to load application fonts: {error:#}").into(), - ); - return; - } - let bounds = Bounds::centered(None, size(px(640.), px(560.)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }, - |_, cx| cx.new(HelloWeb::new), - ) - .expect("failed to open window"); - cx.activate(true); - }); + let handle = gpui_platform::application_with_web_backend(requested_backend()).run_embedded( + |cx: &mut App| { + if let Err(error) = cx + .text_system() + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" + ))]) + { + web_sys::console::error_1( + &format!("failed to load application fonts: {error:#}").into(), + ); + return; + } + let bounds = Bounds::centered(None, size(px(640.), px(560.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(HelloWeb::new), + ) + .expect("failed to open window"); + cx.activate(true); + }, + ); + APPLICATION.with(|cell| *cell.borrow_mut() = Some(handle)); } diff --git a/crates/gpui_web/examples/hello_web/queue_test.rs b/crates/gpui_web/examples/hello_web/queue_test.rs new file mode 100644 index 0000000000..57f8e70660 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/queue_test.rs @@ -0,0 +1,122 @@ +#![feature(stdarch_wasm_atomic_wait)] + +use gpui::{Priority, PriorityQueueReceiver, PriorityQueueSender}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use wasm_bindgen::prelude::*; + +fn main() {} + +/// Exercises both directions of a queue shared by the browser and real workers. +#[wasm_bindgen] +pub struct QueueStress { + sender: PriorityQueueSender>, + receiver: PriorityQueueReceiver, + ready: Arc, + stopped: Arc, + workers: Vec>, + seen: Vec, + sent: usize, + received: usize, +} + +#[wasm_bindgen] +impl QueueStress { + #[wasm_bindgen(constructor)] + pub fn new(worker_count: usize, item_count: usize) -> Self { + assert!(worker_count > 0 && item_count > 0); + let (sender, receiver) = PriorityQueueReceiver::>::new(); + let (reply_sender, reply_receiver) = PriorityQueueReceiver::new(); + let reply_sender = Arc::new(reply_sender); + let ready = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicUsize::new(0)); + let workers = (0..worker_count) + .map(|_| { + let mut receiver = receiver.clone(); + let reply_sender = reply_sender.clone(); + let ready = ready.clone(); + let stopped = stopped.clone(); + wasm_thread::spawn(move || { + ready.fetch_add(1, Ordering::Release); + while let Some(item) = receiver.pop().expect("receive work") { + reply_sender.send(priority(item), item).expect("send reply"); + } + stopped.fetch_add(1, Ordering::Release); + }) + }) + .collect(); + Self { + sender, + receiver: reply_receiver, + ready, + stopped, + workers, + seen: vec![false; item_count], + sent: 0, + received: 0, + } + } + + pub fn ready(&self) -> bool { + self.ready.load(Ordering::Acquire) == self.workers.len() + } + + pub fn send_batch(&mut self, count: usize) { + let end = (self.sent + count).min(self.seen.len()); + for item in self.sent..end { + self.sender + .spin_send(priority(item), Some(item)) + .expect("send work from browser main thread"); + } + self.sent = end; + } + + pub fn drain(&mut self) -> usize { + while let Some(item) = self + .receiver + .spin_try_pop() + .expect("receive reply on browser main thread") + { + let seen = self.seen.get_mut(item).expect("unexpected reply"); + assert!(!*seen, "duplicate reply"); + *seen = true; + self.received += 1; + } + self.received + } + + pub fn stop(&mut self) { + assert_eq!(self.sent, self.seen.len()); + assert_eq!(self.received, self.sent); + assert!(self.seen.iter().all(|seen| *seen)); + // The queue's existing disconnect behavior does not wake empty receivers. + for _ in &self.workers { + self.sender + .spin_send(Priority::Low, None) + .expect("stop worker"); + } + } + + pub fn stopped(&self) -> bool { + self.stopped.load(Ordering::Acquire) == self.workers.len() + } +} + +fn priority(item: usize) -> Priority { + match item % 3 { + 0 => Priority::High, + 1 => Priority::Medium, + _ => Priority::Low, + } +} + +/// Negative control: a browser-main Wasm atomic wait must trap, even with zero timeout. +#[wasm_bindgen] +pub fn forbidden_main_thread_wait() { + let value = std::sync::atomic::AtomicI32::new(0); + unsafe { + std::arch::wasm32::memory_atomic_wait32(value.as_ptr(), 0, 0); + } +} diff --git a/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs b/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs new file mode 100644 index 0000000000..8e8ba607bf --- /dev/null +++ b/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const origin = new URL(process.argv[2] ?? "http://127.0.0.1:8080").origin; +const chrome = process.env.CHROME ?? + (process.platform === "darwin" + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + : "chromium"); +const output = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "target", + "canvas-fallback-test", + String(Date.now()), +); +fs.mkdirSync(output, { recursive: true }); +const log = fs.openSync(path.join(output, "chrome.log"), "w"); +const browser = spawn(chrome, [ + "--headless=new", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + `--user-data-dir=${path.join(output, "profile")}`, + "about:blank", +], { stdio: ["ignore", log, log] }); +let launchError; +browser.on("error", error => { launchError = error; }); +const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); +let socket; + +try { + let port; + for (let attempt = 0; attempt < 100; attempt++) { + if (launchError) throw launchError; + if (browser.exitCode !== null) throw new Error(`Chrome exited; see ${output}/chrome.log`); + const portFile = path.join(output, "profile", "DevToolsActivePort"); + if (fs.existsSync(portFile)) { + port = fs.readFileSync(portFile, "utf8").split("\n")[0]; + break; + } + await delay(100); + } + assert.ok(port, "Chrome did not start"); + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + socket = new WebSocket(pages.find(page => page.type === "page").webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + + let nextId = 0; + const pending = new Map(); + const errors = []; + socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data); + if (message.id) { + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new Error(JSON.stringify(message.error))); + else request.resolve(message.result); + } else if (message.method === "Runtime.exceptionThrown" || + (message.method === "Runtime.consoleAPICalled" && message.params.type === "error") || + (message.method === "Log.entryAdded" && message.params.entry.text.includes("willReadFrequently"))) { + errors.push(message.params); + } + }); + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++nextId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timed out: ${method}`)); + }, 15000); + timer.unref(); + pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async expression => { + const result = await call("Runtime.evaluate", { + expression, returnByValue: true, awaitPromise: true, + }); + assert.equal(result.exceptionDetails, undefined, JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await call("Runtime.enable"); + await call("Page.enable"); + await call("Log.enable"); + await call("Emulation.setDeviceMetricsOverride", { + width: 1280, height: 900, deviceScaleFactor: 1, mobile: false, + }); + await call("Page.addScriptToEvaluateOnNewDocument", { source: ` + window.canvasCalls = []; + for (const name of ["measureText", "fillText"]) { + const original = OffscreenCanvasRenderingContext2D.prototype[name]; + OffscreenCanvasRenderingContext2D.prototype[name] = function(...args) { + canvasCalls.push({ + type: name, text: args[0], font: this.font, + willReadFrequently: this.getContextAttributes().willReadFrequently, + }); + return original.apply(this, args); + }; + } + ` }); + await call("Page.navigate", { url: `${origin}/input` }); + for (let attempt = 0; attempt < 150; attempt++) { + if (await evaluate('!!document.querySelector("textarea")')) break; + await delay(100); + } + assert.ok(await evaluate('!!document.querySelector("textarea")'), "Input example did not launch"); + await evaluate(`(async () => { + const tests = await import("/font_fallback_test.js"); + await tests.default(); + tests.test_missing_glyph_notifications(); + })()`); + await evaluate("canvasCalls.length = 0"); + await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"); + // Read GPUI's buffer through its Copy action without changing the system clipboard. + await evaluate(` + window.copiedText = ""; + Object.defineProperty(navigator.clipboard, "writeText", { + value: async text => { copiedText = text; }, + }); + window.sendKey = (key, metaKey = false) => { + for (const type of ["keydown", "keyup"]) { + document.querySelector("textarea").dispatchEvent( + new KeyboardEvent(type, { key, metaKey, bubbles: true, cancelable: true }), + ); + } + }; + `); + const key = (key, meta = false) => evaluate(`sendKey(${JSON.stringify(key)}, ${meta})`); + const insert = text => call("Input.insertText", { text }); + const readText = async () => { + await key("a", true); + await evaluate('copiedText = ""'); + await key("c", true); + const text = await evaluate("copiedText"); + await key("ArrowRight"); + return text; + }; + const rasterCount = text => evaluate( + `canvasCalls.filter(call => call.type === "fillText" && call.text === ${JSON.stringify(text)}).length`, + ); + + await insert("Hello ©"); + await delay(200); + assert.equal(await evaluate("canvasCalls.length"), 0, "Bundled glyphs should not use Canvas"); + await key("a", true); + await insert("😀😀😀"); + await delay(200); + assert.equal(await rasterCount("😀"), 1, "Repeated emoji should share one atlas image"); + assert.equal(await readText(), "😀😀😀"); + await key("ArrowLeft"); + await key("ArrowRight"); + await delay(200); + assert.equal(await rasterCount("😀"), 1, "Caret movement should not rerasterize emoji"); + + await key("a", true); + await insert("🕸🕸🕸"); + await delay(200); + assert.equal(await rasterCount("🕸"), 1, "Repeated text-default emoji should share one atlas image"); + assert.equal(await readText(), "🕸🕸🕸"); + await key("ArrowLeft"); + await key("ArrowRight"); + await delay(200); + assert.equal(await rasterCount("🕸"), 1, "Caret movement should not rerasterize text-default emoji"); + + const text = "A中文 が か\u3099 각 각 🕸 🕸\uFE0F 🕸\uFE0E 🕸 😀 ❤️ 👍🏽 🇯🇵 1️⃣ 👩‍💻 👨‍👩‍👧‍👦Z"; + await key("a", true); + await insert(text); + await delay(200); + assert.equal(await readText(), text); + for (const grapheme of ["🕸", "🕸\uFE0F", "🕸\uFE0E", "❤️", "👍🏽", "🇯🇵", "1️⃣", "👩‍💻", "👨‍👩‍👧‍👦"]) { + assert.ok(await rasterCount(grapheme) > 0, `Missing whole-grapheme raster: ${grapheme}`); + } + for (const grapheme of ["中", "文", "が", "か\u3099", "각", "각"]) { + assert.equal(await rasterCount(grapheme), 0, `Default policy should not rasterize CJK: ${grapheme}`); + } + assert.ok( + await evaluate("canvasCalls.every(call => call.willReadFrequently === true)"), + "Glyph canvases should be configured for frequent readback", + ); + const screenshot = await call("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(output, "input.png"), Buffer.from(screenshot.data, "base64")); + await key("Backspace"); + await key("Backspace"); + assert.equal(await readText(), text.slice(0, -"👨‍👩‍👧‍👦Z".length)); + + await key("a", true); + await evaluate("canvasCalls = []"); + await insert("ش"); + await delay(200); + assert.equal(await evaluate("canvasCalls.length"), 0, "Unsupported scripts should stay native"); + assert.deepEqual(errors, [], "Browser reported errors"); + console.log(`Canvas fallback checks passed. Screenshot and browser log: ${output}`); +} finally { + socket?.close(); + browser.kill(); + fs.closeSync(log); +} diff --git a/crates/gpui_web/examples/hello_web/test_queue.mjs b/crates/gpui_web/examples/hello_web/test_queue.mjs new file mode 100644 index 0000000000..31f127e773 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/test_queue.mjs @@ -0,0 +1,119 @@ +// With the hello_web gallery served by Trunk: +// node test_queue.mjs http://127.0.0.1:8080 [worker-count] +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const origin = new URL(process.argv[2] ?? "http://127.0.0.1:8080").origin; +const workers = Number(process.argv[3] ?? 8); +assert.ok(Number.isInteger(workers) && workers > 0); +const chrome = process.env.CHROME ?? + (process.platform === "darwin" + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + : "chromium"); +const output = path.join(path.dirname(fileURLToPath(import.meta.url)), "target", "queue-test", String(Date.now())); +fs.mkdirSync(output, { recursive: true }); +const log = fs.openSync(path.join(output, "chrome.log"), "w"); +const browser = spawn(chrome, [ + "--headless=new", "--no-first-run", "--no-default-browser-check", + "--remote-debugging-port=0", `--user-data-dir=${path.join(output, "profile")}`, + "about:blank", +], { stdio: ["ignore", log, log] }); +const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); +let launchError; +browser.on("error", error => { launchError = error; }); +let socket; +try { + let port; + for (let attempt = 0; attempt < 100; attempt++) { + if (launchError) throw launchError; + if (browser.exitCode !== null) throw new Error(`Chrome exited; see ${output}/chrome.log`); + const portFile = path.join(output, "profile", "DevToolsActivePort"); + if (fs.existsSync(portFile)) { + port = fs.readFileSync(portFile, "utf8").split("\n")[0]; + break; + } + await delay(100); + } + assert.ok(port, "Chrome did not start"); + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + socket = new WebSocket(pages.find(page => page.type === "page").webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + let nextId = 0; + const pending = new Map(); + const errors = []; + socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data); + if (message.id) { + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new Error(JSON.stringify(message.error))); + else request.resolve(message.result); + } else if (message.method === "Runtime.exceptionThrown") { + errors.push(message.params); + } + }); + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++nextId; + // This deadline runs outside the page, so it also catches a main-thread hang. + const timer = setTimeout(() => reject(new Error(`Timed out: ${method}`)), 120_000); + timer.unref(); + pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + await call("Runtime.enable"); + await call("Page.enable"); + const loaded = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Page did not load")), 10_000); + socket.addEventListener("message", function onLoad({ data }) { + if (JSON.parse(data).method === "Page.loadEventFired") { + clearTimeout(timer); + socket.removeEventListener("message", onLoad); + resolve(); + } + }); + }); + await call("Page.navigate", { url: origin }); + await loaded; + const result = await call("Runtime.evaluate", { + awaitPromise: true, returnByValue: true, + expression: `(async () => { + if (!crossOriginIsolated) throw new Error("Shared-memory isolation headers are required"); + const fixture = await import("/queue_test.js"); + await fixture.default(); + let trapped = false; + try { fixture.forbidden_main_thread_wait(); } + catch (error) { trapped = error instanceof WebAssembly.RuntimeError; } + if (!trapped) throw new Error("Main-thread Wasm waits must trap in this browser"); + const rounds = 128, batch = 4096; + const stress = new fixture.QueueStress(${workers}, rounds * batch); + const yieldToBrowser = () => new Promise(resolve => setTimeout(resolve, 2)); + while (!stress.ready()) await yieldToBrowser(); + for (let round = 0; round < rounds; round++) { + stress.send_batch(batch); + while (stress.drain() !== (round + 1) * batch) await yieldToBrowser(); + // Let the request queue empty so workers repeatedly park and get notified. + await yieldToBrowser(); + } + stress.stop(); + while (!stress.stopped()) await yieldToBrowser(); + stress.free(); + return { workers: ${workers}, items: rounds * batch, rounds }; + })()`, + }); + assert.equal(result.exceptionDetails, undefined, JSON.stringify(result.exceptionDetails)); + assert.deepEqual(errors, [], JSON.stringify(errors)); + assert.equal(result.result.value.items, 128 * 4096); + console.log("PASS: main-thread/worker queue contention", result.result.value); +} finally { + socket?.close(); + browser.kill(); + fs.closeSync(log); +} diff --git a/crates/gpui_web/examples/hello_web/trunk.toml b/crates/gpui_web/examples/hello_web/trunk.toml index 5ef787c270..1ec4ed4daf 100644 --- a/crates/gpui_web/examples/hello_web/trunk.toml +++ b/crates/gpui_web/examples/hello_web/trunk.toml @@ -1,3 +1,19 @@ +[build] +# The gallery imports only the selected example instead of starting or preloading the main binary. +pattern_script = "" +pattern_preload = "" +filehash = false + +[watch] +watch = [ + ".", + "../../../gpui/examples", + "../../../gpui/src", + "../../../gpui_platform/src", + "../../../gpui_web/src", + "../../../gpui_wgpu/src", +] + [serve] addresses = ["127.0.0.1"] port = 8080 diff --git a/crates/gpui_web/src/canvas_fallback.rs b/crates/gpui_web/src/canvas_fallback.rs new file mode 100644 index 0000000000..c852cea272 --- /dev/null +++ b/crates/gpui_web/src/canvas_fallback.rs @@ -0,0 +1,490 @@ +//! Eligibility only for independent, horizontal Canvas fallback of a complete +//! extended grapheme. Callers must still check whether bundled shaping supplies +//! the requested glyph and presentation; eligibility alone does not override it. +//! +//! This is intentionally not a Unicode sequence validator or an RGI emoji database. +//! Han variation selectors are checked structurally, not against the IVD registry. +//! Emoji selectors are checked against the Emoji property, not the registered +//! variation-sequence list. Eligibility does not guarantee browser coverage. +//! Flags accept two regional indicators, not only assigned country codes. ZWJ +//! support is limited to person/profession pairs and a small explicit allowlist; +//! tags are limited to the three subdivision flags. Other sequences stay on Cosmic. + +use unicode_properties::{EmojiStatus, GeneralCategory, UnicodeEmoji, UnicodeGeneralCategory}; +use unicode_script::{Script, UnicodeScript}; +use unicode_segmentation::UnicodeSegmentation; + +/// Controls browser-font fallback when loaded fonts lack a glyph or its emoji presentation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum CanvasFontFallback { + /// Use only fonts loaded into GPUI. + Disabled, + /// Use Canvas for eligible emoji, including missing text-presentation symbols. + #[default] + Emoji, + /// Also allow approximate independent rendering of eligible horizontal CJK text. + EmojiAndCjk, +} + +impl CanvasFontFallback { + #[cfg(any(target_family = "wasm", test))] + pub(crate) fn allows(self, is_emoji: bool) -> bool { + match self { + Self::Disabled => false, + Self::Emoji => is_emoji, + Self::EmojiAndCjk => true, + } + } +} + +/// A supported grapheme assumed to be independently renderable. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CanvasFallback { + /// Whether this is an emoji-capable grapheme, independent of its presentation. + pub is_emoji: bool, + /// Whether to request emoji presentation instead of ordinary text presentation. + pub emoji_presentation: bool, +} + +/// Returns a presentation only when `grapheme` is exactly one supported extended +/// grapheme cluster. No normalization, splitting, or font-coverage decision occurs. +pub fn classify_canvas_fallback(grapheme: &str) -> Option { + if grapheme.is_ascii() { + return None; + } + let mut graphemes = grapheme.graphemes(true); + if graphemes.next()? != grapheme || graphemes.next().is_some() { + return None; + } + + if is_cjk(grapheme) { + Some(CanvasFallback { + is_emoji: false, + emoji_presentation: false, + }) + } else { + classify_emoji(grapheme).map(|emoji_presentation| CanvasFallback { + is_emoji: true, + emoji_presentation, + }) + } +} + +fn is_cjk(grapheme: &str) -> bool { + let mut characters = grapheme.chars(); + let Some(base) = characters.next() else { + return false; + }; + let suffix = characters.as_str(); + + // The script/category checks exclude unassigned holes; the ranges exclude + // Han radicals, iteration marks, and other non-ideographic Han characters. + if base.script() == Script::Han + && base.general_category() == GeneralCategory::OtherLetter + && matches!(base, '\u{3400}'..='\u{9fff}' | '\u{f900}'..='\u{faff}' + | '\u{20000}'..='\u{323af}') + { + return suffix.is_empty() + || matches!( + (characters.next(), characters.next()), + (Some('\u{fe00}'..='\u{fe02}' | '\u{e0100}'..='\u{e01ef}'), None) + ); + } + + if matches!(base, '\u{3041}'..='\u{3096}' | '\u{30a1}'..='\u{30fa}') { + return suffix.is_empty() + || match suffix { + "\u{3099}" => "うかきくけこさしすせそたちつてとはひふへほウカキクケコサシスセソタチツテトハヒフヘホワヰヱヲ".contains(base), + "\u{309a}" => "はひふへほハヒフヘホ".contains(base), + _ => false, + }; + } + + if matches!(base, '\u{ac00}'..='\u{d7a3}') { + return suffix.is_empty() + || ((base as u32 - 0xac00).is_multiple_of(28) + && is_single_modern_trailing_jamo(suffix)); + } + if matches!(base, '\u{1100}'..='\u{1112}') { + return matches!(characters.next(), Some('\u{1161}'..='\u{1175}')) + && (characters.as_str().is_empty() + || is_single_modern_trailing_jamo(characters.as_str())); + } + + suffix.is_empty() + && (matches!(base, '\u{3131}'..='\u{3163}') + || "、。〈〉《》「」『』【】〔〕()[]{},.!?:;・ー々〆".contains(base)) +} + +fn is_single_modern_trailing_jamo(text: &str) -> bool { + let mut characters = text.chars(); + matches!(characters.next(), Some('\u{11a8}'..='\u{11c2}')) && characters.next().is_none() +} + +fn classify_emoji(grapheme: &str) -> Option { + let mut characters = grapheme.chars(); + let base = characters.next()?; + let suffix = characters.as_str(); + + if matches!(base, '0'..='9' | '#' | '*') { + return match suffix { + "\u{20e3}" | "\u{fe0f}\u{20e3}" => Some(true), + _ => None, + }; + } + if unicode_properties::emoji::is_regional_indicator(base) { + return (characters + .next() + .is_some_and(unicode_properties::emoji::is_regional_indicator) + && characters.next().is_none()) + .then_some(true); + } + if matches!( + grapheme, + "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}" + | "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}" + | "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}" + ) { + return Some(true); + } + if grapheme.contains('\u{200d}') { + return is_supported_zwj_sequence(grapheme).then_some(true); + } + classify_emoji_unit(grapheme) +} + +fn classify_emoji_unit(text: &str) -> Option { + let mut characters = text.chars(); + let base = characters.next()?; + if !base.is_emoji_char() || base.is_emoji_component() { + return None; + } + let status = base.emoji_status(); + let mut emoji_presentation = matches!( + status, + EmojiStatus::EmojiPresentation | EmojiStatus::EmojiPresentationAndModifierBase + ); + let mut next = characters.next(); + let explicit_text = next == Some('\u{fe0e}'); + if matches!(next, Some('\u{fe0e}' | '\u{fe0f}')) { + emoji_presentation = !explicit_text; + next = characters.next(); + } + if let Some(modifier) = next { + if explicit_text + || !matches!( + status, + EmojiStatus::EmojiModifierBase | EmojiStatus::EmojiPresentationAndModifierBase + ) + || modifier.emoji_status() != EmojiStatus::EmojiPresentationAndModifierAndEmojiComponent + { + return None; + } + emoji_presentation = true; + } + characters.next().is_none().then_some(emoji_presentation) +} + +fn is_supported_zwj_sequence(grapheme: &str) -> bool { + if matches!( + grapheme, + "👨‍👩‍👧" | "👨‍👩‍👧‍👦" + | "👩‍👩‍👧‍👦" + | "👨‍👨‍👧‍👦" + | "🏳️‍🌈" + | "🏳️‍⚧️" + | "🏴‍☠️" + | "❤️‍🔥" + | "❤️‍🩹" + | "👁️‍🗨️" + | "🐻‍❄️" + ) { + return true; + } + let Some((person, profession)) = grapheme.split_once('\u{200d}') else { + return false; + }; + matches!(person.chars().next(), Some('👨' | '👩' | '🧑')) + && classify_emoji_unit(person) == Some(true) + && matches!( + profession, + "⚕️" | "⚖️" + | "✈️" + | "🌾" + | "🍳" + | "🎓" + | "🎤" + | "🎨" + | "🏫" + | "🏭" + | "💻" + | "💼" + | "🔧" + | "🔬" + | "🚀" + | "🚒" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canvas_font_fallback_policy() { + assert_eq!(CanvasFontFallback::default(), CanvasFontFallback::Emoji); + for (grapheme, allowed) in [ + ("😀", true), + ("❤️", true), + ("1️⃣", true), + ("👨‍👩‍👧‍👦", true), + ("🕸", true), + ("🕸\u{fe0e}", true), + ("🕸\u{fe0f}", true), + ("©", true), + ("❤︎", true), + ("中", false), + ("か\u{3099}", false), + ("각", false), + ] { + let fallback = classify_canvas_fallback(grapheme).expect("eligible grapheme"); + assert!(!CanvasFontFallback::Disabled.allows(fallback.is_emoji)); + assert_eq!( + CanvasFontFallback::Emoji.allows(fallback.is_emoji), + allowed, + "{grapheme:?}", + ); + assert!(CanvasFontFallback::EmojiAndCjk.allows(fallback.is_emoji)); + } + } + + #[test] + fn ascii_is_ineligible_but_keycaps_are_preserved() { + for byte in 0..=0x7f_u8 { + assert_eq!(classify_canvas_fallback(&(byte as char).to_string()), None); + } + for text in ["", "Hello", "0123456789#*", "\r\n"] { + assert_eq!(classify_canvas_fallback(text), None); + } + for base in "0123456789#*".chars() { + for text in [format!("{base}\u{20e3}"), format!("{base}\u{fe0f}\u{20e3}")] { + assert_eq!( + classify_canvas_fallback(&text), + Some(CanvasFallback { + is_emoji: true, + emoji_presentation: true, + }), + "{text:?}" + ); + } + } + } + + #[test] + fn ordinary_cjk_clusters() { + for text in [ + "漢", + "𠀀", + "﨑", + "漢\u{fe00}", + "葛\u{e0100}", + "葛\u{e01ef}", + "あ", + "ガ", + "か\u{3099}", + "ハ\u{309a}", + "가", + "각", + "가", + "각", + "각", + "ㄱ", + "、", + "。", + "「", + "」", + "(", + "!", + "ー", + "々", + ] { + assert_eq!( + classify_canvas_fallback(text), + Some(CanvasFallback { + is_emoji: false, + emoji_presentation: false, + }), + "{text:?}" + ); + } + } + + #[test] + fn emoji_presentation_is_preserved() { + for (text, emoji_presentation) in [ + ("😀", true), + ("🕸", false), + ("🕸\u{fe0e}", false), + ("🕸\u{fe0f}", true), + ("©", false), + ("©\u{fe0e}", false), + ("©\u{fe0f}", true), + ("❤", false), + ("❤\u{fe0e}", false), + ("❤\u{fe0f}", true), + ("😀\u{fe0e}", false), + ("👍🏽", true), + ("☝🏽", true), + ("👍\u{fe0f}🏽", true), + ("🇯🇵", true), + ("1\u{20e3}", true), + ("#\u{fe0f}\u{20e3}", true), + ("*\u{fe0f}\u{20e3}", true), + ("👩🏽‍💻", true), + ("🧑‍⚕️", true), + ("👨‍👩‍👧‍👦", true), + ("🏳️‍🌈", true), + ( + "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}", + true, + ), + ( + "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}", + true, + ), + ( + "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}", + true, + ), + ] { + assert_eq!( + classify_canvas_fallback(text), + Some(CanvasFallback { + is_emoji: true, + emoji_presentation, + }), + "{text:?}" + ); + } + } + + #[test] + fn unsupported_scripts_and_cjk_forms_stay_on_cosmic() { + for text in [ + "a", + "é", + "α", + "Ж", + "ش", + "ש", + "क", + "क्ष", + "ก", + "ក", + "ᠠ", + "ཀ", + "\0", + "\n", + "\r\n", + "\u{202e}", + "\u{200d}", + "\u{fdd0}", + "\u{10ffff}", + "\u{2a6e0}", + "\u{e000}", + "\u{3099}", + "\u{fe0f}", + "\u{e0100}", + "漢\u{301}", + "漢\u{fe03}", + "漢\u{fe0f}", + "漢\u{e0100}\u{e0101}", + "あ\u{3099}", + "か\u{309a}", + "か\u{3099}\u{3099}", + "ᄀ", + "ᅡ", + "ᆨ", + "ᄓᅡ", + "ᄀᅶ", + "가ᇃ", + "각ᆨ", + "ᄀ가", + "\u{3164}", + "\u{3165}", + "⺀", + "⼀", + "㇀", + "㆐", + "ㄅ", + "㐀\u{200d}", + "\u{1b000}", + "カ", + "!\u{301}", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + } + + #[test] + fn malformed_or_out_of_policy_emoji_stay_on_cosmic() { + for text in [ + "0", + "#", + "*", + "1\u{fe0f}", + "#\u{fe0e}", + "1\u{fe0e}\u{20e3}", + "a\u{20e3}", + "🏽", + "🇯", + "😀🏽", + "👍🏽🏽", + "👍\u{fe0e}🏽", + "👍🏽\u{fe0f}", + "❤\u{fe0f}\u{fe0f}", + "😀\u{301}", + "🦰", + "😀‍😀", + "👩‍", + "👩‍💻‍🚀", + "👩\u{fe0e}‍💻", + "🏴\u{e0067}", + "🏴\u{e0061}\u{e0062}\u{e007f}", + "😀\u{e0067}\u{e007f}", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + } + + #[test] + fn accepts_only_one_whole_extended_grapheme() { + for text in [ + "", + "漢字", + "あい", + "가나", + "😀😀", + "🇯🇵🇺", + "🇯🇵🇺🇸", + " 漢", + "漢\n", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + let text = "aか\u{3099}👩🏽‍💻漢\u{e0100}"; + let eligible: Vec<_> = text + .grapheme_indices(true) + .filter_map(|(start, grapheme)| { + classify_canvas_fallback(grapheme).map(|_| start..start + grapheme.len()) + }) + .collect(); + assert_eq!( + eligible + .iter() + .map(|range| &text[range.clone()]) + .collect::>(), + ["か\u{3099}", "👩🏽‍💻", "漢\u{e0100}"] + ); + } +} diff --git a/crates/gpui_web/src/canvas_text.rs b/crates/gpui_web/src/canvas_text.rs new file mode 100644 index 0000000000..aab243b5f2 --- /dev/null +++ b/crates/gpui_web/src/canvas_text.rs @@ -0,0 +1,197 @@ +use anyhow::{Context as _, Result, anyhow, ensure}; +use gpui::{Bounds, DevicePixels}; +use std::cell::RefCell; +use wasm_bindgen::{JsCast as _, JsValue}; +use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d}; + +const MAX_RASTER_DIMENSION: u32 = 4096; +const MAX_RASTER_PIXELS: usize = 4 * 1024 * 1024; + +thread_local! { + static CANVAS: RefCell> = const { RefCell::new(None) }; +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct CanvasTextMetrics { + pub(crate) advance: f32, + pub(crate) left: f32, + pub(crate) right: f32, + pub(crate) ascent: f32, + pub(crate) descent: f32, +} + +struct TextCanvas { + canvas: OffscreenCanvas, + context: OffscreenCanvasRenderingContext2d, +} + +impl TextCanvas { + fn new() -> Result { + let canvas = OffscreenCanvas::new(1, 1) + .map_err(|error| anyhow!("creating text OffscreenCanvas: {error:?}"))?; + let options = js_sys::Object::new(); + // Every rasterized glyph is read back for upload into GPUI's atlas. + let assigned = js_sys::Reflect::set( + &options, + &JsValue::from_str("willReadFrequently"), + &JsValue::TRUE, + ) + .map_err(|error| anyhow!("setting Canvas readback option: {error:?}"))?; + ensure!(assigned, "Canvas readback option could not be set"); + let context = canvas + .get_context_with_context_options("2d", &options) + .map_err(|error| anyhow!("getting OffscreenCanvas 2D context: {error:?}"))? + .context("OffscreenCanvas 2D text rendering is unavailable")? + .dyn_into::() + .map_err(|error| anyhow!("unexpected OffscreenCanvas 2D context: {error:?}"))?; + Ok(Self { canvas, context }) + } + + fn configure(&self, css_font: &str) -> Result<()> { + ensure!(!css_font.trim().is_empty(), "Canvas text font is empty"); + // An invalid CSS font assignment is ignored by Canvas. Reset first so + // it cannot accidentally inherit the preceding request's font. + self.context.set_font("10px sans-serif"); + self.context.set_font(css_font); + self.context.set_text_align("left"); + self.context.set_text_baseline("alphabetic"); + self.context.set_fill_style_str("white"); + // web-sys does not expose direction on the offscreen 2D context. + let assigned = js_sys::Reflect::set( + self.context.as_ref(), + &JsValue::from_str("direction"), + &JsValue::from_str("ltr"), + ) + .map_err(|error| anyhow!("setting Canvas text direction: {error:?}"))?; + ensure!(assigned, "Canvas text direction could not be set"); + Ok(()) + } +} + +fn with_canvas(operation: impl FnOnce(&mut TextCanvas) -> Result) -> Result { + CANVAS.with(|canvas| { + let mut canvas = canvas + .try_borrow_mut() + .context("Canvas text renderer was called reentrantly")?; + if canvas.is_none() { + *canvas = Some(TextCanvas::new()?); + } + operation( + canvas + .as_mut() + .context("Canvas text renderer is unavailable")?, + ) + }) +} + +pub(crate) fn measure(text: &str, css_font: &str) -> Result { + with_canvas(|canvas| { + canvas.configure(css_font)?; + let metrics = canvas + .context + .measure_text(text) + .map_err(|error| anyhow!("measuring Canvas text: {error:?}"))?; + let metrics = CanvasTextMetrics { + advance: metrics.width() as f32, + left: -metrics.actual_bounding_box_left() as f32, + right: metrics.actual_bounding_box_right() as f32, + ascent: metrics.actual_bounding_box_ascent() as f32, + descent: metrics.actual_bounding_box_descent() as f32, + }; + ensure!( + [ + metrics.advance, + metrics.left, + metrics.right, + metrics.ascent, + metrics.descent + ] + .into_iter() + .all(f32::is_finite), + "Canvas returned non-finite text metrics" + ); + Ok(metrics) + }) +} + +pub(crate) fn rasterize( + text: &str, + css_font: &str, + bounds: Bounds, + subpixel_offset: (f32, f32), + color: bool, +) -> Result> { + ensure!( + subpixel_offset.0.is_finite() && subpixel_offset.1.is_finite(), + "Canvas text subpixel offset must be finite" + ); + let width = u32::try_from(bounds.size.width.0) + .context("Canvas text raster width must be nonnegative")?; + let height = u32::try_from(bounds.size.height.0) + .context("Canvas text raster height must be nonnegative")?; + if width == 0 || height == 0 { + return Ok(Vec::new()); + } + ensure!( + width <= MAX_RASTER_DIMENSION && height <= MAX_RASTER_DIMENSION, + "Canvas text raster exceeds maximum dimension {MAX_RASTER_DIMENSION}: {width}x{height}" + ); + let pixel_count = usize::try_from(width)? + .checked_mul(usize::try_from(height)?) + .context("Canvas text raster pixel count overflow")?; + ensure!( + pixel_count <= MAX_RASTER_PIXELS, + "Canvas text raster exceeds maximum pixel count {MAX_RASTER_PIXELS}: {width}x{height}" + ); + let byte_count = pixel_count + .checked_mul(4) + .context("Canvas text raster byte count overflow")?; + + with_canvas(|canvas| { + // Shrink before growing so intermediate canvas sizes obey the pixel cap. + if height < canvas.canvas.height() { + canvas.canvas.set_height(height); + } + if canvas.canvas.width() != width { + canvas.canvas.set_width(width); + } + if canvas.canvas.height() != height { + canvas.canvas.set_height(height); + } + canvas.configure(css_font)?; + canvas + .context + .clear_rect(0.0, 0.0, f64::from(width), f64::from(height)); + canvas + .context + .fill_text( + text, + -f64::from(bounds.origin.x.0) + f64::from(subpixel_offset.0), + -f64::from(bounds.origin.y.0) + f64::from(subpixel_offset.1), + ) + .map_err(|error| anyhow!("drawing Canvas text: {error:?}"))?; + let image = canvas + .context + .get_image_data(0.0, 0.0, f64::from(width), f64::from(height)) + .map_err(|error| anyhow!("reading Canvas text raster pixels: {error:?}"))?; + let mut pixels = image.data().0; + ensure!( + pixels.len() == byte_count, + "Canvas text raster returned {} bytes, expected {byte_count}", + pixels.len() + ); + if color { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Ok(pixels) + } else { + let mut alpha = Vec::new(); + alpha + .try_reserve_exact(pixel_count) + .context("allocating Canvas text alpha mask")?; + alpha.extend(pixels.chunks_exact(4).map(|pixel| pixel[3])); + Ok(alpha) + } + }) +} diff --git a/crates/gpui_web/src/dispatcher.rs b/crates/gpui_web/src/dispatcher.rs index 37ba891f70..721ce6f7c2 100644 --- a/crates/gpui_web/src/dispatcher.rs +++ b/crates/gpui_web/src/dispatcher.rs @@ -51,7 +51,7 @@ enum MainThreadItem { struct MainThreadMailbox { sender: PriorityQueueSender, - receiver: parking_lot::Mutex>, + receiver: std::sync::Mutex>, signal: AtomicI32, } @@ -60,7 +60,7 @@ impl MainThreadMailbox { let (sender, receiver) = PriorityQueueReceiver::new(); Self { sender, - receiver: parking_lot::Mutex::new(receiver), + receiver: std::sync::Mutex::new(receiver), signal: AtomicI32::new(0), } } @@ -77,7 +77,11 @@ impl MainThreadMailbox { } fn drain(&self, window: &web_sys::Window) { - let mut receiver = self.receiver.lock(); + // Only the main thread accesses this receiver; never park if that changes. + let mut receiver = self + .receiver + .try_lock() + .expect("main-thread mailbox receiver must not be locked"); loop { // We need these `spin` variants because we can't acquire a lock on the main thread. // TODO-WASM: Should we do something different? diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 7afedeb4e8..bc854cca7d 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -172,10 +172,29 @@ impl WebWindowInner { handles.extend(self.register_visibility_change()); handles.extend(self.register_appearance_change()); handles.extend(self.register_fullscreen_change()); + handles.extend(self.register_viewport_changes()); WebEventListeners { _handles: handles } } + fn register_viewport_changes(self: &Rc) -> Vec { + let mut targets: Vec = vec![self.browser_window.clone().into()]; + if let Some(viewport) = self.browser_window.visual_viewport() { + targets.push(viewport.into()); + } + targets + .into_iter() + .flat_map(|target| { + ["resize", "scroll"].map(|event_name| { + let this = Rc::clone(self); + EventListenerHandle::add(&target, event_name, move |_| { + this.notify_viewport_changed() + }) + }) + }) + .collect() + } + fn listen( self: &Rc, event_name: &'static str, @@ -233,6 +252,13 @@ impl WebWindowInner { this.canvas.set_pointer_capture(event.pointer_id()).ok(); if pointer_type == "touch" { + // Android can reopen a manually dismissed keyboard on the + // next touch even without another focus() call. Disarm the + // still-focused mirror before the browser handles that + // gesture; only an editable tap may enable its keyboard again. + if this.keyboard_likely_dismissed() { + this.ime_mirror.set_virtual_keyboard_enabled(false); + } let Some(touch_id) = this.touch_ids.borrow_mut().start(event.pointer_id()) else { log::error!("exhausted touch identifiers"); return; @@ -292,11 +318,6 @@ impl WebWindowInner { .unwrap_or(false) } - fn focused_input_accepts_text(&self) -> bool { - self.with_input_handler(|handler| handler.query_accepts_text_input()) - .unwrap_or(false) - } - fn register_pointer_up(self: &Rc) -> EventListenerHandle { let this = Rc::clone(self); self.listen("pointerup", move |event: JsValue| { @@ -317,7 +338,6 @@ impl WebWindowInner { } _ => false, }; - let focused_input_accepted_text_before_tap = this.focused_input_accepts_text(); // A recognized tap is dispatched synchronously inside this // call, so the text-input check below sees the state the tap // produced. @@ -331,22 +351,19 @@ impl WebWindowInner { // A keyboard opening or closing mid-gesture reflows the // layout, so the release position no longer refers to the - // content the user aimed at (a tap that summoned the keyboard - // often ends up below the shrunken layout, which would - // immediately dismiss it again). Skip the sync then, and for - // anything that wasn't a tap: pans and flings must not move - // keyboard or IME focus at all. + // content the user aimed at. Do not summon the keyboard + // from that stale position, or from pans and flings. let viewport_stable = this.gesture_start_visual_viewport_height.get() == this.visual_viewport_height(); - if completes_tap && viewport_stable { - let preserve_focused_input = should_preserve_focused_input( - focused_input_accepted_text_before_tap, - this.focused_input_accepts_text(), - dispatch_result, - ); - if !preserve_focused_input { - this.sync_virtual_keyboard(this.pointer_targets_text_input(position)); - } + // A consumed tap may navigate to an editor under this same + // coordinate. Do not reinterpret that navigation as editing. + // Controls that intend typing can explicitly request a keyboard. + if completes_tap + && viewport_stable + && dispatch_result.is_some_and(|result| !result.default_prevented) + && this.pointer_targets_text_input(position) + { + this.show_virtual_keyboard(); } this.schedule_ime_mirror_sync(); return; @@ -383,44 +400,41 @@ impl WebWindowInner { .map_or(0.0, |viewport| viewport.height() * viewport.scale()) } - /// Whether the software keyboard is likely hidden — a heuristic, since - /// no cross-browser keyboard-visibility signal exists. It infers from - /// the visual viewport: a shown keyboard shrinks its height well below - /// the greatest height seen at the current width (the width only changes - /// on rotation, which restarts the calibration). `window.innerHeight` - /// can't serve as the reference because Android shrinks it along with - /// the keyboard. Unknown states err toward "visible" so ordinary - /// editable taps don't gratuitously restart the IME session. + /// Infers dismissal only when the visual viewport fills the layout viewport. /// - /// Restricted to coarse-pointer environments: elsewhere (desktop - /// browsers, including touchscreen laptops) viewport height tracks - /// user window resizes rather than a software keyboard, so the - /// calibration would misfire. Split-screen resizes on mobile can still - /// fool it; tracking `visualViewport` resize events around focus - /// transitions would be sturdier. + /// This fallback requires `interactive-widget=resizes-visual` (the modern + /// browser default). It deliberately makes no inference while zoomed, or + /// when the host opts into another resize policy. Floating keyboards can + /// still overlay both viewports; this is not authoritative keyboard state. fn keyboard_likely_dismissed(&self) -> bool { - let coarse_pointer = self - .browser_window - .match_media("(pointer: coarse)") - .ok() - .flatten() - .is_some_and(|media_query_list| media_query_list.matches()); - if !coarse_pointer { + if !self.touch_input { return false; } let Some(viewport) = self.browser_window.visual_viewport() else { return false; }; - let width = viewport.width() * viewport.scale(); - let height = viewport.height() * viewport.scale(); - let (probe_width, probe_height) = self.visual_viewport_probe.get(); - let max_height = if width == probe_width { - probe_height.max(height) - } else { - height + let Some(document) = self.browser_window.document() else { + return false; }; - self.visual_viewport_probe.set((width, max_height)); - height >= max_height * 0.85 + if let Ok(Some(meta)) = document.query_selector("meta[name=viewport]") + && let Some(content) = meta.get_attribute("content") + && content.split(',').any(|directive| { + directive.split_once('=').is_some_and(|(key, value)| { + key.trim().eq_ignore_ascii_case("interactive-widget") + && !value.trim().eq_ignore_ascii_case("resizes-visual") + }) + }) + { + return false; + } + let Some(root) = document.document_element() else { + return false; + }; + (viewport.scale() - 1.).abs() < 0.001 + && viewport.offset_top().abs() < 1. + && viewport.offset_left().abs() < 1. + && (viewport.height() - root.client_height() as f64).abs() < 1. + && (viewport.width() - root.client_width() as f64).abs() < 1. } /// The browser or OS took over the pointer (native scrolling, a system @@ -472,17 +486,17 @@ impl WebWindowInner { /// focused from within a user gesture, so this runs while the tap's /// `pointerup` is still on the stack (by which point GPUI has usually /// painted a frame since the `MouseDown`, so the input handler reflects - /// the tap's focus change). `readOnly` suppresses the keyboard while - /// keeping the hidden input available to the IME. Leaving it blurred after - /// a non-editable tap lets the next editable tap establish a new input - /// session instead of relying on a same-task blur/focus cycle, which iOS - /// may coalesce. + /// the tap's focus change). This also handles an editable tap after manual + /// dismissal, when editability stayed true and no focus transition fires. /// /// We don't use `navigator.virtualKeyboard` here because it's /// Chromium-only. - fn sync_virtual_keyboard(self: &Rc, editable: bool) { - let was_editable = !self.ime_mirror.read_only(); - self.ime_mirror.set_read_only(!editable); + pub(crate) fn show_virtual_keyboard(self: &Rc) { + if !self.touch_input { + return; + } + let was_enabled = self.ime_mirror.virtual_keyboard_enabled(); + self.ime_mirror.set_virtual_keyboard_enabled(true); // Trigger a focus event only when the keyboard actually needs // summoning. Cycling focus on every tap would restart the IME // connection right as the keyboard reads the tapped caret's context, @@ -490,41 +504,38 @@ impl WebWindowInner { // element is a no-op, so a dismissed keyboard would otherwise never // return for taps that stay within editable content: detect that // through the visual viewport and force a fresh focus event. - let editable_needs_focus_event = editable - && (!was_editable || !self.ime_mirror.is_focused() || self.keyboard_likely_dismissed()); - if editable_needs_focus_event || (!editable && was_editable) { - self.suppress_focus_status_events.set(true); - if editable { - // A same-task blur/focus cycle may be coalesced by iOS, but - // this branch only runs when the keyboard is already gone, - // so a coalesced cycle loses nothing. - if self.ime_mirror.is_focused() { - self.ime_mirror.blur(); - } - self.ime_mirror.focus(); - } else { - self.ime_mirror.blur(); - } - self.suppress_focus_status_events.set(false); + if !was_enabled || !self.ime_mirror.is_focused() || self.keyboard_likely_dismissed() { + // A same-task blur/focus cycle may be coalesced by iOS, but + // this branch only runs when the keyboard is already gone, + // so a coalesced cycle loses nothing. + self.focus_ime_mirror(); + } + } - if editable { - let callback = wasm_bindgen::closure::Closure::once_into_js({ - let this = Rc::clone(self); - move || { - this.state.borrow_mut().is_active = true; - this.with_callback( - |callbacks| &mut callbacks.active_status_change, - |callback| callback(true), - ); - } - }); - if let Err(error) = self - .browser_window - .set_timeout_with_callback(callback.unchecked_ref()) - { - log::warn!("failed to defer web window activation: {error:?}"); - } + /// Restarts DOM input without synchronously re-entering GPUI activation. + pub(crate) fn focus_ime_mirror(self: &Rc) { + self.suppress_focus_status_events.set(true); + if self.ime_mirror.is_focused() { + self.ime_mirror.blur(); + } + self.ime_mirror.focus(); + self.suppress_focus_status_events.set(false); + + let callback = wasm_bindgen::closure::Closure::once_into_js({ + let this = Rc::clone(self); + move || { + this.state.borrow_mut().is_active = true; + this.with_callback( + |callbacks| &mut callbacks.active_status_change, + |callback| callback(true), + ); } + }); + if let Err(error) = self + .browser_window + .set_timeout_with_callback(callback.unchecked_ref()) + { + log::warn!("failed to defer web window activation: {error:?}"); } } @@ -1281,16 +1292,6 @@ fn capslock_from_keyboard_event(event: &web_sys::KeyboardEvent) -> Capslock { } } -fn should_preserve_focused_input( - accepted_text_before_tap: bool, - accepts_text_after_tap: bool, - dispatch_result: Option, -) -> bool { - accepted_text_before_tap - && accepts_text_after_tap - && dispatch_result.is_some_and(|result| result.default_prevented) -} - pub(crate) fn is_mac_platform(browser_window: &web_sys::Window) -> bool { let navigator = browser_window.navigator(); @@ -1444,30 +1445,4 @@ mod tests { assert_ne!(reused, first); assert_ne!(reused, concurrent); } - - #[test] - fn handled_tap_preserves_unchanged_text_input() { - assert!(should_preserve_focused_input( - true, - true, - Some(DispatchEventResult { - propagate: false, - default_prevented: true, - }), - )); - } - - #[test] - fn tap_does_not_preserve_unhandled_or_unfocused_input() { - let result = |default_prevented| { - Some(DispatchEventResult { - propagate: false, - default_prevented, - }) - }; - - assert!(!should_preserve_focused_input(true, true, result(false),)); - assert!(!should_preserve_focused_input(false, true, result(true),)); - assert!(!should_preserve_focused_input(true, false, result(true),)); - } } diff --git a/crates/gpui_web/src/glyph_cache.rs b/crates/gpui_web/src/glyph_cache.rs new file mode 100644 index 0000000000..8d0d3afed4 --- /dev/null +++ b/crates/gpui_web/src/glyph_cache.rs @@ -0,0 +1,133 @@ +use anyhow::Result; +use gpui::{FontId, GlyphId, SharedString}; +use std::collections::HashMap; + +#[derive(Clone)] +pub(crate) struct CanvasGlyph { + pub(crate) font_id: FontId, + pub(crate) text: SharedString, + pub(crate) color: bool, +} + +#[derive(Default)] +pub(crate) struct GlyphCache { + glyphs: Vec, + ids_by_font: HashMap<(FontId, bool), HashMap>, +} + +impl GlyphCache { + pub(crate) fn id_for(&self, font_id: FontId, text: &str, color: bool) -> Option { + self.ids_by_font.get(&(font_id, color))?.get(text).copied() + } + + pub(crate) fn get_or_insert( + &mut self, + font_id: FontId, + text: &str, + color: bool, + ) -> Result { + if let Some(glyph_id) = self.id_for(font_id, text, color) { + return Ok(glyph_id); + } + let glyph_id = GlyphId(u32::try_from(self.glyphs.len())?); + let text = SharedString::from(text.to_owned()); + self.glyphs.push(CanvasGlyph { + font_id, + text: text.clone(), + color, + }); + self.ids_by_font + .entry((font_id, color)) + .or_default() + .insert(text, glyph_id); + Ok(glyph_id) + } + + pub(crate) fn glyph(&self, font_id: FontId, glyph_id: GlyphId) -> Option<&CanvasGlyph> { + self.glyphs + .get(glyph_id.0 as usize) + .filter(|glyph| glyph.font_id == font_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context as _; + + #[test] + fn warm_lookup_borrows_text_and_preserves_storage() -> Result<()> { + let mut cache = GlyphCache::default(); + let font_id = FontId(1); + let glyph_id = cache.get_or_insert(font_id, "👩‍💻", true)?; + let glyphs_pointer = cache.glyphs.as_ptr(); + let glyphs_capacity = cache.glyphs.capacity(); + let text_pointer = cache + .glyph(font_id, glyph_id) + .context("missing glyph")? + .text + .as_ptr(); + let surrounding_text = String::from("👩‍💻 suffix"); + let borrowed_text = surrounding_text + .strip_suffix(" suffix") + .context("missing suffix")?; + + for _ in 0..100 { + assert_eq!(cache.id_for(font_id, borrowed_text, true), Some(glyph_id)); + assert_eq!(cache.get_or_insert(font_id, borrowed_text, true)?, glyph_id); + } + + assert_eq!(cache.glyphs.len(), 1); + assert_eq!(cache.glyphs.as_ptr(), glyphs_pointer); + assert_eq!(cache.glyphs.capacity(), glyphs_capacity); + assert_eq!( + cache + .glyph(font_id, glyph_id) + .context("missing glyph")? + .text + .as_ptr(), + text_pointer + ); + Ok(()) + } + + #[test] + fn font_and_presentation_have_distinct_ids() -> Result<()> { + let mut cache = GlyphCache::default(); + let first_font = FontId(1); + let second_font = FontId(2); + let text = cache.get_or_insert(first_font, "❤", false)?; + let color = cache.get_or_insert(first_font, "❤", true)?; + let other_font = cache.get_or_insert(second_font, "❤", false)?; + + assert_ne!(text, color); + assert_ne!(text, other_font); + assert_ne!(color, other_font); + assert_eq!(cache.id_for(first_font, "❤", false), Some(text)); + assert_eq!(cache.id_for(first_font, "❤", true), Some(color)); + assert_eq!(cache.id_for(second_font, "❤", false), Some(other_font)); + assert!(cache.glyph(second_font, text).is_none()); + assert!(cache.glyph(first_font, GlyphId(u32::MAX)).is_none()); + assert_eq!( + cache.glyph(first_font, text).map(|glyph| glyph.color), + Some(false) + ); + assert_eq!( + cache.glyph(first_font, color).map(|glyph| glyph.color), + Some(true) + ); + Ok(()) + } + + #[test] + fn insertion_rechecks_a_previous_miss() -> Result<()> { + let mut cache = GlyphCache::default(); + let font_id = FontId(1); + assert_eq!(cache.id_for(font_id, "中", false), None); + + let inserted = cache.get_or_insert(font_id, "中", false)?; + assert_eq!(cache.get_or_insert(font_id, "中", false)?, inserted); + assert_eq!(cache.glyphs.len(), 1); + Ok(()) + } +} diff --git a/crates/gpui_web/src/gpui_web.rs b/crates/gpui_web/src/gpui_web.rs index 6084804320..c915084325 100644 --- a/crates/gpui_web/src/gpui_web.rs +++ b/crates/gpui_web/src/gpui_web.rs @@ -1,25 +1,54 @@ -#![cfg(target_family = "wasm")] - //! GPUI's browser platform uses one document-owned canvas and supports one top-level window. //! Browser WebGPU is preferred by default, with an automatic WebGL2 fallback. Applications can -//! force either backend with [`WebBackendPreference`]. Opening a second top-level window, or -//! reopening one after it closes, returns [`WebWindowError`]. +//! force either backend with `WebBackendPreference`. Opening a second top-level window, or +//! reopening one after it closes, returns `WebWindowError`. +pub mod canvas_fallback; + +pub use canvas_fallback::CanvasFontFallback; + +#[cfg(target_family = "wasm")] +mod canvas_text; +#[cfg(target_family = "wasm")] mod dispatcher; +#[cfg(target_family = "wasm")] mod display; +#[cfg(target_family = "wasm")] mod events; +#[cfg(any(target_family = "wasm", test))] +mod glyph_cache; +#[cfg(target_family = "wasm")] mod http_client; +#[cfg(target_family = "wasm")] mod ime_mirror; +#[cfg(target_family = "wasm")] mod keyboard; +#[cfg(target_family = "wasm")] mod logging; +#[cfg(target_family = "wasm")] mod platform; +#[cfg(any(target_family = "wasm", test))] +mod run_replacements; +#[cfg(target_family = "wasm")] +mod text_system; +#[cfg(target_family = "wasm")] +mod viewport; +#[cfg(target_family = "wasm")] mod window; +#[cfg(target_family = "wasm")] pub use dispatcher::WebDispatcher; +#[cfg(target_family = "wasm")] pub use display::WebDisplay; +#[cfg(target_family = "wasm")] pub use gpui_wgpu::WebBackendPreference; +#[cfg(target_family = "wasm")] pub use http_client::{FetchCredentials, FetchHttpClient}; +#[cfg(target_family = "wasm")] pub use keyboard::WebKeyboardLayout; +#[cfg(target_family = "wasm")] pub use logging::init_logging; +#[cfg(target_family = "wasm")] pub use platform::{WebPlatform, WebWindowError}; +#[cfg(target_family = "wasm")] pub use window::WebWindow; diff --git a/crates/gpui_web/src/ime_mirror.rs b/crates/gpui_web/src/ime_mirror.rs index 75ef807113..5cb08b5a29 100644 --- a/crates/gpui_web/src/ime_mirror.rs +++ b/crates/gpui_web/src/ime_mirror.rs @@ -52,7 +52,7 @@ const MIN_EDGE_CHARS: usize = 64; /// /// The element and every value/selection write on it are private to this /// module; other code interacts through read accessors, focus and -/// read-only control, and [`ImeMirror::schedule_sync`]. +/// virtual-keyboard control, and [`ImeMirror::schedule_sync`]. pub(crate) struct ImeMirror { element: web_sys::HtmlTextAreaElement, /// The mirror text most recently synced to (or observed in) the hidden @@ -83,19 +83,11 @@ pub(crate) struct ImeMirror { selection_import_rejected: Cell, } -/// Whether the device's primary pointer is coarse (a touch screen). The -/// distinction drives virtual-keyboard policy: touch-first browsers summon -/// the keyboard for any focused editable element on a user gesture. -fn primary_pointer_is_coarse() -> bool { - web_sys::window() - .and_then(|window| window.match_media("(pointer: coarse)").ok().flatten()) - .is_some_and(|media_query_list| media_query_list.matches()) -} - impl ImeMirror { pub(crate) fn new( document: &web_sys::Document, body: &web_sys::HtmlElement, + touch_input: bool, ) -> anyhow::Result { // A textarea rather than an input: single-line inputs silently strip // newlines from assigned values, which would make the mirror text @@ -118,14 +110,16 @@ impl ImeMirror { style.set_property("font-size", "16px").ok(); body.append_child(&element) .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; - element.focus().ok(); + Self::focus_element(&element); // The element must stay focused to receive hardware-key and IME // events, but on touch-first devices a focused *editable* element // invites the browser to summon the virtual keyboard on the next - // user gesture — including a scroll. Start read-only there; only a - // recognized tap on text input lifts it (`sync_virtual_keyboard`). - if primary_pointer_is_coarse() { - element.set_read_only(true); + // user gesture — including a scroll. Suppress the virtual keyboard + // until an editable tap, without disabling hardware IME composition. + if touch_input { + element + .set_attribute("inputmode", "none") + .map_err(|error| anyhow::anyhow!("Failed to configure input mode: {error:?}"))?; } let this = Self { @@ -189,7 +183,17 @@ impl ImeMirror { } pub(crate) fn focus(&self) { - self.element.focus().ok(); + Self::focus_element(&self.element); + } + + fn focus_element(element: &web_sys::HtmlTextAreaElement) { + // The hidden mirror is not a scroll target: revealing its caret can + // pan Android Chrome's visual viewport when the keyboard opens. + let options = web_sys::FocusOptions::new(); + options.set_prevent_scroll(true); + if let Err(error) = element.focus_with_options(&options) { + log::warn!("Failed to focus IME mirror: {error:?}"); + } } pub(crate) fn is_focused(&self) -> bool { @@ -204,14 +208,23 @@ impl ImeMirror { self.element.blur().ok(); } - pub(crate) fn read_only(&self) -> bool { - self.element.read_only() + pub(crate) fn virtual_keyboard_enabled(&self) -> bool { + self.element.get_attribute("inputmode").as_deref() != Some("none") } pub(crate) fn set_read_only(&self, read_only: bool) { self.element.set_read_only(read_only); } + pub(crate) fn set_virtual_keyboard_enabled(&self, enabled: bool) { + if let Err(error) = self + .element + .set_attribute("inputmode", if enabled { "text" } else { "none" }) + { + log::warn!("Failed to configure input mode: {error:?}"); + } + } + pub(crate) fn remove(&self) { let element: &web_sys::Element = self.element.as_ref(); element.remove(); diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index af8cc8584d..d29dc34772 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -1,18 +1,20 @@ +use crate::CanvasFontFallback; use crate::dispatcher::WebDispatcher; use crate::display::WebDisplay; use crate::events::EventListenerHandle; use crate::http_client::FetchHttpClient; use crate::keyboard::WebKeyboardLayout; +use crate::text_system::WebTextSystem; use crate::window::WebWindow; use anyhow::Result; use futures::channel::oneshot; use gpui::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardReadError, - ClipboardString, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, GestureTuning, Image, - ImageFormat, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, - PlatformGestures, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, ScrollPhysics, Task, ThermalState, WindowAppearance, WindowKind, WindowParams, - popup::PopupNotSupportedError, + Action, ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, + ClipboardReadError, ClipboardString, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, + GestureTuning, Image, ImageFormat, Keymap, Menu, MenuItem, PathPromptOptions, Platform, + PlatformDisplay, PlatformGestures, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, ScrollPhysics, Task, ThermalState, WindowAppearance, + WindowKind, WindowParams, popup::PopupNotSupportedError, }; use gpui_wgpu::{PreparedWebGraphics, WebBackendPreference, WgpuContext, wgpu}; use std::{ @@ -149,6 +151,22 @@ impl WebPlatform { pub fn new_with_backend( allow_multi_threading: bool, backend_preference: WebBackendPreference, + ) -> Self { + Self::new_with_backend_and_font_fallback( + allow_multi_threading, + backend_preference, + CanvasFontFallback::default(), + ) + } + + /// Configures browser fallback before any fonts or layouts are cached. + /// + /// Loaded fonts remain preferred, except when they cannot supply requested + /// emoji presentation. The policy cannot be changed after construction. + pub fn new_with_backend_and_font_fallback( + allow_multi_threading: bool, + backend_preference: WebBackendPreference, + canvas_font_fallback: CanvasFontFallback, ) -> Self { let browser_window = web_sys::window().expect("must be running in a browser window context"); @@ -158,9 +176,7 @@ impl WebPlatform { )); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts( - "IBM Plex Sans", - )); + let text_system = Arc::new(WebTextSystem::new("IBM Plex Sans", canvas_font_fallback)); let text_system: Arc = text_system; let active_display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); @@ -493,6 +509,10 @@ impl Platform for WebPlatform { self.callbacks.borrow_mut().reopen = Some(callback); } + // Browsers expose no system sleep or wake signal; the nearest thing is the + // Page Visibility API, which drives `WindowVisibility` instead. + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} @@ -519,6 +539,12 @@ impl Platform for WebPlatform { self.callbacks.borrow_mut().thermal_state_change = Some(callback); } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "Idle sleep prevention for {reason:?} is not supported in the browser" + ))) + } + fn compositor_name(&self) -> &'static str { "Web" } diff --git a/crates/gpui_web/src/run_replacements.rs b/crates/gpui_web/src/run_replacements.rs new file mode 100644 index 0000000000..2a190a2489 --- /dev/null +++ b/crates/gpui_web/src/run_replacements.rs @@ -0,0 +1,594 @@ +use crate::canvas_fallback::classify_canvas_fallback; +use gpui::{FontId, FontRun, LineLayout, Pixels, Point, ShapedGlyph, ShapedRun}; +use smallvec::SmallVec; +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug, PartialEq)] +pub(crate) struct Candidate { + pub(crate) source: Range, + pub(crate) font_id: FontId, + pub(crate) is_emoji: bool, + pub(crate) color: bool, + pub(crate) glyphs: Option, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct GlyphSpan { + pub(crate) range: Range, + pub(crate) position: Point, + pub(crate) end_x: Pixels, + pub(crate) contiguous: bool, + pub(crate) missing: bool, + pub(crate) native_color: bool, +} + +pub(crate) fn collect_candidates( + text: &str, + font_runs: &[FontRun], + layout: &LineLayout, +) -> Vec { + let mut font_runs = font_runs.iter().peekable(); + let mut font_run = font_runs.next(); + let mut run_end = font_run.map_or(0, |run| run.len); + let mut candidates = Vec::new(); + for (start, grapheme) in text.grapheme_indices(true) { + while start >= run_end { + font_run = font_runs.next(); + let Some(run) = font_run else { break }; + run_end += run.len; + } + let Some(run) = font_run else { break }; + // Paint-only run boundaries must not split a fallback grapheme. + while let Some(next_run) = font_runs.next_if(|next| next.font_id == run.font_id) { + run_end += next_run.len; + } + let end = start + grapheme.len(); + if end > run_end { + continue; + } + let Some(fallback) = classify_canvas_fallback(grapheme) else { + continue; + }; + candidates.push(Candidate { + source: start..end, + font_id: run.font_id, + is_emoji: fallback.is_emoji, + color: fallback.emoji_presentation, + glyphs: None, + }); + } + if candidates.is_empty() { + return candidates; + } + + let mut glyphs = layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter()) + .enumerate() + .peekable(); + while let Some((index, glyph)) = glyphs.next() { + let candidate_index = + candidates.partition_point(|candidate| candidate.source.end <= glyph.index); + let Some(candidate) = candidates + .get_mut(candidate_index) + .filter(|candidate| candidate.source.contains(&glyph.index)) + else { + continue; + }; + let span = candidate.glyphs.get_or_insert_with(|| GlyphSpan { + range: index..index, + position: glyph.position, + end_x: glyph.position.x, + contiguous: true, + missing: false, + native_color: true, + }); + span.contiguous &= span.range.end == index; + span.range.end = index + 1; + span.end_x = glyphs + .peek() + .map_or(layout.width, |(_, next)| next.position.x); + span.missing |= glyph.id.0 == 0; + span.native_color &= glyph.is_emoji; + } + candidates +} + +pub(crate) struct Replacement { + pub(crate) glyph_range: Range, + pub(crate) font_id: FontId, + pub(crate) glyph: ShapedGlyph, + pub(crate) width_delta: Pixels, +} + +struct Segment { + font_id: FontId, + length: usize, +} + +/// Ranges refer to the original flattened glyph stream and must be sorted, +/// non-overlapping, nonempty, and in bounds. +pub(crate) fn apply_replacements(layout: &mut LineLayout, replacements: Vec) { + if replacements.is_empty() { + return; + } + + let mut replacements = replacements.into_iter().peekable(); + let mut original_index = 0; + let mut consumed_end = 0; + let mut shift = Pixels::ZERO; + let mut segments_by_run: SmallVec<[SmallVec<[Segment; 4]>; 4]> = SmallVec::new(); + let mut spare_buffers: SmallVec<[Vec; 4]> = SmallVec::new(); + let mut final_run_count = 0; + + layout.runs.retain_mut(|run| { + let mut segments: SmallVec<[Segment; 4]> = SmallVec::new(); + run.glyphs.retain_mut(|glyph| { + let index = original_index; + original_index += 1; + if index < consumed_end { + return false; + } + + let font_id = if let Some(replacement) = + replacements.next_if(|replacement| replacement.glyph_range.start == index) + { + consumed_end = replacement.glyph_range.end; + *glyph = replacement.glyph; + glyph.position.x += shift; + shift += replacement.width_delta; + replacement.font_id + } else { + glyph.position.x += shift; + run.font_id + }; + + if let Some(segment) = segments.last_mut().filter(|last| last.font_id == font_id) { + segment.length += 1; + } else { + segments.push(Segment { font_id, length: 1 }); + } + true + }); + + if segments.is_empty() { + spare_buffers.push(std::mem::take(&mut run.glyphs)); + false + } else { + final_run_count += segments.len(); + segments_by_run.push(segments); + true + } + }); + layout.width += shift; + + let source_run_count = layout.runs.len(); + layout.runs.resize_with(final_run_count, || ShapedRun { + font_id: FontId(0), + glyphs: Vec::new(), + }); + + // Every destination is at or after its source. Expanding from the right + // therefore cannot overwrite an original run that still needs processing. + let mut destination_end = final_run_count; + for (source_index, segments) in (0..source_run_count).zip(segments_by_run).rev() { + let destination_start = destination_end - segments.len(); + let mut glyph_end = layout.runs[source_index].glyphs.len(); + for (segment_index, segment) in segments.into_iter().enumerate().rev() { + let destination_index = destination_start + segment_index; + if segment_index == 0 { + layout.runs[source_index].font_id = segment.font_id; + layout.runs.swap(source_index, destination_index); + } else { + let glyph_start = glyph_end - segment.length; + let mut glyphs = spare_buffers.pop().unwrap_or_default(); + glyphs.extend( + layout.runs[source_index] + .glyphs + .drain(glyph_start..glyph_end), + ); + layout.runs[destination_index] = ShapedRun { + font_id: segment.font_id, + glyphs, + }; + glyph_end = glyph_start; + } + } + destination_end = destination_start; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Context as _, Result}; + use gpui::{GlyphId, point, px}; + + fn source_glyph(index: usize, x: f32, id: u32, color: bool) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(id), + position: point(px(x), px(2.0)), + index, + is_emoji: color, + } + } + + #[test] + fn candidates_capture_bounds_without_a_flattened_vector() -> Result<()> { + let text = "中a文 "; + let mut layout = layout(&[2, 0, 2]); + layout.width = px(50.0); + layout.runs[0].glyphs = vec![ + source_glyph(0, 0.0, 0, false), + source_glyph(3, 10.0, 1, false), + ]; + layout.runs[2].glyphs = vec![ + source_glyph(4, 25.0, 0, false), + source_glyph(7, 40.0, 3, false), + ]; + let candidates = collect_candidates( + text, + &[FontRun { + len: text.len(), + font_id: FontId(7), + }], + &layout, + ); + assert_eq!(candidates.len(), 2); + let first = candidates.first().context("missing first candidate")?; + assert_eq!(first.source, 0..3); + assert_eq!(first.font_id, FontId(7)); + assert_eq!( + first.glyphs, + Some(GlyphSpan { + range: 0..1, + position: point(px(0.0), px(2.0)), + end_x: px(10.0), + contiguous: true, + missing: true, + native_color: false, + }) + ); + let last = candidates.last().context("missing last candidate")?; + assert_eq!(last.source, 4..7); + let span = last.glyphs.as_ref().context("missing glyph span")?; + assert_eq!(span.range, 2..3); + assert_eq!(span.position.x, px(25.0)); + assert_eq!(span.end_x, px(40.0)); + Ok(()) + } + + #[test] + fn candidates_keep_graphemes_across_same_font_boundaries() -> Result<()> { + let text = "か\u{3099}"; + let mut layout = layout(&[1, 1]); + layout.runs[0].glyphs = vec![source_glyph(0, 0.0, 0, false)]; + layout.runs[1].glyphs = vec![source_glyph(3, 10.0, 0, false)]; + let mut font_runs = [ + FontRun { + len: 3, + font_id: FontId(7), + }, + FontRun { + len: 3, + font_id: FontId(7), + }, + ]; + let candidates = collect_candidates(text, &font_runs, &layout); + assert_eq!(candidates.len(), 1); + let span = candidates + .first() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing glyph span")?; + assert_eq!(span.range, 0..2); + assert_eq!(span.position.x, px(0.0)); + assert_eq!(span.end_x, layout.width); + assert!(span.contiguous); + + font_runs[1].font_id = FontId(8); + assert!(collect_candidates(text, &font_runs, &layout).is_empty()); + Ok(()) + } + + #[test] + fn candidates_handle_nonmonotonic_and_disjoint_source_indices() -> Result<()> { + let mut layout = layout(&[3]); + layout.width = px(50.0); + layout.runs[0].glyphs = vec![ + source_glyph(3, 0.0, 0, false), + source_glyph(0, 20.0, 0, false), + source_glyph(3, 30.0, 0, false), + ]; + let candidates = collect_candidates( + "中文", + &[FontRun { + len: 6, + font_id: FontId(7), + }], + &layout, + ); + let first = candidates + .first() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing first span")?; + assert_eq!(first.range, 1..2); + assert_eq!(first.position.x, px(20.0)); + assert_eq!(first.end_x, px(30.0)); + assert!(first.contiguous); + let last = candidates + .last() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing last span")?; + assert_eq!(last.range, 0..3); + assert_eq!(last.position.x, px(0.0)); + assert_eq!(last.end_x, layout.width); + assert!(!last.contiguous); + Ok(()) + } + + #[test] + fn candidates_preserve_native_coverage_and_color() -> Result<()> { + let mut layout = layout(&[1]); + let font_runs = [FontRun { + len: "😀".len(), + font_id: FontId(7), + }]; + for (id, color) in [(0, false), (1, false), (0, true), (1, true)] { + layout.runs[0].glyphs = vec![source_glyph(0, 0.0, id, color)]; + let candidates = collect_candidates("😀", &font_runs, &layout); + let candidate = candidates.first().context("missing candidate")?; + assert!(candidate.color); + let span = candidate.glyphs.as_ref().context("missing span")?; + assert_eq!(span.missing, id == 0); + assert_eq!(span.native_color, color); + } + layout.runs[0].glyphs.clear(); + let candidates = collect_candidates("😀", &font_runs, &layout); + assert!( + candidates + .first() + .context("missing candidate")? + .glyphs + .is_none() + ); + Ok(()) + } + + fn glyph(index: usize) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(index as u32), + position: point(px(index as f32 * 10.0), px(2.0)), + index: index * 2, + is_emoji: false, + } + } + + fn layout(run_lengths: &[usize]) -> LineLayout { + let mut index = 0; + let runs = run_lengths + .iter() + .enumerate() + .map(|(font, length)| { + let glyphs = (index..index + length).map(glyph).collect(); + index += length; + ShapedRun { + font_id: FontId(font), + glyphs, + } + }) + .collect(); + LineLayout { + runs, + width: px(index as f32 * 10.0), + font_size: px(14.0), + ascent: px(11.0), + descent: px(3.0), + len: index * 2, + } + } + + fn replacement(range: Range, font_id: usize, delta: f32) -> Replacement { + let mut glyph = glyph(range.start); + glyph.id = GlyphId(999); + glyph.position.x += px(1.0); + glyph.position.y = px(7.0); + glyph.is_emoji = true; + Replacement { + glyph_range: range, + font_id: FontId(font_id), + glyph, + width_delta: px(delta), + } + } + + fn reference(layout: &mut LineLayout, replacements: Vec) { + let original = std::mem::take(&mut layout.runs); + let mut replacements = replacements.into_iter().peekable(); + let mut shift = Pixels::ZERO; + let mut consumed_end = 0; + for (index, (font_id, mut glyph)) in original + .into_iter() + .flat_map(|run| { + run.glyphs + .into_iter() + .map(move |glyph| (run.font_id, glyph)) + }) + .enumerate() + { + if index < consumed_end { + continue; + } + let font_id = if replacements + .peek() + .is_some_and(|replacement| replacement.glyph_range.start == index) + { + let replacement = replacements.next().expect("peeked replacement"); + glyph = replacement.glyph; + glyph.position.x += shift; + shift += replacement.width_delta; + consumed_end = replacement.glyph_range.end; + replacement.font_id + } else { + glyph.position.x += shift; + font_id + }; + if let Some(run) = layout.runs.last_mut().filter(|run| run.font_id == font_id) { + run.glyphs.push(glyph); + } else { + layout.runs.push(ShapedRun { + font_id, + glyphs: vec![glyph], + }); + } + } + layout.width += shift; + } + + fn assert_equal(actual: &LineLayout, expected: &LineLayout) { + let flatten = |layout: &LineLayout| { + layout + .runs + .iter() + .flat_map(|run| { + run.glyphs.iter().map(move |glyph| { + ( + run.font_id, + glyph.id, + glyph.position, + glyph.index, + glyph.is_emoji, + ) + }) + }) + .collect::>() + }; + assert_eq!(flatten(actual), flatten(expected)); + assert_eq!(actual.width, expected.width); + assert_eq!(actual.font_size, expected.font_size); + assert_eq!(actual.ascent, expected.ascent); + assert_eq!(actual.descent, expected.descent); + assert_eq!(actual.len, expected.len); + } + + #[test] + fn no_replacements_preserves_even_empty_runs() { + let mut actual = layout(&[0, 3, 0]); + let pointer = actual.runs.as_ptr(); + let glyph_pointer = actual.runs[1].glyphs.as_ptr(); + apply_replacements(&mut actual, vec![]); + assert_eq!(actual.runs.len(), 3); + assert_eq!(actual.runs.as_ptr(), pointer); + assert_eq!(actual.runs[1].glyphs.as_ptr(), glyph_pointer); + assert_equal(&actual, &layout(&[0, 3, 0])); + apply_replacements(&mut layout(&[]), vec![]); + apply_replacements(&mut layout(&[0, 0]), vec![]); + } + + #[test] + fn whole_run_reuses_both_allocations() { + let mut actual = layout(&[8]); + actual.runs.reserve(4); + actual.runs[0].glyphs.reserve(10); + let runs = (actual.runs.as_ptr(), actual.runs.capacity()); + let glyphs = ( + actual.runs[0].glyphs.as_ptr(), + actual.runs[0].glyphs.capacity(), + ); + apply_replacements(&mut actual, vec![replacement(0..8, 9, -20.0)]); + assert_eq!((actual.runs.as_ptr(), actual.runs.capacity()), runs); + assert_eq!( + ( + actual.runs[0].glyphs.as_ptr(), + actual.runs[0].glyphs.capacity() + ), + glyphs + ); + assert_eq!(actual.runs[0].font_id, FontId(9)); + assert_eq!(actual.runs[0].glyphs.len(), 1); + } + + #[test] + fn splits_reuse_outer_prefix_unaffected_and_emptied_buffers() { + let mut actual = layout(&[4, 4, 3]); + actual.runs.reserve(8); + let outer = (actual.runs.as_ptr(), actual.runs.capacity()); + let prefix = actual.runs[0].glyphs.as_ptr(); + let emptied = ( + actual.runs[1].glyphs.as_ptr(), + actual.runs[1].glyphs.capacity(), + ); + let unaffected = actual.runs[2].glyphs.as_ptr(); + apply_replacements(&mut actual, vec![replacement(2..8, 9, -3.0)]); + assert_eq!((actual.runs.as_ptr(), actual.runs.capacity()), outer); + assert_eq!(actual.runs[0].glyphs.as_ptr(), prefix); + assert_eq!( + ( + actual.runs[1].glyphs.as_ptr(), + actual.runs[1].glyphs.capacity() + ), + emptied + ); + assert_eq!(actual.runs[2].glyphs.as_ptr(), unaffected); + let mut expected = layout(&[4, 4, 3]); + reference(&mut expected, vec![replacement(2..8, 9, -3.0)]); + assert_equal(&actual, &expected); + } + + #[test] + fn multiple_splits_and_shifts() { + let mut actual = layout(&[7, 2, 3]); + actual.runs.reserve(12); + let outer = actual.runs.as_ptr(); + let mut expected = layout(&[7, 2, 3]); + let replacements = || { + vec![ + replacement(1..2, 9, 3.0), + replacement(3..5, 8, -7.0), + replacement(6..10, 9, 2.0), + ] + }; + apply_replacements(&mut actual, replacements()); + reference(&mut expected, replacements()); + assert_equal(&actual, &expected); + assert_eq!(actual.runs.as_ptr(), outer); + } + + #[test] + fn exhaustive_small_streams_match_reference() { + // Base-four choices at each original glyph: keep, start a native-font + // replacement, start a fallback replacement, or extend the previous one. + for first in 0..=4 { + for second in 0..=4 - first { + let lengths = [first, 0, second, 4 - first - second]; + for choices in 0usize..4usize.pow(4) { + let replacements = || { + let mut replacements: Vec = Vec::new(); + for index in 0..4 { + match (choices >> (2 * index)) & 3 { + 0 => {} + 1 => replacements.push(replacement(index..index + 1, 0, 2.0)), + 2 => replacements.push(replacement(index..index + 1, 9, -3.0)), + _ => { + if let Some(previous) = replacements + .last_mut() + .filter(|previous| previous.glyph_range.end == index) + { + previous.glyph_range.end += 1; + } + } + } + } + replacements + }; + let mut actual = layout(&lengths); + let mut expected = layout(&lengths); + apply_replacements(&mut actual, replacements()); + reference(&mut expected, replacements()); + assert_equal(&actual, &expected); + } + } + } + } +} diff --git a/crates/gpui_web/src/text_system.rs b/crates/gpui_web/src/text_system.rs new file mode 100644 index 0000000000..e8ce869a80 --- /dev/null +++ b/crates/gpui_web/src/text_system.rs @@ -0,0 +1,509 @@ +use crate::canvas_fallback::{CanvasFontFallback, classify_canvas_fallback}; +use crate::canvas_text::{self, CanvasTextMetrics}; +use crate::glyph_cache::{CanvasGlyph, GlyphCache}; +use crate::run_replacements::{Replacement, apply_replacements, collect_candidates}; +use anyhow::{Context as _, Result, ensure}; +use gpui::{ + Bounds, DevicePixels, Font, FontId, FontMetrics, FontRun, FontStyle, GlyphId, Hsla, LineLayout, + MissingGlyphSink, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, + SUBPIXEL_VARIANTS_Y, ShapedGlyph, Size, TextRenderingMode, point, px, size, +}; +use gpui_wgpu::CosmicTextSystem; +use parking_lot::RwLock; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +// Cosmic's font IDs index its loaded-font vector. Keep browser IDs in a disjoint namespace. +const CANVAS_FONT_BIT: usize = 1 << (usize::BITS - 1); +const MAX_MEASUREMENTS: usize = 4096; + +pub(crate) struct WebTextSystem { + native: CosmicTextSystem, + state: RwLock, + canvas_font_fallback: CanvasFontFallback, +} + +#[derive(Default)] +struct State { + missing_glyph_sink: Option>, + descriptors: HashMap, + canvas_fonts: Vec>, + canvas_font_ids: HashMap, + glyphs: GlyphCache, + measurements: HashMap<(FontId, GlyphId, Pixels), CanvasTextMetrics>, +} + +struct CanvasFont { + native_id: FontId, + descriptor: Font, + monospace: bool, +} + +impl State { + fn cached_fallback( + &self, + native_id: FontId, + text: &str, + color: bool, + font_size: Pixels, + ) -> Option<(FontId, GlyphId, CanvasTextMetrics)> { + let font_id = *self.canvas_font_ids.get(&native_id)?; + let glyph_id = self.glyphs.id_for(font_id, text, color)?; + let metrics = *self.measurements.get(&(font_id, glyph_id, font_size))?; + Some((font_id, glyph_id, metrics)) + } +} + +impl WebTextSystem { + pub(crate) fn new( + system_font_fallback: &str, + canvas_font_fallback: CanvasFontFallback, + ) -> Self { + Self { + native: CosmicTextSystem::new_without_system_fonts(system_font_fallback), + state: RwLock::new(State::default()), + canvas_font_fallback, + } + } + + fn canvas_font(&self, font_id: FontId) -> Option> { + if font_id.0 & CANVAS_FONT_BIT == 0 { + return None; + } + self.state + .read() + .canvas_fonts + .get(font_id.0 & !CANVAS_FONT_BIT) + .cloned() + } + + fn native_font_id(&self, font_id: FontId) -> FontId { + self.canvas_font(font_id) + .map_or(font_id, |font| font.native_id) + } + + fn canvas_font_id(&self, native_id: FontId) -> Result { + let descriptor = { + let state = self.state.read(); + if let Some(font_id) = state.canvas_font_ids.get(&native_id) { + return Ok(*font_id); + } + state + .descriptors + .get(&native_id) + .cloned() + .context("missing source font descriptor for Canvas fallback")? + }; + let monospace = match ( + self.native.glyph_for_char(native_id, 'i'), + self.native.glyph_for_char(native_id, 'm'), + ) { + (Some(narrow), Some(wide)) => { + self.native.advance(native_id, narrow)?.width + == self.native.advance(native_id, wide)?.width + } + _ => false, + }; + let mut state = self.state.write(); + if let Some(font_id) = state.canvas_font_ids.get(&native_id) { + return Ok(*font_id); + } + ensure!( + state.canvas_fonts.len() < CANVAS_FONT_BIT, + "Canvas font ID space exhausted" + ); + let font_id = FontId(CANVAS_FONT_BIT | state.canvas_fonts.len()); + state.canvas_fonts.push(Arc::new(CanvasFont { + native_id, + descriptor, + monospace, + })); + state.canvas_font_ids.insert(native_id, font_id); + Ok(font_id) + } + + fn register_glyph(&self, font_id: FontId, text: &str, color: bool) -> Result { + if let Some(glyph_id) = self.state.read().glyphs.id_for(font_id, text, color) { + return Ok(glyph_id); + } + // Another thread may register the same glyph between the read and write locks. + self.state + .write() + .glyphs + .get_or_insert(font_id, text, color) + } + + fn canvas_glyph(&self, font_id: FontId, glyph_id: GlyphId) -> Result { + self.state + .read() + .glyphs + .glyph(font_id, glyph_id) + .cloned() + .context("invalid Canvas glyph ID") + } + + fn measure( + &self, + font_id: FontId, + glyph_id: GlyphId, + font_size: Pixels, + ) -> Result { + let key = (font_id, glyph_id, font_size); + if let Some(metrics) = self.state.read().measurements.get(&key) { + return Ok(*metrics); + } + let font = self + .canvas_font(font_id) + .context("invalid Canvas font ID")?; + let glyph = self.canvas_glyph(font_id, glyph_id)?; + let metrics = canvas_text::measure(&glyph.text, &font.css_font(font_size, glyph.color)?)?; + ensure!(metrics.advance >= 0.0, "Canvas returned a negative advance"); + let mut state = self.state.write(); + if state.measurements.len() >= MAX_MEASUREMENTS { + state.measurements.clear(); + } + state.measurements.insert(key, metrics); + Ok(metrics) + } + + fn fallback_glyph( + &self, + native_id: FontId, + text: &str, + color: bool, + font_size: Pixels, + ) -> Result<(FontId, GlyphId, CanvasTextMetrics)> { + if let Some(cached) = self + .state + .read() + .cached_fallback(native_id, text, color, font_size) + { + return Ok(cached); + } + let font_id = self.canvas_font_id(native_id)?; + let glyph_id = self.register_glyph(font_id, text, color)?; + let metrics = self.measure(font_id, glyph_id, font_size)?; + Ok((font_id, glyph_id, metrics)) + } + + fn apply_fallback(&self, text: &str, font_runs: &[FontRun], layout: &mut LineLayout) { + if self.canvas_font_fallback == CanvasFontFallback::Disabled + || text.is_ascii() + || !f32::from(layout.font_size).is_finite() + || layout.font_size <= px(0.) + { + return; + } + let mut replacements = Vec::new(); + for candidate in collect_candidates(text, font_runs, layout) { + if !self.canvas_font_fallback.allows(candidate.is_emoji) { + continue; + } + let Some(glyphs) = candidate.glyphs else { + continue; + }; + if !glyphs.contiguous || !(glyphs.missing || (candidate.color && !glyphs.native_color)) + { + continue; + } + let old_width = glyphs.end_x - glyphs.position.x; + if old_width < px(0.) { + continue; + } + let replacement = (|| -> Result { + let text = text + .get(candidate.source.clone()) + .context("invalid Canvas fallback source range")?; + let (font_id, glyph_id, metrics) = self.fallback_glyph( + candidate.font_id, + text, + candidate.color, + layout.font_size, + )?; + Ok(Replacement { + glyph_range: glyphs.range, + font_id, + glyph: ShapedGlyph { + id: glyph_id, + position: glyphs.position, + index: candidate.source.start, + is_emoji: candidate.color, + }, + width_delta: px(metrics.advance) - old_width, + }) + })(); + match replacement { + Ok(replacement) => replacements.push(replacement), + Err(error) => log::warn!("Canvas font fallback failed: {error:#}"), + } + } + if replacements.is_empty() { + return; + } + replacements.sort_unstable_by_key(|replacement| replacement.glyph_range.start); + // Keep the primary font's baseline and line spacing stable. Browser ink + // extents affect raster bounds, not the surrounding editor's line metrics. + apply_replacements(layout, replacements); + } +} + +impl PlatformTextSystem for WebTextSystem { + fn set_missing_glyph_sink(&self, sink: Option>) { + // Cosmic must not report missing glyphs before Canvas has a chance to replace them. + self.state.write().missing_glyph_sink = sink; + } + + fn add_fonts(&self, fonts: Vec>) -> Result<()> { + self.native.add_fonts(fonts)?; + self.state.write().measurements.clear(); + Ok(()) + } + + fn all_font_names(&self) -> Vec { + self.native.all_font_names() + } + + fn font_id(&self, font: &Font) -> Result { + let font_id = self.native.font_id(font)?; + ensure!( + font_id.0 & CANVAS_FONT_BIT == 0, + "native font ID space exhausted" + ); + let mut descriptor = font.clone(); + (descriptor.weight, descriptor.style) = self.native.font_weight_and_style(font_id)?; + self.state + .write() + .descriptors + .entry(font_id) + .or_insert(descriptor); + Ok(font_id) + } + + fn prewarm_fonts(&self, font_ids: &[FontId]) { + if font_ids + .iter() + .all(|font_id| font_id.0 & CANVAS_FONT_BIT == 0) + { + return self.native.prewarm_fonts(font_ids); + } + let native_ids: Vec<_> = font_ids + .iter() + .map(|font_id| self.native_font_id(*font_id)) + .collect(); + self.native.prewarm_fonts(&native_ids); + } + + fn font_metrics(&self, font_id: FontId) -> FontMetrics { + self.native.font_metrics(self.native_font_id(font_id)) + } + + fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let Some(font) = self.canvas_font(font_id) else { + return self.native.typographic_bounds(font_id, glyph_id); + }; + let font_size = px(self.native.font_metrics(font.native_id).units_per_em as f32); + let metrics = self.measure(font_id, glyph_id, font_size)?; + Ok(Bounds { + origin: point(metrics.left, -metrics.ascent), + size: size( + metrics.right - metrics.left, + metrics.ascent + metrics.descent, + ), + }) + } + + fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let Some(font) = self.canvas_font(font_id) else { + return self.native.advance(font_id, glyph_id); + }; + let font_size = px(self.native.font_metrics(font.native_id).units_per_em as f32); + Ok(size( + self.measure(font_id, glyph_id, font_size)?.advance, + 0., + )) + } + + fn glyph_for_char(&self, font_id: FontId, character: char) -> Option { + if self.canvas_font(font_id).is_none() { + return self.native.glyph_for_char(font_id, character); + } + let mut buffer = [0; 4]; + let text = character.encode_utf8(&mut buffer); + let color = + classify_canvas_fallback(text).is_some_and(|fallback| fallback.emoji_presentation); + match self.register_glyph(font_id, text, color) { + Ok(glyph_id) => Some(glyph_id), + Err(error) => { + log::warn!("registering Canvas glyph failed: {error:#}"); + None + } + } + } + + fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result> { + if self.canvas_font(params.font_id).is_none() { + return self.native.glyph_raster_bounds(params); + } + let metrics = self.measure( + params.font_id, + params.glyph_id, + params.font_size * params.scale_factor, + )?; + if metrics.right <= metrics.left || metrics.ascent + metrics.descent <= 0. { + return Ok(Bounds::default()); + } + let (offset_x, offset_y) = subpixel_offset(params); + let left = (f64::from(metrics.left) + f64::from(offset_x)).floor() - 1.; + let top = (-f64::from(metrics.ascent) + f64::from(offset_y)).floor() - 1.; + let right = (f64::from(metrics.right) + f64::from(offset_x)).ceil() + 1.; + let bottom = (f64::from(metrics.descent) + f64::from(offset_y)).ceil() + 1.; + ensure!( + [left, top, right, bottom] + .into_iter() + .all(|value| value >= f64::from(i32::MIN) && value <= f64::from(i32::MAX)), + "Canvas text bounds exceed device coordinates" + ); + let width = (right as i32) + .checked_sub(left as i32) + .context("Canvas text width overflow")?; + let height = (bottom as i32) + .checked_sub(top as i32) + .context("Canvas text height overflow")?; + Ok(Bounds { + origin: point((left as i32).into(), (top as i32).into()), + size: size(width.into(), height.into()), + }) + } + + fn rasterize_glyph( + &self, + params: &RenderGlyphParams, + bounds: Bounds, + ) -> Result<(Size, Vec)> { + let Some(font) = self.canvas_font(params.font_id) else { + return self.native.rasterize_glyph(params, bounds); + }; + let glyph = self.canvas_glyph(params.font_id, params.glyph_id)?; + let css_font = font.css_font(params.font_size * params.scale_factor, glyph.color)?; + let mut pixels = canvas_text::rasterize( + &glyph.text, + &css_font, + bounds, + subpixel_offset(params), + glyph.color, + )?; + if params.subpixel_rendering && !glyph.color { + pixels = pixels.into_iter().flat_map(|alpha| [alpha; 4]).collect(); + } + Ok((bounds.size, pixels)) + } + + fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { + let native_runs: Cow<'_, [FontRun]> = + if runs.iter().any(|run| run.font_id.0 & CANVAS_FONT_BIT != 0) { + Cow::Owned( + runs.iter() + .map(|run| FontRun { + len: run.len, + font_id: self.native_font_id(run.font_id), + }) + .collect(), + ) + } else { + Cow::Borrowed(runs) + }; + let mut layout = self.native.layout_line(text, font_size, &native_runs); + self.apply_fallback(text, &native_runs, &mut layout); + let sink = self.state.read().missing_glyph_sink.clone(); + if let Some(sink) = sink { + let missing_indices = layout + .runs + .iter() + .filter(|run| run.font_id.0 & CANVAS_FONT_BIT == 0) + .flat_map(|run| &run.glyphs) + .filter(|glyph| glyph.id.0 == 0) + .map(|glyph| glyph.index); + sink.report( + self.native + .missing_glyphs(text, &native_runs, missing_indices), + ); + } + layout + } + + fn recommended_rendering_mode(&self, font_id: FontId, font_size: Pixels) -> TextRenderingMode { + if self.canvas_font(font_id).is_some() { + TextRenderingMode::Grayscale + } else { + self.native.recommended_rendering_mode(font_id, font_size) + } + } + + fn glyph_dilation_for_color(&self, color: Hsla) -> u8 { + self.native.glyph_dilation_for_color(color) + } +} + +impl CanvasFont { + fn css_font(&self, font_size: Pixels, color: bool) -> Result { + let font_size = f32::from(font_size); + ensure!( + font_size.is_finite() && font_size > 0., + "invalid Canvas font size" + ); + let style = match self.descriptor.style { + FontStyle::Normal => "normal", + FontStyle::Italic => "italic", + FontStyle::Oblique => "oblique", + }; + let mut families = Vec::new(); + if !color && !self.descriptor.family.starts_with('.') { + families.push(quote_family(&self.descriptor.family)); + } + if let Some(fallbacks) = &self.descriptor.fallbacks { + families.extend( + fallbacks + .fallback_list() + .iter() + .map(|family| quote_family(family)), + ); + } + families.push( + if color { + "emoji" + } else if self.monospace { + "monospace" + } else { + "sans-serif" + } + .to_owned(), + ); + Ok(format!( + "{style} {} {font_size}px {}", + self.descriptor.weight.0, + families.join(", ") + )) + } +} + +fn quote_family(family: &str) -> String { + let mut quoted = String::from("\""); + for character in family.chars() { + match character { + '\\' | '"' => { + quoted.push('\\'); + quoted.push(character); + } + character if character.is_control() => quoted.push(' '), + character => quoted.push(character), + } + } + quoted.push('"'); + quoted +} + +fn subpixel_offset(params: &RenderGlyphParams) -> (f32, f32) { + ( + f32::from(params.subpixel_variant.x) / SUBPIXEL_VARIANTS_X as f32, + f32::from(params.subpixel_variant.y) / SUBPIXEL_VARIANTS_Y as f32, + ) +} diff --git a/crates/gpui_web/src/viewport.rs b/crates/gpui_web/src/viewport.rs new file mode 100644 index 0000000000..1e6582af13 --- /dev/null +++ b/crates/gpui_web/src/viewport.rs @@ -0,0 +1,126 @@ +//! Browser viewport and safe-area frame snapshots in canvas coordinates. +//! +//! Browser geometry is sampled before rendering, never by geometry getters. +//! Safe-area padding is resolved by CSS so display cutouts use the browser's +//! own environment values rather than device-specific assumptions. + +use gpui::{Bounds, Edges, Pixels, WindowInsets, point, px, size}; +use wasm_bindgen::JsCast; + +pub(crate) struct WebViewport { + safe_area_probes: [web_sys::HtmlElement; 4], + pub(crate) visible_bounds: Bounds, + pub(crate) insets: WindowInsets, +} + +impl WebViewport { + pub(crate) fn new(document: &web_sys::Document) -> anyhow::Result { + let [top, right, bottom, left] = + ["top", "right", "bottom", "left"].map(|edge| safe_area_probe(document, edge)); + let viewport = Self { + safe_area_probes: [top?, right?, bottom?, left?], + visible_bounds: Bounds::default(), + insets: WindowInsets::default(), + }; + let body = document + .body() + .ok_or_else(|| anyhow::anyhow!("Missing document body"))?; + for probe in &viewport.safe_area_probes { + body.append_child(probe) + .map_err(|error| anyhow::anyhow!("Failed to attach safe-area probe: {error:?}"))?; + } + Ok(viewport) + } + + pub(crate) fn observe_safe_area(&self, observer: &web_sys::ResizeObserver) { + // Separate boxes detect redistribution between edges even when the + // total inset, and therefore a single combined probe's size, is unchanged. + for probe in &self.safe_area_probes { + observer.observe(probe); + } + } + + pub(crate) fn update( + &mut self, + window: &web_sys::Window, + canvas: &web_sys::HtmlCanvasElement, + ) -> anyhow::Result { + let canvas_bounds = canvas.get_bounding_client_rect(); + let document = window + .document() + .ok_or_else(|| anyhow::anyhow!("Missing document"))?; + let root = document + .document_element() + .ok_or_else(|| anyhow::anyhow!("Missing root"))?; + let layout_width = root.client_width() as f64; + let layout_height = root.client_height() as f64; + let (left, top, width, height) = match window.visual_viewport() { + Some(viewport) => ( + viewport.offset_left(), + viewport.offset_top(), + viewport.width(), + viewport.height(), + ), + None => (0., 0., layout_width, layout_height), + }; + // VisualViewport offsets and getBoundingClientRect share layout CSS + // coordinates. Multiplying by the pinch scale here would incorrectly + // expand the visible rectangle back to its unzoomed size. + let x = (left - canvas_bounds.left()).clamp(0., canvas_bounds.width()); + let y = (top - canvas_bounds.top()).clamp(0., canvas_bounds.height()); + let right = (left + width - canvas_bounds.left()).clamp(x, canvas_bounds.width()); + let bottom = (top + height - canvas_bounds.top()).clamp(y, canvas_bounds.height()); + let visible_bounds = Bounds::new( + point(px(x as f32), px(y as f32)), + size(px((right - x) as f32), px((bottom - y) as f32)), + ); + + let [top, right, bottom, left] = self + .safe_area_probes + .each_ref() + .map(|probe| probe.get_bounding_client_rect().height()); + let safe_area = Edges { + top: px((top - canvas_bounds.top()).max(0.) as f32), + right: px((canvas_bounds.right() - layout_width + right).max(0.) as f32), + bottom: px((canvas_bounds.bottom() - layout_height + bottom).max(0.) as f32), + left: px((left - canvas_bounds.left()).max(0.) as f32), + }; + let insets = WindowInsets { + safe_area, + ..WindowInsets::default() + }; + let changed = self.visible_bounds != visible_bounds || self.insets != insets; + self.visible_bounds = visible_bounds; + self.insets = insets; + Ok(changed) + } +} + +impl Drop for WebViewport { + fn drop(&mut self) { + for probe in &self.safe_area_probes { + probe.remove(); + } + } +} + +fn safe_area_probe( + document: &web_sys::Document, + edge: &str, +) -> anyhow::Result { + let probe = document + .create_element("div") + .map_err(|error| anyhow::anyhow!("Failed to create safe-area probe: {error:?}"))? + .dyn_into::() + .map_err(|error| anyhow::anyhow!("Invalid safe-area probe element: {error:?}"))?; + probe + .set_attribute( + "style", + &format!( + "position:fixed;left:0;top:0;width:0;padding:0;border:0;visibility:hidden;\ + pointer-events:none;height:env(safe-area-inset-{edge},0px)" + ), + ) + .map_err(|error| anyhow::anyhow!("Failed to style safe-area probe: {error:?}"))?; + Ok(probe) +} diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 799c3fadcf..bf00bd2d4b 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -4,6 +4,7 @@ use crate::events::{ }; use crate::ime_mirror::ImeMirror; use crate::platform::WebWindowLifecycle; +use crate::viewport::WebViewport; use std::sync::Arc; use std::{cell::Cell, cell::RefCell, rc::Rc}; @@ -11,8 +12,9 @@ use gpui::{ AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - ResizeEdge, Scene, Size, TextInputConfiguration, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, + ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, + WindowInsets, WindowParams, WindowVisibility, px, }; use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu}; use wasm_bindgen::prelude::*; @@ -22,8 +24,10 @@ pub(crate) struct WebWindowCallbacks { pub(crate) request_frame: Option>, pub(crate) input: Option DispatchEventResult>>, pub(crate) active_status_change: Option>, + pub(crate) visibility_change: Option>, pub(crate) hover_status_change: Option>, pub(crate) resize: Option, f32)>>, + pub(crate) visual_viewport_changed: Option>, pub(crate) moved: Option>, pub(crate) should_close: Option bool>>, pub(crate) close: Option>, @@ -40,6 +44,7 @@ pub(crate) struct WebWindowMutableState { pub(crate) input_handler: Option, pub(crate) is_fullscreen: bool, pub(crate) is_active: bool, + pub(crate) visibility: WindowVisibility, pub(crate) is_hovered: bool, pub(crate) mouse_position: Point, pub(crate) modifiers: Modifiers, @@ -50,8 +55,10 @@ pub(crate) struct WebWindowInner { pub(crate) browser_window: web_sys::Window, pub(crate) canvas: web_sys::HtmlCanvasElement, pub(crate) ime_mirror: ImeMirror, + pub(crate) viewport: RefCell, pub(crate) has_device_pixel_support: bool, pub(crate) is_mac: bool, + pub(crate) touch_input: bool, pub(crate) state: RefCell, pub(crate) callbacks: RefCell, pub(crate) click_state: RefCell, @@ -60,17 +67,12 @@ pub(crate) struct WebWindowInner { pub(crate) last_physical_size: Cell<(u32, u32)>, pub(crate) notify_scale: Cell, pub(crate) is_composing: Cell, - /// Set while `sync_virtual_keyboard` blur/focus-cycles the hidden input. + /// Set while `show_virtual_keyboard` blur/focus-cycles the hidden input. /// The cycle is a keyboard-visibility hint, not a real activity change; /// letting the focus/blur listeners report it would re-enter GPUI /// synchronously from inside an input dispatch, and a `RefCell` /// double-borrow panic on wasm never unwinds, wedging the app. pub(crate) suppress_focus_status_events: Cell, - /// The visual viewport's width and greatest height seen at that width, - /// in layout pixels. The keyboard-visibility probe compares the current - /// height against this maximum; the width detects rotation, which must - /// restart the calibration. - pub(crate) visual_viewport_probe: Cell<(f64, f64)>, /// The visual viewport height when the current pointer gesture began. /// A mid-gesture change means the software keyboard opened or closed and /// reflowed the layout, so the release position no longer refers to what @@ -96,6 +98,8 @@ pub struct WebWindow { _raf_closure: Closure, _resize_observer: Option, _resize_observer_closure: Closure, + _safe_area_observer: Option, + _safe_area_observer_closure: Closure, _event_listeners: WebEventListeners, } @@ -120,6 +124,9 @@ impl WebWindow { ("display", "block"), ("outline", "none"), ("touch-action", "none"), + ("-webkit-touch-callout", "none"), + ("user-select", "none"), + ("-webkit-user-select", "none"), ] { style.set_property(property, value).map_err(|error| { anyhow::anyhow!("Failed to set canvas {property} style: {error:?}") @@ -164,7 +171,14 @@ impl WebWindow { }; let renderer = WgpuRenderer::new_from_surface(context, surface, renderer_config)?; - let ime_mirror = ImeMirror::new(&document, &body)?; + let touch_input = browser_window + .match_media("(pointer: coarse)") + .ok() + .flatten() + .is_some_and(|query| query.matches()); + let ime_mirror = ImeMirror::new(&document, &body, touch_input)?; + let mut viewport = WebViewport::new(&document)?; + viewport.update(&browser_window, &canvas)?; let display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); @@ -182,6 +196,7 @@ impl WebWindow { input_handler: None, is_fullscreen: false, is_active: true, + visibility: document_visibility(&browser_window), is_hovered: false, mouse_position: Point::default(), modifiers: Modifiers::default(), @@ -194,8 +209,10 @@ impl WebWindow { browser_window, canvas, ime_mirror, + viewport: RefCell::new(viewport), has_device_pixel_support, is_mac, + touch_input, state: RefCell::new(mutable_state), callbacks: RefCell::new(WebWindowCallbacks::default()), click_state: RefCell::new(ClickState::default()), @@ -205,7 +222,6 @@ impl WebWindow { notify_scale: Cell::new(false), is_composing: Cell::new(false), suppress_focus_status_events: Cell::new(false), - visual_viewport_probe: Cell::new((0.0, 0.0)), gesture_start_visual_viewport_height: Cell::new(0.0), touch_tap_candidate: Cell::new(None), mql_handle: RefCell::new(None), @@ -227,6 +243,26 @@ impl WebWindow { } let event_listeners = inner.register_event_listeners(); + let safe_area_observer_closure = Closure::wrap(Box::new({ + let inner = Rc::downgrade(&inner); + move |_: js_sys::Array| { + if let Some(inner) = inner.upgrade() { + inner.notify_viewport_changed(); + } + } + }) as Box); + let safe_area_observer = + match web_sys::ResizeObserver::new(safe_area_observer_closure.as_ref().unchecked_ref()) + { + Ok(observer) => { + inner.viewport.borrow().observe_safe_area(&observer); + Some(observer) + } + Err(error) => { + log::warn!("Failed to observe safe area: {error:?}"); + None + } + }; Ok(Self { inner, @@ -237,6 +273,8 @@ impl WebWindow { _resize_observer: resize_observer, _resize_observer_closure: resize_observer_closure, _event_listeners: event_listeners, + _safe_area_observer: safe_area_observer, + _safe_area_observer_closure: safe_area_observer_closure, }) } @@ -358,6 +396,27 @@ impl WebWindow { } impl WebWindowInner { + fn sample_viewport(&self) -> bool { + match self + .viewport + .borrow_mut() + .update(&self.browser_window, &self.canvas) + { + Ok(changed) => changed, + Err(error) => { + log::warn!("Failed to update browser viewport: {error:#}"); + false + } + } + } + + pub(crate) fn notify_viewport_changed(&self) { + self.with_callback( + |callbacks| &mut callbacks.visual_viewport_changed, + |callback| callback(), + ); + } + /// Invokes a registered callback with take/call/restore semantics. /// /// The callback is removed from the slot for the duration of the call, so @@ -458,27 +517,24 @@ impl WebWindowInner { document.as_ref(), "visibilitychange", move |_event: JsValue| { - let is_visible = this - .browser_window - .document() - .map(|doc| { - let state_str: String = - js_sys::Reflect::get(&doc, &"visibilityState".into()) - .ok() - .and_then(|v| v.as_string()) - .unwrap_or_default(); - state_str == "visible" - }) - .unwrap_or(true); + let visibility = document_visibility(&this.browser_window); + let is_visible = visibility.is_visible(); - { + let visibility_changed = { let mut state = this.state.borrow_mut(); state.is_active = is_visible; - } + std::mem::replace(&mut state.visibility, visibility) != visibility + }; this.with_callback( |callbacks| &mut callbacks.active_status_change, |callback| callback(is_visible), ); + if visibility_changed { + this.with_callback( + |callbacks| &mut callbacks.visibility_change, + |callback| callback(visibility), + ); + } }, )) } @@ -551,6 +607,9 @@ impl Drop for WebWindow { if let Some(ref observer) = self._resize_observer { observer.disconnect(); } + if let Some(observer) = &self._safe_area_observer { + observer.disconnect(); + } // The DPR media-query closure captures an `Rc` and is // stored inside the inner itself, forming a reference cycle; take it @@ -580,6 +639,22 @@ fn current_appearance(browser_window: &web_sys::Window) -> WindowAppearance { } } +/// The Page Visibility API: `hidden` when the tab is in the background or the +/// browser window is minimized. A missing document reads as visible. +fn document_visibility(browser_window: &web_sys::Window) -> WindowVisibility { + let is_visible = browser_window + .document() + .and_then(|document| js_sys::Reflect::get(&document, &"visibilityState".into()).ok()) + .and_then(|value| value.as_string()) + .is_none_or(|state| state == "visible"); + + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } +} + struct MqlHandle { mql: web_sys::MediaQueryList, _closure: Closure, @@ -691,6 +766,89 @@ impl PlatformWindow for WebWindow { self.inner.ime_mirror.apply_configuration(&configuration); } + fn show_soft_keyboard(&self) { + self.inner.show_virtual_keyboard(); + } + + fn hide_soft_keyboard(&self) { + if !self.inner.touch_input { + return; + } + self.inner.ime_mirror.set_virtual_keyboard_enabled(false); + // Restart the browser input session without a software keyboard, but + // keep receiving hardware keys that can move the caret back into an + // editable region. GPUI focus, not this DOM element, owns their target. + self.inner.suppress_focus_status_events.set(true); + self.inner.ime_mirror.blur(); + self.inner.suppress_focus_status_events.set(false); + // Chrome can coalesce a same-task blur/focus and leave the keyboard + // visible. Restore the hardware-key receiver after this task instead. + let callback = Closure::once_into_js({ + let inner = Rc::downgrade(&self.inner); + move || { + if let Some(inner) = inner.upgrade() + && !inner.ime_mirror.virtual_keyboard_enabled() + && inner.state.borrow().is_active + { + inner.suppress_focus_status_events.set(true); + inner.ime_mirror.focus(); + inner.suppress_focus_status_events.set(false); + } + } + }); + if let Err(error) = self + .inner + .browser_window + .set_timeout_with_callback(callback.unchecked_ref()) + { + log::warn!("failed to restore keyboard event receiver: {error:?}"); + } + } + + fn visual_viewport_bounds(&self) -> Bounds { + self.inner.viewport.borrow().visible_bounds + } + + fn prepare_frame(&self) -> bool { + self.inner.sample_viewport() + } + + fn on_visual_viewport_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().visual_viewport_changed = Some(callback); + } + + fn insets(&self) -> WindowInsets { + self.inner.viewport.borrow().insets.clone() + } + + fn text_input_state_changed(&self, change: TextInputStateChange) { + // React to editability transitions, not each frame: manually dismissing + // the keyboard must remain effective until another editing gesture. + // On desktop, the DOM input remains the keyboard event receiver even + // when GPUI focuses a read-only surface. Blurring it would also disable + // hardware shortcuts, unlike a native window. + if self.inner.touch_input { + match change { + TextInputStateChange::FocusGained => self.inner.show_virtual_keyboard(), + TextInputStateChange::FocusLost => self.hide_soft_keyboard(), + TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {} + } + return; + } + match change { + TextInputStateChange::FocusGained => { + self.inner.ime_mirror.set_read_only(false); + if !self.inner.ime_mirror.is_focused() { + self.inner.focus_ime_mirror(); + } + } + TextInputStateChange::FocusLost => { + self.inner.ime_mirror.set_read_only(true); + } + TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {} + } + } + fn prompt( &self, _level: PromptLevel, @@ -709,6 +867,10 @@ impl PlatformWindow for WebWindow { self.inner.state.borrow().is_active } + fn visibility(&self) -> WindowVisibility { + self.inner.state.borrow().visibility + } + fn is_hovered(&self) -> bool { self.inner.state.borrow().is_hovered } @@ -778,6 +940,10 @@ impl PlatformWindow for WebWindow { self.inner.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); } diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 3d889ffb46..18d6737c3d 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/gpui_wgpu.rs" +doctest = false [features] default = [] @@ -44,6 +45,7 @@ wgpu = { workspace = true, features = ["webgl"] } [dev-dependencies] criterion.workspace = true +gpui = { workspace = true, features = ["test-support"] } naga = { version = "29.0.4", features = ["wgsl-in"] } [[bench]] diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index e84d924177..0d04438693 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -5,10 +5,10 @@ use cosmic_text::{ FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Stretch, Style, Weight, }; use gpui::{ - Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, - IsZero as _, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, - SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, - size, + Bounds, DevicePixels, FallbackFontClass, Font, FontFallbacks, FontFeatures, FontId, + FontMetrics, FontRun, GlyphId, IsZero as _, LineLayout, MissingGlyph, MissingGlyphSink, Pixels, + PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ShapedGlyph, + ShapedRun, SharedString, Size, TextRenderingMode, point, size, }; use itertools::Itertools; @@ -40,6 +40,12 @@ impl FontKey { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct LoadedFontKey { + database_id: cosmic_text::fontdb::ID, + font: FontKey, +} + struct CosmicTextSystemState { font_system: FontSystem, scratch: ShapeBuffer, @@ -47,10 +53,12 @@ struct CosmicTextSystemState { pending_glyph_images: HashMap, /// Contains all already loaded fonts, including all faces. Indexed by `FontId`. loaded_fonts: Vec, + loaded_font_ids_by_key: HashMap, /// Caches the `FontId`s associated with a specific family to avoid iterating the font database /// for every font face in a family. font_ids_by_family_cache: HashMap>, system_font_fallback: String, + missing_glyph_sink: Option>, } struct LoadedFont { @@ -84,6 +92,42 @@ impl FontMatchProperties { } impl CosmicTextSystem { + /// Returns the selected face's weight and style, which may differ from the request. + pub fn font_weight_and_style( + &self, + font_id: FontId, + ) -> Result<(gpui::FontWeight, gpui::FontStyle)> { + let state = self.0.read(); + let font = state + .loaded_fonts + .get(font_id.0) + .context("invalid font ID")?; + let face = state + .font_system + .db() + .face(font.font.id()) + .context("font face not found")?; + let style = match face.style { + cosmic_text::Style::Normal => gpui::FontStyle::Normal, + cosmic_text::Style::Italic => gpui::FontStyle::Italic, + cosmic_text::Style::Oblique => gpui::FontStyle::Oblique, + }; + Ok((gpui::FontWeight(face.weight.0 as f32), style)) + } + + /// Builds reports for unresolved source indices after an outer text system + /// has applied its own fallback. + pub fn missing_glyphs( + &self, + text: &str, + font_runs: &[FontRun], + missing_text_indices: impl IntoIterator, + ) -> Vec { + self.0 + .read() + .missing_glyphs(text, font_runs, missing_text_indices) + } + pub fn new(system_font_fallback: &str) -> Self { let font_system = FontSystem::new(); @@ -93,8 +137,10 @@ impl CosmicTextSystem { swash_scale_context: ScaleContext::new(), pending_glyph_images: HashMap::default(), loaded_fonts: Vec::new(), + loaded_font_ids_by_key: HashMap::default(), font_ids_by_family_cache: HashMap::default(), system_font_fallback: system_font_fallback.to_string(), + missing_glyph_sink: None, })) } @@ -110,8 +156,10 @@ impl CosmicTextSystem { swash_scale_context: ScaleContext::new(), pending_glyph_images: HashMap::default(), loaded_fonts: Vec::new(), + loaded_font_ids_by_key: HashMap::default(), font_ids_by_family_cache: HashMap::default(), system_font_fallback: system_font_fallback.to_string(), + missing_glyph_sink: None, })) } } @@ -121,6 +169,10 @@ impl PlatformTextSystem for CosmicTextSystem { self.0.write().add_fonts(fonts) } + fn set_missing_glyph_sink(&self, sink: Option>) { + self.0.write().missing_glyph_sink = sink; + } + fn all_font_names(&self) -> Vec { let mut result = self .0 @@ -275,6 +327,7 @@ impl CosmicTextSystemState { #[profiling::function] fn add_fonts(&mut self, fonts: Vec>) -> Result<()> { + self.font_ids_by_family_cache.clear(); let db = self.font_system.db_mut(); for bytes in fonts { db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))); @@ -289,6 +342,12 @@ impl CosmicTextSystemState { features: &FontFeatures, fallbacks: Option<&FontFallbacks>, ) -> Result> { + let loaded_font_key = FontKey::new( + SharedString::from(name.to_owned()), + features.clone(), + fallbacks.cloned(), + ); + // recurse with `fallbacks = None` so a fallback family cannot pull in // another chain. missing fallback families are dropped so a typo in // settings still lets the primary family load. @@ -337,10 +396,20 @@ impl CosmicTextSystemState { let cosmic_features = cosmic_font_features(features)?; let mut loaded_font_ids = SmallVec::new(); - for (font_id, postscript_name) in families { + for (database_id, postscript_name) in families { + let key = LoadedFontKey { + database_id, + font: loaded_font_key.clone(), + }; + if let Some(&font_id) = self.loaded_font_ids_by_key.get(&key) { + self.loaded_fonts[font_id.0].user_fallback_chain = Arc::clone(&user_fallback_chain); + loaded_font_ids.push(font_id); + continue; + } + let font = self .font_system - .get_font(font_id, cosmic_text::Weight::NORMAL) + .get_font(database_id, cosmic_text::Weight::NORMAL) .context("Could not load font")?; // HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback. @@ -364,6 +433,7 @@ impl CosmicTextSystemState { is_known_emoji_font: check_is_known_emoji_font(&postscript_name), user_fallback_chain: Arc::clone(&user_fallback_chain), }); + self.loaded_font_ids_by_key.insert(key, font_id); } Ok(loaded_font_ids) @@ -699,6 +769,18 @@ impl CosmicTextSystemState { }; }; + let missing_glyphs = self.missing_glyph_sink.as_ref().map(|_| { + self.missing_glyphs( + text, + font_runs, + layout + .glyphs + .iter() + .filter(|glyph| glyph.glyph_id == 0) + .map(|glyph| glyph.start), + ) + }); + let mut runs: Vec = Vec::new(); for glyph in &layout.glyphs { let mut font_id = FontId(glyph.metadata); @@ -745,6 +827,10 @@ impl CosmicTextSystemState { } } + if let Some((sink, missing_glyphs)) = self.missing_glyph_sink.as_ref().zip(missing_glyphs) { + sink.report(missing_glyphs); + } + LineLayout { font_size, width: layout.w.into(), @@ -754,6 +840,80 @@ impl CosmicTextSystemState { len: text.len(), } } + + fn missing_glyphs( + &self, + text: &str, + font_runs: &[FontRun], + missing_text_indices: impl IntoIterator, + ) -> Vec { + let mut missing_text_indices = missing_text_indices.into_iter().peekable(); + if missing_text_indices.peek().is_none() { + return Vec::new(); + } + let mut missing_text_indices = missing_text_indices.collect::>(); + missing_text_indices.sort_unstable(); + missing_text_indices.dedup(); + + let mut font_run_index = 0; + let mut font_run_end = font_runs.first().map_or(0, |font_run| font_run.len); + let mut missing_glyphs = Vec::new(); + let mut missing_index = 0; + for (grapheme_start, grapheme) in text.grapheme_indices(true) { + let grapheme_end = grapheme_start + grapheme.len(); + while missing_text_indices + .get(missing_index) + .is_some_and(|text_index| *text_index < grapheme_start) + { + missing_index += 1; + } + let Some(&text_index) = missing_text_indices.get(missing_index) else { + break; + }; + if text_index >= grapheme_end { + continue; + } + + while font_run_end <= text_index && font_run_index + 1 < font_runs.len() { + font_run_index += 1; + let Some(font_run) = font_runs.get(font_run_index) else { + break; + }; + font_run_end += font_run.len; + } + let font_class = self.fallback_font_class( + font_runs + .get(font_run_index) + .or_else(|| font_runs.last()) + .map(|font_run| font_run.font_id), + ); + missing_glyphs.push(MissingGlyph::new(grapheme.into(), font_class)); + while missing_text_indices + .get(missing_index) + .is_some_and(|text_index| *text_index < grapheme_end) + { + missing_index += 1; + } + } + missing_glyphs + } + + fn fallback_font_class(&self, font_id: Option) -> FallbackFontClass { + let Some(font_id) = font_id else { + return FallbackFontClass::Proportional; + }; + let loaded_font = self.loaded_font(font_id); + let is_monospace = self + .font_system + .db() + .face(loaded_font.font.id()) + .is_some_and(|face| face.monospaced); + if is_monospace { + FallbackFontClass::Monospace + } else { + FallbackFontClass::Proportional + } + } } #[inline(always)] @@ -1040,6 +1200,27 @@ fn check_is_known_emoji_font(postscript_name: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use std::{cell::RefCell, rc::Rc}; + + #[test] + fn all_font_names_tracks_available_families() -> Result<()> { + let text_system = gpui::TextSystem::new(Arc::new( + CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"), + )); + assert!(text_system.all_font_names().is_empty()); + + text_system.add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../assets/fonts/lilex/Lilex-Regular.ttf" + ))])?; + assert_eq!(text_system.all_font_names(), ["Lilex"]); + + text_system.add_fonts(vec![ + Cow::Borrowed(IBM_PLEX), + Cow::Borrowed(include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf")), + ])?; + assert_eq!(text_system.all_font_names(), ["IBM Plex Sans", "Lilex"]); + Ok(()) + } fn fid(i: usize) -> FontId { FontId(i) @@ -1062,6 +1243,7 @@ mod tests { const IBM_PLEX: &[u8] = include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const LILEX: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); /// Every code point of `Bidi_Class=B`, each of which starts a new bidi /// paragraph and so can split one line into mixed-direction paragraphs. @@ -1075,6 +1257,31 @@ mod tests { Ok(text_system) } + #[test] + fn font_properties_describe_the_selected_face() -> Result<()> { + let text_system = text_system()?; + let regular = gpui::font("IBM Plex Sans"); + let regular_id = text_system.font_id(®ular)?; + for (weight, style) in [ + (gpui::FontWeight::MEDIUM, gpui::FontStyle::Normal), + (gpui::FontWeight::BOLD, gpui::FontStyle::Italic), + (gpui::FontWeight::NORMAL, gpui::FontStyle::Oblique), + ] { + let requested = Font { + weight, + style, + ..regular.clone() + }; + let font_id = text_system.font_id(&requested)?; + assert_eq!(font_id, regular_id); + assert_eq!( + text_system.font_weight_and_style(font_id)?, + (gpui::FontWeight::NORMAL, gpui::FontStyle::Normal) + ); + } + Ok(()) + } + fn layout_text(text_system: &CosmicTextSystem, text: &str) -> Result { let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?; let runs = [FontRun { @@ -1107,6 +1314,97 @@ mod tests { Ok(()) } + #[test] + fn reports_graphemes_that_exhaust_font_fallback() -> Result<()> { + let platform_text_system = Arc::new(text_system()?); + let dispatcher = gpui::TestDispatcher::new(0); + let cx = + gpui::TestAppContext::build_with_text_system(dispatcher, None, platform_text_system); + let observed = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let observed = observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + observed.borrow_mut().extend_from_slice(missing_glyphs); + }) + }); + let text: SharedString = "界".into(); + + cx.update(|cx| { + let text_system = gpui::WindowTextSystem::new(cx.text_system().clone()); + let runs = [gpui::TextRun { + len: text.len(), + font: gpui::font("IBM Plex Sans"), + ..Default::default() + }]; + text_system.shape_line(text, gpui::px(14.0), &runs, None); + }); + cx.run_until_parked(); + + let observed = observed.borrow(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].grapheme(), "界"); + assert_eq!( + observed[0].font_class(), + gpui::FallbackFontClass::Proportional + ); + Ok(()) + } + + #[test] + fn combines_missing_glyphs_from_one_grapheme() -> Result<()> { + let text_system = text_system()?; + let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?; + let text = "x\u{0301}"; + let runs = [FontRun { + len: text.len(), + font_id, + }]; + + let missing_glyphs = text_system + .0 + .read() + .missing_glyphs(text, &runs, [0, "x".len()]); + + assert_eq!(missing_glyphs.len(), 1); + assert_eq!(missing_glyphs[0].grapheme(), text); + Ok(()) + } + + #[test] + fn adding_fonts_invalidates_cached_line_layouts() -> Result<()> { + let platform_text_system = Arc::new(text_system()?); + let text_system = Arc::new(gpui::TextSystem::new(platform_text_system.clone())); + let window_text_system = gpui::WindowTextSystem::new(text_system.clone()); + let text: SharedString = "cached text".into(); + let runs = [gpui::TextRun { + len: text.len(), + font: gpui::font("IBM Plex Sans"), + ..Default::default() + }]; + + let first_layout = window_text_system.shape_line(text.clone(), gpui::px(14.0), &runs, None); + let cached_layout = + window_text_system.shape_line(text.clone(), gpui::px(14.0), &runs, None); + assert!(std::ptr::eq::( + &**first_layout, + &**cached_layout + )); + let loaded_font_count = platform_text_system.0.read().loaded_fonts.len(); + + text_system.add_fonts(vec![Cow::Borrowed(LILEX)])?; + + let refreshed_layout = window_text_system.shape_line(text, gpui::px(14.0), &runs, None); + assert!(!std::ptr::eq::( + &**first_layout, + &**refreshed_layout + )); + assert_eq!( + platform_text_system.0.read().loaded_fonts.len(), + loaded_font_count + ); + Ok(()) + } + #[test] fn layout_line_with_mixed_direction_paragraphs() -> Result<()> { let text_system = text_system()?; diff --git a/crates/gpui_wgpu/src/shaders_webgl.wgsl b/crates/gpui_wgpu/src/shaders_webgl.wgsl index 0c9c217a69..bf5f629cf9 100644 --- a/crates/gpui_wgpu/src/shaders_webgl.wgsl +++ b/crates/gpui_wgpu/src/shaders_webgl.wgsl @@ -1,7 +1,7 @@ @group(1) @binding(0) var t_instances: texture_2d; -// Each texel of `t_instances` packs four 32-bit words of instance data. Records -// are read strictly front to back, so all readers share a cursor that keeps the +// Each texel of `t_instances` packs four 32-bit words of instance data. Most +// records are read strictly front to back through a cursor that keeps the // most recently fetched texel and only touches the texture again when the next // word crosses a texel boundary. This fetches each texel exactly once per // record load instead of once per word. The `read_*` functions must therefore @@ -136,16 +136,57 @@ fn read_transformation(cursor: ptr) -> TransformationM } fn load_quad(instance_id: u32) -> Quad { - var cursor = instance_cursor(instance_id * 40u); + // Keep this fixed-layout decoder explicit. Some WebGL shader compilers fail + // to optimize the cursor's branches and dynamic vector indexing for quads. + let first_texel_index = instance_id * 10u; + let width = textureDimensions(t_instances).x; + let texel0 = fetch_instance_texel(first_texel_index, width); + let texel1 = fetch_instance_texel(first_texel_index + 1u, width); + let texel2 = fetch_instance_texel(first_texel_index + 2u, width); + let texel3 = fetch_instance_texel(first_texel_index + 3u, width); + let texel4 = fetch_instance_texel(first_texel_index + 4u, width); + let texel5 = fetch_instance_texel(first_texel_index + 5u, width); + let texel6 = fetch_instance_texel(first_texel_index + 6u, width); + let texel7 = fetch_instance_texel(first_texel_index + 7u, width); + let texel8 = fetch_instance_texel(first_texel_index + 8u, width); + let texel9 = fetch_instance_texel(first_texel_index + 9u, width); + + let values0 = bitcast>(texel0); + let values1 = bitcast>(texel1); + let values2 = bitcast>(texel2); + let values3 = bitcast>(texel3); + let values4 = bitcast>(texel4); + let values5 = bitcast>(texel5); + let values6 = bitcast>(texel6); + let values7 = bitcast>(texel7); + let values8 = bitcast>(texel8); + let values9 = bitcast>(texel9); + return Quad( - read_word(&cursor), - read_word(&cursor), - read_bounds(&cursor), - read_bounds(&cursor), - read_background(&cursor), - read_hsla(&cursor), - read_corners(&cursor), - read_edges(&cursor), + texel0.x, + texel0.y, + Bounds(values0.zw, values1.xy), + Bounds(values1.zw, values2.xy), + Background( + texel2.z, + texel2.w, + Hsla(values3.x, values3.y, values3.z, values3.w), + values4.x, + array( + LinearColorStop( + Hsla(values4.y, values4.z, values4.w, values5.x), + values5.y, + ), + LinearColorStop( + Hsla(values5.z, values5.w, values6.x, values6.y), + values6.z, + ), + ), + texel6.w, + ), + Hsla(values7.x, values7.y, values7.z, values7.w), + Corners(values8.x, values8.y, values8.z, values8.w), + Edges(values9.x, values9.y, values9.z, values9.w), ); } diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index 3cf71ac98c..72aa46ad1a 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -1,9 +1,8 @@ use anyhow::{Context as _, Result}; -use collections::FxHashMap; use etagere::{BucketedAtlasAllocator, size2}; use gpui::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, - PlatformAtlas, Point, Size, + AtlasBackend, AtlasKey, AtlasState, AtlasTextureId, AtlasTextureKind, AtlasTextureList, + AtlasTile, Bounds, DevicePixels, PlatformAtlas, Point, Size, }; use parking_lot::Mutex; use std::{borrow::Cow, ops, sync::Arc}; @@ -21,7 +20,7 @@ fn etagere_point_to_device(point: etagere::Point) -> Point { } } -pub struct WgpuAtlas(Mutex); +pub struct WgpuAtlas(Mutex>); struct PendingUpload { id: AtlasTextureId, @@ -29,18 +28,20 @@ struct PendingUpload { data: Vec, } -struct WgpuAtlasState { +struct WgpuAtlasTextures { device: Arc, queue: Arc, max_texture_size: u32, color_texture_format: wgpu::TextureFormat, storage: WgpuAtlasStorage, - tiles_by_key: FxHashMap, pending_uploads: Vec, + next_texture_generation: u64, } pub struct WgpuTextureInfo { pub view: wgpu::TextureView, + /// Distinguishes new textures that reuse an [`AtlasTextureId`]. + pub generation: u64, } impl WgpuAtlas { @@ -50,15 +51,15 @@ impl WgpuAtlas { color_texture_format: wgpu::TextureFormat, ) -> Self { let max_texture_size = device.limits().max_texture_dimension_2d; - WgpuAtlas(Mutex::new(WgpuAtlasState { + WgpuAtlas(Mutex::new(AtlasState::new(WgpuAtlasTextures { device, queue, max_texture_size, color_texture_format, storage: WgpuAtlasStorage::default(), - tiles_by_key: Default::default(), pending_uploads: Vec::new(), - })) + next_texture_generation: 0, + }))) } pub fn from_context(context: &WgpuContext) -> Self { @@ -71,71 +72,69 @@ impl WgpuAtlas { pub fn before_frame(&self) { let mut lock = self.0.lock(); - lock.flush_uploads(); + lock.backend.flush_uploads(); } pub fn get_texture_info(&self, id: AtlasTextureId) -> WgpuTextureInfo { let lock = self.0.lock(); - let texture = &lock.storage[id]; + let texture = &lock.backend.storage[id]; WgpuTextureInfo { view: texture.view.clone(), + generation: texture.generation, } } /// Clears all cached textures and tiles, forcing them to be recreated. /// Use this for incremental recovery when the device is still valid. pub fn clear(&self) { - let mut lock = self.0.lock(); - lock.storage = WgpuAtlasStorage::default(); - lock.tiles_by_key.clear(); - lock.pending_uploads.clear(); + self.0.lock().clear(|textures| { + textures.storage = WgpuAtlasStorage::default(); + textures.pending_uploads.clear(); + }); } /// Handles device lost by clearing all textures and cached tiles. /// The atlas will lazily recreate textures as needed on subsequent frames. pub fn handle_device_lost(&self, context: &WgpuContext) { - let mut lock = self.0.lock(); - lock.device = context.device.clone(); - lock.queue = context.queue.clone(); - lock.color_texture_format = context.color_texture_format(); - lock.storage = WgpuAtlasStorage::default(); - lock.tiles_by_key.clear(); - lock.pending_uploads.clear(); + self.0.lock().clear(|textures| { + textures.device = context.device.clone(); + textures.queue = context.queue.clone(); + textures.color_texture_format = context.color_texture_format(); + textures.storage = WgpuAtlasStorage::default(); + textures.pending_uploads.clear(); + }); } } impl PlatformAtlas for WgpuAtlas { fn get_or_insert_with<'a>( &self, - key: &AtlasKey, + key: AtlasKey, build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, ) -> Result> { - let mut lock = self.0.lock(); - if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(*tile)) - } else { - profiling::scope!("new tile"); - let Some((size, bytes)) = build()? else { - return Ok(None); - }; - let tile = lock - .allocate(size, key.texture_kind()) - .context("failed to allocate")?; - lock.upload_texture(tile.texture_id, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile); - Ok(Some(tile)) - } + self.0.lock().get_or_insert_with(key, build) } fn remove(&self, key: &AtlasKey) { - let mut lock = self.0.lock(); + self.0.lock().remove(key); + } +} - let Some(tile) = lock.tiles_by_key.remove(key) else { - return; - }; +impl AtlasBackend for WgpuAtlasTextures { + fn insert( + &mut self, + kind: AtlasTextureKind, + size: Size, + bytes: &[u8], + ) -> Result { + let tile = self.allocate(size, kind).context("failed to allocate")?; + self.upload_texture(tile.texture_id, tile.bounds, bytes); + Ok(tile) + } + + fn remove(&mut self, tile: AtlasTile) { let id = tile.texture_id; - - let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { + let Some(texture_slot) = self.storage[id.kind].textures.get_mut(id.index as usize) else { return; }; @@ -143,9 +142,9 @@ impl PlatformAtlas for WgpuAtlas { texture.allocator.deallocate(tile.tile_id.into()); texture.decrement_ref_count(); if texture.is_unreferenced() { - lock.pending_uploads + self.pending_uploads .retain(|upload| upload.id != texture.id); - lock.storage[id.kind] + self.storage[id.kind] .free_list .push(texture.id.index as usize); } else { @@ -155,7 +154,7 @@ impl PlatformAtlas for WgpuAtlas { } } -impl WgpuAtlasState { +impl WgpuAtlasTextures { fn allocate( &mut self, size: Size, @@ -217,12 +216,15 @@ impl WgpuAtlasState { let texture_list = &mut self.storage[kind]; let index = texture_list.free_list.pop(); + let generation = self.next_texture_generation; + self.next_texture_generation = self.next_texture_generation.wrapping_add(1); let atlas_texture = WgpuAtlasTexture { id: AtlasTextureId { index: index.unwrap_or(texture_list.textures.len()) as u32, kind, }, + generation, allocator: BucketedAtlasAllocator::new(device_size_to_etagere(size)), format, texture, @@ -345,6 +347,7 @@ impl ops::Index for WgpuAtlasStorage { struct WgpuAtlasTexture { id: AtlasTextureId, + generation: u64, allocator: BucketedAtlasAllocator, texture: wgpu::Texture, view: wgpu::TextureView, @@ -456,7 +459,7 @@ mod tests { // Regression test: before the fix, this panicked in flush_uploads atlas - .get_or_insert_with(&key, &mut build)? + .get_or_insert_with(key.clone(), &mut build)? .expect("tile should be created"); atlas.remove(&key); atlas.before_frame(); @@ -483,7 +486,7 @@ mod tests { frame_index: 0, }) }; - let insert = |key: &AtlasKey, size: Size| { + let insert = |key: AtlasKey, size: Size| { let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; atlas .get_or_insert_with(key, &mut || { @@ -497,16 +500,55 @@ mod tests { let big_key_a = make_key(2); let big_key_b = make_key(3); - let keeper_tile = insert(&keeper_key, small); - let tile_a = insert(&big_key_a, big); + let keeper_tile = insert(keeper_key, small); + let tile_a = insert(big_key_a.clone(), big); assert_eq!(keeper_tile.texture_id, tile_a.texture_id); atlas.remove(&big_key_a); - let tile_b = insert(&big_key_b, big); + let tile_b = insert(big_key_b, big); assert_eq!(tile_b.texture_id, keeper_tile.texture_id); Ok(()) } + #[test] + fn reused_texture_id_has_new_generation() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let size = Size { + width: DevicePixels(700), + height: DevicePixels(700), + }; + let make_key = |image_id| { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }) + }; + let insert = |key: AtlasKey| { + atlas + .get_or_insert_with(key, &mut || { + Ok(Some(( + size, + Cow::Owned(vec![0; size.width.0 as usize * size.height.0 as usize * 4]), + ))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + }; + + let first_key = make_key(1); + let first_tile = insert(first_key.clone()); + let first_generation = atlas.get_texture_info(first_tile.texture_id).generation; + atlas.remove(&first_key); + + let second_tile = insert(make_key(2)); + let second_generation = atlas.get_texture_info(second_tile.texture_id).generation; + + assert_eq!(second_tile.texture_id, first_tile.texture_id); + assert_ne!(second_generation, first_generation); + Ok(()) + } + #[test] fn swizzle_upload_data_preserves_bgra_uploads() { let input = vec![0x10, 0x20, 0x30, 0x40]; diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index b0cd40a767..d728711a64 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -1,6 +1,7 @@ use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; use anyhow::{Context as _, Result}; use bytemuck::{Pod, Zeroable}; +use collections::FxHashMap; use gpui::{ AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, Path, Point, PrimitiveBatch, ScaledPixels, Scene, Size, get_gamma_correction_ratios, @@ -8,6 +9,7 @@ use gpui::{ use log::warn; #[cfg(not(target_family = "wasm"))] use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use smallvec::SmallVec; use std::cell::RefCell; use std::num::NonZeroU64; use std::ops::Range; @@ -184,6 +186,7 @@ struct WgpuResources { pipelines: WgpuPipelines, bind_group_layouts: WgpuBindGroupLayouts, atlas_sampler: wgpu::Sampler, + atlas_texture_bind_groups: FxHashMap, globals_buffer: wgpu::Buffer, globals_bind_group: wgpu::BindGroup, path_globals_bind_group: wgpu::BindGroup, @@ -194,6 +197,11 @@ struct WgpuResources { path_msaa_view: Option, } +struct CachedTextureBindGroup { + texture_generation: u64, + bind_group: wgpu::BindGroup, +} + impl WgpuResources { fn invalidate_intermediate_textures(&mut self) { self.path_intermediate_texture = None; @@ -568,6 +576,7 @@ impl WgpuRenderer { pipelines, bind_group_layouts, atlas_sampler, + atlas_texture_bind_groups: FxHashMap::default(), globals_buffer, globals_bind_group, path_globals_bind_group, @@ -1418,6 +1427,7 @@ impl WgpuRenderer { scene.polychrome_sprites.len(), ) })?; + self.prepare_texture_bind_groups(scene); let mut encoder = self.resources() @@ -1498,13 +1508,15 @@ impl WgpuRenderer { instance_range(range), &mut pass, ), - PrimitiveBatch::MonochromeSprites { texture_id, range } => self.draw_sprites( - &instance_bindings.monochrome_sprites, - texture_id, - &self.resources().pipelines.mono_sprites, - instance_range(range), - &mut pass, - ), + PrimitiveBatch::MonochromeSprites { texture_id, range } => { + self.draw_sprites( + &instance_bindings.monochrome_sprites, + texture_id, + &self.resources().pipelines.mono_sprites, + instance_range(range), + &mut pass, + )?; + } PrimitiveBatch::SubpixelSprites { texture_id, range } => { let resources = self.resources(); self.draw_sprites( @@ -1517,15 +1529,17 @@ impl WgpuRenderer { .unwrap_or(&resources.pipelines.mono_sprites), instance_range(range), &mut pass, - ); + )?; + } + PrimitiveBatch::PolychromeSprites { texture_id, range } => { + self.draw_sprites( + &instance_bindings.polychrome_sprites, + texture_id, + &self.resources().pipelines.poly_sprites, + instance_range(range), + &mut pass, + )?; } - PrimitiveBatch::PolychromeSprites { texture_id, range } => self.draw_sprites( - &instance_bindings.polychrome_sprites, - texture_id, - &self.resources().pipelines.poly_sprites, - instance_range(range), - &mut pass, - ), // Surfaces are macOS-only for video playback and are not // implemented by the WGPU renderer. PrimitiveBatch::Surfaces(_surfaces) => {} @@ -1602,6 +1616,47 @@ impl WgpuRenderer { }) } + fn prepare_texture_bind_groups(&mut self, scene: &Scene) { + let mut texture_ids = SmallVec::<[AtlasTextureId; 8]>::new(); + for batch in scene.batches() { + let texture_id = match batch { + PrimitiveBatch::MonochromeSprites { texture_id, .. } + | PrimitiveBatch::SubpixelSprites { texture_id, .. } + | PrimitiveBatch::PolychromeSprites { texture_id, .. } => texture_id, + _ => continue, + }; + if !texture_ids.contains(&texture_id) { + texture_ids.push(texture_id); + } + } + + self.resources_mut() + .atlas_texture_bind_groups + .retain(|texture_id, _| texture_ids.contains(texture_id)); + + for texture_id in texture_ids { + let texture_info = self.atlas.get_texture_info(texture_id); + let is_current = self + .resources() + .atlas_texture_bind_groups + .get(&texture_id) + .is_some_and(|cached| cached.texture_generation == texture_info.generation); + if is_current { + continue; + } + + let bind_group = + self.create_texture_bind_group("atlas_texture_bind_group", &texture_info.view); + self.resources_mut().atlas_texture_bind_groups.insert( + texture_id, + CachedTextureBindGroup { + texture_generation: texture_info.generation, + bind_group, + }, + ); + } + } + fn draw_instances( &self, instances: &InstanceBinding, @@ -1628,22 +1683,25 @@ impl WgpuRenderer { pipeline: &wgpu::RenderPipeline, range: Range, pass: &mut wgpu::RenderPass<'_>, - ) { + ) -> Result<()> { if range.is_empty() { - return; + return Ok(()); } - let texture_info = self.atlas.get_texture_info(texture_id); - let texture = - self.create_texture_bind_group("atlas_texture_bind_group", &texture_info.view); + let resources = self.resources(); + let texture = resources + .atlas_texture_bind_groups + .get(&texture_id) + .context("missing atlas texture bind group")?; pass.set_pipeline(pipeline); - pass.set_bind_group(0, &self.resources().globals_bind_group, &[]); + pass.set_bind_group(0, &resources.globals_bind_group, &[]); pass.set_bind_group(1, &sprite_instances.bind_group, &[]); - pass.set_bind_group(2, &texture, &[]); + pass.set_bind_group(2, &texture.bind_group, &[]); pass.draw( 0..4, sprite_instances.first_instance + range.start ..sprite_instances.first_instance + range.end, ); + Ok(()) } unsafe fn instance_bytes(instances: &[T]) -> &[u8] { @@ -2201,7 +2259,11 @@ impl RenderingParameters { #[cfg(test)] mod tests { use super::*; - use gpui::{MonochromeSprite, PolychromeSprite, Quad, Shadow, SubpixelSprite, Underline}; + use gpui::{ + BorderStyle, ColorSpace, ContentMask, Corners, Edges, Hsla, MonochromeSprite, + PolychromeSprite, Quad, Shadow, SubpixelSprite, Underline, linear_color_stop, + linear_gradient, + }; #[test] fn webgl_shader_is_valid_wgsl_without_storage_buffers() { @@ -2240,4 +2302,122 @@ mod tests { assert_eq!(std::mem::size_of::(), 28 * 4); assert_eq!(std::mem::size_of::(), 24 * 4); } + + #[test] + fn webgl_quad_layout_matches_fixed_decoder() { + let quad = Quad { + order: 41, + border_style: BorderStyle::Dashed, + bounds: Bounds { + origin: Point { + x: 1.0.into(), + y: 2.0.into(), + }, + size: Size { + width: 3.0.into(), + height: 4.0.into(), + }, + }, + content_mask: ContentMask { + bounds: Bounds { + origin: Point { + x: 5.0.into(), + y: 6.0.into(), + }, + size: Size { + width: 7.0.into(), + height: 8.0.into(), + }, + }, + }, + background: linear_gradient( + 11.0, + linear_color_stop( + Hsla { + h: 12.0, + s: 13.0, + l: 14.0, + a: 15.0, + }, + 16.0, + ), + linear_color_stop( + Hsla { + h: 17.0, + s: 18.0, + l: 19.0, + a: 20.0, + }, + 21.0, + ), + ) + .color_space(ColorSpace::Oklab), + border_color: Hsla { + h: 22.0, + s: 23.0, + l: 24.0, + a: 25.0, + }, + corner_radii: Corners { + top_left: 26.0.into(), + top_right: 27.0.into(), + bottom_right: 28.0.into(), + bottom_left: 29.0.into(), + }, + border_widths: Edges { + top: 30.0.into(), + right: 31.0.into(), + bottom: 32.0.into(), + left: 33.0.into(), + }, + }; + + let bytes = unsafe { WgpuRenderer::instance_bytes(std::slice::from_ref(&quad)) }; + let words: &[u32] = bytemuck::cast_slice(bytes); + assert_eq!( + words, + &[ + 41, + 1, + 1.0_f32.to_bits(), + 2.0_f32.to_bits(), + 3.0_f32.to_bits(), + 4.0_f32.to_bits(), + 5.0_f32.to_bits(), + 6.0_f32.to_bits(), + 7.0_f32.to_bits(), + 8.0_f32.to_bits(), + 1, + 1, + 0, + 0, + 0, + 0, + 11.0_f32.to_bits(), + 12.0_f32.to_bits(), + 13.0_f32.to_bits(), + 14.0_f32.to_bits(), + 15.0_f32.to_bits(), + 16.0_f32.to_bits(), + 17.0_f32.to_bits(), + 18.0_f32.to_bits(), + 19.0_f32.to_bits(), + 20.0_f32.to_bits(), + 21.0_f32.to_bits(), + 0, + 22.0_f32.to_bits(), + 23.0_f32.to_bits(), + 24.0_f32.to_bits(), + 25.0_f32.to_bits(), + 26.0_f32.to_bits(), + 27.0_f32.to_bits(), + 28.0_f32.to_bits(), + 29.0_f32.to_bits(), + 30.0_f32.to_bits(), + 31.0_f32.to_bits(), + 32.0_f32.to_bits(), + 33.0_f32.to_bits(), + ] + ); + } } diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 823e4d1d62..3d6221a79a 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/gpui_windows.rs" +doctest = false [features] default = ["gpui/default"] @@ -38,15 +39,15 @@ smallvec.workspace = true uuid.workspace = true windows.workspace = true windows-core.workspace = true -windows-numerics = "0.2" -windows-registry = "0.5" +windows-numerics = "0.3" +windows-registry.workspace = true [target.'cfg(target_os = "windows")'.dependencies.scap] workspace = true optional = true [target.'cfg(target_os = "windows")'.build-dependencies] -windows-registry = "0.5" +windows-registry.workspace = true [package.metadata.cargo-shear] ignored = ["scap"] diff --git a/crates/gpui_windows/src/dialog.rs b/crates/gpui_windows/src/dialog.rs new file mode 100644 index 0000000000..16866cfc35 --- /dev/null +++ b/crates/gpui_windows/src/dialog.rs @@ -0,0 +1,340 @@ +use std::{ + cell::RefCell, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use anyhow::{Context as _, Result, anyhow}; +use futures::{ + channel::oneshot, + future::{Either, select}, + lock::Mutex, +}; +use gpui::ForegroundExecutor; +use gpui_util::ResultExt; +use windows::{ + Win32::{ + Foundation::*, + System::{Com::*, Threading::GetCurrentThreadId}, + UI::{ + Controls::{TASKDIALOG_NOTIFICATIONS, TDN_BUTTON_CLICKED}, + WindowsAndMessaging::*, + }, + }, + core::{BOOL, HRESULT, w}, +}; + +use crate::SafeHwnd; + +pub(crate) struct DialogOwner { + hwnd: HWND, + closed: Arc, + serialization: Mutex<()>, +} + +impl DialogOwner { + pub(crate) fn new(hwnd: HWND) -> Rc { + Rc::new(Self { + hwnd, + closed: Arc::new(AtomicBool::new(false)), + serialization: Mutex::new(()), + }) + } + + pub(crate) fn close(&self) { + self.closed.store(true, Ordering::Release); + } + + pub(crate) async fn when_idle(&self) { + // After close(), queued requests cannot start a native dialog. Only the + // active dialog can still use the HWND, and it holds this lock until exit. + let _guard = self.serialization.lock().await; + } +} + +#[derive(Clone, Default)] +struct Cancellation { + requested: Arc, + owner_closed: Option>, +} + +impl Cancellation { + fn new(owner: Option<&DialogOwner>) -> Self { + Self { + owner_closed: owner.map(|owner| owner.closed.clone()), + ..Self::default() + } + } + + fn is_requested(&self) -> bool { + self.requested.load(Ordering::Acquire) + || self + .owner_closed + .as_ref() + .is_some_and(|closed| closed.load(Ordering::Acquire)) + } +} + +/// Native modal loops may synchronously message their owner, so the foreground +/// thread must keep pumping messages rather than waiting for the dialog thread. +/// Holding the owner's lock through native completion also allows window +/// destruction to wait for the dialog without blocking the foreground thread. +pub(crate) fn show_dialog( + owner: Option>, + executor: &ForegroundExecutor, + dialog: impl FnOnce(HWND) -> Result + Send + 'static, +) -> oneshot::Receiver> { + let (mut sender, receiver) = oneshot::channel(); + let cancellation = Cancellation::new(owner.as_deref()); + if cancellation.is_requested() { + sender.send(Err(anyhow!("dialog owner has closed"))).ok(); + return receiver; + } + let hwnd = owner.as_ref().map(|owner| SafeHwnd::from(owner.hwnd)); + executor + .spawn(async move { + // Let Windows manage enabling and activating the owner. Overlapping + // modal loops could otherwise re-enable it while another dialog is open. + let serialization = if let Some(owner) = owner.as_ref() { + match select(owner.serialization.lock(), sender.cancellation()).await { + Either::Left((guard, _)) => Some(guard), + Either::Right(((), _)) => return, + } + } else { + None + }; + if sender.is_canceled() || cancellation.is_requested() { + return; + } + let (result_sender, mut result_receiver) = oneshot::channel(); + let thread = std::thread::Builder::new() + .name("windows-native-dialog".into()) + .spawn({ + let cancellation = cancellation.clone(); + move || { + let result = (|| { + let _apartment = Apartment::new()?; + let _timer = CancellationTimer::new(cancellation.clone())?; + if cancellation.is_requested() { + return Err(anyhow!("dialog cancelled")); + } + let result = dialog(hwnd.map(|hwnd| hwnd.as_raw()).unwrap_or_default()); + if cancellation.is_requested() { + return Err(anyhow!("dialog cancelled")); + } + result + })(); + result_sender.send(result).ok(); + } + }); + if let Err(error) = thread { + drop(serialization); + sender.send(Err(error.into())).ok(); + return; + } + let result = match select(&mut result_receiver, sender.cancellation()).await { + Either::Left((result, _)) => result, + Either::Right(((), _)) => { + cancellation.requested.store(true, Ordering::Release); + result_receiver.await + } + }; + // Neither launch another dialog nor destroy the owner until the + // native loop exits, even if the caller no longer wants its result. + drop(serialization); + sender + .send(result.unwrap_or_else(|error| Err(error.into()))) + .ok(); + }) + .detach(); + receiver +} + +struct Apartment; + +impl Apartment { + fn new() -> Result { + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()? }; + Ok(Self) + } +} + +impl Drop for Apartment { + fn drop(&mut self) { + unsafe { CoUninitialize() }; + } +} + +thread_local! { + static CANCELLATION: RefCell> = const { RefCell::new(None) }; +} + +struct CancellationTimer(usize); + +impl CancellationTimer { + fn new(cancellation: Cancellation) -> Result { + let timer = unsafe { SetTimer(None, 0, 100, Some(cancel_dialog)) }; + if timer == 0 { + return Err(windows::core::Error::from_thread()) + .context("starting native dialog cancellation timer"); + } + CANCELLATION.with(|slot| slot.replace(Some(cancellation))); + Ok(Self(timer)) + } +} + +impl Drop for CancellationTimer { + fn drop(&mut self) { + CANCELLATION.with(|slot| slot.take()); + unsafe { KillTimer(None, self.0).log_err() }; + } +} + +unsafe extern "system" fn cancel_dialog(_: HWND, _: u32, _: usize, _: u32) { + if cancellation_requested() { + unsafe { + EnumThreadWindows(GetCurrentThreadId(), Some(close_dialog), LPARAM(0)) + .ok() + .log_err(); + }; + } +} + +fn cancellation_requested() -> bool { + CANCELLATION.with(|slot| { + slot.borrow() + .as_ref() + .is_some_and(Cancellation::is_requested) + }) +} + +pub(crate) unsafe extern "system" fn task_dialog_callback( + _: HWND, + notification: TASKDIALOG_NOTIFICATIONS, + button: WPARAM, + _: LPARAM, + has_cancel_button: isize, +) -> HRESULT { + // WM_CLOSE requires TDF_ALLOW_DIALOG_CANCELLATION, which also enables Esc/X. + // Only allow those user gestures when the prompt offered a Cancel button. + if notification == TDN_BUTTON_CLICKED + && button.0 == IDCANCEL.0 as usize + && has_cancel_button == 0 + && !cancellation_requested() + { + S_FALSE + } else { + S_OK + } +} + +unsafe extern "system" fn close_dialog(hwnd: HWND, _: LPARAM) -> BOOL { + // Shell dialogs can open further modal dialogs. Close only dialog windows + // on this dedicated thread, not hidden COM infrastructure windows. + let mut class_name = [0u16; 32]; + let length = unsafe { GetClassNameW(hwnd, &mut class_name) } as usize; + let dialog_class = w!("#32770"); + if class_name.get(..length) == Some(unsafe { dialog_class.as_wide() }) { + unsafe { PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)).log_err() }; + } + TRUE +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt as _; + + #[test] + fn task_dialog_cancellation_requires_cancel_button_or_internal_request() { + let cancellation = Cancellation::default(); + CANCELLATION.with(|slot| slot.replace(Some(cancellation.clone()))); + for internally_cancelled in [false, true] { + cancellation + .requested + .store(internally_cancelled, Ordering::Release); + for has_cancel_button in [false, true] { + for button in [IDCANCEL.0, IDOK.0, -1] { + let result = unsafe { + task_dialog_callback( + HWND::default(), + TDN_BUTTON_CLICKED, + WPARAM(button as usize), + LPARAM(0), + has_cancel_button as isize, + ) + }; + let should_veto = + button == IDCANCEL.0 && !has_cancel_button && !internally_cancelled; + assert_eq!(result, if should_veto { S_FALSE } else { S_OK }); + } + } + } + CANCELLATION.with(|slot| slot.take()); + } + + #[test] + fn closing_owner_cancels_every_outstanding_dialog() { + let owner = DialogOwner::new(HWND::default()); + let first = Cancellation::new(Some(&owner)); + let second = Cancellation::new(Some(&owner)); + assert!(!first.is_requested()); + assert!(!second.is_requested()); + + owner.close(); + owner.close(); + + assert!(first.is_requested()); + assert!(second.is_requested()); + assert!(Cancellation::new(Some(&owner)).is_requested()); + } + + #[test] + fn closed_owner_waits_for_active_dialog_but_queued_requests_stay_cancelled() { + let owner = DialogOwner::new(HWND::default()); + let active = owner.serialization.try_lock().expect("first dialog"); + let queued = Cancellation::new(Some(&owner)); + + owner.close(); + assert!(owner.when_idle().now_or_never().is_none()); + drop(active); + assert!(owner.when_idle().now_or_never().is_some()); + + let _guard = owner.serialization.try_lock().expect("queued request"); + assert!(queued.is_requested()); + } + + #[test] + fn cancelling_one_request_does_not_cancel_its_owner_or_other_requests() { + let owner = DialogOwner::new(HWND::default()); + let first = Cancellation::new(Some(&owner)); + let second = Cancellation::new(Some(&owner)); + first.requested.store(true, Ordering::Release); + assert!(first.is_requested()); + assert!(!second.is_requested()); + assert!(!owner.closed.load(Ordering::Acquire)); + } + + #[test] + fn dialogs_serialize_per_owner() { + let owner = DialogOwner::new(HWND::default()); + let other_owner = DialogOwner::new(HWND::default()); + let guard = owner.serialization.try_lock().expect("first dialog"); + + assert!(owner.serialization.try_lock().is_none()); + assert!(other_owner.serialization.try_lock().is_some()); + drop(guard); + assert!(owner.serialization.try_lock().is_some()); + } + + #[test] + fn ownerless_request_can_be_cancelled_without_a_window() { + let cancellation = Cancellation::new(None); + assert!(!cancellation.is_requested()); + cancellation.requested.store(true, Ordering::Release); + assert!(cancellation.is_requested()); + } +} diff --git a/crates/gpui_windows/src/direct_write.rs b/crates/gpui_windows/src/direct_write.rs index 5a761cce19..dfe98a76be 100644 --- a/crates/gpui_windows/src/direct_write.rs +++ b/crates/gpui_windows/src/direct_write.rs @@ -1787,9 +1787,6 @@ fn apply_font_features( features: &FontFeatures, ) -> Result<()> { let tag_values = features.tag_value_list(); - if tag_values.is_empty() { - return Ok(()); - } // All of these features are enabled by default by DirectWrite. // If you want to (and can) peek into the source of DirectWrite diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs index 62386997f5..a4ebd3c9b0 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -1,4 +1,3 @@ -use collections::FxHashMap; use etagere::BucketedAtlasAllocator; use parking_lot::Mutex; use windows::Win32::Graphics::{ @@ -10,19 +9,18 @@ use windows::Win32::Graphics::{ }; use gpui::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, - PlatformAtlas, Point, Size, + AtlasBackend, AtlasKey, AtlasState, AtlasTextureId, AtlasTextureKind, AtlasTextureList, + AtlasTile, Bounds, DevicePixels, PlatformAtlas, Point, Size, }; -pub(crate) struct DirectXAtlas(Mutex); +pub(crate) struct DirectXAtlas(Mutex>); -struct DirectXAtlasState { +struct DirectXAtlasTextures { device: ID3D11Device, device_context: ID3D11DeviceContext, monochrome_textures: AtlasTextureList, polychrome_textures: AtlasTextureList, subpixel_textures: AtlasTextureList, - tiles_by_key: FxHashMap, } struct DirectXAtlasTexture { @@ -36,14 +34,13 @@ struct DirectXAtlasTexture { impl DirectXAtlas { pub(crate) fn new(device: &ID3D11Device, device_context: &ID3D11DeviceContext) -> Self { - DirectXAtlas(Mutex::new(DirectXAtlasState { + DirectXAtlas(Mutex::new(AtlasState::new(DirectXAtlasTextures { device: device.clone(), device_context: device_context.clone(), monochrome_textures: Default::default(), polychrome_textures: Default::default(), subpixel_textures: Default::default(), - tiles_by_key: Default::default(), - })) + }))) } pub(crate) fn get_texture_view( @@ -51,8 +48,8 @@ impl DirectXAtlas { id: AtlasTextureId, ) -> [Option; 1] { let lock = self.0.lock(); - let tex = lock.texture(id); - tex.view.clone() + let texture = lock.backend.texture(id); + texture.view.clone() } pub(crate) fn handle_device_lost( @@ -61,52 +58,54 @@ impl DirectXAtlas { device_context: &ID3D11DeviceContext, ) { let mut lock = self.0.lock(); - lock.device = device.clone(); - lock.device_context = device_context.clone(); - lock.monochrome_textures = AtlasTextureList::default(); - lock.polychrome_textures = AtlasTextureList::default(); - lock.subpixel_textures = AtlasTextureList::default(); - lock.tiles_by_key.clear(); + lock.clear(|textures| { + textures.device = device.clone(); + textures.device_context = device_context.clone(); + textures.monochrome_textures = AtlasTextureList::default(); + textures.polychrome_textures = AtlasTextureList::default(); + textures.subpixel_textures = AtlasTextureList::default(); + }); } } impl PlatformAtlas for DirectXAtlas { fn get_or_insert_with<'a>( &self, - key: &AtlasKey, + key: AtlasKey, build: &mut dyn FnMut() -> anyhow::Result< Option<(Size, std::borrow::Cow<'a, [u8]>)>, >, ) -> anyhow::Result> { - let mut lock = self.0.lock(); - if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(*tile)) - } else { - let Some((size, bytes)) = build()? else { - return Ok(None); - }; - let tile = lock - .allocate(size, key.texture_kind()) - .ok_or_else(|| anyhow::anyhow!("failed to allocate"))?; - let texture = lock.texture(tile.texture_id); - texture.upload(&lock.device_context, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile); - Ok(Some(tile)) - } + self.0.lock().get_or_insert_with(key, build) } fn remove(&self, key: &AtlasKey) { - let mut lock = self.0.lock(); + self.0.lock().remove(key); + } +} - let Some(tile) = lock.tiles_by_key.remove(key) else { - return; - }; +impl AtlasBackend for DirectXAtlasTextures { + fn insert( + &mut self, + kind: AtlasTextureKind, + size: Size, + bytes: &[u8], + ) -> anyhow::Result { + let tile = self + .allocate(size, kind) + .ok_or_else(|| anyhow::anyhow!("failed to allocate"))?; + let texture = self.texture(tile.texture_id); + texture.upload(&self.device_context, tile.bounds, bytes); + Ok(tile) + } + + fn remove(&mut self, tile: AtlasTile) { let id = tile.texture_id; let textures = match id.kind { - AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, - AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, - AtlasTextureKind::Subpixel => &mut lock.subpixel_textures, + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, }; let Some(texture_slot) = textures.textures.get_mut(id.index as usize) else { @@ -125,7 +124,7 @@ impl PlatformAtlas for DirectXAtlas { } } -impl DirectXAtlasState { +impl DirectXAtlasTextures { fn allocate( &mut self, size: Size, @@ -379,7 +378,7 @@ mod tests { }) } - fn insert_tile(atlas: &DirectXAtlas, key: &AtlasKey, size: Size) -> AtlasTile { + fn insert_tile(atlas: &DirectXAtlas, key: AtlasKey, size: Size) -> AtlasTile { atlas .get_or_insert_with(key, &mut || { let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; @@ -408,13 +407,13 @@ mod tests { let big_key_a = make_image_key(2); let big_key_b = make_image_key(3); - let keeper_tile = insert_tile(&atlas, &keeper_key, small); - let tile_a = insert_tile(&atlas, &big_key_a, big); + let keeper_tile = insert_tile(&atlas, keeper_key, small); + let tile_a = insert_tile(&atlas, big_key_a.clone(), big); assert_eq!(keeper_tile.texture_id, tile_a.texture_id); atlas.remove(&big_key_a); - let tile_b = insert_tile(&atlas, &big_key_b, big); + let tile_b = insert_tile(&atlas, big_key_b, big); assert_eq!(tile_b.texture_id, keeper_tile.texture_id); } } diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 8590407e61..3f53556e1d 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -417,7 +417,7 @@ impl DirectXRenderer { ) -> Result { // A pending device-lost recovery (`skip_draws`) leaves the atlas holding // tile references from the previous device; drawing before the forced - // re-render rebuilds them panics in `DirectXAtlasState::texture`. + // re-render rebuilds them panics in `DirectXAtlasTextures::texture`. anyhow::ensure!( !self.skip_draws, "render_to_image unavailable while recovering from a lost device" diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index e700cb0fc5..f1910b56b5 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -236,7 +236,11 @@ impl WindowsWindowInner { Some(0) } - fn handle_size_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + fn handle_size_msg(self: &Rc, wparam: WPARAM, lparam: LPARAM) -> Option { + // Minimizing and restoring both arrive as `WM_SIZE`; the deferred report + // reads `IsIconic` at delivery, so one call covers both directions. + self.report_visibility(); + // Don't resize the renderer when the window is minimized, but record that it was minimized so // that on restore the swap chain can be recreated via `update_drawable_size_even_if_unchanged`. if wparam.0 == SIZE_MINIMIZED as usize { @@ -1261,13 +1265,42 @@ impl WindowsWindowInner { Some(0) } - fn handle_window_visibility_changed(&self, handle: HWND, wparam: WPARAM) -> Option { + fn handle_window_visibility_changed( + self: &Rc, + handle: HWND, + wparam: WPARAM, + ) -> Option { + self.report_visibility(); if wparam.0 == 1 { self.draw_window(handle, false); } None } + // The window procedure can run while GPUI is updating this window (e.g. + // `ShowWindow` from an action handler), so deliver observers after that + // update completes, as activation does. The state is read at delivery so + // a burst of messages collapses to the final value. + fn report_visibility(self: &Rc) { + if self.state.last_visibility.get().is_none() { + return; + } + let this = self.clone(); + self.executor + .spawn(async move { + let visibility = this.visibility(); + if this.state.last_visibility.get() == Some(visibility) { + return; + } + this.state.last_visibility.set(Some(visibility)); + if let Some(mut callback) = this.state.callbacks.visibility_change.take() { + callback(visibility); + this.state.callbacks.visibility_change.set(Some(callback)); + } + }) + .detach(); + } + fn handle_device_lost(&self, lparam: LPARAM) -> Option { let devices = lparam.0 as *const DirectXDevices; let devices = unsafe { &*devices }; diff --git a/crates/gpui_windows/src/gpui_windows.rs b/crates/gpui_windows/src/gpui_windows.rs index 692d0dbbfd..e7a45a034d 100644 --- a/crates/gpui_windows/src/gpui_windows.rs +++ b/crates/gpui_windows/src/gpui_windows.rs @@ -2,6 +2,7 @@ mod clipboard; mod destination_list; +mod dialog; mod direct_manipulation; mod direct_write; mod directx_atlas; diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index d0ae3c764f..eab3b5e55c 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -11,7 +11,7 @@ use std::{ }; use anyhow::{Context as _, Result, anyhow}; -use futures::channel::oneshot::{self, Receiver}; +use futures::channel::oneshot::Receiver; use gpui_util::{ResultExt, get_powershell, new_std_command}; use itertools::Itertools; use parking_lot::RwLock; @@ -22,7 +22,15 @@ use windows::{ Foundation::*, Graphics::{Direct3D11::ID3D11Device, Gdi::*}, Security::Credentials::*, - System::{Com::*, LibraryLoader::*, Ole::*, Power::*, SystemInformation::*}, + System::{ + Com::*, + LibraryLoader::*, + Ole::*, + Power::*, + SystemInformation::*, + SystemServices::POWER_REQUEST_CONTEXT_VERSION, + Threading::{POWER_REQUEST_CONTEXT_SIMPLE_STRING, REASON_CONTEXT, REASON_CONTEXT_0}, + }, UI::{Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*}, }, core::*, @@ -85,6 +93,7 @@ struct PlatformCallbacks { will_open_app_menu: Cell>>, validate_app_menu_command: Cell bool>>>, keyboard_layout_change: Cell>>, + system_sleep: Cell>>, system_wake: Cell>>, } @@ -106,6 +115,51 @@ impl WindowsPlatformState { } } +struct PowerRequest { + handle: HANDLE, + // `PowerCreateRequest` retains a pointer into the reason string for the + // lifetime of the handle, so the UTF-16 buffer must outlive the request. + _reason: Vec, +} + +unsafe impl Send for PowerRequest {} + +impl PowerRequest { + fn prevent_idle_sleep(reason: &str) -> Result { + let mut reason = reason.encode_utf16().chain([0]).collect::>(); + let context = REASON_CONTEXT { + Version: POWER_REQUEST_CONTEXT_VERSION, + Flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING, + Reason: REASON_CONTEXT_0 { + SimpleReasonString: PWSTR(reason.as_mut_ptr()), + }, + }; + let handle = unsafe { PowerCreateRequest(&context) } + .context("Failed to create a Windows power request")?; + if let Err(error) = unsafe { PowerSetRequest(handle, PowerRequestSystemRequired) } { + unsafe { CloseHandle(handle) } + .context("Failed to close the Windows power request") + .log_err(); + return Err(error).context("Failed to set the Windows power request"); + } + Ok(Self { + handle, + _reason: reason, + }) + } +} + +impl Drop for PowerRequest { + fn drop(&mut self) { + unsafe { PowerClearRequest(self.handle, PowerRequestSystemRequired) } + .context("Failed to clear the Windows power request") + .log_err(); + unsafe { CloseHandle(self.handle) } + .context("Failed to close the Windows power request") + .log_err(); + } +} + impl WindowsPlatform { pub fn new(headless: bool) -> Result { unsafe { @@ -444,6 +498,13 @@ impl Platform for WindowsPlatform { ThermalState::Nominal } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready( + PowerRequest::prevent_idle_sleep(reason) + .map(|request| ActivityGuard::new(move || drop(request))), + ) + } + fn run(&self, on_finish_launching: Box) { on_finish_launching(); if !self.headless { @@ -565,7 +626,7 @@ impl Platform for WindowsPlatform { #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, - ) -> oneshot::Receiver>>> { + ) -> futures::channel::oneshot::Receiver>>> { gpui::scap_screen_capture::scap_screen_sources(&self.foreground_executor) } @@ -613,15 +674,13 @@ impl Platform for WindowsPlatform { &self, options: PathPromptOptions, ) -> Receiver>>> { - let (tx, rx) = oneshot::channel(); - let window = self.find_current_active_window(); - self.foreground_executor() - .spawn(async move { - let _ = tx.send(file_open_dialog(options, window)); - }) - .detach(); - - rx + let owner = self + .find_current_active_window() + .and_then(|hwnd| self.window_from_hwnd(hwnd)) + .map(|window| window.dialog_owner.clone()); + crate::dialog::show_dialog(owner, &self.foreground_executor, move |window| { + file_open_dialog(options, Some(window)) + }) } fn prompt_for_new_path( @@ -631,15 +690,13 @@ impl Platform for WindowsPlatform { ) -> Receiver>> { let directory = directory.to_owned(); let suggested_name = suggested_name.map(|s| s.to_owned()); - let (tx, rx) = oneshot::channel(); - let window = self.find_current_active_window(); - self.foreground_executor() - .spawn(async move { - let _ = tx.send(file_save_dialog(directory, suggested_name, window)); - }) - .detach(); - - rx + let owner = self + .find_current_active_window() + .and_then(|hwnd| self.window_from_hwnd(hwnd)) + .map(|window| window.dialog_owner.clone()); + crate::dialog::show_dialog(owner, &self.foreground_executor, move |window| { + file_save_dialog(directory, suggested_name, Some(window)) + }) } fn can_select_mixed_files_and_dirs(&self) -> bool { @@ -683,6 +740,10 @@ impl Platform for WindowsPlatform { self.inner.state.callbacks.reopen.set(Some(callback)); } + fn on_system_sleep(&self, callback: Box) { + self.inner.state.callbacks.system_sleep.set(Some(callback)); + } + fn on_system_wake(&self, callback: Box) { self.inner.state.callbacks.system_wake.set(Some(callback)); let mut notification = self.suspend_resume_notification.borrow_mut(); @@ -853,7 +914,7 @@ impl Platform for WindowsPlatform { .encode_utf16() .chain(Some(0)) .collect_vec(); - self.foreground_executor().spawn(async move { + self.background_executor().spawn(async move { let credentials = CREDENTIALW { LastWritten: unsafe { GetSystemTimeAsFileTime() }, Flags: CRED_FLAGS(0), @@ -882,7 +943,7 @@ impl Platform for WindowsPlatform { .encode_utf16() .chain(Some(0)) .collect_vec(); - self.foreground_executor().spawn(async move { + self.background_executor().spawn(async move { let mut credentials: *mut CREDENTIALW = std::ptr::null_mut(); let result = unsafe { CredReadW( @@ -924,7 +985,7 @@ impl Platform for WindowsPlatform { .encode_utf16() .chain(Some(0)) .collect_vec(); - self.foreground_executor().spawn(async move { + self.background_executor().spawn(async move { unsafe { CredDeleteW( PCWSTR::from_raw(target_name.as_ptr()), @@ -1164,8 +1225,14 @@ impl WindowsPlatformInner { } fn handle_power_broadcast(&self, wparam: WPARAM) -> Option { - if wparam.0 as u32 == PBT_APMRESUMEAUTOMATIC { - self.with_callback(|callbacks| &callbacks.system_wake, |callback| callback()); + match wparam.0 as u32 { + PBT_APMSUSPEND => { + self.with_callback(|callbacks| &callbacks.system_sleep, |callback| callback()); + } + PBT_APMRESUMEAUTOMATIC => { + self.with_callback(|callbacks| &callbacks.system_wake, |callback| callback()); + } + _ => {} } Some(1) } @@ -1327,9 +1394,11 @@ fn file_open_dialog( folder_dialog.SetOkButtonLabel(&HSTRING::from(prompt))?; } - if folder_dialog.Show(window).is_err() { - // User cancelled - return Ok(None); + if let Err(error) = folder_dialog.Show(window) { + if error.code() == HRESULT::from_win32(ERROR_CANCELLED.0) { + return Ok(None); + } + return Err(error.into()); } } @@ -1387,9 +1456,11 @@ fn file_save_dialog( pszName: windows::core::w!("All files"), pszSpec: windows::core::w!("*.*"), }])?; - if dialog.Show(window).is_err() { - // User cancelled - return Ok(None); + if let Err(error) = dialog.Show(window) { + if error.code() == HRESULT::from_win32(ERROR_CANCELLED.0) { + return Ok(None); + } + return Err(error.into()); } } let shell_item = unsafe { dialog.GetResult()? }; diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index d20cb317db..ba89c68733 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -127,12 +127,12 @@ float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds bounds, Bo // Convert linear RGB to sRGB float3 linear_to_srgb(float3 color) { - return pow(color, float3(2.2, 2.2, 2.2)); + return pow(color, float3(1.0 / 2.2, 1.0 / 2.2, 1.0 / 2.2)); } // Convert sRGB to linear RGB float3 srgb_to_linear(float3 color) { - return pow(color, float3(1.0 / 2.2, 1.0 / 2.2, 1.0 / 2.2)); + return pow(color, float3(2.2, 2.2, 2.2)); } /// Hsla to linear RGBA conversion. diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 37e9d9a176..73f56d7a54 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -60,6 +60,7 @@ pub struct WindowsWindowState { pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, pub hovered: Cell, + pub last_visibility: Cell>, pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, @@ -67,7 +68,7 @@ pub struct WindowsWindowState { /// render. Used after a GPU device-lost recovery, where the next frame /// must both re-enable drawing (via `mark_drawable`) and bypass the GPUI /// view cache (which would otherwise replay stale atlas tile references - /// from the previous frame and panic in `DirectXAtlasState::texture`), + /// from the previous frame and panic in `DirectXAtlasTextures::texture`), /// and when a forced render was requested while another draw was in /// progress and had to be deferred. pub force_render_pending: Cell, @@ -92,6 +93,7 @@ pub struct WindowsWindowState { pub(crate) struct WindowsWindowInner { hwnd: HWND, + pub(crate) dialog_owner: Rc, drop_target_helper: IDropTargetHelper, pub(crate) state: WindowsWindowState, system_settings: WindowsSystemSettings, @@ -172,6 +174,7 @@ impl WindowsWindowState { last_reported_modifiers: Cell::new(last_reported_modifiers), last_reported_capslock: Cell::new(last_reported_capslock), hovered: Cell::new(hovered), + last_visibility: Cell::new(None), renderer: RefCell::new(renderer), force_render_pending: Cell::new(false), click_state, @@ -249,6 +252,19 @@ impl WindowsWindowState { } impl WindowsWindowInner { + /// Whether the window is being presented: shown and not minimized. Windows + /// has no notification for a window fully covered by other windows, so + /// that case reports `Visible`. + pub(crate) fn visibility(&self) -> WindowVisibility { + let is_visible = + unsafe { IsWindowVisible(self.hwnd).as_bool() && !IsIconic(self.hwnd).as_bool() }; + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } + } + fn new(context: &mut WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result> { let state = WindowsWindowState::new( hwnd, @@ -266,6 +282,7 @@ impl WindowsWindowInner { Ok(Rc::new(Self { hwnd, + dialog_owner: crate::dialog::DialogOwner::new(hwnd), drop_target_helper: context.drop_target_helper.clone(), state, handle: context.handle, @@ -380,6 +397,7 @@ pub(crate) struct Callbacks { pub(crate) request_frame: Cell>>, pub(crate) input: Cell DispatchEventResult>>>, pub(crate) active_status_change: Cell>>, + pub(crate) visibility_change: Cell>>, pub(crate) hovered_status_change: Cell>>, pub(crate) resize: Cell, f32)>>>, pub(crate) moved: Cell>>, @@ -589,11 +607,17 @@ impl rwh::HasDisplayHandle for WindowsWindow { impl Drop for WindowsWindow { fn drop(&mut self) { + self.0.dialog_owner.close(); + unsafe { ShowWindowAsync(self.0.hwnd, SW_HIDE).ok().log_err() }; + // `DestroyWindow` below sends `WM_SHOWWINDOW`; without a callback the + // resulting visibility report has nothing to notify. + self.0.state.callbacks.visibility_change.take(); // clone this `Rc` to prevent early release of the pointer let this = self.0.clone(); self.0 .executor .spawn(async move { + this.dialog_owner.when_idle().await; let handle = this.hwnd; unsafe { RevokeDragDrop(handle).log_err(); @@ -698,18 +722,19 @@ impl PlatformWindow for WindowsWindow { detail: Option<&str>, answers: &[PromptButton], ) -> Option> { - let (done_tx, done_rx) = oneshot::channel(); + let (mut done_tx, done_rx) = oneshot::channel(); let msg = msg.to_string(); let detail_string = detail.map(|detail| detail.to_string()); - let handle = self.0.hwnd; let answers = answers.to_vec(); - self.0 - .executor - .spawn(async move { + let dialog = crate::dialog::show_dialog( + Some(self.0.dialog_owner.clone()), + &self.0.executor, + move |handle| { unsafe { let mut config = TASKDIALOGCONFIG::default(); config.cbSize = std::mem::size_of::() as _; config.hwndParent = handle; + config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION; let title; let main_icon; match level { @@ -757,17 +782,26 @@ impl PlatformWindow for WindowsWindow { config.cButtons = buttons.len() as _; config.pButtons = buttons.as_ptr(); - config.pfCallback = None; + config.pfCallback = Some(crate::dialog::task_dialog_callback); + config.lpCallbackData = button_id_map.contains(&IDCANCEL.0) as isize; let mut res = std::mem::zeroed(); - let _ = TaskDialogIndirect(&config, Some(&mut res), None, None) - .context("unable to create task dialog") - .log_err(); - - if let Some(clicked) = - button_id_map.iter().position(|&button_id| button_id == res) - { - let _ = done_tx.send(clicked); - } + TaskDialogIndirect(&config, Some(&mut res), None, None) + .context("unable to create task dialog")?; + Ok(button_id_map.iter().position(|&button_id| button_id == res)) + } + }, + ); + self.0 + .executor + .spawn(async move { + if let futures::future::Either::Left((result, _)) = + futures::future::select(dialog, done_tx.cancellation()).await + && let Some(Some(clicked)) = result + .context("native dialog thread stopped") + .and_then(|result| result) + .log_err() + { + done_tx.send(clicked).ok(); } }) .detach(); @@ -854,6 +888,10 @@ impl PlatformWindow for WindowsWindow { self.0.hwnd == unsafe { GetActiveWindow() } } + fn visibility(&self) -> WindowVisibility { + self.0.visibility() + } + fn is_hovered(&self) -> bool { self.state.hovered.get() } @@ -943,6 +981,11 @@ impl PlatformWindow for WindowsWindow { .set(Some(callback)); } + fn on_visibility_change(&self, callback: Box) { + self.0.state.last_visibility.set(Some(self.0.visibility())); + self.0.state.callbacks.visibility_change.set(Some(callback)); + } + fn on_hover_status_change(&self, callback: Box) { self.0 .state @@ -1419,6 +1462,9 @@ unsafe extern "system" fn window_procedure( } let inner = unsafe { &*ptr }; let result = if let Some(inner) = inner.upgrade() { + if msg == WM_NCDESTROY { + inner.dialog_owner.close(); + } inner.handle_msg(hwnd, msg, wparam, lparam) } else { unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } diff --git a/crates/grammars/Cargo.toml b/crates/grammars/Cargo.toml index 00b35ce964..ef83bb7079 100644 --- a/crates/grammars/Cargo.toml +++ b/crates/grammars/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "grammars" version = "0.1.0" -edition = "2024" +edition.workspace = true publish = false [lints] @@ -9,12 +9,13 @@ workspace = true [lib] path = "src/grammars.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true language_core.workspace = true rust-embed.workspace = true -toml.workspace = true util.workspace = true tree-sitter = { workspace = true, optional = true } diff --git a/crates/grammars/src/c/highlights.scm b/crates/grammars/src/c/highlights.scm index b73c8e80b8..b3a3914e46 100644 --- a/crates/grammars/src/c/highlights.scm +++ b/crates/grammars/src/c/highlights.scm @@ -1,5 +1,6 @@ [ "const" + "constexpr" "enum" "extern" "inline" diff --git a/crates/grammars/src/go/highlights.scm b/crates/grammars/src/go/highlights.scm index aaec47bf5e..25ec7cb18d 100644 --- a/crates/grammars/src/go/highlights.scm +++ b/crates/grammars/src/go/highlights.scm @@ -101,32 +101,35 @@ ] @operator [ - "break" - "case" "chan" "const" + "func" + "import" + "interface" + "map" + "package" + "struct" + "type" + "var" +] @keyword + +[ + "break" + "case" "continue" "default" "defer" "else" "fallthrough" "for" - "func" "go" "goto" "if" - "import" - "interface" - "map" - "package" "range" "return" "select" - "struct" "switch" - "type" - "var" -] @keyword +] @keyword.control [ (interpreted_string_literal) diff --git a/crates/grammars/src/grammars.rs b/crates/grammars/src/grammars.rs index 426926d0db..fa7b226596 100644 --- a/crates/grammars/src/grammars.rs +++ b/crates/grammars/src/grammars.rs @@ -55,7 +55,7 @@ pub fn load_config(name: &str) -> LanguageConfig { ) .unwrap(); - let config: LanguageConfig = ::toml::from_str(&config_toml) + let config = LanguageConfig::from_toml(&config_toml) .with_context(|| format!("failed to load config.toml for language {name:?}")) .unwrap(); diff --git a/crates/grammars/src/python/highlights.scm b/crates/grammars/src/python/highlights.scm index 7641cef117..cf2fb39762 100644 --- a/crates/grammars/src/python/highlights.scm +++ b/crates/grammars/src/python/highlights.scm @@ -46,18 +46,17 @@ function: (identifier) @function.call) (decorator - "@" @punctuation.special) - -(decorator - "@" @punctuation.special + "@" @function.decorator [ (identifier) @function.decorator (attribute + object: (identifier) @function.decorator attribute: (identifier) @function.decorator) (call function: (identifier) @function.decorator.call) (call (attribute + object: (identifier) @function.decorator.call attribute: (identifier) @function.decorator.call)) ]) @@ -257,7 +256,6 @@ "&" "%" "%=" - "@" "^" "+" "->" @@ -282,6 +280,9 @@ "|=" ] @operator +(binary_operator + operator: "@" @operator) + [ "and" "in" @@ -335,8 +336,9 @@ ] @keyword.definition (decorator - (identifier) @attribute.builtin - (#any-of? @attribute.builtin "classmethod" "staticmethod" "property")) + "@" @attribute.builtin + (identifier) @attribute.builtin @_decorator + (#any-of? @_decorator "classmethod" "staticmethod" "property")) (attribute attribute: (identifier) @attribute.special diff --git a/crates/grammars/src/python/injections.scm b/crates/grammars/src/python/injections.scm index bc47469dc8..8981caffac 100644 --- a/crates/grammars/src/python/injections.scm +++ b/crates/grammars/src/python/injections.scm @@ -13,14 +13,14 @@ arguments: (argument_list (comment) @_comment (string - (string_content) @injection.content))) + (string_content) @injection.content) @injection.host)) ; string variables ((comment) @_comment . (expression_statement (assignment right: (string - (string_content) @injection.content)))) + (string_content) @injection.content) @injection.host))) ] (#match? @_comment "^(#|#\\s+)(?i:sql)\\s*$") (#set! injection.language "sql")) diff --git a/crates/html_to_markdown/Cargo.toml b/crates/html_to_markdown/Cargo.toml index 70ff3b3555..8dc631f324 100644 --- a/crates/html_to_markdown/Cargo.toml +++ b/crates/html_to_markdown/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [lib] path = "src/html_to_markdown.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/html_to_markdown/src/html_to_markdown.rs b/crates/html_to_markdown/src/html_to_markdown.rs index fda2cce5f9..f7fe9bac68 100644 --- a/crates/html_to_markdown/src/html_to_markdown.rs +++ b/crates/html_to_markdown/src/html_to_markdown.rs @@ -18,8 +18,11 @@ pub use crate::html_element::*; pub use crate::markdown_writer::*; /// Converts the provided HTML to Markdown. -pub fn convert_html_to_markdown(html: impl Read, handlers: &mut [TagHandler]) -> Result { - let dom = parse_html(html).context("failed to parse HTML")?; +pub fn convert_html_to_markdown( + mut html: impl Read, + handlers: &mut [TagHandler], +) -> Result { + let dom = parse_html(&mut html).context("failed to parse HTML")?; let markdown_writer = MarkdownWriter::new(); let markdown = markdown_writer @@ -29,7 +32,8 @@ pub fn convert_html_to_markdown(html: impl Read, handlers: &mut [TagHandler]) -> Ok(markdown) } -fn parse_html(mut html: impl Read) -> Result { +#[inline(never)] +fn parse_html(mut html: &mut dyn Read) -> Result { let parse_options = ParseOpts { tree_builder: TreeBuilderOpts { drop_doctype: true, diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index efae728ddb..ba2282a0ca 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -21,7 +21,7 @@ github-download = [ [lib] path = "src/http_client.rs" -doctest = true +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/http_client_tls/Cargo.toml b/crates/http_client_tls/Cargo.toml index 58cce9c745..3b2f456b3b 100644 --- a/crates/http_client_tls/Cargo.toml +++ b/crates/http_client_tls/Cargo.toml @@ -13,7 +13,8 @@ test-support = [] [lib] path = "src/http_client_tls.rs" -doctest = true +doctest = false +test = false [dependencies] log.workspace = true diff --git a/crates/http_proxy/Cargo.toml b/crates/http_proxy/Cargo.toml index 239cd9657d..1b4faecf7d 100644 --- a/crates/http_proxy/Cargo.toml +++ b/crates/http_proxy/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/http_proxy.rs" +doctest = false [features] # Compiles in the `ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS` escape hatch that lets the diff --git a/crates/icons/Cargo.toml b/crates/icons/Cargo.toml index fc00165843..7eac6af6d1 100644 --- a/crates/icons/Cargo.toml +++ b/crates/icons/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/icons.rs" +doctest = false [dependencies] serde.workspace = true diff --git a/crates/input_latency_ui/Cargo.toml b/crates/input_latency_ui/Cargo.toml index 67322ccb37..792f8fe107 100644 --- a/crates/input_latency_ui/Cargo.toml +++ b/crates/input_latency_ui/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/input_latency_ui.rs" +test = false +doctest = false [dependencies] chrono.workspace = true diff --git a/crates/input_latency_ui/src/input_latency_ui.rs b/crates/input_latency_ui/src/input_latency_ui.rs index a1961a2254..dced138039 100644 --- a/crates/input_latency_ui/src/input_latency_ui.rs +++ b/crates/input_latency_ui/src/input_latency_ui.rs @@ -164,6 +164,7 @@ pub fn report_input_latency_telemetry(window: &Window, cx: &mut App) { frames_with_2_events = frames_with_2_events, frames_with_3_events = frames_with_3_events, report_window_seconds = report_window_seconds, + measurement_version = gpui::profiler::hang::MEASUREMENT_VERSION, ); } @@ -264,6 +265,7 @@ pub fn report_frame_duration_telemetry(window: &Window, cx: &mut App) { let intervals_18to36 = count_frames_in_range(&delta_intervals, MS18_NS, MS36_NS); let intervals_36to100 = count_frames_in_range(&delta_intervals, MS36_NS, MS100_NS); // intervals > 100ms are implicitly total_intervals - (the buckets above) + let average_present_interval_ms = delta_intervals.mean() / 1_000_000.0; let average_dirty_to_present_ms = delta_dirty_to_present.mean() / 1_000_000.0; telemetry::event!( @@ -278,9 +280,17 @@ pub fn report_frame_duration_telemetry(window: &Window, cx: &mut App) { intervals_18to36 = intervals_18to36, intervals_36to100 = intervals_36to100, total_intervals = total_intervals, + average_present_interval_ms = average_present_interval_ms, average_dirty_to_present_ms = average_dirty_to_present_ms, + dirty_to_present_samples = delta_dirty_to_present.len(), + dirty_to_present_p50_ms = + delta_dirty_to_present.value_at_quantile(0.5) as f64 / 1_000_000.0, + dirty_to_present_p95_ms = + delta_dirty_to_present.value_at_quantile(0.95) as f64 / 1_000_000.0, + dirty_to_present_max_ms = delta_dirty_to_present.max() as f64 / 1_000_000.0, root_entity_type_name = window_handle.root_entity_type_name(), report_window_seconds = report_window_seconds, + measurement_version = gpui::profiler::hang::MEASUREMENT_VERSION, ); } diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index 892f93d9b5..908479bb1f 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -10,9 +10,7 @@ workspace = true [lib] path = "src/inspector_ui.rs" - -[features] -inspector = [] +doctest = false [dependencies] anyhow.workspace = true @@ -30,3 +28,15 @@ util.workspace = true util_macros.workspace = true workspace.workspace = true zed_actions.workspace = true + +[dev-dependencies] +fs = { workspace = true, features = ["test-support"] } +futures.workspace = true +gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +paths.workspace = true +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +snippet_provider.workspace = true +theme.workspace = true +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index 78c76ee3e5..803386224c 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -5,7 +5,8 @@ use editor::{ use fuzzy::StringMatch; use gpui::{ AsyncWindowContext, DivInspectorState, Entity, InspectorElementId, IntoElement, - StyleRefinement, Task, Window, inspector_reflection::FunctionReflection, styled_reflection, + StyleRefinement, Subscription, Task, WeakEntity, Window, + inspector_reflection::FunctionReflection, styled_reflection, }; use language::language_settings::SoftWrap; use language::{ @@ -47,6 +48,7 @@ pub(crate) struct DivInspector { rust_completion: Option, /// Range that will be replaced by the completion if selected. rust_completion_replace_range: Option>, + _initialization_task: Task<()>, } enum State { @@ -60,6 +62,7 @@ enum State { rust_style_editor: Entity, json_style_buffer: Entity, json_style_editor: Entity, + _subscriptions: [Subscription; 2], }, LoadError { message: SharedString, @@ -73,7 +76,7 @@ impl DivInspector { cx: &mut Context, ) -> DivInspector { // Open the buffers once, so they can then be used for each editor. - cx.spawn_in(window, { + let initialization_task = cx.spawn_in(window, { let languages = project.read(cx).languages().clone(); let project = project.clone(); async move |this, cx| { @@ -104,10 +107,11 @@ impl DivInspector { // `update_inspected_element`. This avoids continuing to show // "Loading..." until the user moves the mouse to a different element. if let Some(id) = this.inspector_id.take() { - let inspector_state = - window.with_inspector_state(Some(&id), cx, |state, _window| { + let inspector_state = window + .with_inspector_state(Some(&id), cx, |state, _window| { state.clone() - }); + }) + .flatten(); if let Some(inspector_state) = inspector_state { this.update_inspected_element(&id, inspector_state, window, cx); cx.notify(); @@ -129,8 +133,7 @@ impl DivInspector { } } } - }) - .detach(); + }); DivInspector { state: State::Loading, @@ -143,6 +146,7 @@ impl DivInspector { rust_completion: None, rust_completion_replace_range: None, json_style_error: None, + _initialization_task: initialization_task, } } @@ -180,7 +184,7 @@ impl DivInspector { let rust_style_editor = self.create_editor(rust_style_buffer.clone(), window, cx); rust_style_editor.update(cx, { - let div_inspector = cx.entity(); + let div_inspector = cx.weak_entity(); |rust_style_editor, _cx| { rust_style_editor.set_completion_provider(Some(Rc::new( RustStyleCompletionProvider { div_inspector }, @@ -200,7 +204,7 @@ impl DivInspector { } }; - cx.subscribe_in(&json_style_editor, window, { + let json_subscription = cx.subscribe_in(&json_style_editor, window, { let id = id.clone(); let rust_style_buffer = rust_style_buffer.clone(); move |this, editor, event: &EditorEvent, window, cx| { @@ -247,10 +251,9 @@ impl DivInspector { } } } - }) - .detach(); + }); - cx.subscribe(&rust_style_editor, { + let rust_subscription = cx.subscribe(&rust_style_editor, { let json_style_buffer = json_style_buffer.clone(); let rust_style_buffer = rust_style_buffer.clone(); move |this, _editor, event: &EditorEvent, cx| { @@ -258,8 +261,7 @@ impl DivInspector { this.update_json_style_from_rust(&json_style_buffer, &rust_style_buffer, cx); } } - }) - .detach(); + }); self.unconvertible_style = style.subtract(&rust_style); self.json_style_overrides = StyleRefinement::default(); @@ -268,6 +270,7 @@ impl DivInspector { rust_style_editor, json_style_buffer, json_style_editor, + _subscriptions: [json_subscription, rust_subscription], }; } @@ -638,7 +641,7 @@ fn is_not_identifier_char(c: char) -> bool { } struct RustStyleCompletionProvider { - div_inspector: Entity, + div_inspector: WeakEntity, } impl CompletionProvider for RustStyleCompletionProvider { @@ -655,9 +658,15 @@ impl CompletionProvider for RustStyleCompletionProvider { return Task::ready(Ok(Vec::new())); }; - self.div_inspector.update(cx, |div_inspector, _cx| { - div_inspector.rust_completion_replace_range = Some(replace_range.clone()); - }); + if self + .div_inspector + .update(cx, |div_inspector, _cx| { + div_inspector.rust_completion_replace_range = Some(replace_range.clone()); + }) + .is_err() + { + return Task::ready(Ok(Vec::new())); + } Task::ready(Ok(vec![CompletionResponse { completions: STYLE_METHODS @@ -699,9 +708,11 @@ impl CompletionProvider for RustStyleCompletionProvider { let div_inspector = self.div_inspector.clone(); let rust_completion = mat.as_ref().map(|mat| mat.string.clone()); cx.defer(move |cx| { - div_inspector.update(cx, |div_inspector, cx| { - div_inspector.handle_rust_completion_selection_change(rust_completion, cx); - }); + div_inspector + .update(cx, |div_inspector, cx| { + div_inspector.handle_rust_completion_selection_change(rust_completion, cx); + }) + .ok(); }); } @@ -734,3 +745,298 @@ fn completion_replace_range(snapshot: &BufferSnapshot, anchor: &Anchor) -> Optio None } } + +#[cfg(test)] +mod tests { + use super::*; + use fs::FakeFs; + use futures::{FutureExt as _, channel::oneshot}; + use gpui::{ + AnyWeakEntity, Focusable as _, Modifiers, TestAppContext, VisualTestContext, point, + }; + use language::{LanguageConfig, LanguageName, LanguageQueries, LoadedLanguage}; + use settings::SettingsStore; + use snippet_provider::SnippetProvider; + use std::{ + cell::RefCell, + collections::BTreeSet, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + }; + + #[gpui::test] + async fn test_drop_during_initialization(cx: &mut TestAppContext) { + let (fs, watcher_baseline) = test_fs(cx); + let project = Project::test(fs.clone(), [], cx).await; + let weak_project = project.downgrade(); + let weak_lsp_store = project.read_with(cx, |project, _| project.lsp_store().downgrade()); + let (finish_loading, loading_gate) = oneshot::channel::<()>(); + let loading_gate = loading_gate.shared(); + let loading_started = Arc::new(AtomicBool::new(false)); + project.read_with(cx, |project, _| { + project.languages().register_language( + LanguageName::from("Rust"), + None, + Arc::default(), + false, + None, + Arc::new({ + let loading_started = loading_started.clone(); + move || { + let loading_gate = loading_gate.clone(); + let loading_started = loading_started.clone(); + Box::pin(async move { + loading_started.store(true, Ordering::SeqCst); + loading_gate.await?; + Ok(LoadedLanguage { + config: LanguageConfig { + name: LanguageName::from("Rust"), + ..LanguageConfig::default() + }, + queries: LanguageQueries::default(), + context_provider: None, + toolchain_provider: None, + manifest_name: None, + }) + }) + } + }), + ); + }); + + let cx = cx.add_empty_window(); + let div_inspector = + cx.new_window_entity(|window, cx| DivInspector::new(project, window, cx)); + let weak_inspector = div_inspector.downgrade(); + cx.run_until_parked(); + + assert!(loading_started.load(Ordering::SeqCst)); + assert_inspector_watchers(&fs, &watcher_baseline); + let weak_json_buffer = div_inspector.read_with(cx, |div_inspector, cx| { + assert!(matches!(div_inspector.state, State::Loading)); + div_inspector + .project + .read(cx) + .buffer_store() + .read(cx) + .buffers() + .next() + .expect("initialization must load the JSON buffer before Rust") + .downgrade() + }); + + cx.update(|_, _| drop(div_inspector)); + flush_teardown(cx); + + assert!(weak_inspector.upgrade().is_none()); + assert!(weak_project.upgrade().is_none()); + assert!(weak_lsp_store.upgrade().is_none()); + assert!(weak_json_buffer.upgrade().is_none()); + assert_eq!(watched_paths(&fs), watcher_baseline); + + finish_loading + .send(()) + .expect("language loader must still be waiting"); + cx.run_until_parked(); + assert!(weak_inspector.upgrade().is_none()); + assert!(weak_project.upgrade().is_none()); + assert!(weak_lsp_store.upgrade().is_none()); + assert_eq!(watched_paths(&fs), watcher_baseline); + } + + #[gpui::test] + async fn test_close_focused_inspector_and_reopen_same_element(cx: &mut TestAppContext) { + let (fs, watcher_baseline) = test_fs(cx); + let pending_project = Rc::new(RefCell::new(None::>)); + let inspectors = Rc::new(RefCell::new(Vec::new())); + cx.update({ + let pending_project = pending_project.clone(); + let inspectors = inspectors.clone(); + move |cx| { + cx.set_inspector_renderer(Box::new(|inspector, window, cx| { + v_flex() + .size_full() + .children(inspector.render_inspector_states(window, cx)) + .into_any_element() + })); + cx.register_inspector_element(move |window, cx| { + let project = pending_project.borrow_mut().take().expect("fresh project"); + let inspector = cx.new(|cx| DivInspector::new(project, window, cx)); + inspectors.borrow_mut().push(inspector.downgrade()); + move |id, state: &DivInspectorState, window: &mut Window, cx: &mut App| { + inspector.update(cx, |inspector, cx| { + inspector.update_inspected_element(&id, state.clone(), window, cx); + inspector.render(window, cx).into_any_element() + }) + } + }); + } + }); + let first_window = cx.add_window(|_, _| InspectorTarget); + let second_window = cx.add_window(|_, _| InspectorTarget); + let mut second = VisualTestContext::from_window(second_window.into(), cx); + let mut selected_id = None; + let mut resources = Vec::new(); + let mut surviving_resources = Vec::new(); + let mut surviving_editor = None; + let mut active_baseline = watcher_baseline.clone(); + let windows = [second_window, first_window, first_window]; + for (generation, window) in windows.into_iter().enumerate() { + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let project = Project::test(fs.clone(), [], cx).await; + project.read_with(cx, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: LanguageName::from("Rust"), + ..LanguageConfig::default() + }); + }); + *pending_project.borrow_mut() = Some(project); + cx.update(|window, cx| { + window.toggle_inspector(cx); + window.draw(cx).clear(cx); + }); + let position = point(px(5.), px(5.)); + cx.simulate_mouse_move(position, None, Modifiers::default()); + cx.simulate_click(position, Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.run_until_parked(); + assert_eq!(inspectors.borrow().len(), generation + 1); + let weak_inspector = inspectors + .borrow() + .last() + .expect("rendered inspector") + .clone(); + let (weak_entities, rust_editor, focus_handle) = weak_inspector + .read_with(cx, |inspector, cx| { + let id = inspector.inspector_id.clone().expect("picked element"); + if generation > 0 + && let Some(previous_id) = selected_id.replace(id.clone()) + { + assert_eq!(id, previous_id); + } + let State::Ready { + rust_style_buffer, + rust_style_editor, + json_style_buffer, + json_style_editor, + .. + } = &inspector.state + else { + panic!("picked element must have initialized editors"); + }; + let editor = if generation == 2 { + json_style_editor + } else { + rust_style_editor + }; + ( + [ + AnyWeakEntity::from(weak_inspector.clone()), + AnyWeakEntity::from(inspector.project.downgrade()), + AnyWeakEntity::from(inspector.project.read(cx).lsp_store().downgrade()), + AnyWeakEntity::from(rust_style_editor.downgrade()), + AnyWeakEntity::from(json_style_editor.downgrade()), + AnyWeakEntity::from(rust_style_buffer.downgrade()), + AnyWeakEntity::from(json_style_buffer.downgrade()), + ], + rust_style_editor.downgrade(), + editor.focus_handle(cx), + ) + }) + .expect("live inspector"); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.draw(cx).clear(cx); + assert!(focus_handle.is_focused(window)); + }); + assert_inspector_watchers(&fs, &active_baseline); + resources.extend(weak_entities); + if generation == 0 { + surviving_editor = Some(rust_editor); + surviving_resources = resources.clone(); + active_baseline = watched_paths(&fs); + continue; + } + cx.update(|window, cx| { + if generation == 1 { + window.toggle_inspector(cx); + window.draw(cx).clear(cx); + } else { + window.remove_window(); + } + }); + flush_teardown(&mut second); + second.update(|window, cx| window.draw(cx).clear(cx)); + let editor = surviving_editor.as_ref().expect("second window editor"); + let text = |cx: &TestAppContext| { + editor + .read_with(cx, |editor, cx| editor.text(cx)) + .expect("live editor") + }; + let before = text(&second); + second.simulate_input(" "); + assert_eq!(text(&second).len(), before.len() + 1); + resources.retain(AnyWeakEntity::is_upgradable); + assert_eq!(resources, surviving_resources); + assert_eq!(watched_paths(&fs), active_baseline); + } + second.update(|window, cx| { + window.toggle_inspector(cx); + window.draw(cx).clear(cx); + }); + flush_teardown(&mut second); + assert!(resources.iter().all(|entity| !entity.is_upgradable())); + assert_eq!(watched_paths(&fs), watcher_baseline); + } + + fn test_fs(cx: &mut TestAppContext) -> (Arc, Vec) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + }); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + drop(SnippetProvider::new(fs.clone(), BTreeSet::new(), cx)); + }); + cx.run_until_parked(); + let watcher_baseline = watched_paths(&fs); + assert!(!watcher_baseline.is_empty()); + (fs, watcher_baseline) + } + + fn watched_paths(fs: &FakeFs) -> Vec { + let mut paths = fs.watched_paths(); + paths.sort(); + paths + } + + fn assert_inspector_watchers(fs: &FakeFs, baseline: &[PathBuf]) { + let mut expected = baseline.to_vec(); + expected.extend([ + paths::tasks_file().clone(), + paths::debug_scenarios_file().clone(), + PathBuf::from(ZED_INSPECTOR_STYLE_JSON), + ]); + expected.sort(); + assert_eq!(watched_paths(fs), expected); + } + + fn flush_teardown(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|_, _| {}); + cx.run_until_parked(); + } + + struct InspectorTarget; + + impl Render for InspectorTarget { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().id("inspected-element").size(px(100.)) + } + } +} diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 84f3d2195d..d2b6179ee7 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, anyhow}; use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, TaskExt, Window}; -use std::{cell::OnceCell, path::Path, sync::Arc}; +use std::{path::Path, sync::Arc}; use ui::{Label, Tooltip, prelude::*, utils::platform_title_bar_height}; use util::{ResultExt as _, command::new_command}; use workspace::AppState; @@ -24,29 +24,28 @@ pub fn init(app_state: Arc, cx: &mut App) { }); }); - // Project used for editor buffers with LSP support - let project = project::Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - project::LocalProjectFlags { - init_worktree_trust: false, - ..Default::default() - }, - cx, - ); - - let div_inspector = OnceCell::new(); - cx.register_inspector_element(move |id, state: &DivInspectorState, window, cx| { - let div_inspector = div_inspector - .get_or_init(|| cx.new(|cx| DivInspector::new(project.clone(), window, cx))); - div_inspector.update(cx, |div_inspector, cx| { - div_inspector.update_inspected_element(&id, state.clone(), window, cx); - div_inspector.render(window, cx).into_any_element() - }) + cx.register_inspector_element(move |window, cx| { + // Project used for editor buffers with LSP support + let project = project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + watch_global_configs: false, + }, + cx, + ); + let div_inspector = cx.new(|cx| DivInspector::new(project, window, cx)); + move |id, state: &DivInspectorState, window: &mut Window, cx: &mut App| { + div_inspector.update(cx, |div_inspector, cx| { + div_inspector.update_inspected_element(&id, state.clone(), window, cx); + div_inspector.render(window, cx).into_any_element() + }) + } }); cx.set_inspector_renderer(Box::new(render_inspector)); diff --git a/crates/inspector_ui/src/inspector_ui.rs b/crates/inspector_ui/src/inspector_ui.rs index d608ea76ca..1342007005 100644 --- a/crates/inspector_ui/src/inspector_ui.rs +++ b/crates/inspector_ui/src/inspector_ui.rs @@ -1,19 +1,19 @@ -#[cfg(any(debug_assertions, feature = "inspector"))] +#[cfg(debug_assertions)] mod div_inspector; -#[cfg(any(debug_assertions, feature = "inspector"))] +#[cfg(debug_assertions)] mod inspector; -#[cfg(any(debug_assertions, feature = "inspector"))] +#[cfg(debug_assertions)] pub use inspector::init; -#[cfg(not(any(debug_assertions, feature = "inspector")))] +#[cfg(not(debug_assertions))] pub fn init(_app_state: std::sync::Arc, cx: &mut gpui::App) { use std::any::TypeId; use workspace::notifications::NotifyResultExt as _; cx.on_action(|_: &zed_actions::dev::ToggleInspector, cx| { Err::<(), anyhow::Error>(anyhow::anyhow!( - "dev::ToggleInspector is only available in debug builds and Nightly" + "dev::ToggleInspector is only available in debug builds" )) .notify_app_err(cx); }); diff --git a/crates/install_cli/Cargo.toml b/crates/install_cli/Cargo.toml index cc849d11c0..0083c6aa5c 100644 --- a/crates/install_cli/Cargo.toml +++ b/crates/install_cli/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/install_cli.rs" +test = false +doctest = false [features] test-support = [] diff --git a/crates/json_schema_store/Cargo.toml b/crates/json_schema_store/Cargo.toml index d94e2a29d1..b9e9bf70c5 100644 --- a/crates/json_schema_store/Cargo.toml +++ b/crates/json_schema_store/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/json_schema_store.rs" +doctest = false [features] default = [] diff --git a/crates/keymap_editor/Cargo.toml b/crates/keymap_editor/Cargo.toml index 904447c460..d119bb8c91 100644 --- a/crates/keymap_editor/Cargo.toml +++ b/crates/keymap_editor/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/keymap_editor.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 7ce1740115..bc27d0b13b 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1163,7 +1163,7 @@ impl KeymapEditor { Tooltip::with_meta( "View conflicts", Some(&ToggleConflictFilter), - "Use alt+click to show all conflicts", + concat!("Use ", ui::alt_key_name!(), "+click to show all conflicts"), cx, ) }) @@ -1193,11 +1193,15 @@ impl KeymapEditor { })) } else { base_button_style(index, IconName::Info) - .tooltip(|_window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Show matching keybinds", Some(&ShowMatchingKeybinds), - "This binding is overridden by other bindings.\nUse alt+click to edit this binding", + concat!( + "This binding is overridden by other bindings.\nUse ", + ui::alt_key_name!(), + "+click to edit this binding" + ), cx, ) }) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 5bf8e87112..17ad2f3030 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -9,18 +9,19 @@ use crate::{ diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, language_settings::{AutoIndentMode, LanguageSettings}, outline::OutlineItem, - row_chunk::RowChunks, + row_chunk::{RowChunkId, RowChunks}, runnable::{self, RunnableRange}, syntax_map::{ MAX_BYTES_TO_QUERY, SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, + flattened_highlight_regions, }, text_diff::text_diff, unified_diff_with_offsets, }; pub use crate::{ - Grammar, HighlightId, HighlightMap, Language, LanguageRegistry, diagnostic_set::DiagnosticSet, - proto, + CaptureId, Grammar, HighlightId, HighlightMap, Language, LanguageRegistry, + diagnostic_set::DiagnosticSet, proto, }; use anyhow::{Context as _, Result}; @@ -35,6 +36,7 @@ use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, HighlightStyle, SharedString, StyledText, Task, TextStyle, }; +use language_core::highlight_cache::{ChunkHighlightCache, ResolvedHighlights}; use lsp::LanguageServerId; use parking_lot::Mutex; @@ -149,21 +151,25 @@ pub struct Buffer { #[derive(Debug)] pub struct TreeSitterData { chunks: RowChunks, - brackets_by_chunks: Mutex>>, + brackets_by_chunks: Mutex>>, + highlights_by_chunks: ChunkHighlightCache, } -const MAX_ROWS_IN_A_CHUNK: u32 = 50; +pub(crate) const MAX_ROWS_IN_A_CHUNK: u32 = 50; +pub(crate) const MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK: usize = 4 * MAX_BYTES_TO_QUERY; impl TreeSitterData { fn clear(&mut self, snapshot: &text::BufferSnapshot) { self.chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); self.brackets_by_chunks.get_mut().clear(); + self.highlights_by_chunks.clear(); } fn new(snapshot: &text::BufferSnapshot) -> Self { Self { chunks: RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK), brackets_by_chunks: Mutex::new(HashMap::default()), + highlights_by_chunks: ChunkHighlightCache::default(), } } @@ -519,6 +525,13 @@ struct BufferChunkHighlights<'a> { highlight_maps: Vec, } +type HighlightRun = (Range, HighlightId); + +struct CachedChunkHighlightsIter { + runs: Vec, + ix: usize, +} + /// An iterator that yields chunks of a buffer's text, along with their /// syntax highlights and diagnostic status. pub struct BufferChunks<'a> { @@ -533,6 +546,7 @@ pub struct BufferChunks<'a> { unnecessary_depth: usize, underline: bool, highlights: Option>, + cached_highlights: Option, } /// A chunk of a buffer's text, along with its syntax highlight and @@ -1553,6 +1567,7 @@ impl Buffer { } self.non_text_state_update_count += 1; self.syntax_map.lock().clear(&self.text); + Self::invalidate_tree_sitter_data(&mut self.tree_sitter_data, self.text.snapshot()); let old_language = std::mem::replace(&mut self.language, language); self.refresh_resolved_settings(cx); self.was_changed(); @@ -3536,6 +3551,7 @@ impl Buffer { snapshot.text = new_text.snapshot.clone(); snapshot.syntax = syntax; + snapshot.tree_sitter_data = Arc::new(TreeSitterData::new(&snapshot.text)); EditedBufferSnapshot { text: new_text, @@ -4121,7 +4137,18 @@ impl BufferSnapshot { let mut syntax = None; if language_aware.tree_sitter { - syntax = Some(self.get_highlights(range.clone())); + match self.cached_highlight_runs(range.clone()) { + Some(runs) => { + return BufferChunks::with_cached_highlights( + self.text.as_rope(), + range, + runs, + language_aware.diagnostics, + self, + ); + } + None => syntax = Some(self.get_highlights(range.clone())), + } } BufferChunks::new( self.text.as_rope(), @@ -4132,6 +4159,102 @@ impl BufferSnapshot { ) } + pub(crate) fn cached_highlight_runs(&self, range: Range) -> Option> { + #[cfg(any(test, feature = "test-support"))] + { + static DISABLE_HIGHLIGHT_CACHE: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + std::env::var_os("ZED_DISABLE_HIGHLIGHT_CACHE").is_some() + }); + if *DISABLE_HIGHLIGHT_CACHE { + return None; + } + } + self.language.as_ref()?.grammar()?; + if range.is_empty() { + return Some(Vec::new()); + } + let mut runs = Vec::::new(); + for chunk in self + .tree_sitter_data + .chunks + .applicable_chunks(&[range.to_point(self)]) + { + let chunk_range = chunk.anchor_range().to_offset(self); + if chunk_range.end <= range.start || chunk_range.start >= range.end { + continue; + } + if chunk_range.len() > MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK { + return None; + } + let chunk_highlights = match self.tree_sitter_data.highlights_by_chunks.get(chunk.id) { + Some(chunk_highlights) => chunk_highlights, + None => { + let chunk_highlights = self.compute_chunk_highlights(chunk_range); + self.tree_sitter_data + .highlights_by_chunks + .insert(chunk.id, chunk_highlights.clone()); + chunk_highlights + } + }; + for (run_range, highlight_id) in chunk_highlights.runs.iter() { + if run_range.end <= range.start { + continue; + } + if run_range.start >= range.end { + break; + } + match runs.last_mut() { + Some((last_range, last_highlight_id)) + if last_highlight_id == highlight_id + && last_range.end == run_range.start => + { + last_range.end = run_range.end; + } + _ => runs.push((run_range.clone(), *highlight_id)), + } + } + } + Some(runs) + } + + fn compute_chunk_highlights(&self, range: Range) -> ResolvedHighlights { + let captures = self.syntax.captures(range.clone(), &self.text, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + let sources = captures + .grammars() + .iter() + .map(|&grammar| (Arc::clone(grammar), grammar.highlight_map())) + .collect::, HighlightMap); 2]>>(); + let mut runs = Vec::<(Range, HighlightId)>::new(); + for region in flattened_highlight_regions(captures, range) { + let highlight_id = region.stack.iter().rev().find_map(|capture| { + let (_, highlight_map) = sources.get(capture.grammar_index)?; + highlight_map.get(capture.capture_id) + }); + let Some(highlight_id) = highlight_id else { + continue; + }; + match runs.last_mut() { + Some((last_range, last_highlight_id)) + if *last_highlight_id == highlight_id + && last_range.end == region.range.start => + { + last_range.end = region.range.end; + } + _ => runs.push((region.range, highlight_id)), + } + } + ResolvedHighlights { + sources, + runs: runs.into(), + } + } + pub fn highlighted_text_for_range( &self, range: Range, @@ -5508,16 +5631,40 @@ impl<'a> BufferChunks<'a> { diagnostics: bool, buffer_snapshot: Option<&'a BufferSnapshot>, ) -> Self { - let mut highlights = None; - if let Some((captures, highlight_maps)) = syntax { - highlights = Some(BufferChunkHighlights { - captures, - next_capture: None, - stack: Default::default(), - highlight_maps, - }) - } + let highlights = syntax.map(|(captures, highlight_maps)| BufferChunkHighlights { + captures, + next_capture: None, + stack: Vec::new(), + highlight_maps, + }); + Self::init(text, range, highlights, None, diagnostics, buffer_snapshot) + } + fn with_cached_highlights( + text: &'a Rope, + range: Range, + runs: Vec, + diagnostics: bool, + buffer_snapshot: &'a BufferSnapshot, + ) -> Self { + Self::init( + text, + range, + None, + Some(CachedChunkHighlightsIter { runs, ix: 0 }), + diagnostics, + Some(buffer_snapshot), + ) + } + + fn init( + text: &'a Rope, + range: Range, + highlights: Option>, + cached_highlights: Option, + diagnostics: bool, + buffer_snapshot: Option<&'a BufferSnapshot>, + ) -> Self { let diagnostic_endpoints = diagnostics.then(|| Vec::new().into_iter().peekable()); let chunks = text.chunks_in_range(range.clone()); @@ -5533,6 +5680,7 @@ impl<'a> BufferChunks<'a> { unnecessary_depth: 0, underline: true, highlights, + cached_highlights, }; this.initialize_diagnostic_endpoints(); this @@ -5542,7 +5690,33 @@ impl<'a> BufferChunks<'a> { pub fn seek(&mut self, range: Range) { let old_range = std::mem::replace(&mut self.range, range.clone()); self.chunks.set_range(self.range.clone()); - if let Some(highlights) = self.highlights.as_mut() { + if let Some(cached) = self.cached_highlights.as_mut() { + if old_range.start <= self.range.start && old_range.end >= self.range.end { + cached.ix = cached + .runs + .partition_point(|(run_range, _)| run_range.end <= range.start); + } else if let Some(snapshot) = self.buffer_snapshot { + if let Some(runs) = snapshot.cached_highlight_runs(self.range.clone()) { + cached.runs = runs; + cached.ix = 0; + } else { + let (captures, highlight_maps) = snapshot.get_highlights(self.range.clone()); + self.cached_highlights = None; + self.highlights = Some(BufferChunkHighlights { + captures, + next_capture: None, + stack: Vec::new(), + highlight_maps, + }); + } + } else { + debug_assert!( + false, + "Attempted to seek on a language-aware buffer iterator without associated buffer snapshot" + ); + } + self.initialize_diagnostic_endpoints(); + } else if let Some(highlights) = self.highlights.as_mut() { if old_range.start <= self.range.start && old_range.end >= self.range.end { // Reuse existing highlights stack, as the new range is a subrange of the old one. highlights @@ -5553,8 +5727,8 @@ impl<'a> BufferChunks<'a> { { let next_capture_end = capture.node.end_byte(); if range.start < next_capture_end - && let Some(capture_id) = - highlights.highlight_maps[capture.grammar_index].get(capture.index) + && let Some(capture_id) = highlights.highlight_maps[capture.grammar_index] + .get(CaptureId(capture.index)) { highlights.stack.push((next_capture_end, capture_id)); } @@ -5565,7 +5739,7 @@ impl<'a> BufferChunks<'a> { *highlights = BufferChunkHighlights { captures, next_capture: None, - stack: Default::default(), + stack: Vec::new(), highlight_maps, }; } else { @@ -5689,8 +5863,8 @@ impl<'a> Iterator for BufferChunks<'a> { next_capture_start = capture.node.start_byte(); break; } else { - let highlight_id = - highlights.highlight_maps[capture.grammar_index].get(capture.index); + let highlight_id = highlights.highlight_maps[capture.grammar_index] + .get(CaptureId(capture.index)); if let Some(highlight_id) = highlight_id { highlights .stack @@ -5701,6 +5875,21 @@ impl<'a> Iterator for BufferChunks<'a> { } } + if let Some(cached) = self.cached_highlights.as_mut() { + while cached + .runs + .get(cached.ix) + .is_some_and(|(run_range, _)| run_range.end <= self.range.start) + { + cached.ix += 1; + } + if let Some((run_range, _)) = cached.runs.get(cached.ix) + && self.range.start < run_range.start + { + next_capture_start = run_range.start; + } + } + let mut diagnostic_endpoints = std::mem::take(&mut self.diagnostic_endpoints); if let Some(diagnostic_endpoints) = diagnostic_endpoints.as_mut() { while let Some(endpoint) = diagnostic_endpoints.peek().copied() { @@ -5734,6 +5923,13 @@ impl<'a> Iterator for BufferChunks<'a> { chunk_end = chunk_end.min(*parent_capture_end); highlight_id = Some(*parent_highlight_id); } + if let Some(cached) = self.cached_highlights.as_ref() + && let Some((run_range, run_highlight_id)) = cached.runs.get(cached.ix) + && run_range.start <= chunk_start + { + chunk_end = chunk_end.min(run_range.end); + highlight_id = Some(*run_highlight_id); + } let bit_start = chunk_start - self.chunks.offset(); let bit_end = chunk_end - self.chunks.offset(); diff --git a/crates/language/src/buffer/row_chunk.rs b/crates/language/src/buffer/row_chunk.rs index 605299f67f..7764067f92 100644 --- a/crates/language/src/buffer/row_chunk.rs +++ b/crates/language/src/buffer/row_chunk.rs @@ -10,6 +10,8 @@ use util::RangeExt; use crate::BufferRow; +pub type RowChunkId = usize; + /// An range of rows, exclusive as [`lsp::Range`] and /// /// denote. @@ -27,7 +29,7 @@ pub struct RowChunks { buffer_snapshot: text::BufferSnapshot, last_row: BufferRow, max_rows_per_chunk: u32, - computed_chunks: Mutex>, + computed_chunks: Mutex>, } impl std::fmt::Debug for RowChunks { @@ -86,12 +88,18 @@ impl RowChunks { } } - fn chunk_row_range(&self, id: usize) -> Range { - let start = id as u32 * self.max_rows_per_chunk; - start..(start + self.max_rows_per_chunk).min(self.last_row) + fn chunk_row_range(&self, id: RowChunkId) -> Range { + let start = u32::try_from(id) + .unwrap_or(u32::MAX) + .saturating_mul(self.max_rows_per_chunk) + .min(self.last_row); + start + ..start + .saturating_add(self.max_rows_per_chunk) + .min(self.last_row) } - fn chunk(&self, id: usize) -> RowChunk { + fn chunk(&self, id: RowChunkId) -> RowChunk { *self.computed_chunks.lock().entry(id).or_insert_with(|| { let row_range = self.chunk_row_range(id); let start = Point::new(row_range.start, 0); @@ -113,7 +121,7 @@ impl RowChunks { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RowChunk { - pub id: usize, + pub id: RowChunkId, pub start: BufferRow, pub end_exclusive: BufferRow, pub start_anchor: Anchor, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 80646668b0..b3b2313aeb 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -1758,7 +1758,9 @@ fn test_bracket_ranges_keep_chunk_spanning_pairs_amid_errors(cx: &mut App) { } #[gpui::test] -fn test_bracket_ranges_keep_pairs_straddling_a_chunk_boundary_amid_errors(cx: &mut App) { +async fn test_bracket_ranges_keep_pairs_straddling_a_chunk_boundary_amid_errors( + cx: &mut TestAppContext, +) { let mut text = String::from("void outer(void) {\n"); for index in 0..56 { text.push_str(&format!(" int before_{index:02} = 0;\n")); @@ -1782,7 +1784,10 @@ fn test_bracket_ranges_keep_pairs_straddling_a_chunk_boundary_amid_errors(cx: &m text.push_str(" }\n}\n"); let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(c_lang(), cx)); - let snapshot = buffer.read(cx).snapshot(); + buffer + .read_with(cx, |buffer, _| buffer.parsing_idle()) + .await; + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); assert_has_syntax_errors(&snapshot); let open_row = snapshot.offset_to_point(if_open_offset).row; @@ -5387,6 +5392,314 @@ fn test_settings_changed_event(cx: &mut TestAppContext) { drop(subscription); } +#[gpui::test] +fn test_chunk_highlights_follow_edits_and_theme_changes(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme_with_keyword = keyword_and_function_theme(); + let theme_without_keyword = + SyntaxTheme::new([("function".to_string(), gpui::rgba(0x0000ffff).into())]); + language.set_theme(&theme_with_keyword); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local("fn main() {}", cx); + buffer.set_language(Some(language.clone()), cx); + buffer + }); + cx.run_until_parked(); + + let highlighted_chunks = |snapshot: &BufferSnapshot| { + snapshot + .chunks( + 0..snapshot.len(), + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ) + .filter_map(|chunk| Some((chunk.text.to_string(), chunk.syntax_highlight_id?))) + .collect::>() + }; + + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let expected_with_keyword = vec![ + ( + "fn".to_string(), + theme_highlight_id(&theme_with_keyword, "keyword"), + ), + ( + "main".to_string(), + theme_highlight_id(&theme_with_keyword, "function"), + ), + ]; + assert_eq!(highlighted_chunks(&snapshot), expected_with_keyword); + assert_eq!( + highlighted_chunks(&snapshot), + expected_with_keyword, + "repeated chunking of the same snapshot must return the same highlights" + ); + + language.set_theme(&theme_without_keyword); + assert_eq!( + highlighted_chunks(&snapshot), + vec![( + "main".to_string(), + theme_highlight_id(&theme_without_keyword, "function") + )], + "a theme change must invalidate highlights of an existing snapshot" + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(3..7, "launch")], None, cx); + }); + cx.run_until_parked(); + let edited_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + highlighted_chunks(&edited_snapshot), + vec![( + "launch".to_string(), + theme_highlight_id(&theme_without_keyword, "function") + )], + "an edit must invalidate the cached highlights" + ); +} + +#[gpui::test] +async fn test_snapshot_with_edits_refreshes_chunk_highlights(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme = keyword_and_function_theme(); + language.set_theme(&theme); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + let row = "fn replacement() {}\n"; + let text = row.repeat(MAX_ROWS_IN_A_CHUNK as usize + 1); + + for original_text in ["", "fn original() {}"] { + let buffer = + cx.new(|cx| Buffer::local(original_text, cx).with_language(language.clone(), cx)); + cx.run_until_parked(); + let original_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let original_highlights = + merged_highlight_runs(&original_snapshot, 0..original_snapshot.len()); + + let edited = buffer + .update(cx, |buffer, cx| { + buffer.snapshot_with_edits([(0..buffer.len(), text.clone())], cx) + }) + .await; + let snapshot = edited.snapshot(); + let expected = vec![ + ("fn".to_string(), keyword), + ("replacement".to_string(), function), + ]; + + assert_eq!(merged_highlight_runs(snapshot, 0..row.len()), expected); + let last_row_start = row.len() * MAX_ROWS_IN_A_CHUNK as usize; + assert_eq!( + merged_highlight_runs(snapshot, last_row_start..snapshot.len()), + expected, + ); + assert_eq!( + merged_highlight_runs(&original_snapshot, 0..original_snapshot.len()), + original_highlights, + ); + } +} + +#[gpui::test] +fn test_chunk_highlights_across_row_chunk_seeks(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme = keyword_and_function_theme(); + language.set_theme(&theme); + + let short_row = "fn short() {}\n"; + let last_row = "fn omega() {}"; + let text = format!( + "{}{last_row}", + short_row.repeat(MAX_ROWS_IN_A_CHUNK as usize) + ); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local(text, cx); + buffer.set_language(Some(language.clone()), cx); + buffer + }); + cx.run_until_parked(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + + assert_eq!( + merged_highlight_runs(&snapshot, 0..short_row.len()), + vec![("fn".to_string(), keyword), ("short".to_string(), function)], + ); + + let last_row_start = short_row.len() * MAX_ROWS_IN_A_CHUNK as usize; + let expected_last_row_runs = vec![("fn".to_string(), keyword), ("omega".to_string(), function)]; + assert_eq!( + merged_highlight_runs(&snapshot, last_row_start..snapshot.len()), + expected_last_row_runs, + ); + + let mut chunks = snapshot.chunks( + 0..short_row.len(), + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ); + chunks.seek(last_row_start..snapshot.len()); + let mut runs_after_seek = Vec::new(); + for chunk in chunks { + merge_highlighted_chunk(&mut runs_after_seek, &chunk); + } + assert_eq!( + runs_after_seek, expected_last_row_runs, + "seeking into another row chunk must refetch that chunk's highlights" + ); +} + +#[gpui::test] +fn test_oversized_chunks_bypass_the_highlight_cache(cx: &mut TestAppContext) { + if std::env::var_os("ZED_DISABLE_HIGHLIGHT_CACHE").is_some() { + return; + } + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme = keyword_and_function_theme(); + language.set_theme(&theme); + + let short_row = "fn short() {}\n"; + let giant_row = format!( + "fn omega() {{}}{}", + " ".repeat(MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK) + ); + let text = format!( + "{}{giant_row}", + short_row.repeat(MAX_ROWS_IN_A_CHUNK as usize) + ); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local(text, cx); + buffer.set_language(Some(language.clone()), cx); + buffer + }); + cx.run_until_parked(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + let expected_giant_row_runs = + vec![("fn".to_string(), keyword), ("omega".to_string(), function)]; + + let last_row_start = short_row.len() * MAX_ROWS_IN_A_CHUNK as usize; + assert_eq!( + snapshot.cached_highlight_runs(last_row_start..snapshot.len()), + None, + "chunks larger than the byte cap must not be cached" + ); + assert!( + snapshot.cached_highlight_runs(0..short_row.len()).is_some(), + "chunks within the byte cap must still be cached" + ); + assert_eq!( + snapshot.cached_highlight_runs(last_row_start + 1..last_row_start + 1), + Some(Vec::new()), + "an empty range must not compute captures for its oversized chunk" + ); + + assert_eq!( + merged_highlight_runs(&snapshot, last_row_start..snapshot.len()), + expected_giant_row_runs, + "oversized chunks must fall back to direct highlighting" + ); + + let mut chunks = snapshot.chunks( + 0..short_row.len(), + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ); + chunks.seek(last_row_start..snapshot.len()); + let mut runs_after_seek = Vec::new(); + for chunk in chunks { + merge_highlighted_chunk(&mut runs_after_seek, &chunk); + } + assert_eq!( + runs_after_seek, expected_giant_row_runs, + "seeking into an oversized chunk must fall back to direct highlighting" + ); +} + +#[gpui::test] +fn test_language_change_invalidates_cached_chunk_highlights(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let rust = keyword_and_function_lang(); + let identifiers_only = Arc::new( + Language::new( + LanguageConfig { + name: "Identifiers".into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query("(identifier) @variable") + .unwrap(), + ); + let theme = SyntaxTheme::new([ + ("keyword".to_string(), gpui::rgba(0xff0000ff).into()), + ("function".to_string(), gpui::rgba(0x00ff00ff).into()), + ("variable".to_string(), gpui::rgba(0x0000ffff).into()), + ]); + rust.set_theme(&theme); + identifiers_only.set_theme(&theme); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local("fn main() {}", cx); + buffer.set_language(Some(rust), cx); + buffer + }); + cx.run_until_parked(); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + let variable = theme_highlight_id(&theme, "variable"); + + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + merged_highlight_runs(&snapshot, 0..snapshot.len()), + vec![("fn".to_string(), keyword), ("main".to_string(), function)], + ); + + buffer.update(cx, |buffer, cx| { + buffer.set_sync_parse_timeout(None); + buffer.set_language(Some(identifiers_only), cx); + }); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + merged_highlight_runs(&snapshot, 0..snapshot.len()), + Vec::new(), + "a language change must not serve the old language's cached highlights while the new parse is pending" + ); + + cx.run_until_parked(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + merged_highlight_runs(&snapshot, 0..snapshot.len()), + vec![("main".to_string(), variable)], + ); +} + #[gpui::test(iterations = 100)] fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { use util::RandomCharIter; @@ -5514,3 +5827,63 @@ fn test_formatted_chunks(cx: &mut gpui::App) { } } } + +fn merged_highlight_runs( + snapshot: &BufferSnapshot, + range: Range, +) -> Vec<(String, HighlightId)> { + let chunks = snapshot.chunks( + range, + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ); + let mut runs = Vec::new(); + for chunk in chunks { + merge_highlighted_chunk(&mut runs, &chunk); + } + runs +} + +fn merge_highlighted_chunk(runs: &mut Vec<(String, HighlightId)>, chunk: &Chunk<'_>) { + let Some(highlight_id) = chunk.syntax_highlight_id else { + return; + }; + match runs.last_mut() { + Some((last_text, last_highlight_id)) if *last_highlight_id == highlight_id => { + last_text.push_str(chunk.text); + } + _ => runs.push((chunk.text.to_string(), highlight_id)), + } +} + +fn keyword_and_function_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query( + r#" + "fn" @keyword + (identifier) @function + "#, + ) + .unwrap(), + ) +} + +fn keyword_and_function_theme() -> SyntaxTheme { + SyntaxTheme::new([ + ("keyword".to_string(), gpui::rgba(0xff0000ff).into()), + ("function".to_string(), gpui::rgba(0x00ff00ff).into()), + ]) +} + +fn theme_highlight_id(theme: &SyntaxTheme, capture_name: &str) -> HighlightId { + HighlightId::new(theme.highlight_id(capture_name).unwrap()) +} diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 8258034447..6dcb998500 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -39,15 +39,17 @@ use collections::{HashMap, HashSet}; use futures::Future; use futures::future::LocalBoxFuture; use futures::lock::OwnedMutexGuard; -use gpui::{App, AsyncApp, Entity}; +use gpui::{App, AsyncApp, Entity, EntityId}; use http_client::HttpClient; pub use language_core::{ SymbolKind, - highlight_map::{HighlightId, HighlightMap}, + highlight_cache::ResolvedHighlights, + highlight_map::{CaptureId, HighlightId, HighlightMap}, }; use futures::future::FutureExt as _; +use language_core::highlight_cache::{MAX_TEXT_HIGHLIGHT_ENTRY_BYTES, TextHighlightKey}; pub use language_core::{ BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, BracketsConfig, BracketsPatternConfig, CodeLabel, CodeLabelBuilder, DebugVariablesConfig, DebuggerTextObject, @@ -83,7 +85,7 @@ use std::{ str, sync::{Arc, LazyLock}, }; -use syntax_map::{QueryCursorHandle, SyntaxSnapshot}; +use syntax_map::{QueryCursorHandle, SyntaxSnapshot, flattened_highlight_regions}; use task::RunnableTag; pub use task_context::{ContextLocation, ContextProvider}; pub use text_diff::{ @@ -492,6 +494,7 @@ pub trait LspAdapterDelegate: Send + Sync { fn worktree_id(&self) -> WorktreeId; fn worktree_root_path(&self) -> &Path; fn resolve_relative_path(&self, path: PathBuf) -> PathBuf; + fn status_source_id(&self) -> EntityId; fn update_status(&self, language: LanguageServerName, status: BinaryStatus); fn registered_lsp_adapters(&self) -> Vec>; async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option>; @@ -1119,29 +1122,94 @@ impl Language { text: &'a Rope, range: Range, ) -> Vec<(Range, HighlightId)> { - let mut result = Vec::new(); - if let Some(grammar) = &self.grammar { - let tree = parse_text(grammar, text, None); - let captures = - SyntaxSnapshot::single_tree_captures(range.clone(), text, &tree, self, |grammar| { - grammar - .highlights_config - .as_ref() - .map(|config| &config.query) - }); - let highlight_maps = vec![grammar.highlight_map()]; - let mut offset = 0; - for chunk in - BufferChunks::new(text, range, Some((captures, highlight_maps)), false, None) + self.highlight_text_resolved(text, range).runs.to_vec() + } + + pub fn highlight_text_resolved( + self: &Arc, + text: &Rope, + range: Range, + ) -> ResolvedHighlights { + let Some(grammar) = &self.grammar else { + return ResolvedHighlights::default(); + }; + let Some(highlights_config) = &grammar.highlights_config else { + return ResolvedHighlights::default(); + }; + let highlights = if text.len() > MAX_TEXT_HIGHLIGHT_ENTRY_BYTES { + self.compute_resolved_highlights(grammar, text) + } else { + let key = TextHighlightKey::new(text.chunks(), text.len()); + match highlights_config + .text_highlight_cache + .get(&key, text.chunks()) { - let end_offset = offset + chunk.text.len(); - if let Some(highlight_id) = chunk.syntax_highlight_id { - result.push((offset..end_offset, highlight_id)); + Some(highlights) => highlights, + None => highlights_config.text_highlight_cache.insert( + key, + Arc::from(text.chunks().collect::()), + self.compute_resolved_highlights(grammar, text), + ), + } + }; + if range.start == 0 && range.end >= text.len() { + return highlights; + } + ResolvedHighlights { + sources: highlights.sources.clone(), + runs: highlights + .runs + .iter() + .filter(|(run_range, _)| run_range.start < range.end && run_range.end > range.start) + .map(|(run_range, highlight_id)| { + ( + run_range.start.max(range.start) - range.start + ..run_range.end.min(range.end) - range.start, + *highlight_id, + ) + }) + .collect(), + } + } + + fn compute_resolved_highlights( + self: &Arc, + grammar: &Arc, + text: &Rope, + ) -> ResolvedHighlights { + let highlight_map = grammar.highlight_map(); + let tree = parse_text(grammar, text, None); + let captures = + SyntaxSnapshot::single_tree_captures(0..text.len(), text, &tree, self, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + let mut runs = Vec::<(Range, HighlightId)>::new(); + for region in flattened_highlight_regions(captures, 0..text.len()) { + let highlight_id = region + .stack + .iter() + .rev() + .find_map(|capture| highlight_map.get(capture.capture_id)); + let Some(highlight_id) = highlight_id else { + continue; + }; + match runs.last_mut() { + Some((last_range, last_highlight_id)) + if *last_highlight_id == highlight_id + && last_range.end == region.range.start => + { + last_range.end = region.range.end; } - offset = end_offset; + _ => runs.push((region.range, highlight_id)), } } - result + ResolvedHighlights { + sources: [(Arc::clone(grammar), highlight_map)].into_iter().collect(), + runs: runs.into(), + } } pub fn path_suffixes(&self) -> &[String] { @@ -1685,19 +1753,163 @@ mod tests { let map = build_highlight_map(capture_names, &theme); assert_eq!( - theme.get_capture_name(map.get(0).unwrap()), + theme.get_capture_name(map.get(CaptureId(0)).unwrap()), Some("function") ); assert_eq!( - theme.get_capture_name(map.get(1).unwrap()), + theme.get_capture_name(map.get(CaptureId(1)).unwrap()), Some("function.async") ); assert_eq!( - theme.get_capture_name(map.get(2).unwrap()), + theme.get_capture_name(map.get(CaptureId(2)).unwrap()), Some("variable.builtin") ); } + #[test] + fn test_highlight_text_resolves_nested_captures_with_theme_fallback() { + let language = Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query( + r#" + (function_item) @function.definition + (identifier) @variable + "fn" @keyword + "#, + ) + .unwrap(), + ); + + let theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("keyword", rgba(0x200000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + language.set_theme(&theme); + + let code = "fn main() {}"; + let highlights = language.highlight_text_resolved(&Rope::from(code), 0..code.len()); + assert!(highlights.is_current()); + let named = highlights + .runs + .iter() + .map(|(range, highlight_id)| { + ( + range.clone(), + theme.get_capture_name(*highlight_id).unwrap(), + ) + }) + .collect::>(); + assert_eq!( + named, + vec![(0..2, "keyword"), (2..12, "function")], + "an inner capture missing from the theme must fall back to its outer capture" + ); + + let memoized = language.highlight_text_resolved(&Rope::from(code), 0..code.len()); + assert!( + Arc::ptr_eq(&highlights.runs, &memoized.runs), + "repeated highlighting of the same text must be memoized" + ); + let partial = language.highlight_text_resolved(&Rope::from(code), 0..2); + assert!( + !Arc::ptr_eq(&highlights.runs, &partial.runs), + "a different range must not reuse the memoized highlights" + ); + assert_eq!(partial.runs.as_ref(), &[(0..2, highlights.runs[0].1)]); + + let richer_theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("keyword", rgba(0x200000ff)), + ("variable", rgba(0x300000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + language.set_theme(&richer_theme); + assert!( + !highlights.is_current(), + "a theme change must invalidate previously resolved highlights" + ); + let rethemed = language.highlight_text_resolved(&Rope::from(code), 0..code.len()); + assert!(rethemed.is_current()); + let renamed = rethemed + .runs + .iter() + .map(|(range, highlight_id)| { + ( + range.clone(), + richer_theme.get_capture_name(*highlight_id).unwrap(), + ) + }) + .collect::>(); + assert_eq!( + renamed, + vec![ + (0..2, "keyword"), + (2..3, "function"), + (3..7, "variable"), + (7..12, "function"), + ], + "a theme change must invalidate memoized highlights and resolve against the new theme" + ); + } + + #[test] + fn test_oversized_text_is_highlighted_without_caching() { + let language = rust_lang(); + let theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("keyword", rgba(0x200000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + language.set_theme(&theme); + + let small_code = "fn main() {}"; + let small_highlights = + language.highlight_text_resolved(&Rope::from(small_code), 0..small_code.len()); + assert!( + !small_highlights.runs.is_empty(), + "texts within the size cap must be highlighted" + ); + + let oversized_code = format!( + "fn main() {{}}{}", + " ".repeat(MAX_TEXT_HIGHLIGHT_ENTRY_BYTES) + ); + let oversized_rope = Rope::from(oversized_code.as_str()); + let first_highlights = + language.highlight_text_resolved(&oversized_rope, 0..oversized_code.len()); + assert_eq!( + first_highlights.runs.as_ref(), + small_highlights.runs.as_ref(), + "texts over the cache entry cap must still be highlighted" + ); + let second_highlights = + language.highlight_text_resolved(&oversized_rope, 0..oversized_code.len()); + assert_eq!( + second_highlights.runs.as_ref(), + first_highlights.runs.as_ref() + ); + assert!( + !Arc::ptr_eq(&first_highlights.runs, &second_highlights.runs), + "texts over the cache entry cap must not be memoized" + ); + } + #[test] fn test_with_parser_resets_after_cancellation() { use std::ops::ControlFlow; diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index d734876003..4a3accb9d1 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -19,7 +19,7 @@ use futures::{ future::{BoxFuture, FutureExt as _}, }; use globset::GlobSet; -use gpui::{App, BackgroundExecutor}; +use gpui::{App, BackgroundExecutor, EntityId, Subscription}; use lsp::LanguageServerId; use parking_lot::{Mutex, RwLock}; use postage::watch; @@ -89,9 +89,17 @@ impl std::fmt::Display for LanguageNotFound { } } +type ServerStatus = (Option, LanguageServerName, BinaryStatus); + #[derive(Clone, Default)] struct ServerStatusSender { - txs: Arc>>>, + state: Arc>, +} + +#[derive(Default)] +struct ServerStatusSenderState { + next_subscription_id: usize, + txs: HashMap>, } pub struct LoadedLanguage { @@ -894,6 +902,20 @@ impl LanguageRegistry { .unwrap_or_default() } + pub fn lsp_adapter( + &self, + language_name: &LanguageName, + server_name: &LanguageServerName, + ) -> Option> { + self.state + .read() + .lsp_adapters + .get(language_name)? + .iter() + .find(|adapter| adapter.name() == *server_name) + .cloned() + } + pub fn all_lsp_adapters(&self) -> Vec> { self.state .read() @@ -908,7 +930,17 @@ impl LanguageRegistry { } pub fn update_lsp_binary_status(&self, server_name: LanguageServerName, status: BinaryStatus) { - self.lsp_binary_status_tx.send(server_name, status); + self.lsp_binary_status_tx.send(None, server_name, status); + } + + pub fn update_lsp_binary_status_for_entity( + &self, + source: EntityId, + server_name: LanguageServerName, + status: BinaryStatus, + ) { + self.lsp_binary_status_tx + .send(Some(source), server_name, status); } pub fn next_language_server_id(&self) -> LanguageServerId { @@ -954,7 +986,10 @@ impl LanguageRegistry { pub fn language_server_binary_statuses( &self, - ) -> mpsc::UnboundedReceiver<(LanguageServerName, BinaryStatus)> { + ) -> ( + mpsc::UnboundedReceiver<(Option, LanguageServerName, BinaryStatus)>, + Subscription, + ) { self.lsp_binary_status_tx.subscribe() } } @@ -1051,14 +1086,41 @@ impl LanguageRegistryState { } impl ServerStatusSender { - fn subscribe(&self) -> mpsc::UnboundedReceiver<(LanguageServerName, BinaryStatus)> { + fn subscribe(&self) -> (mpsc::UnboundedReceiver, Subscription) { let (tx, rx) = mpsc::unbounded(); - self.txs.lock().push(tx); - rx + let subscription_id = { + let mut state = self.state.lock(); + let subscription_id = post_inc(&mut state.next_subscription_id); + state.txs.insert(subscription_id, tx); + subscription_id + }; + let state = self.state.clone(); + let subscription = Subscription::new(move || { + state.lock().txs.remove(&subscription_id); + }); + (rx, subscription) } - fn send(&self, name: LanguageServerName, status: BinaryStatus) { - let mut txs = self.txs.lock(); - txs.retain(|tx| tx.unbounded_send((name.clone(), status.clone())).is_ok()); + fn send(&self, source: Option, name: LanguageServerName, status: BinaryStatus) { + let mut state = self.state.lock(); + state.txs.retain(|_, tx| { + tx.unbounded_send((source, name.clone(), status.clone())) + .is_ok() + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dropping_server_status_subscription_unregisters_sender() { + let sender = ServerStatusSender::default(); + let (_receiver, subscription) = sender.subscribe(); + assert_eq!(sender.state.lock().txs.len(), 1); + + drop(subscription); + assert!(sender.state.lock().txs.is_empty()); } } diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 95c65f562d..3cd3eac021 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -118,8 +118,18 @@ pub struct LanguageSettings { /// Controls whether edit predictions are shown immediately (true) /// or manually by triggering `editor::ShowEditPrediction` (false). pub show_edit_predictions: bool, - /// Controls whether edit predictions are shown in the given language - /// scopes. + /// Disable edit predictions in these language scopes, such as "comment" and + /// "string". + /// + /// Use `"..."` to add scopes without repeating the inherited list. In project + /// settings, it extends the user or parent configuration value. In + /// language-specific settings, it extends the scopes inherited by that + /// language. Omit `"..."` to replace the inherited list. + /// + /// Inherited scopes are inserted at `"..."`, and duplicates keep their first + /// occurrence. + /// + /// Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. pub edit_predictions_disabled_in: Vec, /// Whether to show tabs and spaces in the editor. pub show_whitespaces: settings::ShowWhitespaceSetting, @@ -487,9 +497,28 @@ impl InlayHintSettings { pub struct EditPredictionSettings { /// The provider that supplies edit predictions. pub provider: settings::EditPredictionProvider, - /// A list of globs representing files that edit predictions should be disabled for. - /// This list adds to a pre-existing, sensible default set of globs. - /// Any additional ones you add are combined with them. + /// Disable edit predictions for files matching these glob patterns. + /// + /// Use `"..."` to add patterns without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. + /// + /// ```json + /// { + /// "edit_predictions": { + /// "disabled_globs": ["**/build/**", "..."] + /// } + /// } + /// ``` + /// + /// Inherited patterns are inserted at `"..."`, and duplicates keep their first + /// occurrence. + /// + /// Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. + /// + /// Relative patterns are matched against paths relative to the worktree root. + /// Absolute patterns are matched against absolute paths. A leading `~` is + /// expanded to your home folder. pub disabled_globs: Vec, /// Configures how edit predictions are displayed in the buffer. pub mode: settings::EditPredictionsMode, @@ -842,7 +871,7 @@ impl settings::Settings for AllLanguageSettings { document_symbols: settings.document_symbols.unwrap(), allow_rewrap: settings.allow_rewrap.unwrap(), show_edit_predictions: settings.show_edit_predictions.unwrap(), - edit_predictions_disabled_in: settings.edit_predictions_disabled_in.unwrap(), + edit_predictions_disabled_in: settings.edit_predictions_disabled_in.unwrap().0, show_whitespaces: settings.show_whitespaces.unwrap(), whitespace_map: WhitespaceMap { space: SharedString::new(whitespace_map.space.unwrap().to_string()), @@ -918,6 +947,7 @@ impl settings::Settings for AllLanguageSettings { .disabled_globs .as_ref() .unwrap() + .0 .iter() .collect(); @@ -974,15 +1004,27 @@ impl settings::Settings for AllLanguageSettings { let mut builder = GlobSetBuilder::new(); for pattern in &patterns.0 { - builder.add(Glob::new(pattern).unwrap()); + match Glob::new(pattern) { + Ok(glob) => { + builder.add(glob); + } + Err(error) => { + log::error!( + "Failed to parse a `file_types` pattern for {language}. Skipping this pattern:\n\n{error}" + ); + } + } } + let matcher = builder.build().unwrap_or_else(|error| { + log::error!( + "Failed to compile `file_types` patterns for {language}. Using an empty matcher:\n\n{error}" + ); + GlobSet::empty() + }); file_types.insert( language.clone(), - ( - builder.build().unwrap(), - patterns.0.iter().cloned().collect(), - ), + (matcher, patterns.0.iter().cloned().collect()), ); } @@ -1036,6 +1078,391 @@ mod tests { use settings::{LocalSettingsKind, LocalSettingsPath, WorktreeId}; use util::rel_path::rel_path; + #[gpui::test] + fn test_file_types_preserves_valid_patterns(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + store.register_setting::(); + + for patterns in [ + vec!["*.rs", "[", "config.*"], + vec!["[", "{"], + vec![], + vec!["*.rs", "config.*"], + ] { + store + .set_user_settings( + &serde_json::json!({ + "file_types": { + "Python": ["*.py"], + "Rust": patterns, + }, + }) + .to_string(), + cx, + ) + .unwrap(); + + let settings = store.get::(None); + let (matcher, sources) = settings.file_types.get("Rust").unwrap(); + assert_eq!(sources, &patterns); + assert_eq!(matcher.is_match("main.rs"), patterns.contains(&"*.rs")); + assert_eq!( + matcher.is_match("config.custom"), + patterns.contains(&"config.*") + ); + assert!(!matcher.is_match("main.py")); + assert!(!matcher.is_match("unrelated.txt")); + assert!(!matcher.is_match("[")); + assert!(!matcher.is_match("{")); + assert_eq!(matcher.is_empty(), !patterns.contains(&"*.rs")); + + let (matcher, sources) = settings.file_types.get("Python").unwrap(); + assert_eq!(sources, &["*.py"]); + assert!(matcher.is_match("main.py")); + assert!(!matcher.is_match("main.rs")); + assert!(!matcher.is_match("config.custom")); + } + }); + } + + #[gpui::test] + fn test_edit_predictions_disabled_in_language_overrides(cx: &mut App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + store.register_setting::(); + assert!( + store + .get::(None) + .defaults + .edit_predictions_disabled_in + .is_empty() + ); + + for (language_settings, expected) in [ + (serde_json::json!({}), vec!["comment"]), + ( + serde_json::json!({"edit_predictions_disabled_in": ["string", "..."]}), + vec!["string", "comment"], + ), + ( + serde_json::json!({"edit_predictions_disabled_in": ["string"]}), + vec!["string"], + ), + ( + serde_json::json!({"edit_predictions_disabled_in": []}), + vec![], + ), + ] { + store + .set_user_settings( + &serde_json::json!({ + "edit_predictions_disabled_in": ["comment"], + "languages": {"Rust": language_settings}, + }) + .to_string(), + cx, + ) + .expect("user settings should load"); + let settings = store.get::(None); + assert_eq!(settings.defaults.edit_predictions_disabled_in, ["comment"]); + assert_eq!( + settings + .languages + .get(&LanguageName::from("Rust")) + .expect("Rust settings should load") + .edit_predictions_disabled_in, + expected, + "language settings: {language_settings}" + ); + } + } + + #[gpui::test] + fn test_edit_predictions_disabled_in_project_overrides(cx: &mut App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + store.register_setting::(); + let worktree_id = WorktreeId::from_usize(1); + let root = LocalSettingsPath::InWorktree(rel_path("root").into()); + let child = LocalSettingsPath::InWorktree(rel_path("root/child").into()); + + store + .set_user_settings(r#"{"edit_predictions_disabled_in":["comment"]}"#, cx) + .expect("user settings should load"); + store + .set_local_settings( + worktree_id, + root, + LocalSettingsKind::Settings, + Some(r#"{"edit_predictions_disabled_in":["string","..."]}"#), + cx, + ) + .expect("project settings should load"); + + for (overrides, expected) in [ + (serde_json::json!({}), vec!["string", "comment"]), + ( + serde_json::json!({"edit_predictions_disabled_in": ["documentation", "..."]}), + vec!["documentation", "string", "comment"], + ), + ( + serde_json::json!({"edit_predictions_disabled_in": ["documentation"]}), + vec!["documentation"], + ), + ( + serde_json::json!({"edit_predictions_disabled_in": []}), + vec![], + ), + ] { + for language_specific in [false, true] { + let content = if language_specific { + serde_json::json!({"languages": {"Rust": overrides}}) + } else { + overrides.clone() + }; + store + .set_local_settings( + worktree_id, + child.clone(), + LocalSettingsKind::Settings, + Some(&content.to_string()), + cx, + ) + .expect("child settings should load"); + let settings = store.get::(Some(SettingsLocation { + worktree_id, + path: rel_path("root/child/file.rs"), + })); + let resolved = if language_specific { + assert_eq!( + settings.defaults.edit_predictions_disabled_in, + ["string", "comment"] + ); + settings + .languages + .get(&LanguageName::from("Rust")) + .expect("Rust settings should load") + } else { + &settings.defaults + }; + assert_eq!( + resolved.edit_predictions_disabled_in, expected, + "project settings: {content}" + ); + } + } + assert_eq!( + store + .get::(None) + .defaults + .edit_predictions_disabled_in, + ["comment"] + ); + } + + #[gpui::test] + fn test_edit_predictions_disabled_globs_replace_and_clear(cx: &mut App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + store.register_setting::(); + + for (content, sensitive_enabled, custom_enabled) in [ + (r#"{}"#, false, true), + ( + r#"{"edit_predictions":{"disabled_globs":["**/build/**"]}}"#, + true, + false, + ), + (r#"{"edit_predictions":{"disabled_globs":[]}}"#, true, true), + (r#"{"edit_predictions":{}}"#, false, true), + ] { + store + .set_user_settings(content, cx) + .expect("user settings should load"); + let settings = &store.get::(None).edit_predictions; + for (path, expected_enabled) in [ + (".env", sensitive_enabled), + ("build/output.rs", custom_enabled), + ("certificates/private.pem", sensitive_enabled), + ("config/secrets.yml", sensitive_enabled), + ("src/main.rs", true), + ] { + let file: Arc = Arc::new(crate::TestFile { + path: rel_path(path).into(), + root_name: "project".to_string(), + local_root: None, + }); + assert_eq!( + settings.enabled_for_file(&file, cx), + expected_enabled, + "path: {path}, settings: {content}" + ); + } + } + } + + #[gpui::test] + fn test_edit_predictions_disabled_globs_inherit_across_settings_files(cx: &mut App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + store.register_setting::(); + let worktree_id = WorktreeId::from_usize(1); + let root = LocalSettingsPath::InWorktree(rel_path("root").into()); + let child = LocalSettingsPath::InWorktree(rel_path("root/child").into()); + + store + .set_user_settings( + r#"{"edit_predictions":{"disabled_globs":["**/user/**","..."]}}"#, + cx, + ) + .expect("user settings should load"); + store + .set_local_settings( + worktree_id, + root, + LocalSettingsKind::Settings, + Some(r#"{"edit_predictions":{"disabled_globs":["**/project/**","..."]}}"#), + cx, + ) + .expect("project settings should load"); + + for (content, inherits, child_enabled) in [ + ( + r#"{"edit_predictions":{"disabled_globs":["**/child/**","...","**/.env*","..."]}}"#, + true, + false, + ), + (r#"{"edit_predictions":{}}"#, true, true), + ( + r#"{"edit_predictions":{"disabled_globs":["**/child/**"]}}"#, + false, + false, + ), + (r#"{"edit_predictions":{"disabled_globs":[]}}"#, false, true), + ] { + store + .set_local_settings( + worktree_id, + child.clone(), + LocalSettingsKind::Settings, + Some(content), + cx, + ) + .expect("child settings should load"); + for (location, expected) in [ + (None, [false, true, true, false]), + ( + Some(SettingsLocation { + worktree_id, + path: rel_path("root/file.rs"), + }), + [false, true, false, false], + ), + ( + Some(SettingsLocation { + worktree_id, + path: rel_path("root/child/file.rs"), + }), + [!inherits, child_enabled, !inherits, !inherits], + ), + ] { + let settings = &store.get::(location).edit_predictions; + for (path, enabled) in [ + ".env", + "child/output.rs", + "project/output.rs", + "user/output.rs", + ] + .into_iter() + .zip(expected) + .chain([("...", true), ("src/main.rs", true)]) + { + let file: Arc = Arc::new(crate::TestFile { + path: rel_path(path).into(), + root_name: "project".to_string(), + local_root: None, + }); + assert_eq!( + settings.enabled_for_file(&file, cx), + enabled, + "path: {path}, settings: {content}" + ); + } + } + } + } + + #[gpui::test] + fn test_edit_predictions_disabled_globs_preserve_matching(cx: &mut App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + store.register_setting::(); + let absolute_root = if cfg!(windows) { + "C:/absolute" + } else { + "/absolute" + }; + let absolute_pattern = format!("{absolute_root}/project/**/*.rs"); + let home = shellexpand::tilde("~").into_owned(); + for (patterns, path, local_root, enabled) in [ + ( + vec!["**/src/**", "..."], + "src/main.rs", + Some(absolute_root), + false, + ), + ( + vec!["**/src/**", "..."], + "other/main.rs", + Some(absolute_root), + true, + ), + ( + vec![absolute_pattern.as_str(), "..."], + "src/main.rs", + Some(absolute_root), + false, + ), + ( + vec![absolute_pattern.as_str(), "..."], + "src/main.rs", + None, + true, + ), + ( + vec!["~/project/**/*.rs", "..."], + "src/main.rs", + Some(home.as_str()), + false, + ), + ( + vec!["[", "**/src/**", "..."], + "src/main.rs", + Some(absolute_root), + false, + ), + (vec!["[", "..."], ".env", Some(absolute_root), false), + (vec!["[", "{"], ".env", Some(absolute_root), true), + (vec!["[", "{"], "src/main.rs", Some(absolute_root), true), + (vec!["[.][.][.]"], "...", Some(absolute_root), false), + ] { + store + .set_user_settings( + &serde_json::json!({"edit_predictions": {"disabled_globs": patterns}}) + .to_string(), + cx, + ) + .expect("user settings should load"); + let file: Arc = Arc::new(crate::TestFile { + path: rel_path(path).into(), + root_name: "project".to_string(), + local_root: local_root.map(std::path::PathBuf::from), + }); + let settings = &store.get::(None).edit_predictions; + assert_eq!( + settings.enabled_for_file(&file, cx), + enabled, + "path: {path}, patterns: {patterns:?}" + ); + } + } + #[gpui::test] fn test_edit_predictions_enabled_for_file(cx: &mut TestAppContext) { use crate::TestFile; diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 7f9d377e6d..26a53bfd5b 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -2,11 +2,14 @@ mod syntax_map_tests; use crate::{ - Grammar, InjectionConfig, Language, LanguageId, LanguageRegistry, QUERY_CURSORS, with_parser, + CaptureId, Grammar, InjectionConfig, Language, LanguageId, LanguageRegistry, QUERY_CURSORS, + with_parser, }; use collections::HashMap; use futures::FutureExt; use gpui::SharedString; +use language_core::highlight_map::HighlightCaptureRef; +use smallvec::SmallVec; use std::{ borrow::Cow, cmp::{self, Ordering, Reverse}, @@ -73,7 +76,7 @@ impl Drop for SyntaxSnapshot { pub struct SyntaxMapCaptures<'a> { layers: Vec>, active_layer_count: usize, - grammars: Vec<&'a Grammar>, + grammars: Vec<&'a Arc>, } #[derive(Default)] @@ -90,6 +93,73 @@ pub struct SyntaxMapCapture<'a> { pub grammar_index: usize, } +pub(crate) struct HighlightCaptureRegion { + pub range: Range, + pub stack: SmallVec<[HighlightCaptureRef; 4]>, +} + +pub(crate) fn flattened_highlight_regions( + mut captures: SyntaxMapCaptures<'_>, + range: Range, +) -> Vec { + let capture_refs = iter::from_fn(move || { + let capture = captures.next()?; + Some(( + capture.node.byte_range(), + HighlightCaptureRef { + grammar_index: capture.grammar_index, + capture_id: CaptureId(capture.index), + }, + )) + }); + flatten_capture_regions(range, capture_refs) +} + +fn flatten_capture_regions( + range: Range, + mut captures: impl Iterator, HighlightCaptureRef)>, +) -> Vec { + let mut result = Vec::new(); + let mut stack = Vec::<(Range, HighlightCaptureRef)>::new(); + let mut offset = range.start; + let mut next_capture = captures.next(); + loop { + stack.retain(|(capture_range, _)| capture_range.end > offset); + while let Some((capture_range, capture_ref)) = next_capture.take() { + if capture_range.start > offset { + next_capture = Some((capture_range, capture_ref)); + break; + } + if capture_range.end > offset { + stack.push((capture_range, capture_ref)); + } + next_capture = captures.next(); + } + let mut next_boundary = range.end; + if let Some(min_end) = stack + .iter() + .map(|(capture_range, _)| capture_range.end) + .min() + { + next_boundary = next_boundary.min(min_end); + } + if let Some((capture_range, _)) = &next_capture { + next_boundary = next_boundary.min(capture_range.start); + } + if !stack.is_empty() && next_boundary > offset { + result.push(HighlightCaptureRegion { + range: offset..next_boundary, + stack: stack.iter().map(|(_, capture_ref)| *capture_ref).collect(), + }); + } + if next_boundary >= range.end { + break; + } + offset = next_boundary; + } + result +} + #[derive(Debug)] pub struct SyntaxMapMatch<'a> { pub language: Arc, @@ -253,6 +323,21 @@ enum ParseMode { }, } +/// Identifies a set of injection matches that should share a single syntax layer. +#[derive(PartialEq, Eq, Hash)] +enum InjectionGroupKey { + /// Every match of an `injection.combined` pattern for a given language shares one + /// layer spanning the whole parent layer. + Combined(LanguageId), + /// Every match whose `@injection.host` capture resolves to the same node shares one + /// layer spanning that node. This keeps interpolated strings such as Python + /// f-strings, which produce one match per string fragment, in a single layer. + Host { + language: LanguageId, + host_range: Range, + }, +} + #[derive(Debug, PartialEq, Eq)] struct ChangedRegion { depth: usize, @@ -561,7 +646,7 @@ impl SyntaxSnapshot { let mut changed_regions = ChangeRegionSet::default(); let mut queue = BinaryHeap::new(); - let mut combined_injection_ranges = HashMap::default(); + let mut injection_groups = HashMap::default(); queue.push(ParseStep { depth: 0, language: ParseStepLanguage::Loaded { @@ -860,25 +945,29 @@ impl SyntaxSnapshot { registry, step.depth + 1, &expanded_ranges, - &mut combined_injection_ranges, + &mut injection_groups, &mut queue, ); } - let included_sub_ranges: Option>> = if is_combined { - Some( - included_ranges - .into_iter() - .filter(|r| r.start_byte < r.end_byte) - .map(|r| { - text.anchor_before(r.start_byte + step_start_byte) - ..text.anchor_after(r.end_byte + step_start_byte) - }) - .collect(), - ) - } else { - None - }; + // Layers built from more than one included range don't cover their + // whole span, so record the sub-ranges to let callers tell which + // offsets actually belong to this layer. + let included_sub_ranges: Option>> = + if is_combined || included_ranges.len() > 1 { + Some( + included_ranges + .into_iter() + .filter(|r| r.start_byte < r.end_byte) + .map(|r| { + text.anchor_before(r.start_byte + step_start_byte) + ..text.anchor_after(r.end_byte + step_start_byte) + }) + .collect(), + ) + } else { + None + }; SyntaxLayerContent::Parsed { tree, language, @@ -1182,7 +1271,7 @@ impl<'a> SyntaxMapCaptures<'a> { result } - pub fn grammars(&self) -> &[&'a Grammar] { + pub fn grammars(&self) -> &[&'a Arc] { &self.grammars } @@ -1583,7 +1672,7 @@ fn get_injections( language_registry: &Arc, depth: usize, changed_ranges: &[Range], - combined_injection_ranges: &mut HashMap, Vec)>, + injection_groups: &mut HashMap, Vec)>, queue: &mut BinaryHeap, ) { let mut query_cursor = QueryCursorHandle::new(); @@ -1591,7 +1680,7 @@ fn get_injections( // Ensure that a `ParseStep` is created for every combined injection language, even // if there currently no matches for that injection. - combined_injection_ranges.clear(); + injection_groups.clear(); for pattern in &config.patterns { if let (Some(language_name), true) = (pattern.language.as_ref(), pattern.combined) && let Some(language) = language_registry @@ -1599,7 +1688,10 @@ fn get_injections( .now_or_never() .and_then(|language| language.ok()) { - combined_injection_ranges.insert(language.id, (language, Vec::new())); + injection_groups.insert( + InjectionGroupKey::Combined(language.id), + (language, Vec::new()), + ); } } @@ -1629,6 +1721,11 @@ fn get_injections( prev_match = Some((mat.pattern_index, content_range.clone())); let combined = config.patterns[mat.pattern_index].combined; + let host_range = config + .host_capture_ix + .and_then(|ix| mat.nodes_for_capture_index(ix).next()) + .map(|host_node| host_node.byte_range()); + let mut step_range = content_range.clone(); let language_name = if let Some(name) = config.patterns[mat.pattern_index].language.as_ref() { @@ -1658,11 +1755,23 @@ fn get_injections( .now_or_never() .and_then(|language| language.ok()); let range = text.anchor_before(step_range.start)..text.anchor_after(step_range.end); + if let Some(language) = language { - if combined { - combined_injection_ranges - .entry(language.id) - .or_insert_with(|| (language.clone(), vec![])) + let group_key = if let Some(host_range) = host_range { + Some(InjectionGroupKey::Host { + language: language.id, + host_range, + }) + } else if combined { + Some(InjectionGroupKey::Combined(language.id)) + } else { + None + }; + + if let Some(group_key) = group_key { + injection_groups + .entry(group_key) + .or_insert_with(|| (language.clone(), Vec::new())) .1 .extend(content_ranges); } else { @@ -1689,19 +1798,34 @@ fn get_injections( } } - for (_, (language, mut included_ranges)) in combined_injection_ranges.drain() { + for (group_key, (language, mut included_ranges)) in injection_groups.drain() { included_ranges.sort_unstable_by(|a, b| { Ord::cmp(&a.start_byte, &b.start_byte).then_with(|| Ord::cmp(&a.end_byte, &b.end_byte)) }); + // Overlapping changed ranges can yield the same match more than once, and + // `set_included_ranges` rejects overlapping ranges. + included_ranges.dedup(); + + let (range, mode) = match group_key { + InjectionGroupKey::Combined(_) => ( + outer_range.clone(), + ParseMode::Combined { + parent_layer_range: outer_range.to_offset(text), + parent_layer_changed_ranges: changed_ranges.to_vec(), + }, + ), + InjectionGroupKey::Host { host_range, .. } => ( + text.anchor_before(host_range.start)..text.anchor_after(host_range.end), + ParseMode::Single, + ), + }; + queue.push(ParseStep { depth, language: ParseStepLanguage::Loaded { language }, - range: outer_range.clone(), + range, included_ranges, - mode: ParseMode::Combined { - parent_layer_range: outer_range.to_offset(text), - parent_layer_changed_ranges: changed_ranges.to_vec(), - }, + mode, }) } } diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index 442e6890fe..4c57b38e6f 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -4,6 +4,7 @@ use crate::{ buffer_tests::markdown_inline_lang, markdown_lang, rust_lang, }; use gpui::App; +use indoc::indoc; use pretty_assertions::assert_eq; use rand::rngs::StdRng; use std::borrow::Cow; @@ -1049,6 +1050,99 @@ fn test_comment_triggered_injection_toggle(cx: &mut App) { ); } +#[gpui::test] +fn test_injection_grouped_by_host(cx: &mut App) { + // Each SQL layer is described by the buffer text of the fragments it is parsed from, + // so that interpolated strings are asserted to produce a single layer covering every + // fragment of the string. + let cases: &[(&str, &[&[&str]])] = &[ + ( + indoc! {r#" + # sql + cmd = "SELECT col1, col2 FROM tbl" + "#}, + &[&["SELECT col1, col2 FROM tbl"]], + ), + ( + indoc! {r#" + # sql + cmd = f"SELECT col1 FROM tbl WHERE col2 = '{my_var}'" + "#}, + &[&["SELECT col1 FROM tbl WHERE col2 = '", "'"]], + ), + ( + indoc! {r#" + # sql + cmd = f"SELECT {col1}, {col2} FROM {tbl}" + "#}, + &[&["SELECT ", ", ", " FROM "]], + ), + ( + indoc! {r#" + cursor.execute( + # sql + f"SELECT col1 FROM tbl WHERE col2 = '{my_var}'" + ) + "#}, + &[&["SELECT col1 FROM tbl WHERE col2 = '", "'"]], + ), + ( + indoc! {r#" + cursor.execute( + # sql + f"SELECT col1 FROM tbl WHERE col2 = '{my_var}'" + ) + + cursor.execute( + # sql + f"INSERT INTO tbl VALUES ('{val1}')" + ) + "#}, + &[ + &["SELECT col1 FROM tbl WHERE col2 = '", "'"], + &["INSERT INTO tbl VALUES ('", "')"], + ], + ), + ]; + + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let python = Arc::new(python_lang()); + registry.add(python.clone()); + registry.add(Arc::new(comment_lang())); + registry.add(Arc::new(sql_lang())); + + for (buffer_index, (text, expected_layers)) in cases.iter().enumerate() { + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(buffer_index as u64 + 1).unwrap(), + text.to_string(), + ); + let mut syntax_map = SyntaxMap::new(&buffer); + syntax_map.set_language_registry(registry.clone()); + syntax_map.reparse(python.clone(), &buffer); + + let sql_layers = syntax_map + .layers_for_range(0..buffer.len(), &buffer, true) + .filter(|layer| layer.language.name() == "SQL") + .map(|layer| match layer.included_sub_ranges { + Some(sub_ranges) => sub_ranges + .iter() + .map(|range| buffer.text_for_range(range.clone()).collect::()) + .collect::>(), + None => { + vec![ + buffer + .text_for_range(layer.node().byte_range()) + .collect::(), + ] + } + }) + .collect::>(); + + assert_eq!(sql_layers, *expected_layers, "buffer text:\n{text}"); + } +} + #[gpui::test] fn test_syntax_map_languages_loading_with_erb(cx: &mut App) { let text = r#" @@ -1188,6 +1282,32 @@ fn test_random_syntax_map_edits_with_erb(rng: StdRng, cx: &mut App) { test_random_edits(text, registry, language, rng); } +#[gpui::test(iterations = 50)] +fn test_random_syntax_map_edits_with_python_sql(rng: StdRng, cx: &mut App) { + let text = r#" + # sql + query = f"SELECT {col} FROM tbl WHERE name = '{name}' AND id = {id}" + + cursor.execute( + # sql + f""" + INSERT INTO tbl (col1, col2) + VALUES ('{one}', '{two}') + """ + ) + "# + .unindent() + .repeat(5); + + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let language = Arc::new(python_lang()); + registry.add(language.clone()); + registry.add(Arc::new(comment_lang())); + registry.add(Arc::new(sql_lang())); + + test_random_edits(text, registry, language, rng); +} + #[gpui::test(iterations = 50)] fn test_random_syntax_map_edits_with_heex(rng: StdRng, cx: &mut App) { let text = r#" @@ -1221,6 +1341,48 @@ fn test_random_syntax_map_edits_with_heex(rng: StdRng, cx: &mut App) { test_random_edits(text, registry, language, rng); } +#[test] +fn test_flatten_capture_regions_with_nested_captures() { + let outer = capture_ref(1); + let inner = capture_ref(2); + assert_eq!( + flattened(0..12, &[(0..10, outer), (2..5, inner)]), + vec![ + (0..2, vec![outer]), + (2..5, vec![outer, inner]), + (5..10, vec![outer]), + ], + ); +} + +#[test] +fn test_flatten_capture_regions_with_overlapping_captures() { + let first = capture_ref(1); + let second = capture_ref(2); + assert_eq!( + flattened(0..25, &[(0..10, first), (2..20, second)]), + vec![ + (0..2, vec![first]), + (2..10, vec![first, second]), + (10..20, vec![second]), + ], + "a capture must not extend past its own end when overlapping another capture" + ); +} + +#[test] +fn test_flatten_capture_regions_clips_to_the_requested_range() { + let capture = capture_ref(1); + assert_eq!( + flattened(5..8, &[(0..10, capture)]), + vec![(5..8, vec![capture])], + ); + assert_eq!( + flattened(0..6, &[(4..10, capture)]), + vec![(4..6, vec![capture])], + ); +} + fn test_random_edits( text: String, registry: Arc, @@ -1630,6 +1792,35 @@ fn comment_lang() -> Language { ) } +/// Stands in for the real SQL language, which this crate doesn't depend on. The grammar +/// is irrelevant here; only the layers Python's injection query produces are asserted on. +fn sql_lang() -> Language { + Language::new( + LanguageConfig { + name: "SQL".into(), + ..Default::default() + }, + Some(tree_sitter_json::LANGUAGE.into()), + ) +} + +fn capture_ref(capture_id: u32) -> HighlightCaptureRef { + HighlightCaptureRef { + grammar_index: 0, + capture_id: CaptureId(capture_id), + } +} + +fn flattened( + range: Range, + captures: &[(Range, HighlightCaptureRef)], +) -> Vec<(Range, Vec)> { + flatten_capture_regions(range, captures.iter().cloned()) + .into_iter() + .map(|region| (region.range, region.stack.to_vec())) + .collect() +} + fn range_for_text(buffer: &Buffer, text: &str) -> Range { let start = buffer.as_rope().to_string().find(text).unwrap(); start..start + text.len() diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml index 54ec3ce12d..5281c3e1b3 100644 --- a/crates/language_core/Cargo.toml +++ b/crates/language_core/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "language_core" version = "0.1.0" -edition = "2024" +edition.workspace = true publish = false [lib] path = "src/language_core.rs" +doctest = false [dependencies] anyhow.workspace = true @@ -14,12 +15,14 @@ collections.workspace = true gpui_shared_string.workspace = true gpui_util.workspace = true log.workspace = true +lru.workspace = true parking_lot.workspace = true path.workspace = true regex.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +smallvec.workspace = true strum.workspace = true toml.workspace = true tree-sitter.workspace = true diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs index 42c398d3cd..fc45de7706 100644 --- a/crates/language_core/src/grammar.rs +++ b/crates/language_core/src/grammar.rs @@ -1,6 +1,6 @@ use crate::{ - HighlightId, HighlightMap, LanguageConfig, LanguageConfigOverride, LanguageName, - LanguageQueries, language_config::BracketPairConfig, + CaptureId, HighlightId, HighlightMap, LanguageConfig, LanguageConfigOverride, LanguageName, + LanguageQueries, highlight_cache::TextHighlightCache, language_config::BracketPairConfig, }; use anyhow::{Context as _, Result}; use collections::HashMap; @@ -103,6 +103,7 @@ pub struct Grammar { pub struct HighlightsConfig { pub query: Query, pub identifier_capture_indices: Vec, + pub text_highlight_cache: TextHighlightCache, } pub struct IndentConfig { @@ -183,6 +184,7 @@ pub struct InjectionConfig { pub query: Query, pub content_capture_ix: u32, pub language_capture_ix: Option, + pub host_capture_ix: Option, pub patterns: Vec, } @@ -366,12 +368,16 @@ impl Grammar { self.highlight_map.lock().clone() } + pub fn highlight_map_matches(&self, highlight_map: &HighlightMap) -> bool { + self.highlight_map.lock().same(highlight_map) + } + pub fn highlight_id_for_name(&self, name: &str) -> Option { self.highlights_config .as_ref()? .query .capture_index_for_name(name) - .and_then(|capture_id| self.highlight_map.lock().get(capture_id)) + .and_then(|capture_id| self.highlight_map.lock().get(CaptureId(capture_id))) } pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { @@ -468,6 +474,7 @@ impl Grammar { self.highlights_config = Some(HighlightsConfig { query, identifier_capture_indices, + text_highlight_cache: TextHighlightCache::default(), }); Ok(self) @@ -687,6 +694,7 @@ impl Grammar { let mut injection_language_capture_ix = None; let mut content_capture_ix = None; let mut injection_content_capture_ix = None; + let mut host_capture_ix = None; if populate_capture_indices( &query, language_name, @@ -697,6 +705,7 @@ impl Grammar { Capture::Optional("injection.language", &mut injection_language_capture_ix), Capture::Optional("content", &mut content_capture_ix), Capture::Optional("injection.content", &mut injection_content_capture_ix), + Capture::Optional("injection.host", &mut host_capture_ix), ], ) { language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { @@ -735,6 +744,7 @@ impl Grammar { query, language_capture_ix, content_capture_ix, + host_capture_ix, patterns, }); } else { diff --git a/crates/language_core/src/highlight_cache.rs b/crates/language_core/src/highlight_cache.rs new file mode 100644 index 0000000000..86741ec4d4 --- /dev/null +++ b/crates/language_core/src/highlight_cache.rs @@ -0,0 +1,339 @@ +use crate::grammar::Grammar; +use crate::highlight_map::{HighlightId, HighlightMap}; +use collections::FxHasher; +use lru::LruCache; +use parking_lot::Mutex; +use smallvec::SmallVec; +use std::{ + fmt, + hash::{Hash, Hasher as _}, + ops::Range, + sync::Arc, +}; + +pub const MAX_TEXT_HIGHLIGHT_ENTRY_BYTES: usize = MAX_TEXT_HIGHLIGHT_CACHE_BYTES / 8; + +const MAX_TEXT_HIGHLIGHT_CACHE_BYTES: usize = 4 * 1024 * 1024; +const MAX_CHUNK_HIGHLIGHT_CACHE_BYTES: usize = 10 * 1024 * 1024; +const APPROXIMATE_LRU_NODE_BYTES: usize = 4 * size_of::(); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TextHighlightKey { + text_hash: u64, + text_len: usize, +} + +impl TextHighlightKey { + pub fn new<'a>(text_chunks: impl Iterator, text_len: usize) -> Self { + let mut hasher = FxHasher::default(); + for chunk in text_chunks { + hasher.write(chunk.as_bytes()); + } + Self { + text_hash: hasher.finish(), + text_len, + } + } +} + +#[derive(Clone, Default)] +pub struct ResolvedHighlights { + pub sources: SmallVec<[(Arc, HighlightMap); 2]>, + pub runs: Arc<[(Range, HighlightId)]>, +} + +impl ResolvedHighlights { + pub fn is_current(&self) -> bool { + self.sources + .iter() + .all(|(grammar, highlight_map)| grammar.highlight_map_matches(highlight_map)) + } + + pub fn cost_bytes(&self) -> usize { + self.sources.len() * size_of::<(Arc, HighlightMap)>() + + self.runs.len() * size_of::<(Range, HighlightId)>() + } +} + +impl fmt::Debug for ResolvedHighlights { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolvedHighlights") + .field("runs", &self.runs) + .finish_non_exhaustive() + } +} + +pub struct TextHighlightCache(Mutex>); + +impl Default for TextHighlightCache { + fn default() -> Self { + Self(Mutex::new(CostBudgetedLru::new( + MAX_TEXT_HIGHLIGHT_CACHE_BYTES, + MAX_TEXT_HIGHLIGHT_ENTRY_BYTES, + ))) + } +} + +impl TextHighlightCache { + pub fn get<'a>( + &self, + key: &TextHighlightKey, + text_chunks: impl Iterator, + ) -> Option { + let mut cache = self.0.lock(); + let entry = cache.get(key)?; + if !entry.highlights.is_current() { + return None; + } + if !chunks_match_text(&entry.text, text_chunks) { + return None; + } + Some(entry.highlights.clone()) + } + + pub fn insert( + &self, + key: TextHighlightKey, + text: Arc, + highlights: ResolvedHighlights, + ) -> ResolvedHighlights { + let cost = text.len() + highlights.cost_bytes(); + self.0.lock().insert( + key, + TextHighlightEntry { + text, + highlights: highlights.clone(), + }, + cost, + ); + highlights + } +} + +pub struct ChunkHighlightCache(Mutex>); + +impl Default for ChunkHighlightCache { + fn default() -> Self { + Self(Mutex::new(CostBudgetedLru::new( + MAX_CHUNK_HIGHLIGHT_CACHE_BYTES, + MAX_CHUNK_HIGHLIGHT_CACHE_BYTES, + ))) + } +} + +impl fmt::Debug for ChunkHighlightCache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChunkHighlightCache") + .finish_non_exhaustive() + } +} + +impl ChunkHighlightCache { + pub fn get(&self, chunk_id: usize) -> Option { + let mut lru = self.0.lock(); + let highlights = lru.get(&chunk_id)?; + if !highlights.is_current() { + return None; + } + Some(highlights.clone()) + } + + pub fn insert(&self, chunk_id: usize, highlights: ResolvedHighlights) { + let cost = highlights.cost_bytes(); + self.0.lock().insert(chunk_id, highlights, cost); + } + + pub fn clear(&mut self) { + self.0.get_mut().clear(); + } +} + +struct CostBudgetedLru { + entries: LruCache, + total_cost: usize, + max_total_cost: usize, + max_entry_cost: usize, +} + +impl CostBudgetedLru { + const ENTRY_OVERHEAD_BYTES: usize = + size_of::() + size_of::<(V, usize)>() + APPROXIMATE_LRU_NODE_BYTES; + + fn new(max_total_cost: usize, max_entry_cost: usize) -> Self { + Self { + entries: LruCache::unbounded(), + total_cost: 0, + max_total_cost, + max_entry_cost, + } + } + + fn get(&mut self, key: &K) -> Option<&V> { + self.entries.get(key).map(|(value, _)| value) + } + + fn insert(&mut self, key: K, value: V, cost: usize) { + if cost > self.max_entry_cost { + return; + } + let cost = cost + Self::ENTRY_OVERHEAD_BYTES; + if let Some((_, old_cost)) = self.entries.put(key, (value, cost)) { + self.total_cost -= old_cost; + } + self.total_cost += cost; + while self.total_cost > self.max_total_cost { + let Some((_, (_, evicted_cost))) = self.entries.pop_lru() else { + break; + }; + self.total_cost -= evicted_cost; + } + } + + fn clear(&mut self) { + self.entries.clear(); + self.total_cost = 0; + } +} + +struct TextHighlightEntry { + text: Arc, + highlights: ResolvedHighlights, +} + +fn chunks_match_text<'a>(text: &str, text_chunks: impl Iterator) -> bool { + let mut remaining = text; + for chunk in text_chunks { + let Some(rest) = remaining.strip_prefix(chunk) else { + return false; + }; + remaining = rest; + } + remaining.is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_budget_evicts_least_recently_used() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(2 * (40 + overhead), 100); + cache.insert("a", 1, 40); + cache.insert("b", 2, 40); + cache.get(&"a"); + cache.insert("c", 3, 40); + assert_eq!(cache.get(&"a"), Some(&1)); + assert_eq!(cache.get(&"b"), None); + assert_eq!(cache.get(&"c"), Some(&3)); + assert_eq!(cache.total_cost, 2 * (40 + overhead)); + } + + #[test] + fn test_replacing_an_entry_updates_the_budget() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(100 + 2 * overhead, 100); + cache.insert("a", 1, 60); + cache.insert("a", 2, 30); + assert_eq!(cache.total_cost, 30 + overhead); + assert_eq!(cache.get(&"a"), Some(&2)); + cache.insert("b", 3, 70); + assert_eq!(cache.get(&"a"), Some(&2)); + assert_eq!(cache.get(&"b"), Some(&3)); + } + + #[test] + fn test_oversized_entries_are_rejected_without_flushing() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(100 + overhead, 50); + cache.insert("a", 1, 40); + cache.insert("b", 2, 60); + assert_eq!(cache.get(&"a"), Some(&1)); + assert_eq!(cache.get(&"b"), None); + assert_eq!(cache.total_cost, 40 + overhead); + } + + #[test] + fn test_clear_resets_the_budget() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(90 + overhead, 100); + cache.insert("a", 1, 90); + cache.clear(); + assert_eq!(cache.total_cost, 0); + cache.insert("b", 2, 90); + assert_eq!(cache.get(&"b"), Some(&2)); + } + + #[test] + fn test_zero_cost_entries_still_consume_budget() { + let overhead = CostBudgetedLru::::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::::new(3 * overhead, 100); + for key in 0..5_usize { + cache.insert(key, key as u32, 0); + } + assert_eq!(cache.get(&0), None); + assert_eq!(cache.get(&1), None); + assert_eq!(cache.get(&2), Some(&2)); + assert_eq!(cache.get(&3), Some(&3)); + assert_eq!(cache.get(&4), Some(&4)); + assert_eq!( + cache.total_cost, + 3 * overhead, + "Entries with zero caller-supplied cost must still be bounded by the budget" + ); + } + + #[test] + fn test_oversized_highlights_are_returned_but_not_cached() { + let text = Arc::::from("fn main() {}"); + let run_bytes = size_of::<(Range, HighlightId)>(); + let max_entry_cost = text.len() + 2 * run_bytes; + let cache = TextHighlightCache(Mutex::new(CostBudgetedLru::new( + max_entry_cost * 10, + max_entry_cost, + ))); + let resolved = |ranges: &[Range]| ResolvedHighlights { + sources: SmallVec::new(), + runs: ranges + .iter() + .map(|range| (range.clone(), HighlightId::new(0))) + .collect(), + }; + + let small_text = Arc::::from("fn f() {}"); + let small_key = TextHighlightKey::new([small_text.as_ref()].into_iter(), small_text.len()); + let small_range = 0..2; + let small_highlights = cache.insert( + small_key.clone(), + Arc::clone(&small_text), + resolved(std::slice::from_ref(&small_range)), + ); + assert_eq!( + small_highlights.runs.as_ref(), + resolved(std::slice::from_ref(&small_range)).runs.as_ref() + ); + assert_eq!( + cache + .get(&small_key, [small_text.as_ref()].into_iter()) + .map(|highlights| highlights.runs), + Some(small_highlights.runs.clone()), + "highlights within the entry budget must be cached as-is" + ); + + let big_key = TextHighlightKey::new([text.as_ref()].into_iter(), text.len()); + let big_highlights_source = resolved(&[0..2, 3..7, 8..9]); + let big_highlights = cache.insert( + big_key.clone(), + Arc::clone(&text), + big_highlights_source.clone(), + ); + assert!( + Arc::ptr_eq(&big_highlights.runs, &big_highlights_source.runs), + "highlights over the entry budget must be returned unchanged" + ); + assert!( + cache.get(&big_key, [text.as_ref()].into_iter()).is_none(), + "highlights over the entry budget must not be cached" + ); + } +} diff --git a/crates/language_core/src/highlight_map.rs b/crates/language_core/src/highlight_map.rs index cba5cda6f7..b5ddcf670e 100644 --- a/crates/language_core/src/highlight_map.rs +++ b/crates/language_core/src/highlight_map.rs @@ -3,6 +3,15 @@ use std::{num::NonZeroU32, sync::Arc}; #[derive(Clone, Debug)] pub struct HighlightMap(Arc<[Option]>); +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CaptureId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HighlightCaptureRef { + pub grammar_index: usize, + pub capture_id: CaptureId, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct HighlightId(NonZeroU32); @@ -28,8 +37,13 @@ impl HighlightMap { } #[inline] - pub fn get(&self, capture_id: u32) -> Option { - self.0.get(capture_id as usize).copied().flatten() + pub fn get(&self, capture_id: CaptureId) -> Option { + self.0.get(capture_id.0 as usize).copied().flatten() + } + + #[inline] + pub fn same(&self, other: &HighlightMap) -> bool { + Arc::ptr_eq(&self.0, &other.0) } } diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs index 72ab3fcf5b..6a0059f684 100644 --- a/crates/language_core/src/language_config.rs +++ b/crates/language_core/src/language_config.rs @@ -150,9 +150,14 @@ pub struct LanguageConfig { impl LanguageConfig { pub const FILE_NAME: &str = "config.toml"; + #[inline(never)] + pub fn from_toml(config: &str) -> Result { + toml::from_str::(config) + } + pub fn load(config_path: impl AsRef) -> anyhow::Result { let config = std::fs::read_to_string(config_path.as_ref())?; - toml::from_str(&config).map_err(Into::into) + Self::from_toml(&config).map_err(anyhow::Error::from) } } diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs index f3cb3ab51c..e2050f325e 100644 --- a/crates/language_core/src/language_core.rs +++ b/crates/language_core/src/language_core.rs @@ -2,6 +2,7 @@ // language configuration, and highlight mapping. pub mod grammar; +pub mod highlight_cache; pub mod highlight_map; pub mod language_config; @@ -11,7 +12,7 @@ pub use grammar::{ NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, ParseableLanguage, RedactionConfig, RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, }; -pub use highlight_map::{HighlightId, HighlightMap}; +pub use highlight_map::{CaptureId, HighlightId, HighlightMap}; pub use language_config::{ BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, DecreaseIndentConfig, JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, LanguageMatcher, diff --git a/crates/language_detection/Cargo.toml b/crates/language_detection/Cargo.toml index b2d80c6f16..c00b885393 100644 --- a/crates/language_detection/Cargo.toml +++ b/crates/language_detection/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/language_detection.rs" +test = false +doctest = false [dependencies] betlang.workspace = true diff --git a/crates/language_extension/Cargo.toml b/crates/language_extension/Cargo.toml index f724dd9489..27939018c8 100644 --- a/crates/language_extension/Cargo.toml +++ b/crates/language_extension/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/language_extension.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index d9f28100d8..378e82a160 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -122,6 +122,7 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy { fn update_language_server_status( &self, + source: Option, language_server_id: LanguageServerName, status: BinaryStatus, ) { @@ -130,8 +131,16 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy { language_server_id, status ); - self.language_registry - .update_lsp_binary_status(language_server_id, status); + if let Some(source) = source { + self.language_registry.update_lsp_binary_status_for_entity( + source, + language_server_id, + status, + ); + } else { + self.language_registry + .update_lsp_binary_status(language_server_id, status); + } } } @@ -167,6 +176,7 @@ impl DynLspInstaller for ExtensionLspAdapter { ) -> LanguageServerBinaryLocations { async move { let ret = maybe!(async move { + let language_server_status_source = delegate.status_source_id(); let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; let command = self .extension @@ -174,6 +184,7 @@ impl DynLspInstaller for ExtensionLspAdapter { self.language_server_id.clone(), self.language_name.clone(), delegate, + language_server_status_source, ) .await?; @@ -314,6 +325,7 @@ impl LspAdapter for ExtensionLspAdapter { delegate: &Arc, _: &mut AsyncApp, ) -> Result> { + let language_server_status_source = delegate.status_source_id(); let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; let json_options = self .extension @@ -321,6 +333,7 @@ impl LspAdapter for ExtensionLspAdapter { self.language_server_id.clone(), self.language_name.clone(), delegate, + language_server_status_source, ) .await?; Ok(if let Some(json_options) = json_options { @@ -339,10 +352,15 @@ impl LspAdapter for ExtensionLspAdapter { _: Option, _cx: &mut AsyncApp, ) -> Result { + let language_server_status_source = delegate.status_source_id(); let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; let json_options: Option = self .extension - .language_server_workspace_configuration(self.language_server_id.clone(), delegate) + .language_server_workspace_configuration( + self.language_server_id.clone(), + delegate, + language_server_status_source, + ) .await?; Ok(if let Some(json_options) = json_options { serde_json::from_str(&json_options).with_context(|| { @@ -359,12 +377,14 @@ impl LspAdapter for ExtensionLspAdapter { _cached_binary: OwnedMutexGuard>, _cx: &mut AsyncApp, ) -> Option { + let language_server_status_source = delegate.status_source_id(); let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; let json_schema: Option = self .extension .language_server_initialization_options_schema( self.language_server_id.clone(), delegate, + language_server_status_source, ) .await .ok() @@ -378,12 +398,14 @@ impl LspAdapter for ExtensionLspAdapter { _cached_binary: OwnedMutexGuard>, _cx: &mut AsyncApp, ) -> Option { + let language_server_status_source = delegate.status_source_id(); let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; let json_schema: Option = self .extension .language_server_workspace_configuration_schema( self.language_server_id.clone(), delegate, + language_server_status_source, ) .await .ok() @@ -396,6 +418,7 @@ impl LspAdapter for ExtensionLspAdapter { target_language_server_id: LanguageServerName, delegate: &Arc, ) -> Result> { + let language_server_status_source = delegate.status_source_id(); let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; let json_options: Option = self .extension @@ -403,6 +426,7 @@ impl LspAdapter for ExtensionLspAdapter { self.language_server_id.clone(), target_language_server_id.clone(), delegate, + language_server_status_source, ) .await?; Ok(if let Some(json_options) = json_options { @@ -424,6 +448,7 @@ impl LspAdapter for ExtensionLspAdapter { _cx: &mut AsyncApp, ) -> Result> { + let language_server_status_source = delegate.status_source_id(); let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; let json_options: Option = self .extension @@ -431,6 +456,7 @@ impl LspAdapter for ExtensionLspAdapter { self.language_server_id.clone(), target_language_server_id.clone(), delegate, + language_server_status_source, ) .await?; Ok(if let Some(json_options) = json_options { diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index a9cb4c5b5f..4aa9d34cfe 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -9,6 +9,7 @@ use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream, st use gpui::{App, AsyncApp, Entity, Task}; use http_client::Result; use parking_lot::Mutex; +use std::collections::VecDeque; use std::sync::{ Arc, atomic::{AtomicBool, AtomicU64, Ordering::SeqCst}, @@ -120,7 +121,10 @@ pub struct FakeLanguageModel { supports_images: AtomicBool, supports_server_side_compaction: AtomicBool, max_token_count: AtomicU64, + max_input_tokens: Option, max_output_tokens: AtomicU64, + input_token_counts: Mutex>, + input_token_count_requests: Mutex>, } impl Default for FakeLanguageModel { @@ -138,7 +142,10 @@ impl Default for FakeLanguageModel { supports_images: AtomicBool::new(false), supports_server_side_compaction: AtomicBool::new(false), max_token_count: AtomicU64::new(1_000_000), + max_input_tokens: None, max_output_tokens: AtomicU64::new(0), + input_token_counts: Mutex::new(VecDeque::new()), + input_token_count_requests: Mutex::new(Vec::new()), } } } @@ -191,11 +198,23 @@ impl FakeLanguageModel { self.max_token_count.store(count, SeqCst); } + pub fn set_max_input_tokens(&mut self, count: u64) { + self.max_input_tokens = Some(count); + } + pub fn set_max_output_tokens(&self, count: Option) { self.max_output_tokens .store(count.unwrap_or_default(), SeqCst); } + pub fn queue_input_token_count(&self, count: u64) { + self.input_token_counts.lock().push_back(count); + } + + pub fn input_token_count_requests(&self) -> Vec { + self.input_token_count_requests.lock().clone() + } + pub fn pending_completions(&self) -> Vec { self.current_completion_txs .lock() @@ -253,6 +272,14 @@ impl FakeLanguageModel { .retain(|(req, _)| req != request); } + pub fn is_completion_stream_closed(&self, request: &LanguageModelRequest) -> bool { + self.current_completion_txs + .lock() + .iter() + .find(|(pending_request, _)| pending_request == request) + .is_none_or(|(_, sender)| sender.is_closed()) + } + pub fn send_last_completion_stream_text_chunk(&self, chunk: impl Into) { self.send_completion_stream_text_chunk(self.pending_completions().last().unwrap(), chunk); } @@ -329,6 +356,11 @@ impl LanguageModel for FakeLanguageModel { self.max_token_count.load(SeqCst) } + fn max_input_tokens(&self) -> u64 { + self.max_input_tokens + .unwrap_or_else(|| self.max_token_count()) + } + fn max_output_tokens(&self) -> Option { let max_output_tokens = self.max_output_tokens.load(SeqCst); if max_output_tokens == 0 { @@ -338,6 +370,24 @@ impl LanguageModel for FakeLanguageModel { } } + fn count_input_tokens( + &self, + request: LanguageModelRequest, + _: &AsyncApp, + ) -> BoxFuture<'static, Result, LanguageModelCompletionError>> { + if self.forbid_requests.load(SeqCst) { + return async { + Err(LanguageModelCompletionError::Other(anyhow!( + "requests are forbidden" + ))) + } + .boxed(); + } + self.input_token_count_requests.lock().push(request); + let count = self.input_token_counts.lock().pop_front(); + async move { Ok(count) }.boxed() + } + fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index d4bb4d5f13..dc12a4559b 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -171,6 +171,11 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether native compaction honors `LanguageModelRequest::max_output_tokens`. + fn supports_explicit_compaction_output_limit(&self) -> bool { + false + } + /// The provider-enforced input size required for explicit compaction. fn minimum_explicit_compaction_input_tokens(&self) -> Option { None @@ -210,7 +215,38 @@ pub trait LanguageModel: Send + Sync { false } + /// Returns the model's context-window capacity. fn max_token_count(&self) -> u64; + + /// Returns the input ceiling before reserving output from any shared window. + /// + /// Models with a separate prompt limit override the context-window default. + fn max_input_tokens(&self) -> u64 { + self.max_token_count() + } + + /// Counts request input without generating output, when supported by the provider. + /// + /// Counts may be estimates and differ from subsequent measured usage. Callers + /// choose the content to count; this does not infer which input is already + /// covered by a previous usage report. Unsupported providers return `None`. + fn count_input_tokens( + &self, + _request: LanguageModelRequest, + _cx: &AsyncApp, + ) -> BoxFuture<'static, Result, LanguageModelCompletionError>> { + async { Ok(None) }.boxed() + } + + /// Returns the combined input and output ceiling, if one applies. + /// + /// The conservative default shares the context window with output. `None` + /// means generation does not consume that window, not merely that the API + /// validates input separately or stops generation at the window boundary. + fn max_total_tokens(&self) -> Option { + Some(self.max_token_count()) + } + fn max_output_tokens(&self) -> Option { None } diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index f9a00a3f46..43e243c710 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -488,7 +488,7 @@ impl LanguageModelRegistry { self.inline_assistant_model .clone() - .or_else(|| self.default_model.clone()) + .or_else(|| self.default_model()) } pub fn commit_message_model(&self, cx: &App) -> Option { @@ -538,7 +538,7 @@ impl LanguageModelRegistry { #[cfg(test)] mod tests { use super::*; - use crate::fake_provider::FakeLanguageModelProvider; + use crate::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; #[test] fn selected_model_allows_slashes_in_model_id() { @@ -701,12 +701,61 @@ mod tests { ); assert!(registry.default_model().is_none()); + assert!(registry.inline_assistant_model().is_none()); registry.set_should_use_fallback(true); let default_model = registry.default_model().unwrap(); assert_eq!(default_model.model.id(), model.id()); assert_eq!(default_model.provider.id(), provider.id()); + assert!( + registry + .inline_assistant_model() + .is_some_and(|inline_model| inline_model.is_same_as(&default_model)) + ); + }); + } + + #[gpui::test] + fn test_inline_assistant_model_precedence(cx: &mut App) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + let provider = Arc::new(FakeLanguageModelProvider::default()); + let [inline_model, default_model, fallback_model] = + ["inline", "default", "fallback"].map(|model_id| ConfiguredModel { + provider: provider.clone(), + model: Arc::new(FakeLanguageModel::with_id_and_thinking( + "fake", model_id, model_id, false, + )), + }); + + registry.update(cx, |registry, cx| { + registry.set_should_use_fallback(true); + registry.set_fallback_model(Some(fallback_model.clone()), cx); + registry.set_default_model(Some(default_model.clone()), cx); + registry.set_inline_assistant_model(Some(inline_model.clone()), cx); + + assert!( + registry + .inline_assistant_model() + .is_some_and(|model| model.is_same_as(&inline_model)) + ); + + registry.set_inline_assistant_model(None, cx); + assert!( + registry + .inline_assistant_model() + .is_some_and(|model| model.is_same_as(&default_model)) + ); + + registry.set_default_model(None, cx); + assert!( + registry + .inline_assistant_model() + .is_some_and(|model| model.is_same_as(&fallback_model)) + ); + + registry.set_should_use_fallback(false); + assert!(registry.inline_assistant_model().is_none()); }); } diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml index f4a59a3e08..4ee946ad2f 100644 --- a/crates/language_model_core/Cargo.toml +++ b/crates/language_model_core/Cargo.toml @@ -10,12 +10,11 @@ workspace = true [lib] path = "src/language_model_core.rs" -doctest = false [dependencies] anyhow.workspace = true -async-lock.workspace = true cloud_llm_client.workspace = true +collections.workspace = true futures.workspace = true gpui_shared_string.workspace = true http_client.workspace = true @@ -27,5 +26,12 @@ serde_json.workspace = true strum.workspace = true thiserror.workspace = true +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Workspace inheritance would re-enable defaults, including blocking synchronization. +async-lock = { version = "3.4.2", default-features = false } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +async-lock.workspace = true + [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/language_model_core/src/chat_completion.rs b/crates/language_model_core/src/chat_completion.rs new file mode 100644 index 0000000000..c2570f75ab --- /dev/null +++ b/crates/language_model_core/src/chat_completion.rs @@ -0,0 +1,1636 @@ +//! Wire types and event mapping for streaming responses of OpenAI-compatible +//! Chat Completions APIs. +//! +//! Multiple providers (OpenAI, OpenRouter, LM Studio, llama.cpp, and various +//! OpenAI-compatible proxies) share this format, so the types are deliberately +//! lenient: every field a consumer does not strictly require is optional or +//! defaulted, because real-world providers routinely omit fields or send +//! explicit `null`s where the OpenAI reference implementation would not. + +use crate::util::{fix_streamed_json, parse_tool_arguments}; +use crate::{ + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolUse, + LanguageModelToolUseInput, StopReason, TokenUsage, +}; +use collections::HashMap; +use futures::{Stream, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::pin::Pin; + +/// A single decoded Chat Completions stream chunk: either an event or a +/// provider error envelope (`{"error": {...}}`). +/// +/// The error payload is generic so providers with richer error envelopes +/// (e.g. OpenRouter) can preserve their extra fields. +#[derive(Serialize, Debug)] +#[serde(untagged)] +pub enum ResponseStreamResult { + Ok(ResponseStreamEvent), + Err { error: E }, +} + +/// `#[derive(Deserialize)]` with `#[serde(untagged)]` is avoided here because +/// untagged enums report unhelpful errors ("data did not match any variant") +/// and, with the lenient event type below, would silently swallow error +/// envelopes as empty events. Instead: +/// +/// * A non-null top-level `error` field decodes as an error, taking +/// precedence over any event payload in the same chunk. +/// * A chunk with a `choices` or `usage` field decodes as an event, +/// preserving the underlying deserialization error message on failure. +/// * Anything else (e.g. non-standard error envelopes like `{"detail": ...}` +/// or `{"object": "error", ...}`) is rejected so it surfaces as a stream +/// error instead of being silently dropped as an empty event. +impl<'de, E> Deserialize<'de> for ResponseStreamResult +where + E: serde::de::DeserializeOwned, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + match value.get("error") { + Some(error) if !error.is_null() => { + let error = E::deserialize(error).map_err(serde::de::Error::custom)?; + Ok(ResponseStreamResult::Err { error }) + } + _ => { + if value.get("choices").is_none() && value.get("usage").is_none() { + return Err(serde::de::Error::custom(format!( + "unrecognized chat completion stream chunk: {value}" + ))); + } + let event = + ResponseStreamEvent::deserialize(&value).map_err(serde::de::Error::custom)?; + Ok(ResponseStreamResult::Ok(event)) + } + } + } +} + +/// The error payload most OpenAI-compatible providers send inside an +/// `{"error": {...}}` envelope. +#[derive(Serialize, Deserialize, Debug)] +pub struct ResponseStreamError { + #[serde(default)] + pub code: Option, + #[serde(default, rename = "type")] + pub error_type: Option, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct ResponseStreamEvent { + /// Usage-only chunks from some providers omit `choices` entirely or send + /// an explicit `null` instead of an empty array. + #[serde(default, deserialize_with = "null_as_default")] + pub choices: Vec, + pub usage: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ChoiceDelta { + #[serde(default, deserialize_with = "null_as_default")] + pub index: u32, + pub delta: Option, + pub finish_reason: Option, +} + +/// Deserializes a missing field or an explicit `null` as the type's default, +/// honoring this module's leniency guarantee for fields that are not +/// themselves optional. +fn null_as_default<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Serialize, Deserialize, Debug, Default, Eq, PartialEq)] +pub struct ResponseMessageDelta { + pub content: Option, + /// Reasoning text as sent by OpenRouter and compatible providers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Reasoning text as sent by DeepSeek-style providers and `llama-server` + /// (when started with a reasoning format, e.g. `--reasoning-format deepseek`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + /// Provider-defined structured reasoning metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct ToolCallChunk { + /// `None` when the provider omits the index, sends `null`, or sends a + /// negative sentinel such as `-1` (#42584). + #[serde( + default, + deserialize_with = "lenient_index", + skip_serializing_if = "Option::is_none" + )] + pub index: Option, + pub id: Option, + + // There is also an optional `type` field that would determine if a + // function is there. Sometimes this streams in with the `function` before + // it streams in the `type` + pub function: Option, +} + +/// Treats an explicit `null` and negative sentinels like `-1` (#42584) as an +/// absent index rather than a malformed chunk. +fn lenient_index<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.and_then(|index| usize::try_from(index).ok())) +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct FunctionChunk { + pub name: Option, + pub arguments: Option, + /// Provider-defined metadata required to replay a reasoning tool call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct Usage { + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, + /// Prompt-cache usage when reported by the provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_tokens_details: Option, +} + +/// Reports prompt-cache token usage from compatible providers. +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct PromptTokensDetails { + /// Tokens read from a prompt cache. + pub cached_tokens: Option, + /// Tokens written to a prompt cache. + pub cache_write_tokens: Option, +} + +impl Usage { + /// Converts to a [`TokenUsage`] update, splitting cache reads and writes + /// out of `prompt_tokens` when the provider reports them. + /// + /// Returns `None` unless both `prompt_tokens` and `completion_tokens` are + /// present, because a partial usage object carries no usable totals. + pub fn token_usage(&self) -> Option { + let prompt_tokens = self.prompt_tokens?; + let completion_tokens = self.completion_tokens?; + let details = self.prompt_tokens_details.as_ref(); + let cache_creation_input_tokens = details + .and_then(|details| details.cache_write_tokens) + .unwrap_or(0); + let cache_read_input_tokens = details + .and_then(|details| details.cached_tokens) + .unwrap_or(0); + Some(TokenUsage { + input_tokens: prompt_tokens + .saturating_sub(cache_creation_input_tokens) + .saturating_sub(cache_read_input_tokens), + output_tokens: completion_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + }) + } +} + +/// Accumulates structured reasoning metadata from compatible providers. +/// +/// Array entries are matched by `index` and then `id`. Fragmented `text`, +/// `summary`, and `data` fields are concatenated while other non-null fields +/// replace their previous values. +/// +/// # Examples +/// +/// ``` +/// use language_model_core::chat_completion::ReasoningDetailsAccumulator; +/// use serde_json::json; +/// +/// let mut accumulator = ReasoningDetailsAccumulator::default(); +/// accumulator.push(json!([{"index": 0, "text": "first "}])); +/// let details = accumulator +/// .push(json!([{"index": 0, "text": "second"}])) +/// .expect("non-empty reasoning details"); +/// +/// assert_eq!(details[0]["text"], "first second"); +/// ``` +#[derive(Debug, Default)] +pub struct ReasoningDetailsAccumulator { + accumulated: Option, +} + +impl ReasoningDetailsAccumulator { + /// Merges `chunk` and returns the updated metadata snapshot. + /// + /// `null` and empty arrays do not replace previously accumulated metadata + /// and return `None`. + pub fn push(&mut self, chunk: Value) -> Option { + match chunk { + Value::Null => None, + Value::Array(chunks) if chunks.is_empty() => None, + Value::Array(chunks) => { + let mut details = match self.accumulated.take() { + Some(Value::Array(details)) => details, + _ => Vec::new(), + }; + for chunk in chunks { + merge_reasoning_detail(&mut details, chunk); + } + let accumulated = Value::Array(details); + self.accumulated = Some(accumulated.clone()); + Some(accumulated) + } + chunk => { + self.accumulated = Some(chunk.clone()); + Some(chunk) + } + } + } +} + +fn merge_reasoning_detail(details: &mut Vec, chunk: Value) { + let index = chunk.get("index").and_then(Value::as_u64); + let target_index = index + .and_then(|index| { + details + .iter() + .position(|detail| detail.get("index").and_then(Value::as_u64) == Some(index)) + }) + .or_else(|| { + let id = chunk.get("id").and_then(Value::as_str)?; + details + .iter() + .position(|detail| detail.get("id").and_then(Value::as_str) == Some(id)) + }); + let Some(target_index) = target_index else { + details.push(chunk); + return; + }; + let (Some(target), Some(chunk)) = (details[target_index].as_object_mut(), chunk.as_object()) + else { + return; + }; + for (key, value) in chunk { + if matches!(key.as_str(), "text" | "summary" | "data") + && let Some(fragment) = value.as_str() + && let Some(existing) = target.get(key).and_then(Value::as_str) + { + target.insert(key.clone(), Value::String(format!("{existing}{fragment}"))); + } else if !value.is_null() { + target.insert(key.clone(), value.clone()); + } + } +} + +#[derive(Default)] +struct RawToolCall { + index: Option, + id: String, + name: String, + arguments: String, + thought_signature: Option, +} + +/// Accumulates streamed tool calls, matching chunks by their ID and index when +/// both are present. Either value alone is unreliable because some providers +/// omit indices (#42584), reuse indices, or repeat IDs across parallel calls. +#[derive(Default)] +struct ToolCallAccumulator { + calls: Vec, + calls_by_index: HashMap, +} + +#[derive(Debug, thiserror::Error)] +#[error( + "cannot unambiguously attribute a tool call chunk to one of the \ + {calls_in_flight} tool calls in progress" +)] +struct AmbiguousToolCallChunk { + calls_in_flight: usize, +} + +impl ToolCallAccumulator { + /// Returns the call `chunk` belongs to, creating it if the chunk starts + /// a new call. Fails rather than guessing (and corrupting arguments) when + /// the available identity matches multiple calls in flight. + fn entry(&mut self, chunk: &ToolCallChunk) -> Result<&mut RawToolCall, AmbiguousToolCallChunk> { + let id = chunk.id.as_deref().filter(|id| !id.is_empty()); + let call = match (id, chunk.index) { + (Some(id), index) => self.call_for_id(id, index)?, + (None, Some(index)) => self.call_for_index(index), + (None, None) => match self.calls.len() { + 0 => self.new_call(), + 1 => 0, + calls_in_flight => return Err(AmbiguousToolCallChunk { calls_in_flight }), + }, + }; + Ok(&mut self.calls[call]) + } + + fn call_for_id( + &mut self, + id: &str, + index: Option, + ) -> Result { + let known_call = match index { + Some(index) => self + .calls + .iter() + .rposition(|call| call.id == id && call.index == Some(index)) + .or_else(|| { + // A new ID continues the call at its index only while that + // call is anonymous; a different ID means the index was + // reused for another parallel call. + let call = self.calls_by_index.get(&index).copied()?; + self.calls[call].id.is_empty().then_some(call) + }) + .or_else(|| { + self.calls + .iter() + .rposition(|call| call.id == id && call.index.is_none()) + }), + None => { + let matching_calls = self.calls.iter().filter(|call| call.id == id).count(); + if matching_calls > 1 { + return Err(AmbiguousToolCallChunk { + calls_in_flight: matching_calls, + }); + } + self.calls + .iter() + .position(|call| call.id == id) + .or_else(|| { + // Some providers send the ID only partway through a call. + (self.calls.len() == 1 && self.calls[0].id.is_empty()).then_some(0) + }) + } + }; + let call = known_call.unwrap_or_else(|| self.new_call()); + if self.calls[call].id.is_empty() { + self.calls[call].id = id.to_string(); + } + if let Some(index) = index { + self.calls[call].index = Some(index); + self.calls_by_index.insert(index, call); + } + Ok(call) + } + + fn call_for_index(&mut self, index: usize) -> usize { + match self.calls_by_index.get(&index) { + Some(&call) => call, + None => { + let call = self.new_call(); + self.calls[call].index = Some(index); + self.calls_by_index.insert(index, call); + call + } + } + } + + fn new_call(&mut self) -> usize { + self.calls.push(RawToolCall::default()); + self.calls.len() - 1 + } + + /// Removes and returns every call, in the order they appeared in the stream. + fn drain(&mut self) -> impl Iterator + '_ { + self.calls_by_index.clear(); + self.calls.drain(..) + } +} + +/// Maps a stream of Chat Completions chunks to [`LanguageModelCompletionEvent`]s. +/// +/// This is shared by every provider that speaks an OpenAI-compatible Chat +/// Completions dialect (OpenAI, OpenAI-compatible endpoints, OpenRouter, +/// LM Studio, llama.cpp, Bedrock/Mantle, and others), so its behavior must +/// stay provider-neutral: any provider-specific interpretation belongs in the +/// provider's own adapter before or after this mapping. +pub struct ChatCompletionEventMapper { + tool_calls: ToolCallAccumulator, + tool_call_accumulation_failed: bool, + reasoning_details: ReasoningDetailsAccumulator, +} + +impl ChatCompletionEventMapper { + pub fn new() -> Self { + Self { + tool_calls: ToolCallAccumulator::default(), + tool_call_accumulation_failed: false, + reasoning_details: ReasoningDetailsAccumulator::default(), + } + } + + pub fn map_stream( + mut self, + events: Pin>>>, + ) -> impl Stream> + where + E: Into, + { + events.flat_map(move |event| { + futures::stream::iter(match event { + Ok(event) => self.map_event(event), + Err(error) => vec![Err(error.into())], + }) + }) + } + + pub fn map_event( + &mut self, + event: ResponseStreamEvent, + ) -> Vec> { + let mut events = Vec::new(); + if let Some(token_usage) = event.usage.as_ref().and_then(|usage| usage.token_usage()) { + events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage))); + } + + let Some(choice) = event.choices.first() else { + return events; + }; + + if let Some(delta) = choice.delta.as_ref() { + if let Some(reasoning_details) = delta.reasoning_details.clone() + && let Some(reasoning_details) = self.reasoning_details.push(reasoning_details) + { + events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails( + reasoning_details, + ))); + } + if let Some(reasoning) = delta.reasoning.clone() { + push_thinking_event(reasoning, &mut events); + } + if let Some(reasoning_content) = delta.reasoning_content.clone() { + push_thinking_event(reasoning_content, &mut events); + } + if let Some(content) = delta.content.clone() { + if !content.is_empty() { + events.push(Ok(LanguageModelCompletionEvent::Text(content))); + } + } + + if !self.tool_call_accumulation_failed + && let Some(tool_calls) = delta.tool_calls.as_ref() + { + for tool_call in tool_calls { + let entry = match self.tool_calls.entry(tool_call) { + Ok(entry) => entry, + Err(error) => { + self.tool_call_accumulation_failed = true; + self.tool_calls = ToolCallAccumulator::default(); + events.push(Err(anyhow::Error::new(error).into())); + break; + } + }; + + if let Some(function) = tool_call.function.as_ref() { + if let Some(name) = function.name.clone() + && !name.is_empty() + { + entry.name = name; + } + + if let Some(arguments) = function.arguments.clone() { + entry.arguments.push_str(&arguments); + } + + if let Some(thought_signature) = function.thought_signature.clone() { + entry.thought_signature = Some(thought_signature); + } + } + + if !entry.id.is_empty() && !entry.name.is_empty() { + if let Ok(input) = + serde_json::from_str::(&fix_streamed_json(&entry.arguments)) + { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: entry.id.clone().into(), + name: entry.name.as_str().into(), + is_input_complete: false, + input: LanguageModelToolUseInput::Json(input), + raw_input: entry.arguments.clone(), + thought_signature: entry.thought_signature.clone(), + }, + ))); + } + } + } + } + } + + match choice.finish_reason.as_deref() { + Some("stop") => { + events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); + } + Some("tool_calls") => { + if !self.tool_call_accumulation_failed { + events.extend(self.tool_calls.drain().map( + |tool_call| match parse_tool_arguments(&tool_call.arguments) { + Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: tool_call.id.clone().into(), + name: tool_call.name.as_str().into(), + is_input_complete: true, + input: LanguageModelToolUseInput::Json(input), + raw_input: tool_call.arguments.clone(), + thought_signature: tool_call.thought_signature.clone(), + }, + )), + Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + id: tool_call.id.into(), + tool_name: tool_call.name.into(), + raw_input: tool_call.arguments.clone().into(), + json_parse_error: error.to_string(), + }), + }, + )); + } + + events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); + } + Some("length") => { + events.push(Ok(LanguageModelCompletionEvent::Stop( + StopReason::MaxTokens, + ))); + } + Some(stop_reason) => { + log::error!("Unexpected chat completion stop_reason: {stop_reason:?}",); + events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); + } + None => {} + } + + events + } +} + +fn push_thinking_event( + text: String, + events: &mut Vec>, +) { + if !text.is_empty() { + events.push(Ok(LanguageModelCompletionEvent::Thinking { + text, + signature: None, + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::LanguageModelToolUse; + use pretty_assertions::assert_eq; + use serde_json::json; + + fn parse(chunk: &str) -> ResponseStreamResult { + serde_json::from_str::(chunk).unwrap() + } + + fn expect_event(chunk: &str) -> ResponseStreamEvent { + match parse(chunk) { + ResponseStreamResult::Ok(event) => event, + ResponseStreamResult::Err { .. } => panic!("expected an event, got an error: {chunk}"), + } + } + + #[test] + fn parses_usage_only_chunk_with_null_prompt_cache_tokens() { + let event = expect_event( + r#"{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":null}}}"#, + ); + let details = event.usage.unwrap().prompt_tokens_details.unwrap(); + assert_eq!(details.cached_tokens, Some(0)); + assert_eq!(details.cache_write_tokens, None); + } + + #[test] + fn parses_chunk_without_choices() { + let event = expect_event(r#"{"usage":{"total_tokens":8}}"#); + assert!(event.choices.is_empty()); + assert_eq!(event.usage.unwrap().total_tokens, Some(8)); + } + + #[test] + fn parses_explicit_nulls_for_non_optional_fields() { + let event = expect_event( + r#"{"choices":[{"index":null,"delta":{"tool_calls":[{"index":null,"id":"call_1","function":null}]},"finish_reason":null}],"usage":null}"#, + ); + let choice = &event.choices[0]; + assert_eq!(choice.index, 0); + let tool_call = &choice.delta.as_ref().unwrap().tool_calls.as_ref().unwrap()[0]; + assert_eq!(tool_call.index, None); + assert_eq!(tool_call.id.as_deref(), Some("call_1")); + + let event = expect_event(r#"{"choices":null,"usage":{"total_tokens":8}}"#); + assert!(event.choices.is_empty()); + assert_eq!(event.usage.unwrap().total_tokens, Some(8)); + } + + #[test] + fn parses_empty_usage_object() { + let event = expect_event(r#"{"choices":[],"usage":{}}"#); + let usage = event.usage.unwrap(); + assert_eq!(usage.prompt_tokens, None); + assert_eq!(usage.total_tokens, None); + } + + #[test] + fn parses_tool_call_chunk_without_index() { + let event = expect_event( + r#"{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"edit_file","arguments":""}}]}}]}"#, + ); + let mut choices = event.choices; + let delta = choices.remove(0).delta.unwrap(); + let tool_call = delta.tool_calls.unwrap().remove(0); + assert_eq!(tool_call.index, None); + assert_eq!(tool_call.id.as_deref(), Some("call_1")); + assert_eq!( + tool_call.function.unwrap().name.as_deref(), + Some("edit_file") + ); + } + + // MiniMax has sent `index: -1` on every tool call chunk (#42584). + #[test] + fn parses_negative_tool_call_index_as_absent() { + let event = expect_event( + r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"index":-1,"id":"call_1","function":{"name":"edit_file","arguments":"{}"}}]}}]}"#, + ); + let tool_call = &event.choices[0] + .delta + .as_ref() + .unwrap() + .tool_calls + .as_ref() + .unwrap()[0]; + assert_eq!(tool_call.index, None); + assert_eq!(tool_call.id.as_deref(), Some("call_1")); + } + + #[test] + fn parses_error_envelope() { + match parse(r#"{"error":{"message":"quota exceeded"}}"#) { + ResponseStreamResult::Err { error } => assert_eq!(error.message, "quota exceeded"), + ResponseStreamResult::Ok(_) => panic!("expected an error"), + } + } + + #[test] + fn parses_custom_error_payload() { + #[derive(Deserialize)] + struct CustomError { + code: u16, + message: String, + } + + let chunk = r#"{"error":{"code":429,"message":"slow down"}}"#; + match serde_json::from_str::>(chunk).unwrap() { + ResponseStreamResult::Err { error } => { + assert_eq!(error.code, 429); + assert_eq!(error.message, "slow down"); + } + ResponseStreamResult::Ok(_) => panic!("expected an error"), + } + } + + #[test] + fn error_takes_precedence_over_event_payload() { + match parse(r#"{"error":{"message":"boom"},"choices":[{"delta":{"content":"hi"}}]}"#) { + ResponseStreamResult::Err { error } => assert_eq!(error.message, "boom"), + ResponseStreamResult::Ok(_) => panic!("expected an error"), + } + } + + #[test] + fn rejects_unrecognized_chunks() { + for chunk in [ + r#"{"detail":"Internal Server Error"}"#, + r#"{"object":"error","message":"engine overloaded","code":50302}"#, + r#""catastrophe""#, + ] { + let error = serde_json::from_str::(chunk).unwrap_err(); + assert!( + error.to_string().contains("unrecognized"), + "expected {chunk} to be rejected, got: {error}" + ); + } + } + + #[test] + fn null_error_field_is_not_an_error() { + let event = expect_event(r#"{"error":null,"choices":[{"delta":{"content":"hi"}}]}"#); + assert_eq!( + event.choices[0].delta.as_ref().unwrap().content.as_deref(), + Some("hi") + ); + } + + #[test] + fn token_usage_requires_both_totals() { + let usage = Usage { + prompt_tokens: Some(10), + completion_tokens: None, + ..Default::default() + }; + assert!(usage.token_usage().is_none()); + } + + #[test] + fn token_usage_splits_cache_tokens_out_of_prompt_tokens() { + let usage = Usage { + prompt_tokens: Some(12), + completion_tokens: Some(7), + total_tokens: Some(19), + prompt_tokens_details: Some(PromptTokensDetails { + cached_tokens: Some(5), + cache_write_tokens: Some(3), + }), + }; + let token_usage = usage.token_usage().unwrap(); + assert_eq!(token_usage.input_tokens, 4); + assert_eq!(token_usage.output_tokens, 7); + assert_eq!(token_usage.cache_creation_input_tokens, 3); + assert_eq!(token_usage.cache_read_input_tokens, 5); + } + + #[test] + fn reports_the_underlying_field_error() { + let error = serde_json::from_str::( + r#"{"choices":[{"index":0,"delta":{"content":42}}]}"#, + ) + .unwrap_err(); + let message = error.to_string(); + assert!( + !message.contains("did not match any variant"), + "expected a specific error, got: {message}" + ); + assert!( + message.contains("invalid type"), + "expected the underlying field error, got: {message}" + ); + } + + fn map_completion_events( + events: Vec, + ) -> Vec { + let mut mapper = ChatCompletionEventMapper::new(); + let mut all_events = Vec::new(); + for event in events { + all_events.extend(mapper.map_event(event)); + } + all_events.into_iter().filter_map(|e| e.ok()).collect() + } + + #[test] + fn stream_maps_reasoning() { + let events = map_completion_events(vec![ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + content: None, + reasoning: Some("thinking".into()), + tool_calls: None, + reasoning_content: None, + reasoning_details: None, + }), + finish_reason: None, + }], + usage: None, + }]); + + assert_eq!( + events, + vec![LanguageModelCompletionEvent::Thinking { + text: "thinking".into(), + signature: None, + }] + ); + } + + #[test] + fn stream_maps_length_finish_reason_to_max_tokens_stop() { + let events = map_completion_events(vec![ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: None, + finish_reason: Some("length".into()), + }], + usage: None, + }]); + + assert_eq!( + events, + vec![LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)] + ); + } + + #[test] + fn chunk_without_choices_or_usage_maps_to_no_events() { + let mut mapper = ChatCompletionEventMapper::new(); + assert!(mapper.map_event(ResponseStreamEvent::default()).is_empty()); + } + + #[test] + fn usage_update_precedes_text_and_stop_events_from_the_same_chunk() { + let mut mapper = ChatCompletionEventMapper::new(); + let events = mapper.map_event(ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + content: Some("Hello!".to_string()), + ..Default::default() + }), + finish_reason: Some("stop".to_string()), + }], + usage: Some(Usage { + prompt_tokens: Some(11), + completion_tokens: Some(7), + total_tokens: Some(18), + prompt_tokens_details: None, + }), + }); + + assert!(matches!( + events.as_slice(), + [ + Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 11, + output_tokens: 7, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + })), + Ok(LanguageModelCompletionEvent::Text(text)), + Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)), + ] if text == "Hello!" + )); + } + + #[test] + fn usage_update_precedes_tool_use_and_stop_events_from_the_same_chunk() { + let mut mapper = ChatCompletionEventMapper::new(); + let events = mapper.map_event(ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + tool_calls: Some(vec![ToolCallChunk { + index: Some(0), + id: Some("tool-call-id".to_string()), + function: Some(FunctionChunk { + name: Some("test_tool".to_string()), + arguments: Some(r#"{"value":1}"#.to_string()), + thought_signature: None, + }), + }]), + ..Default::default() + }), + finish_reason: Some("tool_calls".to_string()), + }], + usage: Some(Usage { + prompt_tokens: Some(13), + completion_tokens: Some(5), + total_tokens: Some(18), + prompt_tokens_details: None, + }), + }); + + assert!(matches!( + events.as_slice(), + [ + Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 13, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + })), + Ok(LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + is_input_complete: false, + .. + })), + Ok(LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id, + name, + is_input_complete: true, + .. + })), + Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)), + ] if id.to_string() == "tool-call-id" && name.as_ref() == "test_tool" + )); + } + + #[test] + fn stream_merges_reasoning_details_and_maps_compatible_usage_and_signatures() { + let response_events = serde_json::from_value(json!([ + { + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [{ + "id": "reasoning-1", + "index": 0, + "type": "reasoning.text", + "text": "first " + }], + "tool_calls": [{ + "index": 0, + "id": "call-1", + "function": { + "name": "search", + "arguments": "{", + "thought_signature": "signature" + } + }] + }, + "finish_reason": null + }], + "usage": null + }, + { + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [{ + "id": "reasoning-1", + "index": 0, + "text": "second" + }], + "tool_calls": [{ + "index": 0, + "function": { + "arguments": "}" + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": null + }, + { + "choices": [], + "usage": { + "prompt_tokens": 10000, + "completion_tokens": 500, + "total_tokens": 10500, + "prompt_tokens_details": { + "cached_tokens": 6000, + "cache_write_tokens": 1000 + } + } + } + ])) + .expect("valid compatible Chat Completions events"); + let events = map_completion_events(response_events); + + assert!(events.iter().any(|event| { + matches!( + event, + LanguageModelCompletionEvent::ReasoningDetails(details) + if details[0]["text"] == "first second" + ) + })); + assert!(events.iter().any(|event| { + matches!( + event, + LanguageModelCompletionEvent::ToolUse(tool_use) + if tool_use.is_input_complete + && tool_use.thought_signature.as_deref() == Some("signature") + ) + })); + assert!(events.iter().any(|event| { + matches!( + event, + LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 3_000, + output_tokens: 500, + cache_creation_input_tokens: 1_000, + cache_read_input_tokens: 6_000, + }) + ) + })); + } + + #[test] + fn reasoning_details_accumulator_replaces_an_incompatible_previous_shape() { + let mut accumulator = ReasoningDetailsAccumulator::default(); + assert_eq!( + accumulator.push(json!({"summary": "provider-defined"})), + Some(json!({"summary": "provider-defined"})) + ); + assert_eq!( + accumulator.push(json!([{"index": 0, "text": "reasoning"}])), + Some(json!([{"index": 0, "text": "reasoning"}])) + ); + } + + // OpenRouter sends an empty `reasoning_details` array in the finish chunk; + // it must not wipe out details accumulated from earlier chunks. + #[test] + fn reasoning_details_accumulator_ignores_null_and_empty_array_chunks() { + let mut accumulator = ReasoningDetailsAccumulator::default(); + assert!( + accumulator + .push(json!([{"index": 0, "type": "reasoning.text", "text": "thinking"}])) + .is_some() + ); + assert!( + accumulator + .push( + json!([{"index": 0, "type": "reasoning.encrypted", "data": "encrypted-blob"}]) + ) + .is_some() + ); + + assert_eq!(accumulator.push(json!([])), None); + assert_eq!(accumulator.push(serde_json::Value::Null), None); + + let details = accumulator + .push(json!([{"index": 0, "text": " more"}])) + .expect("accumulated reasoning details"); + assert_eq!(details[0]["text"], "thinking more"); + assert_eq!(details[0]["data"], "encrypted-blob"); + } + + #[test] + fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() { + // DashScope sends id="" and name="" in subsequent tool_calls delta + // chunks after the first chunk. ChatCompletionEventMapper must not overwrite + // the accumulated id and name with these empty strings. + + let events = vec![ + // First chunk: id and name are present + ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + content: None, + reasoning: None, + tool_calls: Some(vec![ToolCallChunk { + index: Some(0), + id: Some("call_dashscope_test".into()), + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some("".into()), + thought_signature: None, + }), + }]), + reasoning_content: None, + reasoning_details: None, + }), + finish_reason: None, + }], + usage: None, + }, + // Subsequent chunks: DashScope sends id="" and name="" + ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + content: None, + reasoning: None, + tool_calls: Some(vec![ToolCallChunk { + index: Some(0), + id: Some("".into()), + function: Some(FunctionChunk { + name: Some("".into()), + arguments: Some("{\"path\": \"".into()), + thought_signature: None, + }), + }]), + reasoning_content: None, + reasoning_details: None, + }), + finish_reason: None, + }], + usage: None, + }, + ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + content: None, + reasoning: None, + tool_calls: Some(vec![ToolCallChunk { + index: Some(0), + id: Some("".into()), + function: Some(FunctionChunk { + name: Some("".into()), + arguments: Some("blog-scraper\"}".into()), + thought_signature: None, + }), + }]), + reasoning_content: None, + reasoning_details: None, + }), + finish_reason: None, + }], + usage: None, + }, + // Final chunk: finish_reason = "tool_calls" + ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: None, + finish_reason: Some("tool_calls".into()), + }], + usage: None, + }, + ]; + + let mapped = map_completion_events(events); + + // Events emitted: + // 1. Partial ToolUse from chunk 1 (fix_json("") → "{}", parseable) + // 2. Partial ToolUse from chunk 3 (arguments fully assembled) + // 3. Complete ToolUse from finish_reason="tool_calls" drain + // 4. Stop(ToolUse) + assert_eq!(mapped.len(), 4); + + // Verify the complete ToolUse event (from finish_reason drain) + // has the correct id, name, and accumulated arguments. + let complete_tool_use = mapped.iter().find_map(|event| { + if let LanguageModelCompletionEvent::ToolUse(tool_use) = event { + if tool_use.is_input_complete { + return Some(tool_use); + } + } + None + }); + assert!( + complete_tool_use.is_some(), + "expected a completed ToolUse event" + ); + let tool_use = complete_tool_use.unwrap(); + assert_eq!( + tool_use.id.to_string(), + "call_dashscope_test", + "id must survive empty-string overwrites" + ); + assert_eq!( + tool_use.name.as_ref(), + "list_directory", + "name must survive empty-string overwrites" + ); + assert_eq!( + tool_use.raw_input, "{\"path\": \"blog-scraper\"}", + "arguments should accumulate across chunks" + ); + + // Verify the Stop event + assert!(mapped.iter().any(|event| { + matches!( + event, + LanguageModelCompletionEvent::Stop(StopReason::ToolUse) + ) + })); + } + + fn tool_call_chunk_event(tool_calls: Vec) -> ResponseStreamEvent { + ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + tool_calls: Some(tool_calls), + ..Default::default() + }), + finish_reason: None, + }], + usage: None, + } + } + + fn finish_tool_calls_event() -> ResponseStreamEvent { + ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: None, + finish_reason: Some("tool_calls".into()), + }], + usage: None, + } + } + + fn completed_tool_calls(events: &[LanguageModelCompletionEvent]) -> Vec<&LanguageModelToolUse> { + events + .iter() + .filter_map(|event| match event { + LanguageModelCompletionEvent::ToolUse(tool_use) if tool_use.is_input_complete => { + Some(tool_use) + } + _ => None, + }) + .collect() + } + + // Ollama has emitted parallel calls with distinct IDs that both use `index: 0`. + #[test] + fn separates_parallel_tool_calls_sharing_an_index() { + let mapped = map_completion_events(vec![ + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: Some("get_weather".into()), + arguments: Some(r#"{"city":"Berlin"}"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: Some("call_b".into()), + function: Some(FunctionChunk { + name: Some("get_weather".into()), + arguments: Some(r#"{"city":"Tokyo"}"#.into()), + thought_signature: None, + }), + }]), + finish_tool_calls_event(), + ]); + + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 2); + assert_eq!(tool_uses[0].id.to_string(), "call_a"); + assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#); + assert_eq!(tool_uses[1].id.to_string(), "call_b"); + assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#); + } + + #[test] + fn separates_parallel_tool_calls_sharing_an_id() { + let mapped = map_completion_events(vec![ + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: Some("get_weather".into()), + arguments: Some(r#"{"city":"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(1), + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: Some("get_weather".into()), + arguments: Some(r#"{"city":"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: None, + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""Berlin"}"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(1), + id: None, + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""Tokyo"}"#.into()), + thought_signature: None, + }), + }]), + finish_tool_calls_event(), + ]); + + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 2); + assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#); + assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#); + } + + #[test] + fn rejects_id_only_chunks_for_calls_sharing_an_id() { + let mut mapper = ChatCompletionEventMapper::new(); + for index in [0, 1] { + mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk { + index: Some(index), + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: Some("get_weather".into()), + arguments: Some("{".into()), + thought_signature: None, + }), + }])); + } + + let events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: None, + arguments: Some("}".into()), + thought_signature: None, + }), + }])); + + match events.as_slice() { + [Err(error)] => { + let message = error.to_string(); + assert!( + message.contains("2 tool calls in progress"), + "expected an attribution error, got: {message}" + ); + } + events => panic!("expected a single error event, got: {events:?}"), + } + } + + #[test] + fn continues_the_latest_call_at_a_reused_index() { + let mapped = map_completion_events(vec![ + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: Some("get_weather".into()), + arguments: Some(r#"{"city":"Berlin"}"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: Some("call_b".into()), + function: Some(FunctionChunk { + name: Some("get_weather".into()), + arguments: Some(r#"{"city":"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: None, + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""Tokyo"}"#.into()), + thought_signature: None, + }), + }]), + finish_tool_calls_event(), + ]); + + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 2); + assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#); + assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#); + } + + // MiniMax identifies calls by ID alone, without usable indices (#42584). + #[test] + fn accumulates_tool_calls_without_indices_by_id() { + let mapped = map_completion_events(vec![ + tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some(r#"{"path":"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""src"}"#.into()), + thought_signature: None, + }), + }]), + finish_tool_calls_event(), + ]); + + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 1); + assert_eq!(tool_uses[0].id.to_string(), "call_a"); + assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#); + } + + #[test] + fn continues_the_only_call_for_chunks_without_id_or_index() { + let mapped = map_completion_events(vec![ + tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some(r#"{"path":"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: None, + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""src"}"#.into()), + thought_signature: None, + }), + }]), + finish_tool_calls_event(), + ]); + + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 1); + assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#); + } + + #[test] + fn adopts_a_late_id_for_the_call_at_the_same_index() { + let mapped = map_completion_events(vec![ + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: None, + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some(r#"{"path":"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: Some(0), + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""src"}"#.into()), + thought_signature: None, + }), + }]), + finish_tool_calls_event(), + ]); + + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 1); + assert_eq!(tool_uses[0].id.to_string(), "call_a"); + assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#); + } + + #[test] + fn adopts_a_late_id_for_the_only_anonymous_call_without_an_index() { + let mapped = map_completion_events(vec![ + tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: None, + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some(r#"{"path":"#.into()), + thought_signature: None, + }), + }]), + tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""src"}"#.into()), + thought_signature: None, + }), + }]), + finish_tool_calls_event(), + ]); + + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 1); + assert_eq!(tool_uses[0].id.to_string(), "call_a"); + assert_eq!(tool_uses[0].raw_input, r#"{"path":"src"}"#); + } + + #[test] + fn rejects_unattributable_tool_call_chunks() { + let mut mapper = ChatCompletionEventMapper::new(); + for id in ["call_a", "call_b"] { + mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some(id.into()), + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some("{".into()), + thought_signature: None, + }), + }])); + } + + let events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: None, + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""path":"src"}"#.into()), + thought_signature: None, + }), + }])); + + match events.as_slice() { + [Err(error)] => { + let message = error.to_string(); + assert!( + message.contains("2 tool calls in progress"), + "expected an attribution error, got: {message}" + ); + } + events => panic!("expected a single error event, got: {events:?}"), + } + } + + #[test] + fn does_not_emit_tool_calls_after_an_attribution_error() { + let mut mapper = ChatCompletionEventMapper::new(); + for id in ["call_a", "call_b"] { + mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some(id.into()), + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some("{".into()), + thought_signature: None, + }), + }])); + } + + let error_events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: None, + function: Some(FunctionChunk { + name: None, + arguments: Some(r#""path":"src"}"#.into()), + thought_signature: None, + }), + }])); + assert!(matches!(error_events.as_slice(), [Err(_)])); + + let continuation_events = mapper.map_event(tool_call_chunk_event(vec![ToolCallChunk { + index: None, + id: Some("call_a".into()), + function: Some(FunctionChunk { + name: None, + arguments: Some("}".into()), + thought_signature: None, + }), + }])); + assert!(continuation_events.is_empty()); + + let finish_events = mapper.map_event(finish_tool_calls_event()); + + assert!(matches!( + finish_events.as_slice(), + [Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))] + )); + } + + // The `index: -1` stream shape from #42584, end to end. + #[test] + fn maps_parallel_tool_calls_with_negative_indices() { + let response_events = serde_json::from_value(json!([ + { + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [ + { + "index": -1, + "id": "call_a", + "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Berlin\"}"} + }, + { + "index": -1, + "id": "call_b", + "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}"} + } + ] + }, + "finish_reason": "tool_calls" + }] + } + ])) + .expect("valid compatible Chat Completions events"); + + let mapped = map_completion_events(response_events); + let tool_uses = completed_tool_calls(&mapped); + assert_eq!(tool_uses.len(), 2); + assert_eq!(tool_uses[0].id.to_string(), "call_a"); + assert_eq!(tool_uses[0].raw_input, r#"{"city":"Berlin"}"#); + assert_eq!(tool_uses[1].id.to_string(), "call_b"); + assert_eq!(tool_uses[1].raw_input, r#"{"city":"Tokyo"}"#); + } +} diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 470844f8f0..a91ba90121 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -1,3 +1,4 @@ +pub mod chat_completion; mod provider; mod rate_limiter; mod request; @@ -148,9 +149,18 @@ impl ProviderErrorCategory { StatusCode::BAD_REQUEST if is_invalid_encrypted_content_message(message) => { Self::InvalidEncryptedContent } - StatusCode::BAD_REQUEST if is_context_window_exceeded_message(message) => { - Self::PromptTooLarge { tokens: None } - } + // Anthropic reports a context-window overflow as HTTP 400 with a + // "prompt is too long" message rather than HTTP 413, so a + // bad request must be sniffed for both providers' phrasings. + StatusCode::BAD_REQUEST => match parse_prompt_too_long(message) { + Some(tokens) => Self::PromptTooLarge { + tokens: Some(tokens), + }, + None if is_context_window_exceeded_message(message) => { + Self::PromptTooLarge { tokens: None } + } + None => Self::InvalidRequest, + }, StatusCode::UNAUTHORIZED => Self::Authentication, StatusCode::FORBIDDEN => Self::Permission, StatusCode::NOT_FOUND => Self::EndpointNotFound, @@ -158,7 +168,6 @@ impl ProviderErrorCategory { StatusCode::PAYLOAD_TOO_LARGE => Self::PromptTooLarge { tokens: parse_prompt_too_long(message), }, - StatusCode::BAD_REQUEST => Self::InvalidRequest, StatusCode::CONFLICT => Self::Conflict, StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => Self::Timeout, StatusCode::TOO_MANY_REQUESTS => Self::RateLimit, @@ -360,6 +369,38 @@ impl LanguageModelCompletionError { } } + /// Whether retrying the same request after a delay may succeed. + pub fn is_transient(&self) -> bool { + match self { + Self::ProviderRejection { + provider, + status, + retry_after, + category, + .. + } => { + *category != ProviderErrorCategory::PaymentRequired + && (status.is_some_and(|status| is_retryable_provider_status(provider, status)) + || matches!( + category, + ProviderErrorCategory::RateLimit + | ProviderErrorCategory::Overloaded + | ProviderErrorCategory::Timeout + | ProviderErrorCategory::InternalServer + ) + || retry_after.is_some()) + } + Self::ApiReadResponseError { .. } | Self::HttpSend { .. } => true, + Self::DataRetentionConsentRequired { .. } + | Self::NoApiKey { .. } + | Self::SerializeRequest { .. } + | Self::BuildRequestBody { .. } + | Self::DeserializeResponse { .. } + | Self::StreamEndedUnexpectedly { .. } + | Self::Other(_) => false, + } + } + /// Returns the delay before a retry attempt, honoring a provider-supplied /// delay before falling back to exponential backoff from five to forty /// seconds. @@ -368,26 +409,12 @@ impl LanguageModelCompletionError { /// transient, and error kinds without shared retry semantics, return /// `None`. pub fn retry_delay(&self, attempt: usize) -> Option { - if attempt == 0 { + if attempt == 0 || !self.is_transient() { return None; } match self { - Self::ProviderRejection { - status, - retry_after, - category, - .. - } if status.is_some_and(is_retryable_provider_status) - || matches!( - category, - ProviderErrorCategory::RateLimit - | ProviderErrorCategory::Overloaded - | ProviderErrorCategory::Timeout - | ProviderErrorCategory::InternalServer - ) - || retry_after.is_some() => - { + Self::ProviderRejection { retry_after, .. } => { (*retry_after).or_else(|| exponential_backoff(attempt)) } Self::ApiReadResponseError { .. } | Self::HttpSend { .. } => { @@ -395,7 +422,6 @@ impl LanguageModelCompletionError { } Self::DataRetentionConsentRequired { .. } | Self::NoApiKey { .. } - | Self::ProviderRejection { .. } | Self::SerializeRequest { .. } | Self::BuildRequestBody { .. } | Self::DeserializeResponse { .. } @@ -437,8 +463,13 @@ fn category_from_cloud_failure(code: &str, message: &str) -> ProviderErrorCatego } } -fn is_retryable_provider_status(status: StatusCode) -> bool { - status.is_server_error() || matches!(status.as_u16(), 408 | 425 | 429) +fn is_retryable_provider_status(provider: &LanguageModelProviderName, status: StatusCode) -> bool { + // OpenAI sometimes returns 404 during service disruptions instead of a server-error status. + // Retrying adds only bounded delay and network traffic when the endpoint is genuinely absent, + // while potentially hiding a spurious failure from the user when the outage is transient. + status.is_server_error() + || matches!(status.as_u16(), 408 | 425 | 429) + || (provider == &OPEN_AI_PROVIDER_NAME && status == StatusCode::NOT_FOUND) } fn exponential_backoff(attempt: usize) -> Option { @@ -989,6 +1020,7 @@ mod tests { "Rate limit exceeded".to_string(), Some(retry_after), ); + assert!(error.is_transient()); assert_eq!(error.retry_delay(1), Some(retry_after)); let error = LanguageModelCompletionError::from_http_status( @@ -997,6 +1029,7 @@ mod tests { "Internal server error".to_string(), None, ); + assert!(error.is_transient()); assert_eq!(error.retry_delay(0), None); assert_eq!(error.retry_delay(1), Some(Duration::from_secs(5))); assert_eq!(error.retry_delay(2), Some(Duration::from_secs(10))); @@ -1019,10 +1052,44 @@ mod tests { category, ); + assert!(error.is_transient()); assert_eq!(error.retry_delay(1), Some(Duration::from_secs(5))); } } + #[test] + fn test_open_ai_not_found_is_transient() { + let error = LanguageModelCompletionError::from_http_status( + OPEN_AI_PROVIDER_NAME, + StatusCode::NOT_FOUND, + "Not found".to_string(), + None, + ); + + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + category: ProviderErrorCategory::EndpointNotFound, + .. + } + )); + assert!(error.is_transient()); + assert_eq!(error.retry_delay(1), Some(Duration::from_secs(5))); + } + + #[test] + fn test_not_found_is_permanent_for_other_providers() { + let error = LanguageModelCompletionError::from_http_status( + ANTHROPIC_PROVIDER_NAME, + StatusCode::NOT_FOUND, + "Not found".to_string(), + None, + ); + + assert!(!error.is_transient()); + assert_eq!(error.retry_delay(1), None); + } + #[test] fn test_retry_delay_rejects_permanent_provider_error() { let error = LanguageModelCompletionError::from_provider_response( @@ -1034,6 +1101,7 @@ mod tests { ProviderErrorCategory::Other, ); + assert!(!error.is_transient()); assert_eq!(error.retry_delay(1), None); } diff --git a/crates/language_model_core/src/provider.rs b/crates/language_model_core/src/provider.rs index b32d2592c8..317a0ab280 100644 --- a/crates/language_model_core/src/provider.rs +++ b/crates/language_model_core/src/provider.rs @@ -5,10 +5,6 @@ pub const ANTHROPIC_PROVIDER_ID: LanguageModelProviderId = pub const ANTHROPIC_PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Anthropic"); -pub const BASETEN_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("baseten"); -pub const BASETEN_PROVIDER_NAME: LanguageModelProviderName = - LanguageModelProviderName::new("Baseten"); - pub const OPEN_AI_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openai"); pub const OPEN_AI_PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenAI"); @@ -29,8 +25,6 @@ pub fn provider_name_for_id(provider_id: &LanguageModelProviderId) -> LanguageMo OPEN_AI_PROVIDER_NAME } else if provider_id == &ANTHROPIC_PROVIDER_ID { ANTHROPIC_PROVIDER_NAME - } else if provider_id == &BASETEN_PROVIDER_ID { - BASETEN_PROVIDER_NAME } else if provider_id == &GOOGLE_PROVIDER_ID { GOOGLE_PROVIDER_NAME } else if provider_id == &X_AI_PROVIDER_ID { diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index 68222eff96..0d9752e3e8 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -91,6 +91,14 @@ impl LanguageModelToolResult { pub fn is_content_empty(&self) -> bool { self.content.iter().all(|part| part.is_empty()) } + + /// Returns an iterator over all the images presents in the content parts. + pub fn images(&self) -> impl Iterator { + self.content.iter().filter_map(|part| match part { + LanguageModelToolResultContent::Image(image) => Some(image), + _ => None, + }) + } } /// Serde helper that accepts both the legacy single-value shape and the new @@ -461,6 +469,11 @@ pub enum CompletionIntent { #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct LanguageModelRequest { pub thread_id: Option, + /// Provider cache affinity, independent of conversation and transport session identity. + /// + /// Supporting providers fall back to `thread_id` when absent for legacy callers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, pub prompt_id: Option, pub intent: Option, pub messages: Vec, @@ -473,9 +486,23 @@ pub struct LanguageModelRequest { pub speed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub compact_at_tokens: Option, + /// An output-token ceiling, including reasoning tokens where the provider counts them. + /// + /// Providers must forward this limit (clamped to a known model maximum), or reject + /// capped requests when unsupported. `None` preserves the provider's default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, } impl LanguageModelRequest { + /// Combines the request's output limit with the model's output limit. + pub fn effective_max_output_tokens(&self, model_maximum: Option) -> Option { + match (self.max_output_tokens, model_maximum) { + (Some(request), Some(model)) => Some(request.min(model)), + (request, model) => request.or(model), + } + } + pub fn contains_custom_tool_input(&self) -> bool { self.tools .iter() @@ -523,6 +550,56 @@ pub struct LanguageModelResponseMessage { mod tests { use super::*; + #[test] + fn request_prompt_cache_key_serialization() -> serde_json::Result<()> { + let request = LanguageModelRequest { + thread_id: Some("thread".into()), + ..Default::default() + }; + let mut serialized = serde_json::to_value(&request)?; + assert!(serialized.get("prompt_cache_key").is_none()); + assert_eq!( + serde_json::from_value::(serialized.clone())?, + request + ); + + serialized["prompt_cache_key"] = serde_json::json!("cache-affinity"); + let explicit: LanguageModelRequest = serde_json::from_value(serialized.clone())?; + assert_eq!(explicit.prompt_cache_key.as_deref(), Some("cache-affinity")); + assert_eq!(explicit.thread_id, request.thread_id); + assert_eq!(serde_json::to_value(explicit)?, serialized); + + serialized["prompt_cache_key"] = serde_json::Value::Null; + assert_eq!( + serde_json::from_value::(serialized)?, + request + ); + Ok(()) + } + + #[test] + fn request_output_limit_serialization() -> serde_json::Result<()> { + let request = LanguageModelRequest::default(); + let mut serialized = serde_json::to_value(&request)?; + assert!(serialized.get("max_output_tokens").is_none()); + assert_eq!( + serde_json::from_value::(serialized.clone())?, + request + ); + + serialized["max_output_tokens"] = serde_json::json!(1024); + let capped: LanguageModelRequest = serde_json::from_value(serialized.clone())?; + assert_eq!(capped.max_output_tokens, Some(1024)); + assert_eq!(serde_json::to_value(capped)?, serialized); + + serialized["max_output_tokens"] = serde_json::Value::Null; + assert_eq!( + serde_json::from_value::(serialized)?, + request + ); + Ok(()) + } + #[test] fn test_language_model_tool_result_content_deserialization() { // Test plain string diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index d24f757f8e..ae6c7c5687 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -10,6 +10,10 @@ workspace = true [lib] path = "src/language_models.rs" +doctest = false + +[features] +test-support = [] [dependencies] ai_onboarding.workspace = true @@ -66,6 +70,7 @@ ui.workspace = true ui_input.workspace = true util.workspace = true x_ai = { workspace = true, features = ["schemars"] } +x_ai_subscribed.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index ce968e3d61..5d064f486c 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -31,6 +31,7 @@ use crate::provider::openai_subscribed::OpenAiSubscribedProvider; use crate::provider::opencode::OpenCodeLanguageModelProvider; use crate::provider::vercel_ai_gateway::VercelAiGatewayLanguageModelProvider; use crate::provider::x_ai::XAiLanguageModelProvider; +use crate::provider::x_ai_subscribed::XAiSubscribedProvider; pub use crate::settings::*; pub fn init(user_store: Entity, client: Arc, cx: &mut App) { @@ -324,6 +325,14 @@ fn register_language_model_providers( )), cx, ); + registry.register_provider( + Arc::new(XAiSubscribedProvider::new( + client.http_client(), + credentials_provider.clone(), + cx, + )), + cx, + ); registry.register_provider( Arc::new(OpenCodeLanguageModelProvider::new( client.http_client(), diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index ec3a1bb0c7..0e520c3691 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -22,6 +22,7 @@ pub mod opencode; pub mod vercel_ai_gateway; pub mod x_ai; +pub mod x_ai_subscribed; const COMMON_RESERVED_HEADER_NAMES: &[&str] = &["Authorization", "Content-Type", "Accept"]; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 73b374302f..e8d380b00f 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -467,6 +467,7 @@ mod tests { let model = direct_anthropic_test_model(&provider); assert!(model.supports_explicit_compaction()); + assert_eq!(model.max_total_tokens(), Some(model.max_token_count())); assert_eq!( model.minimum_explicit_compaction_input_tokens(), Some(anthropic::MIN_COMPACTION_TRIGGER_TOKENS) @@ -570,6 +571,7 @@ mod tests { store_key.await.unwrap(); let model = direct_anthropic_test_model(&provider); let request = LanguageModelRequest { + max_output_tokens: Some(8192), messages: vec![LanguageModelRequestMessage { role: language_model::Role::User, content: vec![MessageContent::Text("Retain this context.".to_string())], @@ -614,6 +616,7 @@ mod tests { .is_some_and(|header| header.contains(anthropic::COMPACTION_BETA_HEADER)) ); let body = serde_json::from_str::(&body).unwrap(); + assert_eq!(body["max_tokens"], 8192); assert_eq!( body["context_management"], json!({ @@ -798,6 +801,10 @@ impl LanguageModel for AnthropicModel { self.model.supports_compaction } + fn supports_explicit_compaction_output_limit(&self) -> bool { + self.supports_explicit_compaction() + } + fn minimum_explicit_compaction_input_tokens(&self) -> Option { self.supports_explicit_compaction() .then_some(anthropic::MIN_COMPACTION_TRIGGER_TOKENS) @@ -885,6 +892,51 @@ impl LanguageModel for AnthropicModel { Some(self.model.max_output_tokens) } + fn count_input_tokens( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture<'static, Result, LanguageModelCompletionError>> { + let request_id = self.model.request_id(!request.tools.is_empty()).to_string(); + let request = into_anthropic( + request, + request_id, + self.model.default_temperature, + self.model.max_output_tokens, + self.model.mode.clone(), + AnthropicPromptCacheMode::Automatic, + &PROVIDER_ID, + ); + let http_client = self.http_client.clone(); + let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| { + let api_url = AnthropicLanguageModelProvider::api_url(cx); + let extra_headers = AnthropicLanguageModelProvider::settings(cx) + .custom_headers + .clone(); + (state.api_key_state.key(&api_url), api_url, extra_headers) + }); + let beta_headers = self.model.beta_headers(); + self.request_limiter + .run(async move { + let request = request?.into_count_tokens_request(); + let api_key = api_key.ok_or(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + })?; + anthropic::count_input_tokens( + http_client.as_ref(), + &api_url, + &api_key, + request, + beta_headers, + &extra_headers, + ) + .await + .map(Some) + .map_err(Into::into) + }) + .boxed() + } + fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 5710b7ef92..092dcd1ae1 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -66,11 +66,14 @@ use util::ResultExt; use crate::AllLanguageModelSettings; use crate::provider::open_ai::{ - ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; +use language_model::chat_completion::{ + ChatCompletionEventMapper, ResponseStreamEvent, ResponseStreamResult, +}; use language_model::util::{fix_streamed_json, parse_tool_arguments}; -use open_ai::{ReasoningEffort, RequestError, ResponseStreamEvent}; +use open_ai::{ReasoningEffort, RequestError}; actions!(bedrock, [Tab, TabPrev]); @@ -1210,22 +1213,10 @@ async fn resolve_mantle_auth( } } -#[derive(Deserialize)] -#[serde(untagged)] -enum MantleChatStreamResult { - Ok(ResponseStreamEvent), - Err { error: MantleChatStreamError }, -} - -#[derive(Deserialize)] -struct MantleChatStreamError { - message: String, -} - fn parse_mantle_chat_stream_line(line: &str) -> Result { - match serde_json::from_str(line) { - Ok(MantleChatStreamResult::Ok(response)) => Ok(response), - Ok(MantleChatStreamResult::Err { error }) => Err(anyhow!(error.message)), + match serde_json::from_str::(line) { + Ok(ResponseStreamResult::Ok(response)) => Ok(response), + Ok(ResponseStreamResult::Err { error }) => Err(anyhow!(error.message)), Err(error) => { log::error!( "Failed to parse Mantle chat completion stream event: `{}`\nResponse: `{}`", @@ -1973,7 +1964,7 @@ impl LanguageModel for BedrockMantleModel { let completions = self.stream_completion(request, cx); let executor = cx.background_executor().clone(); async move { - let mapper = OpenAiEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(language_model::stream_in_background( mapper.map_stream(completions.await?).boxed(), executor, @@ -2013,6 +2004,9 @@ pub fn into_bedrock( guardrail_identifier: Option, guardrail_version: Option, ) -> Result { + let max_output_tokens = request + .max_output_tokens + .map_or(max_output_tokens, |limit| limit.min(max_output_tokens)); if request.contains_custom_tool_input() { anyhow::bail!("Bedrock does not support custom tools"); } @@ -2957,10 +2951,15 @@ mod tests { // Claude Opus 5 runs adaptive thinking by default when the `thinking` // field is omitted, so suppressing thinking requires an explicit // `disabled` opt-out. Earlier Claude models treat omission as "off". - for (model, expects_explicit_opt_out) in [ - ("us.anthropic.claude-opus-5", true), - ("global.anthropic.claude-opus-5", true), - ("us.anthropic.claude-opus-4-8", false), + for (model, expects_explicit_opt_out, output_limit, expected_output) in [ + ("us.anthropic.claude-opus-5", true, None, 128_000), + ("global.anthropic.claude-opus-5", true, Some(8192), 8192), + ( + "us.anthropic.claude-opus-4-8", + false, + Some(256_000), + 128_000, + ), ] { let request = into_bedrock( LanguageModelRequest { @@ -2971,6 +2970,7 @@ mod tests { reasoning_details: None, }], thinking_allowed: false, + max_output_tokens: output_limit, ..Default::default() }, model.to_string(), @@ -2986,6 +2986,7 @@ mod tests { ) .unwrap(); + assert_eq!(request.max_tokens, expected_output); if expects_explicit_opt_out { assert!( matches!(request.thinking, Some(bedrock::Thinking::Disabled)), diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 06131314ee..a577d8b43a 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -436,6 +436,24 @@ struct ZedAiConfiguration { sign_in_callback: Arc, } +#[cfg(any(test, feature = "test-support"))] +pub mod test_support { + use super::*; + + pub fn young_account_configuration() -> AnyElement { + ZedAiConfiguration { + is_connected: true, + plan: Some(Plan::ZedBusiness), + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + account_too_young: true, + compact: true, + sign_in_callback: Arc::new(|_, _| {}), + } + .into_any_element() + } +} + fn zed_ai_description( is_connected: bool, plan: Option, @@ -450,7 +468,9 @@ fn zed_ai_description( Some(Plan::ZedPro) => { "You have access to Zed's hosted models through your Pro subscription." } - Some(Plan::ZedProTrial) => "You have access to Zed's hosted models through your Pro trial.", + Some(Plan::ZedProTrial) => { + "Your Pro trial includes $5 of GPT Luna and unlimited edit predictions for 14 days from trial start." + } Some(Plan::ZedStudent) => { "You have access to Zed's hosted models through your Student subscription." } @@ -466,7 +486,7 @@ fn zed_ai_description( } Some(Plan::ZedFree) | None => { if eligible_for_trial { - "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." + "Start a free trial with $5 of GPT Luna and unlimited edit predictions for 14 days from trial start." } else { "Subscribe for access to Zed's hosted models." } @@ -498,7 +518,7 @@ impl RenderOnce for ZedAiConfiguration { .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) .into_any_element() } else if self.plan.is_none() || self.eligible_for_trial { - Button::new("start_trial", "Start 14-day Free Pro Trial") + Button::new("start_trial", "Start Free Trial") .when(!self.compact, |this| { this.full_width().label_size(LabelSize::Small) }) @@ -538,7 +558,10 @@ impl RenderOnce for ZedAiConfiguration { v_flex() .gap_2() - .when(!self.compact, |this| this.w_full()) + .debug_selector(|| "zed-ai-configuration".into()) + .when(!self.compact || self.account_too_young, |this| { + this.w_full() + }) .map(|this| { if self.account_too_young { this.child(YoungAccountBanner).child( diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index b2365fbbc6..64fe5fbd69 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -166,7 +166,7 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider { fn provided_models(&self, cx: &App) -> Vec> { let mut models = IndexMap::default(); - models.insert("deepseek-v4-flash", deepseek::Model::V4Flash); + models.insert("deepseek-flash", deepseek::Model::V4_1Flash); models.insert("deepseek-v4-pro", deepseek::Model::V4Pro); for available_model in &Self::settings(cx).available_models { @@ -284,7 +284,7 @@ impl LanguageModel for DeepSeekLanguageModel { fn supports_thinking(&self) -> bool { matches!( self.model, - deepseek::Model::V4Flash | deepseek::Model::V4Pro + deepseek::Model::V4_1Flash | deepseek::Model::V4Pro ) } @@ -317,7 +317,7 @@ impl LanguageModel for DeepSeekLanguageModel { } fn supports_images(&self) -> bool { - false + self.model.supports_images() } fn telemetry_id(&self) -> String { @@ -366,6 +366,7 @@ pub fn into_deepseek( model: &deepseek::Model, max_output_tokens: Option, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request.contains_custom_tool_input() { anyhow::bail!("DeepSeek does not support custom tools"); } @@ -375,6 +376,8 @@ pub fn into_deepseek( .as_ref() .is_some_and(|thinking| thinking.kind == deepseek::ThinkingType::Enabled); + let supports_images = model.supports_images(); + let mut messages = Vec::new(); let mut current_reasoning: Option = None; @@ -389,15 +392,24 @@ pub fn into_deepseek( }; if should_add { - messages.push(match message.role { - Role::User => deepseek::RequestMessage::User { content: text }, - Role::Assistant => deepseek::RequestMessage::Assistant { - content: Some(text), - tool_calls: Vec::new(), - reasoning_content: current_reasoning.take(), - }, - Role::System => deepseek::RequestMessage::System { content: text }, - }); + match message.role { + Role::User => { + add_user_message_content_part( + deepseek::MessagePart::Text { text }, + &mut messages, + ); + } + Role::Assistant => { + messages.push(deepseek::RequestMessage::Assistant { + content: Some(text), + tool_calls: Vec::new(), + reasoning_content: current_reasoning.take(), + }); + } + Role::System => { + messages.push(deepseek::RequestMessage::System { content: text }); + } + } } } MessageContent::Thinking { text, .. } => { @@ -405,6 +417,17 @@ pub fn into_deepseek( current_reasoning.get_or_insert_default().push_str(&text); } MessageContent::RedactedThinking(_) => {} + MessageContent::Image(image) if message.role == Role::User => { + add_user_message_content_part( + deepseek::MessagePart::Image { + image_url: deepseek::ImageUrl { + url: image.to_base64_url(), + detail: None, + }, + }, + &mut messages, + ); + } MessageContent::Image(_) => {} MessageContent::Compaction(_) => {} MessageContent::ToolUse(tool_use) => { @@ -435,24 +458,32 @@ pub fn into_deepseek( } } MessageContent::ToolResult(tool_result) => { - let mut text_parts: Vec = Vec::new(); - for part in &tool_result.content { - match part { + let content: Vec = tool_result + .content + .iter() + .filter_map(|part| match part { LanguageModelToolResultContent::Text(text) => { - text_parts.push(text.to_string()); + Some(deepseek::MessagePart::Text { + text: text.to_string(), + }) } - LanguageModelToolResultContent::Image(_) => { - text_parts.push("[Tool responded with an image]".to_string()); + LanguageModelToolResultContent::Image(image) => { + if supports_images { + Some(deepseek::MessagePart::Image { + image_url: deepseek::ImageUrl { + url: image.to_base64_url(), + detail: None, + }, + }) + } else { + None + } } - } - } - let content = if text_parts.is_empty() { - "".to_string() - } else { - text_parts.join("\n") - }; + }) + .collect(); + messages.push(deepseek::RequestMessage::Tool { - content, + content: content.into(), tool_call_id: tool_result.tool_use_id.to_string(), }); } @@ -507,12 +538,28 @@ pub fn into_deepseek( }) } +fn add_user_message_content_part( + new_part: deepseek::MessagePart, + messages: &mut Vec, +) { + match messages.last_mut() { + Some(deepseek::RequestMessage::User { content }) => { + content.push_part(new_part); + } + _ => { + messages.push(deepseek::RequestMessage::User { + content: deepseek::MessageContent::from(vec![new_part]), + }); + } + } +} + fn deepseek_thinking( model: &deepseek::Model, thinking_allowed: bool, ) -> Option { let kind = match model { - deepseek::Model::V4Flash | deepseek::Model::V4Pro => { + deepseek::Model::V4_1Flash | deepseek::Model::V4Pro => { if thinking_allowed { deepseek::ThinkingType::Enabled } else { @@ -666,3 +713,65 @@ impl DeepSeekEventMapper { events } } + +#[cfg(test)] +mod tests { + use super::*; + use language_model::{LanguageModelImage, LanguageModelRequestMessage}; + use serde_json::json; + + #[test] + fn serializes_deepseek_image_parts() -> Result<()> { + let image = LanguageModelImage { + source: SharedString::from("aGVsbG8="), + }; + let image_url = image.to_base64_url(); + let request = into_deepseek( + LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![ + MessageContent::Text("Describe this".to_string()), + MessageContent::Image(image), + ], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }, + &deepseek::Model::V4_1Flash, + Some(1024), + )?; + + assert_eq!( + serde_json::to_value(&request.messages)?, + json!([ + { + "role": "user", + "content": [ + { "type": "text", "text": "Describe this" }, + { "type": "image_url", "image_url": { "url": image_url } } + ] + } + ]) + ); + + Ok(()) + } + + #[test] + fn request_output_limits_reach_deepseek_payloads() -> Result<()> { + for (limit, expected) in [(None, 4096), (Some(1024), 1024), (Some(8192), 4096)] { + let request = into_deepseek( + LanguageModelRequest { + max_output_tokens: limit, + ..Default::default() + }, + &deepseek::Model::V4Pro, + Some(4096), + )?; + assert_eq!(serde_json::to_value(request)?["max_tokens"], expected); + } + Ok(()) + } +} diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index f7a50f8ee9..57e00f8d02 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -354,6 +354,11 @@ impl LanguageModel for GoogleLanguageModel { LanguageModelCompletionError, >, > { + let mut request = request; + if request.max_output_tokens.is_some() { + request.max_output_tokens = + request.effective_max_output_tokens(self.max_output_tokens()); + } let request = match into_google( request, self.model.request_id().to_string(), diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs index 297c1e4284..c21494ffc4 100644 --- a/crates/language_models/src/provider/llama_cpp.rs +++ b/crates/language_models/src/provider/llama_cpp.rs @@ -2,18 +2,16 @@ use anyhow::Result; use collections::{HashMap, HashSet}; use credentials_provider::CredentialsProvider; use fs::Fs; -use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; -use language_model::util::parse_tool_arguments; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView, - RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage, env_var, + LanguageModelToolResultContent, MessageContent, ProviderSettingsView, RateLimiter, Role, + SubPageProviderSettings, env_var, }; use llama_cpp::{ LLAMA_CPP_API_URL, ModelEntry, Props, get_models, get_props, stream_chat_completion, @@ -21,7 +19,6 @@ use llama_cpp::{ }; pub use settings::LlamaCppAvailableModel as AvailableModel; use settings::{Settings, SettingsStore, update_settings_file}; -use std::pin::Pin; use std::sync::LazyLock; use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; use std::time::Duration; @@ -32,6 +29,7 @@ use ui_input::InputField; use util::ResultExt; use crate::AllLanguageModelSettings; +use language_model::chat_completion::ChatCompletionEventMapper; const LLAMA_CPP_DOWNLOAD_URL: &str = "https://llama.app"; const LLAMA_CPP_MODELS_URL: &str = "https://huggingface.co/models?library=gguf&sort=trending"; @@ -855,8 +853,8 @@ fn build_llama_cpp_request( model: model_name.to_string(), messages, stream: true, - // Let the server decide the output length (its `n_predict` default). - max_tokens: None, + // Without an explicit limit, preserve the server's `n_predict` default. + max_tokens: request.max_output_tokens.map(i32::try_from).transpose()?, stop: if request.stop.is_empty() { None } else { @@ -942,147 +940,13 @@ impl LanguageModel for LlamaCppLanguageModel { }; let completions = self.stream_completion(request, cx); async move { - let mapper = LlamaCppEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(mapper.map_stream(completions.await?).boxed()) } .boxed() } } -struct LlamaCppEventMapper { - tool_calls_by_index: HashMap, -} - -impl LlamaCppEventMapper { - fn new() -> Self { - Self { - tool_calls_by_index: HashMap::default(), - } - } - - pub fn map_stream( - mut self, - events: Pin>>>, - ) -> impl Stream> - { - events.flat_map(move |event| { - futures::stream::iter(match event { - Ok(event) => self.map_event(event), - Err(error) => vec![Err(LanguageModelCompletionError::from(error))], - }) - }) - } - - pub fn map_event( - &mut self, - event: llama_cpp::ResponseStreamEvent, - ) -> Vec> { - let mut events = Vec::new(); - - if let Some(usage) = event.usage { - events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }))); - } - - if let Some(choice) = event.choices.into_iter().next() { - if let Some(reasoning_content) = choice.delta.reasoning_content { - events.push(Ok(LanguageModelCompletionEvent::Thinking { - text: reasoning_content, - signature: None, - })); - } - - if let Some(content) = choice.delta.content { - if !content.is_empty() { - events.push(Ok(LanguageModelCompletionEvent::Text(content))); - } - } - - if let Some(tool_calls) = choice.delta.tool_calls { - for tool_call in tool_calls { - let entry = self.tool_calls_by_index.entry(tool_call.index).or_default(); - - if let Some(tool_id) = tool_call.id { - entry.id = tool_id; - } - - if let Some(function) = tool_call.function { - if let Some(name) = function.name { - // Only the first chunk carries the function name; - // later chunks send an empty name with arguments. - if !name.is_empty() { - entry.name = name; - } - } - - if let Some(arguments) = function.arguments { - entry.arguments.push_str(&arguments); - } - } - } - } - - if let Some(finish_reason) = choice.finish_reason.as_deref() { - match finish_reason { - "stop" => { - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - "tool_calls" => { - events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match parse_tool_arguments(&tool_call.arguments) { - Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: tool_call.id.into(), - name: tool_call.name.into(), - is_input_complete: true, - input: language_model::LanguageModelToolUseInput::Json( - input, - ), - raw_input: tool_call.arguments, - thought_signature: None, - }, - )), - Err(error) => { - Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - id: tool_call.id.into(), - tool_name: tool_call.name.into(), - raw_input: tool_call.arguments.into(), - json_parse_error: error.to_string(), - }) - } - } - })); - - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); - } - "length" => { - events.push(Ok(LanguageModelCompletionEvent::Stop( - StopReason::MaxTokens, - ))); - } - unexpected => { - log::warn!("Unexpected llama.cpp finish_reason: {unexpected:?}"); - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - } - } - } - - events - } -} - -#[derive(Default)] -struct RawToolCall { - id: String, - name: String, - arguments: String, -} - fn add_message_content_part( new_part: llama_cpp::MessagePart, role: Role, @@ -1638,6 +1502,7 @@ mod tests { use super::*; use gpui::TestAppContext; use http_client::FakeHttpClient; + use language_model::LanguageModelToolUse; use parking_lot::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1837,6 +1702,7 @@ mod tests { supports_thinking: true, }, LanguageModelRequest { + max_output_tokens: Some(1024), messages: vec![language_model::LanguageModelRequestMessage { role: Role::Assistant, content: vec![ @@ -1854,6 +1720,7 @@ mod tests { ) .unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap()["max_tokens"], 1024); assert_eq!(request.messages.len(), 1); match &request.messages[0] { llama_cpp::ChatMessage::Assistant { @@ -1898,6 +1765,12 @@ mod tests { ) .unwrap(); + assert!( + serde_json::to_value(&request) + .unwrap() + .get("max_tokens") + .is_none() + ); assert_eq!(request.messages.len(), 1); match &request.messages[0] { llama_cpp::ChatMessage::Assistant { @@ -1964,90 +1837,6 @@ mod tests { } } - #[test] - fn usage_event_precedes_stop_event() { - let mut mapper = LlamaCppEventMapper::new(); - let events = mapper.map_event(llama_cpp::ResponseStreamEvent { - model: "test-model".to_string(), - object: "chat.completion.chunk".to_string(), - choices: vec![llama_cpp::ChoiceDelta { - index: 0, - delta: llama_cpp::ResponseMessageDelta { - content: None, - reasoning_content: None, - tool_calls: None, - }, - finish_reason: Some("stop".to_string()), - }], - usage: Some(llama_cpp::Usage { - prompt_tokens: 11, - completion_tokens: 7, - total_tokens: 18, - }), - }); - - assert!(matches!( - events.as_slice(), - [ - Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: 11, - output_tokens: 7, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - })), - Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)), - ] - )); - } - - #[test] - fn usage_event_precedes_tool_use_stop_event() { - let mut mapper = LlamaCppEventMapper::new(); - let events = mapper.map_event(llama_cpp::ResponseStreamEvent { - model: "test-model".to_string(), - object: "chat.completion.chunk".to_string(), - choices: vec![llama_cpp::ChoiceDelta { - index: 0, - delta: llama_cpp::ResponseMessageDelta { - content: None, - reasoning_content: None, - tool_calls: Some(vec![llama_cpp::ToolCallChunk { - index: 0, - id: Some("tool-call-id".to_string()), - function: Some(llama_cpp::FunctionChunk { - name: Some("test_tool".to_string()), - arguments: Some(r#"{"value":1}"#.to_string()), - }), - }]), - }, - finish_reason: Some("tool_calls".to_string()), - }], - usage: Some(llama_cpp::Usage { - prompt_tokens: 13, - completion_tokens: 5, - total_tokens: 18, - }), - }); - - assert!(matches!( - events.as_slice(), - [ - Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: 13, - output_tokens: 5, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - })), - Ok(LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { - id, - name, - .. - })), - Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)), - ] if id.to_string() == "tool-call-id" && name.as_ref() == "test_tool" - )); - } - #[gpui::test] async fn authenticate_fetches_models_after_loading_api_key(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 518b4a2615..310932cfc4 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -1,14 +1,13 @@ use anyhow::{Result, anyhow}; use credentials_provider::CredentialsProvider; use fs::Fs; -use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{App, AsyncApp, Context, Entity, Subscription, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolUse, MessageContent, StopReason, TokenUsage, env_var, + MessageContent, env_var, }; use language_model::{ InlineDescription, LanguageModelId, LanguageModelName, LanguageModelProvider, @@ -19,17 +18,13 @@ use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models}; pub use settings::LmStudioAvailableModel as AvailableModel; use settings::{Settings, SettingsStore, update_settings_file}; -use std::pin::Pin; use std::sync::LazyLock; -use std::{ - collections::{BTreeMap, HashMap}, - sync::Arc, -}; +use std::{collections::BTreeMap, sync::Arc}; use ui::{ButtonLike, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*}; use ui_input::InputField; use crate::AllLanguageModelSettings; -use language_model::util::parse_tool_arguments; +use language_model::chat_completion::ChatCompletionEventMapper; const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download"; const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models"; @@ -430,7 +425,13 @@ impl LmStudioLanguageModel { stream_options: Some(lmstudio::StreamOptions { include_usage: true, }), - max_tokens: Some(-1), + max_tokens: Some( + request + .max_output_tokens + .map(i32::try_from) + .transpose()? + .unwrap_or(-1), + ), stop: Some(request.stop), // In LM Studio you can configure specific settings you'd like to use for your model. // For example Qwen3 is recommended to be used with 0.7 temperature. @@ -559,278 +560,13 @@ impl LanguageModel for LmStudioLanguageModel { }; let completions = self.stream_completion(request, cx); async move { - let mapper = LmStudioEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(mapper.map_stream(completions.await?).boxed()) } .boxed() } } -struct LmStudioEventMapper { - tool_calls_by_index: HashMap, -} - -impl LmStudioEventMapper { - fn new() -> Self { - Self { - tool_calls_by_index: HashMap::default(), - } - } - - pub fn map_stream( - mut self, - events: Pin>>>, - ) -> impl Stream> - { - events.flat_map(move |event| { - futures::stream::iter(match event { - Ok(event) => self.map_event(event), - Err(error) => vec![Err(LanguageModelCompletionError::from(error))], - }) - }) - } - - pub fn map_event( - &mut self, - event: lmstudio::ResponseStreamEvent, - ) -> Vec> { - let mut events = Vec::new(); - - if let Some(usage) = event.usage { - events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }))); - } - - // The final usage summary chunk from OpenAI-compatible servers has an empty choices array. - // Return accumulated events instead of treating it as an error. - let Some(choice) = event.choices.into_iter().next() else { - return events; - }; - - if let Some(content) = choice.delta.content { - events.push(Ok(LanguageModelCompletionEvent::Text(content))); - } - - if let Some(reasoning_content) = choice.delta.reasoning_content { - events.push(Ok(LanguageModelCompletionEvent::Thinking { - text: reasoning_content, - signature: None, - })); - } - - if let Some(tool_calls) = choice.delta.tool_calls { - for tool_call in tool_calls { - let entry = self.tool_calls_by_index.entry(tool_call.index).or_default(); - - if let Some(tool_id) = tool_call.id { - entry.id = tool_id; - } - - if let Some(function) = tool_call.function { - if let Some(name) = function.name { - // At the time of writing this code LM Studio (0.3.15) is incompatible with the OpenAI API: - // 1. It sends function name in the first chunk - // 2. It sends empty string in the function name field in all subsequent chunks for arguments - // According to https://platform.openai.com/docs/guides/function-calling?api-mode=responses#streaming - // function name field should be sent only inside the first chunk. - if !name.is_empty() { - entry.name = name; - } - } - - if let Some(arguments) = function.arguments { - entry.arguments.push_str(&arguments); - } - } - } - } - - match choice.finish_reason.as_deref() { - Some("stop") => { - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - Some("tool_calls") => { - events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match parse_tool_arguments(&tool_call.arguments) { - Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: tool_call.id.into(), - name: tool_call.name.into(), - is_input_complete: true, - input: language_model::LanguageModelToolUseInput::Json(input), - raw_input: tool_call.arguments, - thought_signature: None, - }, - )), - Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - id: tool_call.id.into(), - tool_name: tool_call.name.into(), - raw_input: tool_call.arguments.into(), - json_parse_error: error.to_string(), - }), - } - })); - - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); - } - Some(stop_reason) => { - log::error!("Unexpected LMStudio stop_reason: {stop_reason:?}",); - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - None => {} - } - - events - } -} - -#[derive(Default)] -struct RawToolCall { - id: String, - name: String, - arguments: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use lmstudio::{ChoiceDelta, ResponseMessageDelta, ResponseStreamEvent, Usage}; - - fn make_event(choices: Vec, usage: Option) -> ResponseStreamEvent { - ResponseStreamEvent { - created: 0, - model: "test-model".to_string(), - object: "chat.completion.chunk".to_string(), - choices, - usage, - } - } - - fn make_content_choice(content: &str) -> ChoiceDelta { - ChoiceDelta { - index: 0, - delta: ResponseMessageDelta { - role: None, - content: Some(content.to_string()), - reasoning_content: None, - tool_calls: None, - }, - finish_reason: None, - } - } - - fn make_stop_choice() -> ChoiceDelta { - ChoiceDelta { - index: 0, - delta: ResponseMessageDelta { - role: None, - content: None, - reasoning_content: None, - tool_calls: None, - }, - finish_reason: Some("stop".to_string()), - } - } - - // OpenAI-compatible servers send a final chunk with usage data and an empty - // choices array. Before this fix, the mapper returned an error for empty - // choices, discarding usage entirely. - #[test] - fn test_usage_in_final_empty_choices_chunk() { - let mut mapper = LmStudioEventMapper::new(); - let event = make_event( - vec![], - Some(Usage { - prompt_tokens: 10, - completion_tokens: 20, - total_tokens: 30, - }), - ); - - let results: Vec<_> = mapper - .map_event(event) - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - assert_eq!( - results, - vec![LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: 10, - output_tokens: 20, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - })] - ); - } - - #[test] - fn test_empty_choices_without_usage_returns_empty() { - let mut mapper = LmStudioEventMapper::new(); - let event = make_event(vec![], None); - - let results = mapper.map_event(event); - - assert!(results.is_empty()); - } - - // Usage data can also arrive in a regular chunk that also contains content. - // Both events must be emitted, with UsageUpdate first. - #[test] - fn test_usage_emitted_alongside_content() { - let mut mapper = LmStudioEventMapper::new(); - let event = make_event( - vec![make_content_choice("Hello!")], - Some(Usage { - prompt_tokens: 5, - completion_tokens: 3, - total_tokens: 8, - }), - ); - - let results: Vec<_> = mapper - .map_event(event) - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - assert_eq!( - results[0], - LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: 5, - output_tokens: 3, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }) - ); - assert_eq!( - results[1], - LanguageModelCompletionEvent::Text("Hello!".to_string()) - ); - } - - #[test] - fn test_stop_event_emitted_on_finish_reason() { - let mut mapper = LmStudioEventMapper::new(); - let event = make_event(vec![make_stop_choice()], None); - - let results: Vec<_> = mapper - .map_event(event) - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - assert_eq!( - results, - vec![LanguageModelCompletionEvent::Stop(StopReason::EndTurn)] - ); - } -} - fn add_message_content_part( new_part: lmstudio::MessagePart, role: Role, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index d447cb12ee..78841f097c 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -367,6 +367,7 @@ pub fn into_mistral( model: mistral::Model, max_output_tokens: Option, ) -> Result<(mistral::Request, Option)> { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request.contains_custom_tool_input() { anyhow::bail!("Mistral does not support custom tools"); } @@ -850,7 +851,7 @@ mod tests { #[test] fn test_into_mistral_basic_conversion() { - let request = LanguageModelRequest { + let request = |max_output_tokens| LanguageModelRequest { messages: vec![ LanguageModelRequestMessage { role: Role::System, @@ -876,6 +877,7 @@ mod tests { tools: vec![], tool_choice: None, thread_id: Some("abcdef".into()), + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -883,16 +885,32 @@ mod tests { thinking_effort: None, speed: Default::default(), compact_at_tokens: None, + max_output_tokens, }; - let (mistral_request, affinity) = - into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap(); - - assert_eq!(mistral_request.model, "mistral-small-latest"); - assert_eq!(mistral_request.temperature, Some(0.5)); - assert_eq!(mistral_request.messages.len(), 2); - assert!(mistral_request.stream); - assert_eq!(affinity, Some("abcdef".into())); + for (requested, maximum, expected) in [ + (None, None, None), + (None, Some(4096), Some(4096)), + (Some(1024), Some(4096), Some(1024)), + (Some(8192), Some(4096), Some(4096)), + (Some(1024), None, Some(1024)), + ] { + let (mistral_request, affinity) = into_mistral( + request(requested), + mistral::Model::MistralSmallLatest, + maximum, + ) + .unwrap(); + assert_eq!(mistral_request.model, "mistral-small-latest"); + assert_eq!(mistral_request.temperature, Some(0.5)); + assert_eq!(mistral_request.messages.len(), 2); + assert!(mistral_request.stream); + assert_eq!(affinity, Some("abcdef".into())); + assert_eq!( + serde_json::to_value(mistral_request).unwrap()["max_tokens"].as_u64(), + expected + ); + } } #[test] @@ -908,6 +926,7 @@ mod tests { tools: vec![], tool_choice: None, thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -915,6 +934,7 @@ mod tests { thinking_effort: None, speed: Default::default(), compact_at_tokens: None, + max_output_tokens: None, }; let (mistral_request, _) = @@ -951,6 +971,7 @@ mod tests { tool_choice: None, temperature: None, thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, stop: vec![], @@ -958,6 +979,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let (mistral_request, _) = diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 9ef157f965..486111cadc 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -390,9 +390,18 @@ impl OllamaLanguageModel { { match tool_result { MessageContent::ToolResult(tool_result) => { + let images = tool_result + .images() + .map(|image| image.source.to_string()) + .collect::>(); messages.push(ChatMessage::Tool { tool_name: tool_result.tool_name.to_string(), content: tool_result.text_contents(), + images: if images.is_empty() { + None + } else { + Some(images) + }, }) } _ => unreachable!("Only tool result should be extracted"), @@ -465,6 +474,7 @@ impl OllamaLanguageModel { stream: true, options: Some(ChatOptions { num_ctx: Some(self.model.max_tokens), + num_predict: request.max_output_tokens.map(isize::try_from).transpose()?, // Only send stop tokens if explicitly provided. When empty/None, // Ollama will use the model's default stop tokens from its Modelfile. // Sending an empty array would override and disable the defaults. diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 8c123999af..a99342f725 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -25,9 +25,10 @@ use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::IconName; +use language_model::chat_completion::ChatCompletionEventMapper; use open_ai::completion::token_usage_from_response_usage; pub use open_ai::completion::{ - ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; @@ -509,6 +510,7 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointSixSol | Model::FivePointSixTerra | Model::FivePointSixLuna + | Model::SixAstra | Model::O3 => true, Model::Four => false, Model::Custom { @@ -608,6 +610,56 @@ impl LanguageModel for OpenAiLanguageModel { self.model.max_output_tokens() } + fn count_input_tokens( + &self, + mut request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture<'static, Result, LanguageModelCompletionError>> { + if !self.model.uses_responses_api() { + return async { Ok(None) }.boxed(); + } + normalize_open_ai_response_thinking_effort(&mut request, &self.model); + let request = into_open_ai_response( + request, + self.model.id(), + self.model.supports_parallel_tool_calls(), + self.model.supports_prompt_cache_key(), + self.max_output_tokens(), + default_thinking_reasoning_effort(&self.model), + self.model + .supported_reasoning_efforts() + .contains(&open_ai::ReasoningEffort::None), + &OPEN_AI_PROVIDER_ID, + ); + let http_client = self.http_client.clone(); + let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| { + let api_url = OpenAiLanguageModelProvider::api_url(cx); + let extra_headers = OpenAiLanguageModelProvider::settings(cx) + .custom_headers + .clone(); + (state.api_key_state.key(&api_url), api_url, extra_headers) + }); + self.request_limiter + .run(async move { + let request = request?.into_count_tokens_request(); + let api_key = api_key.ok_or(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + })?; + open_ai::responses::count_input_tokens( + http_client.as_ref(), + PROVIDER_NAME.0.as_str(), + &api_url, + &api_key, + request, + &extra_headers, + ) + .await + .map(Some) + .map_err(Into::into) + }) + .boxed() + } + fn stream_completion( &self, mut request: LanguageModelRequest, @@ -669,7 +721,7 @@ impl LanguageModel for OpenAiLanguageModel { let completions = self.stream_completion(request, cx); let executor = cx.background_executor().clone(); async move { - let mapper = OpenAiEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(stream_in_background( mapper.map_stream(completions.await?).boxed(), executor, diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 47a109b04d..090c348f16 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -3,6 +3,7 @@ use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{App, AppContext, AsyncApp, Entity, Task}; use http_client::{CustomHeaders, HttpClient}; +use language_model::chat_completion::ChatCompletionEventMapper; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, @@ -23,9 +24,7 @@ use crate::provider::api_compatible::{ ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, ApiCompatibleProviderState, }; -use crate::provider::open_ai::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, -}; +use crate::provider::open_ai::{OpenAiResponseEventMapper, into_open_ai, into_open_ai_response}; pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; @@ -436,7 +435,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { let completions = self.stream_completion(request, cx); let executor = cx.background_executor().clone(); async move { - let mapper = OpenAiEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(language_model::stream_in_background( mapper.map_stream(completions.await?).boxed(), executor, diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 381ff36bd1..9a11aedce5 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -1,30 +1,27 @@ use anyhow::Result; -use collections::HashMap; + use credentials_provider::CredentialsProvider; -use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; +use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; +use language_model::chat_completion::ChatCompletionEventMapper; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var, + LanguageModelToolChoice, LanguageModelToolResultContent, MessageContent, ProviderSettingsView, + RateLimiter, Role, env_var, }; -use open_ai::completion::ReasoningDetailsAccumulator; use open_router::{ Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ReasoningEffort, ResponseStreamEvent, list_models, }; use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore}; use sha2::{Digest as _, Sha256}; -use std::pin::Pin; use std::sync::{Arc, LazyLock}; use ui::IconName; -use language_model::util::{fix_streamed_json, parse_tool_arguments}; - const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter"); @@ -446,7 +443,7 @@ impl LanguageModel for OpenRouterLanguageModel { let executor = cx.background_executor().clone(); let future = self.request_limiter.stream(async move { let response = request.await?; - let events = OpenRouterEventMapper::new().map_stream(response); + let events = ChatCompletionEventMapper::new().map_stream(response); Ok(language_model::stream_in_background( events.boxed(), executor, @@ -461,6 +458,7 @@ pub fn into_open_router( model: &Model, max_output_tokens: Option, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request.contains_custom_tool_input() { anyhow::bail!("OpenRouter does not support custom tools"); } @@ -774,373 +772,10 @@ fn add_message_content_part( } } -pub struct OpenRouterEventMapper { - tool_calls_by_index: HashMap, - reasoning_details: ReasoningDetailsAccumulator, -} - -impl OpenRouterEventMapper { - pub fn new() -> Self { - Self { - tool_calls_by_index: HashMap::default(), - reasoning_details: ReasoningDetailsAccumulator::default(), - } - } - - pub fn map_stream( - mut self, - events: Pin< - Box< - dyn Send + Stream>, - >, - >, - ) -> impl Stream> - { - events.flat_map(move |event| { - futures::stream::iter(match event { - Ok(event) => self.map_event(event), - Err(error) => vec![Err(error.into())], - }) - }) - } - - pub fn map_event( - &mut self, - event: ResponseStreamEvent, - ) -> Vec> { - let mut events = Vec::new(); - - if let Some(usage) = event.usage { - let cache_creation_input_tokens = usage - .prompt_tokens_details - .as_ref() - .map_or(0, |details| details.cache_write_tokens); - let cache_read_input_tokens = usage - .prompt_tokens_details - .as_ref() - .map_or(0, |details| details.cached_tokens); - let input_tokens = usage.prompt_tokens.saturating_sub( - cache_creation_input_tokens.saturating_add(cache_read_input_tokens), - ); - - events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens, - output_tokens: usage.completion_tokens, - cache_creation_input_tokens, - cache_read_input_tokens, - }))); - } - - let Some(choice) = event.choices.first() else { - return events; - }; - - if let Some(details) = choice.delta.reasoning_details.clone() - && let Some(details) = self.reasoning_details.push(details) - { - events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails(details))); - } - - if let Some(reasoning) = choice.delta.reasoning.clone() { - events.push(Ok(LanguageModelCompletionEvent::Thinking { - text: reasoning, - signature: None, - })); - } - - if let Some(content) = choice.delta.content.clone() { - // OpenRouter send empty content string with the reasoning content - // This is a workaround for the OpenRouter API bug - if !content.is_empty() { - events.push(Ok(LanguageModelCompletionEvent::Text(content))); - } - } - - if let Some(tool_calls) = choice.delta.tool_calls.as_ref() { - for tool_call in tool_calls { - let entry = self.tool_calls_by_index.entry(tool_call.index).or_default(); - - if let Some(tool_id) = tool_call.id.clone() { - entry.id = tool_id; - } - - if let Some(function) = tool_call.function.as_ref() { - if let Some(name) = function.name.clone() { - entry.name = name; - } - - if let Some(arguments) = function.arguments.clone() { - entry.arguments.push_str(&arguments); - } - - if let Some(signature) = function.thought_signature.clone() { - entry.thought_signature = Some(signature); - } - } - - if !entry.id.is_empty() && !entry.name.is_empty() { - if let Ok(input) = serde_json::from_str::( - &fix_streamed_json(&entry.arguments), - ) { - events.push(Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: entry.id.clone().into(), - name: entry.name.as_str().into(), - is_input_complete: false, - input: language_model::LanguageModelToolUseInput::Json(input), - raw_input: entry.arguments.clone(), - thought_signature: entry.thought_signature.clone(), - }, - ))); - } - } - } - } - - match choice.finish_reason.as_deref() { - Some("stop") => { - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - Some("tool_calls") => { - events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match parse_tool_arguments(&tool_call.arguments) { - Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: tool_call.id.clone().into(), - name: tool_call.name.as_str().into(), - is_input_complete: true, - input: language_model::LanguageModelToolUseInput::Json(input), - raw_input: tool_call.arguments.clone(), - thought_signature: tool_call.thought_signature.clone(), - }, - )), - Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - id: tool_call.id.clone().into(), - tool_name: tool_call.name.as_str().into(), - raw_input: tool_call.arguments.clone().into(), - json_parse_error: error.to_string(), - }), - } - })); - - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); - } - Some(stop_reason) => { - log::error!("Unexpected OpenRouter stop_reason: {stop_reason:?}",); - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - None => {} - } - - events - } -} - -#[derive(Default)] -struct RawToolCall { - id: String, - name: String, - arguments: String, - thought_signature: Option, -} - #[cfg(test)] mod tests { use super::*; - use open_router::{ChoiceDelta, FunctionChunk, ResponseMessageDelta, ToolCallChunk}; - - #[gpui::test] - async fn test_reasoning_details_preservation_with_tool_calls() { - // This test verifies that reasoning_details are properly captured and preserved - // when a model uses tool calling with reasoning/thinking tokens. - // - // The key regression this prevents: - // - OpenRouter sends multiple reasoning_details updates during streaming - // - First with actual content (encrypted reasoning data) - // - Then with empty array on completion - // - We must NOT overwrite the real data with the empty array - - let mut mapper = OpenRouterEventMapper::new(); - - // Simulate the streaming events as they come from OpenRouter/Gemini - let events = vec![ - // Event 1: Initial reasoning details with text - ResponseStreamEvent { - id: Some("response_123".into()), - created: 1234567890, - model: "google/gemini-3.1-pro-preview".into(), - choices: vec![ChoiceDelta { - index: 0, - delta: ResponseMessageDelta { - role: None, - content: None, - reasoning: None, - tool_calls: None, - reasoning_details: Some(serde_json::json!([ - { - "type": "reasoning.text", - "text": "Let me analyze this request...", - "format": "google-gemini-v1", - "index": 0 - } - ])), - }, - finish_reason: None, - }], - usage: None, - }, - // Event 2: More reasoning details - ResponseStreamEvent { - id: Some("response_123".into()), - created: 1234567890, - model: "google/gemini-3.1-pro-preview".into(), - choices: vec![ChoiceDelta { - index: 0, - delta: ResponseMessageDelta { - role: None, - content: None, - reasoning: None, - tool_calls: None, - reasoning_details: Some(serde_json::json!([ - { - "type": "reasoning.encrypted", - "data": "EtgDCtUDAdHtim9OF5jm4aeZSBAtl/randomized123", - "format": "google-gemini-v1", - "index": 0, - "id": "tool_call_abc123" - } - ])), - }, - finish_reason: None, - }], - usage: None, - }, - // Event 3: Tool call starts - ResponseStreamEvent { - id: Some("response_123".into()), - created: 1234567890, - model: "google/gemini-3.1-pro-preview".into(), - choices: vec![ChoiceDelta { - index: 0, - delta: ResponseMessageDelta { - role: None, - content: None, - reasoning: None, - tool_calls: Some(vec![ToolCallChunk { - index: 0, - id: Some("tool_call_abc123".into()), - function: Some(FunctionChunk { - name: Some("list_directory".into()), - arguments: Some("{\"path\":\"test\"}".into()), - thought_signature: Some("sha256:test_signature_xyz789".into()), - }), - }]), - reasoning_details: None, - }, - finish_reason: None, - }], - usage: None, - }, - // Event 4: Empty reasoning_details on tool_calls finish - // This is the critical event - we must not overwrite with this empty array! - ResponseStreamEvent { - id: Some("response_123".into()), - created: 1234567890, - model: "google/gemini-3.1-pro-preview".into(), - choices: vec![ChoiceDelta { - index: 0, - delta: ResponseMessageDelta { - role: None, - content: None, - reasoning: None, - tool_calls: None, - reasoning_details: Some(serde_json::json!([])), - }, - finish_reason: Some("tool_calls".into()), - }], - usage: None, - }, - ]; - - // Process all events - let mut collected_events = Vec::new(); - for event in events { - let mapped = mapper.map_event(event); - collected_events.extend(mapped); - } - - // Verify we got the expected events - let mut has_tool_use = false; - let mut reasoning_details_events = Vec::new(); - let mut thought_signature_value = None; - - for event_result in collected_events { - match event_result { - Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { - has_tool_use = true; - assert_eq!(tool_use.id.to_string(), "tool_call_abc123"); - assert_eq!(tool_use.name.as_ref(), "list_directory"); - thought_signature_value = tool_use.thought_signature.clone(); - } - Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => { - reasoning_details_events.push(details); - } - _ => {} - } - } - - assert!(has_tool_use, "Should have emitted ToolUse event"); - assert_eq!(reasoning_details_events.len(), 2); - let final_details = reasoning_details_events - .last() - .and_then(serde_json::Value::as_array) - .and_then(|details| details.first()) - .expect("accumulated reasoning details"); - assert_eq!(final_details["text"], "Let me analyze this request..."); - assert_eq!( - final_details["data"], - "EtgDCtUDAdHtim9OF5jm4aeZSBAtl/randomized123" - ); - assert_eq!( - thought_signature_value.as_deref(), - Some("sha256:test_signature_xyz789") - ); - } - - #[gpui::test] - async fn test_usage_only_chunk_with_empty_choices_does_not_error() { - let mut mapper = OpenRouterEventMapper::new(); - - let events = mapper.map_event(ResponseStreamEvent { - id: Some("response_123".into()), - created: 1234567890, - model: "google/gemini-3-flash-preview".into(), - choices: Vec::new(), - usage: Some(open_router::Usage { - prompt_tokens: 12, - completion_tokens: 7, - total_tokens: 19, - prompt_tokens_details: Some(open_router::PromptTokensDetails { - cached_tokens: 5, - cache_write_tokens: 3, - }), - }), - }); - - assert_eq!(events.len(), 1); - match events.into_iter().next() { - Some(Ok(LanguageModelCompletionEvent::UsageUpdate(usage))) => { - assert_eq!(usage.input_tokens, 4); - assert_eq!(usage.output_tokens, 7); - assert_eq!(usage.cache_creation_input_tokens, 3); - assert_eq!(usage.cache_read_input_tokens, 5); - assert_eq!(usage.total_tokens(), 19); - } - other => panic!("Expected usage update event, got: {other:?}"), - } - } - #[gpui::test] async fn test_session_id_is_stable_without_exposing_thread_id() { let model = open_router::Model::new( @@ -1156,8 +791,9 @@ mod tests { None, ); let thread_id = "internal-thread-id"; - let request = LanguageModelRequest { + let request = |max_output_tokens| LanguageModelRequest { thread_id: Some(thread_id.to_string()), + max_output_tokens, messages: vec![language_model::LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::Text("Hello".to_string())], @@ -1167,17 +803,27 @@ mod tests { ..Default::default() }; - let result = into_open_router(request, &model, None).unwrap(); - - assert_eq!( - result.session_id, - open_router_session_id(Some(thread_id.into())) - ); - assert_ne!(result.session_id.as_deref(), Some(thread_id)); - assert_ne!( - result.session_id, - open_router_session_id(Some("another-thread-id".into())) - ); + for (requested, maximum, expected) in [ + (None, None, None), + (None, Some(4096), Some(4096)), + (Some(1024), Some(4096), Some(1024)), + (Some(8192), Some(4096), Some(4096)), + ] { + let result = into_open_router(request(requested), &model, maximum).unwrap(); + assert_eq!( + result.session_id, + open_router_session_id(Some(thread_id.into())) + ); + assert_ne!(result.session_id.as_deref(), Some(thread_id)); + assert_ne!( + result.session_id, + open_router_session_id(Some("another-thread-id".into())) + ); + assert_eq!( + serde_json::to_value(result).unwrap()["max_tokens"].as_u64(), + expected + ); + } } #[gpui::test] @@ -1230,9 +876,11 @@ mod tests { thinking_effort: None, speed: None, thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_router(request, &model, None).unwrap(); @@ -1372,9 +1020,11 @@ mod tests { thinking_effort: None, speed: None, thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_router(request, &model, None).unwrap(); @@ -1438,9 +1088,11 @@ mod tests { thinking_effort: None, speed: None, thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_router(request, &model, None).unwrap(); diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 024489457f..bac7831a6d 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -211,8 +211,10 @@ impl Render for ConfigurationView { let last_auth_error = state.last_auth_error(); let provider_state = self.state.clone(); + let cancel_provider_state = self.state.clone(); let is_signing_in = state.is_signing_in(); + let is_sign_in_cancellable = state.is_sign_in_cancellable(); let button_label = if is_signing_in { "Signing in…" } else { @@ -225,14 +227,29 @@ impl Render for ConfigurationView { this.child(Label::new(SUBSCRIPTION_DESCRIPTION)) }) .child( - Button::new("sign-in", button_label) - .when(!self.compact, |this| this.full_width()) - .style(ButtonStyle::Outlined) - .size(ButtonSize::Medium) - .loading(is_signing_in) - .disabled(is_signing_in) - .on_click(move |_, _window, cx| { - provider_state.update(cx, |state, cx| state.sign_in(cx)); + h_flex() + .gap_2() + .child( + Button::new("sign-in", button_label) + .when(!self.compact, |this| this.full_width()) + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .loading(is_signing_in) + .disabled(is_signing_in) + .on_click(move |_, _window, cx| { + provider_state.update(cx, |state, cx| state.sign_in(cx)); + }), + ) + .when(is_sign_in_cancellable, |this| { + this.child( + Button::new("cancel-sign-in", "Cancel") + .style(ButtonStyle::Subtle) + .size(ButtonSize::Medium) + .on_click(move |_, _window, cx| { + cancel_provider_state + .update(cx, |state, cx| state.cancel_sign_in(cx)); + }), + ) }), ) .when_some(last_auth_error, |this, error| { diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index ec0c5a0a90..94b0244101 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -29,9 +29,10 @@ use util::ResultExt; use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic}; use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{ - ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; +use language_model::chat_completion::{ChatCompletionEventMapper, ResponseStreamEvent}; fn normalize_reasoning_effort(effort: &str) -> Option { match effort.trim().to_ascii_lowercase().as_str() { @@ -63,7 +64,8 @@ const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new( const API_KEY_ENV_VAR_NAME: &str = "OPENCODE_API_KEY"; static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); -pub(crate) const RESERVED_HEADER_NAMES: &[&str] = &["x-opencode-session"]; +const OPENCODE_SESSION_HEADER_NAME: &str = "x-opencode-session"; +pub(crate) const RESERVED_HEADER_NAMES: &[&str] = &[OPENCODE_SESSION_HEADER_NAME]; #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenCodeSettings { @@ -325,9 +327,11 @@ impl HttpClient for InjectHeaderClient { fn user_agent(&self) -> Option<&http::HeaderValue> { self.inner.user_agent() } + fn proxy(&self) -> Option<&http_client::Url> { self.inner.proxy() } + fn send( &self, mut req: http::Request, @@ -338,6 +342,15 @@ impl HttpClient for InjectHeaderClient { } } +// Standalone requests do not have a conversation ID, but OpenCode requires a +// non-empty session header. +fn opencode_session_header_value(thread_id: Option<&str>) -> http::HeaderValue { + thread_id + .filter(|thread_id| !thread_id.is_empty()) + .and_then(|thread_id| http::HeaderValue::from_str(thread_id).ok()) + .unwrap_or_else(|| http::HeaderValue::from(rand::random::())) +} + impl OpenCodeLanguageModel { fn base_api_url(&self, cx: &AsyncApp) -> SharedString { // Custom models can override the API URL @@ -423,10 +436,8 @@ impl OpenCodeLanguageModel { http_client: Arc, extra_headers: CustomHeaders, cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result>>, - > { + ) -> BoxFuture<'static, Result>>> + { // OpenAI crate appends /chat/completions to api_url, so we pass base + "/v1" let base_url = self.base_api_url(cx); let api_url: SharedString = format!("{base_url}/v1").into(); @@ -634,17 +645,11 @@ impl LanguageModel for OpenCodeLanguageModel { LanguageModelCompletionError, >, > { - let http_client = if let Some(ref thread_id) = request.thread_id - && let Ok(value) = http::HeaderValue::from_str(thread_id) - { - Arc::new(InjectHeaderClient { - inner: self.http_client.clone(), - name: http::HeaderName::from_static("x-opencode-session"), - value, - }) - } else { - self.http_client.clone() - }; + let http_client: Arc = Arc::new(InjectHeaderClient { + inner: self.http_client.clone(), + name: http::HeaderName::from_static(OPENCODE_SESSION_HEADER_NAME), + value: opencode_session_header_value(request.thread_id.as_deref()), + }); let extra_headers = self.custom_headers(cx); match self.model.protocol(self.subscription) { @@ -706,7 +711,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.stream_openai_chat(openai_request, http_client, extra_headers, cx); let executor = cx.background_executor().clone(); async move { - let mapper = OpenAiEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(language_model::stream_in_background( mapper.map_stream(stream.await?).boxed(), executor, @@ -745,6 +750,11 @@ impl LanguageModel for OpenCodeLanguageModel { .boxed() } ApiProtocol::Google => { + let mut request = request; + if request.max_output_tokens.is_some() { + request.max_output_tokens = + request.effective_max_output_tokens(self.max_output_tokens()); + } let mode = if self.supports_thinking() && request.thinking_allowed { google_ai::GoogleModelMode::Thinking { budget_tokens: None, @@ -985,3 +995,133 @@ impl Render for ConfigurationView { } } } + +#[cfg(test)] +mod tests { + use super::*; + use http_client::{FakeHttpClient, Response}; + use language_model::{LanguageModelRequestMessage, MessageContent, Role}; + use parking_lot::Mutex; + + #[test] + fn test_opencode_session_header_uses_thread_id() { + let value = opencode_session_header_value(Some("thread-123")); + + assert_eq!(value, "thread-123"); + } + + #[test] + fn test_opencode_session_header_without_thread_id() { + let value = opencode_session_header_value(None); + + assert_generated_session_id(&value); + } + + #[test] + fn test_opencode_session_header_with_empty_thread_id() { + let value = opencode_session_header_value(Some("")); + + assert_generated_session_id(&value); + } + + #[test] + fn test_opencode_session_header_with_invalid_thread_id() { + let value = opencode_session_header_value(Some("thread\n123")); + + assert_generated_session_id(&value); + } + + #[gpui::test] + async fn test_stream_completion_sends_session_header_without_thread_id( + cx: &mut gpui::TestAppContext, + ) { + let captured_header = Arc::new(Mutex::new(None)); + let http_client = FakeHttpClient::create({ + let captured_header = captured_header.clone(); + move |request| { + let captured_header = captured_header.clone(); + async move { + *captured_header.lock() = + Some(request.headers().get(OPENCODE_SESSION_HEADER_NAME).cloned()); + Ok(Response::builder().status(200).body(AsyncBody::from( + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ))?) + } + } + }); + let provider = cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + OpenCodeLanguageModelProvider::new(http_client, Arc::new(TestCredentialsProvider), cx) + }); + let store_key = provider.state.update(cx, |state, cx| { + state.set_api_key(Some("test-key".to_string()), cx) + }); + store_key.await.unwrap(); + let model = + provider.create_language_model(opencode::Model::default(), OpenCodeSubscription::Go); + let request = LanguageModelRequest { + thread_id: None, + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }; + + let stream = model + .stream_completion(request, &cx.to_async()) + .await + .unwrap(); + drop(stream); + + let captured_header = captured_header + .lock() + .take() + .expect("request should reach the http client") + .expect("request should carry the session header"); + assert_generated_session_id(&captured_header); + } + + fn assert_generated_session_id(value: &http::HeaderValue) { + value + .to_str() + .unwrap() + .parse::() + .expect("generated session id should be a u64"); + } + + struct TestCredentialsProvider; + + impl CredentialsProvider for TestCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> std::pin::Pin< + Box)>>> + 'a>, + > { + Box::pin(async { Ok(None) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + _username: &'a str, + _password: &'a [u8], + _cx: &'a AsyncApp, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + } +} diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index ede1e45a34..b22c7d3b4a 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -6,6 +6,7 @@ use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http, }; +use language_model::chat_completion::{ChatCompletionEventMapper, ResponseStreamEvent}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, @@ -13,7 +14,6 @@ use language_model::{ LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, ProviderSettingsView, RateLimiter, env_var, }; -use open_ai::ResponseStreamEvent; use serde::Deserialize; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; pub use settings::VercelAiGatewayAvailableModel as AvailableModel; @@ -463,7 +463,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel { let completions = self.stream_open_ai(request, cx); let executor = cx.background_executor().clone(); async move { - let mapper = crate::provider::open_ai::OpenAiEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(language_model::stream_in_background( mapper.map_stream(completions.await?).boxed(), executor, diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 711f71afa0..8bc9e8562d 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -4,6 +4,7 @@ use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; +use language_model::chat_completion::{ChatCompletionEventMapper, ResponseStreamEvent}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, @@ -11,7 +12,6 @@ use language_model::{ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, ProviderSettingsView, RateLimiter, env_var, }; -use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; @@ -430,7 +430,7 @@ impl LanguageModel for XAiLanguageModel { let completions = self.stream_completion(request, cx); let executor = cx.background_executor().clone(); async move { - let mapper = crate::provider::open_ai::OpenAiEventMapper::new(); + let mapper = ChatCompletionEventMapper::new(); Ok(language_model::stream_in_background( mapper.map_stream(completions.await?).boxed(), executor, diff --git a/crates/language_models/src/provider/x_ai_subscribed.rs b/crates/language_models/src/provider/x_ai_subscribed.rs new file mode 100644 index 0000000000..5d60000d2c --- /dev/null +++ b/crates/language_models/src/provider/x_ai_subscribed.rs @@ -0,0 +1,310 @@ +use anyhow::{Result, anyhow}; +use credentials_provider::CredentialsProvider; +use futures::future::Shared; +use gpui::{App, Context, Entity, SharedString, Task, Window}; +use http_client::HttpClient; +use language_model::{ + AuthenticateError, IconOrSvg, InlineDescription, LanguageModel, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + ProviderSettingsView, +}; +use std::sync::Arc; +use ui::{ConfiguredApiCard, prelude::*}; +use x_ai_subscribed::{PROVIDER_ID, PROVIDER_NAME, State, SuperGrokModel, create_language_model}; + +const SUBSCRIPTION_DESCRIPTION: &str = + "Sign in with your SuperGrok subscription to use Grok models in Zed's agent."; + +pub struct XAiSubscribedProvider { + state: Entity, +} + +impl XAiSubscribedProvider { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { + let state = cx.new(|cx| State::new(http_client, credentials_provider, cx)); + Self { state } + } + + fn create_model(&self, model: SuperGrokModel, cx: &App) -> Arc { + create_language_model(model, &self.state, cx) + } +} + +impl LanguageModelProviderState for XAiSubscribedProvider { + type ObservableEntity = State; + + fn observable_entity(&self) -> Option> { + Some(self.state.clone()) + } +} + +impl LanguageModelProvider for XAiSubscribedProvider { + fn id(&self) -> LanguageModelProviderId { + PROVIDER_ID + } + + fn name(&self) -> LanguageModelProviderName { + PROVIDER_NAME + } + + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiXAi) + } + + fn default_model(&self, cx: &App) -> Option> { + Some(self.create_model(SuperGrokModel::Grok46, cx)) + } + + fn default_fast_model(&self, cx: &App) -> Option> { + Some(self.create_model(SuperGrokModel::GrokBuild01, cx)) + } + + fn provided_models(&self, cx: &App) -> Vec> { + if !self.is_authenticated(cx) { + return Vec::new(); + } + SuperGrokModel::all() + .into_iter() + .map(|model| self.create_model(model, cx)) + .collect() + } + + fn recommended_models(&self, cx: &App) -> Vec> { + self.default_model(cx).into_iter().collect() + } + + fn is_authenticated(&self, cx: &App) -> bool { + self.state.read(cx).is_authenticated() + } + + fn authenticate(&self, cx: &mut App) -> Task> { + if self.is_authenticated(cx) { + return Task::ready(Ok(())); + } + let load_task: Option> = self.state.read(cx).load_task(); + if let Some(load_task) = load_task { + let weak_state = self.state.downgrade(); + cx.spawn(async move |cx| { + load_task + .await + .map_err(|error| AuthenticateError::Other(anyhow!("{error:#}")))?; + let is_auth = weak_state + .read_with(&*cx, |state, _| state.is_authenticated()) + .map_err(AuthenticateError::Other)?; + if is_auth { + Ok(()) + } else { + Err(AuthenticateError::CredentialsNotFound) + } + }) + } else { + Task::ready(Err(AuthenticateError::CredentialsNotFound)) + } + } + + fn settings_view(&self, cx: &mut App) -> Option { + let is_authenticated = self.state.read(cx).is_authenticated(); + let title = if is_authenticated { + None + } else { + Some("Configure SuperGrok".into()) + }; + let description = if is_authenticated { + None + } else { + Some(InlineDescription::Text(SUBSCRIPTION_DESCRIPTION.into())) + }; + + Some(ProviderSettingsView::Inline( + language_model::InlineProviderSettings { + title, + description, + create_view: Arc::new({ + let state = self.state.clone(); + move |_window, cx| { + cx.new(|_cx| ConfigurationView { + state: state.clone(), + compact: true, + }) + .into() + } + }), + }, + )) + } + + fn authentication_error_message(&self) -> SharedString { + "Your SuperGrok session is invalid or has expired. \ + Sign in again via Settings > AI > LLM Providers to continue." + .into() + } + + fn missing_credentials_error_message(&self) -> SharedString { + "You are not signed in to SuperGrok. \ + Sign in via Settings > AI > LLM Providers to continue." + .into() + } +} + +struct ConfigurationView { + state: Entity, + /// When `true`, the description is rendered elsewhere (the settings row's + /// left column), so it's omitted here to avoid duplication. + compact: bool, +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = self.state.read(cx); + + if state.is_authenticated() { + let label = state + .email() + .map(|email| format!("Signed in as {email}")) + .unwrap_or_else(|| "Signed in".to_string()); + let state_entity = self.state.clone(); + + return v_flex() + .gap_2() + .child( + ConfiguredApiCard::new("x-ai-subscribed-sign-out", SharedString::from(label)) + .button_label("Sign Out") + .on_click(cx.listener(move |_this, _, _window, cx| { + state_entity + .update(cx, |state, cx| state.sign_out(cx)) + .detach_and_log_err(cx); + })), + ) + .into_any_element(); + } + + let last_auth_error = state.last_auth_error(); + let provider_state = self.state.clone(); + let is_signing_in = state.is_signing_in(); + let button_label = if is_signing_in { + "Signing in…" + } else { + "Sign In" + }; + + v_flex() + .gap_2() + .when(!self.compact, |this| { + this.child(Label::new(SUBSCRIPTION_DESCRIPTION)) + }) + .child( + Button::new("sign-in", button_label) + .when(!self.compact, |this| this.full_width()) + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .loading(is_signing_in) + .disabled(is_signing_in) + .on_click(move |_, _window, cx| { + provider_state.update(cx, |state, cx| state.sign_in(cx)); + }), + ) + .when_some(last_auth_error, |this, error| { + this.child( + h_flex() + .gap_1() + .justify_center() + .child( + Icon::new(IconName::XCircle) + .color(Color::Error) + .size(IconSize::Small), + ) + .child(Label::new(error).color(Color::Muted)), + ) + }) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::AsyncApp; + use http_client::FakeHttpClient; + use parking_lot::Mutex; + use std::future::Future; + use std::pin::Pin; + + #[gpui::test] + async fn test_authenticate_awaits_initial_load(cx: &mut gpui::TestAppContext) { + let creds_json = serde_json::json!({ + "access_token": "fresh_access", + "refresh_token": "fresh_refresh", + "expires_at_ms": u64::MAX, + "email": null, + }); + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + creds_provider.storage.lock().replace(( + "Bearer".to_string(), + serde_json::to_vec(&creds_json).unwrap(), + )); + + let http_client = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + + let provider = cx.update(|cx| XAiSubscribedProvider::new(http_client, creds_provider, cx)); + let auth_task = cx.update(|cx| provider.authenticate(cx)); + cx.run_until_parked(); + auth_task + .await + .expect("authenticate should succeed after load completes with valid credentials"); + cx.update(|cx| { + assert!(provider.is_authenticated(cx)); + }); + } + + struct FakeCredentialsProvider { + storage: Mutex)>>, + } + + impl FakeCredentialsProvider { + fn new() -> Self { + Self { + storage: Mutex::new(None), + } + } + } + + impl CredentialsProvider for FakeCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + Box::pin(async { Ok(self.storage.lock().clone()) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + username: &'a str, + password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + self.storage + .lock() + .replace((username.to_string(), password.to_vec())); + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + *self.storage.lock() = None; + Box::pin(async { Ok(()) }) + } + } +} diff --git a/crates/language_models_cloud/Cargo.toml b/crates/language_models_cloud/Cargo.toml index 571d8e86eb..e73e79a9a0 100644 --- a/crates/language_models_cloud/Cargo.toml +++ b/crates/language_models_cloud/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/language_models_cloud.rs" +doctest = false [dependencies] anthropic = { workspace = true, features = ["schemars"] } diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 2923b80ee0..a3dbd57cd6 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -19,13 +19,13 @@ use http_client::{ AsyncBody, HttpClient, HttpClientWithUrl, HttpRequestExt, Method, Response, StatusCode, }; use language_model::{ - ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, BASETEN_PROVIDER_ID, BASETEN_PROVIDER_NAME, - CompactionResult, DisabledReason, GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, - LanguageModelId, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, - ProviderErrorCategory, RateLimiter, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, - ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, CompactionResult, DisabledReason, + GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, + LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, ProviderErrorCategory, + RateLimiter, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID, + ZED_CLOUD_PROVIDER_NAME, }; use schemars::JsonSchema; @@ -43,8 +43,9 @@ use anthropic::completion::{ AnthropicEventMapper, AnthropicPromptCacheMode, collect_compaction_result, into_anthropic, }; use google_ai::completion::{GoogleEventMapper, into_google}; +use language_model::chat_completion::ChatCompletionEventMapper; use open_ai::completion::{ - ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + ChatCompletionMaxTokensParameter, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, token_usage_from_response_usage, }; @@ -247,6 +248,87 @@ impl CloudLanguageModel { } impl CloudLanguageModel { + fn check_data_retention_consent( + &self, + cx: &AsyncApp, + ) -> Result<(), LanguageModelCompletionError> { + if self.requires_data_retention() && !self.token_provider.has_data_retention_consent(cx) { + return Err(LanguageModelCompletionError::DataRetentionConsentRequired { + model_name: self.model.display_name.clone(), + }); + } + Ok(()) + } + + fn anthropic_request(&self, request: LanguageModelRequest) -> Result { + let enable_thinking = request.thinking_allowed && self.model.supports_thinking; + let effort = request + .thinking_effort + .as_ref() + .and_then(|effort| anthropic::Effort::from_str(effort).ok()); + let mut request = into_anthropic( + request, + self.model.id.to_string(), + 1.0, + self.model.max_output_tokens as u64, + if enable_thinking { + AnthropicModelMode::Thinking { + budget_tokens: Some(4_096), + } + } else { + AnthropicModelMode::Default + }, + AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, + )?; + if enable_thinking && effort.is_some() { + request.thinking = Some(anthropic::Thinking::Adaptive { + display: Some(anthropic::AdaptiveThinkingDisplay::Summarized), + // The cloud proxy owns the beta header needed for block binding. + block_binding: None, + }); + request.output_config = Some(anthropic::OutputConfig { effort }); + } + if !self.model.supports_fast_mode { + request.speed = None; + } + Ok(request) + } + + fn open_ai_request( + &self, + request: LanguageModelRequest, + ) -> Result { + let enable_thinking = request.thinking_allowed && self.model.supports_thinking; + let effort = request + .thinking_effort + .as_ref() + .and_then(|effort| open_ai::ReasoningEffort::from_str(effort).ok()) + .filter(|effort| *effort != open_ai::ReasoningEffort::None); + let supports_none_reasoning_effort = + self.model.supported_effort_levels.iter().any(|effort| { + open_ai::ReasoningEffort::from_str(&effort.value) + .is_ok_and(|effort| effort == open_ai::ReasoningEffort::None) + }); + let mut request = into_open_ai_response( + request, + &self.model.id.0, + self.model.supports_parallel_tool_calls, + true, + None, + None, + supports_none_reasoning_effort, + &OPEN_AI_PROVIDER_ID, + )?; + if enable_thinking && let Some(effort) = effort { + request.reasoning = Some(open_ai::responses::ReasoningConfig { + effort, + summary: Some(open_ai::responses::ReasoningSummaryMode::Auto), + }); + } + Ok(request) + } + fn compact_anthropic( &self, request: LanguageModelRequest, @@ -525,7 +607,6 @@ impl LanguageModel for CloudLanguageModel ANTHROPIC_PROVIDER_ID, - Baseten => BASETEN_PROVIDER_ID, OpenAi => OPEN_AI_PROVIDER_ID, Google => GOOGLE_PROVIDER_ID, XAi => X_AI_PROVIDER_ID, @@ -536,7 +617,6 @@ impl LanguageModel for CloudLanguageModel ANTHROPIC_PROVIDER_NAME, - Baseten => BASETEN_PROVIDER_NAME, OpenAi => OPEN_AI_PROVIDER_NAME, Google => GOOGLE_PROVIDER_NAME, XAi => X_AI_PROVIDER_NAME, @@ -608,6 +688,11 @@ impl LanguageModel for CloudLanguageModel bool { + self.model.provider == cloud_llm_client::LanguageModelProvider::Anthropic + && self.supports_explicit_compaction() + } + fn minimum_explicit_compaction_input_tokens(&self) -> Option { (self.model.provider == cloud_llm_client::LanguageModelProvider::Anthropic && self.supports_explicit_compaction()) @@ -619,6 +704,9 @@ impl LanguageModel for CloudLanguageModel BoxFuture<'static, Result> { + if let Err(error) = self.check_data_retention_consent(cx) { + return async move { Err(error) }.boxed(); + } if !self.supports_explicit_compaction() { return async { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( @@ -636,8 +724,7 @@ impl LanguageModel for CloudLanguageModel async { Err(LanguageModelCompletionError::Other(anyhow::anyhow!( "this cloud model does not support explicit compaction" @@ -675,7 +762,7 @@ impl LanguageModel for CloudLanguageModel bool { use cloud_llm_client::LanguageModelProvider::*; - matches!(self.model.provider, Baseten | OpenAi | XAi) + matches!(self.model.provider, OpenAi | XAi) } fn telemetry_id(&self) -> String { @@ -690,6 +777,75 @@ impl LanguageModel for CloudLanguageModel BoxFuture<'static, Result, LanguageModelCompletionError>> { + if let Err(error) = self.check_data_retention_consent(cx) { + return async move { Err(error) }.boxed(); + } + use cloud_llm_client::LanguageModelProvider; + let provider_request = match self.model.provider { + LanguageModelProvider::Anthropic => self + .anthropic_request(request) + .and_then(|request| Ok(serde_json::to_value(request)?)), + LanguageModelProvider::OpenAi => self + .open_ai_request(request) + .and_then(|request| Ok(serde_json::to_value(request)?)), + _ => return async { Ok(None) }.boxed(), + }; + let body = match provider_request { + Ok(provider_request) => CompletionBody { + thread_id: None, + prompt_id: None, + provider: self.model.provider, + model: self.model.id.to_string(), + provider_request, + }, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; + let http_client = self.http_client.clone(); + let token_provider = self.token_provider.clone(); + let auth_context = token_provider.auth_context(cx); + let app_version = self.app_version.clone(); + self.request_limiter + .run(async move { + let mut response = Self::perform_llm_request( + "/count_tokens", + false, + &http_client, + &*token_provider, + auth_context, + app_version, + body, + ) + .await? + .response; + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(|error| LanguageModelCompletionError::ApiReadResponseError { + provider: PROVIDER_NAME, + error, + })?; + #[derive(Deserialize)] + struct CountResponse { + tokens: u64, + } + let count: CountResponse = serde_json::from_str(&body).map_err(|error| { + LanguageModelCompletionError::DeserializeResponse { + provider: PROVIDER_NAME, + error, + } + })?; + Ok(Some(count.tokens)) + }) + .boxed() + } + fn stream_completion( &self, request: LanguageModelRequest, @@ -701,64 +857,26 @@ impl LanguageModel for CloudLanguageModel, > { - if self.requires_data_retention() && !self.token_provider.has_data_retention_consent(cx) { - let model_name = self.model.display_name.clone(); - return async move { - Err(LanguageModelCompletionError::DataRetentionConsentRequired { model_name }) - } - .boxed(); + if let Err(error) = self.check_data_retention_consent(cx) { + return async move { Err(error) }.boxed(); } + let mut request = request; + if request.max_output_tokens.is_some() { + request.max_output_tokens = + request.effective_max_output_tokens(self.max_output_tokens()); + } let thread_id = request.thread_id.clone(); let prompt_id = request.prompt_id.clone(); let app_version = self.app_version.clone(); - let thinking_allowed = request.thinking_allowed; - let enable_thinking = thinking_allowed && self.model.supports_thinking; let provider_name = provider_name(&self.model.provider); match self.model.provider { cloud_llm_client::LanguageModelProvider::Anthropic => { - let effort = request - .thinking_effort - .as_ref() - .and_then(|effort| anthropic::Effort::from_str(effort).ok()); - - let mut request = match into_anthropic( - request, - self.model.id.to_string(), - 1.0, - self.model.max_output_tokens as u64, - if enable_thinking { - AnthropicModelMode::Thinking { - budget_tokens: Some(4_096), - } - } else { - AnthropicModelMode::Default - }, - AnthropicPromptCacheMode::Automatic, - // Cloud proxies to Anthropic's own infrastructure, so - // compaction state is owned by (and interchangeable with) - // Anthropic proper, not by the cloud transport. - &ANTHROPIC_PROVIDER_ID, - ) { + let request = match self.anthropic_request(request) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), }; - if enable_thinking && effort.is_some() { - request.thinking = Some(anthropic::Thinking::Adaptive { - display: Some(anthropic::AdaptiveThinkingDisplay::Summarized), - // Thinking block binding needs a beta header on the - // upstream Anthropic request, which the cloud proxy - // controls, so opting in belongs server-side. - block_binding: None, - }); - request.output_config = Some(anthropic::OutputConfig { effort }); - } - - if !self.model.supports_fast_mode { - request.speed = None; - } - let http_client = self.http_client.clone(); let token_provider = self.token_provider.clone(); let auth_context = token_provider.auth_context(cx); @@ -804,38 +922,11 @@ impl LanguageModel for CloudLanguageModel { let http_client = self.http_client.clone(); let token_provider = self.token_provider.clone(); - let effort = request - .thinking_effort - .as_ref() - .and_then(|effort| open_ai::ReasoningEffort::from_str(effort).ok()) - .filter(|effort| *effort != open_ai::ReasoningEffort::None); - let supports_none_reasoning_effort = - self.model.supported_effort_levels.iter().any(|effort| { - open_ai::ReasoningEffort::from_str(&effort.value) - .is_ok_and(|effort| effort == open_ai::ReasoningEffort::None) - }); - - let mut request = match into_open_ai_response( - request, - &self.model.id.0, - self.model.supports_parallel_tool_calls, - true, - None, - None, - supports_none_reasoning_effort, - &OPEN_AI_PROVIDER_ID, - ) { + let request = match self.open_ai_request(request) { Ok(request) => request, Err(error) => return async move { Err(error.into()) }.boxed(), }; - if enable_thinking && let Some(effort) = effort { - request.reasoning = Some(open_ai::responses::ReasoningConfig { - effort, - summary: Some(open_ai::responses::ReasoningSummaryMode::Auto), - }); - } - let auth_context = token_provider.auth_context(cx); let executor = cx.background_executor().clone(); let future = self.request_limiter.stream(async move { @@ -875,8 +966,7 @@ impl LanguageModel for CloudLanguageModel { + cloud_llm_client::LanguageModelProvider::XAi => { let http_client = self.http_client.clone(); let token_provider = self.token_provider.clone(); let request = match into_open_ai( @@ -906,7 +996,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel LanguageModelProviderName { match provider { cloud_llm_client::LanguageModelProvider::Anthropic => ANTHROPIC_PROVIDER_NAME, - cloud_llm_client::LanguageModelProvider::Baseten => BASETEN_PROVIDER_NAME, cloud_llm_client::LanguageModelProvider::OpenAi => OPEN_AI_PROVIDER_NAME, cloud_llm_client::LanguageModelProvider::Google => GOOGLE_PROVIDER_NAME, cloud_llm_client::LanguageModelProvider::XAi => X_AI_PROVIDER_NAME, @@ -1263,11 +1352,129 @@ mod tests { use http_client::http::{HeaderMap, StatusCode}; use language_model::{ LanguageModelCompletionError, LanguageModelRequestMessage, MessageContent, - ProviderErrorCategory, Role, Speed, + ProviderErrorCategory, Role, Speed, StopReason, }; use serde_json::json; use std::sync::Mutex; + #[gpui::test] + async fn cloud_google_completion_emits_stop(cx: &mut gpui::TestAppContext) { + for (finish_reason, expected_stop) in [ + ("STOP", StopReason::EndTurn), + ("MAX_TOKENS", StopReason::MaxTokens), + ("SAFETY", StopReason::Refusal), + ] { + let model = cloud_google_test_model(vec![ + json!({"event": {"candidates": [{ + "content": {"role": "model", "parts": [{"text": "Hello"}]} + }]}}), + json!({"event": { + "candidates": [{ + "content": {"role": "model", "parts": [{"text": " world"}]}, + "finishReason": finish_reason + }], + "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 2} + }}), + json!({"status": "stream_ended"}), + ]); + let mut stream = model + .stream_completion(LanguageModelRequest::default(), &cx.to_async()) + .await + .unwrap(); + + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::Text(text))) if text == "Hello" + )); + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::UsageUpdate(usage))) + if usage.input_tokens == 10 && usage.output_tokens == 2 + )); + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::Text(text))) if text == " world" + )); + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::Stop(reason))) if reason == expected_stop + )); + assert!(stream.next().await.is_none()); + } + } + + #[gpui::test] + async fn cloud_google_tool_completion_preserves_transport_errors( + cx: &mut gpui::TestAppContext, + ) { + for stream_ended in [false, true] { + let mut events = vec![json!({"event": {"candidates": [{ + "content": {"role": "model", "parts": [{ + "functionCall": {"name": "list_directory", "args": {"path": "."}} + }]}, + "finishReason": "STOP" + }]}})]; + if stream_ended { + events.push(json!({"status": "stream_ended"})); + } + let model = cloud_google_test_model(events); + let mut stream = model + .stream_completion(LanguageModelRequest::default(), &cx.to_async()) + .await + .unwrap(); + + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::ToolUse(tool))) + if tool.name.as_ref() == "list_directory" + )); + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))) + )); + if !stream_ended { + assert!(matches!( + stream.next().await, + Some(Err( + LanguageModelCompletionError::StreamEndedUnexpectedly { .. } + )) + )); + } + assert!(stream.next().await.is_none()); + } + } + + #[gpui::test] + async fn cloud_google_incomplete_completion_does_not_emit_stop(cx: &mut gpui::TestAppContext) { + for stream_ended in [false, true] { + let mut events = vec![json!({"event": {"candidates": [{ + "content": {"role": "model", "parts": [{"text": "Partial answer"}]} + }]}})]; + if stream_ended { + events.push(json!({"status": "stream_ended"})); + } + let model = cloud_google_test_model(events); + let mut stream = model + .stream_completion(LanguageModelRequest::default(), &cx.to_async()) + .await + .unwrap(); + + assert!(matches!( + stream.next().await, + Some(Ok(LanguageModelCompletionEvent::Text(text))) if text == "Partial answer" + )); + if !stream_ended { + assert!(matches!( + stream.next().await, + Some(Err( + LanguageModelCompletionError::StreamEndedUnexpectedly { .. } + )) + )); + } + assert!(stream.next().await.is_none()); + } + } + #[gpui::test] async fn cloud_explicit_compaction_forwards_supported_request_fields( cx: &mut gpui::TestAppContext, @@ -1327,6 +1534,8 @@ mod tests { } }); let model = cloud_test_model(http_client); + assert!(model.supports_explicit_compaction()); + assert!(!model.supports_explicit_compaction_output_limit()); let request = compact_test_request(); let result = model.compact(request, &cx.to_async()).await.unwrap(); @@ -1474,10 +1683,9 @@ mod tests { }); let model = cloud_anthropic_test_model(http_client); - let result = model - .compact(compact_test_request(), &cx.to_async()) - .await - .unwrap(); + let mut request = compact_test_request(); + request.max_output_tokens = Some(8192); + let result = model.compact(request, &cx.to_async()).await.unwrap(); assert_eq!( result.usage, @@ -1509,6 +1717,7 @@ mod tests { assert_eq!(uri, "http://test.example/completions?"); let body = serde_json::from_str::(&body).unwrap(); assert_eq!(body["provider"], "anthropic"); + assert_eq!(body["provider_request"]["max_tokens"], 8192); assert_eq!( body["provider_request"]["context_management"], json!({ @@ -1804,12 +2013,238 @@ mod tests { let model = cloud_anthropic_test_model(FakeHttpClient::with_404_response()); assert!(model.supports_explicit_compaction()); + assert!(model.supports_explicit_compaction_output_limit()); + assert_eq!(model.max_total_tokens(), Some(model.max_token_count())); assert_eq!( model.minimum_explicit_compaction_input_tokens(), Some(anthropic::MIN_COMPACTION_TRIGGER_TOKENS) ); } + #[gpui::test] + async fn hosted_input_counts_use_authenticated_count_endpoint(cx: &mut gpui::TestAppContext) { + for anthropic in [true, false] { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let http_client = FakeHttpClient::create({ + let calls = calls.clone(); + move |mut request| { + let calls = calls.clone(); + async move { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + assert_eq!(request.uri().path(), "/count_tokens"); + assert_eq!(request.headers()["Authorization"], "Bearer test-token"); + let mut body = String::new(); + request.body_mut().read_to_string(&mut body).await?; + let body: serde_json::Value = serde_json::from_str(&body)?; + let model = if anthropic { + "claude-opus-4-6" + } else { + "gpt-5.4" + }; + assert_eq!(body["model"], model); + assert_eq!(body["provider_request"]["model"], model); + assert!(body["provider_request"].to_string().contains("aW1hZ2U=")); + Ok(Response::builder() + .status(200) + .body(AsyncBody::from(r#"{"tokens":731}"#))?) + } + } + }); + let model = if anthropic { + cloud_anthropic_test_model(http_client) + } else { + cloud_test_model(http_client) + }; + let mut request = compact_test_request(); + request.messages[0].content.push(MessageContent::Image( + language_model::LanguageModelImage { + source: "aW1hZ2U=".into(), + }, + )); + assert_eq!( + model + .count_input_tokens(request, &cx.to_async()) + .await + .unwrap(), + Some(731) + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + } + } + + #[gpui::test] + async fn hosted_input_counts_surface_errors_and_skip_unsupported_providers( + cx: &mut gpui::TestAppContext, + ) { + for (status, body) in [(429, "rate limit"), (200, r#"{"tokens":-1}"#), (200, "{}")] { + let model = cloud_test_model(FakeHttpClient::create(move |_| async move { + Ok(Response::builder() + .status(status) + .body(AsyncBody::from(body))?) + })); + let error = model + .count_input_tokens(compact_test_request(), &cx.to_async()) + .await + .unwrap_err(); + if status == 200 { + assert!(matches!( + error, + LanguageModelCompletionError::DeserializeResponse { .. } + )); + } else { + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + category: ProviderErrorCategory::RateLimit, + .. + } + )); + } + } + let mut model = cloud_test_model(FakeHttpClient::create(|_| async { + panic!("unsupported provider must not send a counting request"); + })); + Arc::make_mut(&mut model.model).provider = cloud_llm_client::LanguageModelProvider::XAi; + assert_eq!( + model + .count_input_tokens(compact_test_request(), &cx.to_async()) + .await + .unwrap(), + None + ); + } + + #[gpui::test] + async fn retention_consent_gates_counting_compaction_and_generation( + cx: &mut gpui::TestAppContext, + ) { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut model = cloud_anthropic_test_model(FakeHttpClient::create({ + let calls = calls.clone(); + move |request| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + assert_eq!(request.uri().path(), "/count_tokens"); + Ok(Response::builder() + .status(200) + .body(AsyncBody::from(r#"{"tokens":731}"#))?) + } + } + })); + model.id = LanguageModelId::from("claude-fable-5-1".to_string()); + Arc::make_mut(&mut model.model).id = + cloud_llm_client::LanguageModelId("claude-fable-5-1".into()); + assert!(model.requires_data_retention()); + let mut request = compact_test_request(); + request.messages[0].content.push(MessageContent::Image( + language_model::LanguageModelImage { + source: "aW1hZ2U=".into(), + }, + )); + assert!(matches!( + model + .count_input_tokens(request.clone(), &cx.to_async()) + .await, + Err(LanguageModelCompletionError::DataRetentionConsentRequired { .. }) + )); + assert!(matches!( + model.compact(request.clone(), &cx.to_async()).await, + Err(LanguageModelCompletionError::DataRetentionConsentRequired { .. }) + )); + assert!(matches!( + model + .stream_completion(request.clone(), &cx.to_async()) + .await, + Err(LanguageModelCompletionError::DataRetentionConsentRequired { .. }) + )); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + + Arc::get_mut(&mut model.token_provider) + .unwrap() + .data_retention_consent = true; + assert_eq!( + model + .count_input_tokens(request, &cx.to_async()) + .await + .unwrap(), + Some(731) + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[gpui::test] + async fn hosted_open_ai_preserves_unset_output_and_clamps_explicit_caps( + cx: &mut gpui::TestAppContext, + ) { + for (limit, expected) in [ + (None, None), + (Some(8192), Some(8192)), + (Some(u64::MAX), Some(128_000)), + ] { + let model = cloud_test_model(FakeHttpClient::create(move |mut request| async move { + assert_eq!(request.uri().path(), "/completions"); + let mut body = String::new(); + request.body_mut().read_to_string(&mut body).await?; + let body: serde_json::Value = serde_json::from_str(&body)?; + assert_eq!( + body["provider_request"].get("max_output_tokens").cloned(), + expected.map(|value| json!(value)) + ); + let completed = CompletionEvent::Event(json!({ + "type": "response.completed", + "response": { + "id": "response-1", "status": "completed", "output": [], + "usage": {"input_tokens": 1, "output_tokens": 0, "total_tokens": 1} + } + })); + let ended = CompletionEvent::::Status( + CompletionRequestStatus::StreamEnded, + ); + Ok(Response::builder() + .status(200) + .header(SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, "true") + .body(AsyncBody::from(format!( + "{}\n{}\n", + serde_json::to_string(&completed)?, + serde_json::to_string(&ended)?, + )))?) + })); + let mut request = compact_test_request(); + request.max_output_tokens = limit; + let mut stream = model + .stream_completion(request, &cx.to_async()) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + } + } + + fn cloud_google_test_model( + events: Vec, + ) -> CloudLanguageModel { + let body = events + .into_iter() + .map(|event| format!("{event}\n")) + .collect::(); + let http_client = FakeHttpClient::create(move |_| { + let body = body.clone(); + async move { + Ok(Response::builder() + .status(200) + .header(SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, "true") + .body(AsyncBody::from(body))?) + } + }); + let mut model = cloud_test_model(http_client); + model.id = LanguageModelId::from("gemini-3.1-pro-preview".to_string()); + let metadata = Arc::make_mut(&mut model.model); + metadata.provider = cloud_llm_client::LanguageModelProvider::Google; + metadata.id = cloud_llm_client::LanguageModelId(Arc::from("gemini-3.1-pro-preview")); + model + } + fn compact_test_request() -> LanguageModelRequest { LanguageModelRequest { thread_id: Some("thread-123".to_string()), @@ -1849,7 +2284,7 @@ mod tests { is_disabled: false, disabled_reason: None, }), - token_provider: Arc::new(TestTokenProvider), + token_provider: Arc::new(TestTokenProvider::default()), http_client, app_version: None, request_limiter: RateLimiter::new(4), @@ -1881,14 +2316,17 @@ mod tests { is_disabled: false, disabled_reason: None, }), - token_provider: Arc::new(TestTokenProvider), + token_provider: Arc::new(TestTokenProvider::default()), http_client, app_version: None, request_limiter: RateLimiter::new(4), } } - struct TestTokenProvider; + #[derive(Default)] + struct TestTokenProvider { + data_retention_consent: bool, + } impl CloudLlmTokenProvider for TestTokenProvider { type AuthContext = (); @@ -1910,7 +2348,7 @@ mod tests { } fn has_data_retention_consent(&self, _cx: &impl AppContext) -> bool { - false + self.data_retention_consent } } } diff --git a/crates/language_onboarding/Cargo.toml b/crates/language_onboarding/Cargo.toml index 1ab0a75fc3..9d4989cb51 100644 --- a/crates/language_onboarding/Cargo.toml +++ b/crates/language_onboarding/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/python.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index e63f6e3051..b3ac451d54 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -1190,7 +1190,8 @@ fn build_highlight_entries( .collect::>(); for capture in captures { - let Some(highlight_id) = highlight_maps[capture.grammar_index].get(capture.index) + let Some(highlight_id) = + highlight_maps[capture.grammar_index].get(language::CaptureId(capture.index)) else { continue; }; diff --git a/crates/language_tools/src/key_context_view.rs b/crates/language_tools/src/key_context_view.rs index d80840a640..3d0b02ba0f 100644 --- a/crates/language_tools/src/key_context_view.rs +++ b/crates/language_tools/src/key_context_view.rs @@ -1,7 +1,6 @@ use gpui::{ Action, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, KeyBindingContextPredicate, KeyContext, Keystroke, MouseButton, Render, Subscription, Task, - actions, }; use itertools::Itertools; use serde_json::json; @@ -12,14 +11,7 @@ use ui::{ h_flex, px, v_flex, }; use workspace::{Item, SplitDirection, Workspace}; - -actions!( - dev, - [ - /// Opens the key context view for debugging keybindings. - OpenKeyContextView - ] -); +use zed_actions::dev::OpenKeyContextView; pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index f5c3c2e28f..49bd5053dd 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -346,7 +346,15 @@ impl LanguageServerState { .lsp_store .update(cx, |lsp_store, _| lsp_store.as_remote().is_some()) .unwrap_or(false); - let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector); + let has_logs = is_remote + || self.workspace.upgrade().is_some_and(|workspace| { + let project = workspace.read(cx).project(); + lsp_logs.read(cx).has_server_logs( + &server_selector, + &project.downgrade(), + &self.lsp_store, + ) + }); let (status_color, status_label) = server_info .binary_status diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index ace4a0da96..338efb35e3 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -13,7 +13,9 @@ use lsp::{ }; use project::{ LanguageServerStatus, Project, - lsp_store::log_store::{self, Event, LanguageServerKind, LogKind, LogStore, Message}, + lsp_store::log_store::{ + self, Event, LanguageServerKind, LanguageServerLogKey, LogKind, LogStore, Message, + }, search::SearchQuery, }; use proto::toggle_lsp_logs::LogType; @@ -44,6 +46,9 @@ pub fn open( workspace .update_in(cx, |workspace, window, cx| { let project = workspace.project().clone(); + let weak_project = project.downgrade(); + let project_is_local = project.read(cx).is_local(); + let weak_lsp_store = project.read(cx).lsp_store().downgrade(); let tool_log_store = log_store.clone(); let log_view = get_or_create_tool( workspace, @@ -53,22 +58,35 @@ pub fn open( move |window, cx| LspLogView::new(project, tool_log_store, window, cx), ); log_view.update(cx, |log_view, cx| { - let server_id = match server { - LanguageServerSelector::Id(id) => Some(id), - LanguageServerSelector::Name(name) => { - log_store.read(cx).language_servers.iter().find_map( - |(id, state)| { - if state.name.as_ref() == Some(&name) { - Some(*id) - } else { - None - } - }, - ) - } + let key_priority = |key: &LanguageServerLogKey| match &key.kind { + LanguageServerKind::Local { .. } if project_is_local => 0, + LanguageServerKind::Remote { .. } if !project_is_local => 0, + _ => 1, }; - if let Some(server_id) = server_id { - log_view.show_logs_for_server(server_id, window, cx); + let server_key = match server { + LanguageServerSelector::Id(id) => log_store + .read(cx) + .language_servers + .keys() + .filter(|key| { + key.server_id == id + && key.is_for_project(&weak_project, &weak_lsp_store) + }) + .min_by_key(|key| key_priority(key)) + .cloned(), + LanguageServerSelector::Name(name) => log_store + .read(cx) + .language_servers + .iter() + .filter(|(key, state)| { + key.is_for_project(&weak_project, &weak_lsp_store) + && state.name.as_ref() == Some(&name) + }) + .min_by_key(|(key, _)| key_priority(key)) + .map(|(key, _)| key.clone()), + }; + if let Some(server_key) = server_key { + log_view.show_logs_for_server(server_key, window, cx); } }); }) @@ -82,7 +100,7 @@ pub struct LspLogView { pub(crate) editor: Entity, editor_subscriptions: Vec, log_store: Entity, - current_server_id: Option, + current_server_key: Option, active_entry_kind: LogKind, project: Entity, focus_handle: FocusHandle, @@ -105,6 +123,12 @@ pub(crate) struct LogMenuItem { pub server_kind: LanguageServerKind, } +impl LogMenuItem { + fn key(&self) -> LanguageServerLogKey { + LanguageServerLogKey::new(self.server_kind.clone(), self.server_id) + } +} + actions!( dev, [ @@ -144,35 +168,38 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) -> Self { - let server_id = log_store - .read(cx) - .language_servers - .iter() - .find(|(_, server)| server.kind.project() == Some(&project.downgrade())) - .map(|(id, _)| *id); - let weak_project = project.downgrade(); + let weak_lsp_store = project.read(cx).lsp_store().downgrade(); + let server_key = log_store + .read(cx) + .server_keys_for_project(&weak_project, &weak_lsp_store) + .next(); let model_changes_subscription = cx.observe_in(&log_store, window, move |this, store, window, cx| { - let first_server_id_for_project = - store.read(cx).server_ids_for_project(&weak_project).next(); - if let Some(current_lsp) = this.current_server_id { - if !store.read(cx).language_servers.contains_key(¤t_lsp) - && let Some(server_id) = first_server_id_for_project + let first_server_key_for_project = store + .read(cx) + .server_keys_for_project(&weak_project, &weak_lsp_store) + .next(); + if let Some(current_key) = this.current_server_key.as_ref() { + let current_server_is_for_project = + store.read(cx).language_servers.contains_key(current_key) + && current_key.is_for_project(&weak_project, &weak_lsp_store); + if !current_server_is_for_project + && let Some(server_key) = first_server_key_for_project { match this.active_entry_kind { - LogKind::Rpc => this.show_rpc_trace_for_server(server_id, window, cx), - LogKind::Trace => this.show_trace_for_server(server_id, window, cx), - LogKind::Logs => this.show_logs_for_server(server_id, window, cx), - LogKind::ServerInfo => this.show_server_info(server_id, window, cx), + LogKind::Rpc => this.show_rpc_trace_for_server(server_key, window, cx), + LogKind::Trace => this.show_trace_for_server(server_key, window, cx), + LogKind::Logs => this.show_logs_for_server(server_key, window, cx), + LogKind::ServerInfo => this.show_server_info(server_key, window, cx), } } - } else if let Some(server_id) = first_server_id_for_project { + } else if let Some(server_key) = first_server_key_for_project { match this.active_entry_kind { - LogKind::Rpc => this.show_rpc_trace_for_server(server_id, window, cx), - LogKind::Trace => this.show_trace_for_server(server_id, window, cx), - LogKind::Logs => this.show_logs_for_server(server_id, window, cx), - LogKind::ServerInfo => this.show_server_info(server_id, window, cx), + LogKind::Rpc => this.show_rpc_trace_for_server(server_key, window, cx), + LogKind::Trace => this.show_trace_for_server(server_key, window, cx), + LogKind::Logs => this.show_logs_for_server(server_key, window, cx), + LogKind::ServerInfo => this.show_server_info(server_key, window, cx), } } @@ -183,8 +210,8 @@ impl LspLogView { &log_store, window, move |log_view, _, e, window, cx| match e { - Event::NewServerLogEntry { id, kind, text } => { - if log_view.current_server_id == Some(*id) + Event::NewServerLogEntry { key, kind, text } => { + if log_view.current_server_key.as_ref() == Some(key) && LogKind::from_server_log_type(kind) == log_view.active_entry_kind { log_view.editor.update(cx, |editor, cx| { @@ -234,12 +261,15 @@ impl LspLogView { }); cx.on_release(|log_view, cx| { + let project = log_view.project.downgrade(); + let lsp_store = log_view.project.read(cx).lsp_store().downgrade(); log_view.log_store.update(cx, |log_store, cx| { - for (server_id, state) in &log_store.language_servers { - if let Some(log_kind) = state.toggled_log_kind { - if let Some(log_type) = log_type(log_kind) { - send_toggle_log_message(state, *server_id, false, log_type, cx); - } + for (key, state) in &log_store.language_servers { + if key.is_for_project(&project, &lsp_store) + && let Some(log_kind) = state.toggled_log_kind + && let Some(log_type) = log_type(log_kind) + { + send_toggle_log_message(key, false, log_type, cx); } } }); @@ -252,7 +282,7 @@ impl LspLogView { editor_subscriptions, project, log_store, - current_server_id: None, + current_server_key: None, active_entry_kind: LogKind::Logs, _log_store_subscriptions: vec![ model_changes_subscription, @@ -260,8 +290,8 @@ impl LspLogView { focus_subscription, ], }; - if let Some(server_id) = server_id { - lsp_log_view.show_logs_for_server(server_id, window, cx); + if let Some(server_key) = server_key { + lsp_log_view.show_logs_for_server(server_key, window, cx); } lsp_log_view } @@ -342,55 +372,28 @@ impl LspLogView { ); (editor, vec![editor_subscription, search_subscription]) } - pub(crate) fn try_ensure_copilot_for_project(&self, cx: &mut App) { - self.log_store.update(cx, |this, cx| { - let copilot = EditPredictionStore::try_global(cx) - .and_then(|store| store.read(cx).copilot_for_project(&self.project))?; - let server = copilot.read(cx).language_server()?.clone(); - let log_subscription = this.copilot_state_for_project(&self.project.downgrade()); - if let Some(subscription_slot @ None) = log_subscription { - let weak_lsp_store = cx.weak_entity(); - let server_id = server.server_id(); - - let name = LanguageServerName::new_static("copilot"); - *subscription_slot = - Some(server.on_notification::( - move |params, cx| { - weak_lsp_store - .update(cx, |lsp_store, cx| { - lsp_store.add_language_server_log( - server_id, - MessageType::LOG, - ¶ms.message, - cx, - ); - }) - .ok(); - }, - )); - this.add_language_server( - LanguageServerKind::Global, - server.server_id(), - Some(name), - None, - Some(server.clone()), - cx, - ); - } - - Some(()) + pub(crate) fn sync_copilot_for_project(&self, cx: &mut App) { + let server = EditPredictionStore::try_global(cx) + .and_then(|store| store.read(cx).copilot_for_project(&self.project)) + .and_then(|copilot| copilot.read(cx).language_server().cloned()); + self.log_store.update(cx, |log_store, cx| { + log_store.sync_copilot_for_project(&self.project.downgrade(), server, cx); }); } + pub(crate) fn menu_items(&self, cx: &mut App) -> Option> { - self.try_ensure_copilot_for_project(cx); + self.sync_copilot_for_project(cx); let log_store = self.log_store.read(cx); let unknown_server = LanguageServerName::new_static("unknown server"); + let project = self.project.downgrade(); + let lsp_store = self.project.read(cx).lsp_store().downgrade(); let mut rows = log_store .language_servers .iter() - .map(|(server_id, state)| match &state.kind { + .filter(|(key, _)| key.is_for_project(&project, &lsp_store)) + .map(|(key, state)| match &key.kind { LanguageServerKind::Local { .. } | LanguageServerKind::Remote { .. } | LanguageServerKind::LocalSsh { .. } => { @@ -401,9 +404,9 @@ impl LspLogView { .unwrap_or_else(|| "Unknown worktree".to_string()); LogMenuItem { - server_id: *server_id, + server_id: key.server_id, server_name: state.name.clone().unwrap_or(unknown_server.clone()), - server_kind: state.kind.clone(), + server_kind: key.kind.clone(), worktree_root_name, rpc_trace_enabled: state.rpc_state.is_some(), selected_entry: self.active_entry_kind, @@ -411,42 +414,24 @@ impl LspLogView { } } - LanguageServerKind::Global => LogMenuItem { - server_id: *server_id, + LanguageServerKind::Supplementary { .. } => LogMenuItem { + server_id: key.server_id, server_name: state.name.clone().unwrap_or(unknown_server.clone()), - server_kind: state.kind.clone(), + server_kind: key.kind.clone(), worktree_root_name: "supplementary".to_string(), rpc_trace_enabled: state.rpc_state.is_some(), selected_entry: self.active_entry_kind, trace_level: lsp::TraceValue::Off, }, }) - .chain( - self.project - .read(cx) - .supplementary_language_servers(cx) - .filter_map(|(server_id, name)| { - let state = log_store.language_servers.get(&server_id)?; - Some(LogMenuItem { - server_id, - server_name: name, - server_kind: state.kind.clone(), - worktree_root_name: "supplementary".to_string(), - rpc_trace_enabled: state.rpc_state.is_some(), - selected_entry: self.active_entry_kind, - trace_level: lsp::TraceValue::Off, - }) - }), - ) .collect::>(); - rows.sort_unstable_by_key(|row| row.server_id); - rows.dedup_by_key(|row| row.server_id); + rows.sort_by_key(|row| row.server_id); Some(rows) } fn show_logs_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { @@ -454,16 +439,16 @@ impl LspLogView { .log_store .read(cx) .language_servers - .get(&server_id) + .get(&key) .map(|v| v.log_level) .unwrap_or(MessageType::LOG); let log_contents = self .log_store .read(cx) - .server_logs(server_id) + .server_logs(&key) .map(|v| log_contents(v, typ)); if let Some(log_contents) = log_contents { - self.current_server_id = Some(server_id); + self.current_server_key = Some(key.clone()); self.active_entry_kind = LogKind::Logs; let (editor, editor_subscriptions) = Self::editor_for_logs(log_contents, window, cx); self.editor = editor; @@ -472,26 +457,26 @@ impl LspLogView { } self.editor.read(cx).focus_handle(cx).focus(window, cx); self.log_store.update(cx, |log_store, cx| { - let state = log_store.get_language_server_state(server_id)?; + let state = log_store.get_language_server_state(&key)?; state.toggled_log_kind = Some(LogKind::Logs); - send_toggle_log_message(state, server_id, true, LogType::Log, cx); + send_toggle_log_message(&key, true, LogType::Log, cx); Some(()) }); } fn update_log_level( &self, - server_id: LanguageServerId, + key: LanguageServerLogKey, level: MessageType, window: &mut Window, cx: &mut Context, ) { let log_contents = self.log_store.update(cx, |this, _| { - if let Some(state) = this.get_language_server_state(server_id) { + if let Some(state) = this.get_language_server_state(&key) { state.log_level = level; } - this.server_logs(server_id).map(|v| log_contents(v, level)) + this.server_logs(&key).map(|v| log_contents(v, level)) }); if let Some(log_contents) = log_contents { @@ -507,31 +492,31 @@ impl LspLogView { fn show_trace_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { let trace_level = self .log_store .update(cx, |log_store, _| { - Some(log_store.get_language_server_state(server_id)?.trace_level) + Some(log_store.get_language_server_state(&key)?.trace_level) }) .unwrap_or(TraceValue::Messages); let log_contents = self .log_store .read(cx) - .server_trace(server_id) + .server_trace(&key) .map(|v| log_contents(v, trace_level)); if let Some(log_contents) = log_contents { - self.current_server_id = Some(server_id); + self.current_server_key = Some(key.clone()); self.active_entry_kind = LogKind::Trace; let (editor, editor_subscriptions) = Self::editor_for_logs(log_contents, window, cx); self.editor = editor; self.editor_subscriptions = editor_subscriptions; self.log_store.update(cx, |log_store, cx| { - let state = log_store.get_language_server_state(server_id)?; + let state = log_store.get_language_server_state(&key)?; state.toggled_log_kind = Some(LogKind::Trace); - send_toggle_log_message(state, server_id, true, LogType::Trace, cx); + send_toggle_log_message(&key, true, LogType::Trace, cx); Some(()) }); cx.notify(); @@ -541,18 +526,18 @@ impl LspLogView { fn show_rpc_trace_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { - self.toggle_rpc_trace_for_server(server_id, true, window, cx); + self.toggle_rpc_trace_for_server(key.clone(), true, window, cx); let rpc_log = self.log_store.update(cx, |log_store, _| { log_store - .enable_rpc_trace_for_language_server(server_id) + .enable_rpc_trace_for_language_server(&key) .map(|state| log_contents(&state.rpc_messages, ())) }); if let Some(rpc_log) = rpc_log { - self.current_server_id = Some(server_id); + self.current_server_key = Some(key); self.active_entry_kind = LogKind::Rpc; let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx); let language = self.project.read(cx).languages().language_for_name("JSON"); @@ -585,43 +570,50 @@ impl LspLogView { fn toggle_rpc_trace_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, enabled: bool, window: &mut Window, cx: &mut Context, ) { self.log_store.update(cx, |log_store, cx| { if enabled { - log_store.enable_rpc_trace_for_language_server(server_id); + log_store.enable_rpc_trace_for_language_server(&key); } else { - log_store.disable_rpc_trace_for_language_server(server_id); + log_store.disable_rpc_trace_for_language_server(&key); } - if let Some(server_state) = log_store.language_servers.get(&server_id) { - send_toggle_log_message(server_state, server_id, enabled, LogType::Rpc, cx); - }; + if log_store.language_servers.contains_key(&key) { + send_toggle_log_message(&key, enabled, LogType::Rpc, cx); + } }); - if !enabled && Some(server_id) == self.current_server_id { - self.show_logs_for_server(server_id, window, cx); + if !enabled && self.current_server_key.as_ref() == Some(&key) { + self.show_logs_for_server(key, window, cx); cx.notify(); } } fn update_trace_level( &self, - server_id: LanguageServerId, + key: LanguageServerLogKey, level: TraceValue, cx: &mut Context, ) { - if let Some(server) = self - .project + let server = self + .log_store .read(cx) - .lsp_store() - .read(cx) - .language_server_for_id(server_id) - { + .language_servers + .get(&key) + .and_then(|state| state.server()) + .or_else(|| { + self.project + .read(cx) + .lsp_store() + .read(cx) + .language_server_for_id(key.server_id) + }); + if let Some(server) = server { self.log_store.update(cx, |this, _| { - if let Some(state) = this.get_language_server_state(server_id) { + if let Some(state) = this.get_language_server_state(&key) { state.trace_level = level; } }); @@ -634,35 +626,46 @@ impl LspLogView { fn show_server_info( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { - let Some(server_info) = self - .project + let server_id = key.server_id; + let server_info = self + .log_store .read(cx) - .lsp_store() - .update(cx, |lsp_store, _| { - lsp_store - .language_server_for_id(server_id) - .as_ref() - .map(|language_server| ServerInfo::new(language_server)) - .or_else(move || { - let capabilities = - lsp_store.lsp_server_capabilities.get(&server_id)?.clone(); - let status = lsp_store.language_server_statuses.get(&server_id)?.clone(); + .language_servers + .get(&key) + .and_then(|state| state.server()) + .as_deref() + .map(ServerInfo::new); + let server_info = server_info.or_else(|| { + self.project + .read(cx) + .lsp_store() + .update(cx, |lsp_store, _| { + lsp_store + .language_server_for_id(server_id) + .as_ref() + .map(|language_server| ServerInfo::new(language_server)) + .or_else(move || { + let capabilities = + lsp_store.lsp_server_capabilities.get(&server_id)?.clone(); + let status = + lsp_store.language_server_statuses.get(&server_id)?.clone(); - Some(ServerInfo { - id: server_id, - capabilities, - status, + Some(ServerInfo { + id: server_id, + capabilities, + status, + }) }) - }) - }) - else { + }) + }); + let Some(server_info) = server_info else { return; }; - self.current_server_id = Some(server_id); + self.current_server_key = Some(key.clone()); self.active_entry_kind = LogKind::ServerInfo; let (editor, editor_subscriptions) = Self::editor_for_server_info(server_info, window, cx); self.editor = editor; @@ -670,12 +673,12 @@ impl LspLogView { cx.notify(); self.editor.read(cx).focus_handle(cx).focus(window, cx); self.log_store.update(cx, |log_store, cx| { - let state = log_store.get_language_server_state(server_id)?; - if let Some(log_kind) = state.toggled_log_kind.take() { - if let Some(log_type) = log_type(log_kind) { - send_toggle_log_message(state, server_id, false, log_type, cx); - } - }; + let state = log_store.get_language_server_state(&key)?; + if let Some(log_kind) = state.toggled_log_kind.take() + && let Some(log_type) = log_type(log_kind) + { + send_toggle_log_message(&key, false, log_type, cx); + } Some(()) }); } @@ -691,13 +694,12 @@ fn log_type(log_kind: LogKind) -> Option { } fn send_toggle_log_message( - server_state: &log_store::LanguageServerState, - server_id: LanguageServerId, + key: &LanguageServerLogKey, enabled: bool, log_type: LogType, cx: &mut App, ) { - if let LanguageServerKind::Remote { project } = &server_state.kind { + if let LanguageServerKind::Remote { project } = &key.kind { project .update(cx, |project, cx| { if let Some((client, project_id)) = project.lsp_store().read(cx).upstream_client() { @@ -705,7 +707,7 @@ fn send_toggle_log_message( .send(proto::ToggleLspLogs { project_id, log_type: log_type as i32, - server_id: server_id.to_proto(), + server_id: key.server_id.to_proto(), enabled, }) .log_err(); @@ -790,12 +792,12 @@ impl Item for LspLogView { { Task::ready(Some(cx.new(|cx| { let mut new_view = Self::new(self.project.clone(), self.log_store.clone(), window, cx); - if let Some(server_id) = self.current_server_id { + if let Some(server_key) = self.current_server_key.clone() { match self.active_entry_kind { - LogKind::Rpc => new_view.show_rpc_trace_for_server(server_id, window, cx), - LogKind::Trace => new_view.show_trace_for_server(server_id, window, cx), - LogKind::Logs => new_view.show_logs_for_server(server_id, window, cx), - LogKind::ServerInfo => new_view.show_server_info(server_id, window, cx), + LogKind::Rpc => new_view.show_rpc_trace_for_server(server_key, window, cx), + LogKind::Trace => new_view.show_trace_for_server(server_key, window, cx), + LogKind::Logs => new_view.show_logs_for_server(server_key, window, cx), + LogKind::ServerInfo => new_view.show_server_info(server_key, window, cx), } } new_view @@ -934,25 +936,24 @@ impl Render for LspLogToolbarItemView { return div(); }; - let (menu_rows, current_server_id) = log_view.update(cx, |log_view, cx| { + let (menu_rows, current_server_key) = log_view.update(cx, |log_view, cx| { let menu_rows = log_view.menu_items(cx).unwrap_or_default(); - let current_server_id = log_view.current_server_id; - (menu_rows, current_server_id) + let current_server_key = log_view.current_server_key.clone(); + (menu_rows, current_server_key) }); - let current_server = current_server_id.and_then(|current_server_id| { - if let Ok(ix) = menu_rows.binary_search_by_key(¤t_server_id, |e| e.server_id) { - Some(menu_rows[ix].clone()) - } else { - None - } + let current_server = current_server_key.and_then(|current_server_key| { + menu_rows + .iter() + .find(|row| row.key() == current_server_key) + .cloned() }); let available_language_servers: Vec<_> = menu_rows .into_iter() .map(|row| { ( - row.server_id, + row.key(), row.server_name, row.worktree_root_name, row.selected_entry, @@ -988,33 +989,44 @@ impl Render for LspLogToolbarItemView { move |window, cx| { let log_view = log_view.clone(); ContextMenu::build(window, cx, |mut menu, window, _| { - for (server_id, name, worktree_root, active_entry_kind) in + for (server_key, name, worktree_root, active_entry_kind) in available_language_servers.iter() { let label = format!("{name} ({worktree_root})"); - let server_id = *server_id; + let server_key = server_key.clone(); let active_entry_kind = *active_entry_kind; menu = menu.entry( label, None, window.handler_for(&log_view, move |view, window, cx| { - view.current_server_id = Some(server_id); + view.current_server_key = Some(server_key.clone()); view.active_entry_kind = active_entry_kind; match view.active_entry_kind { LogKind::Rpc => { view.toggle_rpc_trace_for_server( - server_id, true, window, cx, + server_key.clone(), + true, + window, + cx, + ); + view.show_rpc_trace_for_server( + server_key.clone(), + window, + cx, ); - view.show_rpc_trace_for_server(server_id, window, cx); - } - LogKind::Trace => { - view.show_trace_for_server(server_id, window, cx) - } - LogKind::Logs => { - view.show_logs_for_server(server_id, window, cx) } + LogKind::Trace => view.show_trace_for_server( + server_key.clone(), + window, + cx, + ), + LogKind::Logs => view.show_logs_for_server( + server_key.clone(), + window, + cx, + ), LogKind::ServerInfo => { - view.show_server_info(server_id, window, cx) + view.show_server_info(server_key.clone(), window, cx) } } cx.notify(); @@ -1028,7 +1040,7 @@ impl Render for LspLogToolbarItemView { }); let view_selector = current_server.map(|server| { - let server_id = server.server_id; + let server_key = server.key(); let rpc_trace_enabled = server.rpc_trace_enabled; let log_view = log_view.clone(); let label = match server.selected_entry { @@ -1049,25 +1061,34 @@ impl Render for LspLogToolbarItemView { .menu(move |window, cx| { let log_toolbar_view = log_toolbar_view.upgrade()?; let log_view = log_view.clone(); + let server_key = server_key.clone(); Some(ContextMenu::build(window, cx, move |this, window, _| { this.entry( SERVER_LOGS, None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_logs_for_server(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_logs_for_server(server_key.clone(), window, cx); + } }), ) .entry( SERVER_TRACE, None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_trace_for_server(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_trace_for_server(server_key.clone(), window, cx); + } }), ) .custom_entry( { let log_toolbar_view = log_toolbar_view.clone(); + let server_key = server_key.clone(); move |window, _| { + let server_key = server_key.clone(); h_flex() .w_full() .justify_between() @@ -1090,7 +1111,10 @@ impl Render for LspLogToolbarItemView { ToggleState::Selected ); view.toggle_rpc_logging_for_server( - server_id, enabled, window, cx, + server_key.clone(), + enabled, + window, + cx, ); cx.stop_propagation(); }, @@ -1100,15 +1124,21 @@ impl Render for LspLogToolbarItemView { .into_any_element() } }, - window.handler_for(&log_view, move |view, window, cx| { - view.show_rpc_trace_for_server(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_rpc_trace_for_server(server_key.clone(), window, cx); + } }), ) .entry( SERVER_INFO, None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_server_info(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_server_info(server_key.clone(), window, cx); + } }), ) })) @@ -1146,14 +1176,17 @@ impl Render for LspLogToolbarItemView { let log_view = log_view; move |window, cx| { - let id = log_view.read(cx).current_server_id?; + let key = + log_view.read(cx).current_server_key.clone()?; let trace_level = log_view.update(cx, |this, cx| { this.log_store.update(cx, |this, _| { Some( - this.get_language_server_state(id)? - .trace_level, + this.get_language_server_state( + &key, + )? + .trace_level, ) }) })?; @@ -1173,11 +1206,12 @@ impl Render for LspLogToolbarItemView { let log_view = log_view.clone(); move |_, cx| { log_view.update(cx, |this, cx| { - if let Some(id) = - this.current_server_id + if let Some(key) = this + .current_server_key + .clone() { this.update_trace_level( - id, option, cx, + key, option, cx, ); } }); @@ -1216,14 +1250,17 @@ impl Render for LspLogToolbarItemView { let log_view = log_view; move |window, cx| { - let id = log_view.read(cx).current_server_id?; + let key = + log_view.read(cx).current_server_key.clone()?; let log_level = log_view.update(cx, |this, cx| { this.log_store.update(cx, |this, _| { Some( - this.get_language_server_state(id)? - .log_level, + this.get_language_server_state( + &key, + )? + .log_level, ) }) })?; @@ -1244,11 +1281,12 @@ impl Render for LspLogToolbarItemView { let log_view = log_view.clone(); move |window, cx| { log_view.update(cx, |this, cx| { - if let Some(id) = - this.current_server_id + if let Some(key) = this + .current_server_key + .clone() { this.update_log_level( - id, option, window, cx, + key, option, window, cx, ); } }); @@ -1328,19 +1366,19 @@ impl LspLogToolbarItemView { fn toggle_rpc_logging_for_server( &mut self, - id: LanguageServerId, + key: LanguageServerLogKey, enabled: bool, window: &mut Window, cx: &mut Context, ) { if let Some(log_view) = &self.log_view { log_view.update(cx, |log_view, cx| { - log_view.toggle_rpc_trace_for_server(id, enabled, window, cx); - if !enabled && Some(id) == log_view.current_server_id { - log_view.show_logs_for_server(id, window, cx); + log_view.toggle_rpc_trace_for_server(key.clone(), enabled, window, cx); + if !enabled && log_view.current_server_key.as_ref() == Some(&key) { + log_view.show_logs_for_server(key.clone(), window, cx); cx.notify(); } else if enabled { - log_view.show_rpc_trace_for_server(id, window, cx); + log_view.show_rpc_trace_for_server(key.clone(), window, cx); cx.notify(); } window.focus(&log_view.focus_handle, cx); diff --git a/crates/language_tools/src/lsp_log_view_tests.rs b/crates/language_tools/src/lsp_log_view_tests.rs index 326512d374..33512fbe19 100644 --- a/crates/language_tools/src/lsp_log_view_tests.rs +++ b/crates/language_tools/src/lsp_log_view_tests.rs @@ -5,16 +5,323 @@ use crate::lsp_log_view::LogMenuItem; use super::*; use futures::StreamExt; use gpui::{AppContext as _, TestAppContext, VisualTestContext}; -use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; +use language::{ + FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageServerId, tree_sitter_rust, +}; use lsp::LanguageServerName; use project::{ FakeFs, Project, - lsp_store::log_store::{LanguageServerKind, LogKind, LogStore}, + lsp_store::log_store::{LanguageServerKind, LanguageServerLogKey, LogKind, LogStore}, }; use serde_json::json; use settings::SettingsStore; use util::path; +#[gpui::test] +async fn test_lsp_log_view_filters_servers_from_other_projects(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/current-project"), json!({ "test.rs": "" })) + .await; + fs.insert_tree(path!("/other-project"), json!({ "test.rs": "" })) + .await; + + let project = Project::test(fs.clone(), [path!("/current-project").as_ref()], cx).await; + let other_project = Project::test(fs.clone(), [path!("/other-project").as_ref()], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let current_lsp_store = project + .read_with(cx, |project, _| project.lsp_store()) + .downgrade(); + let other_lsp_store = other_project + .read_with(cx, |project, _| project.lsp_store()) + .downgrade(); + + let current_ssh_server_id = LanguageServerId(99); + let current_server_id = LanguageServerId(100); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |store, cx| { + store.add_language_server( + LanguageServerKind::LocalSsh { + lsp_store: current_lsp_store, + }, + current_ssh_server_id, + Some(LanguageServerName::new_static("current-ssh-server")), + Some(worktree_id), + None, + cx, + ); + store.add_language_server( + LanguageServerKind::Local { + project: project.downgrade(), + }, + current_server_id, + Some(LanguageServerName::new_static("current-server")), + Some(worktree_id), + None, + cx, + ); + store.add_language_server( + LanguageServerKind::Remote { + project: other_project.downgrade(), + }, + current_server_id, + Some(LanguageServerName::new_static("other-remote-server")), + None, + None, + cx, + ); + store.add_language_server( + LanguageServerKind::Supplementary { + project: other_project.downgrade(), + }, + current_server_id, + Some(LanguageServerName::new_static("other-supplementary-server")), + None, + None, + cx, + ); + store.add_language_server( + LanguageServerKind::LocalSsh { + lsp_store: other_lsp_store, + }, + current_ssh_server_id, + Some(LanguageServerName::new_static("other-ssh-server")), + None, + None, + cx, + ); + }); + assert_eq!( + log_store.read_with(cx, |store, _| store.language_servers.len()), + 5 + ); + + let window = + cx.add_window(|window, cx| LspLogView::new(project.clone(), log_store, window, cx)); + let log_view = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window, cx); + + log_view.update(&mut cx, |view, cx| { + let visible_servers = view + .menu_items(cx) + .unwrap() + .into_iter() + .map(|item| (item.server_id, item.server_name)) + .collect::>(); + assert_eq!( + visible_servers, + [ + ( + current_ssh_server_id, + LanguageServerName::new_static("current-ssh-server"), + ), + ( + current_server_id, + LanguageServerName::new_static("current-server"), + ), + ] + ); + }); +} + +#[gpui::test] +async fn test_lsp_log_view_labels_registered_supplementary_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/the-root"), json!({ "test.rs": "" })) + .await; + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |store, cx| store.add_project(&project, cx)); + + let server_id = LanguageServerId(100); + let (server, _fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/prettier".into(), + arguments: Vec::new(), + env: None, + }, + "prettier".to_string(), + Default::default(), + &mut cx.to_async(), + ); + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.register_supplementary_language_server_for_test( + server_id, + LanguageServerName::new_static("prettier (default)"), + Arc::new(server), + cx, + ); + }); + + let window = + cx.add_window(|window, cx| LspLogView::new(project.clone(), log_store, window, cx)); + let log_view = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window, cx); + + log_view.update(&mut cx, |view, cx| { + assert_eq!( + view.menu_items(cx).unwrap(), + &[LogMenuItem { + server_id, + server_name: LanguageServerName::new_static("prettier (default)"), + worktree_root_name: "supplementary".to_string(), + rpc_trace_enabled: false, + selected_entry: LogKind::Logs, + trace_level: lsp::TraceValue::Off, + server_kind: LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + }] + ); + }); + + lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.unregister_supplementary_language_server_for_test(server_id, cx); + }); + log_view.update(&mut cx, |view, cx| { + assert!(view.menu_items(cx).unwrap().is_empty()); + }); +} + +#[gpui::test] +async fn test_log_store_does_not_retain_language_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/the-root"), json!({ "test.rs": "" })) + .await; + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let server_id = LanguageServerId(100); + let server_kind = LanguageServerKind::Local { + project: project.downgrade(), + }; + let server_key = LanguageServerLogKey::new(server_kind.clone(), server_id); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + + let (first_server, _first_fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/first-language-server".into(), + arguments: Vec::new(), + env: None, + }, + "first-language-server".to_string(), + Default::default(), + &mut cx.to_async(), + ); + let first_server = Arc::new(first_server); + let first_server_weak = Arc::downgrade(&first_server); + log_store.update(cx, |store, cx| { + store.add_language_server( + server_kind.clone(), + server_id, + Some(LanguageServerName::new_static("first-language-server")), + None, + Some(first_server.clone()), + cx, + ); + }); + + let (replacement_server, _replacement_fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/replacement-language-server".into(), + arguments: Vec::new(), + env: None, + }, + "replacement-language-server".to_string(), + Default::default(), + &mut cx.to_async(), + ); + let replacement_server = Arc::new(replacement_server); + let replacement_server_weak = Arc::downgrade(&replacement_server); + log_store.update(cx, |store, cx| { + store.add_language_server( + server_kind, + server_id, + Some(LanguageServerName::new_static( + "replacement-language-server", + )), + None, + Some(replacement_server.clone()), + cx, + ); + }); + + let stored_server = log_store + .read_with(cx, |store, _| { + store + .language_servers + .get(&server_key) + .and_then(|state| state.server()) + }) + .expect("replacement language server should be available"); + assert!(Arc::ptr_eq(&stored_server, &replacement_server)); + drop(stored_server); + + drop(first_server); + assert!(first_server_weak.upgrade().is_none()); + drop(replacement_server); + assert!(replacement_server_weak.upgrade().is_none()); + assert!( + log_store + .read_with(cx, |store, _| store + .language_servers + .get(&server_key) + .and_then(|state| state.server())) + .is_none() + ); +} + +#[gpui::test] +async fn test_log_store_removes_unavailable_copilot_server(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/the-root"), json!({ "test.rs": "" })) + .await; + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let server_id = LanguageServerId(100); + let server_kind = LanguageServerKind::Supplementary { + project: project.downgrade(), + }; + let server_key = LanguageServerLogKey::new(server_kind, server_id); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |log_store, cx| log_store.add_project(&project, cx)); + + let (server, _fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/copilot-language-server".into(), + arguments: Vec::new(), + env: None, + }, + "copilot".to_string(), + Default::default(), + &mut cx.to_async(), + ); + log_store.update(cx, |log_store, cx| { + log_store.sync_copilot_for_project(&project.downgrade(), Some(Arc::new(server)), cx); + }); + assert!(log_store.read_with(cx, |log_store, _| { + log_store.language_servers.contains_key(&server_key) + })); + + log_store.update(cx, |log_store, cx| { + log_store.sync_copilot_for_project(&project.downgrade(), None, cx); + }); + assert!(!log_store.read_with(cx, |log_store, _| { + log_store.language_servers.contains_key(&server_key) + })); +} + #[gpui::test] async fn test_lsp_log_view(cx: &mut TestAppContext) { zlog::init_test(); diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index cf35e2e1e1..17ed9024fd 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +doctest = false + [features] test-support = [ "load-grammars" diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 5bb3089c79..d0f468a6b2 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -1842,8 +1842,7 @@ mod tests { vec![ (10..13, HighlightId::TABSTOP_INSERT_ID), (16..19, HighlightId::TABSTOP_INSERT_ID), - (0..7, HighlightId::new(2)), - (7..8, HighlightId::new(2)), + (0..8, HighlightId::new(2)), ], )) ); @@ -1870,8 +1869,7 @@ mod tests { 0..4, vec![ (5..9, HighlightId::TABSTOP_REPLACE_ID), - (0..3, HighlightId::new(2)), - (3..4, HighlightId::new(2)), + (0..4, HighlightId::new(2)), ], )) ); @@ -1956,8 +1954,7 @@ mod tests { vec![ (15..20, HighlightId::TABSTOP_REPLACE_ID), (16..19, HighlightId::TABSTOP_INSERT_ID), - (0..13, HighlightId::new(2)), - (13..14, HighlightId::new(2)), + (0..14, HighlightId::new(2)), ], )) ); diff --git a/crates/line_ending_selector/Cargo.toml b/crates/line_ending_selector/Cargo.toml index db67823a6e..9aa2c3e4c0 100644 --- a/crates/line_ending_selector/Cargo.toml +++ b/crates/line_ending_selector/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/line_ending_selector.rs" doctest = false +test = false [dependencies] editor.workspace = true diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index ab4b8411a6..72d717730a 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -55,11 +55,10 @@ webrtc-sys.workspace = true scap.workspace = true [target.'cfg(target_os = "macos")'.dependencies] -cocoa.workspace = true core-foundation.workspace = true core-video.workspace = true +objc2-core-foundation.workspace = true coreaudio-rs = "0.12.1" -objc.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index f00b64768e..069995cd0b 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -232,10 +232,7 @@ impl AudioStack { _num_channels: u32, output_audio_device: Option, ) -> Result<()> { - // Prevent App Nap from throttling audio playback on macOS. - // This guard is held for the entire duration of audio output. - #[cfg(target_os = "macos")] - let _prevent_app_nap = PreventAppNapGuard::new(); + let _app_nap_guard = executor.prevent_app_nap("Audio playback in progress"); loop { let mut device_change_listener = DeviceChangeListener::new(false)?; @@ -756,11 +753,15 @@ fn video_frame_buffer_from_webrtc(buffer: Box) -> Option Option> { use livekit::webrtc; + use objc2_core_foundation::CFRetained; - let pixel_buffer = frame.0.as_concrete_TypeRef(); - std::mem::forget(frame.0); + let pixel_buffer = CFRetained::into_raw(frame.0); unsafe { - Some(webrtc::video_frame::native::NativeBuffer::from_cv_pixel_buffer(pixel_buffer as _)) + Some( + webrtc::video_frame::native::NativeBuffer::from_cv_pixel_buffer( + pixel_buffer.as_ptr().cast(), + ), + ) } } @@ -828,10 +829,6 @@ trait DeviceChangeListenerApi: Stream + Sized { #[cfg(target_os = "macos")] mod macos { - use cocoa::{ - base::{id, nil}, - foundation::{NSProcessInfo, NSString}, - }; use coreaudio::sys::{ AudioObjectAddPropertyListener, AudioObjectID, AudioObjectPropertyAddress, AudioObjectRemovePropertyListener, OSStatus, kAudioHardwarePropertyDefaultInputDevice, @@ -839,52 +836,6 @@ mod macos { kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, }; use futures::{StreamExt, channel::mpsc::UnboundedReceiver}; - use objc::{msg_send, sel, sel_impl}; - - /// A guard that prevents App Nap while held. - /// - /// On macOS, App Nap can throttle background apps to save power. This can cause - /// audio artifacts when the app is not in the foreground. This guard tells macOS - /// that we're doing latency-sensitive work and should not be throttled. - /// - /// See Apple's documentation on prioritizing work at the app level: - /// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheAppLevel.html - pub struct PreventAppNapGuard { - activity: id, - } - - // The activity token returned by NSProcessInfo is thread-safe - unsafe impl Send for PreventAppNapGuard {} - - // From NSProcessInfo.h - const NS_ACTIVITY_IDLE_SYSTEM_SLEEP_DISABLED: u64 = 1 << 20; - const NS_ACTIVITY_USER_INITIATED: u64 = 0x00FFFFFF | NS_ACTIVITY_IDLE_SYSTEM_SLEEP_DISABLED; - const NS_ACTIVITY_USER_INITIATED_ALLOWING_IDLE_SYSTEM_SLEEP: u64 = - NS_ACTIVITY_USER_INITIATED & !NS_ACTIVITY_IDLE_SYSTEM_SLEEP_DISABLED; - - impl PreventAppNapGuard { - pub fn new() -> Self { - unsafe { - let process_info = NSProcessInfo::processInfo(nil); - #[allow(clippy::disallowed_methods)] - let reason = NSString::alloc(nil).init_str("Audio playback in progress"); - let activity: id = msg_send![process_info, beginActivityWithOptions:NS_ACTIVITY_USER_INITIATED_ALLOWING_IDLE_SYSTEM_SLEEP reason:reason]; - let _: () = msg_send![reason, release]; - let _: () = msg_send![activity, retain]; - Self { activity } - } - } - } - - impl Drop for PreventAppNapGuard { - fn drop(&mut self) { - unsafe { - let process_info = NSProcessInfo::processInfo(nil); - let _: () = msg_send![process_info, endActivity:self.activity]; - let _: () = msg_send![self.activity, release]; - } - } - } /// Implementation from: https://github.com/zed-industries/cpal/blob/fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50/src/host/coreaudio/macos/property_listener.rs#L15 pub struct CoreAudioDefaultDeviceChangeListener { @@ -1065,8 +1016,6 @@ mod macos { #[cfg(target_os = "macos")] type DeviceChangeListener = macos::CoreAudioDefaultDeviceChangeListener; -#[cfg(target_os = "macos")] -use macos::PreventAppNapGuard; #[cfg(not(target_os = "macos"))] mod noop_change_listener { diff --git a/crates/llama_cpp/Cargo.toml b/crates/llama_cpp/Cargo.toml index c77c2dd817..91522abe4c 100644 --- a/crates/llama_cpp/Cargo.toml +++ b/crates/llama_cpp/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/llama_cpp.rs" +doctest = false [features] default = [] @@ -19,6 +20,7 @@ schemars = ["dep:schemars"] anyhow.workspace = true futures.workspace = true http_client.workspace = true +language_model_core.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true diff --git a/crates/llama_cpp/src/llama_cpp.rs b/crates/llama_cpp/src/llama_cpp.rs index 5d5238c4b1..c6df2797d5 100644 --- a/crates/llama_cpp/src/llama_cpp.rs +++ b/crates/llama_cpp/src/llama_cpp.rs @@ -4,6 +4,10 @@ use http_client::{ AsyncBody, CustomHeaders, HttpClient, HttpRequestExt, Method, Request as HttpRequest, RequestBuilderExt, http, }; +pub use language_model_core::chat_completion::{ + ChoiceDelta, FunctionChunk, ResponseMessageDelta, ResponseStreamError, ResponseStreamEvent, + ResponseStreamResult, ToolCallChunk, Usage, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use url::Url; @@ -189,64 +193,6 @@ pub struct StreamOptions { pub include_usage: bool, } -#[derive(Serialize, Deserialize, Debug)] -pub struct Usage { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct LlamaCppError { - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(untagged)] -pub enum ResponseStreamResult { - Ok(ResponseStreamEvent), - Err { error: LlamaCppError }, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ResponseStreamEvent { - pub model: String, - pub object: String, - pub choices: Vec, - pub usage: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ChoiceDelta { - pub index: u32, - pub delta: ResponseMessageDelta, - pub finish_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ResponseMessageDelta { - pub content: Option, - /// `llama-server` emits reasoning as a dedicated `reasoning_content` field - /// when started with a reasoning format (e.g. `--reasoning-format deepseek`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ToolCallChunk { - pub index: usize, - pub id: Option, - pub function: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct FunctionChunk { - pub name: Option, - pub arguments: Option, -} - /// Response of `GET /v1/models`. /// /// In single-model mode `data` has exactly one entry describing the loaded @@ -522,7 +468,7 @@ pub async fn stream_chat_completion( if line == "[DONE]" { None } else { - match serde_json::from_str(line) { + match serde_json::from_str::(line) { Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), Ok(ResponseStreamResult::Err { error }) => { Some(Err(anyhow!(error.message))) @@ -889,7 +835,7 @@ mod tests { ] }); let event: ResponseStreamEvent = serde_json::from_value(event).unwrap(); - let delta = &event.choices[0].delta; + let delta = event.choices[0].delta.as_ref().unwrap(); assert_eq!(delta.reasoning_content.as_deref(), Some("thinking...")); assert_eq!(delta.tool_calls.as_ref().unwrap().len(), 1); } diff --git a/crates/lmstudio/Cargo.toml b/crates/lmstudio/Cargo.toml index 825507b915..c0ed0366b8 100644 --- a/crates/lmstudio/Cargo.toml +++ b/crates/lmstudio/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/lmstudio.rs" +doctest = false [features] default = [] @@ -19,6 +20,7 @@ schemars = ["dep:schemars"] anyhow.workspace = true futures.workspace = true http_client.workspace = true +language_model_core.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs index 4f5c977f12..498cbde985 100644 --- a/crates/lmstudio/src/lmstudio.rs +++ b/crates/lmstudio/src/lmstudio.rs @@ -3,46 +3,16 @@ use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::B use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http, }; +pub use language_model_core::chat_completion::{ + ChoiceDelta, FunctionChunk, ResponseMessageDelta, ResponseStreamError, ResponseStreamEvent, + ResponseStreamResult, ToolCallChunk, Usage, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{convert::TryFrom, time::Duration}; +use std::time::Duration; pub const LMSTUDIO_API_URL: &str = "http://localhost:1234/api/v0"; -#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum Role { - User, - Assistant, - System, - Tool, -} - -impl TryFrom for Role { - type Error = anyhow::Error; - - fn try_from(value: String) -> Result { - match value.as_str() { - "user" => Ok(Self::User), - "assistant" => Ok(Self::Assistant), - "system" => Ok(Self::System), - "tool" => Ok(Self::Tool), - _ => anyhow::bail!("invalid role '{value}'"), - } - } -} - -impl From for String { - fn from(val: Role) -> Self { - match val { - Role::User => "user".to_owned(), - Role::Assistant => "assistant".to_owned(), - Role::System => "system".to_owned(), - Role::Tool => "tool".to_owned(), - } - } -} - #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct Model { @@ -231,46 +201,6 @@ pub struct ChatCompletionRequest { pub tool_choice: Option, } -#[derive(Serialize, Deserialize, Debug)] -pub struct ChatResponse { - pub id: String, - pub object: String, - pub created: u64, - pub model: String, - pub choices: Vec, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ChoiceDelta { - pub index: u32, - pub delta: ResponseMessageDelta, - pub finish_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ToolCallChunk { - pub index: usize, - pub id: Option, - - // There is also an optional `type` field that would determine if a - // function is there. Sometimes this streams in with the `function` before - // it streams in the `type` - pub function: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct FunctionChunk { - pub name: Option, - pub arguments: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct Usage { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, -} - #[derive(Debug, Default, Clone, Deserialize, PartialEq)] #[serde(transparent)] pub struct Capabilities(Vec); @@ -285,27 +215,6 @@ impl Capabilities { } } -#[derive(Serialize, Deserialize, Debug)] -pub struct LmStudioError { - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(untagged)] -pub enum ResponseStreamResult { - Ok(ResponseStreamEvent), - Err { error: LmStudioError }, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ResponseStreamEvent { - pub created: u32, - pub model: String, - pub object: String, - pub choices: Vec, - pub usage: Option, -} - #[derive(Deserialize)] pub struct ListModelsResponse { pub data: Vec, @@ -350,56 +259,6 @@ pub enum CompatibilityType { Mlx, } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ResponseMessageDelta { - pub role: Option, - pub content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -pub async fn complete( - client: &dyn HttpClient, - api_url: &str, - api_key: Option<&str>, - request: ChatCompletionRequest, - extra_headers: &CustomHeaders, -) -> Result { - let uri = format!("{api_url}/chat/completions"); - let mut request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(uri) - .header("Content-Type", "application/json"); - - if let Some(api_key) = api_key { - request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key)); - } - - let serialized_request = serde_json::to_string(&request)?; - let request = request_builder - .extra_headers(extra_headers) - .body(AsyncBody::from(serialized_request))?; - - let mut response = client.send(request).await?; - if response.status().is_success() { - let mut body = Vec::new(); - response.body_mut().read_to_end(&mut body).await?; - let response_message: ChatResponse = serde_json::from_slice(&body)?; - Ok(response_message) - } else { - let mut body = Vec::new(); - response.body_mut().read_to_end(&mut body).await?; - let body_str = std::str::from_utf8(&body)?; - anyhow::bail!( - "Failed to connect to API: {} {}", - response.status(), - body_str - ); - } -} - pub async fn stream_chat_completion( client: &dyn HttpClient, api_url: &str, @@ -432,9 +291,9 @@ pub async fn stream_chat_completion( if line == "[DONE]" { None } else { - match serde_json::from_str(line) { + match serde_json::from_str::(line) { Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), - Ok(ResponseStreamResult::Err { error, .. }) => { + Ok(ResponseStreamResult::Err { error }) => { Some(Err(anyhow!(error.message))) } Err(error) => Some(Err(anyhow!(error))), diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 51317b2a73..2b602d2cd0 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -78,7 +78,7 @@ pub fn workspace_folder_for_uri(uri: Uri) -> WorkspaceFolder { type NotificationHandler = Box, Value, &mut AsyncApp)>; type PendingRespondTasks = Arc>>>; -type ResponseHandler = Box) -> Task<()>>; +type ResponseHandler = Box) -> Task<()>>; type IoHandler = Box; /// Kind of language server stdio given to an IO handler. @@ -273,7 +273,7 @@ struct AnyResponse<'a> { jsonrpc: &'a str, id: RequestId, #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, + error: Option, #[serde(borrow, skip_serializing_if = "Option::is_none")] result: Option<&'a RawValue>, } @@ -294,7 +294,7 @@ struct Response { enum LspResult { #[serde(rename = "result")] Ok(Option), - Error(Option), + Error(Option), } /// Language server protocol RPC notification message. @@ -322,14 +322,99 @@ struct NotificationOrRequest { params: Option, } -#[derive(Debug, Serialize, Deserialize)] -struct Error { - code: i64, - message: String, - #[serde(default)] - data: Option, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "i64", into = "i64")] +pub enum ResponseErrorCode { + ParseError, + MethodNotFound, + RequestFailed, + ServerCancelled, + ContentModified, + Other(i64), } +impl From for ResponseErrorCode { + fn from(code: i64) -> Self { + match code { + -32700 => Self::ParseError, + -32601 => Self::MethodNotFound, + lsp_types::error_codes::REQUEST_FAILED => Self::RequestFailed, + lsp_types::error_codes::SERVER_CANCELLED => Self::ServerCancelled, + lsp_types::error_codes::CONTENT_MODIFIED => Self::ContentModified, + code => Self::Other(code), + } + } +} + +impl From for i64 { + fn from(code: ResponseErrorCode) -> Self { + match code { + ResponseErrorCode::ParseError => -32700, + ResponseErrorCode::MethodNotFound => -32601, + ResponseErrorCode::RequestFailed => lsp_types::error_codes::REQUEST_FAILED, + ResponseErrorCode::ServerCancelled => lsp_types::error_codes::SERVER_CANCELLED, + ResponseErrorCode::ContentModified => lsp_types::error_codes::CONTENT_MODIFIED, + ResponseErrorCode::Other(code) => code, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseError { + pub code: ResponseErrorCode, + pub message: String, + #[serde(default)] + pub data: Option, +} + +impl ResponseError { + pub fn new(code: ResponseErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn server_cancelled() -> Self { + Self::new( + ResponseErrorCode::ServerCancelled, + "server cancelled the request", + ) + } + + pub fn method_not_found(method: &str) -> Self { + Self::new( + ResponseErrorCode::MethodNotFound, + format!("Unrecognized method `{method}`"), + ) + } + + pub fn is_request_denied(&self) -> bool { + self.code == ResponseErrorCode::ServerCancelled + || self.code == ResponseErrorCode::ContentModified + } + + pub fn should_retrigger(&self) -> bool { + self.code == ResponseErrorCode::ServerCancelled + && self + .data + .clone() + .and_then(|data| { + serde_json::from_value::(data).log_err() + }) + .is_none_or(|data| data.retrigger_request) + } +} + +impl std::fmt::Display for ResponseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ResponseError {} + pub trait LspRequestFuture: Future> { fn id(&self) -> i32; } @@ -365,12 +450,12 @@ where } /// Combined capabilities of the server and the adapter. -#[derive(Debug, Clone)] -pub struct AdapterServerCapabilities { +#[derive(Debug, Clone, Copy)] +pub struct AdapterServerCapabilities<'a> { // Reported capabilities by the server - pub server_capabilities: ServerCapabilities, + pub server_capabilities: &'a ServerCapabilities, // List of code actions supported by the LspAdapter matching the server - pub code_action_kinds: Option>, + pub code_action_kinds: Option<&'a [CodeActionKind]>, } // See the VSCode docs [1] and the LSP Spec [2] @@ -533,11 +618,7 @@ impl LanguageServer { let response = AnyResponse { jsonrpc: JSON_RPC_VERSION, id: message_id, - error: Some(Error { - code: -32601, - message: format!("Unrecognized method `{}`", msg.method), - data: None, - }), + error: Some(ResponseError::method_not_found(&msg.method)), result: None, }; if let Ok(response) = serde_json::to_string(&response) { @@ -640,8 +721,8 @@ impl LanguageServer { } /// List of code action kinds this language server reports being able to emit. - pub fn code_action_kinds(&self) -> Option> { - self.code_action_kinds.clone() + pub fn code_action_kinds(&self) -> Option<&[CodeActionKind]> { + self.code_action_kinds.as_deref() } async fn handle_incoming_messages( @@ -865,6 +946,9 @@ impl LanguageServer { link_support: Some(true), dynamic_registration: Some(true), }), + document_highlight: Some(DocumentHighlightClientCapabilities { + dynamic_registration: Some(true), + }), code_action: Some(CodeActionClientCapabilities { code_action_literal_support: Some(CodeActionLiteralSupport { code_action_kind: CodeActionKindLiteralSupport { @@ -1060,7 +1144,7 @@ impl LanguageServer { additional_properties_support: Some(true), }), }), - ..WindowClientCapabilities::default() + show_document: Some(ShowDocumentClientCapabilities { support: true }), }), }, trace: None, @@ -1138,7 +1222,7 @@ impl LanguageServer { Some(async move { log::debug!("language server shutdown started"); - select! { + let shutdown_timed_out = select! { request_result = shutdown_request.fuse() => { match request_result { ConnectionResult::Timeout => { @@ -1152,19 +1236,32 @@ impl LanguageServer { }, ConnectionResult::Result(Ok(())) => {} } + false } _ = timer => { log::info!("timeout waiting for language server {name} (id {server_id}) to shutdown"); + true }, - } + }; response_handlers.lock().take(); Self::notify_internal::(¬ification_serializers, ()).ok(); notification_serializers.close(); - output_done.recv().await; - server.lock().take().map(|mut child| child.kill()); + if !shutdown_timed_out { + select! { + _ = output_done.recv().fuse() => {}, + _ = timer => { + log::info!("timeout draining output for language server {name} (id {server_id}) during shutdown"); + }, + } + } drop(tasks); + if let Some(mut child) = server.lock().take() + && let Err(error) = child.kill() + { + log::warn!("failed to kill language server {name} (id {server_id}): {error}"); + } log::debug!("language server shutdown finished"); Some(()) }) @@ -1280,11 +1377,15 @@ impl LanguageServer { Err(error) => Response { jsonrpc: JSON_RPC_VERSION, id: id.clone(), - value: LspResult::Error(Some(Error { - code: lsp_types::error_codes::REQUEST_FAILED, - message: error.to_string(), - data: None, - })), + value: LspResult::Error(Some( + match error.downcast::() { + Ok(response_error) => response_error, + Err(error) => ResponseError::new( + ResponseErrorCode::RequestFailed, + error.to_string(), + ), + }, + )), }, }; if let Some(response) = @@ -1304,11 +1405,10 @@ impl LanguageServer { jsonrpc: JSON_RPC_VERSION, id, result: None, - error: Some(Error { - code: -32700, // Parse error - message: error.to_string(), - data: None, - }), + error: Some(ResponseError::new( + ResponseErrorCode::ParseError, + error.to_string(), + )), }; if let Some(response) = serde_json::to_string(&response).log_err() { outbound_tx.try_send(response).ok(); @@ -1371,15 +1471,6 @@ impl LanguageServer { self.capabilities.read().clone() } - /// Get the reported capabilities of the running language server and - /// what we know on the client/adapter-side of its capabilities. - pub fn adapter_server_capabilities(&self) -> AdapterServerCapabilities { - AdapterServerCapabilities { - server_capabilities: self.capabilities(), - code_action_kinds: self.code_action_kinds(), - } - } - /// Update the capabilities of the running language server. pub fn update_capabilities(&self, update: impl FnOnce(&mut ServerCapabilities)) { update(self.capabilities.write().deref_mut()); @@ -1428,29 +1519,6 @@ impl LanguageServer { ) } - /// Send a RPC request to the language server with a custom timer. - /// Once the attached future becomes ready, the request will time out with the provided output message. - /// - /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage) - pub fn request_with_timer>( - &self, - params: T::Params, - timer: U, - ) -> impl LspRequestFuture + use - where - T::Result: 'static + Send, - { - Self::request_internal_with_timer::( - &self.next_id, - &self.response_handlers, - &self.outbound_tx, - &self.notification_tx, - &self.executor, - timer, - params, - ) - } - fn request_internal_with_timer( next_id: &AtomicI32, response_handlers: &Arc>>>, @@ -1494,7 +1562,7 @@ impl LanguageServer { Err(error).context("failed to deserialize response") } } - Err(error) => Err(anyhow!("{}", error.message)), + Err(error) => Err(anyhow::Error::new(error)), }; tx.send(response).ok(); }) @@ -2099,7 +2167,7 @@ impl FakeLanguageServer { mod tests { use super::*; use gpui::TestAppContext; - use std::str::FromStr; + use std::{io, str::FromStr, task::Context}; #[ctor::ctor(unsafe)] fn init_logger() { @@ -2192,6 +2260,50 @@ mod tests { fake.receive_notification::().await; } + #[gpui::test] + async fn test_shutdown_bounds_output_drain(cx: &mut TestAppContext) { + for remaining_flushes in [Some(0), Some(1), None] { + let (server, mut stdout, output) = shutdown_test_server(remaining_flushes, cx); + let io_handlers = Arc::downgrade(&server.io_handlers); + let mut shutdown = cx + .executor() + .spawn(server.shutdown().expect("shutdown must start")); + drop(server); + cx.run_until_parked(); + let mut expected = if remaining_flushes == Some(0) { + Vec::new() + } else { + framed_test_message(r#"{"jsonrpc":"2.0","id":0,"method":"shutdown"}"#) + }; + assert_eq!(output.lock().bytes, expected); + assert_eq!((&mut shutdown).now_or_never(), None); + cx.executor().advance_clock(Duration::from_secs(4)); + if remaining_flushes != Some(0) { + stdout + .write_all(&framed_test_message( + r#"{"jsonrpc":"2.0","id":0,"result":null}"#, + )) + .await + .expect("shutdown response must reach stdout"); + } + cx.run_until_parked(); + if remaining_flushes.is_none() { + expected.extend(framed_test_message(r#"{"jsonrpc":"2.0","method":"exit"}"#)); + } else { + assert!(output.lock().blocked); + cx.executor().advance_clock(Duration::from_millis(999)); + cx.run_until_parked(); + assert_eq!((&mut shutdown).now_or_never(), None); + assert!(io_handlers.upgrade().is_some()); + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + } + assert_eq!(shutdown.now_or_never(), Some(Some(()))); + assert_eq!(output.lock().bytes, expected); + assert!(io_handlers.upgrade().is_none()); + } + } + #[gpui::test] async fn test_subscription_leaks_handlers_after_server_drop(cx: &mut TestAppContext) { cx.update(|cx| { @@ -2368,21 +2480,32 @@ mod tests { assert_eq!(notification.id, Some(expected_id)); } + #[test] + fn test_response_error_code_roundtrip() { + for (code, number) in [ + (ResponseErrorCode::ParseError, -32700), + (ResponseErrorCode::MethodNotFound, -32601), + (ResponseErrorCode::RequestFailed, -32803), + (ResponseErrorCode::ServerCancelled, -32802), + (ResponseErrorCode::ContentModified, -32801), + (ResponseErrorCode::Other(-32099), -32099), + ] { + assert_eq!(i64::from(code), number); + assert_eq!(ResponseErrorCode::from(number), code); + } + } + #[test] fn test_serialize_error_response_has_no_result() { let response = AnyResponse { jsonrpc: JSON_RPC_VERSION, id: RequestId::Int(0), - error: Some(Error { - code: -32601, - message: "Unrecognized method".to_string(), - data: None, - }), + error: Some(ResponseError::method_not_found("foo")), result: None, }; assert_eq!( serde_json::to_string(&response).unwrap(), - "{\"jsonrpc\":\"2.0\",\"id\":0,\"error\":{\"code\":-32601,\"message\":\"Unrecognized method\",\"data\":null}}" + "{\"jsonrpc\":\"2.0\",\"id\":0,\"error\":{\"code\":-32601,\"message\":\"Unrecognized method `foo`\",\"data\":null}}" ); } @@ -2431,6 +2554,16 @@ mod tests { let params = cx.update(|cx| server.default_initialize_params(false, false, cx)); + assert_eq!( + params + .capabilities + .text_document + .as_ref() + .and_then(|capabilities| capabilities.document_highlight.as_ref()) + .and_then(|capabilities| capabilities.dynamic_registration), + Some(true) + ); + #[allow(deprecated)] let root_uri = params.root_uri.expect("root_uri should be set"); #[allow(deprecated)] @@ -2454,4 +2587,79 @@ mod tests { }]; assert_eq!(workspace_folders, expected_workspace_folders); } + + struct ShutdownTestOutput { + bytes: Vec, + remaining_flushes: Option, + blocked: bool, + } + + struct ShutdownTestWriter(Arc>); + + impl AsyncWrite for ShutdownTestWriter { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + let mut output = self.0.lock(); + if output.remaining_flushes == Some(0) { + output.blocked = true; + return Poll::Pending; + } + output.bytes.extend_from_slice(bytes); + Poll::Ready(Ok(bytes.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + if let Some(remaining_flushes) = &mut self.0.lock().remaining_flushes { + *remaining_flushes = remaining_flushes.saturating_sub(1); + } + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + fn shutdown_test_server( + remaining_flushes: Option, + cx: &mut TestAppContext, + ) -> ( + LanguageServer, + async_pipe::PipeWriter, + Arc>, + ) { + let (stdout_writer, stdout_reader) = async_pipe::pipe(); + let output = Arc::new(Mutex::new(ShutdownTestOutput { + bytes: Vec::new(), + remaining_flushes, + blocked: false, + })); + let server = LanguageServer::new_internal( + LanguageServerId(0), + LanguageServerName::from("shutdown-test"), + ShutdownTestWriter(output.clone()), + stdout_reader, + None::, + Arc::new(Mutex::new(None)), + None, + None, + LanguageServerBinary { + path: PathBuf::from("shutdown-test"), + arguments: Vec::new(), + env: None, + }, + FakeLanguageServer::root_path(), + None, + &mut cx.to_async(), + |_| false, + ); + (server, stdout_writer, output) + } + + fn framed_test_message(payload: &str) -> Vec { + format!("Content-Length: {}\r\n\r\n{payload}", payload.len()).into_bytes() + } } diff --git a/crates/lsp_command_selector/Cargo.toml b/crates/lsp_command_selector/Cargo.toml new file mode 100644 index 0000000000..ad367a6e9f --- /dev/null +++ b/crates/lsp_command_selector/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "lsp_command_selector" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/lsp_command_selector.rs" +doctest = false + +[dependencies] +editor.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +lsp.workspace = true +picker.workspace = true +project.workspace = true +serde_json.workspace = true +ui.workspace = true +workspace.workspace = true + +[dev-dependencies] +anyhow.workspace = true +editor = { workspace = true, features = ["test-support"] } +futures.workspace = true +menu.workspace = true +gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +util.workspace = true +workspace = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/panel/LICENSE-GPL b/crates/lsp_command_selector/LICENSE-GPL similarity index 100% rename from crates/panel/LICENSE-GPL rename to crates/lsp_command_selector/LICENSE-GPL diff --git a/crates/lsp_command_selector/src/lsp_command_selector.rs b/crates/lsp_command_selector/src/lsp_command_selector.rs new file mode 100644 index 0000000000..f1b053d442 --- /dev/null +++ b/crates/lsp_command_selector/src/lsp_command_selector.rs @@ -0,0 +1,1182 @@ +use std::{ + collections::BTreeSet, + sync::{Arc, atomic::AtomicBool}, +}; + +use editor::Editor; +use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use gpui::{ + AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + ParentElement, Render, SharedString, Task, WeakEntity, Window, actions, +}; +use language::Buffer; +use lsp::{LanguageServerId, LanguageServerName}; +use picker::{Picker, PickerDelegate}; +use project::{File, LspStore, Project}; +use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*}; +use workspace::{ModalView, Workspace}; + +actions!(lsp_command_selector, [Toggle, ToggleArgumentsFocus]); + +pub fn init(cx: &mut App) { + cx.observe_new(LspCommandSelector::register).detach(); +} + +pub struct LspCommandSelector { + picker: Entity>, +} + +impl LspCommandSelector { + fn register(workspace: &mut Workspace, _: Option<&mut Window>, _: &mut Context) { + workspace.register_action_renderer(|div, workspace, _, cx| { + let commands_available = active_buffer(workspace, cx).is_some_and(|buffer| { + available_commands(&buffer, workspace.project(), cx) + .next() + .is_some() + }); + if !commands_available { + return div; + } + div.on_action(cx.listener(|workspace, _: &Toggle, window, cx| { + Self::toggle(workspace, window, cx); + })) + }); + } + + fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let Some(buffer) = active_buffer(workspace, cx) else { + return; + }; + let project = workspace.project(); + let commands = available_commands(&buffer, project, cx).collect::>(); + let lsp_store = project.read(cx).lsp_store(); + workspace.toggle_modal(window, cx, move |window, cx| { + LspCommandSelector::new(lsp_store, commands, window, cx) + }); + } + + fn new( + lsp_store: Entity, + commands: BTreeSet, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let arguments_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text( + "JSON arguments (42, {\"key\": \"value\"}, …) or plain text for one string", + window, + cx, + ); + editor + }); + let delegate = LspCommandSelectorDelegate::new( + cx.entity().downgrade(), + lsp_store, + arguments_editor, + commands, + ); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + Self { picker } + } + + fn toggle_arguments_focus( + &mut self, + _: &ToggleArgumentsFocus, + window: &mut Window, + cx: &mut Context, + ) { + let arguments_focus_handle = self + .picker + .read(cx) + .delegate + .arguments_editor + .focus_handle(cx); + if arguments_focus_handle.contains_focused(window, cx) { + let picker_focus_handle = self.picker.focus_handle(cx); + window.focus(&picker_focus_handle, cx); + } else { + window.focus(&arguments_focus_handle, cx); + } + } +} + +impl Render for LspCommandSelector { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("LspCommandSelector") + .on_action(cx.listener(Self::toggle_arguments_focus)) + .child(self.picker.clone()) + } +} + +impl Focusable for LspCommandSelector { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl EventEmitter for LspCommandSelector {} +impl ModalView for LspCommandSelector {} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct AvailableCommand { + server_name: LanguageServerName, + server_id: LanguageServerId, + command: SharedString, +} + +enum Execution { + Idle, + Running { _task: Task<()> }, + Failed(SharedString), +} + +pub struct LspCommandSelectorDelegate { + selector: WeakEntity, + lsp_store: Entity, + arguments_editor: Entity, + commands: Arc<[AvailableCommand]>, + execution: Execution, + candidates: Arc<[StringMatchCandidate]>, + matches: Vec, + selected_index: usize, +} + +impl LspCommandSelectorDelegate { + fn new( + selector: WeakEntity, + lsp_store: Entity, + arguments_editor: Entity, + commands: BTreeSet, + ) -> Self { + let commands = commands.into_iter().collect::>(); + let candidates = commands + .iter() + .enumerate() + .map(|(index, command)| StringMatchCandidate::new(index, &command.command)) + .collect::>(); + Self { + selector, + lsp_store, + arguments_editor, + commands, + execution: Execution::Idle, + candidates, + matches: Vec::new(), + selected_index: 0, + } + } + + fn is_executing(&self) -> bool { + matches!(self.execution, Execution::Running { .. }) + } + + fn parse_arguments(&self, cx: &App) -> Vec { + let arguments_text = self.arguments_editor.read(cx).text(cx); + let trimmed_arguments = arguments_text.trim(); + if trimmed_arguments.is_empty() { + return Vec::new(); + } + serde_json::Deserializer::from_str(trimmed_arguments) + .into_iter() + .collect::, _>>() + .unwrap_or_else(|_| vec![serde_json::Value::String(trimmed_arguments.to_owned())]) + } +} + +impl PickerDelegate for LspCommandSelectorDelegate { + type ListItem = ListItem; + + fn name() -> &'static str { + "lsp command selector" + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Select a language server command…".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + !self.is_executing() && self.matches.get(ix).is_some() + } + + fn has_another_open_menu(&self, window: &Window, cx: &App) -> bool { + self.arguments_editor + .focus_handle(cx) + .contains_focused(window, cx) + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + if self.is_executing() { + return; + } + let Some(mat) = self.matches.get(self.selected_index) else { + return; + }; + let Some(command) = self.commands.get(mat.candidate_id).cloned() else { + return; + }; + let arguments = self.parse_arguments(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + command.server_id, + command.command.to_string(), + arguments, + cx, + ) + }); + self.arguments_editor + .update(cx, |editor, _| editor.set_read_only(true)); + let task = cx.spawn_in(window, async move |picker, cx| { + let execution = match task.await { + Ok(result) => { + if let Some(result) = result { + log::info!("LSP command {} returned {result}", command.command); + } + Execution::Idle + } + Err(error) => { + let error = format!( + "Failed to execute LSP command {}: {error:#}", + command.command + ); + log::error!("{error}"); + Execution::Failed(SharedString::from(error)) + } + }; + picker + .update_in(cx, |picker, window, cx| { + let succeeded = matches!(execution, Execution::Idle); + picker.delegate.execution = execution; + picker + .delegate + .arguments_editor + .update(cx, |editor, _| editor.set_read_only(false)); + cx.notify(); + if succeeded { + picker.delegate.dismissed(window, cx); + } + }) + .ok(); + }); + self.execution = Execution::Running { _task: task }; + cx.notify(); + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let background = cx.background_executor().clone(); + let candidates = self.candidates.clone(); + cx.spawn_in(window, async move |picker, cx| { + let matches = if query.is_empty() { + candidates + .iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string.clone(), + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + match_strings( + &candidates, + &query, + false, + true, + 100, + &AtomicBool::new(false), + background, + ) + .await + }; + + picker + .update_in(cx, |picker, window, cx| { + picker.delegate.matches = matches; + picker.set_selected_index(0, None, false, window, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + _: &mut Context>, + ) -> Option { + let mat = self.matches.get(ix)?; + let command = self.commands.get(mat.candidate_id)?; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .disabled(self.is_executing()) + .child(HighlightedLabel::new( + mat.string.clone(), + mat.positions.clone(), + )) + .end_slot( + Label::new(command.server_name.0.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + Some( + v_flex() + .p_2() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .justify_between() + .child( + Label::new("Arguments") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child(ui::KeyBinding::for_action_in( + &ToggleArgumentsFocus, + &self.arguments_editor.focus_handle(cx), + cx, + )) + .child( + Label::new("to switch focus") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + .child(self.arguments_editor.clone()) + .map(|footer| match &self.execution { + Execution::Idle => footer, + Execution::Running { .. } => footer.child( + Label::new("Executing command…") + .size(LabelSize::Small) + .color(Color::Muted), + ), + Execution::Failed(error) => footer.child( + Label::new(error.clone()) + .size(LabelSize::Small) + .color(Color::Error), + ), + }) + .into_any_element(), + ) + } +} + +fn active_buffer(workspace: &Workspace, cx: &App) -> Option> { + let editor = workspace.active_item(cx)?.act_as::(cx)?; + let editor = editor.read(cx); + editor + .buffer() + .read(cx) + .as_singleton() + .or_else(|| editor.active_buffer(cx)) +} + +fn available_commands<'a>( + buffer: &Entity, + project: &Entity, + cx: &'a App, +) -> impl Iterator + 'a { + let project = project.read(cx); + let lsp_store = project.lsp_store().read(cx); + let buffer = buffer.read(cx); + let adapters = buffer + .language() + .map(|language| project.languages().lsp_adapters(&language.name())) + .unwrap_or_default(); + let worktree_owning_servers = File::from_dyn(buffer.file()) + .and_then(|file| project.worktree_for_id(file.worktree_id(cx), cx)) + .map(|worktree| worktree.read(cx)) + .filter(|worktree| worktree.is_visible()) + .map(|worktree| worktree.id()); + let opened_in_servers = lsp_store.language_server_ids_for_opened_buffer(buffer.remote_id()); + project + .language_server_statuses(cx) + .filter(move |(server_id, status)| { + adapters.iter().any(|adapter| adapter.name() == status.name) + && match (opened_in_servers, worktree_owning_servers, status.worktree) { + (Some(server_ids), _, _) => server_ids.contains(server_id), + (None, Some(buffer_worktree), Some(server_worktree)) => { + buffer_worktree == server_worktree + } + (None, _, _) => true, + } + }) + .filter_map(move |(server_id, status)| { + let provider = lsp_store + .lsp_server_capabilities + .get(&server_id)? + .execute_command_provider + .as_ref()?; + Some((server_id, status.name.clone(), &provider.commands)) + }) + .flat_map(|(server_id, server_name, commands)| { + commands.iter().map(move |command| AvailableCommand { + server_name: server_name.clone(), + server_id, + command: SharedString::from(command.clone()), + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{ + StreamExt as _, + channel::{mpsc::UnboundedReceiver, oneshot}, + }; + use gpui::{KeyBinding, TestAppContext, VisualTestContext}; + use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher}; + use project::{Project, ProjectPath}; + use serde_json::json; + use std::{ + ops::Deref, + path::PathBuf, + sync::{Arc, Mutex}, + }; + use util::{path, rel_path::rel_path}; + use workspace::{AppState, MultiWorkspace, OpenOptions, OpenVisible}; + + #[gpui::test] + async fn test_executing_commands_from_the_picker(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let SelectorTest { + workspace, + fake_server, + executed_commands, + mut cx, + .. + } = init_selector_test(cx).await; + let (response_sender, response_receiver) = oneshot::channel(); + let response_receiver = Arc::new(Mutex::new(Some(response_receiver))); + fake_server.set_request_handler::({ + let executed_commands = executed_commands.clone(); + move |params, _| { + executed_commands + .lock() + .expect("executed commands lock should not be poisoned") + .push((params.command, params.arguments)); + let response_receiver = response_receiver + .lock() + .expect("response receiver lock should not be poisoned") + .take() + .expect("execute command should only be requested once"); + async move { + response_receiver + .await + .expect("response sender should remain alive"); + Ok(None) + } + } + }); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .matches + .iter() + .map(|mat| mat.string.as_str()) + .collect::>(), + vec!["mdo.index", "mdo.today"], + ); + }); + + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + picker.update_in(&mut cx, |picker, window, cx| { + picker.delegate.arguments_editor.update(cx, |editor, cx| { + editor.set_text("\"foo\" 42", window, cx); + }); + }); + cx.dispatch_action(menu::Confirm); + assert!( + selector_is_open(&workspace, &cx), + "the selector should remain open until the server responds" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState { + executing: true, + error: None, + arguments_read_only: true, + command_selectable: false, + }, + ); + + cx.dispatch_action(menu::Confirm); + assert_eq!( + executed_commands + .lock() + .expect("executed commands lock should not be poisoned") + .as_slice(), + &[("mdo.today".to_string(), vec![json!("foo"), json!(42)])], + "confirmation while executing should not send another command" + ); + + response_sender + .send(()) + .expect("execute command response receiver should remain alive"); + cx.run_until_parked(); + + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after a successful command execution" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState::idle(), + "the dismissed selector should be ready for reopening" + ); + } + + #[gpui::test] + async fn test_command_error_is_shown_in_the_picker_footer(cx: &mut TestAppContext) { + let SelectorTest { + workspace, + fake_server, + mut cx, + .. + } = init_selector_test(cx).await; + fake_server.set_request_handler::( + move |_, _| async move { Err(anyhow::anyhow!("server rejected command")) }, + ); + + let picker = open_selector(&workspace, &mut cx); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + assert!( + selector_is_open(&workspace, &cx), + "the selector should remain open after a failed command execution" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState { + executing: false, + error: Some( + "Failed to execute LSP command mdo.today: server rejected command".to_string() + ), + arguments_read_only: false, + command_selectable: true, + }, + ); + + fake_server.set_request_handler::( + move |_, _| async move { Ok(None) }, + ); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after a successful retry" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState::idle(), + "the error should be cleared and the selector should be ready for reopening" + ); + } + + #[gpui::test] + async fn test_action_is_not_registered_without_commands(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test_with_capabilities( + cx, + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: Vec::new(), + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ) + .await; + + cx.dispatch_action(Toggle); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should not open when no server advertises commands" + ); + } + + #[gpui::test] + async fn test_action_is_not_registered_for_unrelated_buffers(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + + let worktree_id = workspace.read_with(&cx, |workspace, cx| { + workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .expect("project should have a worktree") + .read(cx) + .id() + }); + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: rel_path("notes.txt").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + cx.run_until_parked(); + + cx.dispatch_action(Toggle); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should not open for a buffer whose language has no matching server, \ + even while another server in the project advertises commands" + ); + } + + #[gpui::test] + async fn test_commands_are_scoped_to_the_servers_of_the_buffer_worktree( + cx: &mut TestAppContext, + ) { + let SelectorTest { + workspace, + fake_server: first_server, + mut fake_servers, + executed_commands: first_server_commands, + mut cx, + } = init_selector_test(cx).await; + let fs = workspace.read_with(&cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }); + fs.as_fake() + .insert_tree(path!("/other"), json!({ "other.md": "# other\n" })) + .await; + let (other_worktree, _) = workspace + .update(&mut cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.find_or_create_worktree(path!("/other"), true, cx) + }) + }) + .await + .expect("other worktree should be created"); + let other_worktree_id = other_worktree.read_with(&cx, |worktree, _| worktree.id()); + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: other_worktree_id, + path: rel_path("other.md").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + let second_server = fake_servers + .next() + .await + .expect("a second server should start for the other worktree"); + let second_server_commands = Arc::new(Mutex::new(Vec::new())); + second_server.set_request_handler::({ + let second_server_commands = second_server_commands.clone(); + move |params, _| { + second_server_commands + .lock() + .unwrap() + .push((params.command, params.arguments)); + async move { Ok(None) } + } + }); + cx.run_until_parked(); + assert_ne!( + first_server.server.server_id(), + second_server.server.server_id(), + "each worktree should get its own server instance" + ); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .commands + .iter() + .map(|command| (command.command.as_ref(), command.server_id)) + .collect::>(), + vec![ + ("mdo.index", second_server.server.server_id()), + ("mdo.today", second_server.server.server_id()), + ], + "only the commands of the server for the active buffer's worktree should be listed" + ); + }); + + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_eq!( + ( + first_server_commands.lock().unwrap().as_slice(), + second_server_commands.lock().unwrap().as_slice(), + ), + ( + [].as_slice(), + [("mdo.today".to_string(), Vec::new())].as_slice(), + ), + "the command should be executed by the server of the active buffer's worktree only" + ); + } + + #[gpui::test] + async fn test_commands_of_reused_servers_for_buffers_outside_the_project( + cx: &mut TestAppContext, + ) { + let SelectorTest { + workspace, + fake_server, + mut fake_servers, + executed_commands, + mut cx, + } = init_selector_test(cx).await; + let fs = workspace.read_with(&cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }); + fs.as_fake() + .insert_tree(path!("/outside"), json!({ "readme.md": "# outside\n" })) + .await; + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/outside/readme.md")), + OpenOptions { + visible: Some(OpenVisible::None), + ..OpenOptions::default() + }, + window, + cx, + ) + }) + .await + .expect("file outside the project should open"); + cx.run_until_parked(); + assert_eq!( + fake_servers.try_recv().ok().map(|_| "a new server"), + None, + "a buffer outside the project should reuse the existing server" + ); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .commands + .iter() + .map(|command| (command.command.as_ref(), command.server_id)) + .collect::>(), + vec![ + ("mdo.index", fake_server.server.server_id()), + ("mdo.today", fake_server.server.server_id()), + ], + "the reused server's commands should be listed for a buffer outside the project" + ); + }); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("index", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_eq!( + executed_commands.lock().unwrap().as_slice(), + &[("mdo.index".to_string(), Vec::new())], + ); + } + + #[gpui::test] + async fn test_toggling_arguments_focus(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + + let picker = open_selector(&workspace, &mut cx); + let picker_focus_handle = picker.read_with(&cx, |picker, cx| picker.focus_handle(cx)); + let arguments_focus_handle = picker.read_with(&cx, |picker, cx| { + picker.delegate.arguments_editor.focus_handle(cx) + }); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "the query editor should be focused when the selector opens" + ); + }); + + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (false, true), + "the arguments editor should be focused after toggling" + ); + }); + + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "the query editor should be focused after toggling again" + ); + }); + } + + #[gpui::test] + async fn test_tab_keystroke_toggles_arguments_focus(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + cx.update(|_, cx| { + cx.bind_keys([ + KeyBinding::new("tab", menu::SelectNext, None), + KeyBinding::new("tab", editor::actions::Tab, Some("Editor")), + KeyBinding::new("tab", picker::ConfirmCompletion, Some("Picker > Editor")), + KeyBinding::new( + "tab", + ToggleArgumentsFocus, + Some("LspCommandSelector > Picker > Editor"), + ), + ]); + }); + + let picker = open_selector(&workspace, &mut cx); + let picker_focus_handle = picker.read_with(&cx, |picker, cx| picker.focus_handle(cx)); + let arguments_focus_handle = picker.read_with(&cx, |picker, cx| { + picker.delegate.arguments_editor.focus_handle(cx) + }); + + cx.simulate_keystrokes("tab"); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (false, true), + "tab should win over the less specific tab bindings and focus the arguments editor" + ); + }); + + cx.simulate_keystrokes("tab"); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "tab should focus the query editor back" + ); + }); + } + + #[gpui::test] + async fn test_plain_text_arguments_are_sent_as_a_single_string(cx: &mut TestAppContext) { + let SelectorTest { + workspace, + executed_commands, + mut cx, + .. + } = init_selector_test(cx).await; + + let picker = open_selector(&workspace, &mut cx); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + picker.update_in(&mut cx, |picker, window, cx| { + picker.delegate.arguments_editor.update(cx, |editor, cx| { + editor.set_text("next friday", window, cx); + }); + }); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after execution" + ); + assert_eq!( + executed_commands + .lock() + .unwrap() + .drain(..) + .collect::>(), + vec![("mdo.today".to_string(), vec![json!("next friday")])], + "plain text that is not a JSON stream should be sent as a single string argument" + ); + } + + struct SelectorTest { + workspace: Entity, + fake_server: lsp::FakeLanguageServer, + fake_servers: UnboundedReceiver, + executed_commands: Arc)>>>, + cx: VisualTestContext, + } + + async fn init_selector_test(cx: &mut TestAppContext) -> SelectorTest { + init_selector_test_with_capabilities( + cx, + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["mdo.today".to_string(), "mdo.index".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ) + .await + } + + async fn init_selector_test_with_capabilities( + cx: &mut TestAppContext, + capabilities: lsp::ServerCapabilities, + ) -> SelectorTest { + zlog::init_test(); + let app_state = cx.update(|cx| { + let app_state = AppState::test(cx); + settings::init(cx); + super::init(cx); + editor::init(cx); + app_state + }); + app_state + .fs + .as_fake() + .insert_tree( + path!("/test"), + json!({ + "main.md": "# hi\n", + "notes.txt": "plain text\n", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["md".to_string()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }, + None, + ))); + let mut fake_servers = language_registry.register_fake_lsp( + "Markdown", + FakeLspAdapter { + name: "test-server", + capabilities, + ..FakeLspAdapter::default() + }, + ); + + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let multi_workspace = window.root(cx).expect("window should have a root"); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let cx = &mut cx; + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_id = project.update(cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("project should have a worktree") + .read(cx) + .id() + }); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: rel_path("main.md").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + + let fake_server = fake_servers.next().await.expect("server should start"); + let executed_commands = Arc::new(Mutex::new(Vec::new())); + fake_server.set_request_handler::({ + let executed_commands = executed_commands.clone(); + move |params, _| { + executed_commands + .lock() + .unwrap() + .push((params.command, params.arguments)); + async move { Ok(None) } + } + }); + cx.run_until_parked(); + + SelectorTest { + workspace, + fake_server, + fake_servers, + executed_commands, + cx: cx.clone(), + } + } + + fn open_selector( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> Entity> { + cx.dispatch_action(Toggle); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .expect("lsp command selector should be open") + .read(cx) + .picker + .clone() + }) + } + + fn selector_is_open(workspace: &Entity, cx: &VisualTestContext) -> bool { + workspace.read_with(cx, |workspace, cx| { + workspace.active_modal::(cx).is_some() + }) + } + + #[derive(Debug, PartialEq)] + struct PickerState { + executing: bool, + error: Option, + arguments_read_only: bool, + command_selectable: bool, + } + + impl PickerState { + fn idle() -> Self { + Self { + executing: false, + error: None, + arguments_read_only: false, + command_selectable: true, + } + } + } + + fn picker_state( + picker: &Entity>, + cx: &mut VisualTestContext, + ) -> PickerState { + picker.update_in(cx, |picker, window, cx| PickerState { + executing: picker.delegate.is_executing(), + error: match &picker.delegate.execution { + Execution::Failed(error) => Some(error.to_string()), + Execution::Idle | Execution::Running { .. } => None, + }, + arguments_read_only: picker.delegate.arguments_editor.read(cx).read_only(cx), + command_selectable: picker.delegate.can_select(0, window, cx), + }) + } +} diff --git a/crates/lsp_locations/src/lsp_locations.rs b/crates/lsp_locations/src/lsp_locations.rs index f82a24be57..019a0a8a93 100644 --- a/crates/lsp_locations/src/lsp_locations.rs +++ b/crates/lsp_locations/src/lsp_locations.rs @@ -1,3 +1,4 @@ +use std::any::TypeId; use std::ops::Range; use std::sync::Arc; @@ -5,17 +6,18 @@ use collections::HashMap; use editor::actions::{ FindAllReferences, GoToDeclaration, GoToDefinition, GoToImplementation, GoToTypeDefinition, }; +use editor::hover_links::{HoverLink, OpenDefinitionLocations}; use editor::{Editor, EditorSettings, GotoDefinitionKind, OpenResultsIn}; use file_icons::FileIcons; use fuzzy::StringMatchCandidate; use gpui::{ - AnyElement, App, AppContext, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, - FocusHandle, Focusable, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity, - prelude::*, + AnyElement, App, AppContext, AsyncWindowContext, Context, DismissEvent, DispatchPhase, Entity, + EventEmitter, FocusHandle, Focusable, HighlightStyle, StyledText, Subscription, Task, + TextStyle, WeakEntity, prelude::*, }; use language::{Buffer, HighlightId, LanguageAwareStyling}; use picker::{Picker, PickerDelegate}; -use project::{Location, Project, ProjectPath}; +use project::{Location, LocationLink, Project, ProjectPath}; use settings::{GoToDefinitionFallback, Settings as _}; use text::{Anchor, Point}; use theme_settings::ThemeSettings; @@ -24,6 +26,7 @@ use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt as _; use workspace::item::ItemSettings; use workspace::notifications::NotificationId; +use workspace::pane::NavigationEntry; use workspace::{ModalView, Toast, Workspace}; pub fn init(cx: &mut App) { @@ -38,6 +41,24 @@ fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context< if !editor.mode().is_full() { return; } + editor + .register_action_renderer(|editor, window, _| { + if editor.lsp_data_enabled() { + window.on_action( + TypeId::of::(), + |action, phase, window, cx| { + if phase == DispatchPhase::Bubble { + handle_definition_locations( + action.downcast_ref().expect("definition action"), + window, + cx, + ); + } + }, + ); + } + }) + .detach(); let handle = cx.entity().downgrade(); editor .register_action({ @@ -127,6 +148,65 @@ fn handle_nav_action( LspLocationsPicker::open_for_editor(kind, editor.clone(), window, cx); } +fn handle_definition_locations( + action: &OpenDefinitionLocations, + window: &mut Window, + cx: &mut App, +) { + let action = &action.0; + let Some(editor) = action.editor.upgrade() else { + return; + }; + if !editor.read(cx).lsp_data_enabled() + || !editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx) + .can_resolve(&action.position) + { + return; + } + let Some(workspace) = editor.read(cx).workspace() else { + cx.propagate(); + return; + }; + if EditorSettings::get_global(cx).lsp_results_location != OpenResultsIn::Picker + || action + .locations + .iter() + .any(|location| location.buffer.read(cx).file().is_none()) + { + cx.propagate(); + return; + } + let matches = build_location_matches(&action.locations, cx); + if matches.len() < 2 { + cx.propagate(); + return; + } + let kind = match action.kind { + GotoDefinitionKind::Symbol => LspPickerKind::Definition, + GotoDefinitionKind::Type => LspPickerKind::TypeDefinition, + GotoDefinitionKind::Declaration => LspPickerKind::Declaration, + GotoDefinitionKind::Implementation => LspPickerKind::Implementation, + }; + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + workspace.toggle_modal(window, cx, |window, cx| { + LspLocationsPicker::new( + kind, + matches, + project, + action.editor.clone(), + action.origin.clone(), + window, + cx, + ) + }); + }); +} + /// Runs the LSP query for `kind` and returns the raw locations. Returns `None` /// (and reports any error) when there is nothing to query, so the caller stops /// without an empty-results toast. Deduplication and dropping fileless results @@ -335,7 +415,7 @@ impl LspLocationsPicker { workspace .update_in(cx, |workspace, window, cx| { workspace.toggle_modal(window, cx, |window, cx| { - Self::new(kind, matches, project, editor, window, cx) + Self::new(kind, matches, project, editor, None, window, cx) }); }) .log_err(); @@ -348,11 +428,12 @@ impl LspLocationsPicker { matches: Vec, project: Entity, editor: WeakEntity, + origin: Option, window: &mut Window, cx: &mut Context, ) -> Self { let preview = picker_preview::editor_preview(project.clone(), window, cx); - let delegate = LspLocationsDelegate::new(kind, matches, project, editor); + let delegate = LspLocationsDelegate::new(kind, matches, project, editor, origin); let picker = cx.new(|cx| Picker::list_with_preview(delegate, preview, window, cx)); let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { cx.emit(DismissEvent); @@ -403,6 +484,7 @@ struct LspLocationsDelegate { kind: LspPickerKind, project: Entity, editor: WeakEntity, + origin: Option, all_matches: Vec, candidates: Arc<[StringMatchCandidate]>, matches: Vec, @@ -417,6 +499,7 @@ impl LspLocationsDelegate { all_matches: Vec, project: Entity, editor: WeakEntity, + origin: Option, ) -> Self { // Match against the line text and the file path, mirroring the fuzzy // matching every other Zed picker uses. @@ -439,6 +522,7 @@ impl LspLocationsDelegate { kind, project, editor, + origin, all_matches, candidates, matches, @@ -500,7 +584,21 @@ impl LspLocationsDelegate { }; editor .update(cx, |editor, cx| { - editor.open_location(location, split, window, cx) + if let Some(origin) = self.origin.take() { + editor.navigate_to_hover_links( + None, + vec![HoverLink::Text(LocationLink { + origin: None, + target: location, + })], + Some(origin), + split, + window, + cx, + ) + } else { + editor.open_location(location, split, window, cx) + } }) .detach_and_log_err(cx); cx.emit(DismissEvent); @@ -818,8 +916,11 @@ fn render_matched_line(location_match: &LocationMatch, cx: &App) -> StyledText { mod tests { use super::*; use editor::test::editor_lsp_test_context::EditorLspTestContext; - use gpui::TestAppContext; + use gpui::{Modifiers, TestAppContext}; use indoc::indoc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::{sync::Mutex, time::Duration}; + use text::OffsetRangeExt as _; use workspace::Item as _; async fn rust_cx( @@ -1055,6 +1156,111 @@ mod tests { ); } + #[gpui::test] + async fn test_cmd_click_cold_locations(cx: &mut TestAppContext) { + cx.update(crate::init); + for results in [OpenResultsIn::Picker, OpenResultsIn::MultiBuffer] { + for shift in [false, true] { + for split in [false, true] { + assert_cmd_click(cx, None, shift, false, results, split, false).await; + } + } + assert_cmd_click(cx, None, false, false, results, false, true).await; + } + } + + #[gpui::test] + async fn test_cmd_click_cached_locations(cx: &mut TestAppContext) { + cx.update(crate::init); + for results in [OpenResultsIn::Picker, OpenResultsIn::MultiBuffer] { + for shift in [false, true] { + for split in [false, true] { + assert_cmd_click(cx, Some(shift), shift, false, results, split, false).await; + } + for pending in [false, true] { + assert_cmd_click(cx, Some(!shift), shift, pending, results, false, false).await; + } + } + } + } + + #[gpui::test] + async fn test_cmd_click_empty_hover_requeries(cx: &mut TestAppContext) { + cx.update(crate::init); + for count in [1, 0, 2] { + let targets = vec![(2, 14, 17); count]; + let (mut cx, requests) = cmd_click_request(cx, true, &targets).await; + let target = SOURCE.replace('ˇ', "").replace("= abc", "= «abcˇ»"); + cx.assert_editor_state(if count == 0 { SOURCE } else { &target }); + assert!(active_picker(&mut cx).is_none()); + cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1)); + assert_eq!(requests.load(Ordering::SeqCst), 2); + cx.update(|window, _| window.remove_window()); + cx.run_until_parked(); + } + } + + #[gpui::test] + async fn test_cmd_click_pending_hidden_source(cx: &mut TestAppContext) { + cx.update(crate::init); + let (mut cx, requests) = cmd_click_request(cx, false, &[(2, 14, 17)]).await; + let other = cx + .update_workspace(Editor::new_in_workspace) + .await + .expect("tab"); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update_workspace(|workspace, _, cx| { + assert_eq!(workspace.active_item_as::(cx), Some(other.clone())); + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + cx.update_workspace(|workspace, _, cx| { + assert_eq!(workspace.active_item_as::(cx), Some(other)); + }); + cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("= abc", "= «abcˇ»")); + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + async fn test_cmd_click_pending_preserves_tag_origin(cx: &mut TestAppContext) { + cx.update(crate::init); + let (mut cx, requests) = cmd_click_request(cx, false, &[(0, 3, 7), (2, 14, 17)]).await; + let moved = format!("ˇ{}", SOURCE.replace('ˇ', "")); + cx.set_selections_state(&moved); + assert!(active_picker(&mut cx).is_none()); + cx.assert_editor_state(&moved); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + cx.assert_editor_state(&moved); + let modal = active_picker(&mut cx).expect("locations picker"); + cx.update(|window, cx| { + modal.read(cx).picker.clone().update(cx, |picker, cx| { + let matches = &picker.delegate.all_matches; + let ranges = matches.iter().map(|location| location.range.clone()); + assert_eq!(ranges.collect::>(), vec![3..7, 45..48]); + picker.delegate.confirm(false, window, cx); + }); + }); + cx.run_until_parked(); + cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("main", "«mainˇ»")); + cx.dispatch_action(workspace::ReopenLastPicker); + cx.run_until_parked(); + let modal = active_picker(&mut cx).expect("reopened locations picker"); + cx.update(|window, cx| { + modal.read(cx).picker.clone().update(cx, |picker, cx| { + picker.set_selected_index(2, None, true, window, cx); + picker.delegate.confirm(false, window, cx); + }); + }); + cx.run_until_parked(); + cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("= abc", "= «abcˇ»")); + cx.dispatch_action(workspace::pane::GoToOlderTag); + cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("main", "mainˇ")); + cx.dispatch_action(workspace::pane::GoToOlderTag); + cx.assert_editor_state(SOURCE); + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + #[gpui::test] async fn test_type_definition_honors_lsp_results_location(cx: &mut TestAppContext) { cx.update(crate::init); @@ -1140,4 +1346,233 @@ mod tests { "declaration should open the picker when lsp_results_location is picker" ); } + + async fn assert_cmd_click( + cx: &mut TestAppContext, + hover: Option, + shift: bool, + pending: bool, + results: OpenResultsIn, + split: bool, + disabled: bool, + ) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let scenario = format!( + "hover={hover:?}, shift={shift}, pending={pending}, results={results:?}, split={split}, disabled={disabled}" + ); + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.lsp_results_location = Some(results); + settings.editor.excerpt_context_lines = Some(0); + }); + }); + let source = indoc! {r#" + fn main() { + let foo = (); + + let foo = (); + let bar = fˇoo; + } + "#} + .replace("\n let bar", "\n\n\n\n\n\n let bar"); + cx.set_state(&source); + if disabled { + cx.update_editor(|editor, _, _| { + editor.set_mode(editor::EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }) + }); + } + let position = cx + .editor(|editor, _, cx| editor.pixel_position_of_cursor(cx)) + .expect("click pixel"); + cx.set_selections_state(&source.replace('ˇ', "").replace("main", "ˇmain")); + cx.assert_editor_selections(vec![3..3]); + let buffer = cx.multibuffer(|buffer, _| buffer.as_singleton().expect("source buffer")); + let requests = Arc::new(Mutex::new(Vec::new())); + let completed = Arc::new(AtomicUsize::new(0)); + let delay = Duration::from_secs(1); + for kind in [false, true] { + let requests = requests.clone(); + let completed = completed.clone(); + let handler = move |params: lsp::GotoDefinitionParams, app: gpui::AsyncApp| { + let requests = requests.clone(); + let completed = completed.clone(); + async move { + let params = params.text_document_position_params; + requests.lock().expect("lock").push((kind, params.clone())); + if pending && kind == shift { + app.background_executor().timer(delay).await; + } + completed.fetch_add(1, Ordering::SeqCst); + let (start, end) = if kind { (14, 16) } else { (8, 11) }; + Ok(Some(lsp::GotoDefinitionResponse::Array(references( + params.text_document.uri, + &[(1, start, end), (3, start, end)], + )))) + } + }; + if kind { + cx.lsp + .set_request_handler::(handler); + } else { + cx.lsp + .set_request_handler::(handler); + } + } + let modifiers = Modifiers { + shift, + alt: split, + ..Modifiers::secondary_key() + }; + let mut expected_requests = Vec::new(); + let query = lsp::TextDocumentPositionParams { + text_document: lsp::TextDocumentIdentifier::new(cx.buffer_lsp_url.clone()), + position: lsp::Position::new(9, 15), + }; + if let Some(shift) = hover { + let mut modifiers = modifiers; + modifiers.shift = shift; + cx.simulate_mouse_move(position, None, modifiers); + expected_requests.push((shift, query.clone())); + } + if pending { + cx.simulate_mouse_move(position, None, modifiers); + expected_requests.push((shift, query.clone())); + } + assert_eq!(*requests.lock().expect("requests"), expected_requests); + assert_eq!( + completed.load(Ordering::SeqCst), + usize::from(hover.is_some()) + ); + cx.simulate_click(position, modifiers); + if pending { + cx.executor().advance_clock(delay); + } + cx.run_until_parked(); + if !disabled && hover != Some(shift) { + expected_requests.push((shift, query)); + } + assert_eq!( + *requests.lock().expect("requests"), + expected_requests, + "{scenario}" + ); + let picker = active_picker(&mut cx); + let source_editor = cx.editor.clone(); + let (start, end) = if shift { (14, 16) } else { (8, 11) }; + let expected_ranges = vec![ + Point::new(1, start)..Point::new(1, end), + Point::new(3, start)..Point::new(3, end), + ]; + cx.update_workspace(|workspace, _, cx| { + assert_eq!(workspace.panes().len(), 1 + usize::from(split)); + let active = workspace.active_item_as::(cx).expect("editor"); + if disabled { + assert!(picker.is_none()); + assert_eq!(active, source_editor); + assert_eq!(workspace.items(cx).count(), 1); + return; + } + let actual_ranges = if results == OpenResultsIn::Picker && !split { + let picker = picker.expect("locations picker"); + let delegate = &picker.read(cx).picker.read(cx).delegate; + let kind = match shift { + true => LspPickerKind::TypeDefinition, + false => LspPickerKind::Definition, + }; + assert_eq!(delegate.kind, kind); + assert_eq!(workspace.items(cx).count(), 1); + delegate + .all_matches + .iter() + .map(|location| { + assert_eq!(location.buffer, buffer); + location.anchor_range.to_point(buffer.read(cx)) + }) + .collect::>() + } else { + assert!(picker.is_none()); + let multibuffer = active.read(cx).buffer().read(cx); + assert!(!multibuffer.is_singleton()); + assert_eq!( + multibuffer.all_buffers_iter().collect::>(), + vec![buffer.clone()] + ); + assert_eq!(workspace.items(cx).count(), 2); + multibuffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.primary.to_point(buffer.read(cx))) + .collect::>() + }; + assert_eq!(actual_ranges, expected_ranges); + }); + cx.update(|window, _| window.remove_window()); + cx.run_until_parked(); + } + + async fn cmd_click_request( + cx: &mut TestAppContext, + empty_hover: bool, + targets: &[(u32, u32, u32)], + ) -> (EditorLspTestContext, Arc) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.lsp_results_location = Some(OpenResultsIn::Picker); + settings.editor.go_to_definition_fallback = Some(GoToDefinitionFallback::None); + }); + }); + cx.set_state(SOURCE); + let requests = Arc::new(AtomicUsize::new(0)); + let locations = references(cx.buffer_lsp_url.clone(), targets); + cx.set_request_handler::({ + let requests = requests.clone(); + move |uri, params, app| { + let params = params.text_document_position_params; + assert_eq!(params.text_document.uri, uri); + assert_eq!(params.position, lsp::Position::new(1, 9)); + let empty = requests.fetch_add(1, Ordering::SeqCst) == 0 && empty_hover; + let locations = locations.clone(); + async move { + if !empty_hover { + app.background_executor() + .timer(Duration::from_secs(1)) + .await; + } + Ok(Some(lsp::GotoDefinitionResponse::Array(if empty { + Vec::new() + } else { + locations + }))) + } + } + }); + let position = cx + .editor(|editor, _, cx| editor.pixel_position_of_cursor(cx)) + .expect("click pixel"); + if empty_hover { + cx.simulate_mouse_move(position, None, Modifiers::secondary_key()); + } + cx.simulate_click(position, Modifiers::secondary_key()); + cx.simulate_modifiers_change(Modifiers::none()); + (cx, requests) + } } diff --git a/crates/markdown/src/html.rs b/crates/markdown/src/html.rs index cf37f6138c..32ffa861f7 100644 --- a/crates/markdown/src/html.rs +++ b/crates/markdown/src/html.rs @@ -1,3 +1,14 @@ mod html_minifier; pub(crate) mod html_parser; mod html_rendering; + +use html5ever::{ParseOpts, parse_document, tendril::TendrilSink}; +use markup5ever_rcdom::RcDom; +use std::io::{self, Read}; + +#[inline(never)] +fn parse_html(mut reader: &mut dyn Read) -> io::Result { + parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut reader) +} diff --git a/crates/markdown/src/html/html_minifier.rs b/crates/markdown/src/html/html_minifier.rs index a7d5ad0be0..47cde3c79f 100644 --- a/crates/markdown/src/html/html_minifier.rs +++ b/crates/markdown/src/html/html_minifier.rs @@ -1,8 +1,8 @@ use html5ever::{ - Attribute, ParseOpts, QualName, parse_document, - tendril::{Tendril, TendrilSink, fmt::UTF8}, + Attribute, QualName, + tendril::{Tendril, fmt::UTF8}, }; -use markup5ever_rcdom::{Node, NodeData, RcDom}; +use markup5ever_rcdom::{Node, NodeData}; use std::{cell::RefCell, io, rc::Rc, str}; #[derive(Default)] @@ -38,10 +38,8 @@ where /// /// Will return `Err` if unable to write to the output writer. #[inline] - pub fn minify(&mut self, mut r: &mut R) -> io::Result<()> { - let dom = parse_document(RcDom::default(), ParseOpts::default()) - .from_utf8() - .read_from(&mut r)?; + pub fn minify(&mut self, reader: &mut dyn io::Read) -> io::Result<()> { + let dom = super::parse_html(reader)?; if !self.options.omit_doctype { self.w.write_all(b"")?; diff --git a/crates/markdown/src/html/html_parser.rs b/crates/markdown/src/html/html_parser.rs index 5ab9a48b72..5cd87d5d2b 100644 --- a/crates/markdown/src/html/html_parser.rs +++ b/crates/markdown/src/html/html_parser.rs @@ -1,10 +1,8 @@ use std::{cell::RefCell, collections::HashMap, mem, ops::Range}; use gpui::{DefiniteLength, FontWeight, SharedString, TextAlign, px, relative}; -use html5ever::{ - Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink, -}; -use markup5ever_rcdom::{Node, NodeData, RcDom}; +use html5ever::{Attribute, LocalName, local_name}; +use markup5ever_rcdom::{Node, NodeData}; use pulldown_cmark::{Alignment, HeadingLevel}; use stacksafe::stacksafe; @@ -195,10 +193,7 @@ pub(crate) fn parse_html_block( ) -> Option { let bytes = cleanup_html(source); let mut cursor = std::io::Cursor::new(bytes); - let dom = parse_document(RcDom::default(), ParseOpts::default()) - .from_utf8() - .read_from(&mut cursor) - .ok()?; + let dom = super::parse_html(&mut cursor).ok()?; let mut children = Vec::new(); parse_html_node( diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 53c1aadd88..9dc936e3c7 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -5,7 +5,6 @@ mod path_range; mod selection; use base64::Engine as _; - use gpui::EdgesRefinement; use gpui::HitboxBehavior; use gpui::UnderlineStyle; @@ -41,7 +40,7 @@ use gpui::{ TextStyle, TextStyleRefinement, WrappedLineLayout, actions, canvas, img, point, quad, relative, size, }; -use language::{CharClassifier, Language, LanguageRegistry, Rope}; +use language::{CharClassifier, Language, LanguageRegistry, ResolvedHighlights, Rope}; use parser::CodeBlockMetadata; use parser::{ MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock, parse_links_only, @@ -977,20 +976,7 @@ impl Markdown { return None; }; - match kind { - CodeBlockKind::FencedLang(language) => self - .parsed_markdown - .languages_by_name - .get(language) - .cloned(), - CodeBlockKind::FencedSrc(path_range) => self - .parsed_markdown - .languages_by_path - .get(&path_range.path) - .cloned(), - CodeBlockKind::Fenced => self.parsed_markdown.fallback_code_block_language.clone(), - CodeBlockKind::Indented => None, - } + self.parsed_markdown.code_block_language(kind) }) } @@ -1258,6 +1244,7 @@ impl Markdown { footnote_definitions: HashMap::default(), link_definition_spans: Arc::default(), fallback_code_block_language: None, + code_block_highlights: Arc::default(), }, Default::default(), ); @@ -1335,23 +1322,23 @@ impl Markdown { } } - ( - ParsedMarkdown { - source, - events: Arc::from(events), - languages_by_name, - languages_by_path, - root_block_starts: Arc::from(root_block_starts), - html_blocks, - metadata_blocks, - mermaid_diagrams, - heading_slugs, - footnote_definitions, - link_definition_spans: Arc::from(link_definition_spans), - fallback_code_block_language, - }, - images_by_source_offset, - ) + let mut parsed = ParsedMarkdown { + source, + events: Arc::from(events), + languages_by_name, + languages_by_path, + root_block_starts: Arc::from(root_block_starts), + html_blocks, + metadata_blocks, + mermaid_diagrams, + heading_slugs, + footnote_definitions, + link_definition_spans: Arc::from(link_definition_spans), + fallback_code_block_language, + code_block_highlights: Arc::default(), + }; + parsed.code_block_highlights = Arc::new(compute_code_block_highlights(&parsed)); + (parsed, images_by_source_offset) }); cx.spawn(async move |this, cx| { @@ -1497,8 +1484,11 @@ pub struct ParsedMarkdown { pub footnote_definitions: HashMap, pub(crate) link_definition_spans: Arc<[Range]>, pub(crate) fallback_code_block_language: Option>, + pub(crate) code_block_highlights: Arc, } +pub(crate) type CodeBlockHighlights = HashMap; + impl ParsedMarkdown { pub fn source(&self) -> &SharedString { &self.source @@ -1606,6 +1596,109 @@ impl ParsedMarkdown { selection, ) } + + pub(crate) fn code_block_language(&self, kind: &CodeBlockKind) -> Option> { + match kind { + CodeBlockKind::FencedLang(name) => self.languages_by_name.get(name).cloned(), + CodeBlockKind::FencedSrc(path_range) => { + self.languages_by_path.get(&path_range.path).cloned() + } + CodeBlockKind::Fenced => self.fallback_code_block_language.clone(), + CodeBlockKind::Indented => None, + } + } +} + +struct PendingCodeBlock<'a> { + language: Arc, + texts: Vec<(Range, &'a str)>, +} + +fn compute_code_block_highlights(parsed: &ParsedMarkdown) -> CodeBlockHighlights { + let mut code_block_highlights = CodeBlockHighlights::default(); + let mut pending_block: Option = None; + for (range, event) in parsed.events.iter() { + match event { + MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, .. }) => { + if parsed.mermaid_diagrams.contains_key(&range.start) { + pending_block = None; + continue; + } + pending_block = parsed + .code_block_language(kind) + .map(|language| PendingCodeBlock { + language, + texts: Vec::new(), + }); + } + MarkdownEvent::End(MarkdownTagEnd::CodeBlock) => { + if let Some(block) = pending_block.take() { + highlight_code_block(block, &mut code_block_highlights); + } + } + MarkdownEvent::Text => { + if let Some(block) = &mut pending_block { + block + .texts + .push((range.clone(), &parsed.source[range.clone()])); + } + } + MarkdownEvent::SubstitutedText(text) => { + if let Some(block) = &mut pending_block { + block.texts.push((range.clone(), text.as_str())); + } + } + _ => {} + } + } + code_block_highlights +} + +fn highlight_code_block(block: PendingCodeBlock, code_block_highlights: &mut CodeBlockHighlights) { + let mut combined = String::new(); + let mut text_offsets = Vec::with_capacity(block.texts.len()); + for (_, text) in &block.texts { + text_offsets.push(combined.len()); + combined.push_str(text); + } + let resolved = block + .language + .highlight_text_resolved(&Rope::from(combined.as_str()), 0..combined.len()); + if resolved.runs.is_empty() { + return; + } + if let [(source_range, _)] = block.texts.as_slice() { + code_block_highlights.insert(source_range.start, resolved); + return; + } + let mut runs = resolved.runs.iter().peekable(); + for ((source_range, text), text_offset) in block.texts.iter().zip(text_offsets) { + let text_end = text_offset + text.len(); + let mut text_runs = Vec::new(); + while let Some((run_range, highlight_id)) = runs.peek() { + if run_range.start >= text_end { + break; + } + let start = run_range.start.max(text_offset); + let end = run_range.end.min(text_end); + if end > start { + text_runs.push((start - text_offset..end - text_offset, *highlight_id)); + } + if run_range.end > text_end { + break; + } + runs.next(); + } + if !text_runs.is_empty() { + code_block_highlights.insert( + source_range.start, + ResolvedHighlights { + sources: resolved.sources.clone(), + runs: text_runs.into(), + }, + ); + } + } } pub enum AutoscrollBehavior { @@ -1938,9 +2031,15 @@ impl MarkdownElement { }; let mut heading_style = self.style.heading.clone(); - let heading_text_style = heading_style.text_style().clone(); + let mut heading_text_style = heading_style.text_style().clone(); heading.style().refine(&heading_style); + if let Some(level_style) = + heading_level_style(level, self.style.heading_level_styles.as_ref()) + { + heading_text_style.refine(level_style); + } + builder.push_text_style(TextStyleRefinement { text_align: Some(align), ..heading_text_style @@ -2485,12 +2584,7 @@ impl Element for MarkdownElement { next_search_highlight_ix: 0, } }; - let mut builder = MarkdownElementBuilder::new( - &self.style.container_style, - self.style.base_text_style.clone(), - self.style.syntax.clone(), - highlights, - ); + let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = { let markdown = self.markdown.read(cx); ( @@ -2501,6 +2595,13 @@ impl Element for MarkdownElement { markdown.mermaid_state.clone(), ) }; + let mut builder = MarkdownElementBuilder::new( + &self.style.container_style, + self.style.base_text_style.clone(), + self.style.syntax.clone(), + highlights, + parsed_markdown.code_block_highlights.clone(), + ); let markdown_end = if let Some(last) = parsed_markdown.events.last() { last.0.end } else { @@ -2664,19 +2765,7 @@ impl Element for MarkdownElement { continue; } - let language = match kind { - CodeBlockKind::Fenced => { - parsed_markdown.fallback_code_block_language.clone() - } - CodeBlockKind::FencedLang(language) => { - parsed_markdown.languages_by_name.get(language).cloned() - } - CodeBlockKind::FencedSrc(path_range) => parsed_markdown - .languages_by_path - .get(&path_range.path) - .cloned(), - _ => None, - }; + let language = parsed_markdown.code_block_language(kind); let is_indented = matches!(kind, CodeBlockKind::Indented); let scroll_handle = if self.style.code_block_overflow_x_scroll { @@ -3341,24 +3430,28 @@ fn apply_heading_style( }; } - if let Some(styles) = custom_styles { - let style_opt = match level { - pulldown_cmark::HeadingLevel::H1 => &styles.h1, - pulldown_cmark::HeadingLevel::H2 => &styles.h2, - pulldown_cmark::HeadingLevel::H3 => &styles.h3, - pulldown_cmark::HeadingLevel::H4 => &styles.h4, - pulldown_cmark::HeadingLevel::H5 => &styles.h5, - pulldown_cmark::HeadingLevel::H6 => &styles.h6, - }; - - if let Some(style) = style_opt { - heading.style().text = style.clone(); - } + if let Some(style) = heading_level_style(level, custom_styles) { + heading.style().text = style.clone(); } heading } +fn heading_level_style( + level: pulldown_cmark::HeadingLevel, + custom_styles: Option<&HeadingLevelStyles>, +) -> Option<&TextStyleRefinement> { + let styles = custom_styles?; + match level { + pulldown_cmark::HeadingLevel::H1 => styles.h1.as_ref(), + pulldown_cmark::HeadingLevel::H2 => styles.h2.as_ref(), + pulldown_cmark::HeadingLevel::H3 => styles.h3.as_ref(), + pulldown_cmark::HeadingLevel::H4 => styles.h4.as_ref(), + pulldown_cmark::HeadingLevel::H5 => styles.h5.as_ref(), + pulldown_cmark::HeadingLevel::H6 => styles.h6.as_ref(), + } +} + fn render_wrap_code_block_button( id: usize, is_wrapped: bool, @@ -3577,6 +3670,7 @@ struct MarkdownElementBuilder { base_text_style: TextStyle, text_style_stack: Vec, code_block_stack: Vec>>, + code_block_highlights: Arc, link_depth: usize, list_stack: Vec, table: TableState, @@ -3678,6 +3772,7 @@ impl MarkdownElementBuilder { base_text_style: TextStyle, syntax_theme: Arc, highlights: MarkdownHighlights, + code_block_highlights: Arc, ) -> Self { Self { div_stack: vec![{ @@ -3696,6 +3791,7 @@ impl MarkdownElementBuilder { base_text_style, text_style_stack: Vec::new(), code_block_stack: Vec::new(), + code_block_highlights, link_depth: 0, list_stack: Vec::new(), table: TableState::default(), @@ -3927,24 +4023,34 @@ impl MarkdownElementBuilder { // Compute the base text style once let text_style = self.text_style(); - if let Some(Some(language)) = self.code_block_stack.last() { + if let Some(language) = self.code_block_stack.last().and_then(Option::as_ref) + && let Some(resolved) = self.code_block_highlights.get(&source_range.start) + { + let runs = if resolved.is_current() { + resolved.runs.clone() + } else { + language + .highlight_text_resolved(&Rope::from(text), 0..text.len()) + .runs + }; let mut offset = 0; - for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) { - if range.start > offset { + for (run_range, highlight_id) in runs.iter() { + if run_range.start > offset { self.pending_line .runs - .push(text_style.to_run(range.start - offset)); + .push(text_style.to_run(run_range.start - offset)); } - let run_len = range.len(); - if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() { + let run_len = run_range.len(); + let highlight = self.syntax_theme.get(*highlight_id).cloned(); + if let Some(highlight) = highlight { self.pending_line .runs .push(text_style.clone().highlight(highlight).to_run(run_len)); } else { self.pending_line.runs.push(text_style.to_run(run_len)); } - offset = range.end; + offset = run_range.end; } if offset < text.len() { @@ -4070,7 +4176,11 @@ impl MarkdownElementBuilder { layout: text.layout().clone(), source_mappings: line.source_mappings, source_end: self.current_source_index, - language: self.code_block_stack.last().cloned().flatten(), + language: self + .code_block_stack + .last() + .and_then(|entry| entry.as_ref()) + .cloned(), text_align, highlights, code_chips: line.code_chips.into_iter().collect(), @@ -5154,9 +5264,118 @@ mod tests { }); } + #[gpui::test] + fn test_code_block_highlights_cached_at_parse_time(cx: &mut TestAppContext) { + let source = "```rust\nfn main() {}\n```"; + let (language, markdown) = markdown_with_rust_language(source, cx); + + let code_text = "fn main() {}\n"; + let code_start = source.find(code_text).unwrap(); + let cached = cached_code_block_highlights(&markdown, code_start, cx); + let expected = language.highlight_text(&Rope::from(code_text), 0..code_text.len()); + assert_eq!( + cached.runs.to_vec(), + expected, + "highlights cached at parse time must match direct highlighting" + ); + assert!(!expected.is_empty(), "rust code must produce highlights"); + assert!(cached.is_current()); + + language.set_theme(&rust_test_theme()); + let stale = cached_code_block_highlights(&markdown, code_start, cx); + assert!( + !stale.is_current(), + "a theme change must make parse-time highlights stale so rendering re-resolves them" + ); + } + + #[gpui::test] + fn test_code_block_highlights_reused_when_streaming(cx: &mut TestAppContext) { + let source = "```rust\nfn main() {}\n```\n\nSome trailing text"; + let (language, markdown) = markdown_with_rust_language(source, cx); + + let code_start = source.find("fn main").unwrap(); + let first_parse_highlights = cached_code_block_highlights(&markdown, code_start, cx); + + markdown.update(cx, |markdown, cx| { + markdown.append(" that keeps streaming in\n\n```rust\nlet x = 1;\n```", cx) + }); + cx.run_until_parked(); + + let second_parse_highlights = cached_code_block_highlights(&markdown, code_start, cx); + assert!( + Arc::ptr_eq(&first_parse_highlights.runs, &second_parse_highlights.runs), + "highlights of an unchanged code block must be reused across parses" + ); + + markdown.update(cx, |markdown, cx| { + markdown.replace("```rust\nfn main() { panic!() }\n```", cx) + }); + cx.run_until_parked(); + + let changed_highlights = cached_code_block_highlights(&markdown, code_start, cx); + let changed_code = "fn main() { panic!() }\n"; + assert_eq!( + changed_highlights.runs.as_ref(), + language + .highlight_text_resolved(&Rope::from(changed_code), 0..changed_code.len()) + .runs + .as_ref(), + "highlights of a changed code block must be recomputed" + ); + } + + #[gpui::test] + fn test_mermaid_blocks_are_not_highlighted(cx: &mut TestAppContext) { + ensure_theme_initialized(cx); + let mermaid_language = Arc::new( + Language::new( + LanguageConfig { + name: "mermaid".into(), + ..LanguageConfig::default() + }, + Some(language::rust_lang().grammar().unwrap().ts_language.clone()), + ) + .with_highlights_query("(identifier) @variable") + .unwrap(), + ); + let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); + language_registry.add(mermaid_language); + + let source = "```mermaid\ngraph TD;\n```"; + let markdown = cx.new(|cx| { + Markdown::new_with_options( + source.into(), + Some(language_registry), + None, + MarkdownOptions { + render_mermaid_diagrams: true, + ..MarkdownOptions::default() + }, + cx, + ) + }); + cx.run_until_parked(); + + markdown.read_with(cx, |markdown, _| { + let parsed = markdown.parsed_markdown(); + assert_eq!( + parsed.mermaid_diagrams.len(), + 1, + "the block must be recognized as a mermaid diagram" + ); + assert_eq!( + parsed.code_block_highlights.len(), + 0, + "mermaid blocks must not be highlighted" + ); + }); + } + #[gpui::test] fn test_fallback_language_highlights_untagged_code_blocks(cx: &mut TestAppContext) { let language = language::rust_lang(); + language.set_theme(&rust_test_theme()); let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); language_registry.add(language.clone()); @@ -5176,19 +5395,19 @@ mod tests { cx.run_until_parked(); markdown.read_with(cx, |markdown, _| { - let fallback = markdown - .parsed_markdown() + let parsed = markdown.parsed_markdown(); + let fallback = parsed .fallback_code_block_language .as_ref() .expect("the fallback language must be resolved for untagged code blocks"); assert_eq!(fallback.name(), language.name()); - assert_eq!( - markdown - .first_code_block_language() - .map(|language| language.name()), - Some(language.name()), - "untagged code blocks must resolve to the fallback language" - ); + + let code_start = source.find("fn main").unwrap(); + let cached = parsed + .code_block_highlights + .get(&code_start) + .expect("untagged code blocks must be highlighted with the fallback language"); + assert!(!cached.runs.is_empty()); }); } @@ -6826,7 +7045,7 @@ mod tests { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.theme.ui_font_size = Some(16.0.into()); - settings.theme.markdown_preview_font_size = None; + settings.markdown_preview.get_or_insert_default().font_size = None; }); }); }); @@ -6854,7 +7073,7 @@ mod tests { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.theme.ui_font_size = Some(20.0.into()); - settings.theme.markdown_preview_font_size = None; + settings.markdown_preview.get_or_insert_default().font_size = None; }); }); }); @@ -6915,7 +7134,7 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.markdown_preview_font_size = Some(14.0.into()); + settings.markdown_preview.get_or_insert_default().font_size = Some(14.0.into()); settings.theme.buffer_line_height = Some(settings::BufferLineHeight::Custom(1.5)); }); @@ -7120,7 +7339,7 @@ mod tests { /// Note that this does not reproduce the `WithRemSize` wrapper the real /// preview renders inside, so rem-derived lengths (such as the `rems(1.3)` /// prose leading) resolve against the default rem size rather than - /// `markdown_preview_font_size`. Code block metrics are unaffected, since + /// `markdown_preview.font_size`. Code block metrics are unaffected, since /// the code font size is set as absolute pixels. fn rendered_prose_and_code_line_heights( cx: &mut TestAppContext, @@ -7131,7 +7350,8 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.markdown_preview_font_size = Some(font_size.into()); + settings.markdown_preview.get_or_insert_default().font_size = + Some(font_size.into()); settings.theme.buffer_line_height = Some(settings::BufferLineHeight::Custom(buffer_line_height)); }); @@ -7156,4 +7376,53 @@ mod tests { }; (prose.layout.line_height(), code.layout.line_height()) } + + fn rust_test_theme() -> SyntaxTheme { + SyntaxTheme::new( + [ + ("keyword", gpui::red()), + ("function", gpui::blue()), + ("type", gpui::green()), + ] + .into_iter() + .map(|(name, color)| { + ( + name.to_owned(), + gpui::HighlightStyle { + color: Some(color), + ..gpui::HighlightStyle::default() + }, + ) + }), + ) + } + + fn markdown_with_rust_language( + source: &str, + cx: &mut TestAppContext, + ) -> (Arc, Entity) { + let language = language::rust_lang(); + language.set_theme(&rust_test_theme()); + let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); + language_registry.add(language.clone()); + let source = SharedString::from(source.to_owned()); + let markdown = cx.new(|cx| Markdown::new(source, Some(language_registry), None, cx)); + cx.run_until_parked(); + (language, markdown) + } + + fn cached_code_block_highlights( + markdown: &Entity, + code_start: usize, + cx: &mut TestAppContext, + ) -> ResolvedHighlights { + markdown.read_with(cx, |markdown, _| { + markdown + .parsed_markdown() + .code_block_highlights + .get(&code_start) + .expect("code block highlights must be computed during parse") + .clone() + }) + } } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 69addd9034..1df6d72026 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/markdown_preview.rs" +doctest = false [features] test-support = [] @@ -34,6 +35,7 @@ zed_actions.workspace = true [dev-dependencies] buffer_diff.workspace = true +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } fs.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/markdown_preview/src/markdown_preview_settings.rs b/crates/markdown_preview/src/markdown_preview_settings.rs index 94bd4ab597..ab22cd6476 100644 --- a/crates/markdown_preview/src/markdown_preview_settings.rs +++ b/crates/markdown_preview/src/markdown_preview_settings.rs @@ -4,6 +4,8 @@ use settings::{IntoGpui, RegisterSetting, Settings}; /// The settings for the markdown preview. #[derive(Clone, Copy, Debug, Default, RegisterSetting)] pub struct MarkdownPreviewSettings { + /// Whether to automatically open Markdown files in the preview. + pub open_markdown_files_in_preview: bool, /// The maximum width of the rendered markdown content, or `None` to render /// content edge to edge. pub max_width: Option, @@ -17,6 +19,9 @@ impl Settings for MarkdownPreviewSettings { } else { None }; - Self { max_width } + Self { + open_markdown_files_in_preview: content.open_markdown_files_in_preview.unwrap_or(false), + max_width, + } } } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 2f18d914a3..4d0f53da1e 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,6 +1,7 @@ use std::any::TypeId; use std::borrow::Cow; use std::cmp::min; +use std::collections::HashSet; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -13,9 +14,10 @@ use editor::{ Editor, EditorEvent, EditorSettingsScrollbarProxy, MultiBufferOffset, SelectionEffects, }; use gpui::{ - App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, - InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, - ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px, + App, ClipboardItem, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, + ImageSource, InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, + RetainAllImageCache, ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, + Window, point, px, }; use language::{Buffer, LanguageRegistry}; use markdown::{ @@ -55,6 +57,12 @@ use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); +fn reset_persisted_font_size(settings: &mut settings::SettingsContent) { + if let Some(markdown_preview) = settings.markdown_preview.as_mut() { + markdown_preview.font_size = None; + } +} + pub struct MarkdownPreviewView { workspace: WeakEntity, active_editor: Option, @@ -102,6 +110,11 @@ struct EditorState { _subscription: Subscription, } +#[derive(Default)] +struct SuppressedAutoPreviews(HashSet); + +impl Global for SuppressedAutoPreviews {} + #[derive(Clone, Copy, Debug)] pub enum MarkdownPreviewEvent { SourceEditorChanged, @@ -114,7 +127,69 @@ enum PreviewLinkTarget { } impl MarkdownPreviewView { - pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { + pub fn register(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + cx.default_global::(); + let workspace_entity = cx.entity(); + cx.subscribe_in( + &workspace_entity, + window, + |workspace, _, event: &workspace::Event, window, cx| { + let workspace::Event::ItemAdded { item } = event else { + return; + }; + if cx + .global_mut::() + .0 + .remove(&item.item_id()) + { + return; + } + if !MarkdownPreviewSettings::get_global(cx).open_markdown_files_in_preview + || workspace.is_restoring() + { + return; + } + let Some(editor) = item.downcast::() else { + return; + }; + if !Self::is_markdown_editor(workspace, &editor, cx) { + return; + } + let Some(pane) = workspace.pane_for_item_id(item.item_id()) else { + return; + }; + let replaces_editor = !item.is_dirty(cx); + let replaces_temporary_editor = + replaces_editor && pane.read(cx).is_active_preview_item(item.item_id()); + let activate = workspace.active_pane() == &pane + && pane + .read(cx) + .active_item() + .is_some_and(|active_item| active_item.item_id() == item.item_id()); + let focus = editor.focus_handle(cx).contains_focused(window, cx); + let preview = Self::activate_or_add_preview( + workspace, + editor, + pane.clone(), + activate, + focus, + window, + cx, + ); + if !replaces_editor { + return; + } + pane.update(cx, |pane, cx| { + if replaces_temporary_editor { + pane.replace_preview_item_id(preview.entity_id(), window, cx); + } else { + pane.remove_item(item.item_id(), false, false, window, cx); + } + }); + }, + ) + .detach(); + workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { let pane = workspace.active_pane().clone(); @@ -162,7 +237,7 @@ impl MarkdownPreviewView { window: &mut Window, cx: &mut Context, ) { - Self::activate_or_add_preview(workspace, editor, pane, true, window, cx); + Self::activate_or_add_preview(workspace, editor, pane, true, true, window, cx); } pub fn open_preview_to_the_side_of_pane( @@ -173,7 +248,15 @@ impl MarkdownPreviewView { cx: &mut Context, ) { let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx); - Self::activate_or_add_preview(workspace, editor.clone(), target_pane, false, window, cx); + Self::activate_or_add_preview( + workspace, + editor.clone(), + target_pane, + false, + false, + window, + cx, + ); editor.focus_handle(cx).focus(window, cx); } @@ -181,42 +264,46 @@ impl MarkdownPreviewView { workspace: &mut Workspace, editor: Entity, pane: Entity, + activate: bool, focus: bool, window: &mut Window, cx: &mut Context, - ) { - let existing_view_idx = + ) -> Entity { + let existing_view = Self::find_existing_independent_preview_item_idx(pane.read(cx), &editor, cx); - if let Some(existing_view_idx) = existing_view_idx { + let view = if let Some((existing_view, existing_view_idx)) = existing_view { pane.update(cx, |pane, cx| { - pane.activate_item(existing_view_idx, focus, focus, window, cx); + pane.activate_item(existing_view_idx, activate, focus, window, cx); }); + existing_view } else { let view = Self::create_markdown_view(workspace, editor, window, cx); pane.update(cx, |pane, cx| { - pane.add_item(Box::new(view), focus, focus, None, window, cx) + pane.add_item(Box::new(view.clone()), activate, focus, None, window, cx) }); - } + view + }; cx.notify(); + view } fn find_existing_independent_preview_item_idx( pane: &Pane, editor: &Entity, cx: &App, - ) -> Option { + ) -> Option<(Entity, usize)> { let target_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; - pane.items_of_type::() - .find(|view| { - // Only look for independent (Default mode) previews, not Follow previews. - // Match by buffer entity rather than editor entity so the lookup survives - // workspace restoration, where the preview's bound editor may differ from - // the editor the user is currently invoking the action on even though both - // wrap the same source buffer. - view.read(cx).mode == MarkdownPreviewMode::Default - && view.read(cx).is_previewing(&target_buffer, cx) - }) - .and_then(|view| pane.index_for_item(&view)) + let view = pane.items_of_type::().find(|view| { + // Only look for independent (Default mode) previews, not Follow previews. + // Match by buffer entity rather than editor entity so the lookup survives + // workspace restoration, where the preview's bound editor may differ from + // the editor the user is currently invoking the action on even though both + // wrap the same source buffer. + view.read(cx).mode == MarkdownPreviewMode::Default + && view.read(cx).is_previewing(&target_buffer, cx) + })?; + let index = pane.index_for_item(&view)?; + Some((view, index)) } fn is_previewing(&self, buffer: &Entity, cx: &App) -> bool { @@ -433,6 +520,24 @@ impl MarkdownPreviewView { false } + fn is_markdown_editor(workspace: &Workspace, editor: &Entity, cx: &App) -> bool { + let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { + return false; + }; + let buffer = buffer.read(cx); + if let Some(language) = buffer.language() { + return language.name() == "Markdown"; + } + let Some(file) = buffer.file() else { + return false; + }; + let languages = workspace.project().read(cx).languages().clone(); + let Some(markdown) = languages.available_language_for_name("Markdown") else { + return false; + }; + languages.language_for_file(file, None, cx) == Some(markdown.id()) + } + fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { if let Some(active) = &self.active_editor && active.editor == editor @@ -466,11 +571,8 @@ impl MarkdownPreviewView { (index, focused) }); if let Some(selection_start) = selection_start { - this.sync_preview_to_source_index( - selection_start, - editor_is_focused, - cx, - ); + let reveal = editor_is_focused || this.focus_handle.is_focused(window); + this.sync_preview_to_source_index(selection_start, reveal, cx); cx.notify(); } } @@ -758,7 +860,7 @@ impl MarkdownPreviewView { }; update_settings_file(fs, cx, move |settings, cx| { let size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx) + delta; - settings.theme.markdown_preview_font_size = + settings.markdown_preview.get_or_insert_default().font_size = Some(f32::from(theme_settings::clamp_font_size(size)).into()); }); } else { @@ -780,7 +882,7 @@ impl MarkdownPreviewView { return; }; update_settings_file(fs, cx, move |settings, _| { - settings.theme.markdown_preview_font_size = None; + reset_persisted_font_size(settings); }); } else { theme_settings::reset_markdown_preview_font_size(cx); @@ -922,6 +1024,10 @@ impl MarkdownPreviewView { window.defer(cx, move |window, cx| { workspace.update(cx, |workspace, cx| { if !workspace.activate_item(&editor, true, true, window, cx) { + // Re-adding the source emits `ItemAdded`; suppress its automatic preview once. + cx.global_mut::() + .0 + .insert(editor.entity_id()); workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); } @@ -935,7 +1041,7 @@ impl MarkdownPreviewView { }); } - /// Returns the theme chosen in `markdown_preview_theme`, or `None` if the + /// Returns the theme chosen in `markdown_preview.theme`, or `None` if the /// user hasn't set one or it can't be resolved. fn resolve_preview_theme(&self, cx: &App) -> Option> { let theme_settings = ThemeSettings::get_global(cx); @@ -1534,6 +1640,14 @@ impl Item for MarkdownPreviewView { .unwrap_or_else(|| SharedString::from("Markdown Preview")) } + fn tab_tooltip_text(&self, cx: &App) -> Option { + self.active_editor + .as_ref()? + .editor + .read(cx) + .tab_tooltip_text(cx) + } + fn telemetry_event_text(&self) -> Option<&'static str> { Some("Markdown Preview Opened") } @@ -2092,7 +2206,9 @@ mod tests { use crate::markdown_preview_view::resolve_project_path_for_preview_image; use buffer_diff::BufferDiff; use editor::Editor; + use editor::items::open_resolved_target; use fs::FakeFs; + use gpui::UpdateGlobal as _; use gpui::{ App, AppContext as _, Entity, Focusable as _, Modifiers, TestAppContext, WindowHandle, px, }; @@ -2103,15 +2219,19 @@ mod tests { use std::sync::Arc; use std::time::Duration; use util::path; - use util::paths::PathStyle; + use util::paths::{PathStyle, PathWithPosition}; use util::rel_path::{RelPath, rel_path}; use util::test::TempTree; - use workspace::item::SerializableItem; + use workspace::item::{Item, ItemHandle, SerializableItem}; + use workspace::path_link::{OpenTarget, OpenTargetFoundBy}; use workspace::{ - AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths, + AppState, ItemId, MultiWorkspace, Pane, SaveIntent, Workspace, WorkspaceId, open_paths, }; - use super::{MarkdownPreviewView, filter_non_rendered_matches, open_preview_url}; + use super::{ + MarkdownPreviewView, filter_non_rendered_matches, open_preview_url, + reset_persisted_font_size, + }; #[test] fn filters_matches_in_non_rendered_link_source() { @@ -2134,6 +2254,57 @@ mod tests { ); } + #[gpui::test] + async fn preview_tab_tooltip_matches_source_file_path(cx: &mut TestAppContext) { + let (project, _, multi_workspace) = markdown_workspace( + cx, + json!({ + "docs": { + "guide.md": "# Guide" + } + }), + false, + ) + .await; + let source_item = + open_project_file(cx, &project, &multi_workspace, "docs/guide.md", None, true).await; + let source_editor = cx.update(|cx| source_item.act_as::(cx).unwrap()); + let preview = open_preview_for_active_editor(cx, &multi_workspace); + + let source_tooltip = source_editor.read_with(cx, |editor, cx| editor.tab_tooltip_text(cx)); + assert!(source_tooltip.is_some()); + assert_eq!( + preview.read_with(cx, |preview, cx| preview.tab_tooltip_text(cx)), + source_tooltip + ); + } + + #[gpui::test] + fn resetting_persisted_font_size_preserves_unrelated_settings(cx: &mut App) { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + let without_markdown_preview = "{\n \"buffer_font_size\": 14\n}\n".to_string(); + assert_eq!( + cx.global::() + .new_text_for_update(without_markdown_preview.clone(), reset_persisted_font_size) + .unwrap(), + without_markdown_preview + ); + + let updated = cx + .global::() + .new_text_for_update( + r#"{"markdown_preview":{"font_size":18,"open_markdown_files_in_preview":true}}"# + .into(), + reset_persisted_font_size, + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&updated).unwrap(), + json!({"markdown_preview": {"open_markdown_files_in_preview": true}}) + ); + } + #[gpui::test] fn resolves_workspace_absolute_preview_image_path_and_rejects_missing(cx: &mut App) { let tree = TempTree::new(json!({ @@ -3044,6 +3215,34 @@ mod tests { assert_no_markdown_preview_items(cx, &multi_workspace); } + #[gpui::test] + async fn close_and_return_to_editor_returns_to_source_when_auto_preview_is_enabled( + cx: &mut TestAppContext, + ) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), false).await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + open_project_file(cx, &project, &multi_workspace, "note.md", None, true).await; + + let preview = cx.update(|cx| { + assert_eq!(workspace.read(cx).items_of_type::(cx).count(), 0); + workspace + .read(cx) + .active_item_as::(cx) + .unwrap() + }); + let editor = preview.read_with(cx, |preview, _| { + preview.active_editor.as_ref().unwrap().editor.clone() + }); + + dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview); + cx.run_until_parked(); + + assert_editor_is_active_and_focused(cx, &multi_workspace, &editor); + assert_no_markdown_preview_items(cx, &multi_workspace); + } + #[gpui::test] async fn preview_serialized_path_updates_when_source_file_is_renamed(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -3504,6 +3703,377 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn opens_markdown_files_in_preview_when_enabled(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "automatic.md": "# Automatic\n", + "plain.txt": "Plain text\n", + "targeted.md": "# Targeted\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + let automatic_editor = + open_project_file(cx, &project, &multi_workspace, "automatic.md", None, true).await; + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), automatic_editor.as_ref(), cx); + }); + + open_project_file(cx, &project, &multi_workspace, "plain.txt", None, true).await; + cx.update(|cx| { + assert!(workspace.read(cx).active_item_as::(cx).is_some()); + }); + + let (target_pane, other_pane) = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let target_pane = workspace.active_pane().clone(); + let other_pane = workspace.split_pane( + target_pane.clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + (target_pane, other_pane) + }) + }) + .unwrap(); + open_project_file( + cx, + &project, + &multi_workspace, + "targeted.md", + Some(&target_pane), + true, + ) + .await; + let preview = cx.update(|cx| { + let preview = target_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("the Markdown preview should open in the requested pane"); + assert_eq!( + other_pane + .read(cx) + .items_of_type::() + .count(), + 0 + ); + preview + }); + assert_eq!( + preview_source_path(cx, &preview).as_ref(), + rel_path("targeted.md") + ); + } + + #[gpui::test] + async fn first_markdown_file_opens_in_preview_before_language_load(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), true).await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let editor = open_project_file(cx, &project, &multi_workspace, "note.md", None, true) + .await + .downcast::() + .unwrap(); + + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), &editor, cx); + }); + } + + #[gpui::test] + async fn workspace_restore_does_not_auto_preview_markdown(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "restored.md": "# Restored\n", + "ordinary.md": "# Ordinary\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + workspace.update(cx, |workspace, _| workspace.set_restoring_workspace(true)); + + let restored_editor = + open_project_file(cx, &project, &multi_workspace, "restored.md", None, true) + .await + .downcast::() + .unwrap(); + cx.update(|cx| { + let workspace = workspace.read(cx); + assert_eq!( + workspace.active_item_as::(cx), + Some(restored_editor) + ); + assert_eq!( + workspace.items_of_type::(cx).count(), + 0 + ); + }); + + workspace.update(cx, |workspace, _| workspace.set_restoring_workspace(false)); + let ordinary_editor = + open_project_file(cx, &project, &multi_workspace, "ordinary.md", None, true) + .await + .downcast::() + .unwrap(); + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), &ordinary_editor, cx); + }); + } + + #[gpui::test] + async fn dirty_markdown_editor_is_retained_when_auto_preview_runs(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), false).await; + let editor = open_project_file(cx, &project, &multi_workspace, "note.md", None, true) + .await + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + let end = editor.buffer().read(cx).snapshot(cx).len(); + editor.edit([(end..end, "dirty\n")], cx); + }); + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane( + Box::new(editor.clone()), + None, + true, + window, + cx, + ); + }); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + let workspace = workspace.read(cx); + assert!( + workspace + .panes() + .iter() + .any(|pane| pane.read(cx).index_for_item(&editor).is_some()) + ); + assert_eq!( + workspace.items_of_type::(cx).count(), + 1 + ); + }); + } + + #[gpui::test] + async fn automatic_previews_preserve_temporary_tab_semantics(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "first.md": "# First\n", + "second.md": "# Second\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + for path in ["first.md", "second.md"] { + let open = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_path_preview( + test_project_path(&project, path, cx), + None, + true, + true, + true, + window, + cx, + ) + }) + }) + .unwrap(); + open.await.unwrap(); + cx.run_until_parked(); + + let preview = cx.update(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + let preview = pane + .preview_item() + .and_then(|item| item.downcast::()) + .unwrap(); + assert_eq!(pane.items_len(), 1); + preview + }); + assert_eq!(preview_source_path(cx, &preview).as_ref(), rel_path(path)); + } + } + + #[gpui::test] + async fn automatic_preview_preserves_pane_activation_and_focus(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "plain.txt": "Plain\n", + "active.md": "# Active\n", + "background.md": "# Background\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let plain_editor = + open_project_file(cx, &project, &multi_workspace, "plain.txt", None, true) + .await + .downcast::() + .unwrap(); + + let (plain_pane, target_pane) = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let plain_pane = workspace.active_pane().clone(); + let target_pane = workspace.split_pane( + plain_pane.clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + plain_editor.focus_handle(cx).focus(window, cx); + (plain_pane, target_pane) + }) + }) + .unwrap(); + open_project_file( + cx, + &project, + &multi_workspace, + "active.md", + Some(&target_pane), + false, + ) + .await; + + let target_preview = multi_workspace + .update(cx, |_, window, cx| { + assert_eq!(workspace.read(cx).active_pane(), &target_pane); + let target_preview = target_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .unwrap(); + assert!(plain_editor.focus_handle(cx).contains_focused(window, cx)); + target_preview + }) + .unwrap(); + multi_workspace + .update(cx, |_, window, cx| { + target_preview.focus_handle(cx).focus(window, cx); + }) + .unwrap(); + + let open_background = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_path_preview( + test_project_path(&project, "background.md", cx), + Some(plain_pane.downgrade()), + false, + false, + false, + window, + cx, + ) + }) + }) + .unwrap(); + open_background.await.unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + assert_eq!(workspace.read(cx).active_pane(), &target_pane); + assert!( + plain_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .is_some() + ); + assert!(target_preview.focus_handle(cx).contains_focused(window, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn targeted_markdown_open_reveals_the_source_position(cx: &mut TestAppContext) { + let (_project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ "note.md": "# Heading\n\nFirst line\nTarget line\n" }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let open_target = OpenTarget::Path( + PathWithPosition { + path: PathBuf::from(path!("/project/note.md")), + row: Some(4), + column: Some(3), + }, + false, + OpenTargetFoundBy::BackgroundPathResolution, + ); + let open = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |_, cx| { + cx.spawn_in(window, async move |workspace, cx| { + open_resolved_target(&workspace, &open_target, cx).await + }) + }) + }) + .unwrap(); + assert!(open.await.unwrap()); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + let preview = workspace + .read(cx) + .active_item_as::(cx) + .unwrap(); + let editor = preview + .read(cx) + .active_editor + .as_ref() + .unwrap() + .editor + .clone(); + let source_index = editor + .update(cx, |editor, cx| { + MarkdownPreviewView::selected_source_index(editor, cx) + }) + .unwrap(); + assert_eq!(preview.read(cx).active_source_index, Some(source_index)); + assert!(preview.read(cx).focus_handle.is_focused(window)); + assert!( + workspace + .read(cx) + .panes() + .iter() + .all(|pane| { pane.read(cx).index_for_item(&editor).is_none() }) + ); + }) + .unwrap(); + } + #[gpui::test] async fn preview_opens_for_the_given_pane_not_the_focused_editor(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -3654,6 +4224,7 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { + cx.set_global(db::AppDatabase::test_new()); let state = AppState::test(cx); editor::init(cx); crate::init(cx); @@ -3661,21 +4232,118 @@ mod tests { }) } + async fn markdown_workspace( + cx: &mut TestAppContext, + files: serde_json::Value, + lazy_language: bool, + ) -> ( + Entity, + Entity, + WindowHandle, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), files).await; + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + if lazy_language { + register_lazy_markdown_language(&project, cx); + } else { + register_markdown_language(&project, cx); + } + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + }) + .unwrap(); + (project, workspace, multi_workspace) + } + + async fn open_project_file( + cx: &mut TestAppContext, + project: &Entity, + multi_workspace: &WindowHandle, + path: &str, + pane: Option<&Entity>, + focus_item: bool, + ) -> Box { + let open = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_path( + test_project_path(project, path, cx), + pane.map(Entity::downgrade), + focus_item, + window, + cx, + ) + }) + }) + .unwrap(); + let item = open.await.unwrap(); + cx.run_until_parked(); + item + } + + fn assert_editor_replaced_by_preview(workspace: &Workspace, editor: &dyn ItemHandle, cx: &App) { + assert!( + workspace + .active_item_as::(cx) + .is_some() + ); + assert!( + workspace + .panes() + .iter() + .all(|pane| pane.read(cx).index_for_item(editor).is_none()) + ); + } + + fn set_auto_preview_enabled(cx: &mut App, enabled: bool) { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .markdown_preview + .get_or_insert_default() + .open_markdown_files_in_preview = Some(enabled); + }); + }); + } + + fn test_project_path(project: &Entity, path: &str, cx: &App) -> ProjectPath { + let worktree_id = project.read(cx).worktrees(cx).next().unwrap().read(cx).id(); + ProjectPath { + worktree_id, + path: rel_path(path).into(), + } + } + + fn markdown_language_config() -> language::LanguageConfig { + language::LanguageConfig { + name: "Markdown".into(), + matcher: Arc::new(language::LanguageMatcher { + path_suffixes: vec!["md".to_string(), "markdown".to_string()], + ..Default::default() + }), + ..Default::default() + } + } + fn register_markdown_language(project: &Entity, cx: &mut TestAppContext) { let languages = project.read_with(cx, |project, _| project.languages().clone()); languages.add(Arc::new(language::Language::new( - language::LanguageConfig { - name: "Markdown".into(), - matcher: Arc::new(language::LanguageMatcher { - path_suffixes: vec!["md".to_string(), "markdown".to_string()], - ..Default::default() - }), - ..Default::default() - }, + markdown_language_config(), None, ))); } + fn register_lazy_markdown_language(project: &Entity, cx: &mut TestAppContext) { + project + .read_with(cx, |project, _| project.languages().clone()) + .register_test_language(markdown_language_config()); + } + async fn wait_for_preview_serialization(cx: &mut TestAppContext) { cx.run_until_parked(); cx.executor().advance_clock(Duration::from_millis(250)); diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml deleted file mode 100644 index 330fc26a06..0000000000 --- a/crates/media/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "media" -version = "0.1.0" -edition.workspace = true -publish = false -license = "Apache-2.0" -description = "Bindings to macos media handling APIs for Zed" - -[lints] -workspace = true - -[lib] -path = "src/media.rs" -doctest = false - -[dependencies] -anyhow.workspace = true - -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation.workspace = true -foreign-types.workspace = true -metal.workspace = true -core-video.workspace = true -objc.workspace = true - -[build-dependencies] -bindgen = "0.71" diff --git a/crates/media/LICENSE-APACHE b/crates/media/LICENSE-APACHE deleted file mode 120000 index 1cd601d0a3..0000000000 --- a/crates/media/LICENSE-APACHE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/media/build.rs b/crates/media/build.rs deleted file mode 100644 index 090002e9e9..0000000000 --- a/crates/media/build.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] -#[cfg(target_os = "macos")] -fn main() { - use std::{env, path::PathBuf, process::Command}; - - let sdk_path = String::from_utf8( - Command::new("xcrun") - .args(["--sdk", "macosx", "--show-sdk-path"]) - .output() - .unwrap() - .stdout, - ) - .unwrap(); - let sdk_path = sdk_path.trim_end(); - - println!("cargo:rerun-if-changed=src/bindings.h"); - let bindings = bindgen::Builder::default() - .header("src/bindings.h") - .clang_arg(format!("-isysroot{}", sdk_path)) - .clang_arg("-xobjective-c") - .allowlist_type("CMItemIndex") - .allowlist_type("CMSampleTimingInfo") - .allowlist_type("CMVideoCodecType") - .allowlist_type("VTEncodeInfoFlags") - .allowlist_function("CMTimeMake") - .allowlist_var("kCVPixelFormatType_.*") - .allowlist_var("kCVReturn.*") - .allowlist_var("VTEncodeInfoFlags_.*") - .allowlist_var("kCMVideoCodecType_.*") - .allowlist_var("kCMTime.*") - .allowlist_var("kCMSampleAttachmentKey_.*") - .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) - .layout_tests(false) - .generate() - .expect("unable to generate bindings"); - - let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); - bindings - .write_to_file(out_path.join("bindings.rs")) - .expect("couldn't write dispatch bindings"); -} - -#[cfg(not(target_os = "macos"))] -fn main() {} diff --git a/crates/media/src/bindings.h b/crates/media/src/bindings.h deleted file mode 100644 index 4df283d0c1..0000000000 --- a/crates/media/src/bindings.h +++ /dev/null @@ -1,5 +0,0 @@ -#import -#import -#import -#import -#import diff --git a/crates/media/src/bindings.rs b/crates/media/src/bindings.rs deleted file mode 100644 index a1c78c17c4..0000000000 --- a/crates/media/src/bindings.rs +++ /dev/null @@ -1,10 +0,0 @@ -#![allow(non_upper_case_globals)] -#![allow(non_camel_case_types)] -#![allow(non_snake_case)] -#![allow(unused)] - -#[cfg(target_os = "macos")] -use objc::*; - -#[cfg(target_os = "macos")] -include!(concat!(env!("OUT_DIR"), "/bindings.rs")); diff --git a/crates/media/src/media.rs b/crates/media/src/media.rs deleted file mode 100644 index c42bad62e7..0000000000 --- a/crates/media/src/media.rs +++ /dev/null @@ -1,352 +0,0 @@ -#![allow(non_snake_case)] -#![allow(non_camel_case_types)] - -mod bindings; - -#[cfg(target_os = "macos")] -pub mod core_media { - #![allow(non_snake_case)] - - pub use crate::bindings::{ - CMItemIndex, CMSampleTimingInfo, CMTime, CMTimeMake, CMVideoCodecType, - kCMSampleAttachmentKey_NotSync, kCMTimeInvalid, kCMVideoCodecType_H264, - }; - use anyhow::Result; - use core_foundation::{ - array::{CFArray, CFArrayRef}, - base::{CFTypeID, OSStatus, TCFType}, - declare_TCFType, - dictionary::CFDictionary, - impl_CFTypeDescription, impl_TCFType, - string::CFString, - }; - use core_video::image_buffer::{CVImageBuffer, CVImageBufferRef}; - use std::{ffi::c_void, ptr}; - - #[repr(C)] - pub struct __CMSampleBuffer(c_void); - // The ref type must be a pointer to the underlying struct. - pub type CMSampleBufferRef = *const __CMSampleBuffer; - - declare_TCFType!(CMSampleBuffer, CMSampleBufferRef); - impl_TCFType!(CMSampleBuffer, CMSampleBufferRef, CMSampleBufferGetTypeID); - impl_CFTypeDescription!(CMSampleBuffer); - - impl CMSampleBuffer { - pub fn attachments(&self) -> Vec> { - unsafe { - let attachments = - CMSampleBufferGetSampleAttachmentsArray(self.as_concrete_TypeRef(), true); - CFArray::::wrap_under_get_rule(attachments) - .into_iter() - .map(|attachments| { - CFDictionary::wrap_under_get_rule(attachments.as_concrete_TypeRef()) - }) - .collect() - } - } - - pub fn image_buffer(&self) -> Option { - unsafe { - let ptr = CMSampleBufferGetImageBuffer(self.as_concrete_TypeRef()); - if ptr.is_null() { - None - } else { - Some(CVImageBuffer::wrap_under_get_rule(ptr)) - } - } - } - - pub fn sample_timing_info(&self, index: usize) -> Result { - unsafe { - let mut timing_info = CMSampleTimingInfo { - duration: kCMTimeInvalid, - presentationTimeStamp: kCMTimeInvalid, - decodeTimeStamp: kCMTimeInvalid, - }; - let result = CMSampleBufferGetSampleTimingInfo( - self.as_concrete_TypeRef(), - index as CMItemIndex, - &mut timing_info, - ); - anyhow::ensure!( - result == 0, - "error getting sample timing info, code {result}" - ); - Ok(timing_info) - } - } - - pub fn format_description(&self) -> CMFormatDescription { - unsafe { - CMFormatDescription::wrap_under_get_rule(CMSampleBufferGetFormatDescription( - self.as_concrete_TypeRef(), - )) - } - } - - pub fn data(&self) -> CMBlockBuffer { - unsafe { - CMBlockBuffer::wrap_under_get_rule(CMSampleBufferGetDataBuffer( - self.as_concrete_TypeRef(), - )) - } - } - } - - #[link(name = "CoreMedia", kind = "framework")] - unsafe extern "C" { - fn CMSampleBufferGetTypeID() -> CFTypeID; - fn CMSampleBufferGetSampleAttachmentsArray( - buffer: CMSampleBufferRef, - create_if_necessary: bool, - ) -> CFArrayRef; - fn CMSampleBufferGetImageBuffer(buffer: CMSampleBufferRef) -> CVImageBufferRef; - fn CMSampleBufferGetSampleTimingInfo( - buffer: CMSampleBufferRef, - index: CMItemIndex, - timing_info_out: *mut CMSampleTimingInfo, - ) -> OSStatus; - fn CMSampleBufferGetFormatDescription(buffer: CMSampleBufferRef) -> CMFormatDescriptionRef; - fn CMSampleBufferGetDataBuffer(sample_buffer: CMSampleBufferRef) -> CMBlockBufferRef; - } - - #[repr(C)] - pub struct __CMFormatDescription(c_void); - pub type CMFormatDescriptionRef = *const __CMFormatDescription; - - declare_TCFType!(CMFormatDescription, CMFormatDescriptionRef); - impl_TCFType!( - CMFormatDescription, - CMFormatDescriptionRef, - CMFormatDescriptionGetTypeID - ); - impl_CFTypeDescription!(CMFormatDescription); - - impl CMFormatDescription { - pub fn h264_parameter_set_count(&self) -> usize { - unsafe { - let mut count = 0; - let result = CMVideoFormatDescriptionGetH264ParameterSetAtIndex( - self.as_concrete_TypeRef(), - 0, - ptr::null_mut(), - ptr::null_mut(), - &mut count, - ptr::null_mut(), - ); - assert_eq!(result, 0); - count - } - } - - pub fn h264_parameter_set_at_index(&self, index: usize) -> Result<&[u8]> { - unsafe { - let mut bytes = ptr::null(); - let mut len = 0; - let result = CMVideoFormatDescriptionGetH264ParameterSetAtIndex( - self.as_concrete_TypeRef(), - index, - &mut bytes, - &mut len, - ptr::null_mut(), - ptr::null_mut(), - ); - anyhow::ensure!(result == 0, "error getting parameter set, code: {result}"); - Ok(std::slice::from_raw_parts(bytes, len)) - } - } - } - - #[link(name = "CoreMedia", kind = "framework")] - unsafe extern "C" { - fn CMFormatDescriptionGetTypeID() -> CFTypeID; - fn CMVideoFormatDescriptionGetH264ParameterSetAtIndex( - video_desc: CMFormatDescriptionRef, - parameter_set_index: usize, - parameter_set_pointer_out: *mut *const u8, - parameter_set_size_out: *mut usize, - parameter_set_count_out: *mut usize, - NALUnitHeaderLengthOut: *mut isize, - ) -> OSStatus; - } - - #[repr(C)] - pub struct __CMBlockBuffer(c_void); - pub type CMBlockBufferRef = *const __CMBlockBuffer; - - declare_TCFType!(CMBlockBuffer, CMBlockBufferRef); - impl_TCFType!(CMBlockBuffer, CMBlockBufferRef, CMBlockBufferGetTypeID); - impl_CFTypeDescription!(CMBlockBuffer); - - impl CMBlockBuffer { - pub fn bytes(&self) -> &[u8] { - unsafe { - let mut bytes = ptr::null(); - let mut len = 0; - let result = CMBlockBufferGetDataPointer( - self.as_concrete_TypeRef(), - 0, - &mut 0, - &mut len, - &mut bytes, - ); - assert!(result == 0, "could not get block buffer data"); - std::slice::from_raw_parts(bytes, len) - } - } - } - - #[link(name = "CoreMedia", kind = "framework")] - unsafe extern "C" { - fn CMBlockBufferGetTypeID() -> CFTypeID; - fn CMBlockBufferGetDataPointer( - buffer: CMBlockBufferRef, - offset: usize, - length_at_offset_out: *mut usize, - total_length_out: *mut usize, - data_pointer_out: *mut *const u8, - ) -> OSStatus; - } -} - -#[cfg(target_os = "macos")] -pub mod core_video { - #![allow(non_snake_case)] - - #[cfg(target_os = "macos")] - use core_foundation::{ - base::{CFTypeID, TCFType}, - declare_TCFType, impl_CFTypeDescription, impl_TCFType, - }; - #[cfg(target_os = "macos")] - use std::ffi::c_void; - - use crate::bindings::{CVReturn, kCVReturnSuccess}; - pub use crate::bindings::{ - kCVPixelFormatType_32BGRA, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, - kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, kCVPixelFormatType_420YpCbCr8Planar, - }; - use anyhow::Result; - use core_foundation::{ - base::kCFAllocatorDefault, dictionary::CFDictionaryRef, mach_port::CFAllocatorRef, - }; - use foreign_types::ForeignTypeRef; - - use metal::{MTLDevice, MTLPixelFormat}; - use std::ptr; - - #[repr(C)] - pub struct __CVMetalTextureCache(c_void); - pub type CVMetalTextureCacheRef = *const __CVMetalTextureCache; - - declare_TCFType!(CVMetalTextureCache, CVMetalTextureCacheRef); - impl_TCFType!( - CVMetalTextureCache, - CVMetalTextureCacheRef, - CVMetalTextureCacheGetTypeID - ); - impl_CFTypeDescription!(CVMetalTextureCache); - - impl CVMetalTextureCache { - /// # Safety - /// - /// metal_device must be valid according to CVMetalTextureCacheCreate - pub unsafe fn new(metal_device: *mut MTLDevice) -> Result { - let mut this = ptr::null(); - let result = unsafe { - CVMetalTextureCacheCreate( - kCFAllocatorDefault, - ptr::null(), - metal_device, - ptr::null(), - &mut this, - ) - }; - anyhow::ensure!( - result == kCVReturnSuccess, - "could not create texture cache, code: {result}" - ); - unsafe { Ok(CVMetalTextureCache::wrap_under_create_rule(this)) } - } - - /// # Safety - /// - /// The arguments to this function must be valid according to CVMetalTextureCacheCreateTextureFromImage - pub unsafe fn create_texture_from_image( - &self, - source: ::core_video::image_buffer::CVImageBufferRef, - texture_attributes: CFDictionaryRef, - pixel_format: MTLPixelFormat, - width: usize, - height: usize, - plane_index: usize, - ) -> Result { - let mut this = ptr::null(); - let result = unsafe { - CVMetalTextureCacheCreateTextureFromImage( - kCFAllocatorDefault, - self.as_concrete_TypeRef(), - source, - texture_attributes, - pixel_format, - width, - height, - plane_index, - &mut this, - ) - }; - anyhow::ensure!( - result == kCVReturnSuccess, - "could not create texture, code: {result}" - ); - unsafe { Ok(CVMetalTexture::wrap_under_create_rule(this)) } - } - } - - #[link(name = "CoreVideo", kind = "framework")] - unsafe extern "C" { - fn CVMetalTextureCacheGetTypeID() -> CFTypeID; - fn CVMetalTextureCacheCreate( - allocator: CFAllocatorRef, - cache_attributes: CFDictionaryRef, - metal_device: *const MTLDevice, - texture_attributes: CFDictionaryRef, - cache_out: *mut CVMetalTextureCacheRef, - ) -> CVReturn; - fn CVMetalTextureCacheCreateTextureFromImage( - allocator: CFAllocatorRef, - texture_cache: CVMetalTextureCacheRef, - source_image: ::core_video::image_buffer::CVImageBufferRef, - texture_attributes: CFDictionaryRef, - pixel_format: MTLPixelFormat, - width: usize, - height: usize, - plane_index: usize, - texture_out: *mut CVMetalTextureRef, - ) -> CVReturn; - } - - #[repr(C)] - pub struct __CVMetalTexture(c_void); - pub type CVMetalTextureRef = *const __CVMetalTexture; - - declare_TCFType!(CVMetalTexture, CVMetalTextureRef); - impl_TCFType!(CVMetalTexture, CVMetalTextureRef, CVMetalTextureGetTypeID); - impl_CFTypeDescription!(CVMetalTexture); - - impl CVMetalTexture { - pub fn as_texture_ref(&self) -> &metal::TextureRef { - unsafe { - let texture = CVMetalTextureGetTexture(self.as_concrete_TypeRef()); - metal::TextureRef::from_ptr(texture as *mut _) - } - } - } - - #[link(name = "CoreVideo", kind = "framework")] - unsafe extern "C" { - fn CVMetalTextureGetTypeID() -> CFTypeID; - fn CVMetalTextureGetTexture(texture: CVMetalTextureRef) -> *mut c_void; - } -} diff --git a/crates/menu/Cargo.toml b/crates/menu/Cargo.toml index fcb209df88..c1b728a13e 100644 --- a/crates/menu/Cargo.toml +++ b/crates/menu/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/menu.rs" doctest = false +test = false [dependencies] gpui.workspace = true diff --git a/crates/mermaid_render/src/postprocess.rs b/crates/mermaid_render/src/postprocess.rs index 1f3b818c47..bc24fd70e3 100644 --- a/crates/mermaid_render/src/postprocess.rs +++ b/crates/mermaid_render/src/postprocess.rs @@ -18,8 +18,8 @@ mod strip_foreignobject; pub(crate) mod util; use anyhow::{Context as _, Result}; -use quick_xml::Reader; use quick_xml::events::Event; +use quick_xml::{Reader, XmlVersion}; use crate::MermaidTheme; @@ -60,7 +60,7 @@ fn extract_svg_id(svg: &str) -> String { .try_get_attribute("id") .ok() .flatten() - .and_then(|a| a.unescape_value().ok()) + .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok()) .map(|v| v.into_owned()) .unwrap_or_default(); } diff --git a/crates/mermaid_render/src/postprocess/accent_colors.rs b/crates/mermaid_render/src/postprocess/accent_colors.rs index 2145f447c9..6cd72acea8 100644 --- a/crates/mermaid_render/src/postprocess/accent_colors.rs +++ b/crates/mermaid_render/src/postprocess/accent_colors.rs @@ -10,6 +10,7 @@ mod mindmap; mod sequence_diagram; use anyhow::Result; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use crate::MermaidTheme; @@ -59,7 +60,7 @@ impl NodeTracker { pub(crate) fn parse_translate(e: &BytesStart<'_>) -> Option<(f64, f64)> { let attr = e.try_get_attribute("transform").ok()??; - let val = attr.unescape_value().ok()?; + let val = attr.normalized_value(XmlVersion::Implicit1_0).ok()?; let inner = val.strip_prefix("translate(")?.strip_suffix(')')?; let (x_str, y_str) = inner.split_once(',')?; Some((x_str.trim().parse().ok()?, y_str.trim().parse().ok()?)) @@ -67,7 +68,7 @@ pub(crate) fn parse_translate(e: &BytesStart<'_>) -> Option<(f64, f64)> { pub(crate) fn parse_path_half_height(e: &BytesStart<'_>) -> Option { let attr = e.try_get_attribute("d").ok()??; - let d = attr.unescape_value().ok()?; + let d = attr.normalized_value(XmlVersion::Implicit1_0).ok()?; let rest = d.strip_prefix('M')?.trim_start(); // The path data starts with `M x,y ...`; the y coordinate is the second // whitespace/comma-separated token. @@ -129,7 +130,7 @@ pub(crate) fn add_class<'a>(e: &BytesStart<'_>, class_to_add: &str) -> Result let parse_attr = |name| -> Option { e.try_get_attribute(name) .ok()?? - .unescape_value() + .normalized_value(XmlVersion::Implicit1_0) .ok()? .parse() .ok() @@ -221,7 +222,7 @@ fn detect_diagram_type(e: &BytesStart<'_>) -> DiagramType { .try_get_attribute("class") .ok() .flatten() - .and_then(|a| a.unescape_value().ok()) + .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok()) { Some(c) => c, None => return DiagramType::SequenceDiagram, @@ -279,7 +280,7 @@ impl<'a, 'theme, I: Iterator>>> AccentColors<'theme, I> self.plot_depth += 1; } if let Some(class_attr) = e.try_get_attribute("class")? { - let class = class_attr.unescape_value()?; + let class = class_attr.normalized_value(XmlVersion::Implicit1_0)?; if class.as_ref() == "plot" { if is_start && !self.in_plot { self.in_plot = true; @@ -326,7 +327,7 @@ impl<'a, 'theme, I: Iterator>>> AccentColors<'theme, I> Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"path" => { let class_val = e .try_get_attribute("class")? - .map(|a| a.unescape_value()) + .map(|a| a.normalized_value(XmlVersion::Implicit1_0)) .transpose()?; if class_val.as_deref() == Some("pieCircle") { diff --git a/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs b/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs index 6c7d019c38..1893e3aee2 100644 --- a/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs +++ b/crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use quick_xml::XmlVersion; use quick_xml::events::Event; use super::{ @@ -38,7 +39,7 @@ impl ClassDiagramAccents { } let is_node = if let Some(class_attr) = e.try_get_attribute("class")? { - let class = class_attr.unescape_value()?; + let class = class_attr.normalized_value(XmlVersion::Implicit1_0)?; class .split_whitespace() .any(|token| token == "node" || token == "stateGroup") diff --git a/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs b/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs index f77329c689..b978c8afd0 100644 --- a/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs +++ b/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use super::{AccentStackEntry, NodeTracker}; @@ -105,7 +106,7 @@ impl MindmapAccents { Some(attr) => attr, None => return Ok(None), }; - let class = class_attr.unescape_value()?; + let class = class_attr.normalized_value(XmlVersion::Implicit1_0)?; let is_root = class.split_whitespace().any(|t| t == "section-root"); for token in class.split_whitespace() { diff --git a/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs b/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs index 8489e93b76..005ff7c479 100644 --- a/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs +++ b/crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use super::{accent_class_name, add_to_event}; @@ -67,7 +68,7 @@ impl SequenceDiagramAccents { Some(a) => a, None => return Ok(None), }; - let class_val = class_attr.unescape_value()?; + let class_val = class_attr.normalized_value(XmlVersion::Implicit1_0)?; if class_val.contains("actor-bottom") { let idx = self.actor_bottom_counter % self.accent_count; self.actor_bottom_counter += 1; @@ -88,7 +89,7 @@ impl SequenceDiagramAccents { Some(a) => a, None => return Ok(None), }; - let class_val = class_attr.unescape_value()?; + let class_val = class_attr.normalized_value(XmlVersion::Implicit1_0)?; if class_val.contains("actor") && class_val.contains("actor-box") { Ok(self.last_actor_accent.take()) } else { diff --git a/crates/mermaid_render/src/postprocess/element_fixup.rs b/crates/mermaid_render/src/postprocess/element_fixup.rs index fabdf21e8c..275022c4a1 100644 --- a/crates/mermaid_render/src/postprocess/element_fixup.rs +++ b/crates/mermaid_render/src/postprocess/element_fixup.rs @@ -22,6 +22,7 @@ use std::borrow::Cow; use std::fmt::Write as _; use anyhow::Result; +use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use crate::MermaidTheme; @@ -68,7 +69,7 @@ fn is_bad_rect(e: &BytesStart) -> Result { match e.try_get_attribute(attr_name)? { None => return Ok(true), Some(attr) => { - let val = attr.unescape_value()?; + let val = attr.normalized_value(XmlVersion::Implicit1_0)?; let trimmed = val.trim(); if trimmed.is_empty() { return Ok(true); @@ -183,7 +184,7 @@ impl<'a, I: Iterator>>> ElementFixup { fn rewrite_svg_style(&self, e: &BytesStart<'_>) -> Result>> { let Some(style) = e .try_get_attribute("style")? - .map(|a| a.unescape_value()) + .map(|a| a.normalized_value(XmlVersion::Implicit1_0)) .transpose()? else { return Ok(None); @@ -207,7 +208,7 @@ impl<'a, I: Iterator>>> ElementFixup { let attr = attr?; match attr.key.local_name().as_ref() { b"fill" if fix_fill => { - let val = attr.unescape_value()?; + let val = attr.normalized_value(XmlVersion::Implicit1_0)?; if is_hardcoded_text_fill(&val) { new_elem.push_attribute(("fill", self.text_color_css.as_str())); } else { @@ -220,7 +221,7 @@ impl<'a, I: Iterator>>> ElementFixup { } b"style" => { has_style = true; - let style = attr.unescape_value()?; + let style = attr.normalized_value(XmlVersion::Implicit1_0)?; let style = rewrite_font_style(&style, &self.font_family_css); new_elem.push_attribute(("style", style.as_ref())); } diff --git a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs index 1163c0a621..c0f23e306f 100644 --- a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs +++ b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs @@ -26,8 +26,8 @@ use std::collections::{HashSet, VecDeque}; use anyhow::Result; -use quick_xml::Reader; use quick_xml::events::Event; +use quick_xml::{Reader, XmlVersion}; use super::ReaderIter; @@ -72,7 +72,7 @@ fn is_fallback_text(e: &quick_xml::events::BytesStart<'_>) -> bool { e.try_get_attribute("class") .ok() .flatten() - .and_then(|a| a.unescape_value().ok()) + .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok()) .is_some_and(|v| v.split_whitespace().any(|c| c == FALLBACK_TEXT_CLASS)) } diff --git a/crates/mermaid_render/tests/check_invalid_attrs.rs b/crates/mermaid_render/tests/check_invalid_attrs.rs index 93093d899d..3c51ac69a9 100644 --- a/crates/mermaid_render/tests/check_invalid_attrs.rs +++ b/crates/mermaid_render/tests/check_invalid_attrs.rs @@ -174,6 +174,7 @@ fn check_svg_issues(name: &str, svg: &str) -> Vec { } // Parse with quick-xml to find ANY empty attribute values on visual elements + use quick_xml::XmlVersion; use quick_xml::events::Event; let mut reader = quick_xml::Reader::from_str(svg); loop { @@ -183,7 +184,9 @@ fn check_svg_issues(name: &str, svg: &str) -> Vec { let tag = String::from_utf8_lossy(e.name().local_name().as_ref()).to_string(); for attr in e.attributes().flatten() { let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).to_string(); - let val = attr.unescape_value().unwrap_or_default(); + let val = attr + .normalized_value(XmlVersion::Implicit1_0) + .unwrap_or_default(); let visual_attr = matches!( key.as_str(), "fill" @@ -283,6 +286,7 @@ fn class_diagram_label_text_uses_accent_classes() { let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + use quick_xml::XmlVersion; use quick_xml::events::Event; let mut reader = quick_xml::Reader::from_str(&svg); let mut accent_classes: Vec = Vec::new(); @@ -291,7 +295,10 @@ fn class_diagram_label_text_uses_accent_classes() { Ok(Event::Eof) => break, Ok(Event::Start(e)) if e.name().as_ref() == b"text" => { if let Ok(Some(class_attr)) = e.try_get_attribute("class") { - let class = class_attr.unescape_value().unwrap_or_default().to_string(); + let class = class_attr + .normalized_value(XmlVersion::Implicit1_0) + .unwrap_or_default() + .to_string(); for token in class.split_whitespace() { if token.starts_with("zed-accent-") { accent_classes.push(token.to_string()); @@ -315,6 +322,7 @@ fn sequence_diagram_tspan_uses_accent_classes() { let source = "sequenceDiagram\n participant Database"; let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + use quick_xml::XmlVersion; use quick_xml::events::Event; let mut reader = quick_xml::Reader::from_str(&svg); let mut accent_classes: Vec = Vec::new(); @@ -323,7 +331,10 @@ fn sequence_diagram_tspan_uses_accent_classes() { Ok(Event::Eof) => break, Ok(Event::Start(e)) if e.name().as_ref() == b"tspan" => { if let Ok(Some(class_attr)) = e.try_get_attribute("class") { - let class = class_attr.unescape_value().unwrap_or_default().to_string(); + let class = class_attr + .normalized_value(XmlVersion::Implicit1_0) + .unwrap_or_default() + .to_string(); for token in class.split_whitespace() { if token.starts_with("zed-accent-") { accent_classes.push(token.to_string()); diff --git a/crates/migrator/Cargo.toml b/crates/migrator/Cargo.toml index a531873d3d..f632251459 100644 --- a/crates/migrator/Cargo.toml +++ b/crates/migrator/Cargo.toml @@ -28,4 +28,3 @@ settings_json = { workspace = true, features = ["editing"] } [dev-dependencies] indoc.workspace = true pretty_assertions.workspace = true -unindent.workspace = true diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 5592449c87..516b9dfc45 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -370,3 +370,15 @@ pub(crate) mod m_2026_08_26 { pub(crate) use settings::rename_folder_icons_to_folder_indicator; } + +pub(crate) mod m_2026_08_30 { + mod settings; + + pub(crate) use settings::nest_markdown_preview_settings; +} + +pub(crate) mod m_2026_09_16 { + mod settings; + + pub(crate) use settings::nest_agent_threads_sidebar_settings; +} diff --git a/crates/migrator/src/migrations/m_2026_08_30/settings.rs b/crates/migrator/src/migrations/m_2026_08_30/settings.rs new file mode 100644 index 0000000000..142ab0a093 --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_08_30/settings.rs @@ -0,0 +1,56 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const MARKDOWN_PREVIEW_KEY: &str = "markdown_preview"; +const KEY_MAPPINGS: &[(&str, &str)] = &[ + ("markdown_preview_font_size", "font_size"), + ("markdown_preview_font_family", "font_family"), + ("markdown_preview_code_font_family", "code_font_family"), + ("markdown_preview_theme", "theme"), +]; + +pub fn nest_markdown_preview_settings(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(object: &mut serde_json::Map) -> Result<()> { + let has_legacy_settings = KEY_MAPPINGS + .iter() + .any(|(old_key, _)| object.contains_key(*old_key)); + match object.get(MARKDOWN_PREVIEW_KEY) { + Some(Value::Null) if has_legacy_settings => { + object.remove(MARKDOWN_PREVIEW_KEY); + } + Some(markdown_preview) if !markdown_preview.is_object() => return Ok(()), + _ => {} + } + + let migrated_settings = KEY_MAPPINGS + .iter() + .filter_map(|(old_key, new_key)| { + object + .remove(*old_key) + .map(|value| ((*new_key).to_string(), value)) + }) + .collect::>(); + + if migrated_settings.is_empty() { + return Ok(()); + } + + let Some(markdown_preview) = object + .entry(MARKDOWN_PREVIEW_KEY) + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + else { + return Ok(()); + }; + + for (key, value) in migrated_settings { + markdown_preview.entry(key).or_insert(value); + } + + Ok(()) +} diff --git a/crates/migrator/src/migrations/m_2026_09_16/settings.rs b/crates/migrator/src/migrations/m_2026_09_16/settings.rs new file mode 100644 index 0000000000..fa008f1a8d --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_09_16/settings.rs @@ -0,0 +1,170 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const THREADS_SIDEBAR_KEY: &str = "threads_sidebar"; +const KEY_MAPPINGS: &[(&str, &str)] = &[ + ("sidebar_side", "position"), + ("threads_sidebar_default_width", "default_width"), + ("threads_sidebar_auto_open", "auto_open"), +]; + +pub fn nest_agent_threads_sidebar_settings(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(object: &mut serde_json::Map) -> Result<()> { + let Some(agent) = object.get_mut("agent").and_then(Value::as_object_mut) else { + return Ok(()); + }; + let has_legacy_settings = KEY_MAPPINGS + .iter() + .any(|(old_key, _)| agent.contains_key(*old_key)); + if !has_legacy_settings { + return Ok(()); + } + + let mut threads_sidebar = match agent.remove(THREADS_SIDEBAR_KEY) { + None | Some(Value::Null) => serde_json::Map::new(), + Some(Value::Object(settings)) => settings, + Some(value) => { + agent.insert(THREADS_SIDEBAR_KEY.into(), value); + return Ok(()); + } + }; + + for (old_key, new_key) in KEY_MAPPINGS { + if let Some(value) = agent.remove(*old_key) { + threads_sidebar.entry(*new_key).or_insert(value); + } + } + agent.insert(THREADS_SIDEBAR_KEY.into(), Value::Object(threads_sidebar)); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn migrate(value: &mut Value) { + nest_agent_threads_sidebar_settings(value).expect("migration should succeed"); + } + + #[test] + fn nest_agent_threads_sidebar_settings_moves_keys_and_preserves_unrelated_fields() { + let mut value = json!({ + "agent": { + "sidebar_side": "right", + "threads_sidebar_default_width": 420, + "threads_sidebar_auto_open": false, + "button": false + } + }); + migrate(&mut value); + assert_eq!( + value, + json!({ + "agent": { + "threads_sidebar": { "position": "right", "default_width": 420, "auto_open": false }, + "button": false + } + }) + ); + let migrated = value.clone(); + migrate(&mut value); + assert_eq!(value, migrated); + } + + #[test] + fn nest_agent_threads_sidebar_settings_preserves_nested_values_and_fills_missing_fields() { + let mut value = json!({ + "agent": { + "sidebar_side": "left", + "threads_sidebar_default_width": 360, + "threads_sidebar_auto_open": true, + "threads_sidebar": { "position": null, "auto_open": false } + } + }); + migrate(&mut value); + assert_eq!( + value, + json!({ + "agent": { + "threads_sidebar": { "position": null, "default_width": 360, "auto_open": false } + } + }) + ); + } + + #[test] + fn nest_agent_threads_sidebar_settings_handles_null_destinations_only_when_needed() { + let mut with_legacy = json!({ + "agent": { + "sidebar_side": "right", + "threads_sidebar": null + } + }); + migrate(&mut with_legacy); + assert_eq!( + with_legacy, + json!({ "agent": { "threads_sidebar": { "position": "right" } } }) + ); + } + + #[test] + fn nest_agent_threads_sidebar_settings_leaves_unrelated_and_malformed_values_untouched() { + for mut value in [ + json!({ "agent": { "threads_sidebar": null } }), + json!({ "agent": false }), + json!({ + "agent": { + "sidebar_side": "left", + "threads_sidebar_default_width": 360, + "threads_sidebar": false + } + }), + ] { + let original = value.clone(); + migrate(&mut value); + assert_eq!(value, original); + } + } + + #[test] + fn nest_agent_threads_sidebar_settings_migrates_all_supported_scopes() { + let legacy_agent = json!({ + "sidebar_side": "right", + "threads_sidebar_default_width": 420, + "threads_sidebar_auto_open": false + }); + let mut value = json!({ + "agent": legacy_agent, + "macos": { "agent": legacy_agent }, + "preview": { "agent": legacy_agent }, + "profiles": { + "direct": { "agent": legacy_agent }, + "wrapped": { "settings": { "agent": legacy_agent } } + } + }); + migrate(&mut value); + + for agent in [ + &value["agent"], + &value["macos"]["agent"], + &value["preview"]["agent"], + &value["profiles"]["direct"]["agent"], + &value["profiles"]["wrapped"]["settings"]["agent"], + ] { + assert_eq!( + agent, + &json!({ + "threads_sidebar": { "position": "right", "default_width": 420, "auto_open": false } + }) + ); + } + } +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index acf5289e0c..b184f25adc 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -255,6 +255,8 @@ pub fn migrate_settings(text: &str) -> Result> { ), MigrationType::Json(migrations::m_2026_08_17::make_git_gutter_width_an_enum), MigrationType::Json(migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator), + MigrationType::Json(migrations::m_2026_08_30::nest_markdown_preview_settings), + MigrationType::Json(migrations::m_2026_09_16::nest_agent_threads_sidebar_settings), ]; run_migrations(text, migrations) } @@ -411,7 +413,6 @@ static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new mod tests { use super::*; use indoc::indoc; - use unindent::Unindent as _; #[track_caller] fn assert_migrated_correctly(migrated: Option, expected: Option<&str>) { @@ -683,24 +684,20 @@ mod tests { #[test] fn test_nested_string_replace_for_settings() { assert_migrate_settings( - &r#" - { - "features": { - "inline_completion_provider": "zed" - }, - } - "# - .unindent(), - Some( - &r#" + indoc! {r#" + { + "features": { + "inline_completion_provider": "zed" + }, + } + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "zed" } } - "# - .unindent(), - ), + "#}), ) } @@ -1250,31 +1247,29 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - } - ] - }"# - .unindent(), - Some( - &r#"{ - "formatter": [ - { - "code_action": "included-1" - }, - { - "code_action": "included-2" - } - ] - }"# - .unindent(), - ), + indoc! {r#" + { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + } + ] + }"#}, + Some(indoc! {r#" + { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + } + ] + }"#}), ); } @@ -1284,18 +1279,18 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": { - "code_actions": { - "included-1": true, - "excluded": false, - "included-2": true - } - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": { + "code_actions": { + "included-1": true, + "excluded": false, + "included-2": true + } + } + }"#}, + Some(indoc! {r#" + { "formatter": [ { "code_action": "included-1" @@ -1304,133 +1299,127 @@ mod tests { "code_action": "included-2" } ] - }"# - .unindent(), - ), + }"#}), ); } #[test] fn test_flatten_code_action_formatters_array_with_multiple_action_blocks() { assert_migrate_settings( - &r#"{ - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - }"# - .unindent(), - Some( - &r#"{ - "formatter": [ - { - "code_action": "included-1" - }, - { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - }"# - .unindent(), - ), + indoc! {r#" + { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }"#}, + Some(indoc! {r#" + { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }"#}), ); } #[test] fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_languages() { assert_migrate_settings( - &r#"{ - "languages": { - "Rust": { - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - } - } - }"# - .unindent(), - Some( - &r#"{ - "languages": { - "Rust": { - "formatter": [ + indoc! {r#" { - "code_action": "included-1" - }, + "languages": { + "Rust": { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"#}, + Some(indoc! {r#" { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - } - } - }"# - .unindent(), - ), + "languages": { + "Rust": { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"#}), ); } @@ -1441,123 +1430,121 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": { - "code_actions": { - "default-1": true, - "default-2": true, - "default-3": true, - "default-4": true, - } - }, - "languages": { - "Rust": { - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - }, - "Python": { - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - } - } - }"# - .unindent(), - Some( - &r#"{ - "formatter": [ - { - "code_action": "default-1" - }, - { - "code_action": "default-2" - }, - { - "code_action": "default-3" - }, - { - "code_action": "default-4" - } - ], - "languages": { - "Rust": { - "formatter": [ + indoc! {r#" { - "code_action": "included-1" - }, + "formatter": { + "code_actions": { + "default-1": true, + "default-2": true, + "default-3": true, + "default-4": true, + } + }, + "languages": { + "Rust": { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }, + "Python": { + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"#}, + Some(indoc! {r#" { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - }, - "Python": { - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - } - } - }"# - .unindent(), - ), + "formatter": [ + { + "code_action": "default-1" + }, + { + "code_action": "default-2" + }, + { + "code_action": "default-3" + }, + { + "code_action": "default-4" + } + ], + "languages": { + "Rust": { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }, + "Python": { + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"#}), ); } @@ -1567,184 +1554,181 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": { - "code_actions": { - "default-1": true, - "default-2": true, - "default-3": true, - "default-4": true, - } - }, - "format_on_save": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ], - "languages": { - "Rust": { - "format_on_save": "prettier", - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - }, - "Python": { - "format_on_save": { - "code_actions": { - "on-save-1": true, - "on-save-2": true, - } - }, - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - } - } - }"# - .unindent(), - Some( - &r#" - { - "formatter": [ - { - "code_action": "default-1" - }, - { - "code_action": "default-2" - }, - { - "code_action": "default-3" - }, - { - "code_action": "default-4" - } - ], - "format_on_save": [ - { - "code_action": "included-1" - }, - { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ], - "languages": { - "Rust": { - "format_on_save": "prettier", - "formatter": [ + indoc! {r#" { - "code_action": "included-1" - }, + "formatter": { + "code_actions": { + "default-1": true, + "default-2": true, + "default-3": true, + "default-4": true, + } + }, + "format_on_save": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ], + "languages": { + "Rust": { + "format_on_save": "prettier", + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }, + "Python": { + "format_on_save": { + "code_actions": { + "on-save-1": true, + "on-save-2": true, + } + }, + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"#}, + Some(indoc! {r#" { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - }, - "Python": { - "format_on_save": [ - { - "code_action": "on-save-1" - }, - { - "code_action": "on-save-2" - } - ], - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - } - } - }"# - .unindent(), - ), + "formatter": [ + { + "code_action": "default-1" + }, + { + "code_action": "default-2" + }, + { + "code_action": "default-3" + }, + { + "code_action": "default-4" + } + ], + "format_on_save": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ], + "languages": { + "Rust": { + "format_on_save": "prettier", + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }, + "Python": { + "format_on_save": [ + { + "code_action": "on-save-1" + }, + { + "code_action": "on-save-2" + } + ], + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"#}), ); } @@ -1754,17 +1738,15 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "prettier" - }"# - .unindent(), - Some( - &r#"{ - "formatter": "prettier", - "format_on_save": "on" - }"# - .unindent(), - ), + indoc! {r#" + { + "format_on_save": "prettier" + }"#}, + Some(indoc! {r#" + { + "formatter": "prettier", + "format_on_save": "on" + }"#}), ); } @@ -1774,12 +1756,12 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": ["prettier", {"language_server": "eslint"}] - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "format_on_save": ["prettier", {"language_server": "eslint"}] + }"#}, + Some(indoc! {r#" + { "formatter": [ "prettier", { @@ -1787,9 +1769,7 @@ mod tests { } ], "format_on_save": "on" - }"# - .unindent(), - ), + }"#}), ); } @@ -1799,10 +1779,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "on" - }"# - .unindent(), + indoc! {r#" + { + "format_on_save": "on" + }"#}, None, ); @@ -1810,10 +1790,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "off" - }"# - .unindent(), + indoc! {r#" + { + "format_on_save": "off" + }"#}, None, ); } @@ -1824,19 +1804,19 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "languages": { - "Rust": { - "format_on_save": "rust-analyzer" - }, - "Python": { - "format_on_save": ["ruff", "black"] + indoc! {r#" + { + "languages": { + "Rust": { + "format_on_save": "rust-analyzer" + }, + "Python": { + "format_on_save": ["ruff", "black"] + } } - } - }"# - .unindent(), - Some( - &r#"{ + }"#}, + Some(indoc! {r#" + { "languages": { "Rust": { "formatter": "rust-analyzer", @@ -1850,9 +1830,7 @@ mod tests { "format_on_save": "on" } } - }"# - .unindent(), - ), + }"#}), ); } @@ -1862,20 +1840,20 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "prettier", - "languages": { - "Rust": { - "format_on_save": "rust-analyzer" - }, - "Python": { - "format_on_save": "on" + indoc! {r#" + { + "format_on_save": "prettier", + "languages": { + "Rust": { + "format_on_save": "rust-analyzer" + }, + "Python": { + "format_on_save": "on" + } } - } - }"# - .unindent(), - Some( - &r#"{ + }"#}, + Some(indoc! {r#" + { "formatter": "prettier", "format_on_save": "on", "languages": { @@ -1887,9 +1865,7 @@ mod tests { "format_on_save": "on" } } - }"# - .unindent(), - ), + }"#}), ); } @@ -1899,10 +1875,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "formatter": ["prettier"] - }"# - .unindent(), + indoc! {r#" + { + "formatter": ["prettier"] + }"#}, None, ); } @@ -1913,34 +1889,32 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": { - "code_action": "foo" - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": { + "code_action": "foo" + } + }"#}, + Some(indoc! {r#" + { "code_actions_on_format": { "foo": true }, "formatter": [] - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": [ - { "code_action": "foo" }, - "auto" - ] - }"# - .unindent(), + indoc! {r#" + { + "formatter": [ + { "code_action": "foo" }, + "auto" + ] + }"#}, None, ); @@ -1948,46 +1922,44 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": { - "code_action": "foo" - }, - "code_actions_on_format": { - "bar": true, - "baz": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": { + "code_action": "foo" + }, + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"#}, + Some(indoc! {r#" + { "formatter": [], "code_actions_on_format": { "foo": true, "bar": true, "baz": false } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": [ - { "code_action": "foo" }, - { "code_action": "qux" }, - ], - "code_actions_on_format": { - "bar": true, - "baz": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": [ + { "code_action": "foo" }, + { "code_action": "qux" }, + ], + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"#}, + Some(indoc! {r#" + { "formatter": [], "code_actions_on_format": { "foo": true, @@ -1995,23 +1967,21 @@ mod tests { "bar": true, "baz": false } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": [], - "code_actions_on_format": { - "bar": true, - "baz": false - } - }"# - .unindent(), + indoc! {r#" + { + "formatter": [], + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"#}, None, ); } @@ -2022,7 +1992,7 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2030,60 +2000,54 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ - "file_finder": { - "include_ignored": true - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "file_finder": { + "include_ignored": true + } + }"#}, + Some(indoc! {r#" + { "file_finder": { "include_ignored": "all" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ - "file_finder": { - "include_ignored": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "file_finder": { + "include_ignored": false + } + }"#}, + Some(indoc! {r#" + { "file_finder": { "include_ignored": "indexed" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ - "file_finder": { - "include_ignored": null - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "file_finder": { + "include_ignored": null + } + }"#}, + Some(indoc! {r#" + { "file_finder": { "include_ignored": "smart" } - }"# - .unindent(), - ), + }"#}), ); // Platform key: settings nested inside "linux" should be migrated @@ -2091,18 +2055,16 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#" - { - "linux": { - "file_finder": { - "include_ignored": true + indoc! {r#" + { + "linux": { + "file_finder": { + "include_ignored": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "file_finder": { @@ -2110,9 +2072,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2120,20 +2080,18 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#" - { - "profiles": { - "work": { - "file_finder": { - "include_ignored": false + indoc! {r#" + { + "profiles": { + "work": { + "file_finder": { + "include_ignored": false + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2143,9 +2101,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -2155,7 +2111,7 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2163,32 +2119,28 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#"{ - "relative_line_numbers": true - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "relative_line_numbers": true + }"#}, + Some(indoc! {r#" + { "relative_line_numbers": "enabled" - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#"{ - "relative_line_numbers": false - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "relative_line_numbers": false + }"#}, + Some(indoc! {r#" + { "relative_line_numbers": "disabled" - }"# - .unindent(), - ), + }"#}), ); // Platform key: settings nested inside "macos" should be migrated @@ -2196,24 +2148,20 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#" - { - "macos": { - "relative_line_numbers": true + indoc! {r#" + { + "macos": { + "relative_line_numbers": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "relative_line_numbers": "enabled" } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2221,18 +2169,16 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#" - { - "profiles": { - "dev": { - "relative_line_numbers": false + indoc! {r#" + { + "profiles": { + "dev": { + "relative_line_numbers": false + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "dev": { @@ -2240,9 +2186,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -2252,7 +2196,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2260,52 +2204,48 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ - "agent": { - "play_sound_when_agent_done": true - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "agent": { + "play_sound_when_agent_done": true + } + }"#}, + Some(indoc! {r#" + { "agent": { "play_sound_when_agent_done": "always" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ - "agent": { - "play_sound_when_agent_done": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "agent": { + "play_sound_when_agent_done": false + } + }"#}, + Some(indoc! {r#" + { "agent": { "play_sound_when_agent_done": "never" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ - "agent": { - "play_sound_when_agent_done": "when_hidden" - } - }"# - .unindent(), + indoc! {r#" + { + "agent": { + "play_sound_when_agent_done": "when_hidden" + } + }"#}, None, ); @@ -2314,18 +2254,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#" - { - "macos": { - "agent": { - "play_sound_when_agent_done": true + indoc! {r#" + { + "macos": { + "agent": { + "play_sound_when_agent_done": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "agent": { @@ -2333,9 +2271,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2343,20 +2279,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#" - { - "profiles": { - "work": { - "agent": { - "play_sound_when_agent_done": false + indoc! {r#" + { + "profiles": { + "work": { + "agent": { + "play_sound_when_agent_done": false + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2366,38 +2300,34 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_remove_context_server_source() { assert_migrate_settings( - &r#" - { - "context_servers": { - "extension_server": { - "source": "extension", - "settings": { - "foo": "bar" - } - }, - "custom_server": { - "source": "custom", - "command": "foo", - "args": ["bar"], - "env": { - "FOO": "BAR" - } - }, + indoc! {r#" + { + "context_servers": { + "extension_server": { + "source": "extension", + "settings": { + "foo": "bar" + } + }, + "custom_server": { + "source": "custom", + "command": "foo", + "args": ["bar"], + "env": { + "FOO": "BAR" + } + }, + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "context_servers": { "extension_server": { @@ -2414,9 +2344,7 @@ mod tests { }, } } - "# - .unindent(), - ), + "#}), ); // Platform key: settings nested inside "linux" should be migrated @@ -2424,23 +2352,21 @@ mod tests { &[MigrationType::Json( migrations::m_2025_11_25::remove_context_server_source, )], - &r#" - { - "linux": { - "context_servers": { - "my_server": { - "source": "extension", - "settings": { - "key": "value" + indoc! {r#" + { + "linux": { + "context_servers": { + "my_server": { + "source": "extension", + "settings": { + "key": "value" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "context_servers": { @@ -2452,9 +2378,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2462,24 +2386,22 @@ mod tests { &[MigrationType::Json( migrations::m_2025_11_25::remove_context_server_source, )], - &r#" - { - "profiles": { - "work": { - "context_servers": { - "my_server": { - "source": "custom", - "command": "foo", - "args": ["bar"] + indoc! {r#" + { + "profiles": { + "work": { + "context_servers": { + "my_server": { + "source": "custom", + "command": "foo", + "args": ["bar"] + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2492,72 +2414,60 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_project_panel_open_file_on_paste_migration() { assert_migrate_settings( - &r#" - { - "project_panel": { - "open_file_on_paste": true + indoc! {r#" + { + "project_panel": { + "open_file_on_paste": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "auto_open": { "on_paste": true } } } - "# - .unindent(), - ), + "#}), ); assert_migrate_settings( - &r#" - { - "project_panel": { - "open_file_on_paste": false + indoc! {r#" + { + "project_panel": { + "open_file_on_paste": false + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "auto_open": { "on_paste": false } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_enable_preview_from_code_navigation_migration() { assert_migrate_settings( - &r#" - { - "other_setting_1": 1, - "preview_tabs": { - "other_setting_2": 2, - "enable_preview_from_code_navigation": false + indoc! {r#" + { + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_preview_from_code_navigation": false + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "other_setting_1": 1, "preview_tabs": { @@ -2565,24 +2475,20 @@ mod tests { "enable_keep_preview_on_code_navigation": false } } - "# - .unindent(), - ), + "#}), ); assert_migrate_settings( - &r#" - { - "other_setting_1": 1, - "preview_tabs": { - "other_setting_2": 2, - "enable_preview_from_code_navigation": true + indoc! {r#" + { + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_preview_from_code_navigation": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "other_setting_1": 1, "preview_tabs": { @@ -2590,9 +2496,7 @@ mod tests { "enable_keep_preview_on_code_navigation": true } } - "# - .unindent(), - ), + "#}), ); } @@ -2603,7 +2507,7 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2612,16 +2516,14 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": true - }"# - .unindent(), - Some( - &r#"{ - "auto_indent": "syntax_aware" - }"# - .unindent(), - ), + indoc! {r#" + { + "auto_indent": true + }"#}, + Some(indoc! {r#" + { + "auto_indent": "syntax_aware" + }"#}), ); // false should become "none" @@ -2629,16 +2531,14 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": false - }"# - .unindent(), - Some( - &r#"{ - "auto_indent": "none" - }"# - .unindent(), - ), + indoc! {r#" + { + "auto_indent": false + }"#}, + Some(indoc! {r#" + { + "auto_indent": "none" + }"#}), ); // Already valid enum values should not change @@ -2646,10 +2546,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": "preserve_indent" - }"# - .unindent(), + indoc! {r#" + { + "auto_indent": "preserve_indent" + }"#}, None, ); @@ -2658,26 +2558,24 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": true, - "languages": { - "Python": { - "auto_indent": false + indoc! {r#" + { + "auto_indent": true, + "languages": { + "Python": { + "auto_indent": false + } } - } - }"# - .unindent(), - Some( - &r#"{ + }"#}, + Some(indoc! {r#" + { "auto_indent": "syntax_aware", "languages": { "Python": { "auto_indent": "none" } } - }"# - .unindent(), - ), + }"#}), ); } @@ -2687,7 +2585,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2695,93 +2593,80 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "copilot" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "copilot" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "copilot" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "zed" - }, - "edit_predictions": { - "mode": "eager" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "zed" + }, + "edit_predictions": { + "mode": "eager" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "zed", "mode": "eager" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "supermaven" - }, - "edit_predictions": { - "provider": "copilot" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "supermaven" + }, + "edit_predictions": { + "provider": "copilot" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "copilot" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "edit_predictions": { - "provider": "zed" + indoc! {r#" + { + "edit_predictions": { + "provider": "zed" + } } - } - "# - .unindent(), + "#}, None, ); @@ -2791,23 +2676,19 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "copilot" - }, - "edit_predictions": true - } - "# - .unindent(), - Some( - &r#" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "copilot" + }, + "edit_predictions": true + } + "#}, + Some(indoc! {r#" { "edit_predictions": true } - "# - .unindent(), - ), + "#}), ); // Platform key: settings nested inside "macos" should be migrated @@ -2815,18 +2696,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "macos": { - "features": { - "edit_prediction_provider": "copilot" + indoc! {r#" + { + "macos": { + "features": { + "edit_prediction_provider": "copilot" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "edit_predictions": { @@ -2834,9 +2713,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2844,20 +2721,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "profiles": { - "work": { - "features": { - "edit_prediction_provider": "copilot" + indoc! {r#" + { + "profiles": { + "work": { + "features": { + "edit_prediction_provider": "copilot" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2867,9 +2742,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Combined: root + platform + profile should all be migrated simultaneously @@ -2877,28 +2750,26 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "copilot" - }, - "macos": { + indoc! {r#" + { "features": { - "edit_prediction_provider": "zed" - } - }, - "profiles": { - "work": { + "edit_prediction_provider": "copilot" + }, + "macos": { "features": { - "edit_prediction_provider": "supermaven" + "edit_prediction_provider": "zed" + } + }, + "profiles": { + "work": { + "features": { + "edit_prediction_provider": "supermaven" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "copilot" @@ -2916,9 +2787,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -2928,7 +2797,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2936,92 +2805,79 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "sweep" + indoc! {r#" + { + "edit_predictions": { + "provider": { + "experimental": "sweep" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "sweep" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "mercury" + indoc! {r#" + { + "edit_predictions": { + "provider": { + "experimental": "mercury" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "mercury" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "features": { - "edit_prediction_provider": { - "experimental": "sweep" + indoc! {r#" + { + "features": { + "edit_prediction_provider": { + "experimental": "sweep" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "features": { "edit_prediction_provider": "sweep" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": "zed" + indoc! {r#" + { + "edit_predictions": { + "provider": "zed" + } } - } - "# - .unindent(), + "#}, None, ); @@ -3029,26 +2885,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "zeta2" + indoc! {r#" + { + "edit_predictions": { + "provider": { + "experimental": "zeta2" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "zed" } } - "# - .unindent(), - ), + "#}), ); // Platform key: settings nested inside "linux" should be migrated @@ -3056,20 +2908,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "linux": { - "edit_predictions": { - "provider": { - "experimental": "sweep" + indoc! {r#" + { + "linux": { + "edit_predictions": { + "provider": { + "experimental": "sweep" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "edit_predictions": { @@ -3077,9 +2927,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -3087,22 +2935,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "profiles": { - "dev": { - "edit_predictions": { - "provider": { - "experimental": "mercury" + indoc! {r#" + { + "profiles": { + "dev": { + "edit_predictions": { + "provider": { + "experimental": "mercury" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "dev": { @@ -3112,9 +2958,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Combined: root + platform + profile should all be migrated simultaneously @@ -3122,34 +2966,32 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "sweep" - } - }, - "linux": { + indoc! {r#" + { "edit_predictions": { "provider": { - "experimental": "mercury" + "experimental": "sweep" } - } - }, - "profiles": { - "dev": { + }, + "linux": { "edit_predictions": { "provider": { - "experimental": "sweep" + "experimental": "mercury" + } + } + }, + "profiles": { + "dev": { + "edit_predictions": { + "provider": { + "experimental": "sweep" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "sweep" @@ -3167,9 +3009,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -3180,7 +3020,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -3189,16 +3029,14 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3206,9 +3044,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // always_allow_tool_actions: false -> just remove it @@ -3216,14 +3052,13 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": false + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": false + } } - } - "# - .unindent(), + "#}, Some( // The blank line has spaces because the migration preserves the original indentation "{\n \"agent\": {\n \n }\n}\n", @@ -3235,23 +3070,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": { - "tools": { - "terminal": { - "always_deny": [{ "pattern": "rm\\s+-rf" }] + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": { + "tools": { + "terminal": { + "always_deny": [{ "pattern": "rm\\s+-rf" }] + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3264,9 +3097,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Don't override existing default (and migrate default_mode to default) @@ -3274,19 +3105,17 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": { - "default_mode": "confirm" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": { + "default_mode": "confirm" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3294,9 +3123,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Migrate existing default_mode to default (no always_allow_tool_actions) @@ -3304,18 +3131,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default_mode": "allow" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default_mode": "allow" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3323,9 +3148,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // No migration needed if already using new format with "default" @@ -3333,16 +3156,15 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default": "allow" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default": "allow" + } } } - } - "# - .unindent(), + "#}, None, ); @@ -3351,23 +3173,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default_mode": "confirm", - "tools": { - "terminal": { - "default_mode": "allow" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default_mode": "confirm", + "tools": { + "terminal": { + "default_mode": "allow" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3380,9 +3200,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // When tool_permissions is null, replace it so always_allow is preserved @@ -3390,17 +3208,15 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": null + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": null + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3408,9 +3224,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Platform-specific agent migration @@ -3418,18 +3232,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "linux": { - "agent": { - "always_allow_tool_actions": true + indoc! {r#" + { + "linux": { + "agent": { + "always_allow_tool_actions": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "agent": { @@ -3439,9 +3251,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Channel-specific agent migration @@ -3449,23 +3259,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "nightly": { + indoc! {r#" + { "agent": { - "tool_permissions": { - "default_mode": "confirm" + "always_allow_tool_actions": true + }, + "nightly": { + "agent": { + "tool_permissions": { + "default_mode": "confirm" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3480,9 +3288,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile-level migration @@ -3490,23 +3296,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "profiles": { - "custom": { - "always_allow_tool_actions": true, - "tool_permissions": { - "default_mode": "allow" + indoc! {r#" + { + "agent": { + "profiles": { + "custom": { + "always_allow_tool_actions": true, + "tool_permissions": { + "default_mode": "allow" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "profiles": { @@ -3518,9 +3322,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Platform-specific agent with profiles @@ -3528,25 +3330,23 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "macos": { - "agent": { - "always_allow_tool_actions": true, - "profiles": { - "strict": { - "tool_permissions": { - "default_mode": "deny" + indoc! {r#" + { + "macos": { + "agent": { + "always_allow_tool_actions": true, + "profiles": { + "strict": { + "tool_permissions": { + "default_mode": "deny" + } } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "agent": { @@ -3563,9 +3363,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Root-level profile with always_allow_tool_actions @@ -3573,20 +3371,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "profiles": { - "work": { - "agent": { - "always_allow_tool_actions": true + indoc! {r#" + { + "profiles": { + "work": { + "agent": { + "always_allow_tool_actions": true + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -3598,9 +3394,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Root-level profile with default_mode @@ -3608,22 +3402,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "profiles": { - "work": { - "agent": { - "tool_permissions": { - "default_mode": "allow" + indoc! {r#" + { + "profiles": { + "work": { + "agent": { + "tool_permissions": { + "default_mode": "allow" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -3635,9 +3427,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Root-level profile + root-level agent both migrated @@ -3645,25 +3435,23 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "profiles": { - "strict": { - "agent": { - "tool_permissions": { - "default_mode": "deny" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true + }, + "profiles": { + "strict": { + "agent": { + "tool_permissions": { + "default_mode": "deny" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3680,9 +3468,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Non-boolean always_allow_tool_actions (string "true") is left in place @@ -3691,14 +3477,13 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": "true" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": "true" + } } - } - "# - .unindent(), + "#}, None, ); @@ -3707,14 +3492,13 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": null + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": null + } } - } - "# - .unindent(), + "#}, Some(&"{\n \"agent\": {\n \n }\n}\n"), ); @@ -3724,24 +3508,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": { - "tools": { - "terminal": { - "default_mode": "confirm", - "always_deny": [{ "pattern": "rm\\s+-rf" }] + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": { + "tools": { + "terminal": { + "default_mode": "confirm", + "always_deny": [{ "pattern": "rm\\s+-rf" }] + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3755,9 +3537,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Project-local settings with only default_mode (no always_allow_tool_actions) @@ -3765,18 +3545,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default_mode": "deny" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default_mode": "deny" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3784,9 +3562,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Project-local settings with no agent section at all - no change @@ -3794,13 +3570,12 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "tab_size": 4, - "format_on_save": "on" - } - "# - .unindent(), + indoc! {r#" + { + "tab_size": 4, + "format_on_save": "on" + } + "#}, None, ); @@ -3809,24 +3584,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "agent_servers": { - "claude": { - "default_mode": "plan" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true }, - "codex": { - "default_mode": "read-only" + "agent_servers": { + "claude": { + "default_mode": "plan" + }, + "codex": { + "default_mode": "read-only" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3842,9 +3615,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Existing agent_servers are left untouched even with partial entries @@ -3852,21 +3623,19 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "agent_servers": { - "claude": { - "default_mode": "plan" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true + }, + "agent_servers": { + "claude": { + "default_mode": "plan" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3879,9 +3648,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // always_allow_tool_actions: false leaves agent_servers untouched @@ -3889,17 +3656,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": false - }, - "agent_servers": { - "claude": {} + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": false + }, + "agent_servers": { + "claude": {} + } } - } - "# - .unindent(), + "#}, Some( "{\n \"agent\": {\n \n },\n \"agent_servers\": {\n \"claude\": {}\n }\n}\n", ), @@ -4324,18 +4090,17 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction_conflict", - "bindings": { - "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding + indoc! {r#" + [ + { + "context": "Editor && edit_prediction_conflict", + "bindings": { + "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding + } } - } - ] - "#.unindent(), - Some( - &r#" + ] + "#}, + Some(indoc! {r#" [ { "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", @@ -4344,8 +4109,7 @@ mod tests { } } ] - "#.unindent(), - ), + "#}), ); assert_migrate_with_migrations( @@ -4353,19 +4117,18 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction_conflict && !showing_completions", - "bindings": { - // Here we don't require a modifier unless there's a language server completion - "tab": "editor::AcceptEditPrediction" + indoc! {r#" + [ + { + "context": "Editor && edit_prediction_conflict && !showing_completions", + "bindings": { + // Here we don't require a modifier unless there's a language server completion + "tab": "editor::AcceptEditPrediction" + } } - } - ] - "#.unindent(), - Some( - &r#" + ] + "#}, + Some(indoc! {r#" [ { "context": "Editor && (edit_prediction && in_leading_whitespace)", @@ -4375,8 +4138,7 @@ mod tests { } } ] - "#.unindent(), - ), + "#}), ); assert_migrate_with_migrations( @@ -4384,19 +4146,17 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction_conflict && showing_completions", - "bindings": { - "tab": "editor::AcceptEditPrediction" + indoc! {r#" + [ + { + "context": "Editor && edit_prediction_conflict && showing_completions", + "bindings": { + "tab": "editor::AcceptEditPrediction" + } } - } - ] - "# - .unindent(), - Some( - &r#" + ] + "#}, + Some(indoc! {r#" [ { "context": "Editor && (edit_prediction && showing_completions)", @@ -4405,9 +4165,7 @@ mod tests { } } ] - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( @@ -4415,57 +4173,53 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction", - "bindings": { - "tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - { - "context": "Editor && edit_prediction_conflict", - "bindings": { - "alt-tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - ] - "# - .unindent(), - Some( - &r#" - [ - { - "context": "Editor && edit_prediction", - "bindings": { - "tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - { - "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", - "bindings": { - "alt-tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - ] - "# - .unindent(), - ), + indoc! {r#" + [ + { + "context": "Editor && edit_prediction", + "bindings": { + "tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + { + "context": "Editor && edit_prediction_conflict", + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + ] + "#}, + Some(indoc! {r#" + [ + { + "context": "Editor && edit_prediction", + "bindings": { + "tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + { + "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + ] + "#}), ); } #[test] fn test_restructure_profiles_with_settings_key() { assert_migrate_settings( - &r#" + indoc! {r#" { "buffer_font_size": 14, "profiles": { @@ -4478,10 +4232,8 @@ mod tests { } } } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "buffer_font_size": 14, "profiles": { @@ -4498,16 +4250,14 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_restructure_profiles_with_settings_key_already_migrated() { assert_migrate_settings( - &r#" + indoc! {r#" { "profiles": { "Presenting": { @@ -4517,8 +4267,7 @@ mod tests { } } } - "# - .unindent(), + "#}, None, ); } @@ -4526,12 +4275,11 @@ mod tests { #[test] fn test_restructure_profiles_with_settings_key_no_profiles() { assert_migrate_settings( - &r#" + indoc! {r#" { "buffer_font_size": 14 } - "# - .unindent(), + "#}, None, ); } @@ -4542,22 +4290,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "allow": true + indoc! {r#" + { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "allow": true + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -4569,9 +4315,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4581,22 +4325,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "profiles": { - "write": { - "tools": { - "web_search": false + indoc! {r#" + { + "agent": { + "profiles": { + "write": { + "tools": { + "web_search": false + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "profiles": { @@ -4608,9 +4350,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4620,20 +4360,19 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "tool_permissions": { - "tools": { - "search_web": { - "allow": true + indoc! {r#" + { + "agent": { + "tool_permissions": { + "tools": { + "search_web": { + "allow": true + } } } } } - } - "# - .unindent(), + "#}, None, ); } @@ -4644,25 +4383,23 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "allow": false - }, - "search_web": { - "allow": true + indoc! {r#" + { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "allow": false + }, + "search_web": { + "allow": true + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -4674,9 +4411,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4686,24 +4421,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "linux": { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "allow": true + indoc! {r#" + { + "linux": { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "allow": true + } } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "agent": { @@ -4717,9 +4450,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4729,24 +4460,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "nightly": { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "default": "allow" + indoc! {r#" + { + "nightly": { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "default": "allow" + } } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "nightly": { "agent": { @@ -4760,9 +4489,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4772,12 +4499,11 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "buffer_font_size": 14 - } - "# - .unindent(), + indoc! {r#" + { + "buffer_font_size": 14 + } + "#}, None, ); } @@ -4880,16 +4606,17 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "profiles": { - "Work": { - "settings": { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "default": "allow" + indoc! {r#" + { + "profiles": { + "Work": { + "settings": { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "default": "allow" + } } } } @@ -4897,11 +4624,8 @@ mod tests { } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "Work": { @@ -4919,9 +4643,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4958,70 +4680,60 @@ mod tests { #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_at_root() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "title_bar": { "show_branch_status_icon": true, "show_branch_name": true } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_drop_show_branch_icon_false_without_setting_status_icon() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_icon": false, - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_icon": false, + "show_branch_name": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "title_bar": { "show_branch_name": true } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_platform_override() { assert_migrate_settings( - &r#" - { - "macos": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "macos": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "title_bar": { @@ -5030,28 +4742,24 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_release_override() { assert_migrate_settings( - &r#" - { - "preview": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "preview": { "title_bar": { @@ -5060,30 +4768,26 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_profiles() { assert_migrate_settings( - &r#" - { - "profiles": { - "work": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -5096,46 +4800,42 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_across_all_scopes() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true - }, - "macos": { + indoc! {r#" + { "title_bar": { "show_branch_icon": true, "show_branch_name": true - } - }, - "preview": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true - } - }, - "profiles": { - "work": { + }, + "macos": { "title_bar": { "show_branch_icon": true, "show_branch_name": true } + }, + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + }, + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "title_bar": { "show_branch_status_icon": true, @@ -5164,9 +4864,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5210,31 +4908,29 @@ mod tests { fn test_promote_show_branch_icon_true_to_show_branch_status_icon_no_change_when_already_migrated() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_status_icon": true, - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } } - } - "# - .unindent(), + "#}, None, ); // No title_bar key — should be unchanged - assert_migrate_settings(&r#"{ "theme": "One Dark" }"#.unindent(), None); + assert_migrate_settings(r#"{ "theme": "One Dark" }"#, None); // title_bar without show_branch_icon — should be unchanged assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_name": true + } } - } - "# - .unindent(), + "#}, None, ); } @@ -5242,16 +4938,14 @@ mod tests { #[test] fn test_make_git_gutter_width_an_enum_from_number() { assert_migrate_settings( - &r#" - { - "gutter": { - "git_gutter_width": 4.0 + indoc! {r#" + { + "gutter": { + "git_gutter_width": 4.0 + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "gutter": { "git_gutter_width": { @@ -5259,9 +4953,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5269,32 +4961,30 @@ mod tests { fn test_make_git_gutter_width_an_enum_no_change_when_already_migrated() { // already "default" string — no change assert_migrate_settings( - &r#" - { - "gutter": { - "git_gutter_width": "default" + indoc! {r#" + { + "gutter": { + "git_gutter_width": "default" + } } - } - "# - .unindent(), + "#}, None, ); // already custom object — no change assert_migrate_settings( - &r#" - { - "gutter": { - "git_gutter_width": { "custom": 4.0 } + indoc! {r#" + { + "gutter": { + "git_gutter_width": { "custom": 4.0 } + } } - } - "# - .unindent(), + "#}, None, ); // no gutter key — no change - assert_migrate_settings(&r#"{ "theme": "One Dark" }"#.unindent(), None); + assert_migrate_settings(r#"{ "theme": "One Dark" }"#, None); } #[test] @@ -5343,22 +5033,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - "project_panel": { - "folder_icons": true - }, - "outline_panel": { - "folder_icons": false - }, - "git_panel": { - "folder_icons": true + indoc! {r#" + { + "project_panel": { + "folder_icons": true + }, + "outline_panel": { + "folder_icons": false + }, + "git_panel": { + "folder_icons": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "folder_indicator": "icon" @@ -5370,9 +5058,7 @@ mod tests { "folder_indicator": "icon" } } - "# - .unindent(), - ), + "#}), ); } @@ -5385,19 +5071,17 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - // Keep this comment. - "project_panel": { - "file_icons": true, - "folder_icons": false, - "indent_size": 20 + indoc! {r#" + { + // Keep this comment. + "project_panel": { + "file_icons": true, + "folder_icons": false, + "indent_size": 20 + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { // Keep this comment. "project_panel": { @@ -5406,9 +5090,7 @@ mod tests { "indent_size": 20 } } - "# - .unindent(), - ), + "#}), ); } @@ -5418,18 +5100,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - "macos": { - "project_panel": { - "folder_icons": false + indoc! {r#" + { + "macos": { + "project_panel": { + "folder_icons": false + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "project_panel": { @@ -5437,9 +5117,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5449,25 +5127,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - "project_panel": { - "folder_icons": true, - "folder_indicator": "both" + indoc! {r#" + { + "project_panel": { + "folder_icons": true, + "folder_indicator": "both" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "folder_indicator": "both" } } - "# - .unindent(), - ), + "#}), ); } @@ -5480,42 +5154,39 @@ mod tests { // Already migrated. assert_migrate_with_migrations( migrations, - &r#" - { - "project_panel": { - "folder_indicator": "both" + indoc! {r#" + { + "project_panel": { + "folder_indicator": "both" + } } - } - "# - .unindent(), + "#}, None, ); // A non-boolean value is already invalid; leave it rather than guess. assert_migrate_with_migrations( migrations, - &r#" - { - "project_panel": { - "folder_icons": 3 + indoc! {r#" + { + "project_panel": { + "folder_icons": 3 + } } - } - "# - .unindent(), + "#}, None, ); // `folder_icons` outside the three panels is not ours to rename. assert_migrate_with_migrations( migrations, - &r#" - { - "terminal": { - "folder_icons": true + indoc! {r#" + { + "terminal": { + "folder_icons": true + } } - } - "# - .unindent(), + "#}, None, ); } @@ -5523,24 +5194,220 @@ mod tests { #[test] fn test_rename_folder_icons_to_folder_indicator_is_registered() { assert_migrate_settings( - &r#" - { - "project_panel": { - "folder_icons": false + indoc! {r#" + { + "project_panel": { + "folder_icons": false + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "folder_indicator": "chevron" } } - "# - .unindent(), - ), + "#}), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_across_all_scopes() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + indoc! {r#" + { + "markdown_preview_font_size": 14, + "macos": { + "markdown_preview_font_size": 15 + }, + "preview": { + "markdown_preview_font_size": 16 + }, + "profiles": { + "work": { + "settings": { + "markdown_preview_font_size": 17 + } + } + } + } + "#}, + Some(indoc! {r#" + { + "markdown_preview": { + "font_size": 14 + }, + "macos": { + "markdown_preview": { + "font_size": 15 + } + }, + "preview": { + "markdown_preview": { + "font_size": 16 + } + }, + "profiles": { + "work": { + "settings": { + "markdown_preview": { + "font_size": 17 + } + } + } + } + } + "#}), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_merges_without_clobbering() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + indoc! {r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark", + "markdown_preview": { + "font_size": 18, + "limit_content_width": false, + "max_width": 900 + } + } + "#}, + Some(indoc! {r#" + { + "markdown_preview": { + "theme": "One Dark", + "code_font_family": "Zed Mono", + "font_family": "Zed Sans", + "font_size": 18, + "limit_content_width": false, + "max_width": 900 + } + } + "#}), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_leaves_malformed_object() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + indoc! {r#" + { + "markdown_preview_font_size": 15, + "markdown_preview": false + } + "#}, + None, + ); + } + + #[test] + fn test_nest_markdown_preview_settings_replaces_null_when_legacy_settings_exist() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + indoc! {r#" + { + "markdown_preview": null, + "markdown_preview_font_size": 15, + "preview": { + "markdown_preview": null, + "markdown_preview_theme": "One Dark" + } + } + "#}, + Some(indoc! {r#" + { + "markdown_preview": { + "font_size": 15 + }, + "preview": { + "markdown_preview": { + "theme": "One Dark" + } + } + } + "#}), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_leaves_null_without_legacy_settings() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + indoc! {r#" + { + "markdown_preview": null, + "preview": { + "markdown_preview": null + } + } + "#}, + None, + ); + } + + #[test] + fn test_nest_markdown_preview_settings_is_registered() { + assert_migrate_settings( + indoc! {r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark" + } + "#}, + Some(indoc! {r#" + { + "markdown_preview": { + "font_size": 15, + "font_family": "Zed Sans", + "code_font_family": "Zed Mono", + "theme": "One Dark" + } + } + "#}), + ); + } + + #[test] + fn test_nest_agent_threads_sidebar_settings_is_registered() { + assert_migrate_settings( + indoc! {r#" + { + "agent": { + "sidebar_side": "right", + "threads_sidebar_default_width": 420 + } + } + "#}, + Some(indoc! {r#" + { + "agent": { + "threads_sidebar": { + "position": "right", + "default_width": 420 + } + } + } + "#}), ); } } diff --git a/crates/miniprofiler_ui/Cargo.toml b/crates/miniprofiler_ui/Cargo.toml index 09144c0fc6..5c60b582d5 100644 --- a/crates/miniprofiler_ui/Cargo.toml +++ b/crates/miniprofiler_ui/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/miniprofiler_ui.rs" +test = false +doctest = false [dependencies] command_palette_hooks.workspace = true diff --git a/crates/mistral/Cargo.toml b/crates/mistral/Cargo.toml index c4d475f014..d76b533f7c 100644 --- a/crates/mistral/Cargo.toml +++ b/crates/mistral/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/mistral.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index e07cd34e92..1d98e24e8b 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -3774,10 +3774,18 @@ impl MultiBufferSnapshot { cursor.seek(&start); std::iter::from_fn(move || { - let region = cursor.region()?; - if region.range.start > end { - return None; - } + let Some(region) = cursor.region().filter(|region| { + region.range.start < end || (region.range.start == end && start == end) + }) else { + let excerpt = cursor.excerpt()?; + if excerpt.text_summary.len != 0 || end != self.len() { + return None; + } + let buffer = excerpt.buffer_snapshot(self); + let offset = BufferOffset(excerpt.range.context.start.to_offset(buffer)); + cursor.next_excerpt(); + return Some((buffer, offset..offset, None)); + }; let start_overshoot = start.saturating_sub(region.range.start); let end_overshoot = end.saturating_sub(region.range.start); let start = region @@ -4532,8 +4540,15 @@ impl MultiBufferSnapshot { if self.language_settings(cx).extend_comment_on_newline && let Some(language_scope) = self.language_scope_at(Point::new(row.0, 0)) { - let delimiters = language_scope.line_comment_prefixes(); - for delimiter in delimiters { + let documentation_delimiter = language_scope + .documentation_comment() + .filter(|_| language_scope.override_name() == Some("comment")) + .map(|comment| &comment.prefix); + for delimiter in language_scope + .line_comment_prefixes() + .iter() + .chain(documentation_delimiter) + { if *self .chars_at(Point::new(row.0, indent.len() as u32)) .take(delimiter.chars().count()) diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 651e618b67..462b646e0d 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -1322,6 +1322,45 @@ fn test_expand_excerpts(cx: &mut App) { ); } +#[gpui::test] +fn test_expand_excerpts_with_anchor_for_removed_path(cx: &mut App) { + let buffer_a = cx.new(|cx| Buffer::local(sample_text(10, 3, 'a'), cx)); + let buffer_b = cx.new(|cx| Buffer::local(sample_text(10, 3, 'a'), cx)); + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_a, + vec![Point::new(3, 0)..Point::new(3, 3)], + 1, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_b, + vec![Point::new(3, 0)..Point::new(3, 3)], + 1, + cx, + ); + }); + + let anchor_in_a = multibuffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(1, 0)); + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts(PathKey::sorted(0), cx); + }); + assert_eq!(multibuffer.read(cx).snapshot(cx).text(), "ccc\nddd\neee"); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.expand_excerpts([anchor_in_a], 1, ExpandExcerptDirection::UpAndDown, cx); + }); + + assert_eq!(multibuffer.read(cx).snapshot(cx).text(), "ccc\nddd\neee"); +} + #[gpui::test(iterations = 100)] async fn test_set_anchored_excerpts_for_path(cx: &mut TestAppContext) { let buffer_1 = cx.new(|cx| Buffer::local(sample_text(20, 3, 'a'), cx)); @@ -6263,6 +6302,24 @@ fn test_range_to_buffer_ranges(cx: &mut App) { "Should include trailing empty excerpts" ); assert_eq!(ranges_half_open_max[1].1, BufferOffset(0)..BufferOffset(0)); + + for snapshot in [&snapshot, &snapshot_trailing] { + for start in 0..=snapshot.len().0 { + for end in start..=snapshot.len().0 { + let range = MultiBufferOffset(start)..MultiBufferOffset(end); + let expected = snapshot + .range_to_buffer_ranges(range.clone()) + .into_iter() + .map(|(buffer, range, _)| (buffer.remote_id(), range, None)) + .collect::>(); + let actual = snapshot + .range_to_buffer_ranges_with_deleted_hunks(range.clone()) + .map(|(buffer, range, anchor)| (buffer.remote_id(), range, anchor)) + .collect::>(); + assert_eq!(actual, expected, "{range:?}"); + } + } + } } #[gpui::test] diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 2b268924cc..2c1483f089 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -258,6 +258,7 @@ impl MultiBuffer { cursor.seek_forward(path, Bias::Left); let Some((buffer, buffer_snapshot)) = cursor .item() + .filter(|excerpt| &excerpt.path_key == path) .map(|excerpt| (excerpt.buffer(&self), excerpt.buffer_snapshot(&snapshot))) else { continue; diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index dabf153859..7579789d7b 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -325,6 +325,7 @@ impl NodeRuntime { .iter() .map(|p| p.as_str()) .chain([ + "--no-package-lock", "--save-exact", "--fetch-retry-mintimeout", "2000", diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml index 5a5ff9eee1..11f27dcb32 100644 --- a/crates/notifications/Cargo.toml +++ b/crates/notifications/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/notifications.rs" doctest = false +test = false [features] test-support = [ diff --git a/crates/oauth_callback_server/Cargo.toml b/crates/oauth_callback_server/Cargo.toml index 5fcc6233af..5baf460613 100644 --- a/crates/oauth_callback_server/Cargo.toml +++ b/crates/oauth_callback_server/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [lib] path = "src/oauth_callback_server.rs" doctest = false +test = false [dependencies] anyhow.workspace = true diff --git a/crates/ollama/Cargo.toml b/crates/ollama/Cargo.toml index fed74993fa..01ef988526 100644 --- a/crates/ollama/Cargo.toml +++ b/crates/ollama/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/ollama.rs" +doctest = false [features] default = [] diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs index 0bbb210b12..046a71286f 100644 --- a/crates/ollama/src/ollama.rs +++ b/crates/ollama/src/ollama.rs @@ -96,6 +96,8 @@ pub enum ChatMessage { Tool { tool_name: String, content: String, + #[serde(skip_serializing_if = "Option::is_none")] + images: Option>, }, } diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 6994b2ba95..585bf37a74 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/onboarding.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/open_ai/Cargo.toml b/crates/open_ai/Cargo.toml index 8b89be9bad..f07e3bbfaa 100644 --- a/crates/open_ai/Cargo.toml +++ b/crates/open_ai/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/open_ai.rs" +doctest = false [features] default = [] diff --git a/crates/open_ai/src/chat_completion_transport_tests.rs b/crates/open_ai/src/chat_completion_transport_tests.rs index d13a621264..8ee79cfd72 100644 --- a/crates/open_ai/src/chat_completion_transport_tests.rs +++ b/crates/open_ai/src/chat_completion_transport_tests.rs @@ -4,6 +4,9 @@ use http_client::{ FakeHttpClient, Response, http::{HeaderName, HeaderValue}, }; +use language_model_core::{ + LanguageModelCompletionError, LanguageModelProviderName, ProviderErrorCategory, +}; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -62,13 +65,13 @@ fn streaming_transport_serializes_custom_requests_and_reports_done() { .expect("stream events") }); - assert_eq!( - events, - vec![ - ChatCompletionStreamEvent::Data(json!({"chunk": 1})), + match events.as_slice() { + [ + ChatCompletionStreamEvent::Data(data), ChatCompletionStreamEvent::Done, - ] - ); + ] => assert_eq!(data.get(), r#"{"chunk":1}"#), + events => panic!("unexpected events: {events:?}"), + } assert_eq!( captured_request .lock() @@ -109,10 +112,63 @@ fn streaming_transport_does_not_synthesize_done_at_eof() { .expect("stream events") }); - assert_eq!( - events, - vec![ChatCompletionStreamEvent::Data(json!({"chunk": 1}))] - ); + match events.as_slice() { + [ChatCompletionStreamEvent::Data(data)] => assert_eq!(data.get(), r#"{"chunk":1}"#), + events => panic!("unexpected events: {events:?}"), + } +} + +#[test] +fn streaming_transport_preserves_usage_limit_errors() { + let client = FakeHttpClient::create(|_| async move { + Ok(Response::builder() + .status(200) + .body(AsyncBody::from(concat!( + "data: {\"error\":{\"type\":\"usage_limit_reached\",", + "\"message\":\"The usage limit has been reached\"}}\n\n" + )))?) + }); + let request = serde_json::from_value(json!({ + "model": "custom/model", + "messages": [], + "stream": true + })) + .expect("valid chat completion request"); + + let error = block_on(async { + stream_completion( + client.as_ref(), + "Compatible Provider", + "https://example.com/v1", + "secret", + request, + &CustomHeaders::default(), + ) + .await + .expect("streaming request") + .next() + .await + .expect("provider error event") + .expect_err("usage limit should reject the completion") + }); + let error = error + .downcast_ref::() + .expect("typed provider rejection"); + + assert!(!error.is_transient()); + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + provider, + status: None, + code: Some(code), + message, + category: ProviderErrorCategory::PaymentRequired, + .. + } if provider == &LanguageModelProviderName::from("Compatible Provider".to_string()) + && code == "usage_limit_reached" + && message == "The usage limit has been reached" + )); } #[test] diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index e82d9695ca..359fe281bd 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -25,8 +25,8 @@ use crate::responses::{ provider_compaction_items, provider_compaction_state_from_items, }; use crate::{ - FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort, - ResponseStreamEvent, ServiceTier, ToolCall, ToolCallContent, + FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort, ServiceTier, + ToolCall, ToolCallContent, }; const RESPONSE_MESSAGE_PHASE_COMMENTARY: &str = "commentary"; @@ -42,6 +42,11 @@ fn service_tier_for(speed: Option) -> Option) -> Option { + temperature.filter(|_| model_id != crate::Model::SixAstra.id()) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ChatCompletionMaxTokensParameter { MaxCompletionTokens, @@ -58,6 +63,7 @@ pub fn into_open_ai( reasoning_effort: Option, interleaved_reasoning: bool, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request .tools .iter() @@ -195,7 +201,7 @@ pub fn into_open_ai( None }, stop: request.stop, - temperature: request.temperature.or(Some(1.0)), + temperature: temperature_for_model(model_id, request.temperature.or(Some(1.0))), max_completion_tokens: match max_tokens_parameter { ChatCompletionMaxTokensParameter::MaxCompletionTokens => max_output_tokens, ChatCompletionMaxTokensParameter::MaxTokens => None, @@ -210,7 +216,7 @@ pub fn into_open_ai( None }, prompt_cache_key: if supports_prompt_cache_key { - request.thread_id + request.prompt_cache_key.or(request.thread_id) } else { None }, @@ -257,10 +263,12 @@ pub fn into_open_ai_response( supports_none_reasoning_effort: bool, compaction_state_owner: &LanguageModelProviderId, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); let stream = !model_id.starts_with("o1-"); let LanguageModelRequest { thread_id, + prompt_cache_key, prompt_id: _, intent: _, messages, @@ -272,6 +280,7 @@ pub fn into_open_ai_response( thinking_effort, speed, compact_at_tokens, + max_output_tokens: _, } = request; let service_tier = service_tier_for(speed); @@ -381,7 +390,7 @@ pub fn into_open_ai_response( store: Some(false), include, stream, - temperature, + temperature: temperature_for_model(model_id, temperature), top_p: None, max_output_tokens, parallel_tool_calls: if tools.is_empty() { @@ -396,7 +405,7 @@ pub fn into_open_ai_response( }), tools, prompt_cache_key: if supports_prompt_cache_key { - thread_id + prompt_cache_key.or(thread_id) } else { None }, @@ -729,281 +738,6 @@ fn add_message_content_part( } } -/// Accumulates structured reasoning metadata from compatible providers. -/// -/// Array entries are matched by `index` and then `id`. Fragmented `text`, -/// `summary`, and `data` fields are concatenated while other non-null fields -/// replace their previous values. -/// -/// # Examples -/// -/// ``` -/// use open_ai::completion::ReasoningDetailsAccumulator; -/// use serde_json::json; -/// -/// let mut accumulator = ReasoningDetailsAccumulator::default(); -/// accumulator.push(json!([{"index": 0, "text": "first "}])); -/// let details = accumulator -/// .push(json!([{"index": 0, "text": "second"}])) -/// .expect("non-empty reasoning details"); -/// -/// assert_eq!(details[0]["text"], "first second"); -/// ``` -#[derive(Debug, Default)] -pub struct ReasoningDetailsAccumulator { - accumulated: Option, -} - -impl ReasoningDetailsAccumulator { - /// Merges `chunk` and returns the updated metadata snapshot. - /// - /// `null` and empty arrays do not replace previously accumulated metadata - /// and return `None`. - pub fn push(&mut self, chunk: serde_json::Value) -> Option { - match chunk { - serde_json::Value::Null => None, - serde_json::Value::Array(chunks) if chunks.is_empty() => None, - serde_json::Value::Array(chunks) => { - let mut details = match self.accumulated.take() { - Some(serde_json::Value::Array(details)) => details, - _ => Vec::new(), - }; - for chunk in chunks { - merge_reasoning_detail(&mut details, chunk); - } - let accumulated = serde_json::Value::Array(details); - self.accumulated = Some(accumulated.clone()); - Some(accumulated) - } - chunk => { - self.accumulated = Some(chunk.clone()); - Some(chunk) - } - } - } -} - -pub struct OpenAiEventMapper { - tool_calls_by_index: HashMap, - reasoning_details: ReasoningDetailsAccumulator, -} - -impl OpenAiEventMapper { - pub fn new() -> Self { - Self { - tool_calls_by_index: HashMap::default(), - reasoning_details: ReasoningDetailsAccumulator::default(), - } - } - - pub fn map_stream( - mut self, - events: Pin>>>, - ) -> impl Stream> - { - events.flat_map(move |event| { - futures::stream::iter(match event { - Ok(event) => self.map_event(event), - Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))], - }) - }) - } - - pub fn map_event( - &mut self, - event: ResponseStreamEvent, - ) -> Vec> { - let mut events = Vec::new(); - if let Some(usage) = event.usage - && let Some(prompt_tokens) = usage.prompt_tokens - && let Some(completion_tokens) = usage.completion_tokens - { - let cache_creation_input_tokens = usage - .prompt_tokens_details - .as_ref() - .and_then(|details| details.cache_write_tokens) - .unwrap_or(0); - let cache_read_input_tokens = usage - .prompt_tokens_details - .as_ref() - .and_then(|details| details.cached_tokens) - .unwrap_or(0); - events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: prompt_tokens - .saturating_sub(cache_creation_input_tokens) - .saturating_sub(cache_read_input_tokens), - output_tokens: completion_tokens, - cache_creation_input_tokens, - cache_read_input_tokens, - }))); - } - - let Some(choice) = event.choices.first() else { - return events; - }; - - if let Some(delta) = choice.delta.as_ref() { - if let Some(reasoning_details) = delta.reasoning_details.clone() - && let Some(reasoning_details) = self.reasoning_details.push(reasoning_details) - { - events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails( - reasoning_details, - ))); - } - if let Some(reasoning) = delta.reasoning.clone() { - push_thinking_event(reasoning, &mut events); - } - if let Some(reasoning_content) = delta.reasoning_content.clone() { - push_thinking_event(reasoning_content, &mut events); - } - if let Some(content) = delta.content.clone() { - if !content.is_empty() { - events.push(Ok(LanguageModelCompletionEvent::Text(content))); - } - } - - if let Some(tool_calls) = delta.tool_calls.as_ref() { - for tool_call in tool_calls { - let entry = self.tool_calls_by_index.entry(tool_call.index).or_default(); - - if let Some(tool_id) = tool_call.id.clone() - && !tool_id.is_empty() - { - entry.id = tool_id; - } - - if let Some(function) = tool_call.function.as_ref() { - if let Some(name) = function.name.clone() - && !name.is_empty() - { - entry.name = name; - } - - if let Some(arguments) = function.arguments.clone() { - entry.arguments.push_str(&arguments); - } - - if let Some(thought_signature) = function.thought_signature.clone() { - entry.thought_signature = Some(thought_signature); - } - } - - if !entry.id.is_empty() && !entry.name.is_empty() { - if let Ok(input) = serde_json::from_str::( - &fix_streamed_json(&entry.arguments), - ) { - events.push(Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: entry.id.clone().into(), - name: entry.name.as_str().into(), - is_input_complete: false, - input: LanguageModelToolUseInput::Json(input), - raw_input: entry.arguments.clone(), - thought_signature: entry.thought_signature.clone(), - }, - ))); - } - } - } - } - } - - match choice.finish_reason.as_deref() { - Some("stop") => { - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - Some("tool_calls") => { - events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match parse_tool_arguments(&tool_call.arguments) { - Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: tool_call.id.clone().into(), - name: tool_call.name.as_str().into(), - is_input_complete: true, - input: LanguageModelToolUseInput::Json(input), - raw_input: tool_call.arguments.clone(), - thought_signature: tool_call.thought_signature.clone(), - }, - )), - Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - id: tool_call.id.into(), - tool_name: tool_call.name.into(), - raw_input: tool_call.arguments.clone().into(), - json_parse_error: error.to_string(), - }), - } - })); - - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); - } - Some(stop_reason) => { - log::error!("Unexpected OpenAI stop_reason: {stop_reason:?}",); - events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); - } - None => {} - } - - events - } -} - -fn push_thinking_event( - text: String, - events: &mut Vec>, -) { - if !text.is_empty() { - events.push(Ok(LanguageModelCompletionEvent::Thinking { - text, - signature: None, - })); - } -} - -fn merge_reasoning_detail(details: &mut Vec, chunk: serde_json::Value) { - let index = chunk.get("index").and_then(serde_json::Value::as_u64); - let target_index = index - .and_then(|index| { - details.iter().position(|detail| { - detail.get("index").and_then(serde_json::Value::as_u64) == Some(index) - }) - }) - .or_else(|| { - let id = chunk.get("id").and_then(serde_json::Value::as_str)?; - details - .iter() - .position(|detail| detail.get("id").and_then(serde_json::Value::as_str) == Some(id)) - }); - let Some(target_index) = target_index else { - details.push(chunk); - return; - }; - let (Some(target), Some(chunk)) = (details[target_index].as_object_mut(), chunk.as_object()) - else { - return; - }; - for (key, value) in chunk { - if matches!(key.as_str(), "text" | "summary" | "data") - && let Some(fragment) = value.as_str() - && let Some(existing) = target.get(key).and_then(serde_json::Value::as_str) - { - target.insert( - key.clone(), - serde_json::Value::String(format!("{existing}{fragment}")), - ); - } else if !value.is_null() { - target.insert(key.clone(), value.clone()); - } - } -} - -#[derive(Default)] -struct RawToolCall { - id: String, - name: String, - arguments: String, - thought_signature: Option, -} - pub struct OpenAiResponseEventMapper { /// The backend whose infrastructure produced this stream; stamped on any /// compaction state it emits so replay is limited to the same backend. @@ -1631,11 +1365,16 @@ fn completion_error_from_response_error( error: &ResponseError, provider: language_model_core::LanguageModelProviderName, ) -> LanguageModelCompletionError { - let category = response_error_category(error.code.as_deref(), None, &error.message); + let category = response_error_category( + error.code.as_deref(), + error.error_type.as_deref(), + None, + &error.message, + ); LanguageModelCompletionError::from_provider_response( provider, None, - error.code.clone(), + error.code.clone().or_else(|| error.error_type.clone()), error.message.clone(), None, category, @@ -1644,41 +1383,52 @@ fn completion_error_from_response_error( pub(crate) fn response_error_category( code: Option<&str>, + error_type: Option<&str>, status: Option, message: &str, ) -> ProviderErrorCategory { - match code { - Some("context_length_exceeded" | "request_too_large") => { + code.and_then(response_error_category_from_discriminator) + .or_else(|| error_type.and_then(response_error_category_from_discriminator)) + .unwrap_or_else(|| { + status + .map(|status| ProviderErrorCategory::from_http_status(status, message)) + .unwrap_or(ProviderErrorCategory::Other) + }) +} + +fn response_error_category_from_discriminator( + discriminator: &str, +) -> Option { + let category = match discriminator { + "context_length_exceeded" | "request_too_large" => { ProviderErrorCategory::PromptTooLarge { tokens: None } } - Some("invalid_encrypted_content") => ProviderErrorCategory::InvalidEncryptedContent, - Some("invalid_request_error") => ProviderErrorCategory::InvalidRequest, - Some("authentication_error") => ProviderErrorCategory::Authentication, - Some( - "billing_error" - | "payment_required_error" - | "credit_balance_exhausted" - | "insufficient_quota" - | "organization_spend_limit_exceeded" - | "project_spend_limit_exceeded" - | "organization_usage_limit_exceeded", - ) => ProviderErrorCategory::PaymentRequired, - Some("permission_error") => ProviderErrorCategory::Permission, - Some("cyber_policy" | "invalid_prompt") => ProviderErrorCategory::ContentPolicy, - Some("not_found_error") => ProviderErrorCategory::EndpointNotFound, - Some("conflict_error") => ProviderErrorCategory::Conflict, - Some("rate_limit_error" | "rate_limit_exceeded") => ProviderErrorCategory::RateLimit, - Some("timeout_error" | "request_timed_out") => ProviderErrorCategory::Timeout, - Some("api_error" | "internal_server_error" | "server_error") => { + "invalid_encrypted_content" => ProviderErrorCategory::InvalidEncryptedContent, + "invalid_request_error" => ProviderErrorCategory::InvalidRequest, + "authentication_error" => ProviderErrorCategory::Authentication, + "billing_error" + | "payment_required_error" + | "credit_balance_exhausted" + | "insufficient_quota" + | "organization_spend_limit_exceeded" + | "project_spend_limit_exceeded" + | "organization_usage_limit_exceeded" + | "usage_limit_reached" => ProviderErrorCategory::PaymentRequired, + "permission_error" => ProviderErrorCategory::Permission, + "cyber_policy" | "invalid_prompt" => ProviderErrorCategory::ContentPolicy, + "not_found_error" => ProviderErrorCategory::EndpointNotFound, + "conflict_error" => ProviderErrorCategory::Conflict, + "rate_limit_error" | "rate_limit_exceeded" => ProviderErrorCategory::RateLimit, + "timeout_error" | "request_timed_out" => ProviderErrorCategory::Timeout, + "api_error" | "internal_server_error" | "server_error" => { ProviderErrorCategory::InternalServer } - Some("overloaded_error" | "server_is_overloaded" | "slow_down") => { + "overloaded_error" | "server_is_overloaded" | "slow_down" => { ProviderErrorCategory::Overloaded } - Some(_) | None => status - .map(|status| ProviderErrorCategory::from_http_status(status, message)) - .unwrap_or(ProviderErrorCategory::Other), - } + _ => return None, + }; + Some(category) } fn response_error_message(error: &ResponseError) -> String { @@ -1774,9 +1524,51 @@ mod tests { use serde_json::json; use super::*; - use crate::{ - ChoiceDelta, FunctionChunk, ResponseMessageDelta, ResponseStreamEvent, ToolCallChunk, - }; + + #[test] + fn prompt_cache_key_respects_override_fallback_and_capability() -> Result<()> { + for (explicit, thread, supported, expected) in [ + (Some("cache"), Some("thread"), true, Some("cache")), + (Some("cache"), None, true, Some("cache")), + (None, Some("thread"), true, Some("thread")), + (None, None, true, None), + (Some("cache"), Some("thread"), false, None), + (None, Some("thread"), false, None), + ] { + let request = LanguageModelRequest { + thread_id: thread.map(str::to_owned), + prompt_cache_key: explicit.map(str::to_owned), + ..Default::default() + }; + let chat = into_open_ai( + request.clone(), + "gpt-5", + true, + supported, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + false, + )?; + let response = into_open_ai_response( + request, + "gpt-5", + true, + supported, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + )?; + assert_eq!(chat.prompt_cache_key.as_deref(), expected); + assert_eq!(response.prompt_cache_key.as_deref(), expected); + assert_eq!( + response.into_compact_request().prompt_cache_key.as_deref(), + expected + ); + } + Ok(()) + } fn map_response_events(events: Vec) -> Vec { block_on(async { @@ -1790,17 +1582,6 @@ mod tests { }) } - fn map_completion_events( - events: Vec, - ) -> Vec { - let mut mapper = OpenAiEventMapper::new(); - let mut all_events = Vec::new(); - for event in events { - all_events.extend(mapper.map_event(event)); - } - all_events.into_iter().filter_map(|e| e.ok()).collect() - } - fn response_item_message(id: &str) -> ResponseOutputItem { ResponseOutputItem::Message(ResponseOutputMessage { id: Some(id.to_string()), @@ -2060,6 +1841,7 @@ mod tests { let request = LanguageModelRequest { thread_id: Some("thread-123".into()), + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![ @@ -2107,6 +1889,7 @@ mod tests { thinking_effort: Some("high".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2265,6 +2048,7 @@ mod tests { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2293,6 +2077,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2358,6 +2143,7 @@ mod tests { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2394,6 +2180,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2449,6 +2236,7 @@ mod tests { fn into_open_ai_response_replays_reasoning_without_encrypted_content() { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2479,6 +2267,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2529,6 +2318,7 @@ mod tests { fn into_open_ai_response_omits_reasoning_when_thinking_is_disabled_and_none_is_unsupported() { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2545,6 +2335,7 @@ mod tests { thinking_effort: Some("high".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2563,6 +2354,53 @@ mod tests { assert_eq!(serialized.get("reasoning"), None); } + #[test] + fn request_conversion_omits_unsupported_temperature() -> Result<()> { + for (model_id, temperature, expected_temperature) in [ + ("gpt-6-astra", Some(0.25), None), + ("gpt-6-astra", None, None), + ("gpt-4o-mini", Some(0.25), Some(0.25)), + ("custom-model", Some(0.25), Some(0.25)), + ] { + let request = LanguageModelRequest { + temperature, + ..Default::default() + }; + let response = into_open_ai_response( + request.clone(), + model_id, + true, + true, + None, + None, + false, + &OPEN_AI_PROVIDER_ID, + )?; + let chat = into_open_ai( + request, + model_id, + true, + true, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + false, + )?; + + for (endpoint, serialized) in [ + ("responses", serde_json::to_value(response)?), + ("chat/completions", serde_json::to_value(chat)?), + ] { + assert_eq!( + serialized.get("temperature"), + expected_temperature.map(serde_json::Value::from).as_ref(), + "{endpoint} temperature for {model_id} with {temperature:?}", + ); + } + } + Ok(()) + } + /// `Speed::Fast` should translate to `service_tier: "priority"` on the /// outgoing Responses request, while `Standard` / `None` should leave the /// field unset so the project's default tier wins. @@ -2575,6 +2413,7 @@ mod tests { ] { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2591,6 +2430,7 @@ mod tests { thinking_effort: None, speed, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2627,6 +2467,7 @@ mod tests { ] { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2643,6 +2484,7 @@ mod tests { thinking_effort: None, speed, compact_at_tokens: None, + max_output_tokens: None, }; let chat = into_open_ai( @@ -2672,6 +2514,7 @@ mod tests { fn into_open_ai_can_send_max_tokens_parameter() -> Result<()> { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2688,22 +2531,60 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; - let chat = into_open_ai( - request, - "compatible-model", - false, - false, - Some(4096), - ChatCompletionMaxTokensParameter::MaxTokens, - None, - false, - )?; + for (requested, model_maximum, expected) in [ + (None, None, None), + (None, Some(4096), Some(4096)), + (Some(1024), Some(4096), Some(1024)), + (Some(8192), Some(4096), Some(4096)), + (Some(1024), None, Some(1024)), + ] { + let mut request = request.clone(); + request.max_output_tokens = requested; + for (parameter, field, absent_field) in [ + ( + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + "max_completion_tokens", + "max_tokens", + ), + ( + ChatCompletionMaxTokensParameter::MaxTokens, + "max_tokens", + "max_completion_tokens", + ), + ] { + let chat = into_open_ai( + request.clone(), + "compatible-model", + false, + false, + model_maximum, + parameter, + None, + false, + )?; + let serialized = serde_json::to_value(chat)?; + assert_eq!(serialized[field].as_u64(), expected); + assert!(serialized.get(absent_field).is_none()); + } - let serialized = serde_json::to_value(&chat)?; - assert_eq!(serialized.get("max_completion_tokens"), None); - assert_eq!(serialized["max_tokens"], json!(4096)); + let response = into_open_ai_response( + request, + "gpt-4.1", + false, + false, + model_maximum, + None, + false, + &language_model_core::OPEN_AI_PROVIDER_ID, + )?; + assert_eq!( + serde_json::to_value(response)?["max_output_tokens"].as_u64(), + expected + ); + } Ok(()) } @@ -2711,6 +2592,7 @@ mod tests { fn into_open_ai_response_sends_none_reasoning_when_thinking_is_disabled() -> Result<()> { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2727,6 +2609,7 @@ mod tests { thinking_effort: Some("high".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2752,6 +2635,7 @@ mod tests { fn into_open_ai_response_uses_default_effort_when_selected_effort_is_none() -> Result<()> { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2768,6 +2652,7 @@ mod tests { thinking_effort: Some("none".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2795,6 +2680,7 @@ mod tests { fn into_open_ai_response_replays_assistant_phase() { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -2821,6 +2707,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2889,6 +2776,7 @@ mod tests { }); let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![ @@ -2913,6 +2801,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2967,6 +2856,7 @@ mod tests { fn into_open_ai_response_replays_reasoning_details_but_not_thinking_text() { let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![LanguageModelRequestMessage { @@ -3003,6 +2893,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -3144,6 +3035,7 @@ mod tests { status: Some("failed".into()), error: Some(ResponseError { code: Some("server_error".into()), + error_type: None, message: "The model failed to generate a response.".into(), param: None, }), @@ -3236,6 +3128,35 @@ mod tests { )); } + #[test] + fn responses_stream_preserves_and_classifies_type_without_code() { + let event = serde_json::from_value::(json!({ + "type": "error", + "error": { + "type": "usage_limit_reached", + "message": "The usage limit has been reached", + "plan_type": "plus" + } + })) + .expect("nested usage limit error event"); + + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); + let mapped = mapper.map_event(event); + + assert_eq!(mapped.len(), 1); + let error = mapped.into_iter().next().unwrap().unwrap_err(); + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + code: Some(code), + message, + category: ProviderErrorCategory::PaymentRequired, + .. + } if code == "usage_limit_reached" + && message == "The usage limit has been reached" + )); + } + #[test] fn responses_stream_maps_billing_codes_to_payment_required() { for code in [ @@ -3248,17 +3169,30 @@ mod tests { "organization_usage_limit_exceeded", ] { assert_eq!( - response_error_category(Some(code), None, ""), + response_error_category(Some(code), None, None, ""), ProviderErrorCategory::PaymentRequired, "{code}" ); } } + #[test] + fn responses_stream_uses_known_type_when_code_is_unknown() { + assert_eq!( + response_error_category( + Some("provider_specific_code"), + Some("usage_limit_reached"), + Some(StatusCode::TOO_MANY_REQUESTS), + "", + ), + ProviderErrorCategory::PaymentRequired + ); + } + #[test] fn responses_stream_maps_invalid_prompt_to_content_policy() { assert_eq!( - response_error_category(Some("invalid_prompt"), None, ""), + response_error_category(Some("invalid_prompt"), None, None, ""), ProviderErrorCategory::ContentPolicy ); } @@ -3267,7 +3201,7 @@ mod tests { fn responses_stream_maps_overload_codes_to_overloaded() { for code in ["overloaded_error", "server_is_overloaded", "slow_down"] { assert_eq!( - response_error_category(Some(code), None, ""), + response_error_category(Some(code), None, None, ""), ProviderErrorCategory::Overloaded, "{code}" ); @@ -3310,6 +3244,7 @@ mod tests { status: Some("failed".into()), error: Some(ResponseError { code: Some("context_length_exceeded".into()), + error_type: None, message: "Your input exceeds the context window of this model.".into(), param: Some("input".into()), }), @@ -4222,6 +4157,7 @@ mod tests { }; let request = LanguageModelRequest { thread_id: None, + prompt_cache_key: None, prompt_id: None, intent: None, messages: vec![ @@ -4263,6 +4199,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_ai( @@ -4323,274 +4260,6 @@ mod tests { ); } - #[test] - fn stream_maps_reasoning() { - let events = map_completion_events(vec![ResponseStreamEvent { - choices: vec![ChoiceDelta { - index: 0, - delta: Some(ResponseMessageDelta { - role: None, - content: None, - reasoning: Some("thinking".into()), - tool_calls: None, - reasoning_content: None, - reasoning_details: None, - }), - finish_reason: None, - }], - usage: None, - }]); - - assert_eq!( - events, - vec![LanguageModelCompletionEvent::Thinking { - text: "thinking".into(), - signature: None, - }] - ); - } - - #[test] - fn stream_merges_reasoning_details_and_maps_compatible_usage_and_signatures() { - let response_events = serde_json::from_value(json!([ - { - "choices": [{ - "index": 0, - "delta": { - "reasoning_details": [{ - "id": "reasoning-1", - "index": 0, - "type": "reasoning.text", - "text": "first " - }], - "tool_calls": [{ - "index": 0, - "id": "call-1", - "function": { - "name": "search", - "arguments": "{", - "thought_signature": "signature" - } - }] - }, - "finish_reason": null - }], - "usage": null - }, - { - "choices": [{ - "index": 0, - "delta": { - "reasoning_details": [{ - "id": "reasoning-1", - "index": 0, - "text": "second" - }], - "tool_calls": [{ - "index": 0, - "function": { - "arguments": "}" - } - }] - }, - "finish_reason": "tool_calls" - }], - "usage": null - }, - { - "choices": [], - "usage": { - "prompt_tokens": 10000, - "completion_tokens": 500, - "total_tokens": 10500, - "prompt_tokens_details": { - "cached_tokens": 6000, - "cache_write_tokens": 1000 - } - } - } - ])) - .expect("valid compatible Chat Completions events"); - let events = map_completion_events(response_events); - - assert!(events.iter().any(|event| { - matches!( - event, - LanguageModelCompletionEvent::ReasoningDetails(details) - if details[0]["text"] == "first second" - ) - })); - assert!(events.iter().any(|event| { - matches!( - event, - LanguageModelCompletionEvent::ToolUse(tool_use) - if tool_use.is_input_complete - && tool_use.thought_signature.as_deref() == Some("signature") - ) - })); - assert!(events.iter().any(|event| { - matches!( - event, - LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: 3_000, - output_tokens: 500, - cache_creation_input_tokens: 1_000, - cache_read_input_tokens: 6_000, - }) - ) - })); - } - - #[test] - fn reasoning_details_accumulator_replaces_an_incompatible_previous_shape() { - let mut accumulator = ReasoningDetailsAccumulator::default(); - assert_eq!( - accumulator.push(json!({"summary": "provider-defined"})), - Some(json!({"summary": "provider-defined"})) - ); - assert_eq!( - accumulator.push(json!([{"index": 0, "text": "reasoning"}])), - Some(json!([{"index": 0, "text": "reasoning"}])) - ); - } - - #[test] - fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() { - // DashScope sends id="" and name="" in subsequent tool_calls delta - // chunks after the first chunk. OpenAiEventMapper must not overwrite - // the accumulated id and name with these empty strings. - - let events = vec![ - // First chunk: id and name are present - ResponseStreamEvent { - choices: vec![ChoiceDelta { - index: 0, - delta: Some(ResponseMessageDelta { - role: None, - content: None, - reasoning: None, - tool_calls: Some(vec![ToolCallChunk { - index: 0, - id: Some("call_dashscope_test".into()), - function: Some(FunctionChunk { - name: Some("list_directory".into()), - arguments: Some("".into()), - thought_signature: None, - }), - }]), - reasoning_content: None, - reasoning_details: None, - }), - finish_reason: None, - }], - usage: None, - }, - // Subsequent chunks: DashScope sends id="" and name="" - ResponseStreamEvent { - choices: vec![ChoiceDelta { - index: 0, - delta: Some(ResponseMessageDelta { - role: None, - content: None, - reasoning: None, - tool_calls: Some(vec![ToolCallChunk { - index: 0, - id: Some("".into()), - function: Some(FunctionChunk { - name: Some("".into()), - arguments: Some("{\"path\": \"".into()), - thought_signature: None, - }), - }]), - reasoning_content: None, - reasoning_details: None, - }), - finish_reason: None, - }], - usage: None, - }, - ResponseStreamEvent { - choices: vec![ChoiceDelta { - index: 0, - delta: Some(ResponseMessageDelta { - role: None, - content: None, - reasoning: None, - tool_calls: Some(vec![ToolCallChunk { - index: 0, - id: Some("".into()), - function: Some(FunctionChunk { - name: Some("".into()), - arguments: Some("blog-scraper\"}".into()), - thought_signature: None, - }), - }]), - reasoning_content: None, - reasoning_details: None, - }), - finish_reason: None, - }], - usage: None, - }, - // Final chunk: finish_reason = "tool_calls" - ResponseStreamEvent { - choices: vec![ChoiceDelta { - index: 0, - delta: None, - finish_reason: Some("tool_calls".into()), - }], - usage: None, - }, - ]; - - let mapped = map_completion_events(events); - - // Events emitted: - // 1. Partial ToolUse from chunk 1 (fix_json("") → "{}", parseable) - // 2. Partial ToolUse from chunk 3 (arguments fully assembled) - // 3. Complete ToolUse from finish_reason="tool_calls" drain - // 4. Stop(ToolUse) - assert_eq!(mapped.len(), 4); - - // Verify the complete ToolUse event (from finish_reason drain) - // has the correct id, name, and accumulated arguments. - let complete_tool_use = mapped.iter().find_map(|event| { - if let LanguageModelCompletionEvent::ToolUse(tool_use) = event { - if tool_use.is_input_complete { - return Some(tool_use); - } - } - None - }); - assert!( - complete_tool_use.is_some(), - "expected a completed ToolUse event" - ); - let tool_use = complete_tool_use.unwrap(); - assert_eq!( - tool_use.id.to_string(), - "call_dashscope_test", - "id must survive empty-string overwrites" - ); - assert_eq!( - tool_use.name.as_ref(), - "list_directory", - "name must survive empty-string overwrites" - ); - assert_eq!( - tool_use.raw_input, "{\"path\": \"blog-scraper\"}", - "arguments should accumulate across chunks" - ); - - // Verify the Stop event - assert!(mapped.iter().any(|event| { - matches!( - event, - LanguageModelCompletionEvent::Stop(StopReason::ToolUse) - ) - })); - } - #[test] fn into_open_ai_response_prepends_provider_input_unchanged() { let provider_input = json!([ diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index f25c324966..f4c020726f 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -13,18 +13,19 @@ use http_client::{ http::{HeaderMap, HeaderValue}, }; pub use language_model_core::ReasoningEffort; +pub use language_model_core::chat_completion::{ + ChoiceDelta, FunctionChunk, PromptTokensDetails, ResponseMessageDelta, ResponseStreamError, + ResponseStreamEvent, ResponseStreamResult, ToolCallChunk, Usage, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use serde_json::value::RawValue; use std::{convert::TryFrom, future::Future, io}; use strum::EnumIter; use thiserror::Error; pub const OPEN_AI_API_URL: &str = "https://api.openai.com/v1"; -fn is_none_or_empty, U>(opt: &Option) -> bool { - opt.as_ref().is_none_or(|v| v.as_ref().is_empty()) -} - #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Role { @@ -99,6 +100,8 @@ pub enum Model { FivePointSixTerra, #[serde(rename = "gpt-5.6-luna")] FivePointSixLuna, + #[serde(rename = "gpt-6-astra")] + SixAstra, #[serde(rename = "custom")] Custom { name: String, @@ -148,6 +151,7 @@ impl Model { "gpt-5.6-sol" => Ok(Self::FivePointSixSol), "gpt-5.6-terra" => Ok(Self::FivePointSixTerra), "gpt-5.6-luna" => Ok(Self::FivePointSixLuna), + "gpt-6-astra" => Ok(Self::SixAstra), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -172,6 +176,7 @@ impl Model { Self::FivePointSixSol => "gpt-5.6-sol", Self::FivePointSixTerra => "gpt-5.6-terra", Self::FivePointSixLuna => "gpt-5.6-luna", + Self::SixAstra => "gpt-6-astra", Self::Custom { name, .. } => name, } } @@ -196,6 +201,7 @@ impl Model { Self::FivePointSixSol => "GPT-5.6 Sol", Self::FivePointSixTerra => "GPT-5.6 Terra", Self::FivePointSixLuna => "GPT-5.6 Luna", + Self::SixAstra => "GPT-6 Astra", Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()), } } @@ -220,6 +226,7 @@ impl Model { Self::FivePointSixSol => 1_050_000, Self::FivePointSixTerra => 1_050_000, Self::FivePointSixLuna => 1_050_000, + Self::SixAstra => 1_050_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -247,6 +254,7 @@ impl Model { Self::FivePointSixSol => Some(128_000), Self::FivePointSixTerra => Some(128_000), Self::FivePointSixLuna => Some(128_000), + Self::SixAstra => Some(128_000), } } @@ -270,7 +278,8 @@ impl Model { | Self::FivePointFive | Self::FivePointFivePro | Self::FivePointSixTerra - | Self::FivePointSixLuna => Some(ReasoningEffort::Medium), + | Self::FivePointSixLuna + | Self::SixAstra => Some(ReasoningEffort::Medium), _ => None, } } @@ -325,6 +334,13 @@ impl Model { ReasoningEffort::XHigh, ReasoningEffort::Max, ], + Self::SixAstra => &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ], Self::FivePointTwo | Self::FivePointFour | Self::FivePointFive @@ -371,6 +387,7 @@ impl Model { | Self::FivePointSixSol | Self::FivePointSixTerra | Self::FivePointSixLuna + | Self::SixAstra | Self::FiveNano => true, Self::O3 | Model::Custom { .. } => false, } @@ -401,7 +418,8 @@ impl Model { | Self::FivePointFivePro | Self::FivePointSixSol | Self::FivePointSixTerra - | Self::FivePointSixLuna => true, + | Self::FivePointSixLuna + | Self::SixAstra => true, Self::Four | Self::FourOmniMini | Self::O3 @@ -431,7 +449,8 @@ impl Model { | Self::FivePointFive | Self::FivePointSixSol | Self::FivePointSixTerra - | Self::FivePointSixLuna => true, + | Self::FivePointSixLuna + | Self::SixAstra => true, Self::Four | Self::FiveNano | Self::FivePointFourNano @@ -445,7 +464,8 @@ impl Model { #[cfg(test)] mod tests { use language_model_core::{ - LanguageModelCompletionError, OPEN_AI_PROVIDER_NAME, ProviderErrorCategory, + LanguageModelCompletionError, LanguageModelProviderName, OPEN_AI_PROVIDER_NAME, + ProviderErrorCategory, }; use super::{Model, ReasoningEffort, RequestError, StatusCode}; @@ -550,6 +570,65 @@ mod tests { )); } + #[test] + fn http_usage_limit_type_is_payment_required() { + let error = LanguageModelCompletionError::from(RequestError::HttpResponseError { + provider: OPEN_AI_PROVIDER_NAME.to_string(), + status_code: StatusCode::TOO_MANY_REQUESTS, + body: serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "message": "The usage limit has been reached", + "plan_type": "plus" + } + }) + .to_string(), + headers: Box::default(), + }); + + assert!(!error.is_transient()); + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + status: Some(StatusCode::TOO_MANY_REQUESTS), + code: Some(code), + message, + category: ProviderErrorCategory::PaymentRequired, + .. + } if code == "usage_limit_reached" + && message == "The usage limit has been reached" + )); + } + + #[test] + fn http_unknown_error_type_is_preserved_for_compatible_provider() { + let error = LanguageModelCompletionError::from(RequestError::HttpResponseError { + provider: "Compatible Provider".to_string(), + status_code: StatusCode::BAD_REQUEST, + body: serde_json::json!({ + "error": { + "type": "provider_specific_error", + "message": "Provider-specific rejection" + } + }) + .to_string(), + headers: Box::default(), + }); + + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + provider, + code: Some(code), + message, + category: ProviderErrorCategory::InvalidRequest, + .. + } if provider == LanguageModelProviderName::from("Compatible Provider".to_string()) + && code == "provider_specific_error" + && message == "Provider-specific rejection" + )); + } + #[test] fn http_invalid_prompt_is_content_policy_rejection() { let error = LanguageModelCompletionError::from(RequestError::HttpResponseError { @@ -644,11 +723,11 @@ pub struct Request { pub service_tier: Option, } -/// Service tier for OpenAI requests. Maps to the top-level `service_tier` -/// field on Responses and Chat Completions. We only ever send `Priority` -/// today (in response to Fast Mode being enabled); the other variants are -/// included for symmetry with the API and so deserialization of echoed -/// values does not fail. +/// Selects the service tier for OpenAI requests. +/// +/// This maps to the top-level `service_tier` field on Responses and Chat +/// Completions. `Priority` serializes as `priority` for compatibility, while +/// deserialization also accepts the `fast` value echoed by some models. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ServiceTier { @@ -656,6 +735,7 @@ pub enum ServiceTier { Default, Flex, Scale, + #[serde(alias = "fast")] Priority, } @@ -800,66 +880,6 @@ pub struct Choice { pub finish_reason: Option, } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ResponseMessageDelta { - pub role: Option, - pub content: Option, - pub reasoning: Option, - #[serde(default, skip_serializing_if = "is_none_or_empty")] - pub tool_calls: Option>, - #[serde(default, skip_serializing_if = "is_none_or_empty")] - pub reasoning_content: Option, - /// Provider-defined structured reasoning metadata. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ToolCallChunk { - pub index: usize, - pub id: Option, - - // There is also an optional `type` field that would determine if a - // function is there. Sometimes this streams in with the `function` before - // it streams in the `type` - pub function: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct FunctionChunk { - pub name: Option, - pub arguments: Option, - /// Provider-defined metadata required to replay a reasoning tool call. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub thought_signature: Option, -} - -/// Reports prompt-cache token usage from compatible providers. -#[derive(Clone, Serialize, Deserialize, Debug, Default)] -pub struct PromptTokensDetails { - /// Tokens read from a prompt cache. - pub cached_tokens: Option, - /// Tokens written to a prompt cache. - pub cache_write_tokens: Option, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct Usage { - pub prompt_tokens: Option, - pub completion_tokens: Option, - pub total_tokens: Option, - /// Prompt-cache usage when reported by the provider. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt_tokens_details: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ChoiceDelta { - pub index: u32, - pub delta: Option, - pub finish_reason: Option, -} - /// An error produced while sending an OpenAI-compatible request. /// /// Transport and wire-format failures retain their category so callers can @@ -908,37 +928,20 @@ pub enum RequestError { Other(#[from] anyhow::Error), } -#[derive(Serialize, Deserialize, Debug)] -pub struct ResponseStreamError { - message: String, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(untagged)] -pub enum ResponseStreamResult { - Ok(ResponseStreamEvent), - Err { error: ResponseStreamError }, -} - #[derive(Deserialize)] struct ResponseErrorEnvelope { error: responses::ResponseError, } -#[derive(Serialize, Deserialize, Debug)] -pub struct ResponseStreamEvent { - pub choices: Vec, - pub usage: Option, -} - /// A framed Chat Completions server-sent event. /// /// `Done` is distinct from the underlying response body ending so callers can /// tell whether the server completed the stream according to the protocol. -#[derive(Debug, PartialEq)] +#[derive(Debug)] pub enum ChatCompletionStreamEvent { - /// A JSON payload from a `data` field. - Data(Value), + /// A JSON payload from a `data` field, kept as raw text so consumers can + /// deserialize it into their own wire types in a single pass. + Data(Box), /// The protocol terminator `data: [DONE]`. Done, } @@ -1110,24 +1113,46 @@ pub async fn stream_completion( &request, ) .await?; + let provider_name = + language_model_core::LanguageModelProviderName::from(provider_name.to_string()); Ok(events - .filter_map(|event| async move { - let value = match event { - Ok(ChatCompletionStreamEvent::Data(value)) => value, - Ok(ChatCompletionStreamEvent::Done) => return None, - Err(error) => return Some(Err(anyhow!(error))), - }; - match ResponseStreamResult::deserialize(&value) { - Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), - Ok(ResponseStreamResult::Err { error }) => Some(Err(anyhow!(error.message))), - Err(error) => { - log::error!( - "Failed to parse OpenAI response into ResponseStreamResult: `{}`\n\ - Response: `{}`", - error, - value, - ); - Some(Err(anyhow!(error))) + .filter_map(move |event| { + let provider_name = provider_name.clone(); + async move { + let value = match event { + Ok(ChatCompletionStreamEvent::Data(value)) => value, + Ok(ChatCompletionStreamEvent::Done) => return None, + Err(error) => return Some(Err(anyhow!(error))), + }; + match serde_json::from_str::(value.get()) { + Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), + Ok(ResponseStreamResult::Err { error }) => { + let category = completion::response_error_category( + error.code.as_deref(), + error.error_type.as_deref(), + None, + &error.message, + ); + Some(Err(anyhow!( + language_model_core::LanguageModelCompletionError::from_provider_response( + provider_name, + None, + error.code.or(error.error_type), + error.message, + None, + category, + ) + ))) + } + Err(error) => { + log::error!( + "Failed to parse OpenAI response into ResponseStreamResult: `{}`\n\ + Response: `{}`", + error, + value, + ); + Some(Err(anyhow!(error))) + } } } }) @@ -1271,13 +1296,14 @@ impl From for language_model_core::LanguageModelCompletionError { let error = error_response.error; let category = completion::response_error_category( error.code.as_deref(), + error.error_type.as_deref(), Some(status_code), &error.message, ); Self::from_provider_response( provider.into(), Some(status_code), - error.code, + error.code.or(error.error_type), error.message, retry_after, category, diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 2dfa8f767e..374a9490e5 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -50,6 +50,18 @@ pub struct Request { } impl Request { + pub fn into_count_tokens_request(self) -> CountTokensRequest { + CountTokensRequest { + model: self.model, + instructions: self.instructions, + input: self.input, + tools: self.tools, + tool_choice: self.tool_choice, + parallel_tool_calls: self.parallel_tool_calls, + reasoning: self.reasoning, + } + } + pub fn into_compact_request(self) -> CompactRequest { CompactRequest { model: self.model, @@ -61,6 +73,23 @@ impl Request { } } +/// Structured input for counting, including images and tool definitions. +#[derive(Serialize, Debug)] +pub struct CountTokensRequest { + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + pub input: ResponseInput, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, +} + #[derive(Serialize, Debug)] pub struct CompactRequest { pub model: String, @@ -393,6 +422,8 @@ pub enum CustomToolGrammarSyntax { pub struct ResponseError { #[serde(default)] pub code: Option, + #[serde(default, rename = "type")] + pub error_type: Option, pub message: String, #[serde(default)] pub param: Option, @@ -414,6 +445,8 @@ pub struct GenericStreamErrorPayload { struct PartialResponseError { #[serde(default)] code: Option, + #[serde(default, rename = "type")] + error_type: Option, #[serde(default)] message: Option, #[serde(default)] @@ -425,6 +458,7 @@ impl GenericStreamErrorPayload { let nested = self.error.unwrap_or_default(); ResponseError { code: self.top_level.code.or(nested.code), + error_type: self.top_level.error_type.or(nested.error_type), message: self .top_level .message @@ -732,9 +766,56 @@ pub async fn compact_response( request: CompactRequest, extra_headers: &CustomHeaders, ) -> Result { + send_response_request( + client, + provider_name, + api_url, + api_key, + "/responses/compact", + request, + extra_headers, + ) + .await +} + +/// Counts structured input, including images and tools, without generating output. +pub async fn count_input_tokens( + client: &dyn HttpClient, + provider_name: &str, + api_url: &str, + api_key: &str, + request: CountTokensRequest, + extra_headers: &CustomHeaders, +) -> Result { + #[derive(Deserialize)] + struct CountTokensResponse { + input_tokens: u64, + } + let response: CountTokensResponse = send_response_request( + client, + provider_name, + api_url, + api_key, + "/responses/input_tokens", + request, + extra_headers, + ) + .await?; + Ok(response.input_tokens) +} + +async fn send_response_request( + client: &dyn HttpClient, + provider_name: &str, + api_url: &str, + api_key: &str, + route: &str, + request: impl Serialize, + extra_headers: &CustomHeaders, +) -> Result { let request = HttpRequest::builder() .method(Method::POST) - .uri(format!("{api_url}/responses/compact")) + .uri(format!("{api_url}{route}")) .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {}", api_key.trim())) .extra_headers(extra_headers) @@ -815,7 +896,7 @@ pub async fn stream_response( if line == "[DONE]" || line.is_empty() { None } else { - match serde_json::from_str::(line) { + match decode_stream_event(line) { Ok(event) => Some(Ok(event)), Err(error) => { log::error!( @@ -969,6 +1050,11 @@ pub async fn stream_response( } } +#[inline(never)] +fn decode_stream_event(line: &str) -> serde_json::Result { + serde_json::from_str(line) +} + #[cfg(test)] mod tests { use super::*; @@ -978,6 +1064,111 @@ mod tests { use serde_json::json; use std::sync::{Arc, Mutex}; + #[test] + fn count_input_tokens_preserves_structured_input_without_generation_fields() { + let input = json!([{ + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this"}, + {"type": "input_image", "image_url": "data:image/png;base64,aW1hZ2U=", "detail": "auto"} + ] + }]); + let mut request = response_test_request(); + request.input = + ResponseInput::new(input.as_array().expect("input array").clone(), Vec::new()); + request.instructions = Some("Be precise".into()); + request.max_output_tokens = Some(1234); + request.service_tier = Some(ServiceTier::Priority); + request.prompt_cache_key = Some("thread-id".into()); + request.store = Some(false); + let generation = serde_json::to_value(&request).expect("generation request"); + assert_eq!(generation["stream"], true); + assert_eq!(generation["max_output_tokens"], 1234); + assert_eq!(generation["service_tier"], "priority"); + + let client = FakeHttpClient::create(move |mut request| { + let input = input.clone(); + async move { + assert_eq!(request.method(), Method::POST); + assert_eq!( + request.uri(), + "https://api.openai.com/v1/responses/input_tokens" + ); + assert!( + request + .headers() + .get("Authorization") + .is_some_and(|value| value == "Bearer test-key") + ); + assert_eq!(request.headers()["Content-Type"], "application/json"); + let mut body = String::new(); + request.body_mut().read_to_string(&mut body).await?; + assert_eq!( + serde_json::from_str::(&body)?, + json!({ + "model": "gpt-5.4", + "instructions": "Be precise", + "input": input + }) + ); + Ok(http_client::Response::builder() + .status(200) + .body(AsyncBody::from( + r#"{"object":"response.input_tokens","input_tokens":321}"#, + ))?) + } + }); + let count = block_on(count_input_tokens( + client.as_ref(), + "OpenAI", + "https://api.openai.com/v1", + " test-key ", + request.into_count_tokens_request(), + &CustomHeaders::default(), + )) + .expect("count succeeds"); + assert_eq!(count, 321); + } + + #[test] + fn count_input_tokens_preserves_transport_errors() { + let client = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder().status(401).body(AsyncBody::from( + r#"{"error":{"message":"Invalid API key","type":"invalid_request_error","code":"invalid_api_key"}}"# + ))?) + }); + let error = block_on(count_input_tokens( + client.as_ref(), + "OpenAI", + "https://api.openai.com/v1", + "test-key", + response_test_request().into_count_tokens_request(), + &CustomHeaders::default(), + )) + .expect_err("authentication fails"); + assert!( + matches!(error, RequestError::HttpResponseError { status_code, .. } if status_code.as_u16() == 401) + ); + + let client = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(AsyncBody::from("{}"))?) + }); + assert!( + block_on(count_input_tokens( + client.as_ref(), + "OpenAI", + "https://api.openai.com/v1", + "test-key", + response_test_request().into_count_tokens_request(), + &CustomHeaders::default(), + )) + .is_err() + ); + } + #[test] fn compact_response_posts_supported_request_fields() { let captured_request = Arc::new(Mutex::new(None)); @@ -1159,6 +1350,32 @@ mod tests { } } + #[test] + fn completion_event_decodes_priority_and_fast_service_tiers() -> anyhow::Result<()> { + for response_service_tier in ["priority", "fast"] { + let event: StreamEvent = serde_json::from_value(json!({ + "type": "response.completed", + "response": { + "service_tier": response_service_tier, + "usage": { + "input_tokens": 21, + "output_tokens": 13, + "total_tokens": 34 + } + } + }))?; + let StreamEvent::Completed { response } = event else { + panic!("expected completion event for {response_service_tier}"); + }; + assert_eq!(response.service_tier, Some(ServiceTier::Priority)); + let usage = response.usage.expect("completion includes usage"); + assert_eq!(usage.input_tokens, Some(21)); + assert_eq!(usage.output_tokens, Some(13)); + assert_eq!(usage.total_tokens, Some(34)); + } + Ok(()) + } + #[test] fn compact_response_reports_http_send_errors() { let http_client = diff --git a/crates/open_path_prompt/Cargo.toml b/crates/open_path_prompt/Cargo.toml index bbdba46ea4..55e143de89 100644 --- a/crates/open_path_prompt/Cargo.toml +++ b/crates/open_path_prompt/Cargo.toml @@ -31,3 +31,4 @@ workspace = true [lib] path = "src/open_path_prompt.rs" +doctest = false diff --git a/crates/open_router/Cargo.toml b/crates/open_router/Cargo.toml index 76e9a0d213..bf14a14c2b 100644 --- a/crates/open_router/Cargo.toml +++ b/crates/open_router/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/open_router.rs" +doctest = false [features] default = [] diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index 14cb90079e..6272e063a3 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -4,6 +4,11 @@ use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http, }; pub use language_model_core::ReasoningEffort; +use language_model_core::chat_completion::ResponseStreamResult; +pub use language_model_core::chat_completion::{ + ChoiceDelta, FunctionChunk, PromptTokensDetails, ResponseMessageDelta, ResponseStreamEvent, + ToolCallChunk, Usage, +}; use open_ai::ChatCompletionStreamEvent; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -34,10 +39,6 @@ fn extract_retry_after(headers: &http::HeaderMap) -> Option None } -fn is_none_or_empty, U>(opt: &Option) -> bool { - opt.as_ref().is_none_or(|v| v.as_ref().is_empty()) -} - #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Role { @@ -397,73 +398,6 @@ pub struct FunctionContent { pub thought_signature: Option, } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ResponseMessageDelta { - pub role: Option, - pub content: Option, - pub reasoning: Option, - #[serde(default, skip_serializing_if = "is_none_or_empty")] - pub tool_calls: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ToolCallChunk { - pub index: usize, - pub id: Option, - pub function: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct FunctionChunk { - pub name: Option, - pub arguments: Option, - #[serde(default)] - pub thought_signature: Option, -} - -#[derive(Serialize, Deserialize, Debug, Default)] -pub struct PromptTokensDetails { - #[serde(default)] - pub cached_tokens: u64, - #[serde(default)] - pub cache_write_tokens: u64, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct Usage { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt_tokens_details: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ChoiceDelta { - pub index: u32, - pub delta: ResponseMessageDelta, - pub finish_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ResponseStreamEvent { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - pub created: u32, - pub model: String, - pub choices: Vec, - pub usage: Option, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum ResponseStreamResult { - Response(ResponseStreamEvent), - Error(OpenRouterErrorResponse), -} - #[derive(Serialize, Deserialize, Debug)] pub struct Response { pub id: String, @@ -543,9 +477,9 @@ pub async fn stream_completion( return Some(Err(OpenRouterError::ChatCompletion(error))); } }; - match serde_json::from_value(value) { - Ok(ResponseStreamResult::Response(response)) => Some(Ok(response)), - Ok(ResponseStreamResult::Error(OpenRouterErrorResponse { error })) => { + match serde_json::from_str::>(value.get()) { + Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), + Ok(ResponseStreamResult::Err { error }) => { Some(Err(OpenRouterError::ApiError(ApiError { status: None, code: error.code, @@ -895,7 +829,7 @@ mod tests { }); assert_eq!(responses.len(), 1); - assert_eq!(responses[0].model, "vendor/model"); + assert!(responses[0].choices.is_empty()); let headers = captured_headers.lock().expect("captured headers lock"); let headers = headers.as_ref().expect("captured headers"); assert_eq!(headers["http-referer"], "https://zed.dev"); diff --git a/crates/openai_subscribed/Cargo.toml b/crates/openai_subscribed/Cargo.toml index 4ce7c8db7f..0eca2c0f45 100644 --- a/crates/openai_subscribed/Cargo.toml +++ b/crates/openai_subscribed/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/openai_subscribed.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/openai_subscribed/src/openai_subscribed.rs b/crates/openai_subscribed/src/openai_subscribed.rs index 431bab98bd..9d8de4e093 100644 --- a/crates/openai_subscribed/src/openai_subscribed.rs +++ b/crates/openai_subscribed/src/openai_subscribed.rs @@ -62,9 +62,15 @@ impl CodexCredentials { } } +enum SignInState { + Idle, + Authorizing(Task>), + PersistingCredentials { _task: Task> }, +} + pub struct State { credentials: Option, - sign_in_task: Option>>, + sign_in_state: SignInState, refresh_task: Option>>>>, load_task: Option>>>>, credentials_provider: Arc, @@ -154,7 +160,7 @@ impl State { Self { credentials: None, - sign_in_task: None, + sign_in_state: SignInState::Idle, refresh_task: None, load_task: Some(load_task), credentials_provider, @@ -177,7 +183,22 @@ impl State { } pub fn is_signing_in(&self) -> bool { - self.sign_in_task.is_some() + !matches!(self.sign_in_state, SignInState::Idle) + } + + pub fn is_sign_in_cancellable(&self) -> bool { + matches!(self.sign_in_state, SignInState::Authorizing(_)) + } + + fn begin_persisting_credentials(&mut self, cx: &mut Context) { + let sign_in_state = std::mem::replace(&mut self.sign_in_state, SignInState::Idle); + self.sign_in_state = match sign_in_state { + SignInState::Authorizing(task) => { + cx.notify(); + SignInState::PersistingCredentials { _task: task } + } + sign_in_state => sign_in_state, + }; } pub fn last_auth_error(&self) -> Option { @@ -280,6 +301,10 @@ impl State { let task = cx.spawn(async move |this, cx| { match do_oauth_flow(http_client, cx).await { Ok(creds) => { + this.update(cx, |state, cx| { + state.begin_persisting_credentials(cx); + })?; + let persist_result = async { let credentials_provider = this.read_with(cx, |state, _| state.credentials_provider.clone())?; @@ -303,7 +328,7 @@ impl State { log::warn!("Failed to refresh ChatGPT models: {error:#}"); } this.update(cx, |state, cx| { - state.sign_in_task = None; + state.sign_in_state = SignInState::Idle; cx.notify(); })?; } @@ -312,7 +337,7 @@ impl State { "ChatGPT subscription sign-in failed to persist credentials: {err:?}" ); this.update(cx, |state, cx| { - state.sign_in_task = None; + state.sign_in_state = SignInState::Idle; state.last_auth_error = Some("Failed to save credentials. Please try again.".into()); cx.notify(); @@ -324,7 +349,7 @@ impl State { Err(err) => { log::error!("ChatGPT subscription sign-in failed: {err:?}"); this.update(cx, |state, cx| { - state.sign_in_task = None; + state.sign_in_state = SignInState::Idle; state.last_auth_error = Some("Sign-in failed. Please try again.".into()); cx.notify(); }) @@ -335,17 +360,24 @@ impl State { }); self.last_auth_error = None; - self.sign_in_task = Some(task); + self.sign_in_state = SignInState::Authorizing(task); cx.notify(); } + pub fn cancel_sign_in(&mut self, cx: &mut Context) { + if matches!(self.sign_in_state, SignInState::Authorizing(_)) { + self.sign_in_state = SignInState::Idle; + cx.notify(); + } + } + /// Clears credentials and in-flight work immediately (so observers see the /// sign-out right away); the returned task deletes the persisted /// credentials. pub fn sign_out(&mut self, cx: &mut Context) -> Task> { self.auth_generation += 1; self.credentials = None; - self.sign_in_task = None; + self.sign_in_state = SignInState::Idle; self.refresh_task = None; self.last_auth_error = None; self.reset_model_catalog(); @@ -1510,6 +1542,83 @@ mod tests { }); } + #[gpui::test] + async fn test_cancel_sign_in_drops_pending_task(cx: &mut TestAppContext) { + let http: Arc = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + let state = make_state(http, None, cx); + let (continue_sign_in_tx, continue_sign_in_rx) = futures::channel::oneshot::channel::<()>(); + + state.update(cx, |state, cx| { + let task = cx.spawn(async move |_this, _cx| { + continue_sign_in_rx.await?; + anyhow::Ok(()) + }); + state.sign_in_state = SignInState::Authorizing(task); + }); + + cx.read(|cx| assert!(state.read(cx).is_signing_in())); + state.update(cx, |state, cx| state.cancel_sign_in(cx)); + cx.run_until_parked(); + cx.read(|cx| assert!(!state.read(cx).is_signing_in())); + assert!( + continue_sign_in_tx.send(()).is_err(), + "canceling sign-in should drop the task" + ); + } + + #[gpui::test] + async fn test_sign_in_task_remains_alive_while_persisting_credentials(cx: &mut TestAppContext) { + let http: Arc = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + let state = make_state(http, None, cx); + let (begin_persisting_tx, begin_persisting_rx) = futures::channel::oneshot::channel::<()>(); + let (finish_persisting_tx, finish_persisting_rx) = + futures::channel::oneshot::channel::<()>(); + + state.update(cx, |state, cx| { + let task = cx.spawn(async move |this, cx| { + begin_persisting_rx.await?; + this.update(cx, |state, cx| { + state.begin_persisting_credentials(cx); + })?; + finish_persisting_rx.await?; + this.update(cx, |state, cx| { + state.sign_in_state = SignInState::Idle; + cx.notify(); + })?; + anyhow::Ok(()) + }); + state.sign_in_state = SignInState::Authorizing(task); + }); + + begin_persisting_tx + .send(()) + .expect("sign-in task should be waiting to persist credentials"); + cx.run_until_parked(); + cx.read(|cx| { + let state = state.read(cx); + assert!(state.is_signing_in()); + assert!(!state.is_sign_in_cancellable()); + }); + + state.update(cx, |state, cx| state.cancel_sign_in(cx)); + cx.run_until_parked(); + cx.read(|cx| assert!(state.read(cx).is_signing_in())); + + finish_persisting_tx + .send(()) + .expect("sign-in task should still be persisting credentials"); + cx.run_until_parked(); + cx.read(|cx| assert!(!state.read(cx).is_signing_in())); + } + #[gpui::test] async fn test_sign_out_during_refresh_discards_result(cx: &mut TestAppContext) { let (gate_tx, gate_rx) = futures::channel::oneshot::channel::<()>(); @@ -2219,7 +2328,7 @@ mod tests { ) -> Entity { cx.new(|_cx| State { credentials, - sign_in_task: None, + sign_in_state: SignInState::Idle, refresh_task: None, load_task: None, credentials_provider, diff --git a/crates/opencode/Cargo.toml b/crates/opencode/Cargo.toml index 035d78d53f..01ccb35cb9 100644 --- a/crates/opencode/Cargo.toml +++ b/crates/opencode/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/opencode.rs" test = false +doctest = false [features] default = [] diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index 8e7f998b37..51d95d385c 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -138,8 +138,8 @@ pub enum Model { // -- OpenAI Chat Completions protocol models -- #[serde(rename = "deepseek-v4-pro")] DeepSeekV4Pro, - #[serde(rename = "deepseek-v4-flash")] - DeepSeekV4Flash, + #[serde(rename = "deepseek-flash")] + DeepSeekV4_1Flash, #[serde(rename = "minimax-m2.5")] MiniMaxM2_5, #[serde(rename = "glm-5")] @@ -226,7 +226,6 @@ impl Model { | Self::MiniMaxM2_7 | Self::MiniMaxM3 | Self::DeepSeekV4Pro - | Self::DeepSeekV4Flash | Self::Gpt5_6Luna | Self::Qwen3_6Plus => &[OpenCodeSubscription::Zen, OpenCodeSubscription::Go], @@ -237,6 +236,7 @@ impl Model { | Self::Qwen3_7Plus | Self::Qwen3_7Max | Self::Qwen3_8Max + | Self::DeepSeekV4_1Flash | Self::Hy3 => &[OpenCodeSubscription::Go], // Deprecated on Go (per models.dev); still offered on Zen @@ -295,7 +295,7 @@ impl Model { Self::Gemini3_7Flash => "gemini-3.7-flash", Self::DeepSeekV4Pro => "deepseek-v4-pro", - Self::DeepSeekV4Flash => "deepseek-v4-flash", + Self::DeepSeekV4_1Flash => "deepseek-flash", Self::MiniMaxM2_5 => "minimax-m2.5", Self::Glm5 => "glm-5", Self::Glm5_1 => "glm-5.1", @@ -367,7 +367,7 @@ impl Model { Self::Gemini3_7Flash => "Gemini 3.7 Flash", Self::DeepSeekV4Pro => "DeepSeek V4 Pro", - Self::DeepSeekV4Flash => "DeepSeek V4 Flash", + Self::DeepSeekV4_1Flash => "DeepSeek V4.1 Flash", Self::MiniMaxM2_5 => "MiniMax M2.5", Self::Glm5 => "GLM 5", Self::Glm5_1 => "GLM 5.1", @@ -468,7 +468,7 @@ impl Model { | Self::MimoV2_5Pro | Self::MimoV2_5 | Self::DeepSeekV4Pro - | Self::DeepSeekV4Flash + | Self::DeepSeekV4_1Flash | Self::Hy3 => ApiProtocol::OpenAiChat, Self::Grok4_6 | Self::Grok4_5 | Self::MuseSpark1_2 => ApiProtocol::OpenAiResponses, @@ -480,7 +480,7 @@ impl Model { pub fn interleaved_reasoning(&self) -> bool { match self { Self::DeepSeekV4Pro - | Self::DeepSeekV4Flash + | Self::DeepSeekV4_1Flash | Self::KimiK2_5 | Self::KimiK2_6 | Self::KimiK2_7Code @@ -571,7 +571,7 @@ impl Model { } Self::Qwen3_8Max | Self::Qwen3_7Max | Self::Qwen3_7Plus => 1_000_000, Self::Hy3 => 256_000, - Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => 1_000_000, + Self::DeepSeekV4Pro | Self::DeepSeekV4_1Flash => 1_000_000, Self::Custom { max_tokens, .. } => *max_tokens, } @@ -655,7 +655,7 @@ impl Model { Some(65_536) } Self::Qwen3_8Max => Some(131_072), - Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000), + Self::DeepSeekV4Pro | Self::DeepSeekV4_1Flash => Some(384_000), Self::MimoV2_5Pro | Self::MimoV2_5 => Some(128_000), Self::Hy3 => Some(64_000), @@ -730,7 +730,8 @@ impl Model { | Self::Qwen3_6Plus | Self::Qwen3_7Plus | Self::Qwen3_8Max - | Self::MiniMaxM3 => true, + | Self::MiniMaxM3 + | Self::DeepSeekV4_1Flash => true, // OpenAI-compatible models without image support Self::MiniMaxM2_5 @@ -741,7 +742,6 @@ impl Model { | Self::MiniMaxM2_7 | Self::MimoV2_5Pro | Self::DeepSeekV4Pro - | Self::DeepSeekV4Flash | Self::Qwen3_7Max | Self::Hy3 => false, @@ -857,7 +857,7 @@ impl Model { ]), // DeepSeek models - Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![ + Self::DeepSeekV4Pro | Self::DeepSeekV4_1Flash => Some(vec![ // OpenCode also supports Low&Medium but as per DeepSeek those are mapped to High ReasoningEffort::High, ReasoningEffort::Max, diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index 2db006dc15..af3e8bdc43 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -21,8 +21,8 @@ editor.workspace = true file_icons.workspace = true fuzzy.workspace = true futures.workspace = true +git.workspace = true gpui.workspace = true -itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true @@ -42,6 +42,7 @@ worktree.workspace = true zed_actions.workspace = true [dev-dependencies] +buffer_diff.workspace = true lsp.workspace = true search = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index c2a42c123a..083d4fcace 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -1,7 +1,7 @@ mod outline_panel_settings; use anyhow::Context as _; -use collections::{BTreeSet, HashMap, HashSet}; +use collections::{BTreeMap, BTreeSet, HashMap, HashSet, IndexMap}; use db::kvp::KeyValueStore; use editor::{ AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, ExcerptRange, MultiBufferSnapshot, @@ -13,23 +13,22 @@ use editor::{ use file_icons::FileIcons; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use git::status::{FileStatus, GitSummary}; use gpui::{ Action, AnyElement, App, AppContext as _, AsyncWindowContext, Bounds, ClipboardItem, Context, DismissEvent, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveElement, IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy, - SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, TaskExt, + SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, point, px, size, uniform_list, }; -use itertools::Itertools; -use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; +use language::{Anchor, BufferId, BufferSnapshot, DiskState, OffsetRangeExt, OutlineItem}; use language::{LanguageAwareStyling, language_settings::LanguageSettings}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use std::{ cmp, - collections::BTreeMap, hash::Hash, ops::Range, path::{Path, PathBuf}, @@ -42,11 +41,11 @@ use std::{ }; use outline_panel_settings::{DockSide, FolderIndicator, OutlinePanelSettings, ShowIndentGuides}; -use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem}; +use project::{File, Fs, Project, ProjectPath}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; -use theme::SyntaxTheme; +use theme::{GlobalTheme, SyntaxTheme}; use theme_settings::ThemeSettings; use ui::{ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors, @@ -60,7 +59,7 @@ use workspace::{ item::ItemHandle, searchable::{SearchEvent, SearchableItem}, }; -use worktree::{Entry, ProjectEntryId, WorktreeId}; +use worktree::{PathProgress, PathTarget, WorktreeId}; use crate::outline_panel_settings::OutlinePanelSettingsScrollbarProxy; @@ -110,6 +109,7 @@ const OUTLINE_PANEL_KEY: &str = "OutlinePanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); type Outline = OutlineItem; +type OutlineChildren = HashMap<(Range, usize), bool>; type HighlightStyleData = Arc, HighlightStyle)>>>; pub struct OutlinePanel { @@ -123,11 +123,11 @@ pub struct OutlinePanel { context_menu: Option<(Entity, Point, Subscription)>, focus_handle: FocusHandle, pending_serialization: Task>, - fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), usize>, + fs_entries: Vec, fs_children_count: HashMap, FsChildren>>, collapsed_entries: HashSet, - unfolded_dirs: HashMap>, + unfolded_dirs: HashMap>>, selected_entry: SelectedEntry, active_item: Option, _subscriptions: Vec, @@ -135,8 +135,9 @@ pub struct OutlinePanel { fs_entries_update_task: Task<()>, fs_entries_update_pending: bool, cached_entries_update_task: Task<()>, - cached_entries_update_pending: bool, + cached_entries_update_pending: Option, reveal_selection_task: Task>, + lsp_outline_refresh_task: Task<()>, outline_fetch_tasks: HashMap>, buffers: HashMap, cached_entries: Vec, @@ -146,7 +147,7 @@ pub struct OutlinePanel { preserve_selection_on_buffer_fold_toggles: HashSet, pending_default_expansion_depth: Option, default_depth_applied_buffers: HashSet, - outline_children_cache: HashMap, usize), bool>>, + outline_children_cache: HashMap, hide_symbols_override: Option, } @@ -331,6 +332,12 @@ impl FsChildren { } } +#[derive(PartialEq, Eq)] +enum CachedEntriesUpdate { + Entries, + Contents, +} + #[derive(Clone, Debug)] struct CachedEntry { depth: usize, @@ -340,7 +347,7 @@ struct CachedEntry { #[derive(Clone, Debug, PartialEq, Eq, Hash)] enum CollapsedEntry { - Dir(WorktreeId, ProjectEntryId), + Dir(WorktreeId, Arc), File(WorktreeId, BufferId), ExternalFile(BufferId), Excerpt(ExcerptRange), @@ -352,6 +359,34 @@ struct BufferOutlines { outlines: OutlineState, } +struct IndexedOutline<'a> { + outline: &'a Outline, + ranked_range: Range, + collapsed: bool, +} + +struct ExcerptOutlines<'a> { + excerpt: &'a ExcerptRange, + ranked_range: Range, + expanded: bool, + visible: Vec<&'a Outline>, + children_to_cache: Vec, +} + +impl IndexedOutline<'_> { + fn is_hidden_by(&self, ancestor: &Self) -> bool { + self.outline.depth > ancestor.outline.depth + && ancestor.ranked_range.contains(&self.ranked_range.start) + } + + fn cache_children(&self, next: Option<&Self>, children: &mut OutlineChildren) { + children.insert( + (self.outline.range.clone(), self.outline.depth), + next.is_some_and(|next| next.outline.depth > self.outline.depth), + ); + } +} + impl BufferOutlines { fn invalidate_outlines(&mut self) { if let OutlineState::Outlines(valid_outlines) = &mut self.outlines { @@ -374,6 +409,247 @@ impl BufferOutlines { OutlineState::NotFetched => true, } } + + fn assign_outline_cache_owners( + outlines: &[IndexedOutline<'_>], + excerpts: &mut [ExcerptOutlines<'_>], + ) { + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter() + .enumerate() + .filter(|(_, excerpt)| excerpt.expanded) + .collect::>(); + outline_order + .sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.start)); + excerpt_order.sort_unstable_by_key(|(_, excerpt)| cmp::Reverse(excerpt.ranked_range.end)); + let mut owners = vec![None; outlines.len()]; + let mut frontier = BTreeMap::new(); + let mut cursor = 0; + for index in outline_order { + let outline = &outlines[index]; + while let Some(&(excerpt_index, excerpt)) = excerpt_order.get(cursor) + && excerpt.ranked_range.end >= outline.ranked_range.start + { + Self::insert_outline_prefix_minimum( + &mut frontier, + excerpt.ranked_range.start, + cmp::Reverse(excerpt_index), + ); + cursor += 1; + } + let last = frontier.range(..=outline.ranked_range.end).next_back(); + owners[index] = last.map(|(_, &cmp::Reverse(owner))| owner); + } + for (index, owner) in owners.into_iter().enumerate() { + if let Some(owner) = owner { + excerpts[owner].children_to_cache.push(index); + } + } + } + + fn outlines_are_well_formed(outlines: &[IndexedOutline<'_>]) -> bool { + if outlines + .windows(2) + .any(|pair| pair[0].ranked_range.start > pair[1].ranked_range.start) + { + return false; + } + let mut ancestors: Vec<&IndexedOutline<'_>> = Vec::new(); + for outline in outlines { + if outline.ranked_range.start > outline.ranked_range.end { + return false; + } + while let Some(&parent) = ancestors.last() + && parent.outline.depth >= outline.outline.depth + { + ancestors.pop(); + if parent.ranked_range.end > outline.ranked_range.start { + return false; + } + } + if ancestors + .last() + .is_some_and(|parent| outline.ranked_range.end > parent.ranked_range.end) + { + return false; + } + ancestors.push(outline); + } + true + } + + fn collect_nested_outlines<'a>( + outlines: &[IndexedOutline<'a>], + excerpts: &mut [ExcerptOutlines<'a>], + children: &mut OutlineChildren, + ) { + let mut collapsed_ancestor = None; + let unfolded = outlines + .iter() + .enumerate() + .filter_map(|(index, outline)| { + if collapsed_ancestor.is_some_and(|ancestor| outline.is_hidden_by(ancestor)) { + return None; + } + collapsed_ancestor = outline.collapsed.then_some(outline); + Some(index) + }) + .collect::>(); + let mut active = BTreeSet::new(); + let mut unfolded_active = BTreeSet::new(); + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter_mut() + .filter(|excerpt| excerpt.expanded) + .collect::>(); + outline_order.sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.end)); + excerpt_order.sort_unstable_by_key(|excerpt| cmp::Reverse(excerpt.ranked_range.start)); + let mut cursor = 0; + for excerpt in excerpt_order { + while let Some(&index) = outline_order.get(cursor) + && outlines[index].ranked_range.end >= excerpt.ranked_range.start + { + active.insert(index); + if unfolded.contains(&index) { + unfolded_active.insert(index); + } + cursor += 1; + } + let limit = outlines + .partition_point(|outline| outline.ranked_range.start <= excerpt.ranked_range.end); + excerpt.visible.extend( + unfolded_active + .range(..limit) + .map(|&index| outlines[index].outline), + ); + for &index in &excerpt.children_to_cache { + let next = active + .range(index + 1..limit) + .next() + .map(|&next| &outlines[next]); + outlines[index].cache_children(next, children); + } + } + } + + fn collect_overlapping_outlines<'a>( + outlines: &[IndexedOutline<'a>], + excerpts: &mut [ExcerptOutlines<'a>], + children: &mut OutlineChildren, + ) { + let leaf_count = outlines.len().next_power_of_two(); + let mut starts = vec![BTreeSet::new(); 2 * leaf_count]; + let mut depths = vec![BTreeMap::new(); 2 * leaf_count]; + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter_mut() + .filter(|excerpt| excerpt.expanded) + .collect::>(); + outline_order.sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.end)); + excerpt_order.sort_unstable_by_key(|excerpt| cmp::Reverse(excerpt.ranked_range.start)); + let mut cursor = 0; + for excerpt in excerpt_order { + while let Some(&index) = outline_order.get(cursor) + && outlines[index].ranked_range.end >= excerpt.ranked_range.start + { + let outline = &outlines[index]; + let mut node = leaf_count + index; + while node > 0 { + starts[node].insert(outline.ranked_range.start); + Self::insert_outline_prefix_minimum( + &mut depths[node], + outline.ranked_range.start, + outline.outline.depth, + ); + node /= 2; + } + cursor += 1; + } + for &index in &excerpt.children_to_cache { + let next = Self::next_outline(index + 1, leaf_count, |node| { + starts[node] + .first() + .is_some_and(|&start| start <= excerpt.ranked_range.end) + }); + outlines[index].cache_children(next.map(|next| &outlines[next]), children); + } + let mut collapsed_ancestor: Option<&IndexedOutline<'_>> = None; + let mut first = 0; + while let Some(index) = Self::next_outline(first, leaf_count, |node| { + let Some(&first_start) = starts[node] + .first() + .filter(|&&start| start <= excerpt.ranked_range.end) + else { + return false; + }; + let Some(ancestor) = collapsed_ancestor else { + return true; + }; + first_start < ancestor.ranked_range.start + || starts[node] + .range(..=excerpt.ranked_range.end) + .next_back() + .is_some_and(|&start| start >= ancestor.ranked_range.end) + || depths[node] + .range(..=excerpt.ranked_range.end) + .next_back() + .is_some_and(|(_, &depth)| depth <= ancestor.outline.depth) + }) { + let outline = &outlines[index]; + excerpt.visible.push(outline.outline); + collapsed_ancestor = outline.collapsed.then_some(outline); + first = index + 1; + } + } + } + + fn next_outline( + first: usize, + leaf_count: usize, + has_match: impl Fn(usize) -> bool, + ) -> Option { + if first >= leaf_count { + return None; + } + let mut node = leaf_count + first; + loop { + while node.is_multiple_of(2) { + node /= 2; + } + if has_match(node) { + while node < leaf_count { + node *= 2; + if !has_match(node) { + node += 1; + } + } + return Some(node - leaf_count); + } + node += 1; + if node.is_power_of_two() { + return None; + } + } + } + + fn insert_outline_prefix_minimum( + minimums: &mut BTreeMap, + position: usize, + value: T, + ) { + let previous = minimums.range(..=position).next_back(); + if previous.is_some_and(|(_, ¤t)| current <= value) { + return; + } + while let Some((&next_position, &next_value)) = minimums.range(position..).next() { + if next_value < value { + break; + } + minimums.remove(&next_position); + } + minimums.insert(position, value); + } } #[derive(Debug)] @@ -386,7 +662,8 @@ enum OutlineState { #[derive(Clone, Debug, PartialEq, Eq)] struct FoldedDirsEntry { worktree_id: WorktreeId, - entries: Vec, + first_buffer_id: BufferId, + entries: Vec, } // TODO: collapse the inner enums into panel entry @@ -431,16 +708,7 @@ impl PartialEq for PanelEntry { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::Fs(a), Self::Fs(b)) => a == b, - ( - Self::FoldedDirs(FoldedDirsEntry { - worktree_id: worktree_id_a, - entries: entries_a, - }), - Self::FoldedDirs(FoldedDirsEntry { - worktree_id: worktree_id_b, - entries: entries_b, - }), - ) => worktree_id_a == worktree_id_b && entries_a == entries_b, + (Self::FoldedDirs(left), Self::FoldedDirs(right)) => left == right, (Self::Outline(a), Self::Outline(b)) => a == b, ( Self::Search(SearchEntry { @@ -520,7 +788,7 @@ impl SearchData { && extended_context_left_border < context_left_border && multi_buffer_snapshot .chars_at(extended_context_left_border) - .last() + .next() .is_some_and(|c| !c.is_whitespace()); let truncated_right = entire_context_text .chars() @@ -572,50 +840,65 @@ impl OutlineEntry { } } +#[derive(Debug, Clone, Eq)] +struct FsEntryPath { + path: Arc, + git_summary: GitSummary, + is_ignored: bool, +} + +impl PartialEq for FsEntryPath { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + #[derive(Debug, Clone, Eq)] struct FsEntryFile { worktree_id: WorktreeId, - entry: GitEntry, + entry: FsEntryPath, buffer_id: BufferId, - excerpts: Vec>, + excerpts: Arc<[ExcerptRange]>, + is_deleted: bool, } impl PartialEq for FsEntryFile { fn eq(&self, other: &Self) -> bool { - self.worktree_id == other.worktree_id - && self.entry.id == other.entry.id - && self.buffer_id == other.buffer_id + self.buffer_id == other.buffer_id } } impl Hash for FsEntryFile { fn hash(&self, state: &mut H) { - (self.buffer_id, self.entry.id, self.worktree_id).hash(state); + self.buffer_id.hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryDirectory { worktree_id: WorktreeId, - entry: GitEntry, + first_buffer_id: BufferId, + entry: FsEntryPath, } impl PartialEq for FsEntryDirectory { fn eq(&self, other: &Self) -> bool { - self.worktree_id == other.worktree_id && self.entry.id == other.entry.id + self.worktree_id == other.worktree_id + && self.first_buffer_id == other.first_buffer_id + && self.entry.path == other.entry.path } } impl Hash for FsEntryDirectory { fn hash(&self, state: &mut H) { - (self.worktree_id, self.entry.id).hash(state); + (self.worktree_id, self.first_buffer_id, &self.entry.path).hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryExternalFile { buffer_id: BufferId, - excerpts: Vec>, + excerpts: Arc<[ExcerptRange]>, } impl PartialEq for FsEntryExternalFile { @@ -630,13 +913,78 @@ impl Hash for FsEntryExternalFile { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq)] enum FsEntry { ExternalFile(FsEntryExternalFile), Directory(FsEntryDirectory), File(FsEntryFile), } +impl PartialEq for FsEntry { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Directory(left), Self::Directory(right)) => left == right, + _ => self + .buffer_id() + .is_some_and(|id| Some(id) == other.buffer_id()), + } + } +} + +impl FsEntry { + fn buffer_id(&self) -> Option { + match self { + Self::File(file) => Some(file.buffer_id), + Self::ExternalFile(file) => Some(file.buffer_id), + Self::Directory(_) => None, + } + } +} + +fn directory_element_id( + worktree_id: WorktreeId, + first_buffer_id: BufferId, + path: &RelPath, +) -> ElementId { + ElementId::from(( + SharedString::from(format!("directory-{first_buffer_id}-{path}")), + worktree_id.to_proto() as usize, + )) +} + +fn is_parent_row(entry: &PanelEntry, worktree_id: WorktreeId, parent_path: &RelPath) -> bool { + match entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id && directory.entry.path.as_ref() == parent_path + } + PanelEntry::FoldedDirs(folded_dirs) => { + folded_dirs.worktree_id == worktree_id + && folded_dirs + .entries + .last() + .is_some_and(|dir| dir.path.as_ref() == parent_path) + } + _ => false, + } +} + +fn is_deleted_file(file: &File, status: Option, has_base_text: bool) -> bool { + match file.disk_state { + DiskState::Deleted => true, + DiskState::Historic { was_deleted } => was_deleted, + DiskState::Present { .. } => false, + DiskState::New => status.is_some_and(|status| status.is_deleted()) || has_base_text, + } +} + +struct BufferExcerpts { + is_new: bool, + is_folded: bool, + excerpts: Vec>, + file: Option<(worktree::Snapshot, FsEntryPath)>, + is_deleted: bool, +} + struct ActiveItem { item_handle: Box, active_editor: WeakEntity, @@ -770,6 +1118,13 @@ impl OutlinePanel { cx.notify(); }); + let theme_subscription = + cx.observe_global_in::(window, |panel, window, cx| { + let buffers = panel.buffers.keys().copied().collect::>(); + panel.invalidate_outlines(&buffers); + panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); + }); + let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx); let mut current_theme = ThemeSettings::get_global(cx).clone(); let mut document_symbols_by_buffer = HashMap::default(); @@ -777,18 +1132,21 @@ impl OutlinePanel { cx.observe_global_in::(window, move |outline_panel, window, cx| { let new_settings = OutlinePanelSettings::get_global(cx); let new_theme = ThemeSettings::get_global(cx); + let auto_fold_dirs_changed = + outline_panel_settings.auto_fold_dirs != new_settings.auto_fold_dirs; let mut outlines_invalidated = false; if ¤t_theme != new_theme { outline_panel_settings = *new_settings; current_theme = new_theme.clone(); + outline_panel.outline_fetch_tasks.clear(); for buffer in outline_panel.buffers.values_mut() { buffer.invalidate_outlines(); } outlines_invalidated = true; - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if matches!(outline_panel.selected_entry(), Some(PanelEntry::Outline(_))) { + outline_panel.selected_entry.invalidate(); } + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } else if &outline_panel_settings != new_settings { let old_expansion_depth = outline_panel_settings.expand_outlines_with_depth; let old_hide_symbols = outline_panel_settings.multi_buffer_hide_symbols; @@ -852,6 +1210,10 @@ impl OutlinePanel { } } + if auto_fold_dirs_changed && let Some(editor) = outline_panel.active_editor() { + outline_panel.update_fs_entries(editor, Some(UPDATE_DEBOUNCE), window, cx); + } + if !outlines_invalidated { let new_document_symbols = outline_panel .buffers @@ -869,17 +1231,17 @@ impl OutlinePanel { .collect(); if new_document_symbols != document_symbols_by_buffer { document_symbols_by_buffer = new_document_symbols; + outline_panel.outline_fetch_tasks.clear(); for buffer in outline_panel.buffers.values_mut() { buffer.invalidate_outlines(); } - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries( - Some(UPDATE_DEBOUNCE), - window, - cx, - ); + if matches!( + outline_panel.selected_entry(), + Some(PanelEntry::Outline(_)) + ) { + outline_panel.selected_entry.invalidate(); } + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } } }); @@ -899,7 +1261,6 @@ impl OutlinePanel { focus_handle, filter_editor, fs_entries: Vec::new(), - fs_entries_depth: HashMap::default(), fs_children_count: HashMap::default(), collapsed_entries: HashSet::default(), unfolded_dirs: HashMap::default(), @@ -914,12 +1275,14 @@ impl OutlinePanel { fs_entries_update_task: Task::ready(()), fs_entries_update_pending: false, cached_entries_update_task: Task::ready(()), - cached_entries_update_pending: false, + cached_entries_update_pending: None, reveal_selection_task: Task::ready(Ok(())), + lsp_outline_refresh_task: Task::ready(()), outline_fetch_tasks: HashMap::default(), buffers: HashMap::default(), cached_entries: Vec::new(), _subscriptions: vec![ + theme_subscription, settings_subscription, icons_subscription, focus_subscription, @@ -998,7 +1361,7 @@ impl OutlinePanel { self.unfolded_dirs .entry(worktree_id) .or_default() - .extend(entries.iter().map(|entry| entry.id)); + .extend(entries.iter().map(|entry| entry.path.clone())); self.update_cached_entries(None, window, cx); } } @@ -1026,7 +1389,7 @@ impl OutlinePanel { return; }; - unfolded_dirs.remove(&entry.id); + unfolded_dirs.remove(&entry.path); self.update_cached_entries(None, window, cx); } @@ -1126,17 +1489,10 @@ impl OutlinePanel { PanelEntry::Fs(FsEntry::File(file)) => { change_selection = false; scroll_to_buffer = Some(file.buffer_id); - self.project - .update(cx, |project, cx| { - project - .path_for_entry(file.entry.id, cx) - .and_then(|path| project.get_open_buffer(&path, cx)) - }) - .map(|buffer| { - multi_buffer_snapshot.excerpts_for_buffer(buffer.read(cx).remote_id()) - }) - .and_then(|mut excerpts| { - let excerpt_range = excerpts.next()?; + multi_buffer_snapshot + .excerpts_for_buffer(file.buffer_id) + .next() + .and_then(|excerpt_range| { multi_buffer_snapshot.anchor_in_excerpt(excerpt_range.context.start) }) } @@ -1318,23 +1674,8 @@ impl OutlinePanel { | FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => entry.path.parent().and_then(|parent_path| { - previous_entries.find(|entry| match entry { - PanelEntry::Fs(FsEntry::Directory(directory)) => { - directory.worktree_id == *worktree_id - && directory.entry.path.as_ref() == parent_path - } - PanelEntry::FoldedDirs(FoldedDirsEntry { - worktree_id: dirs_worktree_id, - entries: dirs, - .. - }) => { - dirs_worktree_id == worktree_id - && dirs - .last() - .is_some_and(|dir| dir.path.as_ref() == parent_path) - } - _ => false, - }) + previous_entries + .find(|entry| is_parent_row(entry, *worktree_id, parent_path)) }), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs @@ -1343,12 +1684,7 @@ impl OutlinePanel { .and_then(|entry| entry.path.parent()) .and_then(|parent_path| { previous_entries.find(|entry| { - if let PanelEntry::Fs(FsEntry::Directory(directory)) = entry { - directory.worktree_id == folded_dirs.worktree_id - && directory.entry.path.as_ref() == parent_path - } else { - false - } + is_parent_row(entry, folded_dirs.worktree_id, parent_path) }) }), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { @@ -1440,40 +1776,16 @@ impl OutlinePanel { ) { self.select_entry(entry.clone(), true, window, cx); let is_root = match &entry { - PanelEntry::Fs(FsEntry::File(FsEntryFile { - worktree_id, entry, .. - })) - | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { - worktree_id, entry, .. - })) => self - .project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .map(|worktree| { - worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) - }) - .unwrap_or(false), - PanelEntry::FoldedDirs(FoldedDirsEntry { - worktree_id, - entries, - .. - }) => entries - .first() - .and_then(|entry| { - self.project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .map(|worktree| { - worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) - }) - }) - .unwrap_or(false), - PanelEntry::Fs(FsEntry::ExternalFile(..)) => false, - PanelEntry::Outline(..) => { - cx.notify(); - return; + PanelEntry::Fs(FsEntry::File(FsEntryFile { entry, .. })) + | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { entry, .. })) => { + entry.path.is_empty() } - PanelEntry::Search(_) => { + PanelEntry::FoldedDirs(folded_dirs) => folded_dirs + .entries + .first() + .is_some_and(|entry| entry.path.is_empty()), + PanelEntry::Fs(FsEntry::ExternalFile(_)) => false, + PanelEntry::Outline(_) | PanelEntry::Search(_) => { cx.notify(); return; } @@ -1531,7 +1843,7 @@ impl OutlinePanel { if self .unfolded_dirs .get(&directory_worktree) - .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.id)) + .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.path)) { return false; } @@ -1566,13 +1878,13 @@ impl OutlinePanel { .. }) => dir_entries.last().map(|entry| { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); - CollapsedEntry::Dir(*worktree_id, entry.id) + CollapsedEntry::Dir(*worktree_id, entry.path.clone()) }), PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); - Some(CollapsedEntry::Dir(*worktree_id, entry.id)) + Some(CollapsedEntry::Dir(*worktree_id, entry.path.clone())) } PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, @@ -1599,15 +1911,6 @@ impl OutlinePanel { }; let expanded = self.collapsed_entries.remove(&collapsed_entry); if expanded { - if let CollapsedEntry::Dir(worktree_id, dir_entry_id) = collapsed_entry { - let task = self.project.update(cx, |project, cx| { - project.expand_entry(worktree_id, dir_entry_id, cx) - }); - if let Some(task) = task { - task.detach_and_log_err(cx); - } - }; - active_editor.update(cx, |editor, cx| { buffers_to_unfold.retain(|buffer_id| editor.is_buffer_folded(*buffer_id, cx)); }); @@ -1643,7 +1946,7 @@ impl OutlinePanel { })) => { if self .collapsed_entries - .insert(CollapsedEntry::Dir(*worktree_id, entry.id)) + .insert(CollapsedEntry::Dir(*worktree_id, entry.path.clone())) { buffers_to_fold.extend(self.buffers_inside_directory(*worktree_id, entry)); true @@ -1680,9 +1983,10 @@ impl OutlinePanel { PanelEntry::FoldedDirs(folded_dirs) => { let mut folded = false; if let Some(dir_entry) = folded_dirs.entries.last() - && self - .collapsed_entries - .insert(CollapsedEntry::Dir(folded_dirs.worktree_id, dir_entry.id)) + && self.collapsed_entries.insert(CollapsedEntry::Dir( + folded_dirs.worktree_id, + dir_entry.path.clone(), + )) { folded = true; buffers_to_fold @@ -1745,7 +2049,7 @@ impl OutlinePanel { FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => { - to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, entry.id)); + to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, entry.path.clone())); } } } @@ -1780,7 +2084,7 @@ impl OutlinePanel { }) = &cached.entry { if let Some(last) = entries.last() { - to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, last.id)); + to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, last.path.clone())); } } } @@ -1817,7 +2121,7 @@ impl OutlinePanel { worktree_id, entry, .. - })) => Some(CollapsedEntry::Dir(*worktree_id, entry.id)), + })) => Some(CollapsedEntry::Dir(*worktree_id, entry.path.clone())), PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, @@ -1834,7 +2138,10 @@ impl OutlinePanel { worktree_id, entries, .. - }) => Some(CollapsedEntry::Dir(*worktree_id, entries.last()?.id)), + }) => Some(CollapsedEntry::Dir( + *worktree_id, + entries.last()?.path.clone(), + )), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { Some(CollapsedEntry::Excerpt(excerpt.clone())) } @@ -1868,17 +2175,10 @@ impl OutlinePanel { entry: dir_entry, .. })) => { - let entry_id = dir_entry.id; - let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + let directory_path = &dir_entry.path; + let collapsed_entry = CollapsedEntry::Dir(*worktree_id, directory_path.clone()); buffers_to_toggle.extend(self.buffers_inside_directory(*worktree_id, dir_entry)); - if self.collapsed_entries.remove(&collapsed_entry) { - self.project - .update(cx, |project, cx| { - project.expand_entry(*worktree_id, entry_id, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx); - } else { + if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } @@ -1909,18 +2209,11 @@ impl OutlinePanel { .. }) => { if let Some(dir_entry) = dir_entries.first() { - let entry_id = dir_entry.id; - let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + let directory_path = &dir_entry.path; + let collapsed_entry = CollapsedEntry::Dir(*worktree_id, directory_path.clone()); buffers_to_toggle .extend(self.buffers_inside_directory(*worktree_id, dir_entry)); - if self.collapsed_entries.remove(&collapsed_entry) { - self.project - .update(cx, |project, cx| { - project.expand_entry(*worktree_id, entry_id, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx); - } else { + if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } @@ -1938,7 +2231,7 @@ impl OutlinePanel { self.collapsed_entries.insert(collapsed_entry); } } - _ => {} + PanelEntry::Search(_) => return, } active_editor.update(cx, |editor, cx| { @@ -2084,160 +2377,76 @@ impl OutlinePanel { { return; } - let project = self.project.clone(); self.reveal_selection_task = cx.spawn_in(window, async move |outline_panel, cx| { cx.background_executor().timer(UPDATE_DEBOUNCE).await; - let multibuffer_snapshot = - editor.read_with(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)); - let entry_with_selection = - outline_panel.update_in(cx, |outline_panel, window, cx| { - outline_panel.location_for_editor_selection(&editor, window, cx) - })?; - let Some(entry_with_selection) = entry_with_selection else { - outline_panel.update(cx, |outline_panel, cx| { + outline_panel.update_in(cx, |outline_panel, window, cx| { + let Some(entry) = outline_panel.location_for_editor_selection(&editor, window, cx) + else { outline_panel.selected_entry = SelectedEntry::None; cx.notify(); - })?; - return Ok(()); - }; - let related_buffer_entry = match &entry_with_selection { - PanelEntry::Fs(FsEntry::File(FsEntryFile { - worktree_id, - buffer_id, - .. - })) => project.update(cx, |project, cx| { - let entry_id = project - .buffer_for_id(*buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - project - .worktree_for_id(*worktree_id, cx) - .zip(entry_id) - .and_then(|(worktree, entry_id)| { - let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }), - PanelEntry::Outline(outline_entry) => { - let buffer_id = outline_entry.buffer_id(); - let outline_range = outline_entry.range(); - outline_panel.update(cx, |outline_panel, cx| { + return; + }; + let buffer_id = match &entry { + PanelEntry::Fs(entry) => entry.buffer_id(), + PanelEntry::Outline(outline) => Some(outline.buffer_id()), + PanelEntry::Search(search) => editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx) + .anchor_to_buffer_anchor(search.match_range.start) + .map(|(anchor, _)| anchor.buffer_id), + PanelEntry::FoldedDirs(_) => None, + }; + let Some(buffer_id) = buffer_id else { return }; + let collapsed_count = outline_panel.collapsed_entries.len(); + if let PanelEntry::Outline(outline) = &entry { + let range = outline.range(); + if let Some(snapshot) = outline_panel.buffer_snapshot_for_id(buffer_id, cx) { + outline_panel.collapsed_entries.retain(|entry| match entry { + CollapsedEntry::Excerpt(excerpt) => { + excerpt.context.start.buffer_id != buffer_id + || !(excerpt.contains(&range.start, &snapshot) + || excerpt.contains(&range.end, &snapshot)) + } + _ => true, + }); + } + } + let file_path = outline_panel + .fs_entries + .iter() + .find_map(|entry| match entry { + FsEntry::File(file) if file.buffer_id == buffer_id => Some(ProjectPath { + worktree_id: file.worktree_id, + path: file.entry.path.clone(), + }), + _ => None, + }); + let reveal_buffer_contents = + matches!(entry, PanelEntry::Outline(_) | PanelEntry::Search(_)); + if reveal_buffer_contents { + outline_panel + .collapsed_entries + .remove(&CollapsedEntry::ExternalFile(buffer_id)); + } + if let Some(path) = file_path { + if reveal_buffer_contents { outline_panel .collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - if let Some(buffer_snapshot) = - outline_panel.buffer_snapshot_for_id(buffer_id, cx) - { - outline_panel.collapsed_entries.retain(|entry| match entry { - CollapsedEntry::Excerpt(excerpt_range) => { - let intersects = excerpt_range.context.start.buffer_id - == buffer_id - && (excerpt_range - .contains(&outline_range.start, &buffer_snapshot) - || excerpt_range - .contains(&outline_range.end, &buffer_snapshot)); - !intersects - } - _ => true, - }); - } - let project = outline_panel.project.read(cx); - let entry_id = project - .buffer_for_id(buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - - entry_id.and_then(|entry_id| { - project - .worktree_for_entry(entry_id, cx) - .and_then(|worktree| { - let worktree_id = worktree.read(cx).id(); - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::File(worktree_id, buffer_id)); - let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }) - })? + .remove(&CollapsedEntry::File(path.worktree_id, buffer_id)); + } + for ancestor in path.path.ancestors().skip(1) { + outline_panel + .collapsed_entries + .remove(&CollapsedEntry::Dir(path.worktree_id, Arc::from(ancestor))); + } + } + outline_panel.select_entry(entry, false, window, cx); + if outline_panel.collapsed_entries.len() != collapsed_count { + outline_panel.update_cached_entries(None, window, cx); } - PanelEntry::Fs(FsEntry::ExternalFile(..)) => None, - PanelEntry::Search(SearchEntry { match_range, .. }) => multibuffer_snapshot - .anchor_to_buffer_anchor(match_range.start) - .map(|(anchor, _)| anchor.buffer_id) - .map(|buffer_id| { - outline_panel.update(cx, |outline_panel, cx| { - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - let project = project.read(cx); - let entry_id = project - .buffer_for_id(buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - - entry_id.and_then(|entry_id| { - project - .worktree_for_entry(entry_id, cx) - .and_then(|worktree| { - let worktree_id = worktree.read(cx).id(); - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::File(worktree_id, buffer_id)); - let entry = - worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }) - }) - }) - .transpose()? - .flatten(), - _ => return anyhow::Ok(()), - }; - if let Some((worktree, buffer_entry)) = related_buffer_entry { - outline_panel.update(cx, |outline_panel, cx| { - let worktree_id = worktree.read(cx).id(); - let mut dirs_to_expand = Vec::new(); - { - let mut traversal = worktree.read(cx).traverse_from_path( - true, - true, - true, - buffer_entry.path.as_ref(), - ); - let mut current_entry = buffer_entry; - loop { - if current_entry.is_dir() - && outline_panel - .collapsed_entries - .remove(&CollapsedEntry::Dir(worktree_id, current_entry.id)) - { - dirs_to_expand.push(current_entry.id); - } - - if traversal.back_to_parent() - && let Some(parent_entry) = traversal.entry() - { - current_entry = parent_entry.clone(); - continue; - } - break; - } - } - for dir_to_expand in dirs_to_expand { - project - .update(cx, |project, cx| { - project.expand_entry(worktree_id, dir_to_expand, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx) - } - })? - } - - outline_panel.update_in(cx, |outline_panel, window, cx| { - outline_panel.select_entry(entry_with_selection, false, window, cx); - outline_panel.update_cached_entries(None, window, cx); })?; - anyhow::Ok(()) }); } @@ -2384,7 +2593,10 @@ impl OutlinePanel { }; let (item_id, label_element, icon) = match rendered_entry { FsEntry::File(FsEntryFile { - worktree_id, entry, .. + worktree_id, + entry, + buffer_id, + .. }) => { let name = self.entry_name(worktree_id, entry, cx); let color = @@ -2396,7 +2608,7 @@ impl OutlinePanel { None }; ( - ElementId::from(entry.id.to_proto() as usize), + ElementId::from(("buffer", buffer_id.to_proto())), HighlightedLabel::new( name, string_match @@ -2416,7 +2628,7 @@ impl OutlinePanel { let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Dir( directory.worktree_id, - directory.entry.id, + directory.entry.path.clone(), )); let color = entry_git_aware_label_color( directory.entry.git_summary, @@ -2431,7 +2643,11 @@ impl OutlinePanel { cx, ); ( - ElementId::from(directory.entry.id.to_proto() as usize), + directory_element_id( + directory.worktree_id, + directory.first_buffer_id, + &directory.entry.path, + ), HighlightedLabel::new( name, string_match @@ -2463,7 +2679,7 @@ impl OutlinePanel { None => (None, "Unknown buffer".to_string()), }; ( - ElementId::from(external_file.buffer_id.to_proto() as usize), + ElementId::from(("buffer", external_file.buffer_id.to_proto())), HighlightedLabel::new( name, string_match @@ -2502,19 +2718,17 @@ impl OutlinePanel { ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); let is_active = match self.selected_entry() { - Some(PanelEntry::FoldedDirs(selected_dirs)) => { - selected_dirs.worktree_id == folded_dir.worktree_id - && selected_dirs.entries == folded_dir.entries - } + Some(PanelEntry::FoldedDirs(selected_dirs)) => selected_dirs == folded_dir, _ => false, }; let (item_id, label_element, icon) = { let name = self.dir_names_string(&folded_dir.entries, folded_dir.worktree_id, cx); let is_expanded = folded_dir.entries.iter().all(|dir| { - !self - .collapsed_entries - .contains(&CollapsedEntry::Dir(folded_dir.worktree_id, dir.id)) + !self.collapsed_entries.contains(&CollapsedEntry::Dir( + folded_dir.worktree_id, + dir.path.clone(), + )) }); let is_ignored = folded_dir.entries.iter().any(|entry| entry.is_ignored); let git_status = folded_dir @@ -2531,14 +2745,19 @@ impl OutlinePanel { cx, ); ( - ElementId::from( - folded_dir - .entries - .last() - .map(|entry| entry.id.to_proto()) - .unwrap_or_else(|| folded_dir.worktree_id.to_proto()) - as usize, - ), + folded_dir + .entries + .last() + .map(|entry| { + directory_element_id( + folded_dir.worktree_id, + folded_dir.first_buffer_id, + &entry.path, + ) + }) + .unwrap_or_else(|| { + ElementId::from(("empty-folded-dirs", folded_dir.worktree_id.to_proto())) + }), HighlightedLabel::new( name, string_match @@ -2668,6 +2887,10 @@ impl OutlinePanel { cx: &mut Context, ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); + let is_deleted = match &rendered_entry { + PanelEntry::Fs(FsEntry::File(file)) => file.is_deleted, + _ => false, + }; div() .text_ui(cx) .id(item_id.clone()) @@ -2699,7 +2922,13 @@ impl OutlinePanel { .child( h_flex() .child(h_flex().min_w(px(16.)).justify_center().child(icon_element)) - .child(h_flex().h_6().child(label_element).ml_1()), + .child( + h_flex() + .h_6() + .child(label_element) + .ml_1() + .when(is_deleted, |this| this.line_through()), + ), ) .on_secondary_mouse_down(cx.listener( move |outline_panel, event: &MouseDownEvent, window, cx| { @@ -2732,25 +2961,9 @@ impl OutlinePanel { ) } - fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &App) -> String { + fn entry_name(&self, worktree_id: &WorktreeId, entry: &FsEntryPath, cx: &App) -> String { match self.project.read(cx).worktree_for_id(*worktree_id, cx) { - Some(worktree) => { - let worktree = worktree.read(cx); - match worktree.snapshot().root_entry() { - Some(root_entry) => { - if root_entry.id == entry.id { - file_name(worktree.abs_path().as_ref()) - } else { - let path = worktree.absolutize(entry.path.as_ref()); - file_name(&path) - } - } - None => { - let path = worktree.absolutize(entry.path.as_ref()); - file_name(&path) - } - } - } + Some(worktree) => file_name(&worktree.read(cx).absolutize(&entry.path)), None => file_name(entry.path.as_std_path()), } } @@ -2782,11 +2995,7 @@ impl OutlinePanel { window: &mut Window, cx: &mut Context, ) { - if !self.active { - return; - } - - if debounce.is_some() && self.fs_entries_update_pending { + if !self.active || (debounce.is_some() && self.fs_entries_update_pending) { return; } self.fs_entries_update_pending = true; @@ -2796,386 +3005,299 @@ impl OutlinePanel { cx.background_executor().timer(debounce).await; } - let mut new_collapsed_entries = HashSet::default(); - let mut new_unfolded_dirs = HashMap::default(); - let mut root_entries = HashSet::default(); let mut new_buffers = HashMap::::default(); - let Ok((buffer_excerpts, auto_fold_dirs, repo_snapshots)) = - outline_panel.update(cx, |outline_panel, cx| { - outline_panel.fs_entries_update_pending = false; - let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; - let active_multi_buffer = active_editor.read(cx).buffer().clone(); - let new_entries = outline_panel.new_entries_for_fs_update.clone(); - let repo_snapshots = outline_panel.project.update(cx, |project, cx| { - project.git_store().read(cx).display_repo_snapshots(cx) - }); - let git_store = outline_panel.project.read(cx).git_store().clone(); - new_collapsed_entries = outline_panel.collapsed_entries.clone(); - new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); - let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); + let Ok(( + buffer_excerpts, + auto_fold_dirs, + repo_snapshots, + mut collapsed_entries, + mut unfolded_dirs, + )) = outline_panel.update(cx, |outline_panel, cx| { + outline_panel.fs_entries_update_pending = false; + let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let git_store = outline_panel.project.read(cx).git_store().clone(); + let repo_snapshots = git_store.read(cx).display_repo_snapshots(cx); + let editor = active_editor.read(cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let mut buffer_excerpts = IndexMap::::default(); - let buffer_excerpts = multi_buffer_snapshot.excerpts().fold( - HashMap::default(), - |mut buffer_excerpts, excerpt_range| { - let Some(buffer_snapshot) = multi_buffer_snapshot - .buffer_for_id(excerpt_range.context.start.buffer_id) - else { - return buffer_excerpts; - }; - let buffer_id = buffer_snapshot.remote_id(); - let file = File::from_dyn(buffer_snapshot.file()); - let entry_id = file.and_then(|file| file.project_entry_id()); - let worktree = file.map(|file| file.worktree.read(cx).snapshot()); - let is_new = new_entries.contains(&buffer_id) - || !outline_panel.buffers.contains_key(&buffer_id); - let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); + for excerpt in snapshot.excerpts() { + let buffer_id = excerpt.context.start.buffer_id; + let Some(buffer) = snapshot.buffer_for_id(buffer_id) else { + continue; + }; + buffer_excerpts + .entry(buffer_id) + .or_insert_with(|| { let status = git_store .read(cx) .display_status_for_buffer_id(buffer_id, cx); - buffer_excerpts - .entry(buffer_id) - .or_insert_with(|| { - (is_new, is_folded, Vec::new(), entry_id, worktree, status) - }) - .2 - .push(excerpt_range.clone()); + let file = File::from_dyn(buffer.file()); + let is_deleted = file.is_some_and(|file| { + is_deleted_file( + file, + status, + snapshot + .diff_for_buffer_id(buffer_id) + .is_some_and(|diff| diff.base_text_exists()), + ) + }); + let file = file.and_then(|file| { + let worktree = file.worktree.read(cx).snapshot(); + let entry = file + .project_entry_id() + .and_then(|id| worktree.entry_for_id(id)); + if entry.is_none() && !is_deleted { + return None; + } + let path_entry = FsEntryPath { + path: file.path.clone(), + git_summary: status.map(GitSummary::from).unwrap_or_default(), + is_ignored: entry.is_some_and(|entry| entry.is_ignored), + }; + Some((worktree, path_entry)) + }); + BufferExcerpts { + is_new: outline_panel + .new_entries_for_fs_update + .contains(&buffer_id) + || !outline_panel.buffers.contains_key(&buffer_id), + is_folded: editor.is_buffer_folded(buffer_id, cx), + excerpts: Vec::new(), + file, + is_deleted, + } + }) + .excerpts + .push(excerpt.clone()); - new_buffers - .entry(buffer_id) - .or_insert_with(|| { - let outlines = match outline_panel.buffers.get(&buffer_id) { - Some(old_buffer) => match &old_buffer.outlines { - OutlineState::Outlines(outlines) => { - OutlineState::Outlines(outlines.clone()) - } - OutlineState::Invalidated(_) => { - OutlineState::NotFetched - } - OutlineState::NotFetched => OutlineState::NotFetched, - }, - None => OutlineState::NotFetched, - }; - BufferOutlines { - outlines, - excerpts: Vec::new(), - } - }) - .excerpts - .push(excerpt_range); - buffer_excerpts - }, - ); - (buffer_excerpts, auto_fold_dirs, repo_snapshots) - }) + new_buffers + .entry(buffer_id) + .or_insert_with(|| { + let outlines = match outline_panel.buffers.get(&buffer_id) { + Some(BufferOutlines { + outlines: OutlineState::Outlines(outlines), + .. + }) => OutlineState::Outlines(outlines.clone()), + _ => OutlineState::NotFetched, + }; + BufferOutlines { + outlines, + excerpts: Vec::new(), + } + }) + .excerpts + .push(excerpt); + } + ( + buffer_excerpts, + auto_fold_dirs, + repo_snapshots, + outline_panel.collapsed_entries.clone(), + outline_panel.unfolded_dirs.clone(), + ) + }) else { return; }; - let Some(( - new_collapsed_entries, - new_unfolded_dirs, - new_fs_entries, - new_depth_map, - new_children_count, - )) = cx + let (fs_entries, children_count, collapsed_entries, unfolded_dirs) = cx .background_spawn(async move { - let mut processed_external_buffers = HashSet::default(); - let mut new_worktree_entries = - BTreeMap::>::default(); - let mut worktree_excerpts = HashMap::< - WorktreeId, - HashMap>)>, - >::default(); - let mut external_excerpts = HashMap::default(); - - for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree, status)) in - buffer_excerpts - { - if is_folded { - match &worktree { - Some(worktree) => { - new_collapsed_entries - .insert(CollapsedEntry::File(worktree.id(), buffer_id)); - } - None => { - new_collapsed_entries - .insert(CollapsedEntry::ExternalFile(buffer_id)); - } - } - } else if is_new { - match &worktree { - Some(worktree) => { - new_collapsed_entries - .remove(&CollapsedEntry::File(worktree.id(), buffer_id)); - } - None => { - new_collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - } - } + let mut fs_entries = Vec::new(); + let mut directories = HashMap::::default(); + let mut parent_paths = Vec::::new(); + let repositories_by_root = repo_snapshots + .values() + .map(|snapshot| (snapshot.work_directory_abs_path.as_ref(), snapshot)) + .collect::>(); + for (buffer_id, buffer) in buffer_excerpts { + let collapsed_entry = match &buffer.file { + Some((worktree, _)) => CollapsedEntry::File(worktree.id(), buffer_id), + None => CollapsedEntry::ExternalFile(buffer_id), + }; + if buffer.is_folded { + collapsed_entries.insert(collapsed_entry); + } else if buffer.is_new { + collapsed_entries.remove(&collapsed_entry); } - - if let Some(worktree) = worktree { - let worktree_id = worktree.id(); - let unfolded_dirs = new_unfolded_dirs.entry(worktree_id).or_default(); - - match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() { - Some(entry) => { - let entry = GitEntry { - git_summary: status - .map(|status| status.summary()) - .unwrap_or_default(), - entry, - }; - let mut traversal = GitTraversal::new( - &repo_snapshots, - worktree.traverse_from_path( - true, - true, - true, - entry.path.as_ref(), - ), - ); - - let mut entries_to_add = HashMap::default(); - worktree_excerpts - .entry(worktree_id) - .or_default() - .insert(entry.id, (buffer_id, excerpts)); - let mut current_entry = entry; - loop { - if current_entry.is_dir() { - let is_root = - worktree.root_entry().map(|entry| entry.id) - == Some(current_entry.id); - if is_root { - root_entries.insert(current_entry.id); - if auto_fold_dirs { - unfolded_dirs.insert(current_entry.id); - } - } - if is_new { - new_collapsed_entries.remove(&CollapsedEntry::Dir( - worktree_id, - current_entry.id, + let Some((worktree, entry)) = buffer.file else { + parent_paths.clear(); + fs_entries.push(FsEntry::ExternalFile(FsEntryExternalFile { + buffer_id, + excerpts: Arc::from(buffer.excerpts), + })); + continue; + }; + let worktree_id = worktree.id(); + let mut ancestors = entry.path.ancestors().skip(1).collect::>(); + ancestors.reverse(); + let shared_depth = parent_paths + .last() + .filter(|parent| parent.worktree_id == worktree_id) + .zip(ancestors.last()) + .map(|(parent, ancestor)| { + parent + .path + .components() + .zip(ancestor.components()) + .take_while(|(left, right)| left == right) + .count() + + 1 + }) + .unwrap_or(0); + parent_paths.truncate(shared_depth); + for (depth, path) in ancestors.into_iter().enumerate() { + let ancestor = + parent_paths + .get(depth) + .cloned() + .unwrap_or_else(|| ProjectPath { + worktree_id, + path: Arc::from(path), + }); + if buffer.is_new { + collapsed_entries.remove(&CollapsedEntry::Dir( + worktree_id, + ancestor.path.clone(), + )); + } + if depth < shared_depth { + continue; + } + let directory = + directories.entry(ancestor.clone()).or_insert_with(|| { + let indexed_entry = worktree + .entry_for_path(&ancestor.path) + .filter(|entry| entry.is_dir()); + let git_summary = indexed_entry + .and_then(|_| { + let abs_path = worktree.absolutize(&ancestor.path); + for query in abs_path.ancestors() { + let (_, repository) = repositories_by_root + .range(Path::new("")..=query) + .next_back()?; + let Some(path) = + repository.abs_path_to_repo_path(&abs_path) + else { + continue; + }; + let mut statuses = repository + .statuses_by_path + .cursor::(()); + statuses.seek_forward( + &PathTarget::Path(&path), + Bias::Left, + ); + return Some(statuses.summary( + &PathTarget::Successor(&path), + Bias::Left, )); } - } - - let new_entry_added = entries_to_add - .insert(current_entry.id, current_entry) - .is_none(); - if new_entry_added - && traversal.back_to_parent() - && let Some(parent_entry) = traversal.entry() - { - current_entry = parent_entry.to_owned(); - continue; - } - break; + None + }) + .unwrap_or_default(); + FsEntryPath { + path: ancestor.path.clone(), + git_summary, + is_ignored: indexed_entry + .is_some_and(|entry| entry.is_ignored), } - new_worktree_entries - .entry(worktree_id) - .or_insert_with(HashMap::default) - .extend(entries_to_add); - } - None => { - if processed_external_buffers.insert(buffer_id) { - external_excerpts - .entry(buffer_id) - .or_insert_with(Vec::new) - .extend(excerpts); - } - } - } - } else if processed_external_buffers.insert(buffer_id) { - external_excerpts - .entry(buffer_id) - .or_insert_with(Vec::new) - .extend(excerpts); + }); + fs_entries.push(FsEntry::Directory(FsEntryDirectory { + worktree_id, + first_buffer_id: buffer_id, + entry: directory.clone(), + })); + parent_paths.push(ancestor); + } + fs_entries.push(FsEntry::File(FsEntryFile { + worktree_id, + entry, + buffer_id, + excerpts: Arc::from(buffer.excerpts), + is_deleted: buffer.is_deleted, + })); + } + collapsed_entries.retain(|entry| match entry { + CollapsedEntry::Dir(worktree_id, path) => { + directories.contains_key(&ProjectPath { + worktree_id: *worktree_id, + path: path.clone(), + }) + } + _ => true, + }); + unfolded_dirs.retain(|worktree_id, paths| { + paths.retain(|path| { + directories.contains_key(&ProjectPath { + worktree_id: *worktree_id, + path: path.clone(), + }) + }); + !paths.is_empty() + }); + let mut children_count = + HashMap::, FsChildren>>::default(); + for directory in directories.keys() { + if let Some(parent) = directory.path.parent() { + children_count + .entry(directory.worktree_id) + .or_default() + .entry(Arc::from(parent)) + .or_default() + .dirs += 1; } } - - let mut new_children_count = - HashMap::, FsChildren>>::default(); - - let worktree_entries = new_worktree_entries - .into_iter() - .map(|(worktree_id, entries)| { - let mut entries = entries.into_values().collect::>(); - entries.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref())); - (worktree_id, entries) - }) - .flat_map(|(worktree_id, entries)| { + for entry in &fs_entries { + if let FsEntry::File(file) = entry + && let Some(parent) = file.entry.path.parent() + { + children_count + .entry(file.worktree_id) + .or_default() + .entry(Arc::from(parent)) + .or_default() + .files += 1; + } + } + if auto_fold_dirs { + for entry in &fs_entries { + let FsEntry::Directory(directory) = entry else { + continue; + }; + let unfolded = unfolded_dirs.entry(directory.worktree_id).or_default(); + let path = &directory.entry.path; + let children = children_count + .get(&directory.worktree_id) + .and_then(|children| children.get(path)) + .copied() + .unwrap_or_default(); + let parent_unfolded = + path.parent().is_none_or(|parent| unfolded.contains(parent)); + if path.is_empty() + || !children.may_be_fold_part() + || (children.dirs == 0 && parent_unfolded) { - entries - .into_iter() - .filter_map(|entry| { - if auto_fold_dirs && let Some(parent) = entry.path.parent() - { - let children = new_children_count - .entry(worktree_id) - .or_default() - .entry(Arc::from(parent)) - .or_default(); - if entry.is_dir() { - children.dirs += 1; - } else { - children.files += 1; - } - } - - if entry.is_dir() { - Some(FsEntry::Directory(FsEntryDirectory { - worktree_id, - entry, - })) - } else { - let (buffer_id, excerpts) = worktree_excerpts - .get_mut(&worktree_id) - .and_then(|worktree_excerpts| { - worktree_excerpts.remove(&entry.id) - })?; - Some(FsEntry::File(FsEntryFile { - worktree_id, - buffer_id, - entry, - excerpts, - })) - } - }) - .collect::>() + unfolded.insert(path.clone()); } - }) - .collect::>(); - - let mut visited_dirs = Vec::new(); - let mut new_depth_map = HashMap::default(); - let new_visible_entries = external_excerpts - .into_iter() - .sorted_by_key(|(id, _)| *id) - .map(|(buffer_id, excerpts)| { - FsEntry::ExternalFile(FsEntryExternalFile { - buffer_id, - excerpts, - }) - }) - .chain(worktree_entries) - .filter(|visible_item| { - match visible_item { - FsEntry::Directory(directory) => { - let parent_id = back_to_common_visited_parent( - &mut visited_dirs, - &directory.worktree_id, - &directory.entry, - ); - - let mut depth = 0; - if !root_entries.contains(&directory.entry.id) { - if auto_fold_dirs { - let children = new_children_count - .get(&directory.worktree_id) - .and_then(|children_count| { - children_count.get(&directory.entry.path) - }) - .copied() - .unwrap_or_default(); - - if !children.may_be_fold_part() - || (children.dirs == 0 - && visited_dirs - .last() - .map(|(parent_dir_id, _)| { - new_unfolded_dirs - .get(&directory.worktree_id) - .is_none_or(|unfolded_dirs| { - unfolded_dirs - .contains(parent_dir_id) - }) - }) - .unwrap_or(true)) - { - new_unfolded_dirs - .entry(directory.worktree_id) - .or_default() - .insert(directory.entry.id); - } - } - - depth = parent_id - .and_then(|(worktree_id, id)| { - new_depth_map.get(&(worktree_id, id)).copied() - }) - .unwrap_or(0) - + 1; - }; - visited_dirs - .push((directory.entry.id, directory.entry.path.clone())); - new_depth_map - .insert((directory.worktree_id, directory.entry.id), depth); - } - FsEntry::File(FsEntryFile { - worktree_id, - entry: file_entry, - .. - }) => { - let parent_id = back_to_common_visited_parent( - &mut visited_dirs, - worktree_id, - file_entry, - ); - let depth = if root_entries.contains(&file_entry.id) { - 0 - } else { - parent_id - .and_then(|(worktree_id, id)| { - new_depth_map.get(&(worktree_id, id)).copied() - }) - .unwrap_or(0) - + 1 - }; - new_depth_map.insert((*worktree_id, file_entry.id), depth); - } - FsEntry::ExternalFile(..) => { - visited_dirs.clear(); - } - } - - true - }) - .collect::>(); - - anyhow::Ok(( - new_collapsed_entries, - new_unfolded_dirs, - new_visible_entries, - new_depth_map, - new_children_count, - )) + } + } + (fs_entries, children_count, collapsed_entries, unfolded_dirs) }) - .await - .log_err() - else { - return; - }; + .await; outline_panel .update_in(cx, |outline_panel, window, cx| { outline_panel.new_entries_for_fs_update.clear(); outline_panel.buffers = new_buffers; - outline_panel.collapsed_entries = new_collapsed_entries; - outline_panel.unfolded_dirs = new_unfolded_dirs; - outline_panel.fs_entries = new_fs_entries; - outline_panel.fs_entries_depth = new_depth_map; - outline_panel.fs_children_count = new_children_count; + outline_panel.collapsed_entries = collapsed_entries; + outline_panel.unfolded_dirs = unfolded_dirs; + outline_panel.fs_entries = fs_entries; + outline_panel.fs_children_count = children_count; outline_panel.update_non_fs_items(window, cx); - - // Only update cached entries if we don't have outlines to fetch - // If we do have outlines to fetch, let fetch_outdated_outlines handle the update if outline_panel.buffers_to_fetch(cx).is_empty() { outline_panel.update_cached_entries(debounce, window, cx); } - cx.notify(); }) .ok(); @@ -3204,11 +3326,8 @@ impl OutlinePanel { e: &SearchEvent, window: &mut Window, cx: &mut Context| { - if matches!(e, SearchEvent::MatchesInvalidated) - && outline_panel.update_search_matches(window, cx) - { - outline_panel.selected_entry.invalidate(); - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if matches!(e, SearchEvent::MatchesInvalidated) { + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } }, ); @@ -3234,9 +3353,10 @@ impl OutlinePanel { fn clear_previous(&mut self, window: &mut Window, cx: &mut App) { self.fs_entries_update_task = Task::ready(()); self.fs_entries_update_pending = false; + self.lsp_outline_refresh_task = Task::ready(()); self.outline_fetch_tasks.clear(); self.cached_entries_update_task = Task::ready(()); - self.cached_entries_update_pending = false; + self.cached_entries_update_pending = None; self.reveal_selection_task = Task::ready(Ok(())); self.filter_editor .update(cx, |editor, cx| editor.clear(window, cx)); @@ -3244,7 +3364,6 @@ impl OutlinePanel { self.unfolded_dirs.clear(); self.active_item = None; self.fs_entries.clear(); - self.fs_entries_depth.clear(); self.fs_children_count.clear(); self.buffers.clear(); self.cached_entries = Vec::new(); @@ -3340,14 +3459,48 @@ impl OutlinePanel { selection_display_point: DisplayPoint, cx: &App, ) -> Option { + let buffer_snapshot = multi_buffer_snapshot.buffer_for_id(selection_anchor.buffer_id); + let mut excerpt_ranges = multi_buffer_snapshot + .excerpts_for_buffer(selection_anchor.buffer_id) + .map(|excerpt| excerpt.context) + .collect::>(); + if let Some(snapshot) = buffer_snapshot { + excerpt_ranges.sort_unstable_by(|left, right| left.start.cmp(&right.start, snapshot)); + let mut maximum_end = None; + for range in &mut excerpt_ranges { + if let Some(end) = maximum_end + && range.end.cmp(&end, snapshot).is_lt() + { + range.end = end; + } + maximum_end = Some(range.end); + } + } + let excerpt_outlines = self .buffers .get(&selection_anchor.buffer_id) .into_iter() .flat_map(|buffer| buffer.iter_outlines()) .flat_map(|outline| { - let range = multi_buffer_snapshot - .buffer_anchor_range_to_anchor_range(outline.range.clone())?; + let snapshot = buffer_snapshot?; + let (start, end) = if outline + .range + .start + .cmp(&outline.range.end, snapshot) + .is_gt() + { + (outline.range.end, outline.range.start) + } else { + (outline.range.start, outline.range.end) + }; + let index = excerpt_ranges + .partition_point(|excerpt| excerpt.start.cmp(&start, snapshot).is_le()) + .checked_sub(1)?; + if excerpt_ranges.get(index)?.end.cmp(&end, snapshot).is_lt() { + return None; + } + let range = multi_buffer_snapshot.anchor_range_in_buffer(outline.range.clone())?; Some(( range.start.to_display_point(&editor_snapshot) ..range.end.to_display_point(&editor_snapshot), @@ -3482,7 +3635,12 @@ impl OutlinePanel { Some(closest_container) } - fn fetch_outdated_outlines(&mut self, window: &mut Window, cx: &mut Context) { + fn fetch_outdated_outlines( + &mut self, + debounce: Option, + window: &mut Window, + cx: &mut Context, + ) { let buffers_to_fetch = self.buffers_to_fetch(cx); if buffers_to_fetch.is_empty() { return; @@ -3490,29 +3648,34 @@ impl OutlinePanel { let first_update = Arc::new(AtomicBool::new(true)); for buffer_id in buffers_to_fetch { - let outline_task = self.active_editor().map(|editor| { - editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx)) - }); - + if self + .outline_fetch_tasks + .get(&buffer_id) + .is_some_and(|task| !task.is_ready()) + { + continue; + } let first_update = first_update.clone(); self.outline_fetch_tasks.insert( buffer_id, cx.spawn_in(window, async move |outline_panel, cx| { - let Some(outline_task) = outline_task else { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(outline_task) = outline_panel + .update(cx, |panel, cx| { + let editor = panel.active_editor()?; + Some(editor.update(cx, |editor, cx| { + editor.buffer_outline_items(buffer_id, cx) + })) + }) + .ok() + .flatten() + else { return; }; let fetched_outlines = outline_task.await; - let outlines_with_children = fetched_outlines - .array_windows::<2>() - .filter_map(|[current, next]| { - if next.depth > current.depth { - Some((current.range.clone(), current.depth)) - } else { - None - } - }) - .collect::>(); outline_panel .update_in(cx, |outline_panel, window, cx| { @@ -3523,19 +3686,31 @@ impl OutlinePanel { .insert(buffer_id) }); - let debounce = - if first_update.fetch_and(false, atomic::Ordering::AcqRel) { - None - } else { - Some(UPDATE_DEBOUNCE) - }; - + let mut entries_changed = false; if let Some(buffer) = outline_panel.buffers.get_mut(&buffer_id) { - buffer.outlines = OutlineState::Outlines(fetched_outlines.clone()); + entries_changed = pending_default_depth.is_some() + || match &buffer.outlines { + OutlineState::Outlines(outlines) + | OutlineState::Invalidated(outlines) => { + outlines != &fetched_outlines + } + OutlineState::NotFetched => true, + }; + buffer.outlines = OutlineState::Outlines(fetched_outlines); if let Some(default_depth) = pending_default_depth && let OutlineState::Outlines(outlines) = &buffer.outlines { + let outlines_with_children = outlines + .array_windows::<2>() + .filter_map(|[current, next]| { + if next.depth > current.depth { + Some((current.range.clone(), current.depth)) + } else { + None + } + }) + .collect::>(); outlines .iter() .filter(|outline| { @@ -3553,7 +3728,27 @@ impl OutlinePanel { } } - outline_panel.update_cached_entries(debounce, window, cx); + if entries_changed + || matches!( + &outline_panel.selected_entry, + SelectedEntry::Invalidated(Some(PanelEntry::Outline(outline))) + if outline.buffer_id() == buffer_id + ) + { + let debounce = + if first_update.fetch_and(false, atomic::Ordering::AcqRel) { + None + } else { + Some(UPDATE_DEBOUNCE) + }; + if let Some(PanelEntry::Outline(outline)) = + outline_panel.selected_entry() + && outline.buffer_id() == buffer_id + { + outline_panel.selected_entry.invalidate(); + } + outline_panel.update_cached_entries(debounce, window, cx); + } }) .ok(); }), @@ -3622,15 +3817,16 @@ impl OutlinePanel { } fn invalidate_outlines(&mut self, ids: &[BufferId]) { - self.outline_fetch_tasks.clear(); - let mut ids = ids.iter().collect::>(); - for (buffer_id, buffer) in self.buffers.iter_mut() { - if ids.remove(&buffer_id) { + if let Some(PanelEntry::Outline(outline)) = self.selected_entry() + && ids.contains(&outline.buffer_id()) + { + self.selected_entry.invalidate(); + } + for buffer_id in ids { + self.outline_fetch_tasks.remove(buffer_id); + if let Some(buffer) = self.buffers.get_mut(buffer_id) { buffer.invalidate_outlines(); } - if ids.is_empty() { - break; - } } } @@ -3717,6 +3913,19 @@ impl OutlinePanel { } } + fn update_contents( + &mut self, + debounce: Option, + window: &mut Window, + cx: &mut Context, + ) { + if !self.active { + return; + } + self.cached_entries_update_pending = Some(CachedEntriesUpdate::Contents); + self.update_cached_entries(debounce, window, cx); + } + fn update_cached_entries( &mut self, debounce: Option, @@ -3727,54 +3936,150 @@ impl OutlinePanel { return; } - // A pending debounced update will read the latest state when it fires, - // so we don't need to reschedule. Constantly rescheduling under a steady stream - // of events (e.g. project search streaming results) would starve the task forever. - if debounce.is_some() && self.cached_entries_update_pending { + self.cached_entries_update_pending + .get_or_insert(CachedEntriesUpdate::Entries); + if !self.cached_entries_update_task.is_ready() { return; } - self.cached_entries_update_pending = true; self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| { - if let Some(debounce) = debounce { - cx.background_executor().timer(debounce).await; - } - let Some(new_cached_entries) = outline_panel - .update_in(cx, |outline_panel, window, cx| { - outline_panel.cached_entries_update_pending = false; - let is_singleton = outline_panel.is_singleton_active(cx); - let query = outline_panel.query(cx); - outline_panel.generate_cached_entries(is_singleton, query, window, cx) - }) - .ok() - else { - return; - }; - let (new_cached_entries, max_width_item_index) = new_cached_entries.await; - outline_panel - .update_in(cx, |outline_panel, window, cx| { - outline_panel.cached_entries = new_cached_entries; - outline_panel.max_width_item_index = max_width_item_index; - if (outline_panel.selected_entry.is_invalidated() - || matches!(outline_panel.selected_entry, SelectedEntry::None)) - && let Some(new_selected_entry) = - outline_panel.active_editor().and_then(|active_editor| { - outline_panel.location_for_editor_selection( - &active_editor, - window, - cx, - ) - }) - { - outline_panel.select_entry(new_selected_entry, false, window, cx); - } + let mut debounce = debounce; + loop { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(new_cached_entries) = outline_panel + .update_in(cx, |outline_panel, window, cx| { + if outline_panel.cached_entries_update_pending.take() + == Some(CachedEntriesUpdate::Contents) + { + outline_panel.update_non_fs_items(window, cx); + } + let is_singleton = outline_panel.is_singleton_active(cx); + let query = outline_panel.query(cx); + outline_panel.generate_cached_entries(is_singleton, query, window, cx) + }) + .ok() + else { + return; + }; + let (new_cached_entries, max_width_item_index) = new_cached_entries.await; + outline_panel + .update_in(cx, |outline_panel, window, cx| { + outline_panel.cached_entries = new_cached_entries; + outline_panel.max_width_item_index = max_width_item_index; + if let SelectedEntry::Valid(selected, _) = &outline_panel.selected_entry + && let Some((index, cached)) = + outline_panel.cached_entry_for_selection(selected) + { + outline_panel.selected_entry = + SelectedEntry::Valid(cached.entry.clone(), index); + } + if (outline_panel.selected_entry.is_invalidated() + || matches!(outline_panel.selected_entry, SelectedEntry::None)) + && let Some(new_selected_entry) = + outline_panel.active_editor().and_then(|active_editor| { + outline_panel.location_for_editor_selection( + &active_editor, + window, + cx, + ) + }) + { + let awaiting_outlines = match &new_selected_entry { + PanelEntry::Outline(outline) => outline_panel + .buffers + .get(&outline.buffer_id()) + .is_some_and(BufferOutlines::should_fetch_outlines), + _ => false, + }; + outline_panel.select_entry(new_selected_entry, false, window, cx); + if awaiting_outlines { + outline_panel.selected_entry.invalidate(); + } + } - cx.notify(); - }) - .ok(); + cx.notify(); + }) + .ok(); + if !outline_panel + .read_with(cx, |panel, _| panel.cached_entries_update_pending.is_some()) + .unwrap_or(false) + { + break; + } + debounce = Some(UPDATE_DEBOUNCE); + } }); } + fn cached_entry_for_selection(&self, selected: &PanelEntry) -> Option<(usize, &CachedEntry)> { + if let Some(entry) = self + .cached_entries + .iter() + .enumerate() + .find(|(_, cached)| &cached.entry == selected) + { + return Some(entry); + } + let (worktree_id, path, first_buffer_id) = match selected { + PanelEntry::Fs(FsEntry::Directory(directory)) => ( + directory.worktree_id, + &directory.entry.path, + directory.first_buffer_id, + ), + PanelEntry::FoldedDirs(group) => ( + group.worktree_id, + &group.entries.last()?.path, + group.first_buffer_id, + ), + _ => return None, + }; + let mut first_section = None; + let mut current_section = None; + let mut selected_section = None; + for entry in &self.fs_entries { + match entry { + FsEntry::Directory(directory) + if directory.worktree_id == worktree_id && directory.entry.path == *path => + { + first_section.get_or_insert(directory.first_buffer_id); + current_section = Some(directory.first_buffer_id); + } + FsEntry::File(file) if file.buffer_id == first_buffer_id => { + if file.worktree_id == worktree_id + && file + .entry + .path + .parent() + .is_some_and(|parent| parent.starts_with(path)) + { + selected_section = current_section; + break; + } + } + _ => {} + } + } + let selected_section = selected_section.or(first_section)?; + self.cached_entries + .iter() + .enumerate() + .find(|(_, cached)| match &cached.entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id + && directory.first_buffer_id == selected_section + && directory.entry.path == *path + } + PanelEntry::FoldedDirs(group) => { + group.worktree_id == worktree_id + && group.first_buffer_id == selected_section + && group.entries.iter().any(|entry| entry.path == *path) + } + _ => false, + }) + } + fn generate_cached_entries( &self, is_singleton: bool, @@ -3782,7 +4087,6 @@ impl OutlinePanel { window: &mut Window, cx: &mut Context, ) -> Task<(Vec, Option)> { - let project = self.project.clone(); let Some(active_editor) = self.active_editor() else { return Task::ready((Vec::new(), None)); }; @@ -3801,6 +4105,7 @@ impl OutlinePanel { folded: bool, expanded: bool, depth: usize, + worktree_id: WorktreeId, } let search_precomputed = if hide_symbols { @@ -3863,27 +4168,20 @@ impl OutlinePanel { let (depth, should_add) = match &entry { FsEntry::Directory(directory_entry) => { let mut should_add = true; - let is_root = project - .read(cx) - .worktree_for_id(directory_entry.worktree_id, cx) - .is_some_and(|worktree| { - worktree.read(cx).root_entry() == Some(&directory_entry.entry) - }); + let is_root = directory_entry.entry.path.is_empty(); let folded = auto_fold_dirs && !is_root && outline_panel .unfolded_dirs .get(&directory_entry.worktree_id) .is_none_or(|unfolded_dirs| { - !unfolded_dirs.contains(&directory_entry.entry.id) + !unfolded_dirs.contains(&directory_entry.entry.path) }); - let fs_depth = outline_panel - .fs_entries_depth - .get(&(directory_entry.worktree_id, directory_entry.entry.id)) - .copied() - .unwrap_or(0); + let fs_depth = directory_entry.entry.path.components().count(); while let Some(parent) = parent_dirs.last() { - if !is_root && directory_entry.entry.path.starts_with(&parent.path) + if !is_root + && parent.worktree_id == directory_entry.worktree_id + && directory_entry.entry.path.starts_with(&parent.path) { break; } @@ -3917,6 +4215,7 @@ impl OutlinePanel { parent.depth + 1 }; parent_dirs.push(ParentStats { + worktree_id: directory_entry.worktree_id, path: directory_entry.entry.path.clone(), folded, expanded: parent_expanded && is_expanded, @@ -3926,6 +4225,7 @@ impl OutlinePanel { } None => { parent_dirs.push(ParentStats { + worktree_id: directory_entry.worktree_id, path: directory_entry.entry.path.clone(), folded, expanded: is_expanded, @@ -3984,6 +4284,7 @@ impl OutlinePanel { depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, + first_buffer_id: directory_entry.first_buffer_id, entries: vec![directory_entry.entry.clone()], }, )) @@ -3994,6 +4295,7 @@ impl OutlinePanel { depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, + first_buffer_id: directory_entry.first_buffer_id, entries: vec![directory_entry.entry.clone()], }, )); @@ -4005,15 +4307,15 @@ impl OutlinePanel { } FsEntry::ExternalFile(..) => { if let Some((folded_depth, folded_dir)) = folded_dirs_entry.take() { + let folded_paths = folded_dir + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dir - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( @@ -4030,15 +4332,15 @@ impl OutlinePanel { } FsEntry::File(file) => { if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { + let folded_paths = folded_dirs + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dirs - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( @@ -4051,13 +4353,15 @@ impl OutlinePanel { } } - let fs_depth = outline_panel - .fs_entries_depth - .get(&(file.worktree_id, file.entry.id)) - .copied() - .unwrap_or(0); + let fs_depth = file.entry.path.components().count(); while let Some(parent) = parent_dirs.last() { - if file.entry.path.starts_with(&parent.path) { + if parent.worktree_id == file.worktree_id + && file + .entry + .path + .parent() + .is_some_and(|path| path.starts_with(&parent.path)) + { break; } parent_dirs.pop(); @@ -4156,15 +4460,15 @@ impl OutlinePanel { } if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { + let folded_paths = folded_dirs + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dirs - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if parent_expanded || query.is_some() { outline_panel.push_entry( @@ -4241,6 +4545,7 @@ impl OutlinePanel { } 1 => PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id: folded_dirs_entry.worktree_id, + first_buffer_id: folded_dirs_entry.first_buffer_id, entry: folded_dirs_entry.entries[0].clone(), })), _ => entry, @@ -4306,7 +4611,12 @@ impl OutlinePanel { }); } - fn dir_names_string(&self, entries: &[GitEntry], worktree_id: WorktreeId, cx: &App) -> String { + fn dir_names_string( + &self, + entries: &[FsEntryPath], + worktree_id: WorktreeId, + cx: &App, + ) -> String { let dir_names_segment = entries .iter() .map(|entry| self.entry_name(&worktree_id, entry, cx)) @@ -4335,23 +4645,23 @@ impl OutlinePanel { }) => CollapsedEntry::File(*worktree_id, *buffer_id), FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. - }) => CollapsedEntry::Dir(*worktree_id, entry.id), + }) => CollapsedEntry::Dir(*worktree_id, entry.path.clone()), }; !self.collapsed_entries.contains(&entry_to_check) } - fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) -> bool { + fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) { if !self.active { - return false; + return; } - let mut update_cached_items = false; - update_cached_items |= self.update_search_matches(window, cx); - self.fetch_outdated_outlines(window, cx); - if update_cached_items { + if self.cached_entries_update_pending == Some(CachedEntriesUpdate::Contents) { + self.cached_entries_update_pending = Some(CachedEntriesUpdate::Entries); + } + if self.update_search_matches(window, cx) { self.selected_entry.invalidate(); } - update_cached_items + self.fetch_outdated_outlines(None, window, cx); } fn update_search_matches( @@ -4366,31 +4676,21 @@ impl OutlinePanel { let project_search = self .active_item() .and_then(|item| item.downcast::()); - let project_search_matches = project_search - .as_ref() - .map(|project_search| project_search.read(cx).get_matches(cx)) - .unwrap_or_default(); - let buffer_search = self - .active_item() - .as_deref() - .and_then(|active_item| { - self.workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).pane_for(active_item)) - }) - .and_then(|pane| { - pane.read(cx) - .toolbar() - .read(cx) - .item_of_type::() - }); let buffer_search_matches = self .active_editor() .map(|active_editor| { active_editor.update(cx, |editor, cx| editor.get_matches(window, cx).0) }) .unwrap_or_default(); + let project_search_matches = if buffer_search_matches.is_empty() { + project_search + .as_ref() + .map(|project_search| project_search.read(cx).get_matches(cx)) + .unwrap_or_default() + } else { + Vec::new() + }; let mut update_cached_entries = false; if buffer_search_matches.is_empty() && project_search_matches.is_empty() { @@ -4408,6 +4708,20 @@ impl OutlinePanel { .unwrap_or_default(), ) } else { + let buffer_search = self + .active_item() + .as_deref() + .and_then(|active_item| { + self.workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).pane_for(active_item)) + }) + .and_then(|pane| { + pane.read(cx) + .toolbar() + .read(cx) + .item_of_type::() + }); ( SearchKind::Buffer, buffer_search_matches, @@ -4465,119 +4779,142 @@ impl OutlinePanel { let Some(buffer) = self.buffers.get(&buffer_id) else { return; }; - let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx); - - for excerpt in &buffer.excerpts { + let snapshot = buffer_snapshot.as_ref(); + let mut excerpts = buffer + .excerpts + .iter() + .map(|excerpt| ExcerptOutlines { + excerpt, + ranked_range: 0..0, + expanded: is_singleton + || query.is_some() + || !self + .collapsed_entries + .contains(&CollapsedEntry::Excerpt(excerpt.clone())), + visible: Vec::new(), + children_to_cache: Vec::new(), + }) + .collect::>(); + let expanded_excerpts = excerpts.iter().filter(|excerpt| excerpt.expanded).count(); + if expanded_excerpts > 0 { + let children = self.outline_children_cache.entry(buffer_id).or_default(); + let outlines = buffer.iter_outlines().collect::>(); + if let Some(snapshot) = snapshot + && expanded_excerpts > 1 + && !outlines.is_empty() + { + let mut coordinates = Vec::new(); + for outline in &outlines { + coordinates.extend([outline.range.start, outline.range.end]); + } + for excerpt in excerpts.iter().filter(|excerpt| excerpt.expanded) { + coordinates + .extend([excerpt.excerpt.context.start, excerpt.excerpt.context.end]); + } + coordinates.sort_unstable_by(|left, right| left.cmp(right, snapshot)); + coordinates.dedup_by(|left, right| left.cmp(right, snapshot).is_eq()); + let rank = |anchor: Anchor| { + coordinates + .partition_point(|coordinate| coordinate.cmp(&anchor, snapshot).is_lt()) + }; + let outlines = outlines + .iter() + .map(|outline| IndexedOutline { + outline, + ranked_range: rank(outline.range.start)..rank(outline.range.end), + collapsed: self + .collapsed_entries + .contains(&CollapsedEntry::Outline(outline.range.clone())), + }) + .collect::>(); + for excerpt in excerpts.iter_mut().filter(|excerpt| excerpt.expanded) { + excerpt.ranked_range = + rank(excerpt.excerpt.context.start)..rank(excerpt.excerpt.context.end); + } + BufferOutlines::assign_outline_cache_owners(&outlines, &mut excerpts); + if BufferOutlines::outlines_are_well_formed(&outlines) { + BufferOutlines::collect_nested_outlines(&outlines, &mut excerpts, children); + } else { + BufferOutlines::collect_overlapping_outlines( + &outlines, + &mut excerpts, + children, + ); + } + } else { + for excerpt in excerpts.iter_mut().filter(|excerpt| excerpt.expanded) { + let mut matches = outlines + .iter() + .copied() + .filter(|outline| { + snapshot.is_none_or(|snapshot| { + outline + .range + .start + .cmp(&excerpt.excerpt.context.end, snapshot) + .is_le() + && outline + .range + .end + .cmp(&excerpt.excerpt.context.start, snapshot) + .is_ge() + }) + }) + .peekable(); + let mut collapsed_ancestor: Option<&Outline> = None; + while let Some(outline) = matches.next() { + children.insert( + (outline.range.clone(), outline.depth), + matches + .peek() + .is_some_and(|next| next.depth > outline.depth), + ); + if let Some(ancestor) = collapsed_ancestor + && let Some(snapshot) = snapshot + && outline.depth > ancestor.depth + && outline + .range + .start + .cmp(&ancestor.range.start, snapshot) + .is_ge() + && outline + .range + .start + .cmp(&ancestor.range.end, snapshot) + .is_lt() + { + continue; + } + excerpt.visible.push(outline); + collapsed_ancestor = self + .collapsed_entries + .contains(&CollapsedEntry::Outline(outline.range.clone())) + .then_some(outline); + } + } + } + } + for excerpt in excerpts { let excerpt_depth = parent_depth + 1; self.push_entry( state, track_matches, - PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())), + PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.excerpt.clone())), excerpt_depth, cx, ); - - let mut outline_base_depth = excerpt_depth + 1; - if is_singleton { - outline_base_depth = 0; + let outline_base_depth = if is_singleton { state.clear(); - } else if query.is_none() - && self - .collapsed_entries - .contains(&CollapsedEntry::Excerpt(excerpt.clone())) - { - continue; - } - - let mut last_depth_at_level: Vec>> = vec![None; 10]; - - let all_outlines = buffer - .iter_outlines() - .filter(|outline| match &buffer_snapshot { - Some(buffer_snapshot) => { - outline - .range - .start - .cmp(&excerpt.context.end, buffer_snapshot) - .is_le() - && outline - .range - .end - .cmp(&excerpt.context.start, buffer_snapshot) - .is_ge() - } - None => true, - }) - .collect::>(); - - let mut outline_has_children = HashMap::default(); - let mut visible_outlines = Vec::new(); - let mut collapsed_state: Option<(usize, Range)> = None; - - for (i, &outline) in all_outlines.iter().enumerate() { - let has_children = all_outlines - .get(i + 1) - .map(|next| next.depth > outline.depth) - .unwrap_or(false); - - outline_has_children.insert((outline.range.clone(), outline.depth), has_children); - - let mut should_include = true; - - if let Some((collapsed_depth, collapsed_range)) = &collapsed_state { - if outline.depth <= *collapsed_depth { - collapsed_state = None; - } else if let Some(buffer_snapshot) = buffer_snapshot.as_ref() { - let outline_start = outline.range.start; - if outline_start - .cmp(&collapsed_range.start, buffer_snapshot) - .is_ge() - && outline_start - .cmp(&collapsed_range.end, buffer_snapshot) - .is_lt() - { - should_include = false; // Skip - inside collapsed range - } else { - collapsed_state = None; - } - } - } - - // Check if this outline itself is collapsed - if should_include - && self - .collapsed_entries - .contains(&CollapsedEntry::Outline(outline.range.clone())) - { - collapsed_state = Some((outline.depth, outline.range.clone())); - } - - if should_include { - visible_outlines.push(outline); - } - } - - self.outline_children_cache - .entry(buffer_id) - .or_default() - .extend(outline_has_children); - - for outline in visible_outlines { - let outline_entry = outline.clone(); - - if outline.depth < last_depth_at_level.len() { - last_depth_at_level[outline.depth] = Some(outline.range.clone()); - // Clear deeper levels when we go back to a shallower depth - for d in (outline.depth + 1)..last_depth_at_level.len() { - last_depth_at_level[d] = None; - } - } - + 0 + } else { + excerpt_depth + 1 + }; + for outline in excerpt.visible { self.push_entry( state, track_matches, - PanelEntry::Outline(OutlineEntry::Outline(outline_entry)), + PanelEntry::Outline(OutlineEntry::Outline(outline.clone())), outline_base_depth + outline.depth, cx, ); @@ -4613,7 +4950,7 @@ impl OutlinePanel { return; }; - let excerpt_ranges = excerpts + let mut excerpt_ranges = excerpts .iter() .filter_map(|excerpt| { let start = search @@ -4626,11 +4963,25 @@ impl OutlinePanel { }) .collect::>(); + let snapshot = &search.multi_buffer_snapshot; + excerpt_ranges.sort_unstable_by(|left, right| left.start.cmp(&right.start, snapshot)); + let mut maximum_end = None; + for range in &mut excerpt_ranges { + if let Some(end) = maximum_end + && range.end.cmp(&end, snapshot).is_lt() + { + range.end = end; + } + maximum_end = Some(range.end); + } let depth = if is_singleton { 0 } else { parent_depth + 1 }; for (match_range, search_data) in buffer_matches.iter().filter(|(match_range, _)| { - excerpt_ranges.iter().any(|excerpt_range| { - excerpt_range.overlaps(match_range, &search.multi_buffer_snapshot) - }) + let index = excerpt_ranges + .partition_point(|excerpt| excerpt.start.cmp(&match_range.end, snapshot).is_le()); + index + .checked_sub(1) + .and_then(|index| excerpt_ranges.get(index)) + .is_some_and(|excerpt| excerpt.end.cmp(&match_range.start, snapshot).is_ge()) }) { self.push_entry( state, @@ -4820,9 +5171,12 @@ impl OutlinePanel { } else { let list_contents = { let items_len = self.cached_entries.len(); - let multi_buffer_snapshot = self - .active_editor() - .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)); + let multi_buffer_snapshot = match self.mode { + ItemsDisplayMode::Search(_) => self + .active_editor() + .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)), + ItemsDisplayMode::Outline => None, + }; uniform_list( "entries", items_len, @@ -5048,35 +5402,19 @@ impl OutlinePanel { fn buffers_inside_directory( &self, - dir_worktree: WorktreeId, - dir_entry: &GitEntry, + worktree_id: WorktreeId, + directory: &FsEntryPath, ) -> HashSet { - if !dir_entry.is_dir() { - debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}"); - return HashSet::default(); - } - self.fs_entries .iter() - .skip_while(|fs_entry| match fs_entry { - FsEntry::Directory(directory) => { - directory.worktree_id != dir_worktree || &directory.entry != dir_entry + .filter_map(|entry| match entry { + FsEntry::File(file) + if file.worktree_id == worktree_id + && file.entry.path != directory.path + && file.entry.path.starts_with(&directory.path) => + { + Some(file.buffer_id) } - _ => true, - }) - .skip(1) - .take_while(|fs_entry| match fs_entry { - FsEntry::ExternalFile(..) => false, - FsEntry::Directory(directory) => { - directory.worktree_id == dir_worktree - && directory.entry.path.starts_with(&dir_entry.path) - } - FsEntry::File(file) => { - file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path) - } - }) - .filter_map(|fs_entry| match fs_entry { - FsEntry::File(file) => Some(file.buffer_id), _ => None, }) .collect() @@ -5094,27 +5432,6 @@ fn workspace_active_editor( Some((active_item, active_editor)) } -fn back_to_common_visited_parent( - visited_dirs: &mut Vec<(ProjectEntryId, Arc)>, - worktree_id: &WorktreeId, - new_entry: &Entry, -) -> Option<(WorktreeId, ProjectEntryId)> { - while let Some((visited_dir_id, visited_path)) = visited_dirs.last() { - match new_entry.path.parent() { - Some(parent_path) => { - if parent_path == visited_path.as_ref() { - return Some((*worktree_id, *visited_dir_id)); - } - } - None => { - break; - } - } - visited_dirs.pop(); - } - None -} - fn file_name(path: &Path) -> String { let mut current_path = path; loop { @@ -5191,6 +5508,8 @@ impl Panel for OutlinePanel { let old_active = outline_panel.active; outline_panel.active = active; if old_active != active { + outline_panel.lsp_outline_refresh_task = Task::ready(()); + outline_panel.outline_fetch_tasks.clear(); if active && let Some((active_item, active_editor)) = outline_panel.workspace.upgrade().and_then(|workspace| { @@ -5380,12 +5699,12 @@ fn subscribe_for_editor_events( match e { EditorEvent::SelectionsChanged { local: true } => { outline_panel.reveal_entry_for_selection(editor.clone(), window, cx); - cx.notify(); } EditorEvent::BuffersRemoved { removed_buffer_ids } => { - outline_panel - .buffers - .retain(|buffer_id, _| !removed_buffer_ids.contains(buffer_id)); + for buffer_id in removed_buffer_ids { + outline_panel.buffers.remove(buffer_id); + outline_panel.outline_fetch_tasks.remove(buffer_id); + } outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::BufferRangesUpdated { buffer, .. } => { @@ -5397,10 +5716,7 @@ fn subscribe_for_editor_events( } EditorEvent::BuffersEdited { buffer_ids } => { outline_panel.invalidate_outlines(buffer_ids); - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } + outline_panel.update_contents(debounce, window, cx); } EditorEvent::BufferFoldToggled { ids, .. } => { outline_panel.invalidate_outlines(ids); @@ -5459,26 +5775,42 @@ fn subscribe_for_editor_events( outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::Reparsed(buffer_id) => { - if let Some(buffer) = outline_panel.buffers.get_mut(buffer_id) { - buffer.invalidate_outlines(); - } - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } + outline_panel.invalidate_outlines(&[*buffer_id]); + outline_panel.update_contents(debounce, window, cx); } EditorEvent::OutlineSymbolsChanged => { - for buffer in outline_panel.buffers.values_mut() { - buffer.invalidate_outlines(); - } - if matches!( - outline_panel.selected_entry(), - Some(PanelEntry::Outline(..)), - ) { - outline_panel.selected_entry.invalidate(); - } - if outline_panel.update_non_fs_items(window, cx) { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if outline_panel.lsp_outline_refresh_task.is_ready() { + outline_panel.lsp_outline_refresh_task = + cx.spawn_in(window, async move |outline_panel, cx| { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + outline_panel + .update_in(cx, |outline_panel, window, cx| { + let Some(editor) = outline_panel.active_editor() else { + return; + }; + let editor_buffer = editor.read(cx).buffer().read(cx); + let mut invalidated = false; + for (buffer_id, outlines) in &mut outline_panel.buffers { + if editor_buffer.buffer(*buffer_id).is_some_and( + |buffer| { + LanguageSettings::for_buffer( + buffer.read(cx), + cx, + ) + .document_symbols + .lsp_enabled() + }, + ) { + outlines.invalidate_outlines(); + invalidated = true; + } + } + if invalidated { + outline_panel.fetch_outdated_outlines(None, window, cx); + } + }) + .ok(); + }); } } EditorEvent::TitleChanged => { @@ -5557,8 +5889,10 @@ impl GenerationState { #[cfg(test)] mod tests { + use buffer_diff::BufferDiff; use db::indoc; - use futures::stream::StreamExt as _; + use editor::{HiddenUnstagedDiffHunkRenderer, PathKey}; + use futures::{FutureExt as _, StreamExt as _, future::poll_fn, task::Poll}; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; use language::{self, FakeLspAdapter, markdown_lang, rust_lang}; use pretty_assertions::assert_eq; @@ -5568,13 +5902,97 @@ mod tests { project_search::{self, perform_project_search}, }; use serde_json::json; - use util::path; + use util::{path, rel_path::rel_path}; use workspace::{MultiWorkspace, OpenOptions, OpenVisible, ToolbarItemView}; use super::*; const SELECTED_MARKER: &str = " <==== selected"; + #[gpui::test] + async fn test_outline_fetches_survive_unrelated_updates_but_not_deactivation( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = outline_panel(&workspace, cx); + let [requested_id, retained_id, unknown_id] = + [1, 2, 3].map(|id| BufferId::new(id).expect("nonzero buffer ID")); + let (requested_sender, requested_receiver) = futures::channel::oneshot::channel::<()>(); + let (retained_sender, retained_receiver) = futures::channel::oneshot::channel::<()>(); + let (refresh_sender, refresh_receiver) = futures::channel::oneshot::channel::<()>(); + panel.update(cx, |panel, cx| { + panel.lsp_outline_refresh_task = cx.background_spawn(async move { + refresh_receiver + .await + .expect("LSP refresh should be canceled before completion"); + }); + for buffer_id in [requested_id, retained_id] { + panel.buffers.insert( + buffer_id, + BufferOutlines { + excerpts: Vec::new(), + outlines: OutlineState::Outlines(Vec::new()), + }, + ); + } + for (buffer_id, receiver) in [ + (requested_id, requested_receiver), + (retained_id, retained_receiver), + ] { + panel.outline_fetch_tasks.insert( + buffer_id, + cx.background_spawn(async move { + receiver + .await + .expect("outline fetch should be canceled before completion"); + }), + ); + } + panel.invalidate_outlines(&[]); + panel.invalidate_outlines(&[unknown_id]); + assert_eq!(panel.outline_fetch_tasks.len(), 2); + panel.invalidate_outlines(&[requested_id, requested_id, unknown_id]); + assert_eq!( + panel + .outline_fetch_tasks + .keys() + .copied() + .collect::>(), + vec![retained_id] + ); + assert!(matches!( + panel.buffers[&requested_id].outlines, + OutlineState::Invalidated(_) + )); + assert!(matches!( + panel.buffers[&retained_id].outlines, + OutlineState::Outlines(_) + )); + }); + cx.run_until_parked(); + assert!(requested_sender.is_canceled()); + assert!(!retained_sender.is_canceled()); + assert!(!refresh_sender.is_canceled()); + + panel.update_in(cx, |panel, window, cx| { + panel.active = true; + panel.pinned = true; + panel.set_active(false, window, cx); + }); + cx.run_until_parked(); + assert!(retained_sender.is_canceled()); + assert!(refresh_sender.is_canceled()); + panel.read_with(cx, |panel, _| { + assert!(!panel.active); + assert!(panel.outline_fetch_tasks.is_empty()); + assert_eq!(panel.buffers.len(), 2); + }); + } + #[gpui::test(iterations = 10)] async fn test_project_search_results_toggling(cx: &mut TestAppContext) { init_test(cx); @@ -6237,15 +6655,14 @@ outline: fn hints_lifetimes_named <==== selected" outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt search: «aaa» aaa <==== selected search: aaa «aaa» two/ b.txt - search: a «aaa»"#, - ), + search: a «aaa»"# + .to_string(), ); }); @@ -6265,13 +6682,12 @@ two/ outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt <==== selected two/ b.txt - search: a «aaa»"#, - ), + search: a «aaa»"# + .to_string(), ); }); @@ -6291,11 +6707,10 @@ two/ outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt -two/ <==== selected"#, - ), +two/ <==== selected"# + .to_string(), ); }); @@ -6314,17 +6729,2289 @@ two/ <==== selected"#, outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt two/ <==== selected b.txt - search: a «aaa»"#, - ) + search: a «aaa»"# + .to_string() ); }); } + #[gpui::test] + async fn test_file_order_matches_diff_multibuffer(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "crates": { "ide": { + "asdsads": "new file", + "src": { + "interpret.rs": "fn interpret() {}", + "join_lines.rs": "fn join_lines() {}" + } + } } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let added = open_buffer(&project, path!("/root/crates/ide/asdsads"), cx).await; + let deleted = open_buffer(&project, path!("/root/crates/ide/src/join_lines.rs"), cx).await; + let modified = open_buffer(&project, path!("/root/crates/ide/src/interpret.rs"), cx).await; + fs.remove_file( + Path::new(path!("/root/crates/ide/src/join_lines.rs")), + project::RemoveOptions::default(), + ) + .await + .expect("file deletion should succeed"); + deleted.update(cx, |buffer, cx| { + buffer.edit( + [(language::Point::default()..buffer.max_point(), "")], + None, + cx, + ); + }); + let editor = add_diff_editor( + &workspace, + &project, + &[ + (&modified, "old interpret"), + (&deleted, "fn join_lines() {}"), + (&added, ""), + ], + cx, + ); + set_buffer_order(&editor, &[&modified, &deleted, &added], cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_file( + &outline_panel, + modified.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + crates/ide/ + src/ + interpret.rs <==== selected + ~~join_lines.rs~~ + asdsads" + ), + cx, + ); + set_buffer_order(&editor, &[&added, &deleted, &modified], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + crates/ide/ + asdsads + src/ + ~~join_lines.rs~~ + interpret.rs <==== selected" + ), + cx, + ); + } + + #[gpui::test] + async fn test_file_order_preserves_interleaved_directory_sections(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/one"), + json!({ "é": { "子": { "first.txt": "first", "second.txt": "second" } } }), + ) + .await; + fs.insert_tree(path!("/two"), json!({ "other.txt": "other" })) + .await; + let project = Project::test( + fs.clone(), + [Path::new(path!("/one")), Path::new(path!("/two"))], + cx, + ) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let first = open_buffer(&project, path!("/one/é/子/first.txt"), cx).await; + let second = open_buffer(&project, path!("/one/é/子/second.txt"), cx).await; + let other = open_buffer(&project, path!("/two/other.txt"), cx).await; + let untitled = project.update(cx, |project, cx| { + project.create_local_buffer("untitled", None, true, cx) + }); + fs.remove_dir( + Path::new(path!("/one/é")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + fs.insert_file(path!("/one/é"), b"replacement".to_vec()) + .await; + let replacement = open_buffer(&project, path!("/one/é"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&first, Vec::new()), + (&second, Vec::new()), + (&other, Vec::new()), + (&untitled, Vec::new()), + (&replacement, Vec::new()), + ], + cx, + ); + set_buffer_order( + &editor, + &[&second, &other, &untitled, &first, &replacement], + cx, + ); + select_file( + &outline_panel, + first.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_parent(&SelectParent, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + é" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + select_file( + &outline_panel, + first.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_parent(&SelectParent, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/ + 子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + } + + #[gpui::test] + async fn test_directory_selection_survives_buffer_reordering(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ "dir": { "sub": { "a.txt": "a", "b.txt": "b" } }, "z.txt": "z" }), + ) + .await; + let project = Project::test(fs, [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/dir/sub/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/sub/b.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&buffer_a, Vec::new()), + (&buffer_b, Vec::new()), + (&buffer_z, Vec::new()), + ], + cx, + ); + set_buffer_order(&editor, &[&buffer_b, &buffer_a, &buffer_z], cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_directory(&outline_panel, worktree_id, rel_path("dir/sub"), cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + b.txt + a.txt + z.txt" + ), + cx, + ); + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + a.txt + b.txt + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ + a.txt <==== selected + b.txt + z.txt" + ), + cx, + ); + select_directory(&outline_panel, worktree_id, rel_path("dir/sub"), cx); + rename_buffer(&project, &buffer_a, rel_path("a.txt"), cx).await; + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a.txt + dir/sub/ <==== selected + b.txt + z.txt" + ), + cx, + ); + rename_buffer(&project, &buffer_a, rel_path("dir/sub/a.txt"), cx).await; + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts_for_buffer(buffer_a_id, cx); + }); + }); + set_buffer_order(&editor, &[&buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + b.txt + z.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_selected_file_survives_rename_and_deletion(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "a.txt": "a", "z.txt": "z" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/a.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let buffer_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_file(&outline_panel, buffer_id, cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a.txt <==== selected + z.txt" + ), + cx, + ); + + fs.rename( + Path::new(path!("/root/a.txt")), + Path::new(path!("/root/b.txt")), + project::RenameOptions::default(), + ) + .await + .expect("file rename should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, _| { + assert_eq!( + File::from_dyn(buffer.file()) + .expect("buffer should retain its file") + .path + .as_ref(), + rel_path("b.txt"), + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt <==== selected + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + assert!(panel.focus_handle.is_focused(window)); + match &panel.selected_entry { + SelectedEntry::Valid(PanelEntry::Fs(FsEntry::File(file)), _) => { + assert_eq!( + (file.buffer_id, file.entry.path.as_ref()), + (buffer_id, rel_path("b.txt")) + ); + } + selected => { + panic!("expected the renamed file to remain selected, got {selected:?}") + } + } + panel.select_next(&SelectNext, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt + z.txt <==== selected" + ), + cx, + ); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_previous(&SelectPrevious, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt <==== selected + z.txt" + ), + cx, + ); + fs.remove_file( + Path::new(path!("/root/b.txt")), + project::RemoveOptions::default(), + ) + .await + .expect("renamed file deletion should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + ~~b.txt~~ <==== selected + z.txt" + ), + cx, + ); + outline_panel.read_with(cx, |panel, _| match &panel.selected_entry { + SelectedEntry::Valid(PanelEntry::Fs(FsEntry::File(file)), _) => { + assert_eq!( + (file.buffer_id, file.entry.path.as_ref()), + (buffer_id, rel_path("b.txt")) + ); + assert!(file.is_deleted); + } + selected => panic!("expected the deleted file to remain selected, got {selected:?}"), + }); + } + + #[gpui::test] + async fn test_directory_state_prunes_paths_after_repeated_renames(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ "d0": { "file.txt": "retained buffer" }, "z.txt": "z" }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer = open_buffer(&project, path!("/root/d0/file.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let (buffer_id, worktree_id) = buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + (buffer.remote_id(), file.worktree.read(cx).id()) + }); + let buffer_z_id = buffer_z.read_with(cx, |buffer, _| buffer.remote_id()); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + select_file(&outline_panel, buffer_z_id, cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + d0/ + file.txt + z.txt <==== selected" + ), + cx, + ); + assert_directory_paths(&outline_panel, worktree_id, &["", "d0"], &[], cx); + + for (previous, next) in [("d0", "d1"), ("d1", "d2"), ("d2", "d3"), ("d3", "d4")] { + select_directory(&outline_panel, worktree_id, rel_path(previous), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + select_file(&outline_panel, buffer_z_id, cx); + assert_tree( + &outline_panel, + &project, + &format!( + indoc!( + " + root/ + {previous}/ + z.txt <==== selected" + ), + previous = previous, + ), + cx, + ); + assert_directory_paths( + &outline_panel, + worktree_id, + &["", previous], + &[previous], + cx, + ); + + let next_directory = Path::new(path!("/root")).join(next); + fs.create_dir(&next_directory) + .await + .expect("destination directory should be created"); + let entry_id = buffer.read_with(cx, |buffer, _| { + File::from_dyn(buffer.file()) + .and_then(|file| file.project_entry_id()) + .expect("file should have a worktree entry") + }); + project + .update(cx, |project, cx| { + project.rename_entry( + entry_id, + ProjectPath { + worktree_id, + path: Arc::from(rel_path(&format!("{next}/file.txt"))), + }, + cx, + ) + }) + .await + .expect("file rename should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + flush_outline_tasks(cx); + buffer.read_with(cx, |buffer, _| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(buffer.remote_id(), buffer_id); + assert_eq!(file.path.as_ref(), rel_path(&format!("{next}/file.txt"))); + }); + assert_tree( + &outline_panel, + &project, + &format!( + indoc!( + " + root/ + {next}/ + file.txt + z.txt <==== selected" + ), + next = next, + ), + cx, + ); + assert_directory_paths(&outline_panel, worktree_id, &["", next], &[], cx); + } + } + + #[gpui::test] + async fn test_ignored_directory_expansion_does_not_scan_worktree(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".gitignore": "ignored\n", + "ignored": { + "a": { "deep": { "open.txt": "first\n" } }, + "b": { "open.txt": "second\n" }, + "c": { "unopened.txt": "unopened\n" } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/ignored/a/deep/open.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/ignored/b/open.txt"), cx).await; + let worktree = buffer_a.read_with(cx, |buffer, _| { + File::from_dyn(buffer.file()).unwrap().worktree.clone() + }); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + wait_for_outline_tasks(&outline_panel, cx).await; + let scan_id = worktree.read_with(cx, |worktree, _| worktree.scan_id()); + let read_dir_calls = fs.read_dir_call_count(); + let metadata_calls = fs.metadata_call_count(); + + for auto_fold_dirs in [false, true] { + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(auto_fold_dirs); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + let expanded_tree = if auto_fold_dirs { + indoc!( + " + root/ + ignored/ + a/deep/ <==== selected + open.txt + b/ + open.txt" + ) + } else { + indoc!( + " + root/ + ignored/ + a/ + deep/ <==== selected + open.txt + b/ + open.txt" + ) + }; + select_directory(&outline_panel, worktree_id, rel_path("ignored/a/deep"), cx); + for keyboard in [true, false] { + outline_panel.update_in(cx, |panel, window, cx| { + if keyboard { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + } else { + let entry = panel.selected_entry().unwrap().clone(); + panel.toggle_expanded(&entry, window, cx); + } + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert!( + editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer_a_id, cx)) + ); + outline_panel.update_in(cx, |panel, window, cx| { + if keyboard { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + } else { + let entry = panel.selected_entry().unwrap().clone(); + panel.toggle_expanded(&entry, window, cx); + } + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert!( + !editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer_a_id, cx)) + ); + assert_tree(&outline_panel, &project, expanded_tree, cx); + assert_eq!( + worktree.read_with(cx, |worktree, _| worktree.scan_id()), + scan_id + ); + assert_eq!(fs.read_dir_call_count(), read_dir_calls); + assert_eq!(fs.metadata_call_count(), metadata_calls); + } + + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + select_in_buffer(&editor, buffer_a_id, cx); + wait_for_outline_tasks(&outline_panel, cx).await; + let revealed_tree = if auto_fold_dirs { + indoc!( + " + root/ + ignored/ + a/deep/ + open.txt <==== selected + b/ + open.txt" + ) + } else { + indoc!( + " + root/ + ignored/ + a/ + deep/ + open.txt <==== selected + b/ + open.txt" + ) + }; + assert_tree(&outline_panel, &project, revealed_tree, cx); + assert_eq!( + worktree.read_with(cx, |worktree, _| worktree.scan_id()), + scan_id + ); + assert_eq!(fs.read_dir_call_count(), read_dir_calls); + assert_eq!(fs.metadata_call_count(), metadata_calls); + worktree.read_with(cx, |worktree, _| { + assert_eq!( + worktree + .entry_for_path(rel_path("ignored/c")) + .map(|entry| entry.kind), + Some(worktree::EntryKind::UnloadedDir), + ); + assert_eq!( + worktree.entry_for_path(rel_path("ignored/c/unopened.txt")), + None + ); + }); + outline_panel.read_with(cx, |panel, _| { + assert_eq!( + panel + .fs_entries + .iter() + .map(|entry| match entry { + FsEntry::Directory(directory) => + (directory.entry.path.as_ref(), directory.entry.is_ignored), + FsEntry::File(file) => + (file.entry.path.as_ref(), file.entry.is_ignored), + FsEntry::ExternalFile(_) => + panic!("indexed ignored files should not be external"), + }) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("ignored"), true), + (rel_path("ignored/a"), true), + (rel_path("ignored/a/deep"), true), + (rel_path("ignored/a/deep/open.txt"), true), + (rel_path("ignored/b"), true), + (rel_path("ignored/b/open.txt"), true), + ], + ); + }); + } + } + + #[gpui::test] + async fn test_selected_folded_directory_survives_git_metadata_changes(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "a": { "b": { "file.txt": "modified\n" } }, + "z.txt": "unchanged\n" + }), + ) + .await; + let base_contents = [ + ("a/b/file.txt", "base\n".to_string()), + ("z.txt", "unchanged\n".to_string()), + ]; + let dot_git = Path::new(path!("/root/.git")); + fs.set_head_for_repo(dot_git, &base_contents, "deadbeef"); + fs.set_index_for_repo(dot_git, &base_contents); + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer = open_buffer(&project, path!("/root/a/b/file.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let worktree_id = buffer.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + select_directory(&outline_panel, worktree_id, rel_path("a/b"), cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ <==== selected + file.txt + z.txt" + ), + cx, + ); + let metadata_before = outline_panel.read_with(cx, |panel, _| { + let Some(PanelEntry::FoldedDirs(group)) = panel.selected_entry() else { + panic!("folded directory should be selected"); + }; + assert_eq!( + group + .entries + .iter() + .map(|entry| ( + entry.path.as_ref(), + entry.git_summary.worktree.modified, + entry.is_ignored + )) + .collect::>(), + vec![(rel_path("a"), 1, false), (rel_path("a/b"), 1, false)], + ); + group + .entries + .iter() + .map(|entry| entry.git_summary) + .collect::>() + }); + + fs.remove_dir( + Path::new(path!("/root/a")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + flush_outline_tasks(cx); + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(file.disk_state, DiskState::Deleted); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("a")) + .map(|entry| entry.kind), + None + ); + }); + let metadata_after = outline_panel.read_with(cx, |panel, _| { + let group = panel + .cached_entries + .iter() + .find_map(|cached| match &cached.entry { + PanelEntry::FoldedDirs(group) + if group.worktree_id == worktree_id + && group + .entries + .last() + .is_some_and(|entry| entry.path.as_ref() == rel_path("a/b")) => + { + Some(group) + } + _ => None, + }) + .expect("folded directory should retain its row"); + assert_eq!( + group + .entries + .iter() + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![rel_path("a"), rel_path("a/b")], + ); + group + .entries + .iter() + .map(|entry| entry.git_summary) + .collect::>() + }); + assert_ne!(metadata_before, metadata_after); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ <==== selected + ~~file.txt~~ + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ + ~~file.txt~~ <==== selected + z.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_deleted_file_nests_under_existing_parent(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.txt": "hello world", + "b.txt": "hello there", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.txt"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + a.txt + b.txt" + ), + cx, + ); + + fs.remove_file(Path::new(path!("/root/dir/a.txt")), Default::default()) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.txt~~ + b.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_directory_collapse_survives_ancestor_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "keep.txt": "hello there", + "dir": { "sub": { "a.txt": "hello world" } } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_a = open_buffer(&project, path!("/root/dir/sub/a.txt"), cx).await; + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_a, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + sub/ + a.txt + keep.txt" + ), + cx, + ); + + fs.remove_file( + Path::new(path!("/root/dir/sub/a.txt")), + project::RemoveOptions::default(), + ) + .await + .expect("file deletion should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + sub/ + ~~a.txt~~ + keep.txt" + ), + cx, + ); + + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.remove_dir( + Path::new(path!("/root/dir")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(file.disk_state, DiskState::Deleted); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("dir")) + .map(|entry| entry.kind), + None, + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.create_dir(Path::new(path!("/root/dir/sub"))) + .await + .expect("ancestor recreation should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("dir/sub")) + .map(|entry| entry.is_dir()), + Some(true), + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.insert_file(path!("/root/dir/sub/a.txt"), b"hello world".to_vec()) + .await; + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, _| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert!(matches!(file.disk_state, DiskState::Present { .. })); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + sub/ + a.txt + keep.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_only_deleted_files_in_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "one": { "é": { "子": { "a.txt": "one" } } }, + "two": { "é": { "子": { "a.txt": "two" } } } + }), + ) + .await; + let project = Project::test( + fs.clone(), + [Path::new(path!("/root/one")), Path::new(path!("/root/two"))], + cx, + ) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_one = open_buffer(&project, path!("/root/one/é/子/a.txt"), cx).await; + let buffer_two = open_buffer(&project, path!("/root/two/é/子/a.txt"), cx).await; + let worktree_one = buffer_one.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_two, Vec::new()), (&buffer_one, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + a.txt + two/ + é/ + 子/ + a.txt" + ), + cx, + ); + + for path in [path!("/root/one/é"), path!("/root/two/é")] { + fs.remove_dir( + Path::new(path), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("directory deletion should succeed"); + } + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + ~~a.txt~~ + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + + select_directory(&outline_panel, worktree_one, rel_path("é"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ <==== selected + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ <==== selected + 子/ + ~~a.txt~~ + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + } + + #[gpui::test] + async fn test_untitled_buffer_still_external(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "existing.txt": "hello" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let untitled_buffer = project.update(cx, |project, cx| { + project.create_local_buffer("untitled content", None, true, cx) + }); + add_multi_buffer_editor(&workspace, &project, &[(&untitled_buffer, Vec::new())], cx); + flush_outline_tasks(cx); + + assert_tree(&outline_panel, &project, "external: untitled", cx); + } + + #[gpui::test] + async fn test_auto_reveal_expands_ancestor_of_deleted_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.rs": indoc!( + " + pub fn foo() { + let x = 1; + } + " + ), + "b.rs": "pub fn bar() {}\n", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.rs"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.rs"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + fs.remove_file( + Path::new(path!("/root/dir/a.rs")), + project::RemoveOptions::default(), + ) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ + outline: pub fn foo <==== selected + b.rs + outline: pub fn bar" + ), + cx, + ); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + let buffer_b_id = buffer_b.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_b_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ + outline: pub fn foo + b.rs + outline: pub fn bar <==== selected" + ), + cx, + ); + + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected" + ), + cx, + ); + + select_in_buffer(&editor, buffer_a_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ <==== selected + b.rs" + ), + cx, + ); + } + + #[gpui::test] + async fn test_open_deleted_file_entry_navigates_editor(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.txt": "hello world", + "b.txt": "hello there", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.txt"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + let buffer_b_id = buffer_b.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_b_id, cx); + + fs.remove_file(Path::new(path!("/root/dir/a.txt")), Default::default()) + .await + .unwrap(); + flush_outline_tasks(cx); + + let deleted_row = outline_panel.read_with(cx, |outline_panel, _cx| { + outline_panel + .cached_entries + .iter() + .find_map(|cached_entry| match &cached_entry.entry { + PanelEntry::Fs(FsEntry::File(file)) + if file.entry.path.as_ref() == rel_path("dir/a.txt") => + { + Some(cached_entry.entry.clone()) + } + _ => None, + }) + .expect("the deleted file should have a row") + }); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_entry(deleted_row, true, window, cx); + }); + editor.update_in(cx, |editor, window, cx| { + assert!( + !editor.focus_handle(cx).is_focused(window), + "sanity check: selecting a row focuses the panel, not the editor" + ); + }); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); + }); + cx.run_until_parked(); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + editor + .scroll_manager + .shared_scroll_anchor(cx) + .scroll_anchor + .anchor + .buffer_id(), + Some(buffer_a_id), + "opening the deleted file's row should scroll the editor to its excerpt" + ); + assert!(editor.focus_handle(cx).is_focused(window)); + }); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.txt~~ <==== selected + b.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_buffer_for_nonexistent_path_not_treated_as_deleted(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "existing.txt": "hello" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let new_buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/root/missing_dir/new.txt"), cx) + .expect("path inside the worktree should resolve"); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + new_buffer.read_with(cx, |buffer, _cx| { + assert_eq!( + buffer.file().map(|file| file.disk_state()), + Some(language::DiskState::New), + "sanity check: a buffer opened on a nonexistent path should be DiskState::New" + ); + }); + add_multi_buffer_editor(&workspace, &project, &[(&new_buffer, Vec::new())], cx); + flush_outline_tasks(cx); + + assert_tree(&outline_panel, &project, "external: new.txt", cx); + } + + #[gpui::test] + async fn test_deleted_file_ancestor_replaced_by_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "éx": "hello there", + "é": { + "a.rs": indoc!( + " + pub fn foo() { + let x = 1; + } + " + ) + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_keep = open_buffer(&project, path!("/root/éx"), cx).await; + let buffer_a = open_buffer(&project, path!("/root/é/a.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_a, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + fs.remove_dir( + Path::new(path!("/root/é")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + fs.insert_file(path!("/root/é"), b"not a directory anymore".to_vec()) + .await; + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é/ + ~~a.rs~~ + outline: pub fn foo <==== selected + éx" + ), + cx, + ); + + let buffer_replacement = open_buffer(&project, path!("/root/é"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&buffer_keep, Vec::new()), + (&buffer_a, Vec::new()), + (&buffer_replacement, Vec::new()), + ], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ + ~~a.rs~~ + outline: pub fn foo + éx" + ), + cx, + ); + + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("é"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ <==== selected + éx" + ), + cx, + ); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_a_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ + ~~a.rs~~ <==== selected + éx" + ), + cx, + ); + } + + #[gpui::test] + async fn test_folded_directories_survive_ancestor_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "keep.txt": "hello there", + "a": { + "b": { + "c": { + "d": { + "gone.txt": "hello world" + } + } + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + }); + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_gone = open_buffer(&project, path!("/root/a/b/c/d/gone.txt"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_gone, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + gone.txt + keep.txt" + ), + cx, + ); + + fs.remove_dir( + Path::new(path!("/root/a/b/c")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + fs.create_dir(Path::new(path!("/root/a/b/c/d"))) + .await + .expect("ancestor recreation should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + + let deleted_file_row = outline_panel.read_with(cx, |outline_panel, _cx| { + outline_panel + .cached_entries + .iter() + .find_map(|cached_entry| match &cached_entry.entry { + PanelEntry::Fs(FsEntry::File(file)) + if file.entry.path.as_ref() == rel_path("a/b/c/d/gone.txt") => + { + Some(cached_entry.entry.clone()) + } + _ => None, + }) + .expect("the deleted file should have a row") + }); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_entry(deleted_file_row, true, window, cx); + }); + + for expected in [ + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + indoc!( + " + root/ <==== selected + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + ] { + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_parent(&SelectParent, window, cx); + }); + assert_tree(&outline_panel, &project, expected, cx); + } + + let worktree_id = buffer_gone.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("a/b/c/d"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ <==== selected + keep.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + for expected in [ + indoc!( + " + root/ + a/b/c/d/ <==== selected + keep.txt" + ), + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + ] { + outline_panel.update_in(cx, |panel, window, cx| { + let entry = panel + .selected_entry() + .expect("folded directory should be selected") + .clone(); + panel.toggle_expanded(&entry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree(&outline_panel, &project, expected, cx); + } + } + + #[gpui::test] + async fn test_scan_excluded_modified_file_stays_external(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(settings::SplicingVec::from(vec![ + "**/excluded.txt".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "keep.txt": "hello there", + "excluded.txt": "new", + }), + ) + .await; + let dot_git = Path::new(path!("/root/.git")); + fs.set_head_for_repo( + dot_git, + &[ + ("keep.txt", "hello there".into()), + ("excluded.txt", "old".into()), + ], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_excluded = open_buffer(&project, path!("/root/excluded.txt"), cx).await; + buffer_excluded.read_with(cx, |buffer, _cx| { + let file = File::from_dyn(buffer.file()).expect("excluded buffer should have a file"); + assert_eq!( + file.project_entry_id(), + None, + "sanity check: a scan-excluded file has no worktree entry" + ); + assert!( + matches!(file.disk_state, language::DiskState::Present { .. }), + "sanity check: a scan-excluded file is present on disk, got {:?}", + file.disk_state + ); + }); + + add_diff_editor( + &workspace, + &project, + &[ + (&buffer_keep, "previous contents\n"), + (&buffer_excluded, "old\n"), + ], + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: excluded.txt + root/ + keep.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_diff_deletions_group_under_scanned_and_excluded_parents(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(settings::SplicingVec::from(vec![ + "**/excluded".to_string(), + "**/missing".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "scanned": { "keep.txt": "still here" }, + "excluded": { "keep.txt": "still here" } + }), + ) + .await; + let dot_git = Path::new(path!("/root/.git")); + fs.set_branch_name(dot_git, Some("feature")); + fs.insert_branches(dot_git, &["main"]); + fs.set_head_for_repo( + dot_git, + &[ + ("scanned/keep.txt", "still here".to_string()), + ("excluded/keep.txt", "still here".to_string()), + ], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_scanned = open_buffer(&project, path!("/root/scanned/gone.txt"), cx).await; + let buffer_excluded = open_buffer(&project, path!("/root/excluded/gone.txt"), cx).await; + let buffer_missing = open_buffer(&project, path!("/root/missing/gone.txt"), cx).await; + for buffer in [&buffer_scanned, &buffer_excluded, &buffer_missing] { + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + assert_eq!(file.disk_state, DiskState::New); + assert_eq!(file.project_entry_id(), None); + assert_eq!( + project + .read(cx) + .git_store() + .read(cx) + .display_status_for_buffer_id(buffer.remote_id(), cx), + None, + ); + }); + } + buffer_excluded.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + let worktree = file.worktree.read(cx); + assert_eq!( + worktree + .entry_for_path(rel_path("excluded")) + .map(|entry| entry.kind), + None + ); + assert_eq!( + worktree + .entry_for_path(rel_path("missing")) + .map(|entry| entry.kind), + None + ); + assert_eq!( + worktree + .entry_for_path(rel_path("scanned")) + .map(|entry| entry.is_dir()), + Some(true), + ); + }); + assert_eq!( + fs.metadata(Path::new(path!("/root/excluded"))) + .await + .expect("metadata should be readable") + .map(|metadata| metadata.is_dir), + Some(true), + ); + assert_eq!( + fs.metadata(Path::new(path!("/root/missing"))) + .await + .expect("metadata should be readable") + .map(|metadata| metadata.is_dir), + None, + ); + add_diff_editor( + &workspace, + &project, + &[ + (&buffer_scanned, "scanned base\n"), + (&buffer_excluded, "excluded base\n"), + (&buffer_missing, "missing base\n"), + ], + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + excluded/ + ~~gone.txt~~ + missing/ + ~~gone.txt~~ + scanned/ + ~~gone.txt~~" + ), + cx, + ); + } + + #[gpui::test] + async fn test_auto_reveal_ignores_external_file_on_nonexistent_path(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "real.rs": "pub fn real() {}\n", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_real = open_buffer(&project, path!("/root/dir/real.rs"), cx).await; + let buffer_new = open_buffer(&project, path!("/root/dir/never_existed.rs"), cx).await; + buffer_new.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + indoc!( + " + pub fn phantom() { + let x = 1; + } + " + ), + )], + None, + cx, + ); + }); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_real, Vec::new()), (&buffer_new, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom <==== selected + root/ + dir/ + real.rs + outline: pub fn real" + ), + cx, + ); + + let worktree_id = buffer_real.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom + root/ + dir/ <==== selected" + ), + cx, + ); + + let buffer_new_id = buffer_new.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_new_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom <==== selected + root/ + dir/" + ), + cx, + ); + } + #[gpui::test] async fn test_navigating_in_singleton(cx: &mut TestAppContext) { init_test(cx); @@ -6758,11 +9445,10 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} <==== selected + search: { "something": "«static»" } <==== selected src/ app/(site)/ (about)/jobs/[slug]/ @@ -6774,7 +9460,7 @@ outline: struct OutlineEntryExcerpt components/ ErrorBoundary.tsx search: «static»"# - ) + .to_string() ); }); @@ -6797,17 +9483,16 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ <==== selected components/ ErrorBoundary.tsx search: «static»"# - ) + .to_string() ); }); @@ -6827,17 +9512,16 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ components/ ErrorBoundary.tsx search: «static» <==== selected"# - ) + .to_string() ); }); @@ -6861,16 +9545,15 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected"# - ) + .to_string() ); }); @@ -6894,17 +9577,16 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected search: «static»"# - ) + .to_string() ); }); @@ -6923,7 +9605,7 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!(r#"frontend-project/"#) + r#"frontend-project/"#.to_string() ); }); @@ -6942,11 +9624,10 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ (about)/jobs/[slug]/ @@ -6958,7 +9639,7 @@ outline: struct OutlineEntryExcerpt components/ ErrorBoundary.tsx <==== selected search: «static»"# - ) + .to_string() ); }); } @@ -7019,6 +9700,39 @@ outline: struct OutlineEntryExcerpt .unwrap() } + async fn rename_buffer( + project: &Entity, + buffer: &Entity, + path: &RelPath, + cx: &mut VisualTestContext, + ) { + let (entry_id, worktree_id) = buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + ( + file.project_entry_id() + .expect("file should have a worktree entry"), + file.worktree.read(cx).id(), + ) + }); + project + .update(cx, |project, cx| { + project.rename_entry( + entry_id, + ProjectPath { + worktree_id, + path: Arc::from(path), + }, + cx, + ) + }) + .await + .expect("file rename should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + } + fn add_multi_buffer_editor( workspace: &Entity, project: &Entity, @@ -7048,17 +9762,87 @@ outline: struct OutlineEntryExcerpt }) } - fn settle_outline_panel(outline_panel: &Entity, cx: &mut VisualTestContext) { - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); - cx.run_until_parked(); - outline_panel.update_in(cx, |panel, window, cx| { - panel.update_non_fs_items(window, cx); - panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + fn set_buffer_order( + editor: &Entity, + buffers: &[&Entity], + cx: &mut VisualTestContext, + ) { + editor.update_in(cx, |editor, window, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + for (index, buffer) in buffers.iter().enumerate() { + let mut path = PathKey::for_buffer(buffer, cx); + path.sort_prefix = Some(index as u64); + let range = language::Point::default()..buffer.read(cx).max_point(); + multibuffer.set_excerpts_for_path(path, (*buffer).clone(), [range], 0, cx); + } + assert_eq!( + multibuffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>(), + buffers + .iter() + .map(|buffer| buffer.read(cx).remote_id()) + .collect::>(), + ); + }); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([language::Point::default()..language::Point::default()]); + }); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + flush_outline_tasks(cx); + } + + fn select_in_buffer(editor: &Entity, buffer_id: BufferId, cx: &mut VisualTestContext) { + let anchor = editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let excerpt = snapshot + .excerpts_for_buffer(buffer_id) + .next() + .expect("buffer should have an excerpt in the multi-buffer"); + snapshot + .anchor_in_excerpt(excerpt.context.start) + .expect("the excerpt's start should resolve") + }); + cx.update(|window, cx| { + window.focus(&editor.focus_handle(cx), cx); + editor.update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(Some(anchor..anchor)) + }); + }); + }); + flush_outline_tasks(cx); + } + + async fn wait_for_outline_tasks( + outline_panel: &Entity, + cx: &mut VisualTestContext, + ) { cx.run_until_parked(); + poll_fn(|task_context| { + outline_panel.update(cx, |panel, _| { + for task in [ + &mut panel.fs_entries_update_task, + &mut panel.lsp_outline_refresh_task, + &mut panel.cached_entries_update_task, + ] + .into_iter() + .chain(panel.outline_fetch_tasks.values_mut()) + { + if !task.is_ready() { + futures::ready!(task.poll_unpin(task_context)); + } + } + if !panel.reveal_selection_task.is_ready() { + futures::ready!(panel.reveal_selection_task.poll_unpin(task_context)) + .expect("selection reveal should complete"); + } + Poll::Ready(()) + }) + }) + .await; } fn update_outline_panel_settings( @@ -7074,6 +9858,34 @@ outline: struct OutlineEntryExcerpt }); } + fn assert_tree( + outline_panel: &Entity, + project: &Entity, + expected: &str, + cx: &mut VisualTestContext, + ) { + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + expected + ); + }); + } + + fn mark_deleted(name: String, is_deleted: bool) -> String { + if is_deleted { + format!("~~{name}~~") + } else { + name + } + } + fn display_entries( project: &Entity, multi_buffer_snapshot: &MultiBufferSnapshot, @@ -7095,15 +9907,19 @@ outline: struct OutlineEntryExcerpt } display_string += &match &entry.entry { PanelEntry::Fs(entry) => match entry { - FsEntry::ExternalFile(_) => { - panic!("Did not cover external files with tests") + FsEntry::ExternalFile(external_file) => { + let name = multi_buffer_snapshot + .buffer_for_id(external_file.buffer_id) + .and_then(|buffer| buffer.file()) + .map(|file| file.file_name(cx).to_string()) + .unwrap_or_else(|| "untitled".to_string()); + format!("external: {name}") } FsEntry::Directory(directory) => { let path = if let Some(worktree) = project .worktree_for_id(directory.worktree_id, cx) - .filter(|worktree| { - worktree.read(cx).root_entry() == Some(&directory.entry.entry) - }) { + .filter(|_| directory.entry.path.is_empty()) + { worktree .read(cx) .root_name() @@ -7120,12 +9936,14 @@ outline: struct OutlineEntryExcerpt }; format!("{path}/") } - FsEntry::File(file) => file - .entry - .path - .file_name() - .map(|name| name.to_string()) - .unwrap_or_default(), + FsEntry::File(file) => mark_deleted( + file.entry + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_default(), + file.is_deleted, + ), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs .entries @@ -7395,6 +10213,174 @@ outline: struct OutlineEntryExcerpt }) } + async fn active_outline_panel( + project: &Entity, + cx: &mut TestAppContext, + ) -> (Entity, Entity, VisualTestContext) { + let (window, workspace) = add_outline_panel(project, cx).await; + let mut cx = VisualTestContext::from_window(window.into(), cx); + let panel = outline_panel(&workspace, &mut cx); + panel.update_in(&mut cx, |panel, window, cx| { + panel.set_active(true, window, cx); + }); + (workspace, panel, cx) + } + + fn add_diff_editor( + workspace: &Entity, + project: &Entity, + entries: &[(&Entity, &str)], + cx: &mut VisualTestContext, + ) -> Entity { + let (editor, diffs) = workspace.update_in(cx, |workspace, window, cx| { + let mut diffs = Vec::new(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite); + for (buffer, base_text) in entries { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text( + base_text, + &buffer.read(cx).text_snapshot(), + cx, + ) + }); + let buffer_snapshot = buffer.read(cx).snapshot(); + let ranges = diff + .read(cx) + .snapshot(cx) + .hunks(&buffer_snapshot) + .map(|hunk| hunk.buffer_range.to_point(&buffer_snapshot)) + .collect::>(); + assert!( + !ranges.is_empty(), + "diff fixture must have at least one hunk" + ); + multibuffer.set_excerpts_for_buffer((*buffer).clone(), ranges, 0, cx); + multibuffer.add_diff(diff.clone(), cx); + diffs.push(diff); + } + multibuffer + }); + let editor = cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx); + editor.set_diff_hunk_renderer(Some(Arc::new(HiddenUnstagedDiffHunkRenderer)), cx); + editor.set_expand_all_diff_hunks(cx); + editor + }); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + (editor, diffs) + }); + flush_outline_tasks(cx); + editor.read_with(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let snapshot = multibuffer.snapshot(cx); + for ((buffer, base_text), diff) in entries.iter().zip(&diffs) { + let buffer_id = buffer.read(cx).remote_id(); + assert_eq!( + multibuffer.diff_for(buffer_id).map(|diff| diff.entity_id()), + Some(diff.entity_id()), + ); + let diff = snapshot + .diff_for_buffer_id(buffer_id) + .expect("fixture diff should remain attached"); + assert!(diff.base_text_exists()); + assert_eq!(diff.base_text().text(), *base_text); + } + }); + editor + } + + fn flush_outline_tasks(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.executor().advance_clock(UPDATE_DEBOUNCE * 3); + cx.run_until_parked(); + } + + fn select_directory( + panel: &Entity, + worktree_id: WorktreeId, + path: &RelPath, + cx: &mut VisualTestContext, + ) { + panel.update_in(cx, |panel, window, cx| { + let mut entries = panel + .cached_entries + .iter() + .filter(|cached| match &cached.entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id + && directory.entry.path.as_ref() == path + } + PanelEntry::FoldedDirs(directories) => { + directories.worktree_id == worktree_id + && directories + .entries + .last() + .is_some_and(|entry| entry.path.as_ref() == path) + } + _ => false, + }); + let entry = entries + .next() + .expect("directory should have a row") + .entry + .clone(); + assert!( + entries.next().is_none(), + "directory should have exactly one row" + ); + panel.select_entry(entry, true, window, cx); + }); + } + + fn select_file(panel: &Entity, buffer_id: BufferId, cx: &mut VisualTestContext) { + panel.update_in(cx, |panel, window, cx| { + let mut entries = panel.cached_entries.iter().filter(|cached| { + matches!(&cached.entry, PanelEntry::Fs(FsEntry::File(file)) if file.buffer_id == buffer_id) + }); + let entry = entries.next().expect("file should have a row").entry.clone(); + assert_eq!(entries.count(), 0, "file should have exactly one row"); + panel.select_entry(entry, true, window, cx); + }); + } + + fn assert_directory_paths( + panel: &Entity, + worktree_id: WorktreeId, + expected_unfolded: &[&str], + expected_collapsed: &[&str], + cx: &mut VisualTestContext, + ) { + panel.read_with(cx, |panel, _| { + assert_eq!( + panel.unfolded_dirs, + HashMap::from_iter([( + worktree_id, + expected_unfolded + .iter() + .map(|path| Arc::::from(rel_path(path))) + .collect::>(), + )]), + ); + assert_eq!( + panel + .collapsed_entries + .iter() + .filter_map(|entry| match entry { + CollapsedEntry::Dir(worktree_id, path) => + Some((*worktree_id, path.clone())), + _ => None, + }) + .collect::>(), + expected_collapsed + .iter() + .map(|path| (worktree_id, Arc::::from(rel_path(path)))) + .collect::>(), + ); + }); + } + #[gpui::test] async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) { init_test(cx); @@ -8149,6 +11135,52 @@ search: | Field | Meaning « » | search: | Field | Meaning « »|" ); }); + + let entries_capacity = outline_panel.update_in(cx, |panel, window, cx| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + let entry = panel + .cached_entries + .get(1) + .expect("second search result") + .entry + .clone(); + panel.toggle_expanded(&entry, window, cx); + panel.scroll_editor_to_entry(&entry, true, false, window, cx); + panel.cached_entries.capacity() + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); + assert_eq!( + panel.selected_entry(), + panel.cached_entries.get(1).map(|cached| &cached.entry) + ); + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("Field", None, true, window, cx) + }) + .await + .expect("search should complete"); + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_cached_entries(None, window, cx); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.update(cx, |panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(panel, cx), + &panel.cached_entries, + panel.selected_entry(), + cx + ), + "search: | «Field» | Meaning | <==== selected" + ); + }); } #[gpui::test] @@ -8161,7 +11193,14 @@ search: | Field | Meaning « »|" root, json!({ "src": { - "lib.rs": "struct Foo {\n bar: u32,\n baz: String,\n}\n", + "lib.rs": indoc!( + " + struct Foo { + bar: u32, + baz: String, + } + " + ), } }), ) @@ -8252,7 +11291,7 @@ search: | Field | Meaning « »|" }); }); - let _editor = workspace + let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/src/lib.rs")), @@ -8305,9 +11344,7 @@ outline: struct Foo <==== selected }, ); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -8328,6 +11365,67 @@ outline: struct Foo <==== selected ); }); + for _ in 0..2 { + outline_panel.update(cx, |panel, _| panel.outline_fetch_tasks.clear()); + editor.update(cx, |_, cx| cx.emit(EditorEvent::OutlineSymbolsChanged)); + cx.run_until_parked(); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE - Duration::from_millis(1)); + editor.update(cx, |_, cx| { + for _ in 0..8 { + cx.emit(EditorEvent::OutlineSymbolsChanged); + } + }); + cx.run_until_parked(); + outline_panel.read_with(cx, |panel, _| { + assert!(panel.outline_fetch_tasks.is_empty()); + assert!( + panel + .buffers + .values() + .all(|buffer| { matches!(buffer.outlines, OutlineState::Outlines(_)) }) + ); + }); + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.outline_fetch_tasks.len(), 1); + assert!(panel.outline_fetch_tasks.values().all(Task::is_ready)); + assert!( + panel + .buffers + .values() + .all(|buffer| { matches!(buffer.outlines, OutlineState::Outlines(_)) }) + ); + }); + } + + let entries_capacity = outline_panel.update(cx, |panel, _| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + panel.cached_entries.capacity() + }); + for (row, expected_symbol) in [(1, "bar"), (2, "lsp_only_field"), (0, "struct Foo")] { + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + let point = language::Point::new(row, 4); + selections.select_ranges([point..point]); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); + let selected_symbol = panel.selected_entry().and_then(|entry| match entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => { + Some(outline.text.as_ref()) + } + _ => None, + }); + assert_eq!(selected_symbol, Some(expected_symbol)); + }); + } + // Step 3: Switch back to tree-sitter cx.update(|_, cx| { settings::SettingsStore::update_global( @@ -8340,9 +11438,7 @@ outline: struct Foo <==== selected }, ); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -8362,6 +11458,14 @@ outline: struct Foo <==== selected "Step 3: tree-sitter outlines should be restored" ); }); + + editor.update(cx, |_, cx| cx.emit(EditorEvent::OutlineSymbolsChanged)); + cx.run_until_parked(); + outline_panel.update_in(cx, |panel, window, cx| { + assert!(!panel.lsp_outline_refresh_task.is_ready()); + panel.clear_previous(window, cx); + assert!(panel.lsp_outline_refresh_task.is_ready()); + }); } #[gpui::test] @@ -8412,17 +11516,19 @@ outline: struct Foo <==== selected .downcast::() .unwrap(); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; - outline_panel.update_in(cx, |panel, window, cx| { - panel.update_non_fs_items(window, cx); - panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + let entries_capacity = outline_panel.update(cx, |panel, _| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + panel.cached_entries.capacity() }); // Helper function to move the cursor to the first column of a given row // and return the selected outline entry's text. let move_cursor_and_get_selection = - |row: u32, cx: &mut VisualTestContext| -> Option { + async |row: u32, cx: &mut VisualTestContext| -> Option { cx.update(|window, cx| { editor.update(cx, |editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -8433,9 +11539,10 @@ outline: struct Foo <==== selected }); }); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.read_with(cx, |panel, _cx| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); panel.selected_entry().and_then(|entry| match entry { PanelEntry::Outline(OutlineEntry::Outline(outline)) => { Some(outline.text.clone()) @@ -8446,28 +11553,48 @@ outline: struct Foo <==== selected }; assert_eq!( - move_cursor_and_get_selection(0, cx).as_deref(), + move_cursor_and_get_selection(0, cx).await.as_deref(), Some("# Section A"), "Cursor at row 0 should select '# Section A'" ); assert_eq!( - move_cursor_and_get_selection(2, cx).as_deref(), + move_cursor_and_get_selection(2, cx).await.as_deref(), Some("## Sub Section A"), "Cursor at row 2 should select '## Sub Section A'" ); assert_eq!( - move_cursor_and_get_selection(4, cx).as_deref(), + move_cursor_and_get_selection(4, cx).await.as_deref(), Some("## Sub Section B"), "Cursor at row 4 should select '## Sub Section B'" ); assert_eq!( - move_cursor_and_get_selection(6, cx).as_deref(), + move_cursor_and_get_selection(6, cx).await.as_deref(), Some("# Section B"), "Cursor at row 6 should select '# Section B'" ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .outline_panel + .get_or_insert_default() + .auto_reveal_entries = Some(false); + }); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + let mut notifications = cx.notifications(&outline_panel); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([language::Point::new(0, 0)..language::Point::new(0, 0)]); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert_eq!(notifications.next().now_or_never(), None); } #[gpui::test] @@ -8511,7 +11638,7 @@ outline: struct Foo <==== selected &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9051,7 +12178,7 @@ rust-analyzer/ ], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -9081,6 +12208,87 @@ test/ and without outlines that lie outside every excerpt" ); }); + + let count = 256_u32; + let text = (0..count) + .map(|index| format!("fn needle_{index:04}() {{}}\n\n\n\n\n\n\n\n")) + .collect::(); + buffer_one.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), text)], None, cx); + }); + let excerpts = (0..count) + .step_by(2) + .map(|index| language::Point::new(index * 8, 0)..language::Point::new(index * 8 + 1, 0)) + .collect::>(); + let editor = add_multi_buffer_editor(&workspace, &project, &[(&buffer_one, excerpts)], cx); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!( + panel + .cached_entries + .iter() + .filter_map(|cached| match &cached.entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => + Some(outline.text.to_string()), + _ => None, + }) + .collect::>(), + (0..count) + .step_by(2) + .map(|index| format!("fn needle_{index:04}")) + .collect::>(), + ); + }); + let search_bar = cx.update(|window, cx| { + cx.new(|cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }) + }); + for dense in [false, true] { + if dense { + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_buffer( + buffer_one.clone(), + [language::Point::default()..buffer_one.read(cx).max_point()], + 0, + cx, + ); + }); + }); + } + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("needle", None, true, window, cx) + }) + .await + .unwrap(); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.update(cx, |panel, cx| { + let snapshot = snapshot(panel, cx); + assert_eq!( + snapshot.excerpts().count(), + if dense { 1 } else { count as usize / 2 } + ); + let mut expected = indoc!( + " + test/ + src/ + one.rs" + ) + .to_string(); + for index in (0..count).step_by(if dense { 1 } else { 2 }) { + expected.push_str(&format!("\n search: fn «needle»_{index:04}() {{}}")); + } + assert_eq!( + display_entries(&project, &snapshot, &panel.cached_entries, None, cx), + expected + ); + }); + } } #[gpui::test] @@ -9124,7 +12332,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9246,7 +12454,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update_in(cx, |panel, window, cx| { panel.filter_editor.update(cx, |filter_editor, cx| { @@ -9382,7 +12590,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, cx| { assert_eq!( @@ -9468,7 +12676,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9484,7 +12692,7 @@ test/ settings.multi_buffer_hide_symbols = Some(false); settings.expand_outlines_with_depth = Some(0); }); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, cx| { assert_eq!( diff --git a/crates/panel/Cargo.toml b/crates/panel/Cargo.toml deleted file mode 100644 index 4e7c81804d..0000000000 --- a/crates/panel/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "panel" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/panel.rs" - -[dependencies] -gpui.workspace = true -ui.workspace = true -workspace.workspace = true diff --git a/crates/panel/src/panel.rs b/crates/panel/src/panel.rs deleted file mode 100644 index e3f9a3e876..0000000000 --- a/crates/panel/src/panel.rs +++ /dev/null @@ -1,35 +0,0 @@ -use gpui::actions; -use ui::prelude::*; - -actions!( - panel, - [ - /// Navigates to the next tab in the panel. - NextPanelTab, - /// Navigates to the previous tab in the panel. - PreviousPanelTab - ] -); - -pub trait PanelHeader: workspace::Panel {} - -/// Implement this trait to enable a panel to have tabs. -pub trait PanelTabs: PanelHeader { - /// Returns the index of the currently selected tab. - fn selected_tab(&self, cx: &mut App) -> usize; - /// Selects the tab at the given index. - fn select_tab(&self, cx: &mut App, index: usize); - /// Moves to the next tab. - fn next_tab(&self, _: NextPanelTab, cx: &mut App) -> Self; - /// Moves to the previous tab. - fn previous_tab(&self, _: PreviousPanelTab, cx: &mut App) -> Self; -} - -#[derive(IntoElement)] -pub struct PanelTab {} - -impl RenderOnce for PanelTab { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - div() - } -} diff --git a/crates/path/Cargo.toml b/crates/path/Cargo.toml index c50e6ef97c..f1668b78ce 100644 --- a/crates/path/Cargo.toml +++ b/crates/path/Cargo.toml @@ -7,6 +7,7 @@ license = "Apache-2.0" [lib] path = "src/path.rs" +doctest = false [features] test-support = [] diff --git a/crates/paths/Cargo.toml b/crates/paths/Cargo.toml index 31eaaa31a9..aed676abfe 100644 --- a/crates/paths/Cargo.toml +++ b/crates/paths/Cargo.toml @@ -13,6 +13,7 @@ test-support = [] [lib] path = "src/paths.rs" +doctest = false [dependencies] const_format.workspace = true diff --git a/crates/picker/src/persistence.rs b/crates/picker/src/persistence.rs index 59de6cf8bf..2254e94087 100644 --- a/crates/picker/src/persistence.rs +++ b/crates/picker/src/persistence.rs @@ -69,9 +69,7 @@ pub(crate) fn try_load_shape( return Ok(None); }; - let shape = serde_json::from_str::(&shape) - .context("Could not deserialize loaded picker shape from persistence")? - .into_centered(); + let shape = parse_shape(&shape)?; Ok(Some(Shape::HorizontallyCentered(shape))) } @@ -90,6 +88,13 @@ pub(crate) fn load_last_preview_layout( parse_layout(&last_layout) } +#[inline(never)] +fn parse_shape(json: &str) -> anyhow::Result { + Ok(serde_json::from_str::(json) + .context("Could not deserialize loaded picker shape from persistence")? + .into_centered()) +} + fn shape_key(picker_delegate: &'static str, preview_layout: Option) -> String { format!("{picker_delegate}/{}", layout_as_str(preview_layout)) } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 3a93c7cd75..b05516775c 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1261,6 +1261,9 @@ impl Picker { delegate_pending_update_matches.await; this.update_in(cx, |this, window, cx| { this.matches_updated(scroll_behavior, window, cx); + if let Some(secondary) = this.confirm_on_update.take() { + this.do_confirm(secondary, window, cx); + } }) }), }); @@ -1306,9 +1309,6 @@ impl Picker { { preview.update(update, window, cx); } - if let Some(secondary) = self.confirm_on_update.take() { - self.do_confirm(secondary, window, cx); - } cx.notify(); } @@ -1648,6 +1648,8 @@ mod tests { items: Vec, selected_index: usize, confirmed_index: Rc>>, + confirmations: Vec<(usize, bool)>, + match_update: Option<(Duration, usize)>, supports_multi_select: bool, selected_items: Vec, multi_confirmed: Rc>>>, @@ -1659,6 +1661,8 @@ mod tests { items, selected_index: 0, confirmed_index: Rc::new(Cell::new(None)), + confirmations: Vec::new(), + match_update: None, supports_multi_select: false, selected_items: Vec::new(), multi_confirmed: Rc::new(Cell::new(None)), @@ -1712,18 +1716,30 @@ mod tests { &mut self, _query: String, _window: &mut Window, - _cx: &mut Context>, + cx: &mut Context>, ) -> Task<()> { - Task::ready(()) + let Some((delay, selected_index)) = self.match_update.take() else { + return Task::ready(()); + }; + let timer = cx.background_executor().timer(delay); + cx.spawn(async move |picker, cx| { + timer.await; + picker + .update(cx, |picker, _| { + picker.delegate.selected_index = selected_index; + }) + .expect("picker should exist until matches finish"); + }) } fn confirm( &mut self, - _secondary: bool, + secondary: bool, _window: &mut Window, _cx: &mut Context>, ) { self.confirmed_index.set(Some(self.selected_index)); + self.confirmations.push((self.selected_index, secondary)); } fn supports_multi_select(&self) -> bool { @@ -1792,6 +1808,55 @@ mod tests { }); } + #[gpui::test] + async fn test_refresh_waits_for_latest_matches_before_confirming(cx: &mut TestAppContext) { + init_test(cx); + + for secondary in [false, true] { + let (picker, cx) = cx.add_window_view(|window, cx| { + Picker::uniform_list(TestDelegate::new(vec![true, true, true]), window, cx) + }); + cx.run_until_parked(); + + picker.update_in(cx, |picker, window, cx| { + picker.delegate.match_update = Some((Duration::from_millis(100), 1)); + picker.set_query("new query", window, cx); + }); + cx.run_until_parked(); + + picker.update_in(cx, |picker, window, cx| { + if secondary { + picker.secondary_confirm(&menu::SecondaryConfirm, window, cx); + } else { + picker.confirm(&menu::Confirm, window, cx); + } + assert_eq!(picker.confirm_on_update, Some(secondary)); + assert_eq!(picker.delegate.confirmations, Vec::new()); + + picker.delegate.match_update = Some((Duration::from_millis(50), 2)); + picker.refresh(window, cx); + assert_eq!(picker.delegate.confirmations, Vec::new()); + assert_eq!(picker.confirm_on_update, Some(secondary)); + }); + cx.run_until_parked(); + + cx.executor().advance_clock(Duration::from_millis(50)); + cx.run_until_parked(); + picker.read_with(cx, |picker, _| { + assert_eq!(picker.delegate.confirmations, vec![(2, secondary)]); + assert_eq!(picker.confirm_on_update, None); + assert!(picker.pending_update_matches.is_none()); + }); + + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + picker.read_with(cx, |picker, _| { + assert_eq!(picker.delegate.selected_index(), 2); + assert_eq!(picker.delegate.confirmations, vec![(2, secondary)]); + }); + } + } + #[gpui::test] async fn test_clicking_non_selectable_item_does_not_confirm(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/picker/src/render/window_controls.rs b/crates/picker/src/render/window_controls.rs index cc7fbf2dc7..0fdb62a317 100644 --- a/crates/picker/src/render/window_controls.rs +++ b/crates/picker/src/render/window_controls.rs @@ -58,7 +58,9 @@ use std::{any::type_name, marker::PhantomData}; -use gpui::{ClickEvent, Context, CursorStyle, DragMoveEvent, MouseButton, Point, Styled, Window}; +use gpui::{ + ClickEvent, Context, CursorStyle, DragMoveEvent, MouseButton, Point, Stateful, Styled, Window, +}; use ui::prelude::*; use crate::shape::{Centered, PositionAndShape, Shape, SizeBounds}; @@ -430,50 +432,31 @@ impl Picker { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - div() - .id(S::id()) - .absolute() - .cursor(side.cursor()) - .map(|this| { - side.position( - this, - self.shape.clamped_position_and_size( - self.preview_layout_rendered(window), - &self.size_bounds, - window, - ), + render_resize_handle( + side, + self.shape, + &self.size_bounds, + self.preview_layout_rendered(window), + window, + ) + .on_drag_move::>(cx.listener( + move |picker, event: &DragMoveEvent>, window, cx| { + let drag = event.drag(cx); + let delta = event.event.position - drag.mouse_pos_before; + let mut working = side.current_position_and_shape(drag.shape_before, delta); + side.clamp( + &mut working, + &picker.size_bounds, + picker.preview_layout_rendered(window), window, - ) - }) - .block_mouse_except_scroll() - .on_mouse_down(MouseButton::Left, do_nothing) - .on_drag( - ResizeDrag::::start_new( - self.shape, - &self.size_bounds, - self.preview_layout_rendered(window), - window, - ), - |_, _, _, cx| cx.new(|_| DragPreview), - ) - .on_drag_move::>(cx.listener( - move |this, event: &DragMoveEvent>, window, cx| { - let drag = event.drag(cx); - let delta = event.event.position - drag.mouse_pos_before; - let mut working = side.current_position_and_shape(drag.shape_before, delta); - side.clamp( - &mut working, - &this.size_bounds, - this.preview_layout_rendered(window), - window, - ); - this.shape = Shape::Resizing(working); - cx.notify(); - }, - )) - .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { - this.reset_size_to_default_on_double_click(side, event, window, cx) - })) + ); + picker.shape = Shape::Resizing(working); + cx.notify(); + }, + )) + .on_click(cx.listener(move |picker, event: &ClickEvent, window, cx| { + picker.reset_size_to_default_on_double_click(side, event, window, cx) + })) } fn reset_size_to_default_on_double_click( @@ -497,6 +480,34 @@ impl Picker { } } +#[inline(never)] +#[track_caller] +fn render_resize_handle( + side: S, + shape: Shape, + bounds: &SizeBounds, + layout: Option, + window: &mut Window, +) -> Stateful
{ + div() + .id(S::id()) + .absolute() + .cursor(side.cursor()) + .map(|handle| { + side.position( + handle, + shape.clamped_position_and_size(layout, bounds, window), + window, + ) + }) + .block_mouse_except_scroll() + .on_mouse_down(MouseButton::Left, do_nothing) + .on_drag( + ResizeDrag::::start_new(shape, bounds, layout, window), + |_, _, _, cx| cx.new(|_| DragPreview), + ) +} + fn do_nothing(_: &gpui::MouseDownEvent, window: &mut Window, cx: &mut App) { window.prevent_default(); cx.stop_propagation(); diff --git a/crates/picker_preview/Cargo.toml b/crates/picker_preview/Cargo.toml index 12d7aec9cc..874148804b 100644 --- a/crates/picker_preview/Cargo.toml +++ b/crates/picker_preview/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/picker_preview.rs" doctest = false +test = false [dependencies] diff --git a/crates/platform_title_bar/Cargo.toml b/crates/platform_title_bar/Cargo.toml index 8497877d04..8f154bc897 100644 --- a/crates/platform_title_bar/Cargo.toml +++ b/crates/platform_title_bar/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/platform_title_bar.rs" doctest = false +test = false [dependencies] gpui.workspace = true diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 937a123b15..20f6cd6abc 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [lib] path = "src/project.rs" doctest = false -test = false [[test]] name = "integration" @@ -38,6 +37,7 @@ aho-corasick.workspace = true anyhow.workspace = true askpass.workspace = true async-channel.workspace = true +async-lock.workspace = true async-trait.workspace = true base64.workspace = true buffer_diff.workspace = true diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 67550e8108..7ab885daba 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -32,6 +32,7 @@ pub struct RegistryAgentMetadata { pub version: SharedString, pub repository: Option, pub website: Option, + pub license_url: Option, pub icon_path: Option, } @@ -88,6 +89,10 @@ impl RegistryAgent { self.metadata().website.as_ref() } + pub fn license_url(&self) -> Option<&SharedString> { + self.metadata().license_url.as_ref() + } + pub fn icon_path(&self) -> Option<&SharedString> { self.metadata().icon_path.as_ref() } @@ -396,6 +401,7 @@ async fn build_registry_agents( version: entry.version.into(), repository: entry.repository.map(Into::into), website: entry.website.map(Into::into), + license_url: entry.license_url.map(Into::into), icon_path, }; @@ -643,6 +649,8 @@ struct RegistryEntry { #[serde(default)] website: Option, #[serde(default)] + license_url: Option, + #[serde(default)] icon: Option, distribution: RegistryDistribution, } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 63806e9b7c..8b10f8bb54 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1713,7 +1713,7 @@ mod tests { http_client: Arc, sha256: Option, ) -> LocalRegistryArchiveAgent { - let fs: Arc = Arc::new(fs::RealFs::new(None, cx.executor())); + let fs: Arc = fs::RealFs::new(None, cx.executor()); let target = RegistryTargetConfig { archive: TEST_ARCHIVE_URL.to_string(), cmd: "./agent".to_string(), @@ -1765,6 +1765,7 @@ mod tests { version: SharedString::from(version.to_string()), repository: None, website: None, + license_url: None, icon_path: None, }, package: id, diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index b9fb887d49..3f96a1da96 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -1404,7 +1404,21 @@ impl BufferStore { let new_path = file.path.clone(); buffer.file_updated(Arc::new(file), cx); - if old_file.as_ref().is_none_or(|old| *old.path() != new_path) { + if old_file.as_ref().is_none_or(|old| { + if *old.path() == new_path { + return false; + } + + let old_path = ProjectPath { + worktree_id: old.worktree_id(cx), + path: old.path().clone(), + }; + + if this.path_to_buffer_id.get(&old_path) == Some(&buffer_id) { + this.path_to_buffer_id.remove(&old_path); + } + true + }) { Some(old_file) } else { None diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 6aa115d607..90b3c61046 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -2863,6 +2863,38 @@ impl Session { }) } + /// Evaluates an expression to obtain its full value for copying. + pub fn evaluate_variable_value( + &mut self, + expression: String, + frame_id: Option, + cx: &mut Context, + ) -> Task> { + let context = if self + .capabilities + .supports_clipboard_context + .unwrap_or_default() + { + EvaluateArgumentsContext::Clipboard + } else { + EvaluateArgumentsContext::Variables + }; + let request = self.request( + EvaluateCommand { + expression, + frame_id, + context: Some(context), + source: None, + }, + |_, response, _| response.ok(), + cx, + ); + cx.background_spawn(async move { + let response = request.await?; + Some(response.result) + }) + } + pub fn location( &mut self, reference: u64, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index fe62b0ea04..afcb419d1e 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -15,6 +15,7 @@ use crate::{ }; use anyhow::{Context as _, Result, anyhow, bail}; use askpass::{AskPassDelegate, EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs}; +use async_lock::Semaphore; use buffer_diff::{ BufferDiff, DiffHunk, DiffHunkSecondaryStatus, DiffOperations, PendingHunk, PendingSense, }; @@ -115,11 +116,14 @@ pub struct GitStore { diffs: HashMap>, buffer_ids_by_index_text_buffer_id: HashMap, shared_diffs: HashMap>, + blob_read_limiter: Arc, _subscriptions: Vec, } const MIN_PARKED_REPOSITORY_DEPTH: usize = 2; +pub const MAX_CONCURRENT_BLOB_READS: usize = 16; + #[derive(Clone, Debug)] pub struct ParkedRepository { worktree_id: WorktreeId, @@ -683,6 +687,7 @@ pub struct Repository { unshallow_state: UnshallowState, commit_message_buffer: Option>, git_store: WeakEntity, + blob_read_limiter: Arc, // For a local repository, holds paths that have had worktree events since the last status scan completed, // and that should be examined during the next status scan. paths_needing_status_update: Vec>, @@ -1019,6 +1024,7 @@ impl GitStore { _subscriptions, loading_diffs: HashMap::default(), shared_diffs: HashMap::default(), + blob_read_limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_BLOB_READS)), diffs: HashMap::default(), buffer_ids_by_index_text_buffer_id: HashMap::default(), } @@ -2912,6 +2918,7 @@ impl GitStore { let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release)); let git_store = cx.weak_entity(); + let blob_read_limiter = self.blob_read_limiter.clone(); let repo = cx.new(|cx| { let mut repo = Repository::local( id, @@ -2923,6 +2930,7 @@ impl GitStore { fs, is_trusted, git_store, + blob_read_limiter, cx, ); if let Some(updates_tx) = updates_tx.as_ref() { @@ -3391,6 +3399,7 @@ impl GitStore { .map(|p| Path::new(p).into()); let mut repo_subscription = None; + let blob_read_limiter = this.blob_read_limiter.clone(); let repo = this.repositories.entry(id).or_insert_with(|| { let git_store = cx.weak_entity(); let repo = cx.new(|cx| { @@ -3403,6 +3412,7 @@ impl GitStore { ProjectId(update.project_id), client, git_store, + blob_read_limiter, cx, ) }); @@ -6530,6 +6540,7 @@ impl Repository { fs: Arc, is_trusted: bool, git_store: WeakEntity, + blob_read_limiter: Arc, cx: &mut Context, ) -> Self { let snapshot = RepositorySnapshot::empty( @@ -6544,6 +6555,7 @@ impl Repository { let mut repo = Repository { this: cx.weak_entity(), git_store, + blob_read_limiter, snapshot, unshallow_state: UnshallowState::default(), pending_ops: Default::default(), @@ -6575,6 +6587,7 @@ impl Repository { project_id: ProjectId, client: AnyProtoClient, git_store: WeakEntity, + blob_read_limiter: Arc, cx: &mut Context, ) -> Self { let snapshot = RepositorySnapshot::empty( @@ -6597,6 +6610,7 @@ impl Repository { unshallow_state: UnshallowState::default(), commit_message_buffer: None, git_store, + blob_read_limiter, pending_ops: Default::default(), paths_needing_status_update: Default::default(), job_sender, @@ -8413,7 +8427,7 @@ impl Repository { is_dir: bool, ) -> oneshot::Receiver> { let id = self.id; - let repository_dir = self.snapshot.repository_dir_abs_path.clone(); + let repository_dir = self.snapshot.common_dir_abs_path.clone(); let path_display = repo_path.as_ref().display(PathStyle::Unix); let path = repo_path.as_unix_str().to_owned(); let file_path_str = if is_dir { @@ -10381,10 +10395,15 @@ impl Repository { }) } - fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task> { + /// Bypasses the serial git job queue: blobs are content-addressed, so this read + /// doesn't depend on the status snapshot, and a diff issues one per changed file. + fn load_blob_content(&self, oid: Oid, cx: &App) -> Task> { let repository_id = self.snapshot.id; - let rx = self.send_job("load_blob_content", None, move |state, _| async move { - match state { + let repository_state = self.repository_state.clone(); + let blob_read_limiter = self.blob_read_limiter.clone(); + cx.background_spawn(async move { + let _permit = blob_read_limiter.acquire_arc().await; + match repository_state.await.map_err(|err| anyhow::anyhow!(err))? { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { decode_git_text(backend.load_blob_content(oid).await?) } @@ -10399,8 +10418,7 @@ impl Repository { Ok(response.content) } } - }); - cx.spawn(|_: &mut AsyncApp| async move { rx.await? }) + }) } fn paths_changed( @@ -11361,7 +11379,7 @@ impl Repository { mod tests { use super::*; use crate::Project; - use fs::{FakeFs, Fs}; + use fs::{FakeBlobReadGate, FakeFs, Fs}; use git::repository::{RepoPath, repo_path}; use gpui::proptest::prelude::*; use gpui::{TestAppContext, UpdateGlobal}; @@ -11746,29 +11764,25 @@ mod tests { #[gpui::test] async fn test_merge_base_status_uses_worktree_contents(cx: &mut TestAppContext) { - use util::rel_path::rel_path; + use util::{path, rel_path::rel_path}; init_test(cx); + let project_root = Path::new(path!("/project")); + let dot_git = project_root.join(".git"); let fs = FakeFs::new(cx.executor()); fs.insert_tree( - Path::new("/project"), + project_root, json!({ ".git": {}, "committed.txt": "base\n", }), ) .await; - fs.set_head_and_index_for_repo( - Path::new("/project/.git"), - &[("committed.txt", "head\n".into())], - ); - fs.set_merge_base_content_for_repo( - Path::new("/project/.git"), - &[("committed.txt", "base\n".into())], - ); + fs.set_head_and_index_for_repo(&dot_git, &[("committed.txt", "head\n".into())]); + fs.set_merge_base_content_for_repo(&dot_git, &[("committed.txt", "base\n".into())]); - let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let project = Project::test(fs.clone(), [project_root], cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; @@ -11836,10 +11850,7 @@ mod tests { assert_eq!(display_snapshot.statuses_by_path.iter().count(), 0); }); - fs.set_merge_base_content_for_repo( - Path::new("/project/.git"), - &[("committed.txt", "head\n".into())], - ); + fs.set_merge_base_content_for_repo(&dot_git, &[("committed.txt", "head\n".into())]); let repository = project.read_with(cx, |project, cx| project.active_repository(cx).unwrap()); let branches = repository.read_with(cx, |repository, _| { @@ -11883,6 +11894,95 @@ mod tests { assert!(weak_display_diff_list.upgrade().is_none()); } + async fn setup_gated_blob_reads( + cx: &mut TestAppContext, + count: usize, + ) -> (FakeBlobReadGate, Entity, Vec) { + use util::path; + + let project_root = Path::new(path!("/project")); + let dot_git = project_root.join(".git"); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(project_root, json!({ ".git": {} })).await; + + let entries = (0..count) + .map(|index| (format!("f{index:02}.txt"), format!("blob-{index:02}\n"))) + .collect::>(); + let entry_refs = entries + .iter() + .map(|(name, content)| (name.as_str(), content.clone())) + .collect::>(); + let oids = fs.set_merge_base_content_for_repo(&dot_git, &entry_refs); + let gate = fs.install_blob_read_gate_for_repo(&dot_git); + + let project = Project::test(fs, [project_root], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let repository = + project.read_with(cx, |project, cx| project.active_repository(cx).unwrap()); + (gate, repository, oids) + } + + #[gpui::test] + async fn test_blob_reads_are_bounded(cx: &mut TestAppContext) { + init_test(cx); + let (gate, repository, oids) = + setup_gated_blob_reads(cx, MAX_CONCURRENT_BLOB_READS + 4).await; + + let reads = oids + .iter() + .map(|oid| { + repository.update(cx, |repository, cx| repository.load_blob_content(*oid, cx)) + }) + .collect::>(); + cx.run_until_parked(); + + assert_eq!(gate.peak_concurrent(), MAX_CONCURRENT_BLOB_READS); + assert_eq!(gate.waiting(), MAX_CONCURRENT_BLOB_READS); + + gate.open(); + cx.run_until_parked(); + for read in reads { + read.await.unwrap(); + } + } + + #[gpui::test] + async fn test_cancelled_blob_read_releases_permit(cx: &mut TestAppContext) { + init_test(cx); + let (gate, repository, oids) = + setup_gated_blob_reads(cx, MAX_CONCURRENT_BLOB_READS + 1).await; + + let mut holding = oids[..MAX_CONCURRENT_BLOB_READS] + .iter() + .map(|oid| { + repository.update(cx, |repository, cx| repository.load_blob_content(*oid, cx)) + }) + .collect::>(); + cx.run_until_parked(); + assert_eq!(gate.waiting(), MAX_CONCURRENT_BLOB_READS); + + // One more read can't get a permit, so it never reaches the backend. + let blocked_oid = oids[MAX_CONCURRENT_BLOB_READS]; + let _blocked = repository.update(cx, |repository, cx| { + repository.load_blob_content(blocked_oid, cx) + }); + cx.run_until_parked(); + assert!(!gate.is_waiting(blocked_oid)); + + let cancelled_oid = oids[0]; + drop(holding.remove(0)); + cx.run_until_parked(); + assert!(gate.is_waiting(blocked_oid)); + assert!(!gate.is_waiting(cancelled_oid)); + assert_eq!(gate.waiting(), MAX_CONCURRENT_BLOB_READS); + assert_eq!(gate.peak_concurrent(), MAX_CONCURRENT_BLOB_READS); + + gate.open(); + cx.run_until_parked(); + } + #[gpui::test] async fn test_append_pattern_to_ignore_file_creates_and_deduplicates(cx: &mut TestAppContext) { let fs: Arc = FakeFs::new(cx.executor()); diff --git a/crates/project/src/git_store/diff_buffer_list.rs b/crates/project/src/git_store/diff_buffer_list.rs index ee0421b587..b338f69a4c 100644 --- a/crates/project/src/git_store/diff_buffer_list.rs +++ b/crates/project/src/git_store/diff_buffer_list.rs @@ -1,7 +1,7 @@ use anyhow::Result; use buffer_diff::BufferDiff; use collections::{HashMap, HashSet}; -use futures::StreamExt; +use futures::{FutureExt, StreamExt, future::LocalBoxFuture}; use git::{ repository::RepoPath, status::{DiffTreeType, FileStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus}, @@ -390,8 +390,10 @@ impl DiffBufferList { repo: Entity, git_store: Entity, cx: &Context, - ) -> Task> { - cx.spawn(async move |_, cx| { + ) -> LocalBoxFuture<'static, Result> { + let mut cx = cx.to_async(); + async move { + let cx = &mut cx; let buffer = git_store .update(cx, |git_store, cx| { git_store.buffer_store.update(cx, |buffer_store, cx| { @@ -466,7 +468,8 @@ impl DiffBufferList { diff: changes, conflict_set, }) - }) + } + .boxed_local() } } @@ -536,9 +539,9 @@ pub struct LoadedDiffBuffer { pub conflict_set: Option>, } -#[derive(Debug)] pub struct DiffBuffer { pub repo_path: RepoPath, pub file_status: FileStatus, - pub load: Task>, + /// Not started until polled, so the consumer controls load concurrency. + pub load: LocalBoxFuture<'static, Result>, } diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index ddc74a4a32..eba7ab4861 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -6,7 +6,7 @@ use crate::{ InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location, LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, ProjectPath, ProjectTransaction, PulledDiagnostics, ResolveState, - lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore}, + lsp_store::{LanguageServerToQuery, LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore}, }; use anyhow::{Context as _, Result}; use async_trait::async_trait; @@ -99,29 +99,19 @@ pub trait LspCommand: 'static + Sized + Send + std::fmt::Debug { None } - fn to_lsp_params_or_response( - &self, - path: &Path, - buffer: &Buffer, - language_server: &Arc, - cx: &App, - ) -> Result< - LspParamsOrResponse<::Params, Self::Response>, - > { - if self.check_capabilities(language_server.adapter_server_capabilities()) { - Ok(LspParamsOrResponse::Params(self.to_lsp( - path, - buffer, - language_server, - cx, - )?)) - } else { - Ok(LspParamsOrResponse::Response(Default::default())) - } + fn language_server_to_query(&self) -> LanguageServerToQuery { + LanguageServerToQuery::FirstCapable } - /// When false, `to_lsp_params_or_response` default implementation will return the default response. - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool; + /// Returns whether the given static or dynamic capability supports this request. + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool; + + fn response_without_request<'a, I>(&self, _applicable_capabilities: I) -> Option + where + I: Iterator>, + { + None + } fn to_lsp( &self, @@ -168,11 +158,6 @@ pub trait LspCommand: 'static + Sized + Send + std::fmt::Debug { fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result; } -pub enum LspParamsOrResponse { - Params(P), - Response(R), -} - #[derive(Debug)] pub(crate) struct PrepareRename { pub position: PointUtf16, @@ -183,6 +168,7 @@ pub(crate) struct PerformRename { pub position: PointUtf16, pub new_name: String, pub push_to_history: bool, + pub language_server_id: Option, } #[derive(Debug, Clone, Copy)] @@ -688,23 +674,18 @@ impl LspCommand for GetIncomingCalls { "Get incoming calls" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - capabilities - .server_capabilities - .call_hierarchy_provider - .as_ref() - .is_some_and(|capability| match capability { - lsp::CallHierarchyServerCapability::Simple(supported) => *supported, - lsp::CallHierarchyServerCapability::Options(_) => true, - }) + /// Follow-up requests operate on a server-issued item, so the server's support is + /// already proven and no capability gate applies. + fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + true } fn to_lsp( &self, path: &Path, buffer: &Buffer, - _language_server: &Arc, - _cx: &App, + _: &Arc, + _: &App, ) -> Result { Ok(lsp::CallHierarchyIncomingCallsParams { item: call_hierarchy_item_to_lsp(&self.item, path, buffer)?, @@ -835,23 +816,18 @@ impl LspCommand for GetOutgoingCalls { "Get outgoing calls" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - capabilities - .server_capabilities - .call_hierarchy_provider - .as_ref() - .is_some_and(|capability| match capability { - lsp::CallHierarchyServerCapability::Simple(supported) => *supported, - lsp::CallHierarchyServerCapability::Options(_) => true, - }) + /// Follow-up requests operate on a server-issued item, so the server's support is + /// already proven and no capability gate applies. + fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + true } fn to_lsp( &self, path: &Path, buffer: &Buffer, - _language_server: &Arc, - _cx: &App, + _: &Arc, + _: &App, ) -> Result { Ok(lsp::CallHierarchyOutgoingCallsParams { item: call_hierarchy_item_to_lsp(&self.item, path, buffer)?, @@ -973,45 +949,34 @@ impl LspCommand for PrepareRename { "Prepare rename" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .rename_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(enabled) => enabled, - OneOf::Right(options) => options.prepare_provider.unwrap_or(false), + OneOf::Left(enabled) => *enabled, + OneOf::Right(_) => true, }) } - fn to_lsp_params_or_response( + fn response_without_request<'a, I>( &self, - path: &Path, - buffer: &Buffer, - language_server: &Arc, - cx: &App, - ) -> Result> { - let rename_provider = language_server - .adapter_server_capabilities() - .server_capabilities - .rename_provider; - match rename_provider { - Some(lsp::OneOf::Right(RenameOptions { - prepare_provider: Some(true), - .. - })) => Ok(LspParamsOrResponse::Params(self.to_lsp( - path, - buffer, - language_server, - cx, - )?)), - Some(lsp::OneOf::Right(_)) => Ok(LspParamsOrResponse::Response( - PrepareRenameResponse::OnlyUnpreparedRenameSupported, - )), - Some(lsp::OneOf::Left(true)) => Ok(LspParamsOrResponse::Response( - PrepareRenameResponse::OnlyUnpreparedRenameSupported, - )), - _ => anyhow::bail!("Rename not supported"), - } + mut applicable_capabilities: I, + ) -> Option + where + I: Iterator>, + { + (!applicable_capabilities.any(|capabilities| { + matches!( + capabilities.server_capabilities.rename_provider.as_ref(), + Some(lsp::OneOf::Right(RenameOptions { + prepare_provider: Some(true), + .. + })) + ) + })) + .then_some(PrepareRenameResponse::OnlyUnpreparedRenameSupported) } fn to_lsp( @@ -1029,7 +994,7 @@ impl LspCommand for PrepareRename { message: Option, _: Entity, buffer: Entity, - _: LanguageServerId, + server_id: LanguageServerId, cx: AsyncApp, ) -> Result { buffer.read_with(&cx, |buffer, _| match message { @@ -1039,9 +1004,10 @@ impl LspCommand for PrepareRename { if buffer.clip_point_utf16(start, Bias::Left) == start.0 && buffer.clip_point_utf16(end, Bias::Left) == end.0 { - Ok(PrepareRenameResponse::Success( - buffer.anchor_after(start)..buffer.anchor_before(end), - )) + Ok(PrepareRenameResponse::Success { + range: buffer.anchor_after(start)..buffer.anchor_before(end), + language_server_id: Some(server_id), + }) } else { Ok(PrepareRenameResponse::InvalidPosition) } @@ -1050,7 +1016,10 @@ impl LspCommand for PrepareRename { let snapshot = buffer.snapshot(); let (range, _) = snapshot.surrounding_word(self.position, None); let range = snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end); - Ok(PrepareRenameResponse::Success(range)) + Ok(PrepareRenameResponse::Success { + range, + language_server_id: Some(server_id), + }) } None => Ok(PrepareRenameResponse::InvalidPosition), }) @@ -1096,12 +1065,16 @@ impl LspCommand for PrepareRename { _: &mut App, ) -> proto::PrepareRenameResponse { match response { - PrepareRenameResponse::Success(range) => proto::PrepareRenameResponse { + PrepareRenameResponse::Success { + range, + language_server_id, + } => proto::PrepareRenameResponse { can_rename: true, only_unprepared_rename_supported: false, start: Some(language::proto::serialize_anchor(&range.start)), end: Some(language::proto::serialize_anchor(&range.end)), version: serialize_version(buffer_version), + language_server_id: language_server_id.map(LanguageServerId::to_proto), }, PrepareRenameResponse::OnlyUnpreparedRenameSupported => proto::PrepareRenameResponse { can_rename: false, @@ -1109,6 +1082,7 @@ impl LspCommand for PrepareRename { start: None, end: None, version: vec![], + language_server_id: None, }, PrepareRenameResponse::InvalidPosition => proto::PrepareRenameResponse { can_rename: false, @@ -1116,6 +1090,7 @@ impl LspCommand for PrepareRename { start: None, end: None, version: vec![], + language_server_id: None, }, } } @@ -1137,7 +1112,12 @@ impl LspCommand for PrepareRename { message.start.and_then(deserialize_anchor), message.end.and_then(deserialize_anchor), ) { - Ok(PrepareRenameResponse::Success(start..end)) + Ok(PrepareRenameResponse::Success { + range: start..end, + language_server_id: message + .language_server_id + .map(LanguageServerId::from_proto), + }) } else { anyhow::bail!( "Missing start or end position in remote project PrepareRenameResponse" @@ -1165,12 +1145,13 @@ impl LspCommand for PerformRename { "Rename" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .rename_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(enabled) => enabled, + OneOf::Left(enabled) => *enabled, OneOf::Right(_) => true, }) } @@ -1222,12 +1203,13 @@ impl LspCommand for PerformRename { )), new_name: self.new_name.clone(), version: serialize_version(&buffer.version()), + language_server_id: self.language_server_id.map(LanguageServerId::to_proto), } } async fn from_proto( message: proto::PerformRename, - _: Entity, + lsp_store: Entity, buffer: Entity, mut cx: AsyncApp, ) -> Result { @@ -1240,11 +1222,25 @@ impl LspCommand for PerformRename { buffer.wait_for_version(deserialize_version(&message.version)) }) .await?; - Ok(Self { + let mut request = Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), new_name: message.new_name, push_to_history: false, - }) + language_server_id: message.language_server_id.map(LanguageServerId::from_proto), + }; + if let Some(server_id) = request.language_server_id { + // Only a store that runs the servers can judge the id; non-local stores forward + // the request upstream, where the authoritative store re-validates. + let server_is_capable = lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.as_local().is_none() + || lsp_store + .language_server_capable_of_lsp_request(&buffer, server_id, &request, cx) + }); + if !server_is_capable { + request.language_server_id = None; + } + } + Ok(request) } fn response_to_proto( @@ -1294,12 +1290,13 @@ impl LspCommand for GetDefinitions { "Get definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .definition_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -1396,12 +1393,13 @@ impl LspCommand for GetEditPredictionDefinitions { "Get edit prediction definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .definition_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -1496,12 +1494,13 @@ impl LspCommand for GetDeclarations { "Get declaration" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .declaration_provider + .as_ref() .is_some_and(|capability| match capability { - lsp::DeclarationCapability::Simple(supported) => supported, + lsp::DeclarationCapability::Simple(supported) => *supported, lsp::DeclarationCapability::RegistrationOptions(..) => true, lsp::DeclarationCapability::Options(..) => true, }) @@ -1599,12 +1598,13 @@ impl LspCommand for GetImplementations { "Get implementation" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .implementation_provider + .as_ref() .is_some_and(|capability| match capability { - lsp::ImplementationProviderCapability::Simple(enabled) => enabled, + lsp::ImplementationProviderCapability::Simple(enabled) => *enabled, lsp::ImplementationProviderCapability::Options(_options) => true, }) } @@ -1701,7 +1701,7 @@ impl LspCommand for GetTypeDefinitions { "Get type definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { !matches!( &capabilities.server_capabilities.type_definition_provider, None | Some(lsp::TypeDefinitionProviderCapability::Simple(false)) @@ -1800,7 +1800,7 @@ impl LspCommand for GetEditPredictionTypeDefinitions { "Get edit prediction type definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { !matches!( &capabilities.server_capabilities.type_definition_provider, None | Some(lsp::TypeDefinitionProviderCapability::Simple(false)) @@ -2276,7 +2276,7 @@ impl LspCommand for GetReferences { Some("Finding references...".to_owned()) } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { match &capabilities.server_capabilities.references_provider { Some(OneOf::Left(has_support)) => *has_support, Some(OneOf::Right(_)) => true, @@ -2450,12 +2450,13 @@ impl LspCommand for GetDocumentHighlights { "Get document highlights" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .document_highlight_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -2605,12 +2606,13 @@ impl LspCommand for GetDocumentSymbols { "Get document symbols" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .document_symbol_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -2802,7 +2804,7 @@ impl LspCommand for GetSignatureHelp { "Get signature help" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .signature_help_provider @@ -2926,9 +2928,9 @@ impl LspCommand for GetHover { "Get hover" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - match capabilities.server_capabilities.hover_provider { - Some(lsp::HoverProviderCapability::Simple(enabled)) => enabled, + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { + match capabilities.server_capabilities.hover_provider.as_ref() { + Some(lsp::HoverProviderCapability::Simple(enabled)) => *enabled, Some(lsp::HoverProviderCapability::Options(_)) => true, None => false, } @@ -3166,7 +3168,7 @@ impl LspCommand for GetCompletions { "Get completion" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .completion_provider @@ -3275,45 +3277,21 @@ impl LspCommand for GetCompletions { return false; } - let default_edit_range = lsp_defaults.as_ref().and_then(|lsp_defaults| { - lsp_defaults - .edit_range - .as_ref() - .and_then(|range| match range { - CompletionListItemDefaultsEditRange::Range(r) => Some(r), - _ => None, - }) - }); + let range = range_for_token + .get_or_insert_with(|| { + let offset = self.position.to_offset(&snapshot); + let (range, kind) = snapshot + .surrounding_word(offset, Some(CharScopeContext::Completion)); + let range = if kind == Some(CharKind::Word) { + range + } else { + offset..offset + }; - let range = if let Some(range) = default_edit_range { - let range = range_from_lsp(*range); - let start = snapshot.clip_point_utf16(range.start, Bias::Left); - let end = snapshot.clip_point_utf16(range.end, Bias::Left); - if start != range.start.0 || end != range.end.0 { - log::info!("completion out of expected range"); - return false; - } - - snapshot.anchor_before(start)..snapshot.anchor_after(end) - } else { - range_for_token - .get_or_insert_with(|| { - let offset = self.position.to_offset(&snapshot); - let (range, kind) = snapshot.surrounding_word( - offset, - Some(CharScopeContext::Completion), - ); - let range = if kind == Some(CharKind::Word) { - range - } else { - offset..offset - }; - - snapshot.anchor_before(range.start) - ..snapshot.anchor_after(range.end) - }) - .clone() - }; + snapshot.anchor_before(range.start) + ..snapshot.anchor_after(range.end) + }) + .clone(); // We already know text_edit is None here let text = lsp_completion @@ -3322,9 +3300,10 @@ impl LspCommand for GetCompletions { .unwrap_or(&lsp_completion.label) .clone(); + let insert_range = Some(range.start..snapshot.anchor_after(self.position)); ParsedCompletionEdit { replace_range: range, - insert_range: None, + insert_range, new_text: text, } } @@ -3530,7 +3509,7 @@ impl LspCommand for GetCodeActions { "Get code actions" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { match &capabilities.server_capabilities.code_action_provider { None => false, Some(lsp::CodeActionProviderCapability::Simple(false)) => false, @@ -3582,27 +3561,6 @@ impl LspCommand for GetCodeActions { relevant_diagnostics.push(diagnostic); } - let only = if let Some(requested) = &self.kinds { - if let Some(supported_kinds) = - Self::supported_code_action_kinds(language_server.adapter_server_capabilities()) - { - let filtered = requested - .iter() - .filter(|requested_kind| { - supported_kinds.iter().any(|supported_kind| { - code_action_kind_matches(requested_kind, supported_kind) - }) - }) - .cloned() - .collect(); - Some(filtered) - } else { - Some(requested.clone()) - } - } else { - None - }; - Ok(lsp::CodeActionParams { text_document: make_text_document_identifier(path)?, range: range_to_lsp(self.range.to_point_utf16(buffer))?, @@ -3610,7 +3568,7 @@ impl LspCommand for GetCodeActions { partial_result_params: Default::default(), context: lsp::CodeActionContext { diagnostics: relevant_diagnostics, - only, + only: self.kinds.clone(), ..lsp::CodeActionContext::default() }, }) @@ -3758,10 +3716,14 @@ impl LspCommand for GetCodeActions { } impl GetCodeActions { - fn supported_code_action_kinds( - capabilities: AdapterServerCapabilities, - ) -> Option> { - match capabilities.server_capabilities.code_action_provider { + fn supported_code_action_kinds<'a>( + capabilities: AdapterServerCapabilities<'a>, + ) -> Option<&'a [CodeActionKind]> { + match capabilities + .server_capabilities + .code_action_provider + .as_ref() + { Some(lsp::CodeActionProviderCapability::Options(CodeActionOptions { code_action_kinds: Some(supported_action_kinds), .. @@ -3809,7 +3771,7 @@ impl LspCommand for OnTypeFormatting { "Formatting on typing" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { Self::supports_on_type_formatting(&self.trigger, &capabilities.server_capabilities) } @@ -4004,6 +3966,7 @@ impl InlayHints { } }), location: Some(server_id).zip(lsp_part.location), + command: Some(server_id).zip(lsp_part.command), }); } InlayHintLabel::LabelParts(parts) @@ -4014,6 +3977,7 @@ impl InlayHints { } pub fn project_to_proto_hint(response_hint: InlayHint) -> proto::InlayHint { + let position = response_hint.position; let (state, lsp_resolve_state) = match response_hint.resolve_state { ResolveState::Resolved => (0, None), ResolveState::CanResolve(server_id, resolve_data) => ( @@ -4061,6 +4025,12 @@ impl InlayHints { location_range_start, location_range_end, language_server_id: label_part.location.as_ref().map(|(server_id, _)| server_id.0 as u64), + command: label_part.command.map(|(server_id, command)| LspStore::serialize_code_action(&CodeAction { + server_id, + range: position..position, + lsp_action: LspAction::Command(command), + resolved: true, + })), }}).collect() }) } @@ -4179,6 +4149,23 @@ impl InlayHints { None => None, } }, + command: match part.command { + Some(command) => { + let action = LspStore::deserialize_code_action(command) + .context("invalid command in inlay hint label part")?; + match action.lsp_action { + LspAction::Command(command) => { + Some((action.server_id, command)) + } + LspAction::Action(_) | LspAction::CodeLens(_) => { + anyhow::bail!( + "unexpected non-command action in inlay hint label part" + ) + } + } + } + None => None, + }, }); } @@ -4264,7 +4251,7 @@ impl InlayHints { }) }), location: part.location.map(|(_, location)| location), - command: None, + command: part.command.map(|(_, command)| command), }) .collect(), ), @@ -4315,7 +4302,7 @@ impl LspCommand for InlayHints { "Inlay hints" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { Self::check_capabilities(&capabilities.server_capabilities) } @@ -4354,9 +4341,18 @@ impl LspCommand for InlayHints { // Hence let's use a heuristic first to handle the most awkward case and look for more. let force_no_type_left_padding = lsp_adapter.name.0.as_ref() == "typescript-language-server"; + let can_resolve = lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/inlayHint", + |capabilities| InlayHints::can_resolve_inlays(capabilities.server_capabilities), + cx, + ) + }); let hints = message.unwrap_or_default().into_iter().map(|lsp_hint| { - let resolve_state = if InlayHints::can_resolve_inlays(&lsp_server.capabilities()) { + let resolve_state = if can_resolve { ResolveState::CanResolve(lsp_server.server_id(), lsp_hint.data.clone()) } else { ResolveState::Resolved @@ -4467,7 +4463,7 @@ impl LspCommand for SemanticTokensFull { "Semantic tokens full" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .semantic_tokens_provider @@ -4618,7 +4614,7 @@ impl LspCommand for SemanticTokensDelta { "Semantic tokens delta" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .semantic_tokens_provider @@ -4771,7 +4767,7 @@ impl LspCommand for GetCodeLens { "Code Lens" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .code_lens_provider @@ -4887,8 +4883,9 @@ impl LspCommand for GetCodeLens { } impl LinkedEditingRange { - pub fn check_server_capabilities(capabilities: ServerCapabilities) -> bool { - let Some(linked_editing_options) = capabilities.linked_editing_range_provider else { + pub fn check_server_capabilities(capabilities: &ServerCapabilities) -> bool { + let Some(linked_editing_options) = capabilities.linked_editing_range_provider.as_ref() + else { return false; }; if let LinkedEditingRangeServerCapabilities::Simple(false) = linked_editing_options { @@ -4908,8 +4905,8 @@ impl LspCommand for LinkedEditingRange { "Linked editing range" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - Self::check_server_capabilities(capabilities.server_capabilities) + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { + Self::check_server_capabilities(&capabilities.server_capabilities) } fn to_lsp( @@ -5359,8 +5356,11 @@ impl LspCommand for GetDocumentDiagnostics { "Get diagnostics" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { - true + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { + capabilities + .server_capabilities + .diagnostic_provider + .is_some() } fn to_lsp( @@ -5548,7 +5548,7 @@ impl LspCommand for GetDocumentColor { "Document color" } - fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities<'_>) -> bool { server_capabilities .server_capabilities .color_provider @@ -5691,7 +5691,7 @@ impl LspCommand for GetFoldingRanges { "Folding ranges" } - fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities<'_>) -> bool { server_capabilities .server_capabilities .folding_range_provider @@ -5847,7 +5847,7 @@ impl LspCommand for GetDocumentLinks { "Document links" } - fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities<'_>) -> bool { server_capabilities .server_capabilities .document_link_provider diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index bd4d53ab54..912d3db6e1 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -15,6 +15,7 @@ mod document_colors; mod document_links; mod document_symbols; mod dynamic_registration; +pub mod emmet_ext; mod folding_ranges; mod inlay_hints; pub mod json_language_server_ext; @@ -28,7 +29,7 @@ use self::code_lens::CodeLensData; use self::document_colors::DocumentColorData; use self::document_links::DocumentLinksData; use self::document_symbols::DocumentSymbolsData; -use self::dynamic_registration::{DynamicRegistrations, RegistrationSource}; +use self::dynamic_registration::DynamicRegistrations; use self::inlay_hints::BufferInlayHints; use crate::{ CodeAction, Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, @@ -63,7 +64,7 @@ use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; use futures::{ AsyncWriteExt, Future, FutureExt, StreamExt, channel::oneshot, - future::{Either, Shared, join_all, select}, + future::{Shared, join_all}, select, select_biased, stream::FuturesUnordered, }; @@ -146,7 +147,7 @@ use util::{ paths::{PathStyle, SanitizedPath, UrlExt}, post_inc, redact::redact_command, - rel_path::RelPath, + rel_path::{RelPath, RelPathBuf}, union_json_value_into, }; @@ -175,6 +176,10 @@ pub use worktree::{ const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); pub const SERVER_PROGRESS_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100); const WORKSPACE_DIAGNOSTICS_TOKEN_START: &str = "id:"; +const WORKSPACE_DIAGNOSTICS_REPULL_DELAY: Duration = Duration::from_secs(2); +const CROSS_BUFFER_DIAGNOSTICS_PULL_DELAY: Duration = Duration::from_millis(100); +const DOCUMENT_DIAGNOSTICS_RETRIGGER_LIMIT: usize = 3; +const DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY: Duration = Duration::from_millis(100); const SERVER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10); static NEXT_PROMPT_REQUEST_ID: AtomicUsize = AtomicUsize::new(0); @@ -298,6 +303,12 @@ pub struct DocumentDiagnostics { version: Option, } +#[derive(Clone, Debug)] +struct DocumentSelectorContext { + language_id: String, + scheme: &'static str, +} + pub struct LocalLspStore { weak: WeakEntity, pub worktree_store: Entity, @@ -316,6 +327,7 @@ pub struct LocalLspStore { language_server_paths_watched_for_rename: HashMap, language_server_dynamic_registrations: HashMap, + initial_server_capabilities: HashMap, supplementary_language_servers: HashMap)>, prettier_store: Entity, @@ -332,6 +344,7 @@ pub struct LocalLspStore { >, buffer_snapshots: HashMap>>, // buffer_id -> server_id -> vec of snapshots _subscription: gpui::Subscription, + _binary_status_task: Task<()>, lsp_tree: LanguageServerTree, registered_buffers: HashMap, buffers_opened_in_servers: HashMap>, @@ -414,6 +427,14 @@ impl LocalLspStore { } } + fn update_binary_status(&self, server_name: LanguageServerName, status: BinaryStatus) { + self.languages.update_lsp_binary_status_for_entity( + self.weak.entity_id(), + server_name, + status, + ); + } + fn start_language_server( &mut self, worktree_handle: &Entity, @@ -677,8 +698,7 @@ impl LocalLspStore { }; if update_binary_status { - self.languages - .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting); + self.update_binary_status(adapter.name(), BinaryStatus::Starting); } self.language_servers.insert(server_id, state); @@ -706,7 +726,6 @@ impl LocalLspStore { && let Some(path) = settings.path.as_ref().map(PathBuf::from) { let settings = settings.clone(); - let languages = self.languages.clone(); return cx.background_spawn(async move { if let Some(mut wait_until_worktree_trust) = wait_until_worktree_trust { let already_trusted = *wait_until_worktree_trust.borrow(); @@ -725,8 +744,7 @@ impl LocalLspStore { adapter.name(), ); } - languages - .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting); + delegate.update_status(adapter.name(), BinaryStatus::Starting); } let mut env = delegate.shell_env().await; env.extend(settings.env.unwrap_or_default()); @@ -747,7 +765,6 @@ impl LocalLspStore { #[cfg(any(test, feature = "test-support"))] if !adapter.adapter.is_extension() && self.languages.has_fake_lsp_server(&adapter.name) { let language_server_name = adapter.name.clone(); - let languages = self.languages.clone(); return cx.spawn(async move |_| { if let Some(mut wait_until_worktree_trust) = wait_until_worktree_trust { let already_trusted = *wait_until_worktree_trust.borrow(); @@ -764,7 +781,7 @@ impl LocalLspStore { "Worktree {worktree_abs_path:?} is trusted, starting language server {language_server_name}", ); } - languages.update_lsp_binary_status( + delegate.update_status( language_server_name.clone(), BinaryStatus::Starting, ); @@ -1177,11 +1194,11 @@ impl LocalLspStore { language_server .on_request::({ - let this = lsp_store.clone(); + let lsp_store = lsp_store.clone(); let name = name.to_string(); let adapter = adapter.clone(); move |params, cx| { - let this = this.clone(); + let lsp_store = lsp_store.clone(); let name = name.to_string(); let adapter = adapter.clone(); let mut cx = cx.clone(); @@ -1202,29 +1219,49 @@ impl LocalLspStore { tx, ); - let did_update = this - .update(&mut cx, |_, cx| { - cx.emit(LspStoreEvent::LanguageServerPrompt(request)); - }) - .is_ok(); - if did_update { - let response = rx.recv().await.ok(); - if let Some(ref selected_action) = response { - let context = language::PromptResponseContext { - message, - selected_action: selected_action.clone(), - }; - adapter.process_prompt_response(&context, &mut cx) - } - - Ok(response) - } else { - Ok(None) + lsp_store.update(&mut cx, |_, cx| { + cx.emit(LspStoreEvent::LanguageServerPrompt(request)); + })?; + let response = rx.recv().await.ok(); + if let Some(ref selected_action) = response { + let context = language::PromptResponseContext { + message, + selected_action: selected_action.clone(), + }; + adapter.process_prompt_response(&context, &mut cx) } + + Ok(response) } } }) .detach(); + + language_server + .on_request::({ + let lsp_store = lsp_store.clone(); + move |params, cx| { + let lsp_store = lsp_store.clone(); + let mut cx = cx.clone(); + async move { + let (tx, rx) = async_channel::bounded(1); + let request = LanguageServerShowDocumentRequest { + uri: params.uri, + external: params.external.unwrap_or(false), + take_focus: params.take_focus.unwrap_or(false), + selection: params.selection, + response_channel: tx, + }; + lsp_store.update(&mut cx, |_, cx| { + cx.emit(LspStoreEvent::LanguageServerShowDocument(request)); + })?; + let success = rx.recv().await.unwrap_or(false); + Ok(lsp::ShowDocumentResult { success }) + } + } + }) + .detach(); + language_server .on_notification::({ let this = lsp_store.clone(); @@ -1412,6 +1449,16 @@ impl LocalLspStore { } } + fn server_pulls_workspace_diagnostics(&self, server_id: LanguageServerId) -> bool { + match self.language_servers.get(&server_id) { + Some(LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + }) => !workspace_diagnostics_refresh_tasks.is_empty(), + _ => false, + } + } + fn language_servers_for_buffer<'a>( &'a self, buffer: &'a Buffer, @@ -1483,6 +1530,7 @@ impl LocalLspStore { Self::execute_code_actions_on_server( &lsp_store, language_server, + buffer, actions, push_to_history, &mut project_transaction, @@ -1920,23 +1968,66 @@ impl LocalLspStore { return Ok(()); }; - let language_server = match specifier { + let adapter_and_server = match specifier { settings::LanguageServerFormatterSpecifier::Specific { name } => { - adapters_and_servers.iter().find_map(|(adapter, server)| { - if adapter.name.0.as_ref() == name { - Some(server.clone()) - } else { - None - } - }) + adapters_and_servers + .iter() + .find(|(adapter, _)| adapter.name.0.as_ref() == name) + .cloned() + } + settings::LanguageServerFormatterSpecifier::Current => { + lsp_store.read_with(cx, |lsp_store, cx| { + lsp_store.as_local().and_then(|local| { + let buffer_snapshot = buffer.handle.read(cx); + let adapter_and_server = if buffer.ranges.is_some() { + let range_formatter = + adapters_and_servers.iter().find(|(adapter, server)| { + buffer_supports_lsp_range_formatting( + local, + buffer_snapshot, + adapter, + server, + ) + }); + if range_formatter.is_some() { + range_formatter + } else if trigger == FormatTrigger::Save + && settings.format_on_save + == FormatOnSave::ModificationsIfAvailable + { + adapters_and_servers.iter().find(|(adapter, server)| { + buffer_supports_lsp_formatting( + local, + buffer_snapshot, + adapter, + server, + ) + }) + } else { + None + } + } else { + adapters_and_servers.iter().find(|(adapter, server)| { + buffer_supports_lsp_formatting( + local, + buffer_snapshot, + adapter, + server, + ) || buffer_supports_lsp_range_formatting( + local, + buffer_snapshot, + adapter, + server, + ) + }) + }; + adapter_and_server.cloned() + }) + })? } - settings::LanguageServerFormatterSpecifier::Current => adapters_and_servers - .iter() - .find(|(_, server)| Self::server_supports_formatting(server)) - .map(|(_, server)| server.clone()), }; - let Some(language_server) = language_server else { + let Some((adapter, language_server)) = adapter_and_server else { zlog::debug!( logger => "No language server found to format buffer {buffer_path_abs:?}. Skipping", @@ -1957,6 +2048,7 @@ impl LocalLspStore { &buffer.handle, ranges, buffer_path_abs, + &adapter, &language_server, &settings, cx, @@ -1978,6 +2070,7 @@ impl LocalLspStore { &lsp_store, &buffer.handle, buffer_path_abs, + &adapter, &language_server, &settings, cx, @@ -2000,6 +2093,7 @@ impl LocalLspStore { &lsp_store, &buffer.handle, buffer_path_abs, + &adapter, &language_server, &settings, cx, @@ -2091,8 +2185,26 @@ impl LocalLspStore { zlog::trace!(logger => "Executing {}", describe_code_action(&action)); - if let Err(err) = - Self::try_resolve_code_action(server, &mut action, request_timeout).await + let can_resolve_actions = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.text_document_capability_matches_for_server( + &buffer.handle, + server.server_id(), + "textDocument/codeAction", + |capabilities| { + GetCodeActions::can_resolve_actions( + capabilities.server_capabilities, + ) + }, + cx, + ) + })?; + if let Err(err) = Self::try_resolve_code_action( + server, + &mut action, + can_resolve_actions, + request_timeout, + ) + .await { zlog::error!( logger => @@ -2384,16 +2496,22 @@ impl LocalLspStore { buffer_handle: &Entity, ranges: &[Range], abs_path: &Path, + adapter: &Arc, language_server: &Arc, settings: &LanguageSettings, cx: &mut AsyncApp, ) -> Result, Arc)>>> { - let capabilities = &language_server.capabilities(); - let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref(); - if !matches!( - range_formatting_provider, - Some(OneOf::Left(true) | OneOf::Right(_)) - ) { + let range_formatting_supported = this.read_with(cx, |this, cx| { + this.as_local().is_some_and(|local| { + buffer_supports_lsp_range_formatting( + local, + buffer_handle.read(cx), + adapter, + language_server, + ) + }) + })?; + if !range_formatting_supported { log::debug!( "Skipping range formatting: language server {} does not support range formatting", language_server.name() @@ -2517,17 +2635,11 @@ impl LocalLspStore { } } - fn server_supports_formatting(server: &Arc) -> bool { - let capabilities = server.capabilities(); - let formatting = capabilities.document_formatting_provider.as_ref(); - matches!(formatting, Some(p) if *p != OneOf::Left(false)) - || server_capabilities_support_range_formatting(&capabilities) - } - async fn format_via_lsp( this: &WeakEntity, buffer: &Entity, abs_path: &Path, + adapter: &Arc, language_server: &Arc, settings: &LanguageSettings, cx: &mut AsyncApp, @@ -2537,10 +2649,23 @@ impl LocalLspStore { let uri = file_path_to_lsp_url(abs_path)?; let text_document = lsp::TextDocumentIdentifier::new(uri); - let capabilities = &language_server.capabilities(); - let formatting_provider = capabilities.document_formatting_provider.as_ref(); - let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref(); + let (formatting_supported, range_formatting_supported) = + this.read_with(cx, |this, cx| match this.as_local() { + Some(local) => { + let buffer = buffer.read(cx); + ( + buffer_supports_lsp_formatting(local, buffer, adapter, language_server), + buffer_supports_lsp_range_formatting( + local, + buffer, + adapter, + language_server, + ), + ) + } + None => (false, false), + })?; let request_timeout = cx.update(|app| { ProjectSettings::get_global(app) @@ -2548,7 +2673,7 @@ impl LocalLspStore { .get_request_timeout() }); - let lsp_edits = if matches!(formatting_provider, Some(p) if *p != OneOf::Left(false)) { + let lsp_edits = if formatting_supported { let _timer = zlog::time!(logger => "format-full"); language_server .request::( @@ -2561,7 +2686,7 @@ impl LocalLspStore { ) .await .into_response()? - } else if matches!(range_formatting_provider, Some(p) if *p != OneOf::Left(false)) { + } else if range_formatting_supported { let _timer = zlog::time!(logger => "format-range"); let buffer_start = lsp::Position::new(0, 0); let buffer_end = buffer.read_with(cx, |b, _| point_to_lsp(b.max_point_utf16())); @@ -2680,12 +2805,13 @@ impl LocalLspStore { async fn try_resolve_code_action( lang_server: &LanguageServer, action: &mut CodeAction, + can_resolve: bool, request_timeout: Duration, ) -> anyhow::Result<()> { match &mut action.lsp_action { LspAction::Action(lsp_action) => { if !action.resolved - && GetCodeActions::can_resolve_actions(&lang_server.capabilities()) + && can_resolve && lsp_action.data.is_some() && (lsp_action.command.is_none() || lsp_action.edit.is_none()) { @@ -2699,7 +2825,7 @@ impl LocalLspStore { } } LspAction::CodeLens(lens) => { - if !action.resolved && GetCodeLens::can_resolve_lens(&lang_server.capabilities()) { + if !action.resolved && can_resolve { *lens = lang_server .request::(lens.clone(), request_timeout) .await @@ -2762,35 +2888,32 @@ impl LocalLspStore { self.lsp_tree .get(path, language.name(), language.manifest(), &delegate, cx) { - let server = self - .language_servers - .get(&server_id) - .and_then(|server_state| { - if let LanguageServerState::Running { server, .. } = server_state { - Some(server.clone()) - } else { - None - } - }); - let server = match server { - Some(server) => server, - None => continue, + let Some((server, adapter)) = + self.language_servers + .get(&server_id) + .and_then(|server_state| { + if let LanguageServerState::Running { + server, adapter, .. + } = server_state + { + Some((server.clone(), adapter.clone())) + } else { + None + } + }) + else { + continue; }; buffer_handle.update(cx, |buffer, cx| { buffer.set_completion_triggers( server.server_id(), - server - .capabilities() - .completion_provider - .as_ref() - .and_then(|provider| { - provider - .trigger_characters - .as_ref() - .map(|characters| characters.iter().cloned().collect()) - }) - .unwrap_or_default(), + completion_trigger_characters_for_buffer( + self, + server.server_id(), + &adapter, + buffer, + ), cx, ); }); @@ -3096,17 +3219,12 @@ impl LocalLspStore { buffer_handle.update(cx, |buffer, cx| { buffer.set_completion_triggers( server.server_id(), - server - .capabilities() - .completion_provider - .as_ref() - .and_then(|provider| { - provider - .trigger_characters - .as_ref() - .map(|characters| characters.iter().cloned().collect()) - }) - .unwrap_or_default(), + completion_trigger_characters_for_buffer( + self, + server.server_id(), + &adapter, + buffer, + ), cx, ); }); @@ -3217,16 +3335,16 @@ impl LocalLspStore { buffer: &Entity, old_file: &File, cx: &mut App, - ) { + ) -> Vec { let old_path = match old_file.as_local() { Some(local) => local.abs_path(cx), - None => return, + None => return Vec::new(), }; let Ok(file_url) = lsp::Uri::from_file_path(old_path.as_path()) else { - return; + return Vec::new(); }; - self.unregister_buffer_from_language_servers(buffer, &file_url, cx); + self.unregister_buffer_from_language_servers(buffer, &file_url, cx) } pub(crate) fn unregister_buffer_from_language_servers( @@ -3234,19 +3352,42 @@ impl LocalLspStore { buffer: &Entity, file_url: &lsp::Uri, cx: &mut App, - ) { + ) -> Vec { + let abs_path = file_url.to_file_path().ok(); buffer.update(cx, |buffer, cx| { - let mut snapshots = self.buffer_snapshots.remove(&buffer.remote_id()); + let buffer_id = buffer.remote_id(); + let mut snapshots = self.buffer_snapshots.remove(&buffer_id); + let mut detached_servers = Vec::new(); for (_, language_server) in self.language_servers_for_buffer(buffer, cx) { + let server_id = language_server.server_id(); if snapshots .as_mut() - .is_some_and(|map| map.remove(&language_server.server_id()).is_some()) + .is_some_and(|map| map.remove(&server_id).is_some()) { language_server.unregister_buffer(file_url.clone()); + detached_servers.push(server_id); } } - }); + + for &server_id in &detached_servers { + buffer.set_completion_triggers(server_id, Default::default(), cx); + if let Some(opened_in_servers) = self.buffers_opened_in_servers.get_mut(&buffer_id) + { + opened_in_servers.remove(&server_id); + } + if let Some(abs_path) = &abs_path + && let Some(result_ids) = + self.buffer_pull_diagnostics_result_ids.get_mut(&server_id) + { + for result_ids in result_ids.values_mut() { + result_ids.remove(abs_path); + } + } + } + + detached_servers + }) } fn buffer_snapshot_for_lsp_version( @@ -3311,6 +3452,7 @@ impl LocalLspStore { pub async fn execute_code_actions_on_server( lsp_store: &WeakEntity, language_server: &Arc, + buffer: &Entity, actions: Vec, push_to_history: bool, project_transaction: &mut ProjectTransaction, @@ -3321,11 +3463,28 @@ impl LocalLspStore { .global_lsp_settings .get_request_timeout() }); + let server_id = language_server.server_id(); + let can_resolve_actions = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.text_document_capability_matches_for_server( + buffer, + server_id, + "textDocument/codeAction", + |capabilities| { + GetCodeActions::can_resolve_actions(capabilities.server_capabilities) + }, + cx, + ) + })?; for mut action in actions { - Self::try_resolve_code_action(language_server, &mut action, request_timeout) - .await - .context("resolving a formatting code action")?; + Self::try_resolve_code_action( + language_server, + &mut action, + can_resolve_actions, + request_timeout, + ) + .await + .context("resolving a formatting code action")?; if let Some(edit) = action.lsp_action.edit() { if edit.changes.is_none() && edit.document_changes.is_none() { @@ -3865,6 +4024,9 @@ impl LocalLspStore { self.last_workspace_edits_by_language_server .remove(server_id_to_remove); self.language_servers.remove(server_id_to_remove); + self.language_server_dynamic_registrations + .remove(server_id_to_remove); + self.initial_server_capabilities.remove(server_id_to_remove); self.buffer_pull_diagnostics_result_ids .remove(server_id_to_remove); self.workspace_pull_diagnostics_result_ids @@ -4301,33 +4463,22 @@ enum ChunkFetch { Running(CacheInlayHintsTask), } -fn notify_server_capabilities_updated(server: &LanguageServer, cx: &mut Context) { - if let Some(capabilities) = serde_json::to_string(&server.capabilities()).ok() { - cx.emit(LspStoreEvent::LanguageServerUpdate { - language_server_id: server.server_id(), - name: Some(server.name()), - message: proto::update_language_server::Variant::MetadataUpdated( - proto::ServerMetadataUpdated { - capabilities: Some(capabilities), - binary: Some(proto::LanguageServerBinaryInfo { - path: server.binary().path.to_string_lossy().into_owned(), - arguments: server - .binary() - .arguments - .iter() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect(), - }), - configuration: serde_json::to_string(server.configuration()).ok(), - workspace_folders: server - .workspace_folders() - .iter() - .map(|uri| uri.to_string()) - .collect(), - }, - ), - }); - } +#[derive(serde::Serialize, serde::Deserialize)] +struct SyncedServerCapabilities { + #[serde(flatten)] + server_capabilities: lsp::ServerCapabilities, + #[serde( + rename = "zedInitialServerCapabilities", + default, + skip_serializing_if = "Option::is_none" + )] + initial_server_capabilities: Option, + #[serde( + rename = "zedTextDocumentRegistrations", + default, + skip_serializing_if = "Option::is_none" + )] + text_document_registrations: Option, } #[derive(Debug)] @@ -4369,6 +4520,9 @@ pub struct LspStore { diagnostic_summaries: HashMap, HashMap>>, pub lsp_server_capabilities: HashMap, + lsp_server_initial_capabilities: HashMap, + lsp_server_text_document_registrations: + HashMap, semantic_token_config: SemanticTokenConfig, lsp_data: HashMap, buffer_reload_tasks: HashMap>>, @@ -4393,6 +4547,7 @@ pub struct BufferLspData { struct LspKey { request_type: TypeId, server_queried: Option, + sender_id: proto::PeerId, } impl BufferLspData { @@ -4456,7 +4611,9 @@ impl BufferLspData { #[derive(Debug)] pub enum LspStoreEvent { LanguageServerAdded(LanguageServerId, LanguageServerName, Option), + SupplementaryLanguageServerAdded(LanguageServerId, LanguageServerName), LanguageServerRemoved(LanguageServerId), + SupplementaryLanguageServerRemoved(LanguageServerId), LanguageServerUpdate { language_server_id: LanguageServerId, name: Option, @@ -4469,6 +4626,7 @@ pub enum LspStoreEvent { new_language: Option>, }, Notification(String), + LanguageServerShowDocument(LanguageServerShowDocumentRequest), RefreshInlayHints { server_id: LanguageServerId, }, @@ -4484,6 +4642,9 @@ pub enum LspStoreEvent { RefreshDocumentLinks { server_id: Option, }, + RefreshDocumentHighlights { + server_id: Option, + }, RefreshFoldingRanges { server_id: Option, }, @@ -4561,6 +4722,18 @@ fn should_log_lsp_request_failure(message: &str) -> bool { !(message.ends_with("content modified") || message.ends_with("server cancelled the request")) } +fn should_log_lsp_response_error(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_none_or(|response_error| !response_error.is_request_denied()) +} + +fn lsp_pull_cancelled_with_retrigger(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|response_error| response_error.should_retrigger()) +} + impl LspStore { pub fn init(client: &AnyProtoClient) { client.add_entity_request_handler(Self::handle_lsp_query); @@ -4587,6 +4760,7 @@ impl LspStore { client.add_entity_request_handler(Self::handle_refresh_code_lens); client.add_entity_request_handler(Self::handle_refresh_document_colors); client.add_entity_request_handler(Self::handle_refresh_document_links); + client.add_entity_request_handler(Self::handle_refresh_document_highlights); client.add_entity_request_handler(Self::handle_refresh_folding_ranges); client.add_entity_request_handler(Self::handle_refresh_document_symbols); client.add_entity_request_handler(Self::handle_on_type_formatting); @@ -4598,13 +4772,23 @@ impl LspStore { client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_command::); - client.add_entity_request_handler(Self::handle_lsp_command::); + client.add_entity_request_handler(|lsp_store, envelope, cx| { + Self::handle_routed_lsp_command::(lsp_store, envelope, cx, |request| { + request + .language_server_id + .map(LanguageServerToQuery::Other) + .unwrap_or(LanguageServerToQuery::FirstCapable) + }) + }); client.add_entity_request_handler(Self::handle_lsp_command::); + client.add_entity_request_handler(Self::handle_execute_lsp_command); client.add_entity_request_handler(Self::handle_lsp_ext_cancel_flycheck); client.add_entity_request_handler(Self::handle_lsp_ext_run_flycheck); client.add_entity_request_handler(Self::handle_lsp_ext_clear_flycheck); client.add_entity_request_handler(Self::handle_lsp_command::); + client + .add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler( Self::handle_lsp_command::, @@ -4677,7 +4861,7 @@ impl LspStore { .detach(); cx.observe_global::(Self::on_settings_changed) .detach(); - subscribe_to_binary_statuses(&languages, cx).detach(); + let binary_status_task = subscribe_to_binary_statuses(&languages, cx); let _maintain_workspace_config = { let (sender, receiver) = watch::channel(); @@ -4697,6 +4881,7 @@ impl LspStore { language_server_watched_paths: Default::default(), language_server_paths_watched_for_rename: Default::default(), language_server_dynamic_registrations: Default::default(), + initial_server_capabilities: HashMap::default(), buffers_being_formatted: Default::default(), buffers_to_refresh_hash_set: HashSet::default(), buffers_to_refresh_queue: VecDeque::new(), @@ -4714,6 +4899,7 @@ impl LspStore { .unwrap() .shutdown_language_servers_on_quit() }), + _binary_status_task: binary_status_task, lsp_tree: LanguageServerTree::new( manifest_tree, languages.clone(), @@ -4739,6 +4925,8 @@ impl LspStore { nonce: StdRng::from_os_rng().random(), diagnostic_summaries: HashMap::default(), lsp_server_capabilities: HashMap::default(), + lsp_server_initial_capabilities: HashMap::default(), + lsp_server_text_document_registrations: HashMap::default(), semantic_token_config: SemanticTokenConfig::new(cx), lsp_data: HashMap::default(), buffer_reload_tasks: HashMap::default(), @@ -4782,7 +4970,6 @@ impl LspStore { .detach(); cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); - subscribe_to_binary_statuses(&languages, cx).detach(); let _maintain_workspace_config = { let (sender, receiver) = watch::channel(); (Self::maintain_workspace_config(receiver, cx), sender) @@ -4801,6 +4988,8 @@ impl LspStore { nonce: StdRng::from_os_rng().random(), diagnostic_summaries: HashMap::default(), lsp_server_capabilities: HashMap::default(), + lsp_server_initial_capabilities: HashMap::default(), + lsp_server_text_document_registrations: HashMap::default(), semantic_token_config: SemanticTokenConfig::new(cx), next_hint_id: Arc::default(), lsp_data: HashMap::default(), @@ -4808,7 +4997,7 @@ impl LspStore { active_entry: None, _maintain_workspace_config, - _maintain_buffer_languages: Self::maintain_buffer_languages(languages.clone(), cx), + _maintain_buffer_languages: Self::maintain_buffer_languages(languages, cx), } } @@ -5042,7 +5231,14 @@ impl LspStore { fn background_diagnostics_worker(cx: &mut Context) -> Task<()> { cx.spawn(async move |this, cx| { - while let Ok(Some(task)) = this.update(cx, |this, cx| this.refresh_next_buffer(cx)) { + loop { + cx.background_executor() + .timer(CROSS_BUFFER_DIAGNOSTICS_PULL_DELAY) + .await; + let Ok(Some(task)) = this.update(cx, |this, cx| this.refresh_next_buffer(cx)) + else { + return; + }; task.await.log_err(); } }) @@ -5121,46 +5317,27 @@ impl LspStore { ); } - let diagnostic_updates = local + // Closing the buffer does not change which servers are relevant + // to its path, so pushed diagnostics stay: a running server may + // still describe the file. Pulled document diagnostics need an + // open document to be refreshed, so they are dropped, except for + // servers whose workspace pull still owns the path's verdict. + let servers_to_clear = local .language_servers - .iter() - .filter_map(|(server_id, state)| { - let supports_workspace_diagnostics = match state { - LanguageServerState::Running { - workspace_diagnostics_refresh_tasks, - .. - } => !workspace_diagnostics_refresh_tasks.is_empty(), - _ => false, - }; - if supports_workspace_diagnostics { - None - } else { - Some(*server_id) - } - }) - .map(|server_id| DocumentDiagnosticsUpdate { - diagnostics: DocumentDiagnostics { - document_abs_path: buffer_abs_path.clone(), - version: None, - diagnostics: Vec::new(), - }, - result_id: None, - registration_id: None, - server_id, - disk_based_sources: Cow::Borrowed(&[]), + .keys() + .copied() + .filter(|server_id| { + !local.server_pulls_workspace_diagnostics(*server_id) }) .collect::>(); - - lsp_store - .merge_diagnostic_entries( - diagnostic_updates, - |_, diagnostic, _| { - diagnostic.source_kind != DiagnosticSourceKind::Pulled - }, - cx, - ) - .context("Clearing diagnostics for the closed buffer") - .log_err(); + lsp_store.clear_path_diagnostics_for_servers( + buffer_abs_path, + servers_to_clear, + |_, diagnostic, _| { + diagnostic.source_kind != DiagnosticSourceKind::Pulled + }, + cx, + ); } } }) @@ -5222,23 +5399,25 @@ impl LspStore { for buffer in buffer_store.buffers() { if let Some(f) = File::from_dyn(buffer.read(cx).file()).cloned() { + // Detach before the language is unassigned: the + // detachment resolves the buffer's servers through + // its current language, and with the language gone + // it skips didClose and leaks the per-server state + // while still dropping the snapshots. + if let Some(local) = this.as_local_mut() + && local + .registered_buffers + .contains_key(&buffer.read(cx).remote_id()) + && let Some(file_url) = + file_path_to_lsp_url(&f.abs_path(cx)).log_err() + { + local.unregister_buffer_from_language_servers( + &buffer, &file_url, cx, + ); + } buffer.update(cx, |buffer, cx| { buffer.set_language_async(None, cx) }); - if let Some(local) = this.as_local_mut() { - local.reset_buffer(&buffer, &f, cx); - - if local - .registered_buffers - .contains_key(&buffer.read(cx).remote_id()) - && let Some(file_url) = - file_path_to_lsp_url(&f.abs_path(cx)).log_err() - { - local.unregister_buffer_from_language_servers( - &buffer, &file_url, cx, - ); - } - } } } }); @@ -5418,13 +5597,17 @@ impl LspStore { let buffer = buffer_entity.read(cx); let buffer_file = buffer.file().cloned(); let buffer_id = buffer.remote_id(); + let mut detached_servers = Vec::new(); + let mut detached_abs_path = None; if let Some(local_store) = self.as_local_mut() && local_store.registered_buffers.contains_key(&buffer_id) && let Some(abs_path) = File::from_dyn(buffer_file.as_ref()).map(|file| file.abs_path(cx)) && let Some(file_url) = file_path_to_lsp_url(&abs_path).log_err() { - local_store.unregister_buffer_from_language_servers(buffer_entity, &file_url, cx); + detached_servers = + local_store.unregister_buffer_from_language_servers(buffer_entity, &file_url, cx); + detached_abs_path = Some(abs_path); } buffer_entity.update(cx, |buffer, cx| { if buffer @@ -5455,6 +5638,41 @@ impl LspStore { None }; + // A detached server keeps running and may legitimately re-publish for this path, + // since publishes are applied by path with no regard for registration. So only + // drop what it already said: clear the (path, server) diagnostics everywhere, + // exempting servers whose workspace-diagnostics pull owns the verdict, mirroring + // the buffer-close path. Anything a live server still asserts comes back on its + // next publish; a verdict from a server that stopped tracking the file has no + // other way to ever go away. + if let Some(abs_path) = detached_abs_path + && !detached_servers.is_empty() + { + let servers_to_clear = if let Some(local) = self.as_local() { + let reattached_servers = buffer_entity.update(cx, |buffer, cx| { + local.language_server_ids_for_buffer(buffer, cx) + }); + detached_servers + .into_iter() + .filter(|server_id| !reattached_servers.contains(server_id)) + .filter(|server_id| !local.server_pulls_workspace_diagnostics(*server_id)) + .collect::>() + } else { + Vec::new() + }; + if let Some(lsp_data) = self.lsp_data.get_mut(&buffer_id) { + for server_id in &servers_to_clear { + lsp_data.remove_server_data(*server_id); + } + } + self.clear_path_diagnostics_for_servers( + abs_path, + servers_to_clear, + |_, _, _| false, + cx, + ); + } + if settings.prettier.allowed && let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings) { @@ -5515,19 +5733,15 @@ impl LspStore { where R: LspCommand, { - self.check_if_capable_for_proto_request( + self.check_if_any_relevant_text_document_server_matches( buffer, - |capabilities| { - request.check_capabilities(AdapterServerCapabilities { - server_capabilities: capabilities.clone(), - code_action_kinds: None, - }) - }, + ::METHOD, + |_, capabilities| request.check_capabilities(capabilities), cx, ) } - fn relevant_server_ids_for_capability_check( + pub fn relevant_server_ids_for_capability_check( &self, buffer: &Entity, cx: &App, @@ -5562,36 +5776,261 @@ impl LspStore { .collect() } - fn check_if_any_relevant_server_matches( + fn synced_server_capabilities(&self, server: &LanguageServer) -> SyncedServerCapabilities { + let (initial_server_capabilities, text_document_registrations) = self + .as_local() + .map(|local| { + let initial_server_capabilities = local + .initial_server_capabilities + .get(&server.server_id()) + .cloned(); + let text_document_registrations = local + .language_server_dynamic_registrations + .get(&server.server_id()) + .map(|registrations| { + registrations + .text_documents + .iter() + .filter(|(_, registrations)| !registrations.is_empty()) + .map(|(method, registrations)| (method.clone(), registrations.clone())) + .collect::() + }) + .filter(|registrations| !registrations.is_empty()); + (initial_server_capabilities, text_document_registrations) + }) + .unwrap_or_default(); + SyncedServerCapabilities { + server_capabilities: server.capabilities(), + initial_server_capabilities, + text_document_registrations, + } + } + + fn serialize_synced_server_capabilities( + &self, + server: &LanguageServer, + ) -> serde_json::Result { + serde_json::to_string(&self.synced_server_capabilities(server)) + } + + fn notify_server_capabilities_updated(&self, server: &LanguageServer, cx: &mut Context) { + let Some(capabilities) = self.serialize_synced_server_capabilities(server).log_err() else { + return; + }; + let message = + proto::update_language_server::Variant::MetadataUpdated(proto::ServerMetadataUpdated { + capabilities: Some(capabilities), + binary: Some(proto::LanguageServerBinaryInfo { + path: server.binary().path.to_string_lossy().into_owned(), + arguments: server + .binary() + .arguments + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(), + }), + configuration: serde_json::to_string(server.configuration()).ok(), + workspace_folders: server + .workspace_folders() + .iter() + .map(|uri| uri.to_string()) + .collect(), + }); + if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { + downstream_client + .send(proto::UpdateLanguageServer { + project_id: *project_id, + server_name: Some(server.name().to_string()), + language_server_id: server.server_id().to_proto(), + variant: Some(message.clone()), + }) + .context("sending server metadata downstream") + .log_err(); + } + cx.emit(LspStoreEvent::LanguageServerUpdate { + language_server_id: server.server_id(), + name: Some(server.name()), + message, + }); + } + + pub(crate) fn insert_synced_server_capabilities( + &mut self, + server_id: LanguageServerId, + capabilities_json: &str, + ) { + if let Some(capabilities) = + serde_json::from_str::(capabilities_json).log_err() + { + self.lsp_server_capabilities + .insert(server_id, capabilities.server_capabilities); + match capabilities.initial_server_capabilities { + Some(capabilities) => { + self.lsp_server_initial_capabilities + .insert(server_id, capabilities); + } + None => { + self.lsp_server_initial_capabilities.remove(&server_id); + } + } + match capabilities.text_document_registrations { + Some(registrations) => { + self.lsp_server_text_document_registrations + .insert(server_id, registrations); + } + None => { + self.lsp_server_text_document_registrations + .remove(&server_id); + } + } + } + } + + fn refresh_document_highlights( + &mut self, + for_server: Option, + cx: &mut Context, + ) { + cx.emit(LspStoreEvent::RefreshDocumentHighlights { + server_id: for_server, + }); + if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { + downstream_client + .send(proto::RefreshDocumentHighlights { + project_id: *project_id, + server_id: for_server.map(|server_id| server_id.to_proto()), + }) + .context("sending refresh document highlights downstream") + .log_err(); + } + } + + async fn handle_refresh_document_highlights( + lsp_store: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + lsp_store.update(&mut cx, |lsp_store, cx| { + let server_id = envelope.payload.server_id.map(LanguageServerId::from_proto); + lsp_store.refresh_document_highlights(server_id, cx); + }); + Ok(proto::Ack {}) + } + + fn remote_document_selector_context( + &self, + server_id: LanguageServerId, + language: Option<&LanguageName>, + ) -> Option { + let language = language?; + let server_name = &self.language_server_statuses.get(&server_id)?.name; + // Without the host's adapter we cannot compute its language id. Callers that the host + // re-filters may fail open, while consumers of synced metadata must fail closed. + let language_id = self + .languages + .lsp_adapters(language) + .iter() + .find(|adapter| &adapter.name() == server_name) + .map(|adapter| adapter.language_id(language))?; + Some(DocumentSelectorContext { + language_id, + scheme: "file", + }) + } + + fn remote_text_document_capability_matches( &self, buffer: &Entity, + server_id: LanguageServerId, + method: &str, + check: impl FnMut(AdapterServerCapabilities<'_>) -> bool, + cx: &App, + ) -> bool { + let registration_method = text_document_registration_method(method); + let language = buffer.read(cx).language().map(|language| language.name()); + let context = self.remote_document_selector_context(server_id, language.as_ref()); + remote_text_document_capabilities_match( + self.lsp_server_initial_capabilities.get(&server_id), + self.lsp_server_capabilities.get(&server_id), + self.lsp_server_text_document_registrations + .get(&server_id) + .and_then(|registrations| registrations.get(registration_method)), + context.as_ref(), + check, + ) + } + + pub(crate) fn text_document_capability_matches_for_server( + &self, + buffer: &Entity, + server_id: LanguageServerId, + method: &str, + mut check: impl FnMut(AdapterServerCapabilities<'_>) -> bool, + cx: &mut App, + ) -> bool { + if let Some(local) = self.as_local() { + return buffer.update(cx, |buffer, cx| { + let Some((adapter, server)) = local + .language_servers_for_buffer(buffer, cx) + .find(|(_, server)| server.server_id() == server_id) + else { + return false; + }; + text_document_capabilities_for_buffer(local, method, buffer, adapter, server) + .any(&mut check) + }); + } + + self.remote_text_document_capability_matches(buffer, server_id, method, check, cx) + } + + fn check_if_any_relevant_text_document_server_matches( + &self, + buffer: &Entity, + method: &str, mut check: F, cx: &App, ) -> bool where - F: FnMut(&LanguageServerStatus, &lsp::ServerCapabilities) -> bool, + F: FnMut(&lsp::LanguageServerName, AdapterServerCapabilities<'_>) -> bool, { + if let Some(local) = self.as_local() { + let buffer = buffer.read(cx); + return local + .buffers_opened_in_servers + .get(&buffer.remote_id()) + .into_iter() + .flatten() + .filter_map(|server_id| match local.language_servers.get(server_id)? { + LanguageServerState::Running { + adapter, server, .. + } => Some((adapter, server)), + _ => None, + }) + .any(|(adapter, server)| { + let server_name = server.name(); + text_document_capabilities_for_buffer(local, method, buffer, adapter, server) + .any(|capabilities| check(&server_name, capabilities)) + }); + } + self.relevant_server_ids_for_capability_check(buffer, cx) .into_iter() .filter_map(|server_id| { Some(( - self.language_server_statuses.get(&server_id)?, - self.lsp_server_capabilities.get(&server_id)?, + server_id, + &self.language_server_statuses.get(&server_id)?.name, )) }) - .any(|(server_status, capabilities)| check(server_status, capabilities)) - } - - fn check_if_capable_for_proto_request( - &self, - buffer: &Entity, - mut check: F, - cx: &App, - ) -> bool - where - F: FnMut(&lsp::ServerCapabilities) -> bool, - { - self.check_if_any_relevant_server_matches(buffer, |_, capabilities| check(capabilities), cx) + .any(|(server_id, server_name)| { + self.remote_text_document_capability_matches( + buffer, + server_id, + method, + |capabilities| check(server_name, capabilities), + cx, + ) + }) } pub fn supports_range_formatting(&self, buffer: &Entity, cx: &App) -> bool { @@ -5601,28 +6040,41 @@ impl LspStore { Formatter::None => false, Formatter::Auto => { settings.prettier.allowed - || self.check_if_capable_for_proto_request( + || self.check_if_any_relevant_text_document_server_matches( buffer, - server_capabilities_support_range_formatting, + "textDocument/rangeFormatting", + |_, capabilities| { + server_capabilities_support_range_formatting( + &capabilities.server_capabilities, + ) + }, cx, ) } Formatter::Prettier => true, Formatter::External { .. } => false, Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current) => { - self.check_if_capable_for_proto_request( + self.check_if_any_relevant_text_document_server_matches( buffer, - server_capabilities_support_range_formatting, + "textDocument/rangeFormatting", + |_, capabilities| { + server_capabilities_support_range_formatting( + &capabilities.server_capabilities, + ) + }, cx, ) } Formatter::LanguageServer( settings::LanguageServerFormatterSpecifier::Specific { name }, - ) => self.check_if_any_relevant_server_matches( + ) => self.check_if_any_relevant_text_document_server_matches( buffer, - |server_status, capabilities| { - server_status.name.0.as_ref() == name - && server_capabilities_support_range_formatting(capabilities) + "textDocument/rangeFormatting", + |server_name, capabilities| { + server_name.0.as_ref() == name + && server_capabilities_support_range_formatting( + &capabilities.server_capabilities, + ) }, cx, ), @@ -5637,11 +6089,12 @@ impl LspStore { fn all_capable_for_proto_request( &self, buffer: &Entity, + method: &str, mut check: F, cx: &App, ) -> Vec<(lsp::LanguageServerId, lsp::LanguageServerName)> where - F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool, + F: FnMut(&lsp::LanguageServerName, AdapterServerCapabilities<'_>) -> bool, { self.relevant_server_ids_for_capability_check(buffer, cx) .into_iter() @@ -5649,11 +6102,18 @@ impl LspStore { Some(( server_id, &self.language_server_statuses.get(&server_id)?.name, - self.lsp_server_capabilities.get(&server_id)?, )) }) - .filter(|(_, server_name, capabilities)| check(server_name, capabilities)) - .map(|(server_id, server_name, _)| (server_id, server_name.clone())) + .filter(|(server_id, server_name)| { + self.remote_text_document_capability_matches( + buffer, + *server_id, + method, + |capabilities| check(server_name, capabilities), + cx, + ) + }) + .map(|(server_id, server_name)| (server_id, server_name.clone())) .collect() } @@ -5679,24 +6139,48 @@ impl LspStore { ); } - let Some(language_server) = buffer.update(cx, |buffer, cx| match server { - LanguageServerToQuery::FirstCapable => self.as_local().and_then(|local| { - local - .language_servers_for_buffer(buffer, cx) - .find(|(_, server)| { - request.check_capabilities(server.adapter_server_capabilities()) - }) - .map(|(_, server)| server.clone()) - }), - LanguageServerToQuery::Other(id) => self - .language_server_for_local_buffer(buffer, id, cx) - .and_then(|(_, server)| { - request - .check_capabilities(server.adapter_server_capabilities()) - .then(|| Arc::clone(server)) - }), - }) else { - return Task::ready(Ok(Default::default())); + let query_outcome = buffer.update(cx, |buffer, cx| { + let mut early_response = None; + if let Some(local) = self.as_local() { + for (adapter, language_server) in local.language_servers_for_buffer(buffer, cx) { + if let LanguageServerToQuery::Other(id) = server + && language_server.server_id() != id + { + continue; + } + let mut applicable_capabilities = + applicable_lsp_command_capabilities_for_buffer( + local, + &request, + buffer, + adapter, + language_server, + ) + .peekable(); + if applicable_capabilities.peek().is_some() { + match request.response_without_request(&mut applicable_capabilities) { + None => { + return LanguageServerQueryOutcome::Query(Arc::clone( + language_server, + )); + } + Some(response) => { + if early_response.is_none() { + early_response = Some(response); + } + } + } + } + if let LanguageServerToQuery::Other(_) = server { + break; + } + } + } + LanguageServerQueryOutcome::Respond(early_response.unwrap_or_default()) + }); + let language_server = match query_outcome { + LanguageServerQueryOutcome::Query(language_server) => language_server, + LanguageServerQueryOutcome::Respond(response) => return Task::ready(Ok(response)), }; let file = File::from_dyn(buffer.read(cx).file()).and_then(File::as_local); @@ -5705,27 +6189,22 @@ impl LspStore { return Task::ready(Ok(Default::default())); }; - let lsp_params = match request.to_lsp_params_or_response( - &file.abs_path(cx), - buffer.read(cx), - &language_server, - cx, - ) { - Ok(LspParamsOrResponse::Params(lsp_params)) => lsp_params, - Ok(LspParamsOrResponse::Response(response)) => return Task::ready(Ok(response)), - Err(err) => { - let message = format!( - "{} via {} failed: {}", - request.display_name(), - language_server.name(), - err - ); - if should_log_lsp_request_failure(&message) { - log::warn!("{message}"); + let lsp_params = + match request.to_lsp(&file.abs_path(cx), buffer.read(cx), &language_server, cx) { + Ok(lsp_params) => lsp_params, + Err(err) => { + let err = err.context(format!( + "{} via {} failed", + request.display_name(), + language_server.name(), + )); + let message = format!("{err:#}"); + if should_log_lsp_request_failure(&message) { + log::warn!("{message}"); + } + return Task::ready(Err(err)); } - return Task::ready(Err(anyhow!(message))); - } - }; + }; let status = request.status(); let request_timeout = ProjectSettings::get_global(cx) @@ -5775,16 +6254,18 @@ impl LspStore { let result = lsp_request.await.into_response(); let response = result.map_err(|err| { - let message = format!( - "{} via {} failed: {}", + let context = format!( + "{} via {} failed", request.display_name(), - language_server.name(), - err + language_server.name() ); - if should_log_lsp_request_failure(&message) { - log::warn!("{message}"); + if should_log_lsp_response_error(&err) { + let message = format!("{context}: {err}"); + if should_log_lsp_request_failure(&message) { + log::warn!("{message}"); + } } - anyhow::anyhow!(message) + err.context(context) })?; request @@ -5997,6 +6478,33 @@ impl LspStore { } } + fn can_resolve_lsp_action_for_buffer( + &self, + buffer: &Entity, + action: &CodeAction, + cx: &mut App, + ) -> bool { + match &action.lsp_action { + LspAction::Action(_) => self.text_document_capability_matches_for_server( + buffer, + action.server_id, + "textDocument/codeAction", + |capabilities| { + GetCodeActions::can_resolve_actions(capabilities.server_capabilities) + }, + cx, + ), + LspAction::CodeLens(_) => self.text_document_capability_matches_for_server( + buffer, + action.server_id, + "textDocument/codeLens", + |capabilities| GetCodeLens::can_resolve_lens(capabilities.server_capabilities), + cx, + ), + LspAction::Command(_) => false, + } + } + pub fn apply_code_action( &self, buffer_handle: Entity, @@ -6025,6 +6533,7 @@ impl LspStore { .await }) } else if self.mode.is_local() { + let can_resolve = self.can_resolve_lsp_action_for_buffer(&buffer_handle, &action, cx); let Some((_, lang_server, request_timeout)) = buffer_handle.update(cx, |buffer, cx| { let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings @@ -6036,9 +6545,14 @@ impl LspStore { }; cx.spawn(async move |this, cx| { - LocalLspStore::try_resolve_code_action(&lang_server, &mut action, request_timeout) - .await - .context("resolving a code action")?; + LocalLspStore::try_resolve_code_action( + &lang_server, + &mut action, + can_resolve, + request_timeout, + ) + .await + .context("resolving a code action")?; if let Some(edit) = action.lsp_action.edit() && (edit.changes.is_some() || edit.document_changes.is_some()) { @@ -6110,6 +6624,67 @@ impl LspStore { } } + pub fn execute_lsp_command( + &self, + server_id: LanguageServerId, + command: String, + arguments: Vec, + cx: &mut Context, + ) -> Task>> { + if let Some((upstream_client, project_id)) = self.upstream_client() { + let request = upstream_client.request(proto::ExecuteLspCommand { + project_id, + language_server_id: server_id.to_proto(), + command, + arguments: arguments + .iter() + .map(|argument| argument.to_string()) + .collect(), + }); + cx.background_spawn(async move { + let response = request.await?; + response + .result + .map(|result| serde_json::from_str(&result)) + .transpose() + .context("deserializing executeCommand result") + }) + } else if self.mode.is_local() { + let Some(server) = self.language_server_for_id(server_id) else { + return Task::ready(Err(anyhow!("language server {server_id} not found"))); + }; + let available_commands = server + .capabilities() + .execute_command_provider + .as_ref() + .map(|options| options.commands.clone()) + .unwrap_or_default(); + if !available_commands.contains(&command) { + return Task::ready(Err(anyhow!( + "command {command} is not advertised by the language server" + ))); + } + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + cx.background_spawn(async move { + server + .request::( + lsp::ExecuteCommandParams { + command, + arguments, + ..lsp::ExecuteCommandParams::default() + }, + request_timeout, + ) + .await + .into_response() + }) + } else { + Task::ready(Err(anyhow!("no upstream client and not local"))) + } + } + pub fn resolve_code_action( &self, buffer: &Entity, @@ -6119,6 +6694,10 @@ impl LspStore { if action.resolved { return Task::ready(Ok(action)); } + if !self.can_resolve_lsp_action_for_buffer(buffer, &action, cx) { + action.resolved = true; + return Task::ready(Ok(action)); + } if let Some((upstream_client, project_id)) = self.upstream_client() { let request = proto::ResolveCodeAction { project_id, @@ -6145,9 +6724,14 @@ impl LspStore { .global_lsp_settings .get_request_timeout(); cx.background_spawn(async move { - LocalLspStore::try_resolve_code_action(&lang_server, &mut action, request_timeout) - .await - .context("resolving a code action")?; + LocalLspStore::try_resolve_code_action( + &lang_server, + &mut action, + true, + request_timeout, + ) + .await + .context("resolving a code action")?; Ok(action) }) } else { @@ -6309,7 +6893,7 @@ impl LspStore { local .language_servers_for_buffer(buffer, cx) .filter(|(_, server)| { - LinkedEditingRange::check_server_capabilities(server.capabilities()) + LinkedEditingRange::check_server_capabilities(&server.capabilities()) }) .filter(|(adapter, _)| { scope @@ -6355,10 +6939,14 @@ impl LspStore { cx: &mut Context, ) -> Task>> { if let Some((client, project_id)) = self.upstream_client() { - if !self.check_if_capable_for_proto_request( + if !self.check_if_any_relevant_text_document_server_matches( &buffer, - |capabilities| { - OnTypeFormatting::supports_on_type_formatting(&trigger, capabilities) + "textDocument/onTypeFormatting", + |_, capabilities| { + OnTypeFormatting::supports_on_type_formatting( + &trigger, + &capabilities.server_capabilities, + ) }, cx, ) { @@ -6423,9 +7011,15 @@ impl LspStore { push_to_history: bool, cx: &mut Context, ) -> Option>>> { - if !self.check_if_capable_for_proto_request( + if !self.check_if_any_relevant_text_document_server_matches( &buffer, - |capabilities| OnTypeFormatting::supports_on_type_formatting(&trigger, capabilities), + "textDocument/onTypeFormatting", + |_, capabilities| { + OnTypeFormatting::supports_on_type_formatting( + &trigger, + &capabilities.server_capabilities, + ) + }, cx, ) { return None; @@ -6994,10 +7588,6 @@ impl LspStore { let server_id = item.server_id; if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetIncomingCalls { item }; - if !self.is_capable_for_proto_request(&buffer, &request, cx) { - return Task::ready(Ok(None)); - } - let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); @@ -7054,10 +7644,6 @@ impl LspStore { let server_id = item.server_id; if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetOutgoingCalls { item }; - if !self.is_capable_for_proto_request(&buffer, &request, cx) { - return Task::ready(Ok(None)); - } - let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); @@ -7196,8 +7782,12 @@ impl LspStore { let scope = snapshot.language_scope_at(offset); let capable_lsps = self.all_capable_for_proto_request( buffer, + "textDocument/completion", |server_name, capabilities| { - capabilities.completion_provider.is_some() + capabilities + .server_capabilities + .completion_provider + .is_some() && scope .as_ref() .map(|scope| scope.language_allowed(server_name)) @@ -7278,10 +7868,17 @@ impl LspStore { return Task::ready(Ok(Vec::new())); } + let request = GetCompletions { + position, + context: context.clone(), + server_id: None, + }; let server_ids: Vec<_> = buffer.update(cx, |buffer, cx| { local .language_servers_for_buffer(buffer, cx) - .filter(|(_, server)| server.capabilities().completion_provider.is_some()) + .filter(|(adapter, server)| { + lsp_command_allowed_for_buffer(local, &request, buffer, adapter, server) + }) .filter(|(adapter, _)| { scope .as_ref() @@ -7377,14 +7974,24 @@ impl LspStore { let client = self.upstream_client(); let buffer_id = buffer.read(cx).remote_id(); let buffer_snapshot = buffer.read(cx).snapshot(); + let resolvable_servers = completion_indices + .iter() + .filter_map(|&completion_index| { + completions.borrow()[completion_index].source.server_id() + }) + .filter(|&server_id| { + self.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/completion", + |capabilities| { + GetCompletions::can_resolve_completions(capabilities.server_capabilities) + }, + cx, + ) + }) + .collect::>(); - if !self.check_if_capable_for_proto_request( - &buffer, - GetCompletions::can_resolve_completions, - cx, - ) { - return Task::ready(Ok(false)); - } cx.spawn(async move |lsp_store, cx| { let request_timeout = cx.update(|app| { ProjectSettings::get_global(app) @@ -7400,6 +8007,9 @@ impl LspStore { completion.source.server_id() }; if let Some(server_id) = server_id { + if !resolvable_servers.contains(&server_id) { + continue; + } if Self::resolve_completion_remote( project_id, server_id, @@ -7428,6 +8038,9 @@ impl LspStore { completion.source.server_id() }; if let Some(server_id) = server_id { + if !resolvable_servers.contains(&server_id) { + continue; + } let server_and_adapter = lsp_store .read_with(cx, |lsp_store, _| { let server = lsp_store.language_server_for_id(server_id)?; @@ -7446,6 +8059,7 @@ impl LspStore { completions.clone(), completion_index, request_timeout, + true, ) .await .log_err() @@ -7479,9 +8093,10 @@ impl LspStore { completions: Rc>>, completion_index: usize, request_timeout: Duration, + can_resolve: bool, ) -> Result<()> { let server_id = server.server_id(); - if !GetCompletions::can_resolve_completions(&server.capabilities()) { + if !can_resolve { return Ok(()); } @@ -7682,6 +8297,10 @@ impl LspStore { .await .context("completion documentation resolve proto request")?; let resolved_lsp_completion = serde_json::from_slice(&response.lsp_completion)?; + let replace_range = response + .old_replace_start + .and_then(deserialize_anchor) + .zip(response.old_replace_end.and_then(deserialize_anchor)); let documentation = if response.documentation.is_empty() { CompletionDocumentation::Undocumented @@ -7704,11 +8323,13 @@ impl LspStore { lsp_defaults: _, } = &mut completion.source { - let completion_insert_range = response - .old_insert_start - .and_then(deserialize_anchor) - .zip(response.old_insert_end.and_then(deserialize_anchor)); - *insert_range = completion_insert_range.map(|(start, end)| start..end); + if replace_range.is_some() { + let completion_insert_range = response + .old_insert_start + .and_then(deserialize_anchor) + .zip(response.old_insert_end.and_then(deserialize_anchor)); + *insert_range = completion_insert_range.map(|(start, end)| start..end); + } if *resolved { return Ok(()); @@ -7721,10 +8342,6 @@ impl LspStore { *resolved = true; } - let replace_range = response - .old_replace_start - .and_then(deserialize_anchor) - .zip(response.old_replace_end.and_then(deserialize_anchor)); if let Some((old_replace_start, old_replace_end)) = replace_range && !response.new_text.is_empty() { @@ -7789,14 +8406,21 @@ impl LspStore { .global_lsp_settings .get_request_timeout(); + let Some(server_id) = completions.borrow()[completion_index].source.server_id() else { + return Task::ready(Ok(None)); + }; + let can_resolve = self.text_document_capability_matches_for_server( + &buffer_handle, + server_id, + "textDocument/completion", + |capabilities| { + GetCompletions::can_resolve_completions(capabilities.server_capabilities) + }, + cx, + ); let Some(server) = buffer_handle.update(cx, |buffer, cx| { - let completion = &completions.borrow()[completion_index]; - let server_id = completion.source.server_id()?; - Some( - self.language_server_for_local_buffer(buffer, server_id, cx)? - .1 - .clone(), - ) + self.language_server_for_local_buffer(buffer, server_id, cx) + .map(|(_, server)| server.clone()) }) else { return Task::ready(Ok(None)); }; @@ -7807,6 +8431,7 @@ impl LspStore { completions.clone(), completion_index, request_timeout, + can_resolve, ) .await .context("resolving completion")?; @@ -7892,32 +8517,15 @@ impl LspStore { let buffer_id = buffer.read(cx).remote_id(); if let Some((client, upstream_project_id)) = self.upstream_client() { - let mut suitable_capabilities = None; - // Are we capable for proto request? - let any_server_has_diagnostics_provider = self.check_if_capable_for_proto_request( - &buffer, - |capabilities| { - if let Some(caps) = &capabilities.diagnostic_provider { - suitable_capabilities = Some(caps.clone()); - true - } else { - false - } - }, - cx, - ); // We don't really care which caps are passed into the request, as they're ignored by RPC anyways. - let Some(dynamic_caps) = suitable_capabilities else { - return Task::ready(Ok(None)); - }; - assert!(any_server_has_diagnostics_provider); - - let identifier = buffer_diagnostic_identifier(&dynamic_caps); let request = GetDocumentDiagnostics { previous_result_id: None, - identifier, + identifier: None, registration_id: None, }; + if !self.is_capable_for_proto_request(&buffer, &request, cx) { + return Task::ready(Ok(None)); + } let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); @@ -7938,28 +8546,48 @@ impl LspStore { } else { let servers = buffer.update(cx, |buffer, cx| { self.running_language_servers_for_local_buffer(buffer, cx) - .map(|(_, server)| server.clone()) + .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }); let pull_diagnostics = servers .into_iter() - .flat_map(|server| { + .flat_map(|(adapter, server)| { let result = maybe!({ let local = self.as_local()?; let server_id = server.server_id(); - let providers_with_identifiers = local + let mut providers_with_identifiers = Vec::new(); + if let Some(provider) = local + .initial_server_capabilities + .get(&server_id) + .and_then(|capabilities| capabilities.diagnostic_provider.clone()) + { + providers_with_identifiers.push((None, provider)); + } + if let Some(diagnostic_registrations) = local .language_server_dynamic_registrations .get(&server_id) - .into_iter() - .flat_map(|registrations| ®istrations.diagnostics) - .map(|(source, dynamic_caps)| { - ( - source.registration_id().map(str::to_string), - dynamic_caps.clone(), - ) + .and_then(|registrations| { + registrations.text_documents.get("textDocument/diagnostic") }) - .collect::>(); + { + for (registration_id, registration) in diagnostic_registrations { + if !dynamic_text_document_registration_allows_buffer( + registration, + buffer.read(cx), + &adapter, + ) { + continue; + } + let Some(provider) = + registration.server_capabilities.diagnostic_provider.clone() + else { + continue; + }; + providers_with_identifiers + .push((Some(registration_id.clone()), provider)); + } + } Some( providers_with_identifiers .into_iter() @@ -8040,7 +8668,10 @@ impl LspStore { cx: &mut Context, ) -> HashMap, Task>> { let next_hint_id = self.next_hint_id.clone(); - let current_servers = self.relevant_server_ids_for_capability_check(&buffer, cx); + let capability_probe = InlayHints { + range: text::Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), + }; + let current_servers = self.language_server_ids_for_request(&buffer, &capability_probe, cx); let lsp_data = self.latest_lsp_data(&buffer, cx); let query_version = lsp_data.buffer_version.clone(); if let InvalidationStrategy::RefreshRequested { server_id } = invalidate { @@ -8320,13 +8951,19 @@ impl LspStore { let Some(local) = self.as_local() else { return false; }; - let Some(registrations) = local.language_server_dynamic_registrations.get(&server_id) - else { - return false; - }; - registrations.diagnostics.iter().any(|(source, _)| { - source.registration_id() == registration_id.as_ref().map(|id| id.as_ref()) - }) + match registration_id { + Some(registration_id) => local + .language_server_dynamic_registrations + .get(&server_id) + .and_then(|registrations| { + registrations.text_documents.get("textDocument/diagnostic") + }) + .is_some_and(|registrations| registrations.contains_key(registration_id.as_ref())), + None => local + .initial_server_capabilities + .get(&server_id) + .is_some_and(|capabilities| capabilities.diagnostic_provider.is_some()), + } } pub fn pull_diagnostics_for_buffer( @@ -8334,17 +8971,32 @@ impl LspStore { buffer: Entity, cx: &mut Context, ) -> Task> { - let diagnostics = self.pull_diagnostics(buffer, cx); + let mut diagnostics_task = self.pull_diagnostics(buffer.clone(), cx); cx.spawn(async move |lsp_store, cx| { - let diagnostics = match diagnostics.await { - Ok(Some(diagnostics)) => diagnostics, - Ok(None) => return Ok(()), - Err(error) if should_log_lsp_request_failure(&format!("{error:#}")) => { - return Err(error).context("pulling diagnostics"); + let mut retriggers_left = DOCUMENT_DIAGNOSTICS_RETRIGGER_LIMIT; + let diagnostics = loop { + match diagnostics_task.await { + Ok(Some(diagnostics)) => break diagnostics, + Ok(None) => return Ok(()), + Err(error) + if retriggers_left > 0 && lsp_pull_cancelled_with_retrigger(&error) => + { + retriggers_left -= 1; + cx.background_executor() + .timer(DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY) + .await; + diagnostics_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics(buffer.clone(), cx) + })?; + } + Err(error) + if should_log_lsp_response_error(&error) + && should_log_lsp_request_failure(&format!("{error:#}")) => + { + return Err(error).context("pulling diagnostics"); + } + Err(_) => return Ok(()), } - // This is a weird way to suppress diagnostic failures on server side cancellation, - // we should actually retry the request here? - Err(_) => return Ok(()), }; lsp_store.update(cx, |lsp_store, cx| { if lsp_store.as_local().is_none() { @@ -9168,6 +9820,13 @@ impl LspStore { .unwrap_or_default() } + pub fn language_server_ids_for_opened_buffer( + &self, + buffer_id: BufferId, + ) -> Option<&HashSet> { + self.as_local()?.buffers_opened_in_servers.get(&buffer_id) + } + pub fn language_server_for_local_buffer<'a>( &'a self, buffer: &'a Buffer, @@ -9179,12 +9838,29 @@ impl LspStore { .find(|(_, s)| s.server_id() == server_id) } + pub(crate) fn language_server_capable_of_lsp_request( + &self, + buffer: &Entity, + server_id: LanguageServerId, + request: &R, + cx: &mut App, + ) -> bool { + self.text_document_capability_matches_for_server( + buffer, + server_id, + ::METHOD, + |capabilities| request.check_capabilities(capabilities), + cx, + ) + } + fn remove_worktree(&mut self, id_to_remove: WorktreeId, cx: &mut Context) { self.diagnostic_summaries.remove(&id_to_remove); if let Some(local) = self.as_local_mut() { let to_remove = local.remove_worktree(id_to_remove, cx); for server in to_remove { self.language_server_statuses.remove(&server); + self.cleanup_lsp_data(server); } } } @@ -9195,6 +9871,20 @@ impl LspStore { changes: &UpdatedEntriesSet, cx: &mut Context, ) { + // A removed path takes its stored diagnostics with it, or reopening a file + // recreated at the same path replays them from the worktree store via + // initialize_buffer, showing verdicts about content that no longer exists. + if let Some(local) = self.as_local_mut() + && let Some(diagnostics_for_tree) = local.diagnostics.get_mut(&worktree_id) + { + for (path, _, _) in changes + .iter() + .filter(|(_, _, change)| *change == PathChange::Removed) + { + diagnostics_for_tree.remove(path); + } + } + let Some(summaries_for_tree) = self.diagnostic_summaries.get_mut(&worktree_id) else { return; }; @@ -9252,7 +9942,10 @@ impl LspStore { self.downstream_client = Some((downstream_client.clone(), project_id)); for (server_id, status) in &self.language_server_statuses { - if let Some(server) = self.language_server_for_id(*server_id) { + if let Some(server) = self.language_server_for_id(*server_id) + && let Some(capabilities) = + self.serialize_synced_server_capabilities(&server).log_err() + { downstream_client .send(proto::StartLanguageServer { project_id, @@ -9265,8 +9958,7 @@ impl LspStore { .as_ref() .map(|name| name.to_proto()), }), - capabilities: serde_json::to_string(&server.capabilities()) - .expect("serializing server LSP capabilities"), + capabilities, }) .log_err(); } @@ -9302,10 +9994,7 @@ impl LspStore { .zip(server_capabilities) .map(|(server, server_capabilities)| { let server_id = LanguageServerId(server.id as usize); - if let Ok(server_capabilities) = serde_json::from_str(&server_capabilities) { - self.lsp_server_capabilities - .insert(server_id, server_capabilities); - } + self.insert_synced_server_capabilities(server_id, &server_capabilities); let name = LanguageServerName::from_proto(server.name); let worktree = server.worktree_id.map(WorktreeId::from_proto); @@ -9412,6 +10101,38 @@ impl LspStore { Ok(()) } + // A path's diagnostics are dropped per server, through the same merge machinery that + // applies them: buffer sets, worktree diagnostics, summaries, downstream propagation + // and the DiagnosticsUpdated event all follow from one call. + fn clear_path_diagnostics_for_servers( + &mut self, + abs_path: PathBuf, + server_ids: Vec, + retain_diagnostic: impl Fn(&lsp::Uri, &Diagnostic, &App) -> bool + Clone, + cx: &mut Context, + ) { + if server_ids.is_empty() { + return; + } + let diagnostic_updates = server_ids + .into_iter() + .map(|server_id| DocumentDiagnosticsUpdate { + diagnostics: DocumentDiagnostics { + document_abs_path: abs_path.clone(), + version: None, + diagnostics: Vec::new(), + }, + result_id: None, + registration_id: None, + server_id, + disk_based_sources: Cow::Borrowed(&[]), + }) + .collect::>(); + self.merge_diagnostic_entries(diagnostic_updates, retain_diagnostic, cx) + .context("clearing path diagnostics") + .log_err(); + } + pub fn merge_diagnostic_entries<'a>( &mut self, diagnostic_updates: Vec>, @@ -9427,7 +10148,7 @@ impl LspStore { self.worktree_store.read(cx).find_worktree(abs_path, cx) else { log::warn!("skipping diagnostics update, no worktree found for path {abs_path:?}"); - return Ok(()); + continue; }; let worktree_id = worktree.read(cx).id(); @@ -9684,15 +10405,6 @@ impl LspStore { let abs_path = abs_path .to_file_path_ext(path_style) .map_err(|()| anyhow!("can't convert URI to path"))?; - - let fs = lsp_store.update(cx, |lsp_store, _cx| { - lsp_store.as_local().map(|local| local.fs.clone()) - })?; - let abs_path = if let Some(fs) = fs { - fs.canonicalize(&abs_path).await.unwrap_or(abs_path) - } else { - abs_path - }; let p = abs_path.clone(); let yarn_worktree = lsp_store .update(cx, move |lsp_store, cx| match lsp_store.as_local() { @@ -9713,11 +10425,16 @@ impl LspStore { } else { (Arc::::from(abs_path.as_path()), None) }; - let worktree = lsp_store.update(cx, |lsp_store, cx| { - lsp_store.worktree_store.update(cx, |worktree_store, cx| { - worktree_store.find_worktree(&worktree_root_target, cx) - }) - })?; + let worktree = if known_relative_path.is_some() { + lsp_store.read_with(cx, |lsp_store, cx| { + lsp_store + .worktree_store + .read(cx) + .find_worktree(&worktree_root_target, cx) + })? + } else { + find_worktree_for_lsp_path(&lsp_store, &abs_path, cx).await? + }; let (worktree, relative_path, source_ws) = if let Some(result) = worktree { let relative_path = known_relative_path.unwrap_or_else(|| result.1.clone()); (result.0, relative_path, None) @@ -9793,28 +10510,43 @@ impl LspStore { }) } - fn local_lsp_servers_for_buffer( + fn language_server_ids_for_request( &self, buffer: &Entity, - cx: &mut Context, - ) -> Vec { + request: &R, + cx: &mut App, + ) -> HashSet + where + R: LspCommand, + { let Some(local) = self.as_local() else { - return Vec::new(); + return self + .relevant_server_ids_for_capability_check(buffer, cx) + .into_iter() + .filter(|server_id| { + self.remote_text_document_capability_matches( + buffer, + *server_id, + ::METHOD, + |capabilities| request.check_capabilities(capabilities), + cx, + ) + }) + .collect(); }; - - let snapshot = buffer.read(cx).snapshot(); - + let buffer_id = buffer.read(cx).remote_id(); buffer.update(cx, |buffer, cx| { local .language_servers_for_buffer(buffer, cx) + .filter(|(adapter, server)| { + lsp_command_allowed_for_buffer(local, request, buffer, adapter, server) + }) .map(|(_, server)| server.server_id()) .filter(|server_id| { - self.as_local().is_none_or(|local| { - local - .buffers_opened_in_servers - .get(&snapshot.remote_id()) - .is_some_and(|servers| servers.contains(server_id)) - }) + local + .buffers_opened_in_servers + .get(&buffer_id) + .is_some_and(|servers| servers.contains(server_id)) }) .collect() }) @@ -9860,6 +10592,9 @@ impl LspStore { let server_ids = buffer.update(cx, |buffer, cx| { local .language_servers_for_buffer(buffer, cx) + .filter(|(adapter, server)| { + lsp_command_allowed_for_buffer(local, &request, buffer, adapter, server) + }) .filter(|(adapter, _)| { scope .as_ref() @@ -9900,7 +10635,9 @@ impl LspStore { match response_result { Ok(response) => responses.push((server_id, response)), // rust-analyzer likes to error with this when its still loading up - Err(e) if format!("{e:#}").ends_with("content modified") => (), + Err(e) + if !should_log_lsp_response_error(&e) + || !should_log_lsp_request_failure(&format!("{e:#}")) => {} Err(e) => log::error!("Error handling response for request {request:?}: {e:#}"), } } @@ -9949,9 +10686,25 @@ impl LspStore { } async fn handle_lsp_command( + this: Entity, + envelope: TypedEnvelope, + cx: AsyncApp, + ) -> Result<::Response> + where + ::Params: Send, + ::Result: Send, + { + Self::handle_routed_lsp_command(this, envelope, cx, |request: &T| { + request.language_server_to_query() + }) + .await + } + + async fn handle_routed_lsp_command( this: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, + server_to_query: impl FnOnce(&T) -> LanguageServerToQuery, ) -> Result<::Response> where ::Params: Send, @@ -9969,14 +10722,10 @@ impl LspStore { cx.clone(), ) .await?; + let server_to_query = server_to_query(&request); let response = this .update(&mut cx, |this, cx| { - this.request_lsp( - buffer_handle.clone(), - LanguageServerToQuery::FirstCapable, - request, - cx, - ) + this.request_lsp(buffer_handle.clone(), server_to_query, request, cx) }) .await?; this.update(&mut cx, |this, cx| { @@ -10224,6 +10973,7 @@ impl LspStore { Self::deduplicate_range_based_lsp_requests::( &lsp_store, server_id, + sender_id, lsp_request_id, &inlay_hints, query_start..query_end, @@ -10257,9 +11007,14 @@ impl LspStore { .await?; } Request::GetIncomingCalls(get_incoming_calls) => { + let item_server_id = get_incoming_calls + .item + .as_ref() + .map(|item| LanguageServerId::from_proto(item.server_id)) + .context("missing call hierarchy item")?; Self::query_lsp_locally::( lsp_store, - server_id, + Some(item_server_id), sender_id, lsp_request_id, get_incoming_calls, @@ -10269,9 +11024,14 @@ impl LspStore { .await?; } Request::GetOutgoingCalls(get_outgoing_calls) => { + let item_server_id = get_outgoing_calls + .item + .as_ref() + .map(|item| LanguageServerId::from_proto(item.server_id)) + .context("missing call hierarchy item")?; Self::query_lsp_locally::( lsp_store, - server_id, + Some(item_server_id), sender_id, lsp_request_id, get_outgoing_calls, @@ -10295,6 +11055,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: server_id, + sender_id, }; if ::ProtoRequest::stop_previous_requests( ) { @@ -10606,21 +11367,11 @@ impl LspStore { mut cx: AsyncApp, ) -> Result<()> { let server = envelope.payload.server.context("invalid server")?; - let server_capabilities = - serde_json::from_str::(&envelope.payload.capabilities) - .with_context(|| { - format!( - "incorrect server capabilities {}", - envelope.payload.capabilities - ) - })?; lsp_store.update(&mut cx, |lsp_store, cx| { let server_id = LanguageServerId(server.id as usize); let server_name = LanguageServerName::from_proto(server.name.clone()); let language_name = server.language_name.map(LanguageName::from_proto); - lsp_store - .lsp_server_capabilities - .insert(server_id, server_capabilities); + lsp_store.insert_synced_server_capabilities(server_id, &envelope.payload.capabilities); if let Some(ref lang_name) = language_name { lsp_store.try_register_remote_adapter_locally(&server_name, lang_name); @@ -10784,6 +11535,29 @@ impl LspStore { Ok(proto::Ack {}) } + async fn handle_execute_lsp_command( + lsp_store: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let server_id = LanguageServerId::from_proto(envelope.payload.language_server_id); + let arguments = envelope + .payload + .arguments + .iter() + .map(|argument| serde_json::from_str(argument)) + .collect::, _>>() + .context("deserializing executeCommand arguments")?; + let result = lsp_store + .update(&mut cx, |lsp_store, cx| { + lsp_store.execute_lsp_command(server_id, envelope.payload.command, arguments, cx) + }) + .await?; + Ok(proto::ExecuteLspCommandResponse { + result: result.map(|result| result.to_string()), + }) + } + async fn handle_lsp_ext_run_flycheck( lsp_store: Entity, envelope: TypedEnvelope, @@ -10947,6 +11721,19 @@ impl LspStore { .map(|(key, value)| (*key, value)) } + fn should_forward_untagged_binary_status(&self, server_name: &LanguageServerName) -> bool { + self.as_local().is_some_and(|local| { + local + .language_server_ids + .keys() + .any(|seed| &seed.name == server_name) + || self + .language_server_statuses + .values() + .any(|status| &status.name == server_name) + }) + } + #[cfg(feature = "test-support")] pub fn has_language_server_seed_for_worktree(&self, worktree_id: WorktreeId) -> bool { self.as_local().is_some_and(|local| { @@ -11137,12 +11924,12 @@ impl LspStore { ); } lsp::ProgressParamsValue::WorkspaceDiagnostic(report) => { - let registration_id = match progress_params.token { - lsp::NumberOrString::Number(_) => None, - lsp::NumberOrString::String(token) => token - .split_once(WORKSPACE_DIAGNOSTICS_TOKEN_START) - .map(|(_, id)| id.to_owned()), + let lsp::NumberOrString::String(token) = progress_params.token else { + return; }; + let registration_id = token + .split_once(WORKSPACE_DIAGNOSTICS_TOKEN_START) + .map(|(_, id)| id.to_owned()); if let Some(LanguageServerState::Running { workspace_diagnostics_refresh_tasks, .. @@ -11151,6 +11938,8 @@ impl LspStore { .and_then(|local| local.language_servers.get_mut(&language_server_id)) && let Some(workspace_diagnostics) = workspace_diagnostics_refresh_tasks.get_mut(®istration_id) + && workspace_diagnostics.current_request_token.as_deref() + == Some(token.as_str()) { workspace_diagnostics.progress_tx.try_send(()).ok(); self.apply_workspace_diagnostic_report( @@ -11349,8 +12138,19 @@ impl LspStore { let lsp_completion = serde_json::from_slice(&envelope.payload.lsp_completion)?; let completion = this - .read_with(&cx, |this, cx| { + .update(&mut cx, |this, cx| { let id = LanguageServerId(envelope.payload.language_server_id as usize); + let buffer_id = BufferId::new(envelope.payload.buffer_id)?; + let buffer = this.buffer_store.read(cx).get_existing(buffer_id)?; + let can_resolve = this.text_document_capability_matches_for_server( + &buffer, + id, + "textDocument/completion", + |capabilities| { + GetCompletions::can_resolve_completions(capabilities.server_capabilities) + }, + cx, + ); let server = this .language_server_for_id(id) .with_context(|| format!("No language server {id}"))?; @@ -11360,12 +12160,6 @@ impl LspStore { .get_request_timeout(); anyhow::Ok(cx.background_spawn(async move { - let can_resolve = server - .capabilities() - .completion_provider - .as_ref() - .and_then(|options| options.resolve_provider) - .unwrap_or(false); if can_resolve { server .request::( @@ -12093,6 +12887,7 @@ impl LspStore { local .language_server_dynamic_registrations .remove(&server_id); + local.initial_server_capabilities.remove(&server_id); let server_state = local.language_servers.remove(&server_id); self.cleanup_lsp_data(server_id); @@ -12110,17 +12905,18 @@ impl LspStore { if let Some(name) = name { log::info!("stopping language server {name}"); - self.languages - .update_lsp_binary_status(name.clone(), BinaryStatus::Stopping); + if let Some(local) = self.as_local() { + local.update_binary_status(name.clone(), BinaryStatus::Stopping); + } cx.notify(); return cx.spawn(async move |lsp_store, cx| { Self::shutdown_language_server(server_state, name.clone(), cx).await; lsp_store .update(cx, |lsp_store, cx| { - lsp_store - .languages - .update_lsp_binary_status(name, BinaryStatus::Stopped); + if let Some(local) = lsp_store.as_local() { + local.update_binary_status(name, BinaryStatus::Stopped); + } cx.emit(LspStoreEvent::LanguageServerRemoved(server_id)); cx.notify(); }) @@ -12639,22 +13435,15 @@ impl LspStore { // indicating that the server is up and running and ready let workspace_folders = workspace_folders.lock().clone(); language_server.set_workspace_folders(workspace_folders); + let server_capabilities = language_server.capabilities(); + local + .initial_server_capabilities + .insert(server_id, server_capabilities.clone()); - let workspace_diagnostics_refresh_tasks = language_server - .capabilities() + let workspace_diagnostics_refresh_tasks = server_capabilities .diagnostic_provider + .clone() .and_then(|provider| { - let diagnostics = &mut local - .language_server_dynamic_registrations - .entry(server_id) - .or_default() - .diagnostics; - if !diagnostics - .iter() - .any(|(source, _)| source.registration_id().is_none()) - { - diagnostics.push((RegistrationSource::Static, provider.clone())); - } let workspace_refresher = lsp_workspace_diagnostics_refresh(None, provider, language_server.clone(), cx)?; @@ -12671,9 +13460,7 @@ impl LspStore { simulate_disk_based_diagnostics_completion: None, }, ); - local - .languages - .update_lsp_binary_status(adapter.name(), BinaryStatus::None); + local.update_binary_status(adapter.name(), BinaryStatus::None); if let Some(file_ops_caps) = language_server .capabilities() .workspace @@ -12716,8 +13503,11 @@ impl LspStore { Some(key.worktree_id), )); - let server_capabilities = language_server.capabilities(); - if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { + if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() + && let Some(capabilities) = self + .serialize_synced_server_capabilities(&language_server) + .log_err() + { downstream_client .send(proto::StartLanguageServer { project_id: *project_id, @@ -12727,8 +13517,7 @@ impl LspStore { worktree_id: Some(key.worktree_id.to_proto()), language_name: Some(language_name.to_proto()), }), - capabilities: serde_json::to_string(&server_capabilities) - .expect("serializing server LSP capabilities"), + capabilities, }) .log_err(); } @@ -12968,13 +13757,27 @@ impl LspStore { cx: &mut Context, ) { if let Some(local) = self.as_local_mut() { + local + .initial_server_capabilities + .insert(id, server.capabilities()); local .supplementary_language_servers .insert(id, (name.clone(), server)); - cx.emit(LspStoreEvent::LanguageServerAdded(id, name, None)); + cx.emit(LspStoreEvent::SupplementaryLanguageServerAdded(id, name)); } } + #[cfg(feature = "test-support")] + pub fn register_supplementary_language_server_for_test( + &mut self, + id: LanguageServerId, + name: LanguageServerName, + server: Arc, + cx: &mut Context, + ) { + self.register_supplementary_language_server(id, name, server, cx); + } + fn unregister_supplementary_language_server( &mut self, id: LanguageServerId, @@ -12982,19 +13785,18 @@ impl LspStore { ) { if let Some(local) = self.as_local_mut() { local.supplementary_language_servers.remove(&id); - cx.emit(LspStoreEvent::LanguageServerRemoved(id)); + local.initial_server_capabilities.remove(&id); + cx.emit(LspStoreEvent::SupplementaryLanguageServerRemoved(id)); } } - pub(crate) fn supplementary_language_servers( - &self, - ) -> impl '_ + Iterator { - self.as_local().into_iter().flat_map(|local| { - local - .supplementary_language_servers - .iter() - .map(|(id, (name, _))| (*id, name.clone())) - }) + #[cfg(feature = "test-support")] + pub fn unregister_supplementary_language_server_for_test( + &mut self, + id: LanguageServerId, + cx: &mut Context, + ) { + self.unregister_supplementary_language_server(id, cx); } pub fn language_server_adapter_for_id( @@ -13354,6 +14156,9 @@ impl LspStore { fn cleanup_lsp_data(&mut self, for_server: LanguageServerId) { self.lsp_server_capabilities.remove(&for_server); + self.lsp_server_initial_capabilities.remove(&for_server); + self.lsp_server_text_document_registrations + .remove(&for_server); self.semantic_token_config.remove_server_data(for_server); for lsp_data in self.lsp_data.values_mut() { lsp_data.remove_server_data(for_server); @@ -13393,6 +14198,27 @@ impl LspStore { /// Gets all result_ids for a workspace diagnostics pull request. /// First, it tries to find buffer's result_id retrieved via the diagnostics pull; if it fails, it falls back to the workspace disagnostics pull result_id. /// The latter is supposed to be of lower priority as we keep on pulling diagnostics for open buffers eagerly. + fn set_workspace_refresh_token( + &mut self, + server_id: LanguageServerId, + registration_id: &Option, + token: Option, + ) { + if let Some(workspace_diagnostics) = self + .as_local_mut() + .and_then(|local| local.language_servers.get_mut(&server_id)) + .and_then(|state| match state { + LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } => workspace_diagnostics_refresh_tasks.get_mut(registration_id), + _ => None, + }) + { + workspace_diagnostics.current_request_token = token; + } + } + pub fn result_ids_for_workspace_refresh( &self, server_id: LanguageServerId, @@ -13497,15 +14323,32 @@ impl LspStore { buffer_id: BufferId, cx: &mut Context, ) { - let Some(local) = self.as_local_mut() else { + let Some(buffer) = self.buffer_store.read(cx).get(buffer_id) else { return; }; - let Some(languages_servers) = local.buffers_opened_in_servers.get(&buffer_id).cloned() + let Some(language_servers) = self + .language_server_ids_for_opened_buffer(buffer_id) + .cloned() else { return; }; - for server_id in languages_servers { - let _ = self.pull_document_diagnostics_for_server(server_id, Some(buffer_id), cx); + for server_id in language_servers { + let inter_file_dependencies = self.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/diagnostic", + |capabilities| { + capabilities + .server_capabilities + .diagnostic_provider + .as_ref() + .is_some_and(diagnostics_inter_file_dependencies) + }, + cx, + ); + if inter_file_dependencies { + let _ = self.pull_document_diagnostics_for_server(server_id, Some(buffer_id), cx); + } } } @@ -13722,6 +14565,7 @@ impl LspStore { async fn deduplicate_range_based_lsp_requests( lsp_store: &Entity, server_id: Option, + sender_id: proto::PeerId, lsp_request_id: LspRequestId, proto_request: &T::ProtoRequest, range: Range, @@ -13751,6 +14595,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: server_id, + sender_id, }; let previous_request = lsp_data .chunk_lsp_requests @@ -13796,6 +14641,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: for_server_id, + sender_id, }; lsp_store.update(cx, |lsp_store, cx| { let request_task = match for_server_id { @@ -13811,7 +14657,9 @@ impl LspStore { match server_task.await { Ok(response) => responses.push((server_id, response)), // rust-analyzer likes to error with this when its still loading up - Err(e) if format!("{e:#}").ends_with("content modified") => (), + Err(e) + if !should_log_lsp_response_error(&e) + || !should_log_lsp_request_failure(&format!("{e:#}")) => {} Err(e) => log::error!( "Error handling response for request {request:?}: {e:#}" ), @@ -13853,6 +14701,7 @@ impl LspStore { .collect::>(); match client.send_lsp_response::( project_id, + sender_id, lsp_request_id, response, ) { @@ -13892,6 +14741,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: server_id, + sender_id, }; if T::ProtoRequest::stop_previous_requests() { if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) { @@ -13922,6 +14772,7 @@ impl LspStore { .collect::>(); if let Err(e) = client.send_lsp_response::( project_id, + sender_id, lsp_request_id, response, ) { @@ -13986,6 +14837,258 @@ impl LspStore { } } +fn document_selector_context_for_buffer( + buffer: &Buffer, + adapter: &CachedLspAdapter, +) -> Option { + let language = buffer.language()?; + Some(document_selector_context_for_language( + &language.name(), + adapter, + )) +} + +fn document_selector_context_for_language( + language: &LanguageName, + adapter: &CachedLspAdapter, +) -> DocumentSelectorContext { + DocumentSelectorContext { + language_id: adapter.language_id(language), + scheme: "file", + } +} + +fn document_selector_matches( + document_selector: Option<&lsp::DocumentSelector>, + context: &DocumentSelectorContext, +) -> bool { + let Some(document_selector) = document_selector else { + return true; + }; + + document_selector.iter().any(|filter| { + if let Some(language) = &filter.language + && language != &context.language_id + { + return false; + } + + if let Some(scheme) = &filter.scheme + && !scheme.eq_ignore_ascii_case(context.scheme) + { + return false; + } + + true + }) +} + +fn remote_text_document_capabilities_match( + initial_capabilities: Option<&lsp::ServerCapabilities>, + merged_capabilities: Option<&lsp::ServerCapabilities>, + registrations: Option< + &collections::IndexMap, + >, + context: Option<&DocumentSelectorContext>, + mut check: impl FnMut(AdapterServerCapabilities<'_>) -> bool, +) -> bool { + let Some(initial_capabilities) = initial_capabilities else { + return merged_capabilities.is_some_and(|server_capabilities| { + check(AdapterServerCapabilities { + server_capabilities, + code_action_kinds: None, + }) + }); + }; + + if check(AdapterServerCapabilities { + server_capabilities: initial_capabilities, + code_action_kinds: None, + }) { + return true; + } + + let Some(registrations) = registrations else { + return false; + }; + registrations.values().any(|registration| { + context.is_none_or(|context| { + document_selector_matches(registration.document_selector.as_ref(), context) + }) && check(AdapterServerCapabilities { + server_capabilities: ®istration.server_capabilities, + code_action_kinds: None, + }) + }) +} + +fn dynamic_text_document_registration_allows_buffer( + registration: &dynamic_registration::DynamicTextDocumentRegistration, + buffer: &Buffer, + adapter: &CachedLspAdapter, +) -> bool { + let Some(context) = document_selector_context_for_buffer(buffer, adapter) else { + return true; + }; + + document_selector_matches(registration.document_selector.as_ref(), &context) +} + +fn text_document_registration_method(method: &str) -> &str { + // Some related requests have distinct methods but share one registration method. + match method { + "textDocument/prepareRename" => "textDocument/rename", + "textDocument/semanticTokens/full" + | "textDocument/semanticTokens/full/delta" + | "textDocument/semanticTokens/range" => "textDocument/semanticTokens", + _ => method, + } +} + +fn text_document_capabilities_for_buffer<'a>( + local: &'a LocalLspStore, + method: &str, + buffer: &'a Buffer, + adapter: &'a CachedLspAdapter, + server: &'a LanguageServer, +) -> impl Iterator> { + let code_action_kinds = server.code_action_kinds(); + let static_capabilities = local + .initial_server_capabilities + .get(&server.server_id()) + .map(move |server_capabilities| AdapterServerCapabilities { + server_capabilities, + code_action_kinds, + }); + + let registration_method = text_document_registration_method(method); + let dynamic_capabilities = local + .language_server_dynamic_registrations + .get(&server.server_id()) + .and_then(|registrations| registrations.text_documents.get(registration_method)) + .into_iter() + .flat_map(|registrations| registrations.values()) + .filter(move |registration| { + dynamic_text_document_registration_allows_buffer(registration, buffer, adapter) + }) + .map(move |registration| AdapterServerCapabilities { + server_capabilities: ®istration.server_capabilities, + code_action_kinds, + }); + + static_capabilities.into_iter().chain(dynamic_capabilities) +} + +fn applicable_lsp_command_capabilities_for_buffer<'a, R>( + local: &'a LocalLspStore, + request: &'a R, + buffer: &'a Buffer, + adapter: &'a CachedLspAdapter, + server: &'a LanguageServer, +) -> impl Iterator> +where + R: LspCommand, +{ + text_document_capabilities_for_buffer( + local, + ::METHOD, + buffer, + adapter, + server, + ) + .filter(move |capabilities| request.check_capabilities(*capabilities)) +} + +fn lsp_command_allowed_for_buffer( + local: &LocalLspStore, + request: &R, + buffer: &Buffer, + adapter: &CachedLspAdapter, + server: &LanguageServer, +) -> bool +where + R: LspCommand, +{ + applicable_lsp_command_capabilities_for_buffer(local, request, buffer, adapter, server) + .next() + .is_some() +} + +fn buffer_supports_lsp_formatting( + local: &LocalLspStore, + buffer: &Buffer, + adapter: &CachedLspAdapter, + server: &LanguageServer, +) -> bool { + text_document_capabilities_for_buffer(local, "textDocument/formatting", buffer, adapter, server) + .any(|capabilities| { + server_capabilities_support_formatting(capabilities.server_capabilities) + }) +} + +fn buffer_supports_lsp_range_formatting( + local: &LocalLspStore, + buffer: &Buffer, + adapter: &CachedLspAdapter, + server: &LanguageServer, +) -> bool { + text_document_capabilities_for_buffer( + local, + "textDocument/rangeFormatting", + buffer, + adapter, + server, + ) + .any(|capabilities| { + server_capabilities_support_range_formatting(capabilities.server_capabilities) + }) +} + +fn completion_trigger_characters_for_buffer( + local: &LocalLspStore, + server_id: LanguageServerId, + adapter: &CachedLspAdapter, + buffer: &Buffer, +) -> BTreeSet { + fn extend_triggers(triggers: &mut BTreeSet, options: &lsp::CompletionOptions) { + if let Some(characters) = &options.trigger_characters { + triggers.extend(characters.iter().cloned()); + } + } + + let mut triggers = BTreeSet::new(); + if let Some(options) = local + .initial_server_capabilities + .get(&server_id) + .and_then(|capabilities| capabilities.completion_provider.as_ref()) + { + extend_triggers(&mut triggers, options); + } + if let Some(registrations) = local + .language_server_dynamic_registrations + .get(&server_id) + .and_then(|registrations| registrations.text_documents.get("textDocument/completion")) + { + for registration in registrations.values() { + if dynamic_text_document_registration_allows_buffer(registration, buffer, adapter) + && let Some(options) = registration + .server_capabilities + .completion_provider + .as_ref() + { + extend_triggers(&mut triggers, options); + } + } + } + triggers +} + +fn server_capabilities_support_formatting(capabilities: &lsp::ServerCapabilities) -> bool { + matches!( + capabilities.document_formatting_provider.as_ref(), + Some(provider) if *provider != OneOf::Left(false) + ) +} + fn server_capabilities_support_range_formatting(capabilities: &lsp::ServerCapabilities) -> bool { matches!( capabilities.document_range_formatting_provider.as_ref(), @@ -14005,15 +15108,118 @@ fn lsp_ranges_overlap(a: &lsp::Range, b: &lsp::Range) -> bool { a.start < b.end && b.start < a.end } +async fn find_worktree_for_lsp_path( + lsp_store: &WeakEntity, + abs_path: &Path, + cx: &mut AsyncApp, +) -> Result, Arc)>> { + let (fs, worktree) = lsp_store.read_with(cx, |lsp_store, cx| { + ( + lsp_store.as_local().map(|local| local.fs.clone()), + lsp_store + .worktree_store + .read(cx) + .find_worktree(abs_path, cx), + ) + })?; + match worktree { + Some((worktree, relative_path)) => { + let relative_path = + normalize_lsp_relative_path(&worktree, abs_path, relative_path, cx).await?; + Ok(Some((worktree, relative_path))) + } + None => { + let Some(fs) = fs else { + return Ok(None); + }; + let Ok(canonical_path) = fs.canonicalize(abs_path).await else { + return Ok(None); + }; + lsp_store.read_with(cx, |lsp_store, cx| { + lsp_store + .worktree_store + .read(cx) + .find_worktree(&canonical_path, cx) + }) + } + } +} + +async fn normalize_lsp_relative_path( + worktree: &Entity, + abs_path: &Path, + relative_path: Arc, + cx: &mut AsyncApp, +) -> Result> { + let Some((fs, snapshot, worktree_root)) = worktree.read_with(cx, |worktree, _| { + let local_worktree = worktree.as_local()?; + if local_worktree.fs_is_case_sensitive() { + return None; + } + Some(( + local_worktree.fs().clone(), + worktree.snapshot(), + worktree.abs_path(), + )) + }) else { + return Ok(relative_path); + }; + if snapshot.entry_for_path(&relative_path).is_some() { + return Ok(relative_path); + } + + let mut normalized_path = RelPathBuf::new(); + let mut components = relative_path.components(); + while let Some(component) = components.next() { + let mut exact_path = normalized_path.clone(); + exact_path.push_component(component)?; + if snapshot.entry_for_path(&exact_path).is_some() { + normalized_path = exact_path; + continue; + } + let case_insensitive_match = snapshot + .child_entries(&normalized_path) + .find(|entry| { + entry + .path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case(component)) + }) + .map(|entry| entry.path.to_rel_path_buf()); + let Some(matched_path) = case_insensitive_match else { + if let Ok(canonical_path) = fs.canonicalize(abs_path).await + && let Ok(canonical_root) = fs.canonicalize(&worktree_root).await + && let Ok(canonical_relative_path) = canonical_path.strip_prefix(&canonical_root) + && let Ok(canonical_relative_path) = + RelPath::new(canonical_relative_path, PathStyle::local()) + { + return Ok(canonical_relative_path.into_arc()); + } + exact_path.push(components.rest()); + return Ok(Arc::from(exact_path)); + }; + normalized_path = matched_path; + } + Ok(Arc::from(normalized_path)) +} + fn subscribe_to_binary_statuses( languages: &Arc, cx: &mut Context<'_, LspStore>, ) -> Task<()> { - let mut server_statuses = languages.language_server_binary_statuses(); + let (mut server_statuses, subscription) = languages.language_server_binary_statuses(); cx.spawn(async move |lsp_store, cx| { - while let Some((server_name, binary_status)) = server_statuses.next().await { + let _subscription = subscription; + while let Some((source, server_name, binary_status)) = server_statuses.next().await { if lsp_store - .update(cx, |_, cx| { + .update(cx, |lsp_store, cx| { + if source.is_some_and(|source| source != cx.entity_id()) + || source.is_none() + && !lsp_store.should_forward_untagged_binary_status(&server_name) + { + return; + } + let mut message = None; let binary_status = match binary_status { BinaryStatus::None => proto::ServerBinaryStatus::None, @@ -14062,7 +15268,7 @@ fn lsp_workspace_diagnostics_refresh( let registration_id_shared = registration_id.as_ref().map(SharedString::from); let (progress_tx, mut progress_rx) = mpsc::channel(1); - let (mut refresh_tx, mut refresh_rx) = mpsc::channel::>>(1); + let (mut refresh_tx, mut refresh_rx) = mpsc::channel::>>(8); refresh_tx.try_send(None).ok(); let request_timeout = ProjectSettings::get_global(cx) @@ -14080,6 +15286,7 @@ fn lsp_workspace_diagnostics_refresh( let workspace_query_language_server = cx.spawn(async move |lsp_store, cx| { let mut attempts = 0; let max_attempts = 50; + let retries_after_exhaustion = 3; let mut requests = 0; loop { @@ -14087,14 +15294,22 @@ fn lsp_workspace_diagnostics_refresh( return; }; let mut completion_txs = completion_tx.into_iter().collect::>(); + let mut queued_completion_txs = Vec::>::new(); 'request: loop { requests += 1; if attempts > max_attempts { log::error!( - "Failed to pull workspace diagnostics {max_attempts} times, aborting" + "Failed to pull workspace diagnostics {max_attempts} times, pausing until the next refresh" ); - return; + for tx in completion_txs + .drain(..) + .chain(queued_completion_txs.drain(..)) + { + tx.send(false).ok(); + } + attempts = max_attempts + 1 - retries_after_exhaustion; + break 'request; } let backoff_millis = (50 * (1 << attempts)).clamp(30, 1000); cx.background_executor() @@ -14104,11 +15319,26 @@ fn lsp_workspace_diagnostics_refresh( // Absorb refresh requests that queued up in the meantime, so their // waiters are resolved by this attempt instead of waiting behind it. + completion_txs.append(&mut queued_completion_txs); while let Ok(queued_refresh) = refresh_rx.try_recv() { completion_txs.extend(queued_refresh); } + let token = if let Some(registration_id) = ®istration_id { + format!( + "workspace/diagnostic/{}/{requests}/{WORKSPACE_DIAGNOSTICS_TOKEN_START}{registration_id}", + server.server_id(), + ) + } else { + format!("workspace/diagnostic/{}/{requests}", server.server_id()) + }; + let Ok(previous_result_ids) = lsp_store.update(cx, |lsp_store, _| { + lsp_store.set_workspace_refresh_token( + server.server_id(), + ®istration_id, + Some(token.clone()), + ); lsp_store .result_ids_for_workspace_refresh(server.server_id(), ®istration_id_shared) .into_iter() @@ -14124,60 +15354,77 @@ fn lsp_workspace_diagnostics_refresh( return; }; - let token = if let Some(registration_id) = ®istration_id { - format!( - "workspace/diagnostic/{}/{requests}/{WORKSPACE_DIAGNOSTICS_TOKEN_START}{registration_id}", - server.server_id(), - ) - } else { - format!("workspace/diagnostic/{}/{requests}", server.server_id()) - }; - progress_rx.try_recv().ok(); - // Restart the timeout whenever a partial result arrives: streaming - // servers may legitimately take longer than a single timeout period, - // but a server that stops streaming without completing the request - // should still time out instead of hanging forever. - let timer = async { + let mut refresh_requested = false; + let mut request = pin!( + server + .request::( + lsp::WorkspaceDiagnosticParams { + previous_result_ids, + identifier: identifier.clone(), + work_done_progress_params: Default::default(), + partial_result_params: lsp::PartialResultParams { + partial_result_token: Some(lsp::ProgressToken::String(token)), + }, + }, + Duration::MAX, + ) + .fuse() + ); + let response_result = 'response: loop { + let mut inactivity_timer = pin!(server.request_timer(timeout).fuse()); loop { - let timer = pin!(server.request_timer(timeout).fuse()); - let progress = pin!(progress_rx.recv().fuse()); - match select(timer, progress).await { - Either::Left((message, ..)) => break message, - Either::Right((Some(()), ..)) => {} - Either::Right((None, timer)) => break timer.await, + let mut progress = pin!(progress_rx.recv().fuse()); + let mut refresh = pin!(refresh_rx.recv().fuse()); + select_biased! { + response = request => break 'response response, + new_progress = progress => match new_progress { + Some(()) => continue 'response, + None => return, + }, + new_refresh = refresh => match new_refresh { + Some(completion_tx) => { + refresh_requested = true; + queued_completion_txs.extend(completion_tx); + } + None => return, + }, + _ = inactivity_timer => { + // Release everyone waiting on this refresh (e.g. the agent's + // diagnostics tool), including waiters that queued up while the + // request was in flight, so they can fall back to cached + // diagnostics instead of waiting through every retry. + for tx in completion_txs + .drain(..) + .chain(queued_completion_txs.drain(..)) + { + tx.send(false).ok(); + } + continue 'response; + } } } }; - let response_result = server - .request_with_timer::( - lsp::WorkspaceDiagnosticParams { - previous_result_ids, - identifier: identifier.clone(), - work_done_progress_params: Default::default(), - partial_result_params: lsp::PartialResultParams { - partial_result_token: Some(lsp::ProgressToken::String(token)), - }, - }, - timer, - ) - .await; + if lsp_store + .update(cx, |lsp_store, _| { + lsp_store.set_workspace_refresh_token( + server.server_id(), + ®istration_id, + None, + ); + }) + .is_err() + { + return; + } // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnostic_refresh // > If a server closes a workspace diagnostic pull request the client should re-trigger the request. match response_result { ConnectionResult::Timeout => { - log::error!("Timeout during workspace diagnostics pull"); - // Release everyone waiting on this refresh (e.g. the agent's - // diagnostics tool), including waiters that queued up while the - // request was in flight, so they can fall back to cached - // diagnostics instead of waiting through every retry. - while let Ok(queued_refresh) = refresh_rx.try_recv() { - completion_txs.extend(queued_refresh); - } - for tx in completion_txs.drain(..) { - tx.send(false).ok(); - } + debug_panic!( + "Unexpected workspace diagnostics pull timeout without a request timer" + ); continue 'request; } ConnectionResult::ConnectionReset => { @@ -14185,11 +15432,23 @@ fn lsp_workspace_diagnostics_refresh( continue 'request; } ConnectionResult::Result(Err(e)) => { - log::error!("Error during workspace diagnostics pull: {e:#}"); - for tx in completion_txs.drain(..) { - tx.send(false).ok(); + if lsp_pull_cancelled_with_retrigger(&e) { + attempts = 0; + } else { + if should_log_lsp_response_error(&e) { + log::error!("Error during workspace diagnostics pull: {e:#}"); + } else { + log::debug!("Workspace diagnostics pull denied: {e:#}"); + } + for tx in completion_txs.drain(..) { + tx.send(false).ok(); + } + if refresh_requested { + continue 'request; + } + debug_assert!(queued_completion_txs.is_empty()); + break 'request; } - break 'request; } ConnectionResult::Result(Ok(pulled_diagnostics)) => { attempts = 0; @@ -14209,9 +15468,26 @@ fn lsp_workspace_diagnostics_refresh( for tx in completion_txs.drain(..) { tx.send(true).ok(); } - break 'request; } } + + if refresh_requested { + continue 'request; + } + let mut repull_timer = pin!( + cx.background_executor() + .timer(WORKSPACE_DIAGNOSTICS_REPULL_DELAY) + .fuse() + ); + let mut refresh = pin!(refresh_rx.recv().fuse()); + select_biased! { + new_refresh = refresh => match new_refresh { + Some(completion_tx) => completion_txs.extend(completion_tx), + None => return, + }, + _ = repull_timer => {} + } + continue 'request; } } }); @@ -14219,10 +15495,24 @@ fn lsp_workspace_diagnostics_refresh( Some(WorkspaceRefreshTask { refresh_tx, progress_tx, + current_request_token: None, task: workspace_query_language_server, }) } +fn diagnostics_inter_file_dependencies(options: &DiagnosticServerCapabilities) -> bool { + match options { + lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => { + diagnostic_options.inter_file_dependencies + } + lsp::DiagnosticServerCapabilities::RegistrationOptions(registration_options) => { + registration_options + .diagnostic_options + .inter_file_dependencies + } + } +} + fn buffer_diagnostic_identifier(options: &DiagnosticServerCapabilities) -> Option { match &options { lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => diagnostic_options @@ -14424,6 +15714,11 @@ pub enum LanguageServerToQuery { Other(LanguageServerId), } +enum LanguageServerQueryOutcome { + Query(Arc), + Respond(T), +} + #[derive(Default)] struct RenamePathsWatchedForServer { did_rename: Vec, @@ -14664,6 +15959,30 @@ impl PartialEq for LanguageServerPromptRequest { } } +#[derive(Clone, Debug)] +pub struct LanguageServerShowDocumentRequest { + pub uri: lsp::Uri, + pub external: bool, + pub take_focus: bool, + pub selection: Option, + pub(crate) response_channel: async_channel::Sender, +} + +impl LanguageServerShowDocumentRequest { + pub fn respond(self, success: bool) { + self.response_channel.try_send(success).ok(); + } +} + +impl PartialEq for LanguageServerShowDocumentRequest { + fn eq(&self, other: &Self) -> bool { + self.uri == other.uri + && self.external == other.external + && self.take_focus == other.take_focus + && self.selection == other.selection + } +} + #[derive(Clone, Debug, PartialEq)] pub enum LanguageServerLogType { Log(MessageType), @@ -14752,6 +16071,7 @@ impl LanguageServerLogType { pub struct WorkspaceRefreshTask { refresh_tx: mpsc::Sender>>, progress_tx: mpsc::Sender<()>, + current_request_token: Option, #[allow(dead_code)] task: Task<()>, } @@ -15202,9 +16522,16 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { Ok(()) } + fn status_source_id(&self) -> gpui::EntityId { + self.lsp_store.entity_id() + } + fn update_status(&self, server_name: LanguageServerName, status: language::BinaryStatus) { - self.language_registry - .update_lsp_binary_status(server_name, status); + self.language_registry.update_lsp_binary_status_for_entity( + self.status_source_id(), + server_name, + status, + ); } fn registered_lsp_adapters(&self) -> Vec> { @@ -15397,11 +16724,11 @@ pub fn ensure_uniform_list_compatible_label(label: &mut CodeLabel) { offset_map[idx] = new_idx; match c { - '\n' if last_char_was_space => { + '\r' | '\n' if last_char_was_space => { newlines_removed = true; } '\t' | ' ' if last_char_was_space => {} - '\n' if !last_char_was_space => { + '\r' | '\n' if !last_char_was_space => { new_text.push(' '); new_idx += 1; last_char_was_space = true; @@ -15529,4 +16856,214 @@ mod tests { )); assert!(should_log_lsp_request_failure("something else entirely")); } + #[test] + fn synced_server_capabilities_wire_format_is_compatible_with_plain_capabilities() { + let registrations: dynamic_registration::TextDocumentRegistrations = + HashMap::from_iter([( + "textDocument/completion".to_string(), + collections::IndexMap::from_iter([( + "file-completion".to_string(), + dynamic_registration::DynamicTextDocumentRegistration { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + server_capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + }, + )]), + )]); + let server_capabilities = lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }; + let initial_server_capabilities = lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + + let envelope = serde_json::to_string(&SyncedServerCapabilities { + server_capabilities: server_capabilities.clone(), + initial_server_capabilities: Some(initial_server_capabilities.clone()), + text_document_registrations: Some(registrations.clone()), + }) + .unwrap(); + let parsed_by_old_peer = + serde_json::from_str::(&envelope).unwrap(); + assert_eq!( + parsed_by_old_peer, server_capabilities, + "peers without the extra field should parse the envelope as plain capabilities", + ); + let parsed = serde_json::from_str::(&envelope).unwrap(); + assert_eq!(parsed.server_capabilities, server_capabilities); + assert_eq!( + parsed.initial_server_capabilities, + Some(initial_server_capabilities) + ); + assert_eq!(parsed.text_document_registrations, Some(registrations)); + + let plain_capabilities = serde_json::to_string(&server_capabilities).unwrap(); + let parsed_plain = + serde_json::from_str::(&plain_capabilities).unwrap(); + assert_eq!( + parsed_plain.server_capabilities, server_capabilities, + "payloads from peers without the extra field should parse as an envelope", + ); + assert_eq!(parsed_plain.initial_server_capabilities, None); + assert_eq!(parsed_plain.text_document_registrations, None); + } + + #[test] + fn remote_capability_check_falls_back_to_merged_capabilities_without_initial_capabilities() { + let merged_capabilities = rename_capabilities(); + let context = rust_file_context(); + assert!( + remote_text_document_capabilities_match( + None, + Some(&merged_capabilities), + None, + Some(&context), + rename_capability_check, + ), + "old hosts send only merged capabilities, which must be consulted", + ); + } + + #[test] + fn remote_capability_check_skips_registrations_with_non_matching_selectors() { + let initial_capabilities = lsp::ServerCapabilities::default(); + let merged_capabilities = rename_capabilities(); + let registrations = rename_registrations("python"); + let context = rust_file_context(); + assert!( + !remote_text_document_capabilities_match( + Some(&initial_capabilities), + Some(&merged_capabilities), + Some(®istrations), + Some(&context), + rename_capability_check, + ), + "a registration scoped to another language must not match", + ); + } + + #[test] + fn remote_capability_check_fails_open_without_selector_context() { + let initial_capabilities = lsp::ServerCapabilities::default(); + let merged_capabilities = rename_capabilities(); + let registrations = rename_registrations("python"); + assert!( + remote_text_document_capabilities_match( + Some(&initial_capabilities), + Some(&merged_capabilities), + Some(®istrations), + None, + rename_capability_check, + ), + "without a selector context the guest must fail open and let the host re-filter", + ); + } + + #[test] + fn remote_capability_check_rejects_when_no_registration_matches() { + let initial_capabilities = lsp::ServerCapabilities::default(); + let merged_capabilities = rename_capabilities(); + let registrations = collections::IndexMap::default(); + let context = rust_file_context(); + assert!( + !remote_text_document_capabilities_match( + Some(&initial_capabilities), + Some(&merged_capabilities), + Some(®istrations), + Some(&context), + rename_capability_check, + ), + "initial capabilities without the capability and no registrations must not match", + ); + } + + fn rename_capability_check(capabilities: AdapterServerCapabilities<'_>) -> bool { + capabilities.server_capabilities.rename_provider.is_some() + } + + fn rename_capabilities() -> lsp::ServerCapabilities { + lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + } + } + + fn rust_file_context() -> DocumentSelectorContext { + DocumentSelectorContext { + language_id: "rust".to_string(), + scheme: "file", + } + } + + fn rename_registrations( + language: &str, + ) -> collections::IndexMap { + collections::IndexMap::from_iter([( + "rename-registration".to_string(), + dynamic_registration::DynamicTextDocumentRegistration { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some(language.to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + server_capabilities: rename_capabilities(), + }, + )]) + } + + #[test] + fn lsp_pull_cancelled_with_retrigger_follows_the_spec() { + assert!(lsp_pull_cancelled_with_retrigger(&server_cancellation( + None + ))); + assert!(lsp_pull_cancelled_with_retrigger(&server_cancellation( + Some(serde_json::json!({ "retriggerRequest": true })) + ))); + assert!(!lsp_pull_cancelled_with_retrigger(&server_cancellation( + Some(serde_json::json!({ "retriggerRequest": false })) + ))); + assert!(lsp_pull_cancelled_with_retrigger( + &server_cancellation(None).context("pulling diagnostics") + )); + assert!(!lsp_pull_cancelled_with_retrigger(&anyhow::Error::new( + lsp::ResponseError::new(lsp::ResponseErrorCode::RequestFailed, "request failed") + ))); + assert!(!lsp_pull_cancelled_with_retrigger(&anyhow::anyhow!( + "server cancelled the request" + ))); + } + + #[test] + fn should_log_lsp_response_error_suppresses_denied_requests() { + assert!(!should_log_lsp_response_error(&server_cancellation(None))); + assert!(!should_log_lsp_response_error(&anyhow::Error::new( + lsp::ResponseError::new( + lsp::ResponseErrorCode::ContentModified, + "content changed meanwhile", + ) + ))); + assert!(should_log_lsp_response_error(&anyhow::Error::new( + lsp::ResponseError::new(lsp::ResponseErrorCode::RequestFailed, "request failed") + ))); + assert!(should_log_lsp_response_error(&anyhow::anyhow!( + "something else entirely" + ))); + } + + fn server_cancellation(data: Option) -> anyhow::Error { + let mut response_error = lsp::ResponseError::server_cancelled(); + response_error.data = data; + anyhow::Error::new(response_error) + } } diff --git a/crates/project/src/lsp_store/code_lens.rs b/crates/project/src/lsp_store/code_lens.rs index 52c35e13e6..abab635f79 100644 --- a/crates/project/src/lsp_store/code_lens.rs +++ b/crates/project/src/lsp_store/code_lens.rs @@ -148,7 +148,7 @@ impl LspStore { ) -> CodeLensTask { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetCodeLens, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { @@ -396,10 +396,17 @@ impl LspStore { let lens = lens.clone(); let action = cached.clone(); + if !self.text_document_capability_matches_for_server( + buffer, + server_id, + "textDocument/codeLens", + |capabilities| GetCodeLens::can_resolve_lens(capabilities.server_capabilities), + cx, + ) { + return Task::ready(None).shared(); + } + if self.upstream_client().is_some() { - if !self.check_if_capable_for_proto_request(buffer, GetCodeLens::can_resolve_lens, cx) { - return Task::ready(None).shared(); - } let resolve = self.resolve_code_action(buffer, action, cx); let task = cx .spawn(async move |lsp_store, cx| { @@ -439,9 +446,6 @@ impl LspStore { let Some(server) = self.language_server_for_id(server_id) else { return Task::ready(None).shared(); }; - if !GetCodeLens::can_resolve_lens(&server.capabilities()) { - return Task::ready(None).shared(); - } let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); diff --git a/crates/project/src/lsp_store/document_colors.rs b/crates/project/src/lsp_store/document_colors.rs index e3465eba44..f87a14edd4 100644 --- a/crates/project/src/lsp_store/document_colors.rs +++ b/crates/project/src/lsp_store/document_colors.rs @@ -108,7 +108,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(&buffer, cx); + let current_servers = self.language_server_ids_for_request(&buffer, &GetDocumentColor, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { diff --git a/crates/project/src/lsp_store/document_links.rs b/crates/project/src/lsp_store/document_links.rs index 323b37604d..313237be00 100644 --- a/crates/project/src/lsp_store/document_links.rs +++ b/crates/project/src/lsp_store/document_links.rs @@ -130,7 +130,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetDocumentLinks, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { @@ -448,10 +448,17 @@ impl LspStore { data: cached_link.data.clone(), }; + if !self.text_document_capability_matches_for_server( + buffer, + server_id, + "textDocument/documentLink", + |capabilities| can_resolve_link(capabilities.server_capabilities), + cx, + ) { + return Task::ready(None); + } + if let Some((upstream_client, project_id)) = self.upstream_client() { - if !self.check_if_capable_for_proto_request(buffer, can_resolve_link, cx) { - return Task::ready(None); - } let request = proto::ResolveDocumentLink { project_id, buffer_id: buffer_id.into(), @@ -466,9 +473,6 @@ impl LspStore { let Some(server) = self.language_server_for_id(server_id) else { return Task::ready(None); }; - if !can_resolve_link(&server.capabilities()) { - return Task::ready(None); - } let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); diff --git a/crates/project/src/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs index adda847c47..2a8698f1db 100644 --- a/crates/project/src/lsp_store/document_symbols.rs +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -107,7 +107,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetDocumentSymbols, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { diff --git a/crates/project/src/lsp_store/dynamic_registration.rs b/crates/project/src/lsp_store/dynamic_registration.rs index 9af94842dd..73a5a1cdf6 100644 --- a/crates/project/src/lsp_store/dynamic_registration.rs +++ b/crates/project/src/lsp_store/dynamic_registration.rs @@ -7,16 +7,16 @@ //! the active registration is unregistered. use anyhow::{Context as _, Result}; -use collections::{BTreeSet, HashSet}; +use collections::{HashMap, HashSet, IndexMap}; use gpui::{Context, SharedString}; use lsp::{ - CompletionOptions, DiagnosticServerCapabilities, LanguageServer, LanguageServerId, OneOf, + DiagnosticServerCapabilities, LanguageServer, LanguageServerId, OneOf, TextDocumentSyncSaveOptions, }; use crate::lsp_store::{ - LanguageServerState, LspStore, RenamePathsWatchedForServer, lsp_workspace_diagnostics_refresh, - notify_server_capabilities_updated, + LanguageServerState, LspStore, RenamePathsWatchedForServer, + completion_trigger_characters_for_buffer, lsp_workspace_diagnostics_refresh, }; #[derive(Debug)] @@ -36,52 +36,188 @@ impl RegistrationSource { pub(super) type CapabilityRegistrations = Vec<(RegistrationSource, T)>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CapabilityUnregistration { - NotFound, - Removed { active_capability_changed: bool }, +#[derive(Debug, Clone, Copy)] +struct CapabilityRegistrationChange { + active_capability_changed: bool, + text_document_registration_changed: bool, + registration_changed: bool, } -impl CapabilityUnregistration { - fn removed(self) -> bool { - self != CapabilityUnregistration::NotFound +impl CapabilityRegistrationChange { + fn applicability_may_have_changed(self) -> bool { + self.active_capability_changed || self.text_document_registration_changed } - fn capability_changed(self) -> bool { - self == CapabilityUnregistration::Removed { - active_capability_changed: true, - } + fn ordered_capability_may_have_changed(self) -> bool { + self.active_capability_changed || self.registration_changed } } +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub(super) struct DynamicTextDocumentRegistration { + pub(super) document_selector: Option, + pub(super) server_capabilities: lsp::ServerCapabilities, +} + +pub(super) type TextDocumentRegistrations = + HashMap>; + #[derive(Default, Debug)] pub(super) struct DynamicRegistrations { pub(super) did_change_watched_files: HashSet, - pub(super) diagnostics: CapabilityRegistrations, + pub(super) text_documents: TextDocumentRegistrations, workspace_folders: CapabilityRegistrations, workspace_symbol: CapabilityRegistrations>, file_operations: CapabilityRegistrations, execute_command: CapabilityRegistrations, - range_formatting: CapabilityRegistrations>, - on_type_formatting: CapabilityRegistrations, - formatting: CapabilityRegistrations>, - rename: CapabilityRegistrations>, - code_action: CapabilityRegistrations, - definition: CapabilityRegistrations>, - completion: CapabilityRegistrations, - hover: CapabilityRegistrations, - signature_help: CapabilityRegistrations, - color: CapabilityRegistrations, - folding_range: CapabilityRegistrations, - document_link: CapabilityRegistrations, - semantic_tokens: CapabilityRegistrations, - inlay_hint: CapabilityRegistrations>, - code_lens: CapabilityRegistrations, - document_symbol: CapabilityRegistrations>, - call_hierarchy: CapabilityRegistrations, } impl LspStore { + fn register_dynamic_text_document_capability( + &mut self, + server: &LanguageServer, + method: &str, + registration_id: String, + options: T, + document_selector: Option, + cx: &mut Context, + capability_of: impl Fn(&mut lsp::ServerCapabilities) -> &mut Option, + ) -> anyhow::Result { + let server_id = server.server_id(); + let local = self + .as_local_mut() + .context("Expected LSP Store to be local")?; + let mut server_capabilities = lsp::ServerCapabilities::default(); + *capability_of(&mut server_capabilities) = Some(options); + let new_registration = DynamicTextDocumentRegistration { + document_selector, + server_capabilities, + }; + let registrations = local + .language_server_dynamic_registrations + .entry(server_id) + .or_default() + .text_documents + .entry(method.to_owned()) + .or_default(); + // Guests track registrations by id, so notify on any id-visible change; caches only + // consume the (selector, options) value set, so refresh flags compare sets. + let previous_registration = registrations.get(®istration_id); + let registration_changed = previous_registration != Some(&new_registration); + let applicable_set_changed = match previous_registration { + Some(previous_registration) if previous_registration == &new_registration => false, + Some(previous_registration) => { + let mut other_registrations = registrations + .iter() + .filter(|(id, _)| **id != registration_id) + .map(|(_, registration)| registration); + let previous_value_remains = other_registrations + .clone() + .any(|registration| registration == previous_registration); + let new_value_exists = + other_registrations.any(|registration| registration == &new_registration); + !previous_value_remains || !new_value_exists + } + None => !registrations + .values() + .any(|registration| registration == &new_registration), + }; + if previous_registration.is_some() { + log::warn!( + "Received a duplicate {method} registration with ID {registration_id}, replacing the previous one" + ); + } + registrations.insert(registration_id, new_registration); + let new_active_envelope = registrations + .last() + .map(|(_, registration)| registration.server_capabilities.clone()); + let new_active = match new_active_envelope { + Some(mut envelope) => capability_of(&mut envelope).take(), + None => local + .initial_server_capabilities + .get(&server_id) + .cloned() + .and_then(|mut capabilities| capability_of(&mut capabilities).take()), + }; + let mut current_capabilities = server.capabilities(); + let previous_active = capability_of(&mut current_capabilities).take(); + let active_changed = new_active != previous_active; + if active_changed { + server.update_capabilities(|capabilities| *capability_of(capabilities) = new_active); + } + if active_changed || registration_changed { + self.notify_server_capabilities_updated(server, cx); + } + Ok(CapabilityRegistrationChange { + active_capability_changed: active_changed, + text_document_registration_changed: applicable_set_changed, + registration_changed, + }) + } + + fn unregister_dynamic_text_document_capability( + &mut self, + server: &LanguageServer, + unregistration: &lsp::Unregistration, + cx: &mut Context, + capability_of: impl Fn(&mut lsp::ServerCapabilities) -> &mut Option, + ) -> anyhow::Result> { + let server_id = server.server_id(); + let local = self + .as_local_mut() + .context("Expected LSP Store to be local")?; + let Some(registrations) = local + .language_server_dynamic_registrations + .get_mut(&server_id) + .and_then(|dynamic_registrations| { + dynamic_registrations + .text_documents + .get_mut(&unregistration.method) + }) + else { + log::warn!( + "Attempted to unregister non-existent {} registration with ID {}", + unregistration.method, + unregistration.id + ); + return Ok(None); + }; + let Some(removed) = registrations.shift_remove(&unregistration.id) else { + log::warn!( + "Attempted to unregister non-existent {} registration with ID {}", + unregistration.method, + unregistration.id + ); + return Ok(None); + }; + let applicable_set_changed = !registrations + .values() + .any(|remaining| remaining == &removed); + let new_active_envelope = registrations + .last() + .map(|(_, registration)| registration.server_capabilities.clone()); + let new_active = match new_active_envelope { + Some(mut envelope) => capability_of(&mut envelope).take(), + None => local + .initial_server_capabilities + .get(&server_id) + .cloned() + .and_then(|mut capabilities| capability_of(&mut capabilities).take()), + }; + let mut current_capabilities = server.capabilities(); + let previous_active = capability_of(&mut current_capabilities).take(); + let active_changed = new_active != previous_active; + if active_changed { + server.update_capabilities(|capabilities| *capability_of(capabilities) = new_active); + } + self.notify_server_capabilities_updated(server, cx); + Ok(Some(CapabilityRegistrationChange { + active_capability_changed: active_changed, + text_document_registration_changed: applicable_set_changed, + registration_changed: true, + })) + } + /// Returns `true` when the registration changed the server's active capability value: /// duplicate-ID replacements of non-active registrations and (re-)registrations with /// options identical to the active ones do not. @@ -99,12 +235,11 @@ impl LspStore { let local = self .as_local_mut() .context("Expected LSP Store to be local")?; - let registrations = registrations_of( - local - .language_server_dynamic_registrations - .entry(server_id) - .or_default(), - ); + let dynamic_registrations = local + .language_server_dynamic_registrations + .entry(server_id) + .or_default(); + let registrations = registrations_of(dynamic_registrations); if registrations.is_empty() { let mut initial_capabilities = server.capabilities(); if let Some(static_options) = capability_of(&mut initial_capabilities).take() { @@ -127,7 +262,7 @@ impl LspStore { let active_changed = active != previously_active; if active_changed { server.update_capabilities(|capabilities| *capability_of(capabilities) = active); - notify_server_capabilities_updated(server, cx); + self.notify_server_capabilities_updated(server, cx); } Ok(active_changed) } @@ -139,41 +274,43 @@ impl LspStore { cx: &mut Context, registrations_of: impl FnOnce(&mut DynamicRegistrations) -> &mut CapabilityRegistrations, capability_of: impl FnOnce(&mut lsp::ServerCapabilities) -> &mut Option, - ) -> anyhow::Result { + ) -> anyhow::Result> { let server_id = server.server_id(); let local = self .as_local_mut() .context("Expected LSP Store to be local")?; - let registrations = local + let Some(dynamic_registrations) = local .language_server_dynamic_registrations .get_mut(&server_id) - .map(registrations_of); - let index = registrations.as_ref().and_then(|registrations| { - registrations.iter().position(|(source, _)| { - source.registration_id() == Some(unregistration.id.as_str()) - }) - }); - let (Some(registrations), Some(index)) = (registrations, index) else { + else { log::warn!( "Attempted to unregister non-existent {} registration with ID {}", unregistration.method, unregistration.id ); - return Ok(CapabilityUnregistration::NotFound); + return Ok(None); + }; + let registrations = registrations_of(dynamic_registrations); + let Some(index) = registrations + .iter() + .position(|(source, _)| source.registration_id() == Some(unregistration.id.as_str())) + else { + log::warn!( + "Attempted to unregister non-existent {} registration with ID {}", + unregistration.method, + unregistration.id + ); + return Ok(None); }; let removed_active = index + 1 == registrations.len(); let (_, removed_options) = registrations.remove(index); let restored = registrations.last().map(|(_, options)| options.clone()); if !removed_active || restored.as_ref() == Some(&removed_options) { - return Ok(CapabilityUnregistration::Removed { - active_capability_changed: false, - }); + return Ok(Some(false)); } server.update_capabilities(|capabilities| *capability_of(capabilities) = restored); - notify_server_capabilities_updated(server, cx); - Ok(CapabilityUnregistration::Removed { - active_capability_changed: true, - }) + self.notify_server_capabilities_updated(server, cx); + Ok(Some(true)) } fn update_paths_watched_for_rename(&mut self, server: &LanguageServer) { @@ -208,35 +345,31 @@ impl LspStore { } } - fn apply_completion_triggers( - &self, - server_id: LanguageServerId, - options: Option, - cx: &mut Context, - ) { + fn apply_completion_triggers(&self, server_id: LanguageServerId, cx: &mut Context) { let Some(local) = self.as_local() else { return; }; + let Some(LanguageServerState::Running { adapter, .. }) = + local.language_servers.get(&server_id) + else { + return; + }; let mut buffers_with_language_server = Vec::new(); for handle in self.buffer_store.read(cx).buffers() { let buffer_id = handle.read(cx).remote_id(); if local .buffers_opened_in_servers .get(&buffer_id) - .filter(|s| s.contains(&server_id)) + .filter(|servers| servers.contains(&server_id)) .is_some() { buffers_with_language_server.push(handle); } } - let triggers = options - .and_then(|options| options.trigger_characters) - .unwrap_or_default() - .into_iter() - .collect::>(); for handle in buffers_with_language_server { - let triggers = triggers.clone(); - handle.update(cx, move |buffer, cx| { + handle.update(cx, |buffer, cx| { + let triggers = + completion_trigger_characters_for_buffer(local, server_id, adapter, buffer); buffer.set_completion_triggers(server_id, triggers, cx); }); } @@ -264,7 +397,7 @@ impl LspStore { false }; if notify { - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } } @@ -340,103 +473,125 @@ impl LspStore { } } "textDocument/rangeFormatting" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.range_formatting, |capabilities| &mut capabilities.document_range_formatting_provider, )?; } "textDocument/onTypeFormatting" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(options) = reg .register_options .map(serde_json::from_value) .transpose()? { - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.on_type_formatting, |capabilities| &mut capabilities.document_on_type_formatting_provider, )?; } } "textDocument/formatting" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.formatting, |capabilities| &mut capabilities.document_formatting_provider, )?; } "textDocument/rename" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.rename, |capabilities| &mut capabilities.rename_provider, )?; } "textDocument/inlayHint" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - options, - cx, - |registrations| &mut registrations.inlay_hint, - |capabilities| &mut capabilities.inlay_hint_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + options, + document_selector, + cx, + |capabilities| &mut capabilities.inlay_hint_provider, + )? + .applicability_may_have_changed() + { self.refresh_inlay_hints(server_id, cx); } } "textDocument/documentSymbol" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - options, - cx, - |registrations| &mut registrations.document_symbol, - |capabilities| &mut capabilities.document_symbol_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + options, + document_selector, + cx, + |capabilities| &mut capabilities.document_symbol_provider, + )? + .applicability_may_have_changed() + { self.refresh_document_symbols(Some(server_id), cx); } } "textDocument/codeAction" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::CodeActionProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, provider, + document_selector, cx, - |registrations| &mut registrations.code_action, |capabilities| &mut capabilities.code_action_provider, )?; } "textDocument/prepareCallHierarchy" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::CallHierarchyServerCapability::Simple(value), @@ -444,77 +599,102 @@ impl LspStore { lsp::CallHierarchyServerCapability::Options(options) } }; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, provider, + document_selector, cx, - |registrations| &mut registrations.call_hierarchy, |capabilities| &mut capabilities.call_hierarchy_provider, )?; } "textDocument/definition" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.definition, |capabilities| &mut capabilities.definition_provider, )?; } - "textDocument/completion" => { - if let Some(caps) = reg - .register_options - .map(serde_json::from_value::) - .transpose()? - { - if self.register_dynamic_capability( + "textDocument/documentHighlight" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; + let options = parse_register_capabilities(reg.register_options)?; + if self + .register_dynamic_text_document_capability( &server, ®.method, reg.id, - caps, + options, + document_selector, + cx, + |capabilities| &mut capabilities.document_highlight_provider, + )? + .applicability_may_have_changed() + { + self.refresh_document_highlights(Some(server_id), cx); + } + } + "textDocument/completion" => { + if let Some(registration_options) = reg + .register_options + .map(serde_json::from_value::) + .transpose()? + { + self.register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + registration_options.completion_options, + registration_options + .text_document_registration_options + .document_selector, cx, - |registrations| &mut registrations.completion, |capabilities| &mut capabilities.completion_provider, - )? { - let active = server.capabilities().completion_provider; - self.apply_completion_triggers(server_id, active, cx); - } + )?; + self.apply_completion_triggers(server_id, cx); } } "textDocument/hover" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::HoverProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, provider, + document_selector, cx, - |registrations| &mut registrations.hover, |capabilities| &mut capabilities.hover_provider, )?; } "textDocument/signatureHelp" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? { - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, caps, + document_selector, cx, - |registrations| &mut registrations.signature_help, |capabilities| &mut capabilities.signature_help_provider, )?; } @@ -532,7 +712,7 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } "textDocument/didSave" => { @@ -560,42 +740,49 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } "textDocument/codeLens" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(options) = reg .register_options .map(serde_json::from_value) .transpose()? { - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - options, - cx, - |registrations| &mut registrations.code_lens, - |capabilities| &mut capabilities.code_lens_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + options, + document_selector, + cx, + |capabilities| &mut capabilities.code_lens_provider, + )? + .applicability_may_have_changed() + { self.refresh_code_lens(Some(server_id), cx); } } } "textDocument/diagnostic" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value::) .transpose()? { let registration_id = reg.id.clone(); - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, caps.clone(), + document_selector, cx, - |registrations| &mut registrations.diagnostics, |capabilities| &mut capabilities.diagnostic_provider, )?; @@ -644,75 +831,94 @@ impl LspStore { } } "textDocument/documentColor" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::ColorProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - provider, - cx, - |registrations| &mut registrations.color, - |capabilities| &mut capabilities.color_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + provider, + document_selector, + cx, + |capabilities| &mut capabilities.color_provider, + )? + .applicability_may_have_changed() + { self.refresh_document_colors(Some(server_id), cx); } } "textDocument/foldingRange" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::FoldingRangeProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - provider, - cx, - |registrations| &mut registrations.folding_range, - |capabilities| &mut capabilities.folding_range_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + provider, + document_selector, + cx, + |capabilities| &mut capabilities.folding_range_provider, + )? + .applicability_may_have_changed() + { self.refresh_folding_ranges(Some(server_id), cx); } } "textDocument/documentLink" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? { - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - caps, - cx, - |registrations| &mut registrations.document_link, - |capabilities| &mut capabilities.document_link_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + caps, + document_selector, + cx, + |capabilities| &mut capabilities.document_link_provider, + )? + .applicability_may_have_changed() + { self.refresh_document_links(Some(server_id), cx); } } } "textDocument/semanticTokens" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value::) .transpose()? { - if self.register_dynamic_capability( + if self.register_dynamic_text_document_capability( &server, ®.method, reg.id, lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(caps), + document_selector, cx, - |registrations| &mut registrations.semantic_tokens, |capabilities| &mut capabilities.semantic_tokens_provider, - )? { + )? + .ordered_capability_may_have_changed() + { // Re-query already-open buffers, which would otherwise keep // tree-sitter-only highlighting until edited. self.refresh_semantic_tokens(server_id, cx); @@ -746,7 +952,7 @@ impl LspStore { false }; if notify { - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } "workspace/didChangeConfiguration" => { @@ -776,20 +982,18 @@ impl LspStore { )?; } "workspace/fileOperations" => { - if self - .unregister_dynamic_capability( - &server, - unreg, - cx, - |registrations| &mut registrations.file_operations, - |capabilities| { - &mut capabilities - .workspace - .get_or_insert_default() - .file_operations - }, - )? - .capability_changed() + if self.unregister_dynamic_capability( + &server, + unreg, + cx, + |registrations| &mut registrations.file_operations, + |capabilities| { + &mut capabilities + .workspace + .get_or_insert_default() + .file_operations + }, + )? == Some(true) { self.update_paths_watched_for_rename(&server); } @@ -804,111 +1008,114 @@ impl LspStore { )?; } "textDocument/rangeFormatting" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.range_formatting, |capabilities| &mut capabilities.document_range_formatting_provider, )?; } "textDocument/onTypeFormatting" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.on_type_formatting, |capabilities| &mut capabilities.document_on_type_formatting_provider, )?; } "textDocument/formatting" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.formatting, |capabilities| &mut capabilities.document_formatting_provider, )?; } "textDocument/rename" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.rename, |capabilities| &mut capabilities.rename_provider, )?; } "textDocument/codeAction" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.code_action, |capabilities| &mut capabilities.code_action_provider, )?; } "textDocument/prepareCallHierarchy" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.call_hierarchy, |capabilities| &mut capabilities.call_hierarchy_provider, )?; } "textDocument/definition" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.definition, |capabilities| &mut capabilities.definition_provider, )?; } - "textDocument/completion" => { + "textDocument/documentHighlight" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( + &server, + unreg, + cx, + |capabilities| &mut capabilities.document_highlight_provider, + )? + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) + { + self.refresh_document_highlights(Some(server_id), cx); + } + } + "textDocument/completion" => { + if self + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.completion, |capabilities| &mut capabilities.completion_provider, )? - .capability_changed() + .is_some() { - let restored = server.capabilities().completion_provider; - self.apply_completion_triggers(server_id, restored, cx); + self.apply_completion_triggers(server_id, cx); } } "textDocument/hover" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.hover, |capabilities| &mut capabilities.hover_provider, )?; } "textDocument/signatureHelp" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.signature_help, |capabilities| &mut capabilities.signature_help_provider, )?; } "textDocument/semanticTokens" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.semantic_tokens, |capabilities| &mut capabilities.semantic_tokens_provider, )? - .capability_changed() + .is_some_and( + CapabilityRegistrationChange::ordered_capability_may_have_changed, + ) { self.refresh_semantic_tokens(server_id, cx); } @@ -920,7 +1127,7 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } "textDocument/didSave" => { server.update_capabilities(|capabilities| { @@ -929,60 +1136,56 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } "textDocument/inlayHint" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.inlay_hint, |capabilities| &mut capabilities.inlay_hint_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_inlay_hints(server_id, cx); } } "textDocument/documentSymbol" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.document_symbol, |capabilities| &mut capabilities.document_symbol_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_document_symbols(Some(server_id), cx); } } "textDocument/codeLens" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.code_lens, |capabilities| &mut capabilities.code_lens_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_code_lens(Some(server_id), cx); } } "textDocument/diagnostic" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.diagnostics, |capabilities| &mut capabilities.diagnostic_provider, )? - .removed() + .is_some() { let local = self .as_local_mut() @@ -1008,42 +1211,39 @@ impl LspStore { } "textDocument/documentColor" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.color, |capabilities| &mut capabilities.color_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_document_colors(Some(server_id), cx); } } "textDocument/foldingRange" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.folding_range, |capabilities| &mut capabilities.folding_range_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_folding_ranges(Some(server_id), cx); } } "textDocument/documentLink" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.document_link, |capabilities| &mut capabilities.document_link_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_document_links(Some(server_id), cx); } @@ -1056,6 +1256,16 @@ impl LspStore { } } +fn parse_text_document_registration( + register_options: Option<&serde_json::Value>, +) -> Result> { + Ok(register_options + .cloned() + .map(serde_json::from_value::) + .transpose()? + .and_then(|options| options.document_selector)) +} + // Registration with registerOptions as null, should fallback to true. // https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/client.ts#L2133 fn parse_register_capabilities( diff --git a/crates/project/src/lsp_store/emmet_ext.rs b/crates/project/src/lsp_store/emmet_ext.rs new file mode 100644 index 0000000000..93feeeb98c --- /dev/null +++ b/crates/project/src/lsp_store/emmet_ext.rs @@ -0,0 +1,186 @@ +use std::{path::Path, sync::Arc}; + +use anyhow::Result; +use async_trait::async_trait; +use gpui::{App, AsyncApp, Entity}; +use language::Buffer; +use lsp::{AdapterServerCapabilities, LanguageServer, LanguageServerId}; +use rpc::proto::{self, PeerId}; +use serde::{Deserialize, Serialize}; +use text::BufferId; + +use crate::{ + lsp_command::LspCommand, + lsp_store::{LanguageServerToQuery, LspStore}, +}; + +pub const EMMET_SERVER_NAME: lsp::LanguageServerName = + lsp::LanguageServerName::new_static("emmet-language-server"); + +pub enum LspExpandAbbreviation {} + +impl lsp::request::Request for LspExpandAbbreviation { + type Params = ExpandAbbreviationParams; + type Result = Option; + const METHOD: &'static str = "emmet/expandAbbreviation"; +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ExpandAbbreviationParams { + pub abbreviation: String, + pub language: String, + pub options: ExpandAbbreviationOptions, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ExpandAbbreviationOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option>, + pub options: EmmetOutputOptions, +} + +#[derive(Deserialize, Serialize, Debug, Default, PartialEq, Eq)] +pub struct EmmetOutputOptions { + #[serde(rename = "output.indent")] + pub indent: String, + #[serde(rename = "output.baseIndent")] + pub base_indent: String, + #[serde(rename = "comment.enabled", skip_serializing_if = "Option::is_none")] + pub comment_enabled: Option, + #[serde(rename = "bem.enabled", skip_serializing_if = "Option::is_none")] + pub bem_enabled: Option, + #[serde(rename = "output.inlineBreak", skip_serializing_if = "Option::is_none")] + pub inline_break: Option, +} + +#[derive(Debug)] +pub struct ExpandAbbreviation { + pub abbreviation: String, + pub text: Option>, + pub language: String, + pub server_id: LanguageServerId, + pub indent: String, + pub base_indent: String, + pub comment_filter: bool, + pub bem_filter: bool, +} + +#[async_trait(?Send)] +impl LspCommand for ExpandAbbreviation { + type Response = Option; + type LspRequest = LspExpandAbbreviation; + type ProtoRequest = proto::LspExtExpandAbbreviation; + + fn display_name(&self) -> &str { + "Expand Emmet abbreviation" + } + + fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + true + } + + fn language_server_to_query(&self) -> LanguageServerToQuery { + LanguageServerToQuery::Other(self.server_id) + } + + fn to_lsp( + &self, + _: &Path, + _: &Buffer, + server: &Arc, + _: &App, + ) -> Result { + anyhow::ensure!( + server.name() == EMMET_SERVER_NAME, + "cannot expand Emmet abbreviations with the {} server", + server.name() + ); + let multiline = self.text.as_ref().is_some_and(|text| text.len() > 1); + Ok(ExpandAbbreviationParams { + abbreviation: self.abbreviation.clone(), + language: self.language.clone(), + options: ExpandAbbreviationOptions { + text: self.text.clone(), + options: EmmetOutputOptions { + indent: self.indent.clone(), + base_indent: self.base_indent.clone(), + comment_enabled: self.comment_filter.then_some(true), + bem_enabled: self.bem_filter.then_some(true), + inline_break: multiline.then_some(1), + }, + }, + }) + } + + async fn response_from_lsp( + self, + message: Option, + _: Entity, + _: Entity, + _: LanguageServerId, + _: AsyncApp, + ) -> anyhow::Result> { + Ok(message.filter(|expansion| !expansion.is_empty())) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::LspExtExpandAbbreviation { + proto::LspExtExpandAbbreviation { + project_id, + buffer_id: buffer.remote_id().into(), + abbreviation: self.abbreviation.clone(), + text: self.text.clone().unwrap_or_default(), + language: self.language.clone(), + server_id: self.server_id.to_proto(), + indent: self.indent.clone(), + base_indent: self.base_indent.clone(), + comment_filter: self.comment_filter, + bem_filter: self.bem_filter, + } + } + + async fn from_proto( + message: Self::ProtoRequest, + _: Entity, + _: Entity, + _: AsyncApp, + ) -> anyhow::Result { + Ok(Self { + abbreviation: message.abbreviation, + text: (!message.text.is_empty()).then_some(message.text), + language: message.language, + server_id: LanguageServerId::from_proto(message.server_id), + indent: message.indent, + base_indent: message.base_indent, + comment_filter: message.comment_filter, + bem_filter: message.bem_filter, + }) + } + + fn response_to_proto( + response: Option, + _: &mut LspStore, + _: PeerId, + _: &clock::Global, + _: &mut App, + ) -> proto::LspExtExpandAbbreviationResponse { + proto::LspExtExpandAbbreviationResponse { + expansion: response, + } + } + + async fn response_from_proto( + self, + message: proto::LspExtExpandAbbreviationResponse, + _: Entity, + _: Entity, + _: AsyncApp, + ) -> anyhow::Result> { + Ok(message.expansion.filter(|expansion| !expansion.is_empty())) + } + + fn buffer_id_from_proto(message: &proto::LspExtExpandAbbreviation) -> Result { + BufferId::new(message.buffer_id) + } +} diff --git a/crates/project/src/lsp_store/folding_ranges.rs b/crates/project/src/lsp_store/folding_ranges.rs index e1be0c3d57..9df23518ef 100644 --- a/crates/project/src/lsp_store/folding_ranges.rs +++ b/crates/project/src/lsp_store/folding_ranges.rs @@ -107,7 +107,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetFoldingRanges, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { diff --git a/crates/project/src/lsp_store/inlay_hints.rs b/crates/project/src/lsp_store/inlay_hints.rs index 8a48992ee5..29800a21a5 100644 --- a/crates/project/src/lsp_store/inlay_hints.rs +++ b/crates/project/src/lsp_store/inlay_hints.rs @@ -258,12 +258,18 @@ impl LspStore { server_id: LanguageServerId, cx: &mut Context, ) -> Task> { + if !self.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/inlayHint", + |capabilities| InlayHints::can_resolve_inlays(capabilities.server_capabilities), + cx, + ) { + hint.resolve_state = ResolveState::Resolved; + return Task::ready(Ok(hint)); + } + if let Some((upstream_client, project_id)) = self.upstream_client() { - if !self.check_if_capable_for_proto_request(&buffer, InlayHints::can_resolve_inlays, cx) - { - hint.resolve_state = ResolveState::Resolved; - return Task::ready(Ok(hint)); - } let request = proto::ResolveInlayHint { project_id, buffer_id: buffer.read(cx).remote_id().into(), @@ -288,9 +294,6 @@ impl LspStore { }) else { return Task::ready(Ok(hint)); }; - if !InlayHints::can_resolve_inlays(&lang_server.capabilities()) { - return Task::ready(Ok(hint)); - } let buffer_snapshot = buffer.read(cx).snapshot(); let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings diff --git a/crates/project/src/lsp_store/log_store.rs b/crates/project/src/lsp_store/log_store.rs index ef798c5cb2..c6da6b5cd9 100644 --- a/crates/project/src/lsp_store/log_store.rs +++ b/crates/project/src/lsp_store/log_store.rs @@ -1,6 +1,6 @@ use std::{ collections::VecDeque, - sync::Arc, + sync::{Arc, Weak}, time::{Duration, Instant}, }; @@ -35,7 +35,7 @@ impl Global for GlobalLogStore {} #[derive(Debug)] pub enum Event { NewServerLogEntry { - id: LanguageServerId, + key: LanguageServerLogKey, kind: LanguageServerLogType, text: String, }, @@ -46,12 +46,13 @@ impl EventEmitter for LogStore {} pub struct LogStore { on_headless_host: bool, projects: HashMap, ProjectState>, - pub language_servers: HashMap, - io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String, Instant)>, + pub language_servers: HashMap, + io_tx: mpsc::UnboundedSender<(LanguageServerLogKey, IoKind, String, Instant)>, } struct ProjectState { _subscriptions: [Subscription; 2], + copilot_server_id: Option, copilot_log_subscription: Option, } @@ -132,7 +133,7 @@ impl Message for RpcMessage { pub struct LanguageServerState { pub name: Option, pub worktree_id: Option, - pub kind: LanguageServerKind, + server: Option>, log_messages: VecDeque, trace_messages: VecDeque, pub rpc_state: Option, @@ -142,12 +143,17 @@ pub struct LanguageServerState { pub toggled_log_kind: Option, } +impl LanguageServerState { + pub fn server(&self) -> Option> { + self.server.as_ref()?.upgrade() + } +} + impl std::fmt::Debug for LanguageServerState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LanguageServerState") .field("name", &self.name) .field("worktree_id", &self.worktree_id) - .field("kind", &self.kind) .field("log_messages", &self.log_messages) .field("trace_messages", &self.trace_messages) .field("rpc_state", &self.rpc_state) @@ -158,12 +164,12 @@ impl std::fmt::Debug for LanguageServerState { } } -#[derive(PartialEq, Clone)] +#[derive(PartialEq, Eq, Hash, Clone)] pub enum LanguageServerKind { Local { project: WeakEntity }, Remote { project: WeakEntity }, LocalSsh { lsp_store: WeakEntity }, - Global, + Supplementary { project: WeakEntity }, } impl std::fmt::Debug for LanguageServerKind { @@ -172,18 +178,61 @@ impl std::fmt::Debug for LanguageServerKind { LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"), LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"), LanguageServerKind::LocalSsh { .. } => write!(f, "LanguageServerKind::LocalSsh"), - LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"), + LanguageServerKind::Supplementary { .. } => { + write!(f, "LanguageServerKind::Supplementary") + } } } } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LanguageServerLogKey { + pub kind: LanguageServerKind, + pub server_id: LanguageServerId, +} + +impl LanguageServerLogKey { + pub fn new(kind: LanguageServerKind, server_id: LanguageServerId) -> Self { + Self { kind, server_id } + } + + pub fn is_for_project( + &self, + project: &WeakEntity, + lsp_store: &WeakEntity, + ) -> bool { + self.kind.is_for_project(project, lsp_store) + } +} + impl LanguageServerKind { pub fn project(&self) -> Option<&WeakEntity> { match self { - Self::Local { project } => Some(project), - Self::Remote { project } => Some(project), + Self::Local { project } + | Self::Remote { project } + | Self::Supplementary { project } => Some(project), Self::LocalSsh { .. } => None, - Self::Global { .. } => None, + } + } + + pub fn is_for_project( + &self, + project: &WeakEntity, + lsp_store: &WeakEntity, + ) -> bool { + match self { + Self::Local { + project: server_project, + } + | Self::Remote { + project: server_project, + } + | Self::Supplementary { + project: server_project, + } => server_project == project, + Self::LocalSsh { + lsp_store: server_lsp_store, + } => server_lsp_store == lsp_store, } } } @@ -400,10 +449,10 @@ impl LogStore { io_tx, }; cx.spawn(async move |log_store, cx| { - while let Some((server_id, io_kind, message, observed_at)) = io_rx.next().await { + while let Some((server_key, io_kind, message, observed_at)) = io_rx.next().await { if let Some(log_store) = log_store.upgrade() { log_store.update(cx, |log_store, cx| { - log_store.on_io(server_id, io_kind, &message, observed_at, cx); + log_store.on_io(&server_key, io_kind, &message, observed_at, cx); }); } } @@ -423,10 +472,11 @@ impl LogStore { cx.observe_release(project, move |this, _, _| { this.projects.remove(&weak_project); this.language_servers - .retain(|_, state| state.kind.project() != Some(&weak_project)); + .retain(|key, _| key.kind.project() != Some(&weak_project)); }), cx.subscribe(project, move |log_store, project, event, cx| { - let server_kind = if project.read(cx).is_local() { + let is_local = project.read(cx).is_local(); + let primary_server_kind = if is_local { LanguageServerKind::Local { project: project.downgrade(), } @@ -435,10 +485,32 @@ impl LogStore { project: project.downgrade(), } }; + let server_kind_for_id = |log_store: &LogStore, server_id| { + // Remote project events carry host-side server IDs, which may + // collide numerically with locally allocated supplementary + // server IDs, so only local projects may resolve an ID to a + // supplementary server. + if is_local { + let supplementary_server_kind = LanguageServerKind::Supplementary { + project: project.downgrade(), + }; + let supplementary_server_key = LanguageServerLogKey::new( + supplementary_server_kind.clone(), + server_id, + ); + if log_store + .language_servers + .contains_key(&supplementary_server_key) + { + return supplementary_server_kind; + } + } + primary_server_kind.clone() + }; match event { crate::Event::LanguageServerAdded(id, name, worktree_id) => { log_store.add_language_server( - server_kind, + primary_server_kind, *id, Some(name.clone()), *worktree_id, @@ -450,6 +522,22 @@ impl LogStore { cx, ); } + crate::Event::SupplementaryLanguageServerAdded(id, name) => { + log_store.add_language_server( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + *id, + Some(name.clone()), + None, + project + .read(cx) + .lsp_store() + .read(cx) + .language_server_for_id(*id), + cx, + ); + } crate::Event::LanguageServerBufferRegistered { server_id, buffer_id, @@ -472,7 +560,7 @@ impl LogStore { .map(|status| status.name.clone()) }); log_store.add_language_server( - server_kind, + server_kind_for_id(log_store, *server_id), *server_id, name, worktree_id, @@ -481,9 +569,23 @@ impl LogStore { ); } crate::Event::LanguageServerRemoved(id) => { - log_store.remove_language_server(*id, cx); + let server_key = + LanguageServerLogKey::new(primary_server_kind, *id); + log_store.remove_language_server(&server_key, cx); + } + crate::Event::SupplementaryLanguageServerRemoved(id) => { + let server_key = LanguageServerLogKey::new( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + *id, + ); + log_store.remove_language_server(&server_key, cx); } crate::Event::LanguageServerLog(id, typ, message) => { + let server_kind = server_kind_for_id(log_store, *id); + let server_key = + LanguageServerLogKey::new(server_kind.clone(), *id); log_store.add_language_server( server_kind, *id, @@ -494,11 +596,16 @@ impl LogStore { ); match typ { crate::LanguageServerLogType::Log(typ) => { - log_store.add_language_server_log(*id, *typ, message, cx); + log_store.add_language_server_log( + &server_key, + *typ, + message, + cx, + ); } crate::LanguageServerLogType::Trace { verbose_info } => { log_store.add_language_server_trace( - *id, + &server_key, message, verbose_info.clone(), cx, @@ -511,7 +618,7 @@ impl LogStore { MessageKind::Send }; log_store.add_language_server_rpc( - *id, + &server_key, kind, message, RpcTiming::Forwarded(*elapsed), @@ -525,12 +632,17 @@ impl LogStore { enabled, toggled_log_kind, } => { - log_store.toggle_lsp_logs(*server_id, *enabled, *toggled_log_kind); + let server_key = LanguageServerLogKey::new( + server_kind_for_id(log_store, *server_id), + *server_id, + ); + log_store.toggle_lsp_logs(&server_key, *enabled, *toggled_log_kind); } _ => {} } }), ], + copilot_server_id: None, copilot_log_subscription: None, }, ); @@ -538,9 +650,9 @@ impl LogStore { pub fn get_language_server_state( &mut self, - id: LanguageServerId, + key: &LanguageServerLogKey, ) -> Option<&mut LanguageServerState> { - self.language_servers.get_mut(&id) + self.language_servers.get_mut(key) } pub fn add_language_server( @@ -552,21 +664,25 @@ impl LogStore { server: Option>, cx: &mut Context, ) -> Option<&mut LanguageServerState> { - let server_state = self.language_servers.entry(server_id).or_insert_with(|| { - cx.notify(); - LanguageServerState { - name: None, - worktree_id: None, - kind, - rpc_state: None, - log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_level: TraceValue::Off, - log_level: MessageType::LOG, - io_logs_subscription: None, - toggled_log_kind: None, - } - }); + let server_key = LanguageServerLogKey::new(kind, server_id); + let server_state = self + .language_servers + .entry(server_key.clone()) + .or_insert_with(|| { + cx.notify(); + LanguageServerState { + name: None, + worktree_id: None, + server: server.as_ref().map(Arc::downgrade), + rpc_state: None, + log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_level: TraceValue::Off, + log_level: MessageType::LOG, + io_logs_subscription: None, + toggled_log_kind: None, + } + }); if let Some(name) = name { server_state.name = Some(name); @@ -575,15 +691,29 @@ impl LogStore { server_state.worktree_id = Some(worktree_id); } - if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) { - let io_tx = self.io_tx.clone(); - let server_id = server.server_id(); - server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { - let observed_at = Instant::now(); - io_tx - .unbounded_send((server_id, io_kind, message.to_string(), observed_at)) - .ok(); - })); + if let Some(server) = server { + let is_new_server = match server_state.server() { + Some(current_server) => !Arc::ptr_eq(¤t_server, &server), + None => true, + }; + if is_new_server { + server_state.server = Some(Arc::downgrade(&server)); + server_state.io_logs_subscription = None; + } + if server_state.io_logs_subscription.is_none() { + let io_tx = self.io_tx.clone(); + server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { + let observed_at = Instant::now(); + io_tx + .unbounded_send(( + server_key.clone(), + io_kind, + message.to_string(), + observed_at, + )) + .ok(); + })); + } } Some(server_state) @@ -591,13 +721,13 @@ impl LogStore { pub fn add_language_server_log( &mut self, - id: LanguageServerId, + key: &LanguageServerLogKey, typ: MessageType, message: &str, cx: &mut Context, ) -> Option<()> { let store_logs = !self.on_headless_host; - let language_server_state = self.get_language_server_state(id)?; + let language_server_state = self.get_language_server_state(key)?; let log_lines = &mut language_server_state.log_messages; let message = message.trim_end().to_string(); @@ -605,7 +735,7 @@ impl LogStore { // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Log(typ), text: message, }, @@ -618,7 +748,7 @@ impl LogStore { ) { self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Log(typ), text: new_message, }, @@ -630,20 +760,20 @@ impl LogStore { fn add_language_server_trace( &mut self, - id: LanguageServerId, + key: &LanguageServerLogKey, message: &str, verbose_info: Option, cx: &mut Context, ) -> Option<()> { let store_logs = !self.on_headless_host; - let language_server_state = self.get_language_server_state(id)?; + let language_server_state = self.get_language_server_state(key)?; let log_lines = &mut language_server_state.trace_messages; if !store_logs { // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Trace { verbose_info }, text: message.trim().to_string(), }, @@ -669,7 +799,7 @@ impl LogStore { } self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Trace { verbose_info }, text: new_message, }, @@ -696,7 +826,7 @@ impl LogStore { fn add_language_server_rpc( &mut self, - language_server_id: LanguageServerId, + key: &LanguageServerLogKey, kind: MessageKind, message: &str, timing: RpcTiming, @@ -704,7 +834,7 @@ impl LogStore { ) { let store_logs = !self.on_headless_host; let Some(state) = self - .get_language_server_state(language_server_id) + .get_language_server_state(key) .and_then(|state| state.rpc_state.as_mut()) else { return; @@ -737,7 +867,7 @@ impl LogStore { if let Some(header) = header { // Do not send a synthetic message over the wire, it will be derived from the actual RPC message cx.emit(Event::NewServerLogEntry { - id: language_server_id, + key: key.clone(), kind: LanguageServerLogType::Rpc { received, elapsed: None, @@ -748,7 +878,7 @@ impl LogStore { self.emit_event( Event::NewServerLogEntry { - id: language_server_id, + key: key.clone(), kind: LanguageServerLogType::Rpc { received, elapsed }, text: message.to_owned(), }, @@ -756,44 +886,37 @@ impl LogStore { ); } - pub fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context) { - self.language_servers.remove(&id); + pub fn remove_language_server(&mut self, key: &LanguageServerLogKey, cx: &mut Context) { + self.language_servers.remove(key); cx.notify(); } - pub fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.log_messages) + pub fn server_logs(&self, key: &LanguageServerLogKey) -> Option<&VecDeque> { + Some(&self.language_servers.get(key)?.log_messages) } - pub fn server_trace(&self, server_id: LanguageServerId) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.trace_messages) + pub fn server_trace(&self, key: &LanguageServerLogKey) -> Option<&VecDeque> { + Some(&self.language_servers.get(key)?.trace_messages) } - pub fn server_ids_for_project<'a>( + pub fn server_keys_for_project<'a>( &'a self, - lookup_project: &'a WeakEntity, - ) -> impl Iterator + 'a { + project: &'a WeakEntity, + lsp_store: &'a WeakEntity, + ) -> impl Iterator + 'a { self.language_servers - .iter() - .filter_map(move |(id, state)| match &state.kind { - LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => { - if project == lookup_project { - Some(*id) - } else { - None - } - } - LanguageServerKind::Global | LanguageServerKind::LocalSsh { .. } => Some(*id), - }) + .keys() + .filter(move |key| key.is_for_project(project, lsp_store)) + .cloned() } pub fn enable_rpc_trace_for_language_server( &mut self, - server_id: LanguageServerId, + key: &LanguageServerLogKey, ) -> Option<&mut LanguageServerRpcState> { let rpc_state = self .language_servers - .get_mut(&server_id)? + .get_mut(key)? .rpc_state .get_or_insert_with(|| LanguageServerRpcState { rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), @@ -805,25 +928,30 @@ impl LogStore { pub fn disable_rpc_trace_for_language_server( &mut self, - server_id: LanguageServerId, + key: &LanguageServerLogKey, ) -> Option<()> { - self.language_servers.get_mut(&server_id)?.rpc_state.take(); + self.language_servers.get_mut(key)?.rpc_state.take(); Some(()) } - pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool { - match server { - LanguageServerSelector::Id(id) => self.language_servers.contains_key(id), - LanguageServerSelector::Name(name) => self - .language_servers - .iter() - .any(|(_, state)| state.name.as_ref() == Some(name)), - } + pub fn has_server_logs( + &self, + server: &LanguageServerSelector, + project: &WeakEntity, + lsp_store: &WeakEntity, + ) -> bool { + self.language_servers.iter().any(|(key, state)| { + key.is_for_project(project, lsp_store) + && match server { + LanguageServerSelector::Id(id) => key.server_id == *id, + LanguageServerSelector::Name(name) => state.name.as_ref() == Some(name), + } + }) } fn on_io( &mut self, - language_server_id: LanguageServerId, + key: &LanguageServerLogKey, io_kind: IoKind, message: &str, observed_at: Instant, @@ -833,7 +961,7 @@ impl LogStore { IoKind::StdOut => true, IoKind::StdIn => false, IoKind::StdErr => { - self.add_language_server_log(language_server_id, MessageType::LOG, message, cx); + self.add_language_server_log(key, MessageType::LOG, message, cx); return Some(()); } }; @@ -844,36 +972,30 @@ impl LogStore { MessageKind::Send }; - self.add_language_server_rpc( - language_server_id, - kind, - message, - RpcTiming::ObservedAt(observed_at), - cx, - ); + self.add_language_server_rpc(key, kind, message, RpcTiming::ObservedAt(observed_at), cx); cx.notify(); Some(()) } fn emit_event(&mut self, e: Event, cx: &mut Context) { match &e { - Event::NewServerLogEntry { id, kind, text } => { - if let Some(state) = self.get_language_server_state(*id) { - let downstream_client = match &state.kind { + Event::NewServerLogEntry { key, kind, text } => { + if let Some(state) = self.get_language_server_state(key) { + let downstream_client = match &key.kind { LanguageServerKind::Remote { project } | LanguageServerKind::Local { project } => project .upgrade() .map(|project| project.read(cx).lsp_store()), LanguageServerKind::LocalSsh { lsp_store } => lsp_store.upgrade(), - LanguageServerKind::Global => None, + LanguageServerKind::Supplementary { .. } => None, } .and_then(|lsp_store| lsp_store.read(cx).downstream_client()); if let Some((client, project_id)) = downstream_client { - if Some(LogKind::from_server_log_type(kind)) == state.toggled_log_kind { + if state.toggled_log_kind == Some(LogKind::from_server_log_type(kind)) { client .send(proto::LanguageServerLog { project_id, - language_server_id: id.to_proto(), + language_server_id: key.server_id.to_proto(), message: text.clone(), log_type: Some(kind.to_proto()), }) @@ -889,31 +1011,88 @@ impl LogStore { pub fn toggle_lsp_logs( &mut self, - server_id: LanguageServerId, + key: &LanguageServerLogKey, enabled: bool, toggled_log_kind: LogKind, ) { - if let Some(server_state) = self.get_language_server_state(server_id) { + if let Some(server_state) = self.get_language_server_state(key) { if enabled { server_state.toggled_log_kind = Some(toggled_log_kind); } else { server_state.toggled_log_kind = None; } } - if LogKind::Rpc == toggled_log_kind { + if toggled_log_kind == LogKind::Rpc { if enabled { - self.enable_rpc_trace_for_language_server(server_id); + self.enable_rpc_trace_for_language_server(key); } else { - self.disable_rpc_trace_for_language_server(server_id); + self.disable_rpc_trace_for_language_server(key); } } } - pub fn copilot_state_for_project( + pub fn sync_copilot_for_project( &mut self, project: &WeakEntity, - ) -> Option<&mut Option> { - self.projects - .get_mut(project) - .map(|project| &mut project.copilot_log_subscription) + server: Option>, + cx: &mut Context, + ) -> Option<()> { + let server_kind = LanguageServerKind::Supplementary { + project: project.clone(), + }; + let current_server_id = server.as_ref().map(|server| server.server_id()); + let current_server_matches = server.as_ref().is_some_and(|server| { + let server_key = LanguageServerLogKey::new(server_kind.clone(), server.server_id()); + self.language_servers + .get(&server_key) + .and_then(|state| state.server()) + .is_some_and(|current_server| Arc::ptr_eq(¤t_server, server)) + }); + let project_state = self.projects.get_mut(project)?; + if project_state.copilot_server_id == current_server_id && current_server_matches { + return Some(()); + } + + project_state.copilot_log_subscription = None; + let previous_server_id = project_state.copilot_server_id.take(); + if previous_server_id != current_server_id + && let Some(previous_server_id) = previous_server_id + { + let previous_server_key = + LanguageServerLogKey::new(server_kind.clone(), previous_server_id); + self.remove_language_server(&previous_server_key, cx); + } + + let Some(server) = server else { + return Some(()); + }; + let server_id = server.server_id(); + let server_key = LanguageServerLogKey::new(server_kind.clone(), server_id); + let weak_log_store = cx.weak_entity(); + let log_subscription = + server.on_notification::(move |params, cx| { + weak_log_store + .update(cx, |log_store, cx| { + log_store.add_language_server_log( + &server_key, + MessageType::LOG, + ¶ms.message, + cx, + ); + }) + .ok(); + }); + self.add_language_server( + server_kind, + server_id, + Some(LanguageServerName::new_static("copilot")), + None, + Some(server), + cx, + ); + + let project_state = self.projects.get_mut(project)?; + project_state.copilot_server_id = Some(server_id); + project_state.copilot_log_subscription = Some(log_subscription); + Some(()) } } diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index bb994492d0..11cd9dea51 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -5,7 +5,7 @@ use crate::{ location_link_to_proto, location_links_from_lsp, location_links_from_proto, location_links_to_proto, }, - lsp_store::LspStore, + lsp_store::{LanguageServerToQuery, LspStore}, make_lsp_text_document_position, make_text_document_identifier, }; use anyhow::{Context as _, Result}; @@ -56,6 +56,7 @@ impl ExpandedMacro { #[derive(Debug)] pub struct ExpandMacro { pub position: PointUtf16, + pub server_id: LanguageServerId, } #[async_trait(?Send)] @@ -68,10 +69,14 @@ impl LspCommand for ExpandMacro { "Expand macro" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } + fn language_server_to_query(&self) -> LanguageServerToQuery { + LanguageServerToQuery::Other(self.server_id) + } + fn to_lsp( &self, path: &Path, @@ -108,6 +113,7 @@ impl LspCommand for ExpandMacro { position: Some(language::proto::serialize_anchor( &buffer.anchor_before(self.position), )), + server_id: self.server_id.to_proto(), } } @@ -123,6 +129,7 @@ impl LspCommand for ExpandMacro { .context("invalid position")?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + server_id: LanguageServerId::from_proto(message.server_id), }) } @@ -188,6 +195,7 @@ impl DocsUrls { #[derive(Debug)] pub struct OpenDocs { pub position: PointUtf16, + pub server_id: LanguageServerId, } #[async_trait(?Send)] @@ -200,10 +208,14 @@ impl LspCommand for OpenDocs { "Open docs" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } + fn language_server_to_query(&self) -> LanguageServerToQuery { + LanguageServerToQuery::Other(self.server_id) + } + fn to_lsp( &self, path: &Path, @@ -242,6 +254,7 @@ impl LspCommand for OpenDocs { position: Some(language::proto::serialize_anchor( &buffer.anchor_before(self.position), )), + server_id: self.server_id.to_proto(), } } @@ -257,6 +270,7 @@ impl LspCommand for OpenDocs { .context("invalid position")?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + server_id: LanguageServerId::from_proto(message.server_id), }) } @@ -307,13 +321,15 @@ pub struct SwitchSourceHeaderParams(lsp::TextDocumentIdentifier); #[serde(rename_all = "camelCase")] pub struct SwitchSourceHeaderResult(pub String); -#[derive(Default, Deserialize, Serialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct SwitchSourceHeader; +#[derive(Debug)] +pub struct SwitchSourceHeader { + pub server_id: LanguageServerId, +} #[derive(Debug)] pub struct GoToParentModule { pub position: PointUtf16, + pub server_id: LanguageServerId, } pub struct LspGoToParentModule {} @@ -334,10 +350,14 @@ impl LspCommand for SwitchSourceHeader { "Switch source header" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } + fn language_server_to_query(&self) -> LanguageServerToQuery { + LanguageServerToQuery::Other(self.server_id) + } + fn to_lsp( &self, path: &Path, @@ -367,16 +387,19 @@ impl LspCommand for SwitchSourceHeader { proto::LspExtSwitchSourceHeader { project_id, buffer_id: buffer.remote_id().into(), + server_id: self.server_id.to_proto(), } } async fn from_proto( - _: Self::ProtoRequest, + message: Self::ProtoRequest, _: Entity, _: Entity, _: AsyncApp, ) -> anyhow::Result { - Ok(Self {}) + Ok(Self { + server_id: LanguageServerId::from_proto(message.server_id), + }) } fn response_to_proto( @@ -416,10 +439,14 @@ impl LspCommand for GoToParentModule { "Go to parent module" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } + fn language_server_to_query(&self) -> LanguageServerToQuery { + LanguageServerToQuery::Other(self.server_id) + } + fn to_lsp( &self, path: &Path, @@ -455,6 +482,7 @@ impl LspCommand for GoToParentModule { position: Some(language::proto::serialize_anchor( &buffer.anchor_before(self.position), )), + server_id: self.server_id.to_proto(), } } @@ -470,6 +498,7 @@ impl LspCommand for GoToParentModule { .context("bad request with bad position")?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + server_id: LanguageServerId::from_proto(request.server_id), }) } @@ -606,6 +635,7 @@ pub struct ShellRunnableArgs { pub struct GetLspRunnables { pub buffer_id: BufferId, pub position: Option, + pub server_id: LanguageServerId, } #[derive(Debug, Default)] @@ -673,10 +703,14 @@ impl LspCommand for GetLspRunnables { "LSP Runnables" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } + fn language_server_to_query(&self) -> LanguageServerToQuery { + LanguageServerToQuery::Other(self.server_id) + } + fn to_lsp( &self, path: &Path, @@ -723,6 +757,7 @@ impl LspCommand for GetLspRunnables { project_id, buffer_id: buffer.remote_id().to_proto(), position: self.position.as_ref().map(serialize_anchor), + server_id: self.server_id.to_proto(), } } @@ -737,6 +772,7 @@ impl LspCommand for GetLspRunnables { Ok(Self { buffer_id, position, + server_id: LanguageServerId::from_proto(message.server_id), }) } diff --git a/crates/project/src/lsp_store/semantic_tokens.rs b/crates/project/src/lsp_store/semantic_tokens.rs index 675ab29f76..06d3b65fd4 100644 --- a/crates/project/src/lsp_store/semantic_tokens.rs +++ b/crates/project/src/lsp_store/semantic_tokens.rs @@ -1,4 +1,4 @@ -use std::{collections::hash_map, ops::Range, slice::ChunksExact, sync::Arc}; +use std::{ops::Range, slice::ChunksExact, sync::Arc}; use anyhow::Result; @@ -10,7 +10,7 @@ use futures::{ }; use gpui::{App, AppContext, AsyncApp, Context, Entity, ReadGlobal as _, SharedString, Task}; use language::{Buffer, LanguageName, language_settings::all_language_settings}; -use lsp::{AdapterServerCapabilities, LanguageServerId}; +use lsp::LanguageServerId; use rpc::{TypedEnvelope, proto}; use settings::{ DefaultSemanticTokenRules, SemanticTokenRule, SemanticTokenRules, Settings as _, SettingsStore, @@ -26,12 +26,17 @@ use crate::{ LspCommand, SemanticTokensDelta, SemanticTokensEdit, SemanticTokensFull, SemanticTokensResponse, }, - lsp_store::missing_servers_to_query, + lsp_store::{ + LanguageServerState, document_selector_context_for_language, document_selector_matches, + missing_servers_to_query, + }, project_settings::ProjectSettings, }; +type StylizerKey = (LanguageServerId, Option); + pub(super) struct SemanticTokenConfig { - stylizers: HashMap<(LanguageServerId, Option), SemanticTokenStylizer>, + stylizers: HashMap, rules: SemanticTokenRules, global_mode: settings::SemanticTokens, } @@ -88,27 +93,54 @@ impl LspStore { cx: &mut Context, ) -> SemanticTokensTask { let version_queried_for = buffer.read(cx).version(); - let current_servers = self.relevant_server_ids_for_capability_check(&buffer, cx); + let capability_probe = SemanticTokensFull { for_server: None }; + let current_servers = self.language_server_ids_for_request(&buffer, &capability_probe, cx); + let empty_current_servers_are_authoritative = self.as_local().is_some(); let latest_lsp_data = self.latest_lsp_data(&buffer, cx); let semantic_tokens_data = latest_lsp_data.semantic_tokens.get_or_insert_default(); - let mut refreshed_servers = std::mem::take(&mut semantic_tokens_data.pending_refreshes); - refreshed_servers.retain(|server_id| current_servers.contains(server_id)); - if !refreshed_servers.is_empty() { + let pending_refreshes = std::mem::take(&mut semantic_tokens_data.pending_refreshes); + let current_servers_are_authoritative = + !current_servers.is_empty() || empty_current_servers_are_authoritative; + let server_set_shrank = current_servers_are_authoritative + && (semantic_tokens_data + .raw_tokens + .servers + .keys() + .any(|server_id| !current_servers.contains(server_id)) + || semantic_tokens_data + .fetched_servers + .iter() + .any(|server_id| !current_servers.contains(server_id))); + let cache_invalidated = !pending_refreshes.is_empty() || server_set_shrank; + if cache_invalidated { semantic_tokens_data.update = None; semantic_tokens_data.generation += 1; } - for refreshed_server in &refreshed_servers { + for refreshed_server in &pending_refreshes { semantic_tokens_data .raw_tokens .servers .remove(refreshed_server); + semantic_tokens_data + .fetched_servers + .remove(refreshed_server); } - let missing_servers = missing_servers_to_query( - &mut semantic_tokens_data.raw_tokens.servers, - &mut semantic_tokens_data.fetched_servers, - ¤t_servers, - ) - .unwrap_or_default(); + let refreshed_servers = pending_refreshes + .into_iter() + .filter(|server_id| current_servers.contains(server_id)) + .collect::>(); + let missing_servers = if current_servers.is_empty() && current_servers_are_authoritative { + semantic_tokens_data.raw_tokens.servers.clear(); + semantic_tokens_data.fetched_servers.clear(); + HashSet::default() + } else { + missing_servers_to_query( + &mut semantic_tokens_data.raw_tokens.servers, + &mut semantic_tokens_data.fetched_servers, + ¤t_servers, + ) + .unwrap_or_default() + }; if !missing_servers.is_empty() { semantic_tokens_data.update = None; } @@ -122,6 +154,7 @@ impl LspStore { let mut servers_to_fetch = refreshed_servers; servers_to_fetch.extend(missing_servers); + let rebuild_from_cache_only = cache_invalidated && servers_to_fetch.is_empty(); let for_server = if servers_to_fetch.len() == 1 { servers_to_fetch.iter().next().copied() } else { @@ -130,13 +163,19 @@ impl LspStore { // with cheap deltas. None }; - semantic_tokens_data - .fetched_servers - .extend(match for_server { - Some(server_id) => HashSet::from_iter([server_id]), - None => current_servers, - }); - let new_tokens = self.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx); + if !rebuild_from_cache_only { + semantic_tokens_data + .fetched_servers + .extend(match for_server { + Some(server_id) => HashSet::from_iter([server_id]), + None => current_servers, + }); + } + let new_tokens = if rebuild_from_cache_only { + Task::ready(None) + } else { + self.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx) + }; let task_buffer = buffer.clone(); let task_version_queried_for = version_queried_for.clone(); @@ -199,7 +238,13 @@ impl LspStore { && let Some(semantic_tokens) = current_lsp_data.semantic_tokens.as_mut() && semantic_tokens.generation == query_generation { - if for_server.is_none() || semantic_tokens.raw_tokens.servers.is_empty() + if rebuild_from_cache_only { + let buffer_snapshot = + buffer.read_with(cx, |buffer, _| buffer.snapshot()); + remaining_tokens = + Some((semantic_tokens.raw_tokens.clone(), buffer_snapshot)); + } else if for_server.is_none() + || semantic_tokens.raw_tokens.servers.is_empty() { semantic_tokens.evict_all(); } else { @@ -296,61 +341,50 @@ impl LspStore { Some(tokens) }) } else { + let full_request = SemanticTokensFull { for_server: None }; let token_tasks = self - .local_lsp_servers_for_buffer(&buffer, cx) + .language_server_ids_for_request(buffer, &full_request, cx) .into_iter() .filter(|&server_id| { for_server.is_none_or(|for_server_id| for_server_id == server_id) }) - .filter_map(|server_id| { - let capabilities = AdapterServerCapabilities { - server_capabilities: self.lsp_server_capabilities.get(&server_id)?.clone(), - code_action_kinds: None, - }; + .map(|server_id| { let request_task = match self.semantic_tokens_result_id(server_id, buffer, cx) { Some(result_id) => { let delta_request = SemanticTokensDelta { previous_result_id: result_id, }; - if !delta_request.check_capabilities(capabilities.clone()) { - let full_request = SemanticTokensFull { - for_server: Some(server_id), - }; - if !full_request.check_capabilities(capabilities) { - return None; - } - - self.request_lsp( - buffer.clone(), - LanguageServerToQuery::Other(server_id), - full_request, - cx, - ) - } else { + if self + .language_server_ids_for_request(buffer, &delta_request, cx) + .contains(&server_id) + { self.request_lsp( buffer.clone(), LanguageServerToQuery::Other(server_id), delta_request, cx, ) + } else { + self.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + SemanticTokensFull { + for_server: Some(server_id), + }, + cx, + ) } } - None => { - let request = SemanticTokensFull { + None => self.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + SemanticTokensFull { for_server: Some(server_id), - }; - if !request.check_capabilities(capabilities) { - return None; - } - self.request_lsp( - buffer.clone(), - LanguageServerToQuery::Other(server_id), - request, - cx, - ) - } + }, + cx, + ), }; - Some(async move { (server_id, request_task.await) }) + async move { (server_id, request_task.await) } }) .collect::>(); if token_tasks.is_empty() { @@ -478,34 +512,119 @@ impl LspStore { language: Option<&LanguageName>, cx: &mut App, ) -> Option<&SemanticTokenStylizer> { - let stylizer = match self + let key = (server_id, language.cloned()); + let legend = self.semantic_tokens_legend(server_id, language)?; + let up_to_date = self .semantic_token_config .stylizers - .entry((server_id, language.cloned())) + .get(&key) + .is_some_and(|(cached_legend, _)| cached_legend == legend); + if !up_to_date { + let legend = legend.clone(); + let language_rules = language.and_then(|language| { + SettingsStore::global(cx).language_semantic_token_rules(language.as_ref()) + }); + let stylizer = SemanticTokenStylizer::new(server_id, &legend, language_rules, cx); + self.semantic_token_config + .stylizers + .insert(key.clone(), (legend, stylizer)); + } + self.semantic_token_config + .stylizers + .get(&key) + .map(|(_, stylizer)| stylizer) + } + + fn semantic_tokens_legend( + &self, + server_id: LanguageServerId, + language: Option<&LanguageName>, + ) -> Option<&lsp::SemanticTokensLegend> { + if let Some(local) = self.as_local() + && let Some(language) = language + && let Some(LanguageServerState::Running { adapter, .. }) = + local.language_servers.get(&server_id) { - hash_map::Entry::Occupied(o) => o.into_mut(), - hash_map::Entry::Vacant(v) => { - let tokens_provider = self - .lsp_server_capabilities - .get(&server_id)? - .semantic_tokens_provider - .as_ref()?; - let legend = match tokens_provider { - lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(opts) => { - &opts.legend + let context = document_selector_context_for_language(language, adapter); + if let Some(registrations) = local + .language_server_dynamic_registrations + .get(&server_id) + .and_then(|registrations| { + registrations + .text_documents + .get("textDocument/semanticTokens") + }) + { + for registration in registrations.values().rev() { + if document_selector_matches(registration.document_selector.as_ref(), &context) + && let Some(provider) = registration + .server_capabilities + .semantic_tokens_provider + .as_ref() + { + return Some(semantic_tokens_provider_legend(provider)); } - lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions( - opts, - ) => &opts.semantic_tokens_options.legend, - }; - let language_rules = language.and_then(|language| { - SettingsStore::global(cx).language_semantic_token_rules(language.as_ref()) - }); - let stylizer = SemanticTokenStylizer::new(server_id, legend, language_rules, cx); - v.insert(stylizer) + } } - }; - Some(stylizer) + if let Some(provider) = local + .initial_server_capabilities + .get(&server_id) + .and_then(|capabilities| capabilities.semantic_tokens_provider.as_ref()) + { + return Some(semantic_tokens_provider_legend(provider)); + } + } + + if let Some(initial_capabilities) = self.lsp_server_initial_capabilities.get(&server_id) { + let context = self.remote_document_selector_context(server_id, language); + if let Some(registrations) = self + .lsp_server_text_document_registrations + .get(&server_id) + .and_then(|registrations| registrations.get("textDocument/semanticTokens")) + { + // Token indices are only meaningful with the provider's legend, so do not + // guess when the guest cannot reconstruct the host's selector context. + for registration in registrations.values().rev() { + let matches = match context.as_ref() { + Some(context) => document_selector_matches( + registration.document_selector.as_ref(), + context, + ), + None if registration.document_selector.is_none() => true, + None => return None, + }; + if matches + && let Some(provider) = registration + .server_capabilities + .semantic_tokens_provider + .as_ref() + { + return Some(semantic_tokens_provider_legend(provider)); + } + } + } + + let provider = initial_capabilities.semantic_tokens_provider.as_ref()?; + return Some(semantic_tokens_provider_legend(provider)); + } + + let tokens_provider = self + .lsp_server_capabilities + .get(&server_id)? + .semantic_tokens_provider + .as_ref()?; + Some(semantic_tokens_provider_legend(tokens_provider)) + } +} + +fn semantic_tokens_provider_legend( + provider: &lsp::SemanticTokensServerCapabilities, +) -> &lsp::SemanticTokensLegend { + match provider { + lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(options) => &options.legend, + lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(options) => { + &options.semantic_tokens_options.legend + } } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ed6627f66c..2bdff41b16 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -167,8 +167,9 @@ pub use buffer_store::ProjectTransaction; pub use lsp_command::{CallHierarchyItem, IncomingCall, OutgoingCall}; pub use lsp_store::{ DiagnosticSummary, InvalidationStrategy, LanguageServerLogType, LanguageServerProgress, - LanguageServerPromptRequest, LanguageServerStatus, LanguageServerToQuery, LspStore, - LspStoreEvent, ProgressToken, SERVER_PROGRESS_THROTTLE_TIMEOUT, + LanguageServerPromptRequest, LanguageServerShowDocumentRequest, LanguageServerStatus, + LanguageServerToQuery, LspStore, LspStoreEvent, ProgressToken, + SERVER_PROGRESS_THROTTLE_TIMEOUT, }; pub use toolchain_store::{ToolchainStore, Toolchains}; const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500; @@ -336,7 +337,9 @@ pub struct ToastLink { #[derive(Clone, Debug, PartialEq)] pub enum Event { LanguageServerAdded(LanguageServerId, LanguageServerName, Option), + SupplementaryLanguageServerAdded(LanguageServerId, LanguageServerName), LanguageServerRemoved(LanguageServerId), + SupplementaryLanguageServerRemoved(LanguageServerId), LanguageServerLog(LanguageServerId, LanguageServerLogType, String), // [`lsp::notification::DidOpenTextDocument`] was sent to this server using the buffer data. // Zed's buffer-related data is updated accordingly. @@ -361,6 +364,7 @@ pub enum Event { notification_id: SharedString, }, LanguageServerPrompt(LanguageServerPromptRequest), + LanguageServerShowDocument(LanguageServerShowDocumentRequest), LanguageNotFound(Entity), ActiveEntryChanged(Option), ActivateProjectPanel, @@ -413,6 +417,9 @@ pub enum Event { RefreshDocumentLinks { server_id: Option, }, + RefreshDocumentHighlights { + server_id: Option, + }, RefreshFoldingRanges { server_id: Option, }, @@ -484,7 +491,10 @@ impl ProjectPath { #[derive(Debug, Default)] pub enum PrepareRenameResponse { - Success(Range), + Success { + range: Range, + language_server_id: Option, + }, OnlyUnpreparedRenameSupported, #[default] InvalidPosition, @@ -512,7 +522,7 @@ impl InlayId { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct InlayHint { pub position: language::Anchor, pub label: InlayHintLabel, @@ -846,17 +856,18 @@ impl InlayHint { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum InlayHintLabel { String(String), LabelParts(Vec), } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct InlayHintLabelPart { pub value: String, pub tooltip: Option, pub location: Option<(LanguageServerId, lsp::Location)>, + pub command: Option<(LanguageServerId, lsp::Command)>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1670,6 +1681,8 @@ impl Project { remote_proto.add_entity_message_handler(Self::handle_toast); remote_proto.add_entity_message_handler(Self::handle_telemetry_event); remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request); + remote_proto + .add_entity_request_handler(Self::handle_language_server_show_document_request); remote_proto.add_entity_message_handler(Self::handle_hide_toast); remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server); remote_proto.add_entity_request_handler(Self::handle_trust_worktrees); @@ -2053,7 +2066,7 @@ impl Project { ) -> Entity { use clock::FakeSystemClock; - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); let languages = LanguageRegistry::test(cx.background_executor().clone()); let clock = Arc::new(FakeSystemClock::new()); let http_client = http_client::FakeHttpClient::with_404_response(); @@ -3696,9 +3709,29 @@ impl Project { LspStoreEvent::LanguageServerAdded(server_id, name, worktree_id) => cx.emit( Event::LanguageServerAdded(*server_id, name.clone(), *worktree_id), ), + LspStoreEvent::SupplementaryLanguageServerAdded(server_id, name) => cx.emit( + Event::SupplementaryLanguageServerAdded(*server_id, name.clone()), + ), LspStoreEvent::LanguageServerRemoved(server_id) => { + if self.is_local() + && let Some(project_id) = self.remote_id() + { + self.collab_client + .send(proto::UpdateLanguageServer { + project_id, + server_name: None, + language_server_id: server_id.to_proto(), + variant: Some(proto::update_language_server::Variant::Removed( + proto::ServerRemoved {}, + )), + }) + .log_err(); + } cx.emit(Event::LanguageServerRemoved(*server_id)) } + LspStoreEvent::SupplementaryLanguageServerRemoved(server_id) => { + cx.emit(Event::SupplementaryLanguageServerRemoved(*server_id)) + } LspStoreEvent::LanguageServerLog(server_id, log_type, string) => cx.emit( Event::LanguageServerLog(*server_id, log_type.clone(), string.clone()), ), @@ -3732,6 +3765,11 @@ impl Project { server_id: *server_id, }) } + LspStoreEvent::RefreshDocumentHighlights { server_id } => { + cx.emit(Event::RefreshDocumentHighlights { + server_id: *server_id, + }) + } LspStoreEvent::RefreshFoldingRanges { server_id } => { cx.emit(Event::RefreshFoldingRanges { server_id: *server_id, @@ -3745,6 +3783,9 @@ impl Project { LspStoreEvent::LanguageServerPrompt(prompt) => { cx.emit(Event::LanguageServerPrompt(prompt.clone())) } + LspStoreEvent::LanguageServerShowDocument(request) => { + cx.emit(Event::LanguageServerShowDocument(request.clone())) + } LspStoreEvent::DiskBasedDiagnosticsStarted { language_server_id } => { cx.emit(Event::DiskBasedDiagnosticsStarted { language_server_id: *language_server_id, @@ -3760,7 +3801,12 @@ impl Project { name, message, } => { - if self.is_local() { + if self.is_local() + && !matches!( + message, + proto::update_language_server::Variant::MetadataUpdated(_) + ) + { self.enqueue_buffer_ordered_message( BufferOrderedMessage::LanguageServerUpdate { language_server_id: *language_server_id, @@ -3774,14 +3820,11 @@ impl Project { match message { proto::update_language_server::Variant::MetadataUpdated(update) => { self.lsp_store.update(cx, |lsp_store, _| { - if let Some(capabilities) = update - .capabilities - .as_ref() - .and_then(|capabilities| serde_json::from_str(capabilities).ok()) - { - lsp_store - .lsp_server_capabilities - .insert(*language_server_id, capabilities); + if let Some(capabilities) = update.capabilities.as_ref() { + lsp_store.insert_synced_server_capabilities( + *language_server_id, + capabilities, + ); } if let Some(language_server_status) = lsp_store @@ -4687,20 +4730,32 @@ impl Project { buffer: Entity, position: T, new_name: String, + language_server_id: Option, cx: &mut Context, ) -> Task> { let push_to_history = true; let position = position.to_point_utf16(buffer.read(cx)); - self.request_lsp( - buffer, - LanguageServerToQuery::FirstCapable, - PerformRename { - position, - new_name, - push_to_history, - }, - cx, - ) + let mut request = PerformRename { + position, + new_name, + push_to_history, + language_server_id, + }; + if let Some(server_id) = request.language_server_id { + let server_is_capable = !self.is_local() + || self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .language_server_capable_of_lsp_request(&buffer, server_id, &request, cx) + }); + if !server_is_capable { + request.language_server_id = None; + } + } + let server_to_query = request + .language_server_id + .map(LanguageServerToQuery::Other) + .unwrap_or(LanguageServerToQuery::FirstCapable); + self.request_lsp(buffer, server_to_query, request, cx) } pub fn on_type_format( @@ -5594,6 +5649,49 @@ impl Project { }) } + async fn handle_language_server_show_document_request( + project: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let payload = envelope.payload; + let selection = payload.selection_start.zip(payload.selection_end).map( + |(selection_start, selection_end)| lsp::Range { + start: lsp::Position { + line: selection_start.row, + character: selection_start.column, + }, + end: lsp::Position { + line: selection_end.row, + character: selection_end.column, + }, + }, + ); + let uri = lsp::Uri::from_str(&payload.uri) + .with_context(|| format!("parsing show document uri {}", payload.uri))?; + let (tx, rx) = async_channel::bounded(1); + project.update(&mut cx, |_, cx| { + cx.emit(Event::LanguageServerShowDocument( + LanguageServerShowDocumentRequest { + uri, + external: payload.external, + take_focus: payload.take_focus, + selection, + response_channel: tx, + }, + )); + }); + drop(project); + + let success = rx.recv().await.unwrap_or(false); + anyhow::ensure!( + success, + "show document request for {} was not handled successfully", + payload.uri + ); + Ok(proto::Ack {}) + } + async fn handle_hide_toast( this: Entity, envelope: TypedEnvelope, @@ -5865,6 +5963,13 @@ impl Project { }); while let Some((buffer, _)) = new_matches.next().await { + let is_private = buffer.read_with(cx, |buffer, _| { + buffer.file().is_some_and(|file| file.is_private()) + }); + if is_private { + continue; + } + let buffer_id = this.update(cx, |this, cx| { this.create_buffer_for_peer(&buffer, peer_id, cx).to_proto() }); @@ -6260,13 +6365,6 @@ impl Project { Ok(()) } - pub fn supplementary_language_servers<'a>( - &'a self, - cx: &'a App, - ) -> impl 'a + Iterator { - self.lsp_store.read(cx).supplementary_language_servers() - } - pub fn any_language_server_supports_inlay_hints(&self, buffer: &Buffer, cx: &mut App) -> bool { let Some(language) = buffer.language().cloned() else { return false; @@ -6332,14 +6430,16 @@ impl Project { if !relevant_language_servers.contains(name) { return None; } + let opened_in_servers = self + .lsp_store + .read(cx) + .language_server_ids_for_opened_buffer(buffer.remote_id()); self.language_server_statuses(cx) .filter(|(_, server_status)| relevant_language_servers.contains(&server_status.name)) .find_map(|(server_id, server_status)| { - if &server_status.name == name { - Some(server_id) - } else { - None - } + (&server_status.name == name + && opened_in_servers.is_none_or(|server_ids| server_ids.contains(&server_id))) + .then_some(server_id) }) } diff --git a/crates/project/tests/integration/dynamic_registration.rs b/crates/project/tests/integration/dynamic_registration.rs index 9600b2cc4c..ae5da9cb3a 100644 --- a/crates/project/tests/integration/dynamic_registration.rs +++ b/crates/project/tests/integration/dynamic_registration.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use pretty_assertions::assert_eq; +use project::lsp_store::TokenType; use super::*; @@ -132,6 +133,675 @@ async fn test_dynamic_semantic_tokens_registration(cx: &mut gpui::TestAppContext ); } +#[gpui::test] +async fn test_local_semantic_tokens_request_uses_matching_dynamic_registration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/semanticTokens"; + + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + let matching_options = lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["keyword".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Delta { delta: Some(true) }), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }; + let nonmatching_options = lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("untitled".to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["comment".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(false)), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }; + + register_capability( + &fake_server, + method, + "file-semantic-tokens", + serde_json::to_value(matching_options).ok(), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-semantic-tokens", + serde_json::to_value(nonmatching_options).ok(), + ) + .await; + cx.executor().run_until_parked(); + + let aggregate_options = match server_capabilities(&project, server_id, cx) + .semantic_tokens_provider + .expect("expected the later registration to become the aggregate capability") + { + lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(options) => options, + lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(options) => { + options.semantic_tokens_options + } + }; + assert_eq!( + aggregate_options.full, + Some(lsp::SemanticTokensFullOptions::Bool(false)), + "expected the nonmatching registration to make the aggregate capability reject full requests", + ); + + let semantic_token_request_count = Arc::new(atomic::AtomicUsize::new(0)); + fake_server.set_request_handler::({ + let semantic_token_request_count = semantic_token_request_count.clone(); + move |params, _| { + semantic_token_request_count.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/the-root/a.rs")).unwrap() + ); + async move { + Ok(Some(lsp::SemanticTokensResult::Tokens( + lsp::SemanticTokens::default(), + ))) + } + } + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer.clone(), cx) + }) + .await + .unwrap(); + + assert_eq!( + semantic_token_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the matching registration to route a full semantic tokens request", + ); + + let stylizer_token_type = |cx: &mut gpui::TestAppContext| { + lsp_store.update(cx, |lsp_store, cx| { + let language = buffer.read(cx).language().map(|language| language.name()); + lsp_store + .get_or_create_token_stylizer(server_id, language.as_ref(), cx) + .and_then(|stylizer| stylizer.token_type_name(TokenType(0)).cloned()) + }) + }; + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("keyword"), + "expected the stylizer to use the matching registration's legend instead of the aggregate one", + ); + + let updated_matching_options = lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["function".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Delta { delta: Some(true) }), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }; + register_capability( + &fake_server, + method, + "file-semantic-tokens-2", + serde_json::to_value(updated_matching_options).ok(), + ) + .await; + cx.executor().run_until_parked(); + + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("function"), + "expected the cached stylizer to be rebuilt for the newest matching registration's legend", + ); +} + +#[gpui::test] +async fn test_semantic_tokens_refresh_when_duplicate_removal_changes_provider_order( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/semanticTokens"; + + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + let registration_options = |scheme: &str, token_type: &str| { + serde_json::to_value(lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some(scheme.to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec![token_type.to_owned().into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(true)), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok() + }; + + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + register_capability( + &fake_server, + method, + "file-keyword-a", + registration_options("file", "keyword"), + ) + .await; + register_capability( + &fake_server, + method, + "file-function", + registration_options("file", "function"), + ) + .await; + register_capability( + &fake_server, + method, + "file-keyword-b", + registration_options("file", "keyword"), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-comment", + registration_options("untitled", "comment"), + ) + .await; + cx.executor().run_until_parked(); + refresh_events.lock().clear(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let stylizer_token_type = |cx: &mut gpui::TestAppContext| { + lsp_store.update(cx, |lsp_store, cx| { + let language = buffer.read(cx).language().map(|language| language.name()); + lsp_store + .get_or_create_token_stylizer(server_id, language.as_ref(), cx) + .and_then(|stylizer| stylizer.token_type_name(TokenType(0)).cloned()) + }) + }; + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("keyword"), + "expected the newest matching duplicate to provide the initial legend", + ); + + unregister_capabilities(&fake_server, method, &["file-keyword-b"]).await; + cx.executor().run_until_parked(); + + assert_eq!( + refresh_events.lock().as_slice(), + &[format!("semantic_tokens({server_id})")], + "expected removing an ordered duplicate to invalidate semantic tokens", + ); + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("function"), + "expected the next matching registration to provide the updated legend", + ); +} + +#[gpui::test] +async fn test_completion_resolve_uses_matching_dynamic_registration(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let method = "textDocument/completion"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + register_capability( + &fake_server, + method, + "file-completion", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "resolveProvider": true, + })), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-completion", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "resolveProvider": false, + })), + ) + .await; + cx.executor().run_until_parked(); + + fake_server.set_request_handler::(|_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "completion".to_string(), + data: Some(json!({ "resolve": true })), + ..lsp::CompletionItem::default() + }, + ]))) + }); + let resolve_requests = Arc::new(atomic::AtomicUsize::new(0)); + fake_server.set_request_handler::({ + let resolve_requests = resolve_requests.clone(); + move |mut item, _| { + resolve_requests.fetch_add(1, atomic::Ordering::SeqCst); + async move { + item.detail = Some("resolved".to_string()); + Ok(item) + } + } + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let responses = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.completions( + &buffer, + PointUtf16::new(0, 0), + lsp::CompletionContext { + trigger_kind: lsp::CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + cx, + ) + }) + .await + .unwrap(); + let completions = responses + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); + assert_eq!(completions.len(), 1); + let completions = Rc::new(RefCell::new(completions.into_boxed_slice())); + + lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.resolve_completions(buffer.clone(), vec![0], completions, cx) + }) + .await + .unwrap(); + + assert_eq!( + resolve_requests.load(atomic::Ordering::SeqCst), + 1, + "expected the matching file registration to enable completion resolve", + ); +} + +#[gpui::test] +async fn test_code_lens_resolve_uses_matching_dynamic_registration(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let method = "textDocument/codeLens"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + register_capability( + &fake_server, + method, + "file-code-lens", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "resolveProvider": true, + })), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-code-lens", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "resolveProvider": false, + })), + ) + .await; + cx.executor().run_until_parked(); + + fake_server.set_request_handler::(|_, _| async move { + Ok(Some(vec![lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + command: None, + data: Some(json!({ "resolve": true })), + }])) + }); + let resolve_requests = Arc::new(atomic::AtomicUsize::new(0)); + fake_server.set_request_handler::({ + let resolve_requests = resolve_requests.clone(); + move |mut lens, _| { + resolve_requests.fetch_add(1, atomic::Ordering::SeqCst); + async move { + lens.command = Some(lsp::Command { + title: "resolved".to_string(), + command: "test.resolve".to_string(), + arguments: None, + }); + Ok(lens) + } + } + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let actions = lsp_store + .update(cx, |lsp_store, cx| lsp_store.code_lens_actions(&buffer, cx)) + .await + .unwrap() + .expect("expected code lens actions"); + let (lens_id, action) = actions.into_iter().next().expect("expected one code lens"); + let resolved = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.resolve_code_lens(&buffer, action.server_id, lens_id, cx) + }) + .await + .expect("expected the code lens to resolve") + .1; + + assert_eq!( + resolve_requests.load(atomic::Ordering::SeqCst), + 1, + "expected the matching file registration to enable code lens resolve", + ); + assert_eq!(resolved.lsp_action.title(), "resolved"); +} + +#[gpui::test] +async fn test_inlay_hint_resolve_state_uses_matching_dynamic_registration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/inlayHint"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "fn main() {}")], None, cx); + }); + cx.executor().run_until_parked(); + + register_capability( + &fake_server, + method, + "file-inlay-hints", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "resolveProvider": true, + })), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-inlay-hints", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "resolveProvider": false, + })), + ) + .await; + cx.executor().run_until_parked(); + + fake_server.set_request_handler::(|_, _| async move { + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 0), + label: lsp::InlayHintLabel::String("hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: Some(json!({ "resolve": true })), + }])) + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let hint_tasks = lsp_store.update(cx, |lsp_store, cx| { + let range = + buffer.read(cx).anchor_before(0)..buffer.read(cx).anchor_after(buffer.read(cx).len()); + lsp_store.inlay_hints( + InvalidationStrategy::None, + buffer.clone(), + vec![range], + None, + cx, + ) + }); + let mut hints = Vec::new(); + for (_, hint_task) in hint_tasks { + for (_, server_hints) in hint_task.await.unwrap() { + hints.extend(server_hints.into_iter().map(|(_, hint)| hint)); + } + } + + assert_eq!(hints.len(), 1); + assert_matches!( + &hints[0].resolve_state, + ResolveState::CanResolve(id, _) if *id == server_id, + "expected the matching file registration to mark the hint as resolvable", + ); +} + +#[gpui::test] +async fn test_dynamic_document_highlight_registration(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/documentHighlight"; + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + None, + ); + + let registration_options = |language: &str| { + json!({ + "documentSelector": [{ "language": language, "scheme": "file" }], + }) + }; + let registered_provider = Some(lsp::OneOf::Right(lsp::DocumentHighlightOptions { + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })); + register_capability( + &fake_server, + method, + "python-document-highlight", + Some(registration_options("python")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + registered_provider, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected the first registration to refresh", + ); + + register_capability( + &fake_server, + method, + "rust-document-highlight", + Some(registration_options("rust")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + registered_provider, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected adding a distinct selector with identical provider options to refresh", + ); + + register_capability( + &fake_server, + method, + "rust-document-highlight-duplicate", + Some(registration_options("rust")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected a registration identical to an existing one to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["rust-document-highlight-duplicate"]).await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected removing a duplicate registration to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["rust-document-highlight"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + registered_provider, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected removing a selector while another provider remains to refresh", + ); + + unregister_capabilities(&fake_server, method, &["python-document-highlight"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + None, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected the last unregistration to clear the capability and refresh", + ); +} + +#[gpui::test] +async fn test_dynamic_registration_sends_metadata_downstream_before_refresh( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let downstream = Arc::new(RecordingProtoClient::default()); + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.shared(1, rpc::AnyProtoClient::new(downstream.clone()), cx); + }); + }); + cx.executor().run_until_parked(); + downstream.sent.lock().clear(); + + register_capability( + &fake_server, + "textDocument/documentHighlight", + "rust-document-highlight", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + downstream.sent.lock().drain(..).collect::>(), + vec!["UpdateLanguageServer", "RefreshDocumentHighlights"], + "expected the capability update to reach guests before the refresh that depends on it", + ); + + unregister_capabilities( + &fake_server, + "textDocument/documentHighlight", + &["rust-document-highlight"], + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + downstream.sent.lock().drain(..).collect::>(), + vec!["UpdateLanguageServer", "RefreshDocumentHighlights"], + ); +} + #[gpui::test] async fn test_multi_registration_inlay_hint(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -424,9 +1094,9 @@ async fn test_multi_registration_duplicate_id_keeps_order(cx: &mut gpui::TestApp "expected the latest distinct registration to stay active after a duplicate ID replaced an older one", ); assert_eq!( - refresh_events.lock().as_slice(), - &[] as &[String], - "expected no refresh after a duplicate ID replaced an inactive registration", + refresh_events.lock().drain(..).collect::>(), + vec![format!("inlay_hints({server_id})")], + "expected replacing an inactive registration to refresh selector-aware caches", ); unregister_capabilities(&fake_server, method, &["inlay-hint-b"]).await; @@ -478,7 +1148,7 @@ async fn test_registration_with_unchanged_options_does_not_refresh(cx: &mut gpui register_capability( &fake_server, method, - "lens-b", + "lens-a", serde_json::to_value(options).ok(), ) .await; @@ -489,19 +1159,6 @@ async fn test_registration_with_unchanged_options_does_not_refresh(cx: &mut gpui "expected a registration with options identical to the active ones to not refresh", ); - unregister_capabilities(&fake_server, method, &["lens-b"]).await; - cx.executor().run_until_parked(); - assert_eq!( - refresh_events.lock().as_slice(), - &[] as &[String], - "expected an unregistration that restores identical options to not refresh", - ); - assert_eq!( - server_capabilities(&project, server_id, cx).code_lens_provider, - Some(options), - "expected the remaining registration's options to stay active", - ); - unregister_capabilities(&fake_server, method, &["lens-a"]).await; cx.executor().run_until_parked(); assert_eq!( @@ -516,6 +1173,275 @@ async fn test_registration_with_unchanged_options_does_not_refresh(cx: &mut gpui ); } +#[gpui::test] +async fn test_selector_only_registration_change_refreshes_cached_lsp_data( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/codeLens"; + let registration_options = |scheme: &str| { + json!({ + "documentSelector": [{ "language": "rust", "scheme": scheme }], + "resolveProvider": true, + }) + }; + + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + register_capability( + &fake_server, + method, + "lens", + Some(registration_options("file")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + ); + + register_capability( + &fake_server, + method, + "lens", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + "expected changing only the document selector to invalidate cached LSP data", + ); +} + +#[gpui::test] +async fn test_duplicate_content_registration_changes_do_not_refresh(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/codeLens"; + let registration_options = |scheme: &str| { + json!({ + "documentSelector": [{ "language": "rust", "scheme": scheme }], + "resolveProvider": true, + }) + }; + let expected_options = lsp::CodeLensOptions { + resolve_provider: Some(true), + }; + + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + register_capability( + &fake_server, + method, + "lens-a", + Some(registration_options("file")), + ) + .await; + register_capability( + &fake_server, + method, + "lens-b", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![ + format!("code_lens({server_id})"), + format!("code_lens({server_id})"), + ], + "expected registrations with distinct selectors to refresh", + ); + + register_capability( + &fake_server, + method, + "lens-a", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + Some(expected_options), + "expected the active code lens options to survive a duplicate ID replacement", + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + "expected a selector change of any registration to refresh", + ); + + unregister_capabilities(&fake_server, method, &["lens-b"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + Some(expected_options), + "expected the duplicate-content registration to keep the capability active", + ); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected removing a registration whose content a remaining one duplicates to not refresh", + ); + + register_capability( + &fake_server, + method, + "lens-c", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected a new registration with content identical to an existing one to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["lens-a"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + Some(expected_options), + "expected the capability to survive removing one of two identical registrations", + ); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected removing one of two identical registrations to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["lens-c"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + None, + "expected the code lens provider to be cleared after unregistering the last registration", + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + "expected the last unregistration to clear the capability and refresh", + ); +} + +#[gpui::test] +async fn test_selector_aware_routing_with_duplicate_content_registrations( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/completion"; + + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + let buffer_triggers = |cx: &mut gpui::TestAppContext| { + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()) + }; + let registration_options = |scheme: &str, trigger: &str| { + json!({ + "documentSelector": [{ "language": "rust", "scheme": scheme }], + "triggerCharacters": [trigger], + }) + }; + let colon_options = lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..lsp::CompletionOptions::default() + }; + + register_capability( + &fake_server, + method, + "completion-a", + Some(registration_options("file", ".")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::from([".".to_string()]), + "expected the matching registration's triggers to apply to the buffer", + ); + + register_capability( + &fake_server, + method, + "completion-b", + Some(registration_options("untitled", ":")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::from([".".to_string()]), + "expected the non-matching registration's triggers to be excluded from the buffer", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + Some(colon_options.clone()), + "expected the latest registration's options to be active", + ); + + register_capability( + &fake_server, + method, + "completion-a", + Some(registration_options("untitled", ":")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::new(), + "expected re-registering with a non-matching selector to remove the triggers from the buffer", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + Some(colon_options.clone()), + "expected the active options to survive a duplicate ID replacement of an older registration", + ); + + unregister_capabilities(&fake_server, method, &["completion-b"]).await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::new(), + "expected the remaining duplicate-content registration to still not match the buffer", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + Some(colon_options), + "expected the capability to survive removing one of two identical registrations", + ); + + unregister_capabilities(&fake_server, method, &["completion-a"]).await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::new(), + "expected no completion triggers after unregistering the last registration", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + None, + "expected the completion provider to be cleared after unregistering the last registration", + ); +} + #[gpui::test] async fn test_multi_registration_completion_triggers(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -566,8 +1492,8 @@ async fn test_multi_registration_completion_triggers(cx: &mut gpui::TestAppConte cx.executor().run_until_parked(); assert_eq!( buffer_triggers(cx), - BTreeSet::from([":".to_string()]), - "expected the second registration's triggers to replace the first ones", + BTreeSet::from([".".to_string(), ":".to_string()]), + "expected global dynamic registrations to union their triggers", ); let options_a_replacement = lsp::CompletionOptions { @@ -584,8 +1510,8 @@ async fn test_multi_registration_completion_triggers(cx: &mut gpui::TestAppConte cx.executor().run_until_parked(); assert_eq!( buffer_triggers(cx), - BTreeSet::from([":".to_string()]), - "expected the active registration's triggers to stay applied after a duplicate ID replaced an inactive one", + BTreeSet::from(["!".to_string(), ":".to_string()]), + "expected replacing a registration to update its triggers without dropping the others", ); unregister_capabilities(&fake_server, method, &["completion-b"]).await; @@ -649,9 +1575,9 @@ async fn test_multi_registration_middle_removal(cx: &mut gpui::TestAppContext) { "expected the latest registration to stay active after removing an older one from the middle", ); assert_eq!( - refresh_events.lock().as_slice(), - &[] as &[String], - "expected no refresh after removing an inactive registration", + refresh_events.lock().drain(..).collect::>(), + vec![format!("inlay_hints({server_id})")], + "expected removing an inactive registration to refresh selector-aware caches", ); unregister_capabilities(&fake_server, method, &["inlay-hint-b"]).await; @@ -820,8 +1746,8 @@ async fn test_multi_registration_completion_static_restore(cx: &mut gpui::TestAp cx.executor().run_until_parked(); assert_eq!( buffer_triggers(cx), - BTreeSet::from([":".to_string()]), - "expected the dynamic registration's triggers to override the static ones", + BTreeSet::from([".".to_string(), ":".to_string()]), + "expected the dynamic registration's triggers to union with the static ones", ); unregister_capabilities(&fake_server, method, &["completion-dynamic"]).await; @@ -1322,6 +2248,87 @@ async fn test_dynamic_registration_refreshes_lsp_data(cx: &mut gpui::TestAppCont ); } +#[gpui::test] +async fn test_semantic_tokens_server_set_shrink_invalidates_completed_task( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/semanticTokens"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + register_capability( + &fake_server, + method, + "semantic-tokens", + serde_json::to_value(lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: None, + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["keyword".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(true)), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok(), + ) + .await; + fake_server.set_request_handler::( + |_, _| async move { + Ok(Some(lsp::SemanticTokensResult::Tokens( + lsp::SemanticTokens { + result_id: None, + data: vec![0, 0, 2, 0, 0], + }, + ))) + }, + ); + cx.executor().run_until_parked(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let initial_tokens = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer.clone(), cx) + }) + .await + .unwrap(); + assert_eq!( + initial_tokens + .tokens + .as_ref() + .map(|tokens| tokens.keys().copied().collect::>()), + Some(HashSet::from_iter([server_id])), + ); + + unregister_capabilities(&fake_server, method, &["semantic-tokens"]).await; + cx.executor().run_until_parked(); + + let tokens_after_unregistration = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer.clone(), cx) + }) + .await + .unwrap(); + assert!( + tokens_after_unregistration + .tokens + .is_none_or(|tokens| tokens.is_empty()), + "expected the completed semantic-token task to drop the inapplicable server", + ); +} + #[gpui::test] async fn test_semantic_tokens_refresh_invalidates_only_the_refreshed_server( cx: &mut gpui::TestAppContext, @@ -1858,6 +2865,9 @@ fn observe_refresh_events( label("document_colors", server_id) } Event::RefreshDocumentLinks { server_id } => label("document_links", server_id), + Event::RefreshDocumentHighlights { server_id } => { + label("document_highlights", server_id) + } Event::RefreshFoldingRanges { server_id } => label("folding_ranges", server_id), Event::RefreshDocumentSymbols { server_id } => { label("document_symbols", server_id) @@ -2235,3 +3245,40 @@ async fn fetch_all_lsp_data( labels.sort(); labels } + +#[derive(Default)] +struct RecordingProtoClient { + sent: Mutex>, + handler_set: Mutex, +} + +impl rpc::ProtoClient for RecordingProtoClient { + fn request( + &self, + _: rpc::proto::Envelope, + _: &'static str, + ) -> futures::future::BoxFuture<'static, Result> { + unimplemented!() + } + + fn send(&self, _: rpc::proto::Envelope, message_type: &'static str) -> Result<()> { + self.sent.lock().push(message_type); + Ok(()) + } + + fn send_response(&self, _: rpc::proto::Envelope, _: &'static str) -> Result<()> { + Ok(()) + } + + fn message_handler_set(&self) -> &Mutex { + &self.handler_set + } + + fn is_via_collab(&self) -> bool { + false + } + + fn has_wsl_interop(&self) -> bool { + false + } +} diff --git a/crates/project/tests/integration/lsp_store.rs b/crates/project/tests/integration/lsp_store.rs index 2ce689b429..de5056fe20 100644 --- a/crates/project/tests/integration/lsp_store.rs +++ b/crates/project/tests/integration/lsp_store.rs @@ -1,28 +1,153 @@ use std::{ - path::Path, + borrow::Cow, + path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; use collections::HashMap; -use fs::FakeFs; -use futures::StreamExt; -use gpui::TestAppContext; -use language::{CodeLabel, FakeLspAdapter, HighlightId, rust_lang}; -use lsp::{LanguageServerName, Uri}; +use fs::{FakeFs, Fs}; +use futures::{FutureExt, StreamExt}; +use gpui::{Entity, TestAppContext}; +use language::{ + Buffer, CodeLabel, DiagnosticSourceKind, FakeLspAdapter, HighlightId, LocalFile, rust_lang, +}; +use lsp::{LanguageServerId, LanguageServerName, Uri}; use parking_lot::Mutex; use project::{ - Project, + DiagnosticSummary, Event, Project, lsp_store::{ log_store::{TestRpcLogHeaderState, TestRpcRequestTracker}, *, }, }; use serde_json::json; -use util::path; +use unindent::Unindent; +use util::{path, rel_path::rel_path}; use crate::init_test; +#[gpui::test] +async fn test_diagnostic_batches_skip_paths_without_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + for skipped_index in 0..=2 { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "one", "b.rs": "two" })) + .await; + let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await; + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let buffer_a = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let worktree_id = + buffer_a.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx)); + let server_id = LanguageServerId(0); + + for message in [Some("error"), None] { + cx.run_until_parked(); + project.read_with(cx, |project, cx| { + assert_eq!( + project.get_open_buffer(&(worktree_id, rel_path("b.rs")).into(), cx), + None + ); + }); + let mut events = cx.events(&project); + let mut paths = vec![path!("/dir/a.rs"), path!("/dir/b.rs")]; + paths.insert(skipped_index, path!("/outside.rs")); + let updates = paths + .into_iter() + .map(|path| DocumentDiagnosticsUpdate { + diagnostics: lsp::PublishDiagnosticsParams { + uri: Uri::from_file_path(path).unwrap(), + version: None, + diagnostics: message + .into_iter() + .map(|message| lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from(message), + ..lsp::Diagnostic::default() + }) + .collect(), + }, + result_id: None, + registration_id: None, + server_id, + disk_based_sources: Cow::Borrowed(&[]), + }) + .collect(); + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .merge_lsp_diagnostics( + DiagnosticSourceKind::Pushed, + updates, + |_, _, _| false, + cx, + ) + .unwrap(); + }); + cx.run_until_parked(); + + project.read_with(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: if message.is_some() { 2 } else { 0 }, + warning_count: 0, + }, + "skipped update at index {skipped_index}, message {message:?}" + ); + }); + let diagnostic_events = std::iter::from_fn(|| events.next().now_or_never().flatten()) + .filter_map(|event| match event { + Event::DiagnosticsUpdated { + language_server_id, + paths, + } => Some((language_server_id, paths)), + _ => None, + }) + .collect::>(); + assert_eq!( + diagnostic_events, + vec![( + server_id, + vec![ + (worktree_id, rel_path("a.rs")).into(), + (worktree_id, rel_path("b.rs")).into(), + ], + )], + "skipped update at index {skipped_index}, message {message:?}" + ); + + let buffer_b = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/b.rs"), cx) + }) + .await + .unwrap(); + for buffer in [&buffer_a, &buffer_b] { + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer + .buffer_diagnostics(Some(server_id)) + .iter() + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>(), + message.into_iter().collect::>() + ); + }); + } + } + } +} + #[gpui::test] async fn test_removing_invisible_worktree_cleans_reused_lsp_bookkeeping(cx: &mut TestAppContext) { init_test(cx); @@ -163,6 +288,117 @@ async fn test_open_buffer_via_lsp_case_variant_no_duplicate(cx: &mut TestAppCont }); } +#[gpui::test] +async fn test_open_buffer_via_lsp_preserves_external_symlink_path(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/shared"), + json!({ "pkg": { "def.rs": "pub fn def() {}" } }), + ) + .await; + fs.insert_tree( + path!("/project"), + json!({ "src": { "main.rs": "fn main() {}" } }), + ) + .await; + fs.create_symlink( + path!("/project/pkg").as_ref(), + PathBuf::from(path!("/shared/pkg")), + ) + .await + .unwrap(); + + let (project, server_id) = + project_with_rust_server(fs, path!("/project"), path!("/project/src/main.rs"), cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_via_lsp( + Uri::from_file_path(path!("/project/pkg/def.rs")).unwrap(), + server_id, + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + buffer_paths(&buffer, cx), + ( + "pkg/def.rs".to_string(), + PathBuf::from(path!("/project/pkg/def.rs")) + ) + ); + assert_eq!( + worktree_roots(&project, cx), + vec![PathBuf::from(path!("/project"))] + ); +} + +#[gpui::test] +async fn test_open_buffer_via_lsp_case_variant_in_unscanned_dir(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.set_case_sensitive(false); + fs.insert_tree( + path!("/root"), + json!({ + ".gitignore": "ignored\n", + "src": { "main.rs": "fn main() {}" }, + "ignored": { "lib.rs": "pub fn lib() {}" }, + }), + ) + .await; + + let (project, server_id) = + project_with_rust_server(fs, path!("/root"), path!("/root/src/main.rs"), cx).await; + assert_eq!( + worktree_entries(&project, cx), + vec!["", ".gitignore", "ignored", "src", "src/main.rs"] + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_via_lsp( + Uri::from_file_path(path!("/root/IGNORED/LIB.rs")).unwrap(), + server_id, + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + buffer_paths(&buffer, cx), + ( + "ignored/lib.rs".to_string(), + PathBuf::from(path!("/root/ignored/lib.rs")) + ) + ); + assert_eq!( + worktree_roots(&project, cx), + vec![PathBuf::from(path!("/root"))] + ); + assert_eq!( + worktree_entries(&project, cx), + vec![ + "", + ".gitignore", + "ignored", + "ignored/lib.rs", + "src", + "src/main.rs" + ] + ); +} + #[test] fn test_rpc_log_grouping_separates_timed_messages() { for (received, direction) in [(false, "Send"), (true, "Receive")] { @@ -364,6 +600,114 @@ fn test_multi_len_chars_normalization() { ); } +#[test] +fn test_completion_label_snippet_normalization() { + for line_ending in ["\n", "\r\n", "\r"] { + let text = " + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_name() { + + } + }" + .unindent() + .replace('\n', line_ending); + let name_start = text.find("test_name").expect("snippet has a test name"); + let text_len = text.len(); + let mut label = CodeLabel::new( + text, + 0..text_len, + vec![ + (0..12, HighlightId::new(1)), + (name_start..name_start + 9, HighlightId::TABSTOP_REPLACE_ID), + ], + ); + + ensure_uniform_list_compatible_label(&mut label); + + assert_eq!( + label, + CodeLabel::new( + "#[cfg(test)] mod tests { use super::*; #[test] fn test_name() { } }".to_string(), + 0..67, + vec![ + (0..12, HighlightId::new(1)), + (50..59, HighlightId::TABSTOP_REPLACE_ID), + ], + ), + "line ending: {line_ending:?}", + ); + } +} + +#[test] +fn test_completion_label_unicode_normalization() { + for line_ending in ["\n", "\r\n", "\r"] { + let text = " + héllo { + 🦀value: 世界, + }" + .unindent() + .replace('\n', line_ending); + let value_start = text.find("🦀value").expect("label has a value"); + let type_start = text.find("世界").expect("label has a type"); + let text_len = text.len(); + let mut label = CodeLabel::new( + text, + value_start..value_start + 9, + vec![ + (0..text_len, HighlightId::new(0)), + (0..6, HighlightId::new(1)), + ( + value_start..value_start + 9, + HighlightId::TABSTOP_REPLACE_ID, + ), + (type_start..type_start + 6, HighlightId::new(2)), + ], + ); + + ensure_uniform_list_compatible_label(&mut label); + + assert_eq!( + label, + CodeLabel::new( + "héllo { 🦀value: 世界, }".to_string(), + 9..18, + vec![ + (0..29, HighlightId::new(0)), + (0..6, HighlightId::new(1)), + (9..18, HighlightId::TABSTOP_REPLACE_ID), + (20..26, HighlightId::new(2)), + ], + ), + "line ending: {line_ending:?}", + ); + } +} + +#[test] +fn test_completion_label_whitespace_normalization() { + for line_ending in ["\n", "\r\n", "\r", "\n\r", "\r\r\n"] { + for before in ["", " ", " \t "] { + for after in ["", " ", " \t "] { + let mut label = CodeLabel::plain(format!("{before}{line_ending}{after}"), None); + ensure_uniform_list_compatible_label(&mut label); + assert_eq!(label, CodeLabel::plain(" ".to_string(), None)); + } + } + } + + for text in ["", " ", " \t ", "héllo 世界", "héllo\t世界"] { + let mut label = CodeLabel::plain(text.to_string(), None); + let expected = label.clone(); + ensure_uniform_list_compatible_label(&mut label); + assert_eq!(label, expected); + } +} + #[test] fn test_trailing_newline_in_completion_documentation() { let doc = @@ -673,3 +1017,63 @@ async fn test_initialization_options_contributions_without_own_options(cx: &mut Some(Some(contribution)), ); } + +async fn project_with_rust_server( + fs: Arc, + root: &str, + first_file: &str, + cx: &mut TestAppContext, +) -> (Entity, LanguageServerId) { + let project = Project::test(fs, [root.as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(first_file, cx) + }) + .await + .unwrap(); + fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + let server_id = project.read_with(cx, |project, cx| { + project + .lsp_store() + .read(cx) + .language_server_statuses() + .next() + .unwrap() + .0 + }); + (project, server_id) +} + +fn buffer_paths(buffer: &Entity, cx: &TestAppContext) -> (String, PathBuf) { + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).unwrap(); + (file.path.as_unix_str().to_string(), file.abs_path(cx)) + }) +} + +fn worktree_roots(project: &Entity, cx: &TestAppContext) -> Vec { + project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect() + }) +} + +fn worktree_entries(project: &Entity, cx: &TestAppContext) -> Vec { + project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + worktree + .read(cx) + .snapshot() + .entries(true, 0) + .map(|entry| entry.path.as_unix_str().to_string()) + .collect() + }) +} diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index ad3a787fe1..eec9a983d3 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -27,7 +27,7 @@ use buffer_diff::{ use collections::{BTreeSet, HashMap, HashSet}; use encoding_rs; use fs::{FakeFs, PathEventKind, RealFs}; -use futures::{FutureExt as _, StreamExt, future}; +use futures::{FutureExt as _, StreamExt, channel::oneshot, future}; use git::{ GitHostingProviderRegistry, repository::{RepoPath, repo_path}, @@ -221,7 +221,7 @@ async fn test_symlinks(cx: &mut gpui::TestAppContext) { .unwrap(); let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), [root_link_path.as_ref()], cx, ) @@ -3217,6 +3217,220 @@ async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::T assert_eq!(notification.version, 0); } +#[gpui::test] +async fn test_selecting_a_language_clears_the_old_servers_diagnostics( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "const A: i32 = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.add(rust_lang()); + language_registry.add(js_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + fake_server.notify::(lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::String("unused constant".to_string()), + ..Default::default() + }], + }); + cx.executor().run_until_parked(); + + let diagnostic_messages = |buffer: &Buffer| { + buffer + .snapshot() + .diagnostics_in_range::<_, usize>(0..buffer.len(), false) + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>() + }; + + buffer.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), vec!["unused constant"]); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 1); + }); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, js_lang(), cx); + }); + cx.executor().run_until_parked(); + + buffer.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), Vec::::new()); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 0); + }); +} + +#[gpui::test] +async fn test_selecting_a_language_clears_diagnostics_when_the_server_keeps_other_buffers( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ "a.rs": "const A: i32 = 1;", "b.rs": "const B: i32 = 2;" }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.add(rust_lang()); + language_registry.add(js_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (buffer_a, _handle_a) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let (buffer_b, _handle_b) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + let mut shutdown_requests = fake_server + .set_request_handler::(|_, _| future::ready(Ok(()))); + for (path, message) in [ + (path!("/dir/a.rs"), "unused constant A"), + (path!("/dir/b.rs"), "unused constant B"), + ] { + fake_server.notify::( + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path).unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::String(message.to_string()), + ..Default::default() + }], + }, + ); + } + cx.executor().run_until_parked(); + + let diagnostic_messages = |buffer: &Buffer| { + buffer + .snapshot() + .diagnostics_in_range::<_, usize>(0..buffer.len(), false) + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>() + }; + + buffer_a.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), vec!["unused constant A"]); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 2); + }); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer_a, js_lang(), cx); + }); + cx.executor().run_until_parked(); + + buffer_a.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), Vec::::new()); + }); + buffer_b.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), vec!["unused constant B"]); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 1); + }); + assert!( + shutdown_requests.next().now_or_never().flatten().is_none(), + "the server still serves b.rs and must not be stopped" + ); +} + +#[gpui::test] +async fn test_registry_reload_detaches_buffers_from_language_servers( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "const A: i32 = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: Arc::new(LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..LanguageMatcher::default() + }), + ..LanguageConfig::default() + }); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let mut fake_server = fake_servers.next().await.unwrap(); + let open_notification = fake_server + .receive_notification::() + .await; + assert_eq!( + open_notification.text_document.uri, + lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap() + ); + + language_registry.reload(); + cx.executor().run_until_parked(); + + let close_notification = fake_server + .receive_notification::() + .await; + assert_eq!( + close_notification.text_document.uri, + lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap() + ); + let reopen_notification = fake_server + .receive_notification::() + .await; + assert_eq!( + reopen_notification.text_document.uri, + lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap() + ); +} + #[gpui::test] async fn test_cancel_language_server_work(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -4152,6 +4366,15 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) { ..Diagnostic::default() }, ), + DiagnosticEntry::new( + Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 0)), + Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "syntax error on empty line".into(), + source_kind: DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + ), ], cx, ) @@ -4177,6 +4400,13 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) { ("\nlet three = 3;\n", None) ] ); + + let snapshot = buffer.snapshot(); + let diagnostics = snapshot + .diagnostics_in_range::<_, Point>(Point::new(3, 0)..Point::new(3, 0), false) + .collect::>(); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].range, Point::new(3, 0)..Point::new(3, 0)); }); } @@ -4384,6 +4614,66 @@ async fn test_diagnostic_summaries_cleared_on_worktree_entry_removal( }); } +#[gpui::test] +async fn test_stored_diagnostics_not_replayed_after_entry_removal(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "one" })) + .await; + + let project = Project::test(fs.clone(), [Path::new(path!("/dir"))], cx).await; + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostic_entries( + LanguageServerId(0), + Path::new(path!("/dir/a.rs")).to_owned(), + None, + None, + vec![DiagnosticEntry::new( + Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)), + Diagnostic { + severity: DiagnosticSeverity::ERROR, + is_primary: true, + message: "error in a".into(), + source_kind: DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + )], + cx, + ) + .unwrap(); + }); + + fs.remove_file(path!("/dir/a.rs").as_ref(), Default::default()) + .await + .unwrap(); + cx.executor().run_until_parked(); + + fs.insert_file(path!("/dir/a.rs"), "one".as_bytes().to_vec()) + .await; + cx.executor().run_until_parked(); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, _| { + assert_eq!( + buffer + .snapshot() + .diagnostics_in_range::<_, usize>(0..buffer.len(), false) + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>(), + Vec::::new(), + ); + }); +} + #[gpui::test] async fn test_diagnostic_summaries_cleared_on_server_restart(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -4575,30 +4865,13 @@ async fn test_diagnostic_summaries_cleared_on_buffer_reload(cx: &mut gpui::TestA async fn test_diagnostic_summaries_cleared_on_buffer_close_without_workspace_diagnostics( cx: &mut gpui::TestAppContext, ) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-close-no-ws".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: false, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-close-no-ws", + inter_file_dependencies: true, + workspace_diagnostics: false, initializer: Some(Box::new(move |fake_server| { fake_server.set_request_handler::( move |_, _| async move { @@ -4627,9 +4900,9 @@ async fn test_diagnostic_summaries_cleared_on_buffer_close_without_workspace_dia }, ); })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (buffer, handle) = project .update(cx, |project, cx| { @@ -4680,30 +4953,13 @@ async fn test_diagnostic_summaries_cleared_on_buffer_close_without_workspace_dia async fn test_diagnostic_summaries_retained_on_buffer_close_with_workspace_diagnostics( cx: &mut gpui::TestAppContext, ) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-close-ws".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: true, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-close-ws", + inter_file_dependencies: true, + workspace_diagnostics: true, initializer: Some(Box::new(move |fake_server| { fake_server.set_request_handler::( move |_, _| async move { @@ -4739,9 +4995,9 @@ async fn test_diagnostic_summaries_retained_on_buffer_close_with_workspace_diagn }, ); })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (buffer, handle) = project .update(cx, |project, cx| { @@ -4790,30 +5046,13 @@ async fn test_diagnostic_summaries_retained_on_buffer_close_with_workspace_diagn #[gpui::test] async fn test_workspace_diagnostics_pull_timeout_releases_waiters(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-ws-timeout".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: true, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-timeout", + inter_file_dependencies: true, + workspace_diagnostics: true, initializer: Some(Box::new(move |fake_server| { // Simulate a server that holds workspace diagnostic pull requests // open forever without responding or streaming partial results. @@ -4824,9 +5063,9 @@ async fn test_workspace_diagnostics_pull_timeout_releases_waiters(cx: &mut gpui: }, ); })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (_buffer, _handle) = project .update(cx, |project, cx| { @@ -4853,35 +5092,847 @@ async fn test_workspace_diagnostics_pull_timeout_releases_waiters(cx: &mut gpui: ); } +#[gpui::test] +async fn test_workspace_diagnostics_long_poll_is_kept_open(cx: &mut gpui::TestAppContext) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let partial_result_token = Arc::new(Mutex::new(None)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three", "b.rs": "four five six" }), + DiagnosticsPullServer { + identifier: "test-ws-long-poll", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let partial_result_token = partial_result_token.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let partial_result_token = partial_result_token.clone(); + move |params, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + *partial_result_token.lock() = + params.partial_result_params.partial_result_token; + async move { + future::pending::<()>().await; + Err(anyhow::anyhow!("should never respond")) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + cx.executor().advance_clock(DEFAULT_LSP_REQUEST_TIMEOUT * 3); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "an open workspace diagnostics request must be kept open instead of being cancelled and re-sent after a period of inactivity" + ); + + let token = partial_result_token + .lock() + .clone() + .expect("the workspace diagnostics pull should carry a partial result token"); + fake_server.notify::(lsp::ProgressParams { + token, + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap(), + version: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + result_id: Some("ws-1".to_string()), + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("streamed error"), + ..lsp::Diagnostic::default() + }], + }, + }, + )], + }), + ), + }); + cx.executor().run_until_parked(); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: 1, + warning_count: 0, + }, + "partial results streamed over the open request must still be applied" + ); + }); + + cx.executor().advance_clock(DEFAULT_LSP_REQUEST_TIMEOUT * 3); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "the request must stay open after streaming partial results" + ); +} + +#[gpui::test] +async fn test_workspace_diagnostics_refresh_during_open_request_pulls_again( + cx: &mut gpui::TestAppContext, +) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (first_response_tx, first_response_rx) = oneshot::channel::<()>(); + let first_response_rx = Arc::new(Mutex::new(Some(first_response_rx))); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-repull", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let first_response_rx = first_response_rx.lock().take(); + async move { + if let Some(first_response_rx) = first_response_rx { + first_response_rx.await.ok(); + } + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "a refresh must not cancel or duplicate the open workspace diagnostics request" + ); + + first_response_tx.send(()).unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "a refresh received while a pull was in flight must trigger one follow-up pull after it completes" + ); + + let refreshed = pull_task.await; + assert!( + refreshed, + "the waiter must be resolved by the follow-up pull" + ); +} + +#[gpui::test] +async fn test_workspace_diagnostics_refreshes_coalesce(cx: &mut gpui::TestAppContext) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (first_response_tx, first_response_rx) = oneshot::channel::<()>(); + let first_response_rx = Arc::new(Mutex::new(Some(first_response_rx))); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-coalesce", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let first_response_rx = first_response_rx.lock().take(); + async move { + if let Some(first_response_rx) = first_response_rx { + first_response_rx.await.ok(); + } + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_tasks = (0..5) + .map(|_| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }) + }) + .collect::>(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + first_response_tx.send(()).unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + for pull_task in pull_tasks { + let refreshed = pull_task.await; + assert!( + refreshed, + "every coalesced waiter must be resolved by the follow-up pull" + ); + } + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "refreshes issued while a pull is in flight must coalesce into one follow-up pull" + ); +} + +#[gpui::test] +async fn test_workspace_diagnostics_refresh_during_failed_request_pulls_again( + cx: &mut gpui::TestAppContext, +) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (first_response_tx, first_response_rx) = oneshot::channel::<()>(); + let first_response_rx = Arc::new(Mutex::new(Some(first_response_rx))); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-error-repull", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let first_response_rx = first_response_rx.lock().take(); + async move { + if let Some(first_response_rx) = first_response_rx { + first_response_rx.await.ok(); + anyhow::bail!("server cancelled the request"); + } + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + first_response_tx.send(()).unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "a refresh received while a failing pull was in flight must trigger one follow-up pull after the failure" + ); + + let refreshed = pull_task.await; + assert!( + refreshed, + "the waiter must be resolved by the follow-up pull" + ); +} + +#[gpui::test] +async fn test_cross_buffer_pull_respects_inter_file_dependencies(cx: &mut gpui::TestAppContext) { + for inter_file_dependencies in [false, true] { + let pulled_uris = Arc::new(Mutex::new(Vec::new())); + let (project, mut fake_servers) = + diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three", "b.rs": "four five six" }), + DiagnosticsPullServer { + identifier: "test-inter-file-deps", + inter_file_dependencies, + workspace_diagnostics: false, + initializer: Some(Box::new({ + let pulled_uris = pulled_uris.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let pulled_uris = pulled_uris.clone(); + move |params, _| { + pulled_uris.lock().push(params.text_document.uri); + async move { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full( + lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: None, + items: Vec::new(), + }, + }, + ), + )) + } + } + }); + } + })), + }, + ) + .await; + + let (buffer_a, _handle_a) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let (_buffer_b, _handle_b) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + pulled_uris.lock().clear(); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_document_diagnostics_for_buffer_edit(buffer_a_id, cx); + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + let cross_buffer_pulls = pulled_uris + .lock() + .drain(..) + .filter(|uri| *uri == lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap()) + .count(); + if inter_file_dependencies { + assert_eq!( + cross_buffer_pulls, 1, + "an edit must re-pull other open buffers when the server declares inter file dependencies" + ); + } else { + assert_eq!( + cross_buffer_pulls, 0, + "an edit must not re-pull other open buffers when the server declares no inter file dependencies" + ); + } + } +} + +#[gpui::test] +async fn test_workspace_diagnostics_repulled_after_server_closes_request( + cx: &mut gpui::TestAppContext, +) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-repull-on-close", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + async move { + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "the client must not re-pull before the repull delay elapses" + ); + + cx.executor().advance_clock(Duration::from_millis(1500)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "the client must re-trigger the workspace pull after the server closes the request" + ); + + cx.executor().advance_clock(Duration::from_millis(2500)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 3, + "the client must keep re-triggering the workspace pull after every completion" + ); +} + +#[gpui::test] +async fn test_document_diagnostics_content_modified_is_suppressed(cx: &mut gpui::TestAppContext) { + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-doc-content-modified", + inter_file_dependencies: false, + workspace_diagnostics: false, + initializer: Some(Box::new(move |fake_server| { + fake_server.set_request_handler::( + move |_, _| async move { + Err(anyhow::Error::new(lsp::ResponseError::new( + lsp::ResponseErrorCode::ContentModified, + "content changed meanwhile", + ))) + }, + ); + })), + }, + ) + .await; + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }); + cx.executor().run_until_parked(); + pull_task + .await + .expect("ContentModified pull failures must be suppressed instead of surfaced"); +} + +#[gpui::test] +async fn test_workspace_diagnostics_stale_partial_results_are_ignored( + cx: &mut gpui::TestAppContext, +) { + let partial_result_token = Arc::new(Mutex::new(None)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three", "b.rs": "four five six" }), + DiagnosticsPullServer { + identifier: "test-ws-stale-progress", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let partial_result_token = partial_result_token.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let partial_result_token = partial_result_token.clone(); + move |params, _| { + *partial_result_token.lock() = + params.partial_result_params.partial_result_token; + async move { + future::pending::<()>().await; + Err(anyhow::anyhow!("should never respond")) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + let lsp::ProgressToken::String(current_token) = partial_result_token + .lock() + .clone() + .expect("the workspace diagnostics pull should carry a partial result token") + else { + panic!("expected a string partial result token"); + }; + let (token_prefix, _) = current_token + .rsplit_once('/') + .expect("workspace diagnostic tokens should contain request numbers"); + let stale_token = format!("{token_prefix}/999"); + + for token in [ + lsp::NumberOrString::Number(1), + lsp::NumberOrString::String(stale_token), + ] { + fake_server.notify::(lsp::ProgressParams { + token, + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap(), + version: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + result_id: Some("stale".to_string()), + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("stale error"), + ..lsp::Diagnostic::default() + }], + }, + }, + )], + }), + ), + }); + } + cx.executor().run_until_parked(); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary::default(), + "partial results with tokens of other requests must be ignored" + ); + }); + + fake_server.notify::(lsp::ProgressParams { + token: lsp::ProgressToken::String(current_token), + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap(), + version: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + result_id: Some("current".to_string()), + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("current error"), + ..lsp::Diagnostic::default() + }], + }, + }, + )], + }), + ), + }); + cx.executor().run_until_parked(); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: 1, + warning_count: 0, + }, + "partial results with the current request's token must be applied" + ); + }); +} + +#[gpui::test] +async fn test_workspace_diagnostics_server_cancellation_retriggers(cx: &mut gpui::TestAppContext) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-server-cancel", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + move |_, _| { + let requests_received = + workspace_requests.fetch_add(1, atomic::Ordering::Release) + 1; + async move { + if requests_received == 1 { + Err(anyhow::Error::new( + lsp::ResponseError::server_cancelled(), + )) + } else { + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + cx.executor().advance_clock(Duration::from_secs(3)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "the client must retrigger the workspace pull after the server cancels it with ServerCancelled" + ); +} + +#[gpui::test] +async fn test_document_diagnostics_server_cancellation_retriggers(cx: &mut gpui::TestAppContext) { + let document_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-doc-server-cancel", + inter_file_dependencies: false, + workspace_diagnostics: false, + initializer: Some(Box::new({ + let document_requests = document_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let document_requests = document_requests.clone(); + move |_, _| { + let requests_received = + document_requests.fetch_add(1, atomic::Ordering::Release) + 1; + async move { + if requests_received == 1 { + Err(anyhow::Error::new( + lsp::ResponseError::server_cancelled(), + )) + } else { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full( + lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: None, + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some( + lsp::DiagnosticSeverity::ERROR, + ), + message: + lsp::DiagnosticMessage::from( + "retried error", + ), + ..Default::default() + }], + }, + }, + ), + )) + } + } + } + }); + } + })), + }, + ) + .await; + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + pull_task.await.unwrap(); + + assert_eq!( + document_requests.load(atomic::Ordering::Acquire), + 2, + "the client must retrigger the document pull after the server cancels it with ServerCancelled" + ); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: 1, + warning_count: 0, + }, + "the retried document pull must produce diagnostics" + ); + }); +} + #[gpui::test] async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( cx: &mut gpui::TestAppContext, ) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - let document_pulls_received = Arc::new(atomic::AtomicUsize::new(0)); - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-refresh-response-first".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: true, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-refresh-response-first", + inter_file_dependencies: true, + workspace_diagnostics: true, initializer: Some(Box::new({ let document_pulls_received = document_pulls_received.clone(); move |fake_server| { @@ -4908,9 +5959,9 @@ async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( ); } })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (_buffer, _handle) = project .update(cx, |project, cx| { @@ -4934,6 +5985,13 @@ async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( .expect("workspace/diagnostic/refresh must be answered without awaiting diagnostic pulls from the same server") .into_response() .expect("workspace/diagnostic/refresh should succeed"); + assert_eq!( + document_pulls_received.load(atomic::Ordering::Acquire), + 0, + "the document diagnostics pull must wait for the cross-buffer pacing delay" + ); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); assert_eq!( document_pulls_received.load(atomic::Ordering::Acquire), 1, @@ -4941,6 +5999,84 @@ async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( ); } +#[gpui::test] +async fn test_workspace_diagnostics_pulls_recover_after_repeated_failures( + cx: &mut gpui::TestAppContext, +) { + let healthy = Arc::new(atomic::AtomicBool::new(false)); + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-failure-recovery", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let healthy = healthy.clone(); + let workspace_requests = workspace_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let healthy = healthy.clone(); + let workspace_requests = workspace_requests.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let healthy = healthy.load(atomic::Ordering::Acquire); + async move { + if healthy { + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } else { + anyhow::bail!("workspace diagnostics failed") + } + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + for _ in 0..60 { + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(3)); + cx.executor().run_until_parked(); + pull_task.await; + } + + healthy.store(true, atomic::Ordering::Release); + let requests_before_recovery = workspace_requests.load(atomic::Ordering::Acquire); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(3)); + cx.executor().run_until_parked(); + let refreshed = pull_task.await; + assert!( + refreshed, + "workspace diagnostics pulls must recover once the server stops failing" + ); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + requests_before_recovery + 1, + "the recovery must send exactly one workspace diagnostics request" + ); +} + #[gpui::test] async fn test_edits_from_lsp2_with_past_version(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -5815,6 +6951,523 @@ async fn test_completions_with_text_edit(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_dynamic_completion_registration_honors_document_selector( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = foo" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( + lsp::TextDocumentSyncOptions { + open_close: Some(true), + change: Some(lsp::TextDocumentSyncKind::FULL), + ..lsp::TextDocumentSyncOptions::default() + }, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("untitled".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert!(buffer.read_with(cx, |buffer, _| buffer.completion_triggers().is_empty())); + + let completion_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _completion_requests = fake_server.set_request_handler::({ + let completion_request_count = completion_request_count.clone(); + move |_, _| { + completion_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "matched".into(), + ..Default::default() + }, + ]))) + } + } + }); + + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap(); + assert!(completions.is_empty()); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 0); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([":".to_string()]) + ); + + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); + + assert_eq!(completions.len(), 1); + assert_eq!(completions[0].new_text, "matched"); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 1); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "second-file-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec!["!".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from(["!".to_string(), ":".to_string()]) + ); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "file-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from(["!".to_string()]) + ); + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap(); + assert_eq!(completions.len(), 1); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 2); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "second-file-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert!(buffer.read_with(cx, |buffer, _| buffer.completion_triggers().is_empty())); + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap(); + assert!(completions.is_empty()); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 2); +} + +#[gpui::test] +async fn test_dynamic_diagnostic_registrations_honor_their_own_document_selectors( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = foo" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( + lsp::TextDocumentSyncOptions { + open_close: Some(true), + change: Some(lsp::TextDocumentSyncKind::FULL), + ..lsp::TextDocumentSyncOptions::default() + }, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let file_diagnostic_requests = Arc::new(atomic::AtomicUsize::new(0)); + let untitled_diagnostic_requests = Arc::new(atomic::AtomicUsize::new(0)); + let _diagnostic_requests = fake_server + .set_request_handler::({ + let file_diagnostic_requests = file_diagnostic_requests.clone(); + let untitled_diagnostic_requests = untitled_diagnostic_requests.clone(); + move |params, _| { + match params.identifier.as_deref() { + Some("file") => { + file_diagnostic_requests.fetch_add(1, atomic::Ordering::SeqCst); + } + Some("untitled") => { + untitled_diagnostic_requests.fetch_add(1, atomic::Ordering::SeqCst); + } + identifier => panic!("unexpected diagnostic identifier: {identifier:?}"), + } + async { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full( + lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: None, + items: Vec::new(), + }, + }, + ), + )) + } + } + }); + + let diagnostic_registration = |identifier: &str, scheme: &str| { + serde_json::to_value(lsp::DiagnosticRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some(scheme.to_string()), + pattern: None, + }]), + }, + diagnostic_options: lsp::DiagnosticOptions { + identifier: Some(identifier.to_string()), + inter_file_dependencies: false, + workspace_diagnostics: false, + ..lsp::DiagnosticOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok() + }; + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "untitled-diagnostics".to_string(), + method: "textDocument/diagnostic".to_string(), + register_options: diagnostic_registration("untitled", "untitled"), + }, + lsp::Registration { + id: "file-diagnostics".to_string(), + method: "textDocument/diagnostic".to_string(), + register_options: diagnostic_registration("file", "file"), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + assert_eq!(file_diagnostic_requests.load(atomic::Ordering::SeqCst), 1); + assert_eq!( + untitled_diagnostic_requests.load(atomic::Ordering::SeqCst), + 0 + ); +} + +#[gpui::test] +async fn test_unregistering_dynamic_completion_preserves_static_capability( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = foo" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "dynamic-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string(), ":".to_string()]) + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "global-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: None, + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec!["@".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string(), ":".to_string(), "@".to_string()]), + "expected static triggers to stay active alongside a global dynamic registration", + ); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "global-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string(), ":".to_string()]) + ); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "dynamic-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string()]) + ); + + let mut completion_request = + fake_server.set_request_handler::(|_, _| async { + Ok(Some(lsp::CompletionResponse::Array(Vec::new()))) + }); + let completions = project.update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }); + completion_request + .next() + .await + .expect("The static completion provider should remain active"); + assert!( + completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .next() + .is_none() + ); +} + #[gpui::test] async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -5838,11 +7491,11 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), - ..Default::default() + ..lsp::CompletionOptions::default() }), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -5875,13 +7528,13 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { lsp::Position::new(0, text.len() as u32), ), )), - ..Default::default() + ..lsp::CompletionListItemDefaults::default() }), items: vec![lsp::CompletionItem { label: "labelText".into(), text_edit_text: Some("textEditText".into()), text_edit: None, - ..Default::default() + ..lsp::CompletionItem::default() }], }))) }) @@ -5922,14 +7575,14 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { lsp::Position::new(0, text.len() as u32), ), )), - ..Default::default() + ..lsp::CompletionListItemDefaults::default() }), items: vec![lsp::CompletionItem { label: "labelText".into(), text_edit_text: None, insert_text: Some("irrelevant".into()), text_edit: None, - ..Default::default() + ..lsp::CompletionItem::default() }], }))) }) @@ -5976,11 +7629,11 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![":".to_string()]), - ..Default::default() + ..lsp::CompletionOptions::default() }), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -6007,7 +7660,7 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { lsp::CompletionItem { label: "fullyQualifiedName?".into(), insert_text: Some("fullyQualifiedName".into()), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }) @@ -6039,7 +7692,7 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "component".into(), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }) @@ -6113,7 +7766,7 @@ async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) { lsp::CompletionItem { label: "fullyQualifiedName?".into(), insert_text: Some("fully\rQualified\r\nName".into()), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }) @@ -6208,6 +7861,110 @@ async fn test_supports_range_formatting_ignores_unrelated_language_servers( })); } +#[gpui::test] +async fn test_range_formatting_prefers_range_capable_current_server(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut full_format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "full-format-server", + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let mut range_format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "range-format-server", + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let full_format_server = full_format_servers.next().await.unwrap(); + let range_format_server = range_format_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let full_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _full_format_requests = full_format_server + .set_request_handler::({ + let full_format_request_count = full_format_request_count.clone(); + move |_, _| { + full_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let range_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _range_format_requests = range_format_server + .set_request_handler::({ + let range_format_request_count = range_format_request_count.clone(); + move |_, _| { + range_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let (buffer_id, range) = buffer.read_with(cx, |buffer, _| { + ( + buffer.remote_id(), + buffer.anchor_before(0)..buffer.anchor_after(buffer.len()), + ) + }); + project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer]), + project::lsp_store::LspFormatTarget::Ranges(std::collections::BTreeMap::from_iter( + [(buffer_id, vec![range])], + )), + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + .await + .unwrap(); + + assert_eq!( + range_format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the range-capable server to handle the range formatting request", + ); + assert_eq!( + full_format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected manual range formatting not to use the full-format-only server", + ); +} + #[gpui::test(iterations = 10)] async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -6326,7 +8083,7 @@ async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) { .into_iter() .collect(), ), - ..Default::default() + ..lsp::WorkspaceEdit::default() }, }, DEFAULT_LSP_REQUEST_TIMEOUT, @@ -6517,17 +8274,17 @@ async fn test_save_file_spawns_language_server(cx: &mut gpui::TestAppContext) { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), "::".to_string()]), - ..Default::default() + ..lsp::CompletionOptions::default() }), text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( lsp::TextDocumentSyncOptions { save: Some(lsp::TextDocumentSyncSaveOptions::Supported(true)), - ..Default::default() + ..lsp::TextDocumentSyncOptions::default() }, )), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -6603,7 +8360,7 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) fs.save( path!("/dir/file1").as_ref(), &"the first contents".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -6614,7 +8371,7 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) fs.save( path!("/dir/file1").as_ref(), &"the second contents".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -6654,7 +8411,7 @@ async fn test_edit_buffer_while_it_reloads(cx: &mut gpui::TestAppContext) { fs.save( path!("/dir/file1").as_ref(), &"the first contents".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -6866,7 +8623,7 @@ async fn test_rescan_and_remote_updates(cx: &mut gpui::TestAppContext) { } })); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [dir.path()], cx).await; let buffer_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| { let buffer = project.update(cx, |p, cx| p.open_local_buffer(dir.path().join(path), cx)); @@ -7020,7 +8777,7 @@ async fn test_recreated_directory_receives_child_events(cx: &mut gpui::TestAppCo cx.executor().allow_parking(); let dir = TempTree::new(json!({})); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [dir.path()], cx).await; let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -7282,7 +9039,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { .detach(); }); - fs.remove_file(path!("/dir/file2").as_ref(), Default::default()) + fs.remove_file(path!("/dir/file2").as_ref(), fs::RemoveOptions::default()) .await .unwrap(); cx.executor().run_until_parked(); @@ -7345,7 +9102,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { buffer.edit([(0..0, "x")], None, cx); }); events.lock().clear(); - fs.remove_file(path!("/dir/file3").as_ref(), Default::default()) + fs.remove_file(path!("/dir/file3").as_ref(), fs::RemoveOptions::default()) .await .unwrap(); cx.executor().run_until_parked(); @@ -7392,7 +9149,7 @@ async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) { fs.save( path!("/dir/file.txt").as_ref(), &"version 2 from external tool".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -7959,35 +9716,35 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) { severity: Some(DiagnosticSeverity::WARNING), message: lsp::DiagnosticMessage::from("error 1"), related_information: Some(error_1_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9)), severity: Some(DiagnosticSeverity::HINT), message: lsp::DiagnosticMessage::from("error 1 hint 1"), related_information: Some(error_1_hint_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 17)), severity: Some(DiagnosticSeverity::ERROR), message: lsp::DiagnosticMessage::from("error 2"), related_information: Some(error_2_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 13), lsp::Position::new(1, 15)), severity: Some(DiagnosticSeverity::HINT), message: lsp::DiagnosticMessage::from("error 2 hint 1"), related_information: Some(error_2_hint_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 13), lsp::Position::new(1, 15)), severity: Some(DiagnosticSeverity::HINT), message: lsp::DiagnosticMessage::from("error 2 hint 2"), related_information: Some(error_2_hint_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, ], version: None, @@ -8212,12 +9969,12 @@ async fn test_lsp_rename_notifications(cx: &mut gpui::TestAppContext) { file_operations: Some(lsp::WorkspaceFileOperationsServerCapabilities { did_rename: Some(watched_paths.clone()), will_rename: Some(watched_paths), - ..Default::default() + ..lsp::WorkspaceFileOperationsServerCapabilities::default() }), }), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -8300,6 +10057,1248 @@ async fn test_lsp_rename_notifications(cx: &mut gpui::TestAppContext) { assert_eq!(resolved_workspace_edit.get(), Some(&expected_edit)); } +#[gpui::test] +async fn test_dynamic_call_hierarchy_followups_use_prepared_server(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "fn one() {}", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-call-hierarchy".to_string(), + method: "textDocument/prepareCallHierarchy".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let item = lsp::CallHierarchyItem { + name: "one".to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + detail: None, + uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 11)), + selection_range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)), + data: None, + }; + let _prepare_requests = fake_server + .set_request_handler::({ + let item = item.clone(); + move |_, _| { + let item = item.clone(); + async move { Ok(Some(vec![item])) } + } + }); + let incoming_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _incoming_requests = fake_server + .set_request_handler::({ + let incoming_request_count = incoming_request_count.clone(); + move |_, _| { + incoming_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let outgoing_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _outgoing_requests = fake_server + .set_request_handler::({ + let outgoing_request_count = outgoing_request_count.clone(); + move |_, _| { + outgoing_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let items = project + .update(cx, |project, cx| { + project.prepare_call_hierarchy(&buffer, 0, cx) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(items.len(), 1); + let item = items.into_iter().next().unwrap(); + + project + .update(cx, |project, cx| project.incoming_calls(item.clone(), cx)) + .await + .unwrap() + .unwrap(); + project + .update(cx, |project, cx| project.outgoing_calls(item, cx)) + .await + .unwrap() + .unwrap(); + + assert_eq!(incoming_request_count.load(atomic::Ordering::SeqCst), 1); + assert_eq!(outgoing_request_count.load(atomic::Ordering::SeqCst), 1); +} + +#[gpui::test] +async fn test_dynamic_rename_registration_routes_prepare_rename(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "prepareProvider": true, + })), + }, + lsp::Registration { + id: "untitled-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "prepareProvider": false, + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let _prepare_rename_requests = fake_server + .set_request_handler::(|_, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + }); + + let response = project + .update(cx, |project, cx| { + project.prepare_rename(buffer.clone(), 7, cx) + }) + .await + .unwrap(); + let PrepareRenameResponse::Success { range, .. } = response else { + panic!("expected prepare rename request, got {response:?}"); + }; + let range = buffer.update(cx, |buffer, _| range.to_offset(buffer)); + assert_eq!(range, 6..9); +} + +#[gpui::test] +async fn test_dynamic_rename_registration_without_prepare_provider_skips_prepare_rename( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "prepareProvider": false, + })), + }, + lsp::Registration { + id: "untitled-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "prepareProvider": true, + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let prepare_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _prepare_rename_requests = fake_server + .set_request_handler::({ + let prepare_rename_request_count = prepare_rename_request_count.clone(); + move |_, _| { + prepare_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + } + } + }); + + let response = project + .update(cx, |project, cx| { + project.prepare_rename(buffer.clone(), 7, cx) + }) + .await + .unwrap(); + + assert_matches!( + response, + PrepareRenameResponse::OnlyUnpreparedRenameSupported + ); + assert_eq!( + prepare_rename_request_count.load(atomic::Ordering::SeqCst), + 0 + ); +} + +#[gpui::test] +async fn test_prepare_rename_prefers_server_with_prepare_support(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut unprepared_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "unprepared-rename-server", + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(false), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let mut prepared_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "prepared-rename-server", + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let unprepared_server = unprepared_servers.next().await.unwrap(); + let prepared_server = prepared_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let unprepared_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _unprepared_requests = unprepared_server + .set_request_handler::({ + let unprepared_request_count = unprepared_request_count.clone(); + move |_, _| { + unprepared_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let _prepared_requests = prepared_server + .set_request_handler::(|_, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + }); + let unprepared_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _unprepared_rename_requests = unprepared_server + .set_request_handler::({ + let unprepared_rename_request_count = unprepared_rename_request_count.clone(); + move |_, _| { + unprepared_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let prepared_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _prepared_rename_requests = prepared_server + .set_request_handler::({ + let prepared_rename_request_count = prepared_rename_request_count.clone(); + move |_, _| { + prepared_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let response = project + .update(cx, |project, cx| { + project.prepare_rename(buffer.clone(), 7, cx) + }) + .await + .unwrap(); + + let PrepareRenameResponse::Success { + range, + language_server_id, + } = response + else { + panic!("expected the prepare-capable server to serve the request, got {response:?}"); + }; + let range = buffer.update(cx, |buffer, _| range.to_offset(buffer)); + assert_eq!(range, 6..9); + assert_eq!(language_server_id, Some(prepared_server.server.server_id()),); + assert_eq!(unprepared_request_count.load(atomic::Ordering::SeqCst), 0); + + project + .update(cx, |project, cx| { + project.perform_rename(buffer, 7, "TWO".to_string(), language_server_id, cx) + }) + .await + .unwrap(); + assert_eq!( + unprepared_rename_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected rename not to switch back to the first rename-capable server", + ); + assert_eq!( + prepared_rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected rename to use the server that prepared it", + ); +} + +#[gpui::test] +async fn test_perform_rename_falls_back_when_prepared_server_is_gone( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _rename_requests = fake_server.set_request_handler::({ + let rename_request_count = rename_request_count.clone(); + move |params, _| { + rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!(params.new_name, "TWO"); + Ok(Some(lsp::WorkspaceEdit { + changes: Some( + [( + lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + "TWO".to_string(), + )], + )] + .into_iter() + .collect(), + ), + ..lsp::WorkspaceEdit::default() + })) + } + } + }); + + let transaction = project + .update(cx, |project, cx| { + project.perform_rename( + buffer.clone(), + 7, + "TWO".to_string(), + Some(LanguageServerId(4242)), + cx, + ) + }) + .await + .unwrap() + .0; + + assert_eq!( + rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the rename to fall back to the live rename-capable server", + ); + assert_eq!( + transaction.len(), + 1, + "expected a transaction for the renamed buffer", + ); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "const TWO: usize = 1;" + ); +} + +#[gpui::test] +async fn test_perform_rename_falls_back_when_server_unregisters_rename( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut dynamic_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "dynamic-rename-server", + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + let mut static_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "static-rename-server", + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let dynamic_server = dynamic_servers.next().await.unwrap(); + let static_server = static_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + dynamic_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "prepareProvider": false, + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let dynamic_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _dynamic_rename_requests = dynamic_server + .set_request_handler::({ + let dynamic_rename_request_count = dynamic_rename_request_count.clone(); + move |_, _| { + dynamic_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let static_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _static_rename_requests = + static_server.set_request_handler::({ + let static_rename_request_count = static_rename_request_count.clone(); + move |params, _| { + static_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!(params.new_name, "TWO"); + Ok(None) + } + } + }); + + dynamic_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let dynamic_server_id = dynamic_server.server.server_id(); + project + .update(cx, |project, cx| { + project.perform_rename( + buffer.clone(), + 7, + "TWO".to_string(), + Some(dynamic_server_id), + cx, + ) + }) + .await + .unwrap(); + + assert_eq!( + dynamic_rename_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no rename request for the server that unregistered its rename capability", + ); + assert_eq!( + static_rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the rename to fall back to the rename-capable server", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registrations_honor_document_selectors( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let format_buffer = |cx: &mut gpui::TestAppContext| { + project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + }; + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no formatting request for a buffer outside the registration's selector", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a formatting request once a matching registration exists", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registration_with_pattern_only_selector_fails_open( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "pattern-only-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "pattern": "**/*.nomatch" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + .await + .unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a pattern-only filter to fail open and route the formatting request", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registration_with_empty_selector_matches_nothing( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "empty-selector-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + .await + .unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected an empty document selector to match no buffers", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registration_honors_language_selector( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let format_buffer = |cx: &mut gpui::TestAppContext| { + project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + }; + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "plaintext-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "plaintext" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no formatting request for a registration with a different language filter", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "rust-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a formatting request once a registration with the buffer's language id exists", + ); +} + +#[gpui::test] +async fn test_dynamic_range_formatting_registrations_honor_document_selectors( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let range_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _range_format_requests = fake_server + .set_request_handler::({ + let range_format_request_count = range_format_request_count.clone(); + move |_, _| { + range_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let (buffer_id, range) = buffer.read_with(cx, |buffer, _| { + ( + buffer.remote_id(), + buffer.anchor_before(0)..buffer.anchor_after(buffer.len()), + ) + }); + let format_ranges = |cx: &mut gpui::TestAppContext| { + let ranges = std::collections::BTreeMap::from_iter([(buffer_id, vec![range.clone()])]); + project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Ranges(ranges), + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + }; + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-range-formatting".to_string(), + method: "textDocument/rangeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_ranges(cx).await.unwrap(); + assert_eq!( + range_format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no range formatting request for a buffer outside the registration's selector", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-range-formatting".to_string(), + method: "textDocument/rangeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_ranges(cx).await.unwrap(); + assert_eq!( + range_format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a range formatting request once a matching registration exists", + ); +} + +#[gpui::test] +async fn test_dynamic_on_type_formatting_registration_routes_matching_trigger( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..Default::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-on-type-formatting".to_string(), + method: "textDocument/onTypeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "firstTriggerCharacter": ";", + })), + }, + lsp::Registration { + id: "untitled-on-type-formatting".to_string(), + method: "textDocument/onTypeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "firstTriggerCharacter": ":", + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let on_type_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _on_type_format_requests = fake_server + .set_request_handler::({ + let on_type_format_request_count = on_type_format_request_count.clone(); + move |params, _| { + on_type_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!(params.ch, ";"); + async move { Ok(None) } + } + }); + + let on_type_format = project + .update(cx, |project, cx| { + project.on_type_format(buffer.clone(), 7, ";".to_string(), false, cx) + }) + .expect("matching dynamic registration should support on-type formatting"); + let response = on_type_format.await.unwrap(); + + assert!(response.is_none()); + assert_eq!( + on_type_format_request_count.load(atomic::Ordering::SeqCst), + 1 + ); +} + #[gpui::test] async fn test_rename(cx: &mut gpui::TestAppContext) { // hi @@ -8362,14 +11361,24 @@ async fn test_rename(cx: &mut gpui::TestAppContext) { .await .unwrap(); let response = response.await.unwrap(); - let PrepareRenameResponse::Success(range) = response else { + let PrepareRenameResponse::Success { + range, + language_server_id, + } = response + else { panic!("{:?}", response); }; let range = buffer.update(cx, |buffer, _| range.to_offset(buffer)); assert_eq!(range, 6..9); let response = project.update(cx, |project, cx| { - project.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx) + project.perform_rename( + buffer.clone(), + 7, + "THREE".to_string(), + language_server_id, + cx, + ) }); fake_server .set_request_handler::(|params, _| async move { @@ -8487,7 +11496,7 @@ async fn test_rename_that_also_renames_file(cx: &mut gpui::TestAppContext) { cx.executor().run_until_parked(); let response = project.update(cx, |project, cx| { - project.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx) + project.perform_rename(buffer.clone(), 7, "THREE".to_string(), None, cx) }); fake_server .set_request_handler::(|_params, _| async move { @@ -10758,6 +13767,232 @@ async fn test_code_actions_with_related_information_from_multiple_servers( assert!(code_actions_task.await.unwrap().unwrap().is_empty()); } +#[gpui::test] +async fn test_dynamic_code_action_registrations_use_matching_options( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.ts": "a" })).await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(typescript_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( + lsp::TextDocumentSyncOptions { + open_close: Some(true), + change: Some(lsp::TextDocumentSyncKind::FULL), + ..lsp::TextDocumentSyncOptions::default() + }, + )), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-code-actions".to_string(), + method: "textDocument/codeAction".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "typescript", "scheme": "file" }], + "codeActionKinds": [CodeActionKind::SOURCE_ORGANIZE_IMPORTS.as_str()], + })), + }, + lsp::Registration { + id: "untitled-code-actions".to_string(), + method: "textDocument/codeAction".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "typescript", "scheme": "untitled" }], + "codeActionKinds": [CodeActionKind::SOURCE_FIX_ALL.as_str()], + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let code_action_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let mut code_action_requests = fake_server + .set_request_handler::({ + let code_action_request_count = code_action_request_count.clone(); + move |params, _| { + code_action_request_count.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!( + params.context.only, + Some(vec![CodeActionKind::SOURCE_ORGANIZE_IMPORTS]) + ); + async { + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "organize imports".to_string(), + kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS), + ..lsp::CodeAction::default() + }, + )])) + } + } + }); + + let code_actions = project.update(cx, |project, cx| { + project.code_actions( + &buffer, + 0..buffer.read(cx).len(), + Some(vec![CodeActionKind::SOURCE_ORGANIZE_IMPORTS]), + cx, + ) + }); + code_action_requests + .next() + .await + .expect("The matching code action registration should receive the request"); + let code_actions = code_actions.await.unwrap().unwrap(); + + assert_eq!(code_action_request_count.load(atomic::Ordering::SeqCst), 1); + assert_eq!(code_actions.len(), 1); +} + +#[gpui::test] +async fn test_format_code_actions_skip_resolve_from_non_matching_registration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.ts": "a", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(typescript_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-code-actions".to_string(), + method: "textDocument/codeAction".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "typescript", "scheme": "untitled" }], + "codeActionKinds": [CodeActionKind::SOURCE_ORGANIZE_IMPORTS.as_str()], + "resolveProvider": true, + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let _code_action_requests = fake_server + .set_request_handler::(|params, _| async move { + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "organize imports".to_string(), + kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS), + data: Some(json!({ "marker": true })), + edit: Some(lsp::WorkspaceEdit { + changes: Some( + [( + params.text_document.uri, + vec![lsp::TextEdit::new( + lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 0), + ), + "organized ".to_string(), + )], + )] + .into_iter() + .collect(), + ), + ..lsp::WorkspaceEdit::default() + }), + ..lsp::CodeAction::default() + }, + )])) + }); + let resolve_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _resolve_requests = fake_server + .set_request_handler::({ + let resolve_request_count = resolve_request_count.clone(); + move |action, _| { + resolve_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(action) } + } + }); + + project + .update(cx, |project, cx| { + project.apply_code_action_kind( + HashSet::from_iter([buffer.clone()]), + CodeActionKind::SOURCE_ORGANIZE_IMPORTS, + true, + cx, + ) + }) + .await + .unwrap(); + + assert_eq!( + resolve_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no resolve request when the resolve capability comes from a non-matching registration", + ); + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "organized a"); + }); +} + #[gpui::test] async fn test_code_actions_without_requested_kinds_do_not_send_only_filter( cx: &mut gpui::TestAppContext, @@ -13516,12 +16751,7 @@ async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestApp git_commit("Initial commit", &repo); std::fs::write(&file_path, file_contents).unwrap(); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let buffer = project .update(cx, |project, cx| { @@ -13756,12 +16986,7 @@ async fn test_git_repository_status(cx: &mut gpui::TestAppContext) { std::fs::remove_file(work_dir.join("d.txt")).unwrap(); std::fs::write(work_dir.join("a.txt"), "aa").unwrap(); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -14043,12 +17268,7 @@ async fn test_git_events_after_project_excludes_dot_git(cx: &mut gpui::TestAppCo git_commit("Initial commit", &repo); git_branch("other-branch", &repo); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [work_dir.as_path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [work_dir.as_path()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -14135,12 +17355,7 @@ async fn test_git_status_postprocessing(cx: &mut gpui::TestAppContext) { // `sub` is a nested git repository. let _sub = git_init(&work_dir.join("sub")); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -14991,7 +18206,7 @@ async fn test_conflicted_cherry_pick(cx: &mut gpui::TestAppContext) { git_add("a.txt", &repo); git_commit("init", &repo); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -15147,7 +18362,7 @@ async fn test_rename_work_directory(cx: &mut gpui::TestAppContext) { git_commit("init", &repo); std::fs::write(root_path.join("projects/project1/a"), "aa").unwrap(); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -15262,7 +18477,7 @@ async fn test_file_status(cx: &mut gpui::TestAppContext) { git_add(DOTGITIGNORE, &repo); git_commit("Initial commit", &repo); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -15545,7 +18760,7 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) { git_add(".gitignore", &repo); git_commit("Initial commit", &repo); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let repository_updates = Arc::new(Mutex::new(Vec::new())); let project_events = Arc::new(Mutex::new(Vec::new())); project.update(cx, |project, cx| { @@ -16514,6 +19729,51 @@ async fn test_initial_scan_complete(cx: &mut gpui::TestAppContext) { }); } +struct DiagnosticsPullServer { + identifier: &'static str, + inter_file_dependencies: bool, + workspace_diagnostics: bool, + initializer: Option>, +} + +async fn diagnostics_pull_project( + cx: &mut gpui::TestAppContext, + files: serde_json::Value, + server: DiagnosticsPullServer, +) -> ( + Entity, + futures::channel::mpsc::UnboundedReceiver, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), files).await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( + lsp::DiagnosticOptions { + identifier: Some(server.identifier.to_string()), + inter_file_dependencies: server.inter_file_dependencies, + workspace_diagnostics: server.workspace_diagnostics, + work_done_progress_options: Default::default(), + }, + )), + ..lsp::ServerCapabilities::default() + }, + initializer: server.initializer, + ..FakeLspAdapter::default() + }, + ); + (project, fake_servers) +} + pub fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); @@ -17249,10 +20509,10 @@ async fn test_read_only_files_setting(cx: &mut gpui::TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.read_only_files = Some(vec![ - "**/generated/**".to_string(), + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![ "**/*.gen.rs".to_string(), - ]); + "**/generated/**".to_string(), + ])); }); }); }); @@ -17317,6 +20577,56 @@ async fn test_read_only_files_setting(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_read_only_files_splice_project_settings(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![ + SplicingVec::REST.to_string(), + "**/*.lock".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".zed": { + "settings.json": r#"{"read_only_files": ["**/generated/**", "..."]}"#, + }, + "generated": {"schema.rs": ""}, + "src": {"main.rs": ""}, + "yarn.lock": "", + }), + ) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + cx.executor().run_until_parked(); + + for (relative_path, expected_read_only) in [ + ("generated/schema.rs", true), + ("src/main.rs", false), + ("yarn.lock", true), + ] { + let full_path = Path::new(path!("/root")).join(relative_path); + let result = project + .update(cx, |project, cx| project.open_local_buffer(&full_path, cx)) + .await; + match result { + Ok(buffer) => assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.read_only()), + expected_read_only, + "{relative_path}" + ), + Err(error) => panic!("could not open {relative_path}: {error}"), + } + } +} + #[gpui::test] async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -17325,7 +20635,7 @@ async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.read_only_files = Some(vec![]); + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![])); }); }); }); @@ -17392,12 +20702,7 @@ async fn test_os_read_only_files_open_as_read_only(cx: &mut gpui::TestAppContext permissions.set_readonly(true); std::fs::set_permissions(&file_path, permissions).unwrap(); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let buffer = project .update(cx, |project, cx| { @@ -17422,10 +20727,10 @@ async fn test_read_only_files_with_lock_files(cx: &mut gpui::TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.read_only_files = Some(vec![ + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![ "**/*.lock".to_string(), "**/package-lock.json".to_string(), - ]); + ])); }); }); }); diff --git a/crates/project_benchmarks/src/main.rs b/crates/project_benchmarks/src/main.rs index b2c4275487..60afe80659 100644 --- a/crates/project_benchmarks/src/main.rs +++ b/crates/project_benchmarks/src/main.rs @@ -136,7 +136,7 @@ fn main() -> Result<(), anyhow::Error> { let node = NodeRuntime::new(http_client, None, rx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); let registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9dc8c56acb..db0222d409 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -18,15 +18,15 @@ use git; use git::status::GitSummary; use git_ui_core::file_diff_view::FileDiffView; use gpui::{ - Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry, - ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, - ExternalDragPayload, ExternalPaths, FileDragPaths, FocusHandle, Focusable, FontWeight, Hsla, - InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, - ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, - PathPromptOptions, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, Styled, - Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, - div, hsla, linear_color_stop, linear_gradient, point, px, size, transparent_white, - uniform_list, + Action, Anchor, AnyElement, App, AsyncWindowContext, Bounds, + ClipboardEntry as GpuiClipboardEntry, ClipboardItem, Context, CursorStyle, DismissEvent, Div, + DragMoveEvent, Entity, EventEmitter, ExternalDragPayload, ExternalPaths, FileDragPaths, + FileDropEvent, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext, + ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point, + PromptLevel, Render, ScrollStrategy, Stateful, Styled, Subscription, Task, + UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, hsla, + linear_color_stop, linear_gradient, point, px, size, transparent_white, uniform_list, }; use language::DiagnosticSeverity; use markdown_preview::markdown_preview_view::MarkdownPreviewView; @@ -52,8 +52,7 @@ use std::{ cell::OnceCell, cmp, collections::HashSet, - ops::Neg, - ops::Range, + ops::{Neg, Range}, path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, @@ -68,7 +67,7 @@ use util::{ ResultExt, TakeUntilExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, - paths::{PathStyle, compare_paths}, + paths::{PathExt, PathStyle, compare_paths}, rel_path::{RelPath, RelPathBuf}, }; use workspace::{ @@ -148,7 +147,7 @@ pub struct ProjectPanel { drag_target_entry: Option, marked_entries: Vec, selection: Option, - context_menu: Option<(Entity, Point, Subscription)>, + context_menu: Option, filename_editor: Entity, clipboard: Option, _dragged_entry_destination: Option>, @@ -190,6 +189,26 @@ impl Default for UpdateVisibleEntriesTask { } } +/// Where a context menu should appear when it is deployed. +#[derive(Copy, Clone, Debug, PartialEq)] +enum ContextMenuPlacement { + /// Slightly offset from the given mouse position, e.g. for a right click. + AtMouse(Point), + /// Next to the row of the entry the menu is deployed for, e.g. when opened from the keyboard. + AtEntry, +} + +const CONTEXT_MENU_KEYBOARD_OFFSET: Point = point(px(2.), px(2.)); + +struct DeployedContextMenu { + menu: Entity, + position: Point, + /// Which corner of the menu is placed at `position`, determining the direction the menu opens + /// in. When `None`, the menu is placed like any other anchored element. + anchor: Option, + _subscription: Subscription, +} + enum DragTarget { /// Dragging on an entry Entry { @@ -427,6 +446,8 @@ actions!( Redo, /// Opens a markdown preview for the selected file. OpenMarkdownPreview, + /// Opens the context menu for the selected entry. + OpenContextMenu, ] ); @@ -1077,9 +1098,26 @@ impl ProjectPanel { } } + /// Whether keyboard focus is within the panel, including a deployed context menu. + /// + /// `contains_focused` answers from the last rendered frame's element tree. On the frame a + /// context menu is deployed, its focus node is not part of that tree yet, so the menu has + /// to be checked directly against the window's current focus. + /// + /// TODO: We really really should fix context menu focus to not have to deal with + /// this everywhere as this is not a proper fix here but a mere workaround. Yet + /// a fix would require some more time and thought to not break other users of this + fn contains_focus(&self, window: &Window, cx: &App) -> bool { + self.focus_handle.contains_focused(window, cx) + || self + .context_menu + .as_ref() + .is_some_and(|context_menu| context_menu.menu.focus_handle(cx).is_focused(window)) + } + fn deploy_context_menu( &mut self, - position: Point, + placement: ContextMenuPlacement, entry_id: ProjectEntryId, window: &mut Window, cx: &mut Context, @@ -1261,17 +1299,127 @@ impl ProjectPanel { }) }); + let (position, anchor) = match placement { + ContextMenuPlacement::AtMouse(mouse_position) => (mouse_position, None), + ContextMenuPlacement::AtEntry => match self.scroll_selection_into_view() { + Some(entry_bounds) => { + let (position, anchor) = + Self::context_menu_placement_for_entry(entry_bounds, window, cx); + + let shifted_position = position + + if anchor.is_bottom() { + -CONTEXT_MENU_KEYBOARD_OFFSET + } else { + CONTEXT_MENU_KEYBOARD_OFFSET + }; + + (shifted_position, Some(anchor)) + } + None => (window.mouse_position(), None), + }, + }; + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { this.context_menu.take(); cx.notify(); }); - self.context_menu = Some((context_menu, position, subscription)); + self.context_menu = Some(DeployedContextMenu { + menu: context_menu, + position, + anchor, + _subscription: subscription, + }); } cx.notify(); } + fn open_context_menu( + &mut self, + _: &OpenContextMenu, + window: &mut Window, + cx: &mut Context, + ) { + let entry_id = if let Some(selection) = &self.selection { + selection.entry_id + } else if let Some(entry_id) = self.state.last_worktree_root_id { + entry_id + } else { + return; + }; + + self.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + } + + /// Opens the menu downwards from the entry when the entry is in the upper half of the window + /// and upwards otherwise, and always towards the center of the window from the side the panel + /// is docked on, so that the menu has the most room to grow into. + fn context_menu_placement_for_entry( + entry_bounds: Bounds, + window: &Window, + cx: &App, + ) -> (Point, Anchor) { + let opens_downwards = entry_bounds.center().y <= window.viewport_size().center().y; + // The menu's anchored corner sits on the diagonally opposite corner of the entry. + match (ProjectPanelSettings::get_global(cx).dock, opens_downwards) { + (DockSide::Left, true) => (entry_bounds.bottom_left(), Anchor::TopLeft), + (DockSide::Left, false) => (entry_bounds.origin, Anchor::BottomLeft), + (DockSide::Right, true) => (entry_bounds.bottom_right(), Anchor::TopRight), + (DockSide::Right, false) => (entry_bounds.top_right(), Anchor::BottomRight), + } + } + + /// The height of a single row in the entries list, as measured by the list during its last layout. + fn entry_row_height(&self) -> Option { + let item_count: usize = self + .state + .visible_entries + .iter() + .map(|worktree| worktree.entries.len()) + .sum(); + if item_count == 0 { + return None; + } + let last_item_size = self.scroll_handle.0.borrow().last_item_size?; + let row_height = last_item_size.contents.height / item_count as f32; + (row_height > Pixels::ZERO).then_some(row_height) + } + + /// Scrolls the entries list the minimal amount needed for the selected entry to be fully + /// visible, and returns the bounds of the entry's row in window coordinates. + fn scroll_selection_into_view(&self) -> Option> { + let selection = self.selection?; + let (_, _, index) = self.index_for_selection(selection)?; + let row_height = self.entry_row_height()?; + + let viewport = self.scroll_handle.viewport(); + let current_offset = self.scroll_handle.offset(); + let row_top = row_height * index; + let row_bottom = row_top + row_height; + // Sticky entries are drawn over the top rows of the list, so a row underneath them is not + // actually visible. + let visible_top = row_height * self.sticky_items_count; + + let mut offset = current_offset; + if row_top + offset.y < visible_top { + offset.y = visible_top - row_top; + } else if row_bottom + offset.y > viewport.size.height { + offset.y = viewport.size.height - row_bottom; + } + offset.y = offset + .y + .clamp(-self.scroll_handle.max_offset().y, Pixels::ZERO); + if offset != current_offset { + self.scroll_handle.set_offset(offset); + } + + Some(Bounds::new( + point(viewport.left(), viewport.top() + offset.y + row_top), + size(viewport.size.width, row_height), + )) + } + fn has_git_changes(&self, entry_id: ProjectEntryId) -> bool { for visible in &self.state.visible_entries { if let Some(git_entry) = visible.entries.iter().find(|e| e.id == entry_id) { @@ -1398,10 +1546,14 @@ impl ProjectPanel { match expanded_dir_ids.binary_search(&entry_id) { Ok(ix) => { expanded_dir_ids.remove(ix); + self.selection = Some(SelectedEntry { + worktree_id, + entry_id, + }); self.update_visible_entries( Some((worktree_id, entry_id)), false, - false, + true, window, cx, ); @@ -1770,7 +1922,13 @@ impl ProjectPanel { fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context) { let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel; - self.open_internal(true, !preview_tabs_enabled, None, window, cx); + self.open_internal( + preview_tabs_enabled, + !preview_tabs_enabled, + None, + window, + cx, + ); } fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context) { @@ -5817,25 +5975,25 @@ impl ProjectPanel { None }; - let border_color = - if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) { - match validation_color_and_message { - Some((color, _)) => color, - None => item_colors.focused, - } - } else { - bg_color - }; + let show_focused_border = !self.mouse_down && is_active && self.contains_focus(window, cx); - let border_hover_color = - if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) { - match validation_color_and_message { - Some((color, _)) => color, - None => item_colors.focused, - } - } else { - bg_hover_color - }; + let border_color = if show_focused_border { + match validation_color_and_message { + Some((color, _)) => color, + None => item_colors.focused, + } + } else { + bg_color + }; + + let border_hover_color = if show_focused_border { + match validation_color_and_message { + Some((color, _)) => color, + None => item_colors.focused, + } + } else { + bg_hover_color + }; let folded_directory_drag_target = self.folded_directory_drag_target; let is_highlighted = { @@ -5875,10 +6033,39 @@ impl ProjectPanel { (entry_id.to_proto() as usize).into() }; + let tooltip_path = self + .project + .read(cx) + .worktree_for_id(worktree_id, cx) + .map(|worktree| { + worktree + .read(cx) + .absolutize(&path) + .compact() + .to_string_lossy() + .into_owned() + }); + div() .id(id.clone()) .relative() .group(GROUP_NAME) + .when_some( + tooltip_path.and_then(|path| { + let delay = match settings.title_tooltip_delay { + settings::ProjectPanelTitleTooltipDelay::Default => 1500, + settings::ProjectPanelTitleTooltipDelay::Custom(delay_ms) => delay_ms.0, + settings::ProjectPanelTitleTooltipDelay::Disabled => { + return None; + } + }; + Some((path, delay)) + }), + |this, (path, delay)| { + this.tooltip_show_delay(Duration::from_millis(delay)) + .tooltip(Tooltip::text(path)) + }, + ) .cursor_pointer() .rounded_none() .bg(bg_color) @@ -6431,7 +6618,12 @@ impl ProjectPanel { if !this.marked_entries.contains(&selection) { this.marked_entries.clear(); } - this.deploy_context_menu(event.position, entry_id, window, cx); + this.deploy_context_menu( + ContextMenuPlacement::AtMouse(event.position), + entry_id, + window, + cx, + ); }, )) .overflow_x(), @@ -7091,6 +7283,10 @@ fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) - impl Render for ProjectPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !cx.has_active_drag() { + self.clear_drag_state(cx); + } + let has_worktree = !self.state.visible_entries.is_empty(); let project = self.project.read(cx); let panel_settings = ProjectPanelSettings::get_global(cx); @@ -7203,6 +7399,9 @@ impl Render for ProjectPanel { cx.stop_active_drag(window); this.clear_drag_state(cx); })) + .on_file_drop_exit(cx.listener(|this, _: &FileDropEvent, _window, cx| { + this.clear_drag_state(cx); + })) }) .size_full() .relative() @@ -7250,6 +7449,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::fold_directory)) .on_action(cx.listener(Self::remove_from_project)) .on_action(cx.listener(Self::compare_marked_files)) + .on_action(cx.listener(Self::open_context_menu)) .when(!project.is_read_only(cx), |el| { el.on_action(cx.listener(Self::new_file)) .on_action(cx.listener(Self::new_directory)) @@ -7635,7 +7835,7 @@ impl Render for ProjectPanel { // act as if the user clicked the root of the last worktree. if let Some(entry_id) = this.state.last_worktree_root_id { this.deploy_context_menu( - event.position, + ContextMenuPlacement::AtMouse(event.position), entry_id, window, cx, @@ -7689,12 +7889,14 @@ impl Render for ProjectPanel { window, cx, ) - .children(self.context_menu.as_ref().map(|(menu, position, _)| { + .children(self.context_menu.as_ref().map(|context_menu| { deferred( anchored() - .position(*position) - .anchor(gpui::Anchor::TopLeft) - .child(menu.clone()), + .position(context_menu.position) + .when_some(context_menu.anchor, |anchored, anchor| { + anchored.anchor(anchor) + }) + .child(context_menu.menu.clone()), ) .with_priority(3) })) diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index f4ca810d03..ff0330158f 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -4,7 +4,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ DockSide, FolderIndicator, IntoGpui, ProjectPanelEntrySpacing, ProjectPanelSortMode, - ProjectPanelSortOrder, RegisterSetting, Settings, ShowDiagnostics, ShowIndentGuides, + ProjectPanelSortOrder, ProjectPanelTitleTooltipDelay, RegisterSetting, Settings, + ShowDiagnostics, ShowIndentGuides, }; use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar}; @@ -13,6 +14,7 @@ pub struct ProjectPanelSettings { pub button: bool, pub hide_gitignore: bool, pub default_width: Pixels, + pub title_tooltip_delay: ProjectPanelTitleTooltipDelay, pub dock: DockSide, pub entry_spacing: ProjectPanelEntrySpacing, pub file_icons: bool, @@ -99,6 +101,7 @@ impl Settings for ProjectPanelSettings { hide_gitignore: project_panel.hide_gitignore.unwrap(), default_width: project_panel.default_width.unwrap().into_gpui(), dock: project_panel.dock.unwrap(), + title_tooltip_delay: project_panel.title_tooltip_delay.unwrap(), entry_spacing: project_panel.entry_spacing.unwrap(), file_icons: project_panel.file_icons.unwrap(), folder_indicator: project_panel.folder_indicator.unwrap(), diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index f02b4c7fd7..607e385f98 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -6,7 +6,10 @@ use git::{ Oid, repository::{InitialGraphCommitData, LogSource, RepoPath}, }; -use gpui::{Empty, Entity, TestAppContext, VisualTestContext}; +use gpui::{ + AnyWindowHandle, Empty, Entity, InputEvent as _, KeyDownEvent, Keystroke, Size, TestAppContext, + VisualTestContext, +}; use language::{ Diagnostic, DiagnosticEntry, DiagnosticMessage, DiagnosticSourceKind, LanguageServerId, PointUtf16, Unclipped, @@ -174,6 +177,66 @@ async fn test_opening_file(cx: &mut gpui::TestAppContext) { ] ); ensure_single_file_is_opened(&workspace, "test/second.rs", cx); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + pane.read_with(cx, |pane, _| { + assert_eq!(pane.items_len(), 1); + let active_item = pane.active_item(); + assert!(active_item.is_some()); + assert_eq!( + pane.preview_item_id(), + active_item.map(|item| item.item_id()) + ); + }); +} + +#[gpui::test] +async fn test_opening_file_with_project_panel_previews_disabled(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings + .preview_tabs + .get_or_insert_default() + .enable_preview_from_project_panel = Some(false); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/src"), + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + toggle_expand_dir(&panel, "src/test", cx); + for path in ["src/test/first.rs", "src/test/second.rs"] { + select_path(&panel, path, cx); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.run_until_parked(); + } + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + pane.read_with(cx, |pane, _| { + assert_eq!(pane.items_len(), 2); + assert_eq!(pane.preview_item_id(), None); + }); } #[gpui::test] @@ -3936,6 +3999,276 @@ async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_collapse_selected_entry_scrolls_into_view(cx: &mut TestAppContext) { + init_test(cx); + let (panel, mut cx) = open_panel_with_tree( + json!({ + "docs": { + "api.md": "", + "guide.md": "", + "setup.md": "", + }, + "src": { + "a.rs": "", + "b.rs": "", + "c.rs": "", + "d.rs": "", + "e.rs": "", + "f.rs": "", + }, + "tests": { + "a.rs": "", + "b.rs": "", + "c.rs": "", + "d.rs": "", + "e.rs": "", + "f.rs": "", + } + }), + size(px(800.), px(160.)), + cx, + ) + .await; + let cx = &mut cx; + toggle_expand_dir(&panel, "root/docs", cx); + toggle_expand_dir(&panel, "root/tests", cx); + let parent_id = find_project_entry(&panel, "root/src", cx).expect("src exists"); + + for sticky_scroll in [false, true] { + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + sticky_scroll, + ..settings + }, + cx, + ); + }); + toggle_expand_dir(&panel, "root/src", cx); + select_path(&panel, "root/src/f.rs", cx); + panel.update_in(cx, |panel, window, cx| { + panel.scroll_cursor_center(&ScrollCursorCenter, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..usize::MAX, cx), + &[ + "v root", + " v docs", + " api.md", + " guide.md", + " setup.md", + " v src", + " a.rs", + " b.rs", + " c.rs", + " d.rs", + " e.rs", + " f.rs <== selected", + " v tests", + " a.rs", + " b.rs", + " c.rs", + " d.rs", + " e.rs", + " f.rs", + ] + ); + let sticky_height = panel.read_with(cx, |panel, _| { + let parent_bounds = entry_row_bounds(panel, parent_id); + let viewport = panel.scroll_handle.viewport(); + assert!( + parent_bounds.bottom() < viewport.top(), + "src must start above the viewport: {parent_bounds:?}, {viewport:?}" + ); + parent_bounds.size.height * panel.sticky_items_count + }); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..usize::MAX, cx), + &[ + "v root", + " v docs", + " api.md", + " guide.md", + " setup.md", + " > src <== selected", + " v tests", + " a.rs", + " b.rs", + " c.rs", + " d.rs", + " e.rs", + " f.rs", + ] + ); + panel.read_with(cx, |panel, _| { + let parent_bounds = entry_row_bounds(panel, parent_id); + let viewport = panel.scroll_handle.viewport(); + assert!( + parent_bounds.top() >= viewport.top() + sticky_height, + "src must be below the sticky rows: {parent_bounds:?}, {sticky_height:?}" + ); + assert!( + parent_bounds.bottom() <= viewport.bottom(), + "src must fit in the viewport: {parent_bounds:?}, {viewport:?}" + ); + assert_eq!( + parent_bounds.center().y, + viewport.center().y + sticky_height / 2., + "src should be centered below the sticky rows (sticky_scroll={sticky_scroll})" + ); + }); + } +} + +#[gpui::test] +async fn test_collapse_selected_entry_does_not_scroll_visible_parent(cx: &mut TestAppContext) { + init_test(cx); + let (panel, mut cx) = open_panel_with_tree( + json!({ + "docs": { + "api.md": "", + "guide.md": "", + "setup.md": "", + }, + "src": { + "main.rs": "", + }, + "tests": { + "a.rs": "", + "b.rs": "", + "c.rs": "", + "d.rs": "", + "e.rs": "", + "f.rs": "", + } + }), + size(px(800.), px(160.)), + cx, + ) + .await; + let cx = &mut cx; + toggle_expand_dir(&panel, "root/docs", cx); + toggle_expand_dir(&panel, "root/tests", cx); + toggle_expand_dir(&panel, "root/src", cx); + let parent_id = find_project_entry(&panel, "root/src", cx).expect("src exists"); + panel.update_in(cx, |panel, window, cx| { + panel.scroll_cursor_bottom(&ScrollCursorBottom, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..usize::MAX, cx), + &[ + "v root", + " v docs", + " api.md", + " guide.md", + " setup.md", + " v src <== selected", + " main.rs", + " v tests", + " a.rs", + " b.rs", + " c.rs", + " d.rs", + " e.rs", + " f.rs", + ] + ); + let offset_before = panel.read_with(cx, |panel, _| { + let parent_bounds = entry_row_bounds(panel, parent_id); + let viewport = panel.scroll_handle.viewport(); + assert_eq!( + parent_bounds.bottom(), + viewport.bottom(), + "src should start at the bottom of the viewport" + ); + assert_ne!( + panel.scroll_handle.offset().y, + Pixels::ZERO, + "the list must be scrolled so that recentering src would move it" + ); + panel.scroll_handle.offset() + }); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..usize::MAX, cx), + &[ + "v root", + " v docs", + " api.md", + " guide.md", + " setup.md", + " > src <== selected", + " v tests", + " a.rs", + " b.rs", + " c.rs", + " d.rs", + " e.rs", + " f.rs", + ] + ); + panel.read_with(cx, |panel, _| { + assert_eq!( + panel.scroll_handle.offset(), + offset_before, + "collapsing the already-visible src must not scroll the list" + ); + }); +} + +#[gpui::test] +async fn test_collapse_selected_entry_preserves_selection_during_refresh(cx: &mut TestAppContext) { + init_test(cx); + let (panel, mut cx) = open_panel_with_tree( + json!({ + "src": { + "main.rs": "", + } + }), + size(px(800.), px(600.)), + cx, + ) + .await; + let cx = &mut cx; + toggle_expand_dir(&panel, "root/src", cx); + select_path(&panel, "root/src/main.rs", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..usize::MAX, cx).join("\n"), + "v root + v src + main.rs <== selected" + ); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + panel.update_visible_entries(None, false, false, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..usize::MAX, cx).join("\n"), + "v root + > src <== selected" + ); +} + #[gpui::test] async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); @@ -5873,8 +6206,9 @@ async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) { store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(Vec::new())); - settings.project.worktree.file_scan_inclusions = - Some(vec!["always_included_but_ignored_dir/*".to_string()]); + settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![ + "always_included_but_ignored_dir/*".to_string(), + ])); settings .project_panel .get_or_insert_default() @@ -6412,6 +6746,21 @@ async fn test_autoreveal_follows_multibuffer_selection(cx: &mut gpui::TestAppCon "Moving the cursor into a different excerpt buffer should reveal that buffer's entry" ); + cx.dispatch_action(workspace::RevealInProjectPanel::default()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py", + " v dir_2", + " file_2.py <== selected <== marked", + ], + "Explicit reveal should use the project path for the excerpt under the cursor" + ); + // Wrappers re-emit inner-editor events through `to_item_events`, so a // benign `TitleChanged` (e.g. diagnostic summary updates) ultimately // reaches `Workspace::active_item_path_changed`. The active path should be @@ -9197,7 +9546,7 @@ async fn test_context_menu_new_file_in_empty_hidden_root(cx: &mut gpui::TestAppC .last_worktree_root_id .expect("hidden root should be available for background context menu actions"); panel.deploy_context_menu( - gpui::point(gpui::px(1.), gpui::px(1.)), + ContextMenuPlacement::AtMouse(point(px(1.), px(1.))), root_entry_id, window, cx, @@ -11108,7 +11457,7 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu panel.update_in(cx, |panel, window, cx| { panel.deploy_context_menu( - gpui::point(gpui::px(1.), gpui::px(1.)), + ContextMenuPlacement::AtMouse(point(px(1.), px(1.))), child_entry_id, window, cx, @@ -11125,7 +11474,7 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu panel.update_in(cx, |panel, window, cx| { panel.deploy_context_menu( - gpui::point(gpui::px(2.), gpui::px(2.)), + ContextMenuPlacement::AtMouse(point(px(2.), px(2.))), subdir_entry_id, window, cx, @@ -11205,6 +11554,260 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu ); } +#[gpui::test] +async fn test_context_menu_opens_at_mouse_position(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await; + let cx = &mut cx; + + let mouse_position = point(px(37.), px(83.)); + let entry_id = + find_project_entry(&panel, "root/file_2.txt", cx).expect("file should exist for this test"); + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu( + ContextMenuPlacement::AtMouse(mouse_position), + entry_id, + window, + cx, + ); + }); + + panel.update(cx, |panel, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + assert_eq!(context_menu.position, mouse_position); + assert_eq!( + context_menu.anchor, None, + "mouse-deployed menus should not force an anchor" + ); + }); +} + +#[gpui::test] +async fn test_context_menu_for_entry_in_upper_half_opens_downwards(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await; + let cx = &mut cx; + + let entry_id = + find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test"); + let offset_before = panel.read_with(cx, |panel, _| panel.scroll_handle.offset()); + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + }); + + panel.update_in(cx, |panel, window, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + let selection = panel.selection.expect("deploying should select the entry"); + assert_eq!(selection.entry_id, entry_id); + + let (_, _, index) = panel + .index_for_selection(selection) + .expect("selected entry should be visible"); + let row_height = panel + .entry_row_height() + .expect("the entries list should have been laid out"); + let viewport = panel.scroll_handle.viewport(); + assert!( + viewport.right() < window.viewport_size().center().x, + "the panel should be docked on the left side of the window" + ); + assert_eq!( + panel.scroll_handle.offset(), + offset_before, + "an entry that is already visible should not cause scrolling" + ); + + let expected_entry_bottom = viewport.top() + offset_before.y + row_height * (index + 1); + assert!( + expected_entry_bottom < window.viewport_size().height / 2., + "test entry should be in the upper half of the window" + ); + assert_eq!( + context_menu.position, + point(viewport.left(), expected_entry_bottom) + CONTEXT_MENU_KEYBOARD_OFFSET, + "menu should hang off the bottom left corner of the entry" + ); + assert_eq!(context_menu.anchor, Some(Anchor::TopLeft)); + }); +} + +#[gpui::test] +async fn test_context_menu_for_entry_in_right_docked_panel_opens_leftwards( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Right, cx).await; + let cx = &mut cx; + + let entry_id = + find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test"); + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + }); + + panel.update_in(cx, |panel, window, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + let selection = panel.selection.expect("deploying should select the entry"); + let (_, _, index) = panel + .index_for_selection(selection) + .expect("selected entry should be visible"); + let row_height = panel + .entry_row_height() + .expect("the entries list should have been laid out"); + let viewport = panel.scroll_handle.viewport(); + assert!( + viewport.left() > window.viewport_size().center().x, + "the panel should be docked on the right side of the window" + ); + + let expected_entry_bottom = + viewport.top() + panel.scroll_handle.offset().y + row_height * (index + 1); + assert_eq!( + context_menu.position, + point(viewport.right(), expected_entry_bottom) + CONTEXT_MENU_KEYBOARD_OFFSET, + "menu should hang off the bottom right corner of the entry" + ); + assert_eq!(context_menu.anchor, Some(Anchor::TopRight)); + }); +} + +#[gpui::test] +async fn test_context_menu_for_entry_in_lower_half_scrolls_into_view_and_opens_upwards( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(200, DockSide::Left, cx).await; + let cx = &mut cx; + + let entry_id = find_project_entry(&panel, "root/file_199.txt", cx) + .expect("file should exist for this test"); + panel.read_with(cx, |panel, _| { + assert!( + panel.scroll_handle.is_scrollable(), + "the entries list should overflow the panel for this test" + ); + assert_eq!(panel.scroll_handle.offset().y, Pixels::ZERO); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + }); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + let selection = panel.selection.expect("deploying should select the entry"); + let (_, _, index) = panel + .index_for_selection(selection) + .expect("selected entry should be visible"); + let row_height = panel + .entry_row_height() + .expect("the entries list should have been laid out"); + let viewport = panel.scroll_handle.viewport(); + let offset = panel.scroll_handle.offset(); + + assert!( + offset.y < Pixels::ZERO, + "the list should have scrolled to reveal the entry" + ); + let entry_top = viewport.top() + offset.y + row_height * index; + let entry_bottom = entry_top + row_height; + assert!( + entry_top >= viewport.top() && entry_bottom <= viewport.bottom() + px(0.01), + "the entry should be scrolled into view, got {entry_top}..{entry_bottom} in {viewport:?}" + ); + assert!( + entry_top > window.viewport_size().height / 2., + "test entry should be in the lower half of the window" + ); + assert_eq!( + context_menu.position, + point(viewport.left(), entry_top) - CONTEXT_MENU_KEYBOARD_OFFSET, + "menu should hang off the top left corner of the entry" + ); + assert_eq!(context_menu.anchor, Some(Anchor::BottomLeft)); + }); +} + +#[gpui::test] +async fn test_panel_keeps_focus_highlight_while_context_menu_is_deployed( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await; + let cx = &mut cx; + + let entry_id = + find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test"); + panel.update_in(cx, |panel, window, cx| { + panel.focus_handle.focus(window, cx); + assert!(panel.contains_focus(window, cx)); + + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + // No frame has been drawn since the menu took focus, so the rendered element tree does + // not know about the menu yet. The panel must still consider itself focused so the + // selected entry keeps its focus border on this frame. + assert!( + panel + .context_menu + .as_ref() + .is_some_and(|context_menu| context_menu.menu.focus_handle(cx).is_focused(window)), + "deploying should focus the context menu" + ); + assert!(panel.contains_focus(window, cx)); + }); + + cx.run_until_parked(); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.contains_focus(window, cx)); + }); +} + +async fn open_panel_with_files( + file_count: usize, + dock: DockSide, + cx: &mut gpui::TestAppContext, +) -> (Entity, VisualTestContext) { + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global(ProjectPanelSettings { dock, ..settings }, cx); + }); + + let fs = FakeFs::new(cx.executor()); + let files = (0..file_count) + .map(|index| (format!("file_{index}.txt"), json!(""))) + .collect::>(); + fs.insert_tree(path!("/root"), serde_json::Value::Object(files)) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + panel + }); + cx.run_until_parked(); + (panel, cx) +} + async fn run_create_file_in_folded_path_case( case_name: &str, active_ancestor_path: &str, @@ -11967,3 +12570,207 @@ async fn test_file_rows_reserve_the_chevron_slot(cx: &mut gpui::TestAppContext) ); } } + +#[gpui::test] +async fn test_file_drag_state_clears_before_window_handoff(cx: &mut TestAppContext) { + init_test_with_editor(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "" })) + .await; + let first_project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let second_project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let (first_window, first_panel) = create_drag_test_panel(&first_project, cx); + let (second_window, second_panel) = create_drag_test_panel(&second_project, cx); + + cx.update(|cx| { + cx.update_window(first_window, |_, window, cx| { + window.dispatch_event( + KeyDownEvent { + keystroke: Keystroke::parse("down").expect("valid keystroke"), + is_held: false, + prefer_character_input: false, + } + .to_platform_input(), + cx, + ); + enter_file_drag_over_root(&first_panel, window, cx); + window.draw(cx).clear(cx); + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + }) + .expect("first window is open"); + cx.update_window(second_window, |_, window, cx| { + enter_file_drag_over_root(&second_panel, window, cx); + }) + .expect("second window is open"); + + assert!(cx.has_active_drag()); + assert_drag_state_cleared(first_panel.read(cx)); + cx.update_window(first_window, |_, window, cx| { + window.draw(cx).clear(cx); + assert_drag_state_cleared(first_panel.read(cx)); + }) + .expect("first window is open"); + assert!(second_panel.read(cx).drag_target_entry.is_some()); + assert!(cx.has_active_drag()); + + cx.update_window(second_window, |_, window, cx| { + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + assert_drag_state_cleared(second_panel.read(cx)); + }) + .expect("second window is open"); + assert!(!cx.has_active_drag()); + }); +} + +#[gpui::test] +async fn test_file_drag_state_clears_on_render_after_drag_stops(cx: &mut TestAppContext) { + init_test_with_editor(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "" })) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let (window, panel) = create_drag_test_panel(&project, cx); + + cx.update_window(window, |_, window, cx| { + enter_file_drag_over_root(&panel, window, cx); + window.draw(cx).clear(cx); + assert!(cx.stop_active_drag(window)); + assert!(panel.read(cx).drag_target_entry.is_some()); + + window.draw(cx).clear(cx); + assert_drag_state_cleared(panel.read(cx)); + }) + .expect("window is open"); +} + +fn create_drag_test_panel( + project: &Entity, + cx: &mut TestAppContext, +) -> (AnyWindowHandle, Entity) { + let window = cx.open_window(size(px(800.), px(600.)), |window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .expect("window is open"); + let window = AnyWindowHandle::from(window); + let panel = cx + .update_window(window, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + panel + }) + }) + .expect("window is open"); + cx.run_until_parked(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .expect("window is open"); + (window, panel) +} + +fn enter_file_drag_over_root(panel: &Entity, window: &mut Window, cx: &mut App) { + let (position, root_entry_id) = { + let panel = panel.read(cx); + let scroll = panel.scroll_handle.0.borrow(); + let bounds = scroll.base_handle.bounds(); + let item_size = scroll + .last_item_size + .expect("project entries were rendered"); + let item_count = panel + .state + .visible_entries + .iter() + .map(|worktree| worktree.entries.len()) + .sum::(); + assert_eq!(item_count, 2); + let item_height = item_size.contents.height / item_count as f32; + let position = bounds.origin + point(bounds.size.width / 2., item_height / 2.); + let worktree = panel + .project + .read(cx) + .visible_worktrees(cx) + .next() + .expect("project has a worktree") + .read(cx); + let root_entry_id = worktree.root_entry().expect("worktree has a root").id; + (position, root_entry_id) + }; + window.dispatch_event( + FileDropEvent::Entered { + position, + paths: ExternalPaths( + [PathBuf::from(path!("/outside/file.txt"))] + .into_iter() + .collect(), + ), + } + .to_platform_input(), + cx, + ); + let panel = panel.read(cx); + let target_entry_ids = panel + .drag_target_entry + .as_ref() + .and_then(|target| match target { + DragTarget::Entry { + entry_id, + highlight_entry_id, + } => Some((*entry_id, *highlight_entry_id)), + DragTarget::Background => None, + }); + assert_eq!(target_entry_ids, Some((root_entry_id, root_entry_id))); + assert_eq!(panel.previous_drag_position, Some(position)); + assert!(panel.hover_scroll_task.is_some()); +} + +fn assert_drag_state_cleared(panel: &ProjectPanel) { + assert!(panel.drag_target_entry.is_none()); + assert!(panel.folded_directory_drag_target.is_none()); + assert!(panel.hover_scroll_task.is_none()); + assert!(panel.hover_expand_task.is_none()); + assert_eq!(panel.previous_drag_position, None); +} + +async fn open_panel_with_tree( + tree: serde_json::Value, + window_size: Size, + cx: &mut TestAppContext, +) -> (Entity, VisualTestContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), tree).await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let window = cx.open_window(window_size, |window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .expect("window is open"); + let mut cx = VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + panel + }); + cx.run_until_parked(); + (panel, cx) +} + +fn entry_row_bounds(panel: &ProjectPanel, entry_id: ProjectEntryId) -> Bounds { + let row_index = panel + .state + .visible_entries + .iter() + .flat_map(|worktree| &worktree.entries) + .position(|entry| entry.id == entry_id) + .expect("entry has a row in the list"); + let row_height = panel.entry_row_height().expect("list is laid out"); + let viewport = panel.scroll_handle.viewport(); + let row_top = viewport.top() + panel.scroll_handle.offset().y + row_height * row_index; + Bounds::new( + point(viewport.left(), row_top), + size(viewport.size.width, row_height), + ) +} diff --git a/crates/prompt_store/Cargo.toml b/crates/prompt_store/Cargo.toml index bc4179e5e7..82d24e8944 100644 --- a/crates/prompt_store/Cargo.toml +++ b/crates/prompt_store/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/prompt_store.rs" +doctest = false [dependencies] agent_skills.workspace = true diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index c07d33857b..12e2b9a003 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package zed.messages; import "buffer.proto"; +import "core.proto"; message GetDefinition { uint64 project_id = 1; @@ -299,6 +300,7 @@ message LspExtExpandMacro { uint64 project_id = 1; uint64 buffer_id = 2; Anchor position = 3; + uint64 server_id = 4; } message LspExtExpandMacroResponse { @@ -306,10 +308,28 @@ message LspExtExpandMacroResponse { string expansion = 2; } +message LspExtExpandAbbreviation { + uint64 project_id = 1; + uint64 buffer_id = 2; + string abbreviation = 3; + repeated string text = 4; + string language = 5; + uint64 server_id = 6; + bool comment_filter = 7; + bool bem_filter = 8; + string indent = 9; + string base_indent = 10; +} + +message LspExtExpandAbbreviationResponse { + optional string expansion = 1; +} + message LspExtOpenDocs { uint64 project_id = 1; uint64 buffer_id = 2; Anchor position = 3; + uint64 server_id = 4; } message LspExtOpenDocsResponse { @@ -320,6 +340,7 @@ message LspExtOpenDocsResponse { message LspExtSwitchSourceHeader { uint64 project_id = 1; uint64 buffer_id = 2; + uint64 server_id = 3; } message LspExtSwitchSourceHeaderResponse { @@ -330,6 +351,7 @@ message LspExtGoToParentModule { uint64 project_id = 1; uint64 buffer_id = 2; Anchor position = 3; + uint64 server_id = 4; } message LspExtGoToParentModuleResponse { @@ -486,6 +508,7 @@ message PrepareRenameResponse { Anchor end = 3; repeated VectorClockEntry version = 4; bool only_unprepared_rename_supported = 5; + optional uint64 language_server_id = 6; } message PerformRename { @@ -494,6 +517,7 @@ message PerformRename { Anchor position = 3; string new_name = 4; repeated VectorClockEntry version = 5; + optional uint64 language_server_id = 6; } message OnTypeFormatting { @@ -548,6 +572,7 @@ message InlayHintLabelPart { PointUtf16 location_range_start = 4; PointUtf16 location_range_end = 5; optional uint64 language_server_id = 6; + optional CodeAction command = 7; } message InlayHintTooltip { @@ -648,6 +673,11 @@ message RefreshDocumentLinks { optional uint64 server_id = 2; } +message RefreshDocumentHighlights { + uint64 project_id = 1; + optional uint64 server_id = 2; +} + message RefreshFoldingRanges { uint64 project_id = 1; optional uint64 server_id = 2; @@ -936,6 +966,26 @@ message LanguageServerPromptResponse { optional uint64 action_response = 1; } +message LanguageServerShowDocumentRequest { + uint64 project_id = 1; + string uri = 2; + bool external = 3; + bool take_focus = 4; + optional PointUtf16 selection_start = 5; + optional PointUtf16 selection_end = 6; +} + +message ExecuteLspCommand { + uint64 project_id = 1; + uint64 language_server_id = 2; + string command = 3; + repeated string arguments = 4; +} + +message ExecuteLspCommandResponse { + optional string result = 1; +} + message GetDocumentColor { uint64 project_id = 1; uint64 buffer_id = 2; @@ -1012,6 +1062,7 @@ message LspQueryResponse { uint64 project_id = 1; uint64 lsp_request_id = 2; repeated LspResponse responses = 3; + PeerId peer_id = 4; } message LspResponse { @@ -1068,6 +1119,7 @@ message LspExtRunnables { uint64 project_id = 1; uint64 buffer_id = 2; optional Anchor position = 3; + uint64 server_id = 4; } message LspExtRunnablesResponse { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index c9cf63b4ba..d376daf9aa 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -516,7 +516,13 @@ message Envelope { GetIncomingCalls get_incoming_calls = 484; GetIncomingCallsResponse get_incoming_calls_response = 485; GetOutgoingCalls get_outgoing_calls = 486; - GetOutgoingCallsResponse get_outgoing_calls_response = 487; // current max + GetOutgoingCallsResponse get_outgoing_calls_response = 487; + LspExtExpandAbbreviation lsp_ext_expand_abbreviation = 488; + LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; + LanguageServerShowDocumentRequest language_server_show_document_request = 490; + ExecuteLspCommand execute_lsp_command = 491; + ExecuteLspCommandResponse execute_lsp_command_response = 492; + RefreshDocumentHighlights refresh_document_highlights = 493; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index e851c933cd..b4d2a48a6f 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -19,6 +19,23 @@ include!(concat!(env!("OUT_DIR"), "/zed.messages.rs")); pub const REMOTE_SERVER_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 }; pub const REMOTE_SERVER_PROJECT_ID: u64 = 0; +impl Envelope { + #[inline(never)] + pub fn decode_from_slice(buffer: &[u8]) -> Result { + Self::decode(buffer) + } + + #[inline(never)] + pub fn encode_to_buffer(&self, buffer: &mut Vec) -> Result<(), prost::EncodeError> { + self.encode(buffer) + } + + #[inline(never)] + pub fn encoded_size(&self) -> usize { + self.encoded_len() + } +} + messages!( (Ack, Foreground), (AckBufferOperation, Background), @@ -162,6 +179,7 @@ messages!( (LanguageServerLog, Foreground), (LanguageServerPromptRequest, Foreground), (LanguageServerPromptResponse, Foreground), + (LanguageServerShowDocumentRequest, Background), (LeaveChannelBuffer, Background), (LeaveChannelChat, Foreground), (LeaveProject, Foreground), @@ -176,6 +194,8 @@ messages!( (LoadCommitDiffResponse, Foreground), (LspExtExpandMacro, Background), (LspExtExpandMacroResponse, Background), + (LspExtExpandAbbreviation, Background), + (LspExtExpandAbbreviationResponse, Background), (LspExtOpenDocs, Background), (LspExtOpenDocsResponse, Background), (LspExtRunnables, Background), @@ -184,6 +204,8 @@ messages!( (LspExtSwitchSourceHeaderResponse, Background), (LspExtGoToParentModule, Background), (LspExtGoToParentModuleResponse, Background), + (ExecuteLspCommand, Background), + (ExecuteLspCommandResponse, Background), (LspExtCancelFlycheck, Background), (LspExtRunFlycheck, Background), (LspExtClearFlycheck, Background), @@ -216,6 +238,7 @@ messages!( (RefreshSemanticTokens, Background), (RefreshDocumentColors, Background), (RefreshDocumentLinks, Background), + (RefreshDocumentHighlights, Background), (RefreshFoldingRanges, Background), (RefreshDocumentSymbols, Background), (RegisterBufferWithLanguageServers, Background), @@ -499,6 +522,7 @@ request_messages!( (RefreshCodeLens, Ack), (RefreshDocumentColors, Ack), (RefreshDocumentLinks, Ack), + (RefreshDocumentHighlights, Ack), (RefreshFoldingRanges, Ack), (RefreshDocumentSymbols, Ack), (RejoinChannelBuffers, RejoinChannelBuffersResponse), @@ -547,6 +571,7 @@ request_messages!( (UpdateRepository, Ack), (RemoveRepository, Ack), (LspExtExpandMacro, LspExtExpandMacroResponse), + (LspExtExpandAbbreviation, LspExtExpandAbbreviationResponse), (LspExtOpenDocs, LspExtOpenDocsResponse), (LspExtRunnables, LspExtRunnablesResponse), (SetRoomParticipantRole, Ack), @@ -558,6 +583,7 @@ request_messages!( (StopLanguageServers, Ack), (LspExtSwitchSourceHeader, LspExtSwitchSourceHeaderResponse), (LspExtGoToParentModule, LspExtGoToParentModuleResponse), + (ExecuteLspCommand, ExecuteLspCommandResponse), (LspExtCancelFlycheck, Ack), (LspExtRunFlycheck, Ack), (LspExtClearFlycheck, Ack), @@ -569,6 +595,7 @@ request_messages!( (GetPermalinkToLine, GetPermalinkToLineResponse), (FlushBufferedMessages, Ack), (LanguageServerPromptRequest, LanguageServerPromptResponse), + (LanguageServerShowDocumentRequest, Ack), (GitGetBranches, GitBranchesResponse), (UpdateGitBranch, Ack), (ListToolchains, ListToolchainsResponse), @@ -752,6 +779,7 @@ entity_messages!( RefreshCodeLens, RefreshDocumentColors, RefreshDocumentLinks, + RefreshDocumentHighlights, RefreshFoldingRanges, RefreshDocumentSymbols, ReloadBuffers, @@ -784,10 +812,12 @@ entity_messages!( UpdateWorktreeSettings, UpdateUserSettings, LspExtExpandMacro, + LspExtExpandAbbreviation, LspExtOpenDocs, LspExtRunnables, LspExtSwitchSourceHeader, LspExtGoToParentModule, + ExecuteLspCommand, LspExtCancelFlycheck, LspExtRunFlycheck, LspExtClearFlycheck, @@ -799,6 +829,7 @@ entity_messages!( GetFilePermalink, GetPermalinkToLine, LanguageServerPromptRequest, + LanguageServerShowDocumentRequest, GitGetBranches, UpdateGitBranch, ListToolchains, diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs index 2cb1dfde82..c5d429fa1c 100644 --- a/crates/recent_projects/src/dev_container_suggest.rs +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -77,13 +77,16 @@ pub fn suggest_on_worktree_updated( window: &mut Window, cx: &mut Context, ) { - let cli_auto_open = workspace.open_in_dev_container(); + if workspace.open_in_dev_container() { + open_dev_container_from_cli(workspace, window, cx); + return; + } let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| { path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path() }); - if !devcontainer_updated && !cli_auto_open { + if !devcontainer_updated { return; } @@ -97,35 +100,7 @@ pub fn suggest_on_worktree_updated( return; } - let has_configs = !find_configs_in_snapshot(worktree).is_empty(); - - if cli_auto_open { - workspace.set_open_in_dev_container(false); - let task = cx.spawn_in(window, async move |workspace, cx| { - let scans_complete = - workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?; - scans_complete.await; - - workspace.update_in(cx, |workspace, window, cx| { - let has_configs = workspace - .project() - .read(cx) - .worktrees(cx) - .any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty()); - if has_configs { - cx.on_next_frame(window, move |_workspace, window, cx| { - window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); - }); - } else { - log::warn!("--dev-container: no devcontainer configuration found in project"); - } - }) - }); - workspace.set_dev_container_task(task); - return; - } - - if !has_configs { + if find_configs_in_snapshot(worktree).is_empty() { return; } @@ -193,3 +168,32 @@ pub fn suggest_on_worktree_updated( }); }); } + +pub fn open_dev_container_from_cli( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + workspace.set_open_in_dev_container(false); + let task = cx.spawn_in(window, async move |workspace, cx| { + let scans_complete = + workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?; + scans_complete.await; + + workspace.update_in(cx, |workspace, window, cx| { + let has_configs = workspace + .project() + .read(cx) + .worktrees(cx) + .any(|worktree| !find_configs_in_snapshot(worktree.read(cx)).is_empty()); + if has_configs { + cx.on_next_frame(window, move |_workspace, window, cx| { + window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); + }); + } else { + log::warn!("--dev-container: no devcontainer configuration found in project"); + } + }) + }); + workspace.set_dev_container_task(task); +} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index c26d7537b5..5578ae9382 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -545,6 +545,12 @@ pub fn init(cx: &mut App) { let Some(window) = window else { return; }; + // A workspace opened with `--dev-container` has its worktrees scanning + // before this observer runs, so their update events can't be relied on + // to trigger the auto-open. + if workspace.open_in_dev_container() { + dev_container_suggest::open_dev_container_from_cli(workspace, window, cx); + } cx.subscribe_in( workspace.project(), window, diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index bc61d33943..9861d72870 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1198,6 +1198,7 @@ impl PickerDelegate for RemoteServerPickerDelegate { let Some(server_entry) = self.state.servers.get(*server) else { return; }; + cx.emit(DismissEvent); match server_entry { RemoteEntry::Project { connection, index, .. @@ -2722,7 +2723,6 @@ impl RemoteServerProjects { let distro_name = distro_name.clone(); move |_, _: &menu::Confirm, window, cx| { remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); - cx.focus_self(window); } })) .child( @@ -2734,7 +2734,6 @@ impl RemoteServerProjects { .child(Label::new("Remove Distro").color(Color::Error)) .on_click(cx.listener(move |_, _, window, cx| { remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); - cx.focus_self(window); })), ) }) @@ -2879,7 +2878,6 @@ impl RemoteServerProjects { window, cx, ); - cx.focus_self(window); } })) .child( @@ -2897,7 +2895,6 @@ impl RemoteServerProjects { window, cx, ); - cx.focus_self(window); })), ) }) diff --git a/crates/refineable/Cargo.toml b/crates/refineable/Cargo.toml index 4c4e02851b..98bfd84bb8 100644 --- a/crates/refineable/Cargo.toml +++ b/crates/refineable/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [lib] path = "src/refineable.rs" doctest = false +test = false [dependencies] derive_refineable.workspace = true diff --git a/crates/refineable/derive_refineable/Cargo.toml b/crates/refineable/derive_refineable/Cargo.toml index 75e9ef1f8f..8c3f7c1dfb 100644 --- a/crates/refineable/derive_refineable/Cargo.toml +++ b/crates/refineable/derive_refineable/Cargo.toml @@ -14,6 +14,7 @@ workspace = true path = "src/derive_refineable.rs" proc-macro = true doctest = false +test = false [dependencies] proc-macro2.workspace = true diff --git a/crates/release_channel/Cargo.toml b/crates/release_channel/Cargo.toml index 54a8afff8c..0454e5c5cc 100644 --- a/crates/release_channel/Cargo.toml +++ b/crates/release_channel/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +doctest = false + [dependencies] gpui.workspace = true semver.workspace = true diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml index f420c2dca1..2c2442ea0b 100644 --- a/crates/remote/Cargo.toml +++ b/crates/remote/Cargo.toml @@ -30,7 +30,6 @@ gpui.workspace = true log.workspace = true parking_lot.workspace = true paths.workspace = true -prost.workspace = true release_channel.workspace = true rpc = { workspace = true, features = ["gpui"] } schemars.workspace = true diff --git a/crates/remote/src/protocol.rs b/crates/remote/src/protocol.rs index 867a31b164..d9757577cb 100644 --- a/crates/remote/src/protocol.rs +++ b/crates/remote/src/protocol.rs @@ -1,6 +1,5 @@ use anyhow::Result; use futures::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use prost::Message as _; use rpc::proto::Envelope; #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] @@ -20,7 +19,7 @@ pub async fn read_message_with_len( ) -> Result { buffer.resize(message_len as usize, 0); stream.read_exact(buffer).await?; - Ok(Envelope::decode(buffer.as_slice())?) + Ok(Envelope::decode_from_slice(buffer.as_slice())?) } pub async fn read_message( @@ -40,13 +39,13 @@ pub async fn write_message( buffer: &mut Vec, message: Envelope, ) -> Result<()> { - let message_len = message.encoded_len() as u32; + let message_len = message.encoded_size() as u32; stream .write_all(message_len.to_le_bytes().as_slice()) .await?; buffer.clear(); buffer.reserve(message_len as usize); - message.encode(buffer)?; + message.encode_to_buffer(buffer)?; stream.write_all(buffer).await?; Ok(()) } diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 64489598b4..91eb5c5a2f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1372,6 +1372,16 @@ impl RemoteConnectionOptions { RemoteConnectionOptions::Mock(_) => "mock", } } + + pub fn host(&self) -> String { + match self { + RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(), + RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), + RemoteConnectionOptions::Docker(opts) => opts.name.clone(), + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(opts) => format!("mock-{}", opts.id), + } + } } #[cfg(test)] @@ -1563,6 +1573,17 @@ mod tests { "stream channel should be removed once the consumer has dropped the stream" ); } + + #[test] + fn test_ssh_host_ignores_nickname() { + let options = RemoteConnectionOptions::Ssh(SshConnectionOptions { + host: "1.2.3.4".into(), + nickname: Some("My Cool Project".to_string()), + ..Default::default() + }); + + assert_eq!(options.host(), "1.2.3.4"); + } } impl From for RemoteConnectionOptions { diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index 09447dcce2..7ffdbd4e3f 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -7,6 +7,7 @@ use parking_lot::Mutex; use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use semver::Version as SemanticVersion; use std::collections::BTreeMap; +use std::fmt::Write; use std::time::Instant; use std::{ path::{Path, PathBuf}, @@ -14,6 +15,7 @@ use std::{ }; use util::ResultExt; use util::command::Stdio; +use util::redact::{is_valid_environment_name, redact_command}; use util::shell::ShellKind; use util::{ paths::{PathStyle, RemotePathBuf}, @@ -512,15 +514,49 @@ impl DockerExecConnection { command.arg(arg.as_ref()); } let output = command.output().await?; - log::debug!("{:?}: {:?}", command, output); - anyhow::ensure!( - output.status.success(), - "failed to run command {command:?}: {}", - String::from_utf8_lossy(&output.stderr) + log::debug!( + "{}: {:?}", + redact_arguments(self.docker_cli(), subcommand, args), + output.status ); + if !output.status.success() { + anyhow::bail!(docker_command_error( + self.docker_cli(), + subcommand, + args, + &String::from_utf8_lossy(&output.stderr), + )); + } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } + fn docker_exec_arguments( + &self, + inner_program: &str, + working_directory: Option<&str>, + env: &HashMap, + program_args: &[impl AsRef], + ) -> Vec { + let mut args = match working_directory { + Some(dir) => vec!["-w".to_string(), dir.to_string()], + None => vec![], + }; + + args.push("-u".to_string()); + args.push(self.connection_options.remote_user.clone()); + + push_environment(&mut args, &self.connection_options.remote_env); + push_environment(&mut args, env); + + args.push(self.connection_options.container_id.clone()); + args.push(inner_program.to_string()); + + for arg in program_args { + args.push(arg.as_ref().to_owned()); + } + args + } + async fn run_docker_exec( &self, inner_program: &str, @@ -528,30 +564,7 @@ impl DockerExecConnection { env: &HashMap, program_args: &[impl AsRef], ) -> Result { - let mut args = match working_directory { - Some(dir) => vec!["-w".to_string(), dir.to_string()], - None => vec![], - }; - - args.push("-u".to_string()); - args.push(self.connection_options.remote_user.clone()); - - for (k, v) in self.connection_options.remote_env.iter() { - args.push("-e".to_string()); - args.push(format!("{k}={v}")); - } - - for (k, v) in env.iter() { - args.push("-e".to_string()); - args.push(format!("{k}={v}")); - } - - args.push(self.connection_options.container_id.clone()); - args.push(inner_program.to_string()); - - for arg in program_args { - args.push(arg.as_ref().to_owned()); - } + let args = self.docker_exec_arguments(inner_program, working_directory, env, program_args); self.run_docker_command("exec", args.as_ref()).await } @@ -674,10 +687,7 @@ impl RemoteConnection for DockerExecConnection { let mut docker_args = vec!["exec".to_string()]; - for (k, v) in self.connection_options.remote_env.iter() { - docker_args.push("-e".to_string()); - docker_args.push(format!("{k}={v}")); - } + push_environment(&mut docker_args, &self.connection_options.remote_env); for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { if let Some(value) = std::env::var(env_var).ok() { docker_args.push("-e".to_string()); @@ -815,15 +825,8 @@ impl RemoteConnection for DockerExecConnection { docker_args.push(parsed_working_dir); } - for (k, v) in self.connection_options.remote_env.iter() { - docker_args.push("-e".to_string()); - docker_args.push(format!("{k}={v}")); - } - - for (k, v) in env.iter() { - docker_args.push("-e".to_string()); - docker_args.push(format!("{k}={v}")); - } + push_environment(&mut docker_args, &self.connection_options.remote_env); + push_environment(&mut docker_args, env); match interactive { Interactive::Yes => docker_args.push("-it".to_string()), @@ -877,3 +880,211 @@ impl RemoteConnection for DockerExecConnection { String::from("/bin/sh") } } + +fn push_environment<'a>( + args: &mut Vec, + environment: impl IntoIterator, +) { + for (name, value) in environment { + if !is_valid_environment_name(name) { + log::warn!("Skipping environment variable with invalid name"); + continue; + } + args.push("-e".to_string()); + args.push(format!("{name}={value}")); + } +} + +fn redact_arguments(docker_cli: &str, subcommand: &str, args: &[impl AsRef]) -> String { + let mut redacted = format!("{docker_cli:?} {subcommand:?}"); + let mut next_argument_is_environment = false; + + for arg in args { + let arg = arg.as_ref(); + let argument = if next_argument_is_environment { + next_argument_is_environment = false; + redact_environment(arg) + } else if arg == "-e" { + next_argument_is_environment = true; + arg.to_string() + } else { + redact_command(arg) + }; + write!(redacted, " {argument:?}").ok(); + } + + redacted +} + +fn docker_command_error( + docker_cli: &str, + subcommand: &str, + args: &[impl AsRef], + stderr: &str, +) -> String { + format!( + "failed to run command {}: {}", + redact_arguments(docker_cli, subcommand, args), + redact_command(stderr) + ) +} + +fn redact_environment(assignment: &str) -> String { + match assignment.split_once('=') { + Some((name, _)) => format!("{name}="), + None => assignment.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_forwarded_env() { + let connection = connection(&[ + ("DATABASE_URL", "postgres://user:password@host/db"), + ("GH_TOKEN", "ghp_supersecret"), + ("PATH", "/usr/bin"), + ("lowercase_token", "secret"), + ]); + + assert_eq!( + redacted_docker_exec(&connection, &[], &["-c", "echo hi"]), + concat!( + "\"docker\" \"exec\" \"-w\" \"/workspace\" \"-u\" \"user\"", + " \"-e\" \"DATABASE_URL=\" \"-e\" \"GH_TOKEN=\"", + " \"-e\" \"PATH=\" \"-e\" \"lowercase_token=\"", + " \"container_id\" \"sh\" \"-c\" \"echo hi\"" + ) + ); + } + + #[test] + fn preserves_argument_boundaries_and_escapes_control_characters() { + let connection = connection(&[]); + + assert_eq!( + redacted_docker_exec(&connection, &[], &["-c", "first argument\nsecond argument"]), + concat!( + "\"docker\" \"exec\" \"-w\" \"/workspace\" \"-u\" \"user\"", + " \"container_id\" \"sh\" \"-c\" \"first argument\\nsecond argument\"" + ) + ); + } + + #[test] + fn redacts_assignments_in_command_arguments() { + assert_eq!( + redact_arguments( + "docker", + "exec", + &["container_id", "sh", "-c", "GH_TOKEN=ghp_supersecret run"] + ), + concat!( + "\"docker\" \"exec\" \"container_id\" \"sh\" \"-c\"", + " \"GH_TOKEN=\\\"[REDACTED]\\\" run\"" + ) + ); + } + + #[test] + fn redacts_failure_message() { + let connection = connection(&[("API_KEY", "remote-secret")]); + let env = [("COMMAND_SECRET".to_string(), "command-secret".to_string())] + .into_iter() + .collect::>(); + let args = connection.docker_exec_arguments("sh", None, &env, &["-c", "echo hi"]); + + assert_eq!( + docker_command_error( + connection.docker_cli(), + "exec", + &args, + "sh: 1: /usr/local/cargo/bin/zed-remote-server: not found\nGH_TOKEN=ghp_supersecret run", + ), + concat!( + "failed to run command \"docker\" \"exec\" \"-u\" \"user\"", + " \"-e\" \"API_KEY=\" \"-e\" \"COMMAND_SECRET=\"", + " \"container_id\" \"sh\" \"-c\" \"echo hi\"", + ": sh: 1: /usr/local/cargo/bin/zed-remote-server: not found\nGH_TOKEN=\"[REDACTED]\" run" + ) + ); + } + + #[test] + fn skips_invalid_env_names() { + let connection = connection(&[ + ("", "empty"), + ("NAME=VALUE", "equals"), + ("LINE\nBREAK", "newline"), + ("NAME\0NUL", "nul"), + ("GH_TOKEN", "ghp_supersecret"), + ]); + let env = HashMap::default(); + let program_args = Vec::<&str>::new(); + let args = connection.docker_exec_arguments("sh", None, &env, &program_args); + + assert_eq!( + args, + vec![ + "-u", + "user", + "-e", + "GH_TOKEN=ghp_supersecret", + "container_id", + "sh" + ] + ); + } + + #[test] + fn uses_podman_cli() { + let mut connection = connection(&[("GH_TOKEN", "ghp_supersecret")]); + connection.connection_options.use_podman = true; + + assert_eq!( + redacted_docker_exec(&connection, &[], &[]), + concat!( + "\"podman\" \"exec\" \"-w\" \"/workspace\" \"-u\" \"user\"", + " \"-e\" \"GH_TOKEN=\" \"container_id\" \"sh\"" + ) + ); + } + + fn connection(remote_env: &[(&str, &str)]) -> DockerExecConnection { + DockerExecConnection { + proxy_process: Mutex::new(None), + remote_dir_for_server: "/tmp/zed".to_string(), + remote_binary_relpath: None, + connection_options: DockerConnectionOptions { + name: "container".to_string(), + container_id: "container_id".to_string(), + remote_user: "user".to_string(), + upload_binary_over_docker_exec: false, + use_podman: false, + remote_env: remote_env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + }, + remote_platform: None, + os_version: None, + path_style: None, + shell: "/bin/sh".to_string(), + } + } + + fn redacted_docker_exec( + connection: &DockerExecConnection, + env: &[(&str, &str)], + program_args: &[&str], + ) -> String { + let env = env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + let args = connection.docker_exec_arguments("sh", Some("/workspace"), &env, program_args); + redact_arguments(connection.docker_cli(), "exec", &args) + } +} diff --git a/crates/remote_connection/Cargo.toml b/crates/remote_connection/Cargo.toml index 02cd44671c..783ac9de09 100644 --- a/crates/remote_connection/Cargo.toml +++ b/crates/remote_connection/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/remote_connection.rs" +doctest = false [features] default = [] diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 094521a9f1..faa82cc896 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -18,7 +18,7 @@ name = "remote_server" [features] default = [] -debug-embed = ["dep:rust-embed"] +debug-embed = ["dep:rust-embed", "util/debug-embed"] test-support = ["fs/test-support"] [dependencies] diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 7f916b3c81..0a6905362e 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -22,7 +22,9 @@ use project::{ debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore}, git_store::GitStore, image_store::ImageId, - lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind, LogKind}, + lsp_store::log_store::{ + self, GlobalLogStore, LanguageServerKind, LanguageServerLogKey, LogKind, + }, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, @@ -408,13 +410,39 @@ impl HeadlessProject { }); } } - LspStoreEvent::LanguageServerRemoved(id) => { + LspStoreEvent::SupplementaryLanguageServerAdded(id, name) => { let log_store = cx .try_global::() .map(|lsp_logs| lsp_logs.0.clone()); if let Some(log_store) = log_store { log_store.update(cx, |log_store, cx| { - log_store.remove_language_server(*id, cx); + log_store.add_language_server( + LanguageServerKind::LocalSsh { + lsp_store: self.lsp_store.downgrade(), + }, + *id, + Some(name.clone()), + None, + lsp_store.read(cx).language_server_for_id(*id), + cx, + ); + }); + } + } + LspStoreEvent::LanguageServerRemoved(id) + | LspStoreEvent::SupplementaryLanguageServerRemoved(id) => { + let log_store = cx + .try_global::() + .map(|lsp_logs| lsp_logs.0.clone()); + if let Some(log_store) = log_store { + let server_key = LanguageServerLogKey::new( + LanguageServerKind::LocalSsh { + lsp_store: self.lsp_store.downgrade(), + }, + *id, + ); + log_store.update(cx, |log_store, cx| { + log_store.remove_language_server(&server_key, cx); }); } self.session @@ -428,6 +456,10 @@ impl HeadlessProject { }) .log_err(); } + LspStoreEvent::LanguageServerUpdate { + message: proto::update_language_server::Variant::MetadataUpdated(_), + .. + } => {} LspStoreEvent::LanguageServerUpdate { language_server_id, name, @@ -451,6 +483,33 @@ impl HeadlessProject { }) .log_err(); } + LspStoreEvent::LanguageServerShowDocument(show_document_request) => { + let request = self + .session + .request(proto::LanguageServerShowDocumentRequest { + project_id: REMOTE_SERVER_PROJECT_ID, + uri: show_document_request.uri.as_str().to_owned(), + external: show_document_request.external, + take_focus: show_document_request.take_focus, + selection_start: show_document_request.selection.map(|selection| { + proto::PointUtf16 { + row: selection.start.line, + column: selection.start.character, + } + }), + selection_end: show_document_request.selection.map(|selection| { + proto::PointUtf16 { + row: selection.end.line, + column: selection.end.character, + } + }), + }); + let show_document_request = show_document_request.clone(); + cx.background_spawn(async move { + show_document_request.respond(request.await.is_ok()); + }) + .detach(); + } LspStoreEvent::LanguageServerPrompt(prompt) => { let request = self.session.request(proto::LanguageServerPromptRequest { project_id: REMOTE_SERVER_PROJECT_ID, @@ -837,11 +896,12 @@ impl HeadlessProject { } async fn handle_toggle_lsp_logs( - _: Entity, + this: Entity, envelope: TypedEnvelope, cx: AsyncApp, ) -> Result<()> { let server_id = LanguageServerId::from_proto(envelope.payload.server_id); + let lsp_store = this.read_with(&cx, |this, _| this.lsp_store.downgrade()); cx.update(|cx| { let log_store = cx .try_global::() @@ -856,8 +916,10 @@ impl HeadlessProject { proto::toggle_lsp_logs::LogType::Trace => LogKind::Trace, proto::toggle_lsp_logs::LogType::Rpc => LogKind::Rpc, }; + let server_key = + LanguageServerLogKey::new(LanguageServerKind::LocalSsh { lsp_store }, server_id); log_store.update(cx, |log_store, _| { - log_store.toggle_lsp_logs(server_id, envelope.payload.enabled, toggled_log_kind); + log_store.toggle_lsp_logs(&server_key, envelope.payload.enabled, toggled_log_kind); }); anyhow::Ok(()) })?; diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index bd5b6d16a3..dc99eb5df6 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -36,13 +36,14 @@ use language::{ }; use lsp::{ CompletionContext, CompletionResponse, CompletionTriggerKind, DEFAULT_LSP_REQUEST_TIMEOUT, - LanguageServerName, + LanguageServerId, LanguageServerName, }; use node_runtime::NodeRuntime; use project::{ - ProgressToken, Project, ProjectPath, + CompletionSource, LanguageServerLogType, ProgressToken, Project, ProjectPath, agent_server_store::AgentServerCommand, image_store, + lsp_store::log_store::{LanguageServerKind, LanguageServerLogKey, LogStore}, search::{SearchQuery, SearchResult}, }; use remote::{ConnectionState, RemoteClient, RemoteClientEvent}; @@ -53,6 +54,7 @@ use settings::{ }; use smol::stream::StreamExt; use std::{ + cell::RefCell, path::{Path, PathBuf}, rc::Rc, str::FromStr, @@ -183,6 +185,36 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test buffer.update(cx, |buffer, _| { assert_eq!(&**buffer.file().unwrap().path(), rel_path("src/lib2.rs")); }); + let renamed_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("src/lib2.rs")), cx) + }) + .await + .unwrap(); + assert_eq!(renamed_buffer, buffer); + + fs.insert_file( + path!("/code/project1/src/lib.rs"), + b"fn two() -> usize { 2 }".to_vec(), + ) + .await; + cx.run_until_parked(); + + let recreated_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + assert_ne!(recreated_buffer, buffer); + recreated_buffer.read_with(cx, |buffer, _| { + assert_eq!(&**buffer.file().unwrap().path(), rel_path("src/lib.rs")); + assert_eq!(buffer.text(), "fn two() -> usize { 2 }"); + }); + buffer.read_with(cx, |buffer, _| { + assert_eq!(&**buffer.file().unwrap().path(), rel_path("src/lib2.rs")); + assert_eq!(buffer.text(), "fn one() -> usize { 100 }"); + }); fs.set_index_for_repo( Path::new(path!("/code/project1/.git")), @@ -197,6 +229,68 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test }); } +#[gpui::test] +async fn test_remote_buffer_path_swap(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code/project"), + json!({ "a.txt": "first", "b.txt": "second" }), + ) + .await; + let (project, headless) = init_test(&fs, cx, server_cx).await; + let session = headless.read_with(server_cx, |headless, _| headless.session.clone()); + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project"), true, cx) + }) + .await + .unwrap(); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let first_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("a.txt")), cx) + }) + .await + .unwrap(); + let second_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("b.txt")), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + for updates in [ + [(&first_buffer, "b.txt"), (&second_buffer, "a.txt")], + [(&second_buffer, "b.txt"), (&first_buffer, "a.txt")], + ] { + for (buffer, path) in updates { + let message = buffer.read_with(cx, |buffer, cx| { + let mut file = buffer.file().unwrap().to_proto(cx); + file.path = path.to_owned(); + proto::UpdateBufferFile { + project_id: proto::REMOTE_SERVER_PROJECT_ID, + buffer_id: buffer.remote_id().to_proto(), + file: Some(file), + } + }); + session.send(message).unwrap(); + cx.run_until_parked(); + } + for (buffer, path) in updates { + buffer.read_with(cx, |buffer, _| { + assert_eq!(&**buffer.file().unwrap().path(), rel_path(path)); + }); + project.read_with(cx, |project, cx| { + assert_eq!( + project.get_open_buffer(&(worktree_id, rel_path(path)).into(), cx), + Some(buffer.clone()) + ); + }); + } + } +} + #[gpui::test] async fn test_remote_telemetry_event_forwarding( cx: &mut TestAppContext, @@ -1110,7 +1204,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext project .update(cx, |project, cx| { - project.perform_rename(buffer.clone(), 3, "two".to_string(), cx) + project.perform_rename(buffer.clone(), 3, "two".to_string(), None, cx) }) .await .unwrap(); @@ -1121,6 +1215,185 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext }) } +#[gpui::test] +async fn test_remote_completion_resolve_edit_ranges( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code/project"), + json!({ "lib.rs": "test(value1, value2)" }), + ) + .await; + let (project, headless) = init_test(&fs, cx, server_cx).await; + let capabilities = lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }; + project.update(cx, |project, _| { + project.languages().add(rust_lang()); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + }); + let mut fake_servers = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName(SharedString::from("rust-analyzer")), + capabilities, + Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 13) + ); + Ok(Some(CompletionResponse::Array(vec![lsp::CompletionItem { + label: "value2=".to_string(), + ..lsp::CompletionItem::default() + }]))) + }, + ); + })), + ) + }); + let worktree_id = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project"), true, cx) + }) + .await + .expect("worktree should open") + .0 + .read_with(cx, |worktree, _| worktree.id()); + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("lib.rs")), cx) + }) + .await + .expect("buffer should open with LSP"); + let fake_server = fake_servers.next().await.expect("LSP should start"); + cx.run_until_parked(); + + let explicit_replace_range = + lsp::Range::new(lsp::Position::new(0, 12), lsp::Position::new(0, 19)); + let explicit_insert_range = + lsp::Range::new(lsp::Position::new(0, 12), lsp::Position::new(0, 13)); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + for (resolved_edit, expected_insert_range, expected_replace_range, expected_text) in [ + (None, Some(13..13), 13..19, "value2="), + ( + Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: explicit_replace_range, + new_text: " value2=".to_string(), + })), + None, + 12..19, + " value2=", + ), + ( + Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + insert: explicit_insert_range, + replace: explicit_replace_range, + new_text: " value2=".to_string(), + }, + )), + Some(12..13), + 12..19, + " value2=", + ), + ] { + fake_server.set_request_handler::( + move |mut item, _| { + let resolved_edit = resolved_edit.clone(); + async move { + assert_eq!(item.label, "value2="); + item.documentation = Some(lsp::Documentation::String("resolved".to_string())); + item.text_edit = resolved_edit; + Ok(item) + } + }, + ); + let completions = project + .update(cx, |project, cx| { + project.completions( + &buffer, + 13, + CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + cx, + ) + }) + .await + .expect("completions should load") + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); + assert_eq!(completions.len(), 1); + buffer.read_with(cx, |buffer, _| { + let completion = completions.first().expect("completion should exist"); + let CompletionSource::Lsp { insert_range, .. } = &completion.source else { + panic!("expected LSP completion"); + }; + assert_eq!( + *insert_range, + Some(buffer.anchor_before(13)..buffer.anchor_after(13)) + ); + assert_eq!( + completion.replace_range, + buffer.anchor_before(13)..buffer.anchor_after(19) + ); + }); + let completions = Rc::new(RefCell::new(completions.into_boxed_slice())); + let did_resolve = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.resolve_completions(buffer.clone(), vec![0], completions.clone(), cx) + }) + .await + .expect("completion should resolve"); + assert!(did_resolve); + buffer.read_with(cx, |buffer, _| { + let completions = completions.borrow(); + let completion = completions.first().expect("completion should exist"); + let CompletionSource::Lsp { + insert_range, + resolved, + lsp_completion, + .. + } = &completion.source + else { + panic!("expected LSP completion"); + }; + assert!(*resolved); + assert_eq!( + lsp_completion.documentation, + Some(lsp::Documentation::String("resolved".to_string())) + ); + assert_eq!( + *insert_range, + expected_insert_range + .map(|range| buffer.anchor_before(range.start)..buffer.anchor_after(range.end)) + ); + assert_eq!( + completion.replace_range, + buffer.anchor_before(expected_replace_range.start) + ..buffer.anchor_after(expected_replace_range.end) + ); + assert_eq!(completion.new_text, expected_text); + }); + } +} + #[gpui::test] async fn test_remote_call_hierarchy(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor()); @@ -3429,6 +3702,92 @@ async fn test_add_path_to_git_info_exclude_in_remote_repository( ); } +#[gpui::test] +async fn test_add_path_to_git_info_exclude_in_remote_linked_worktree( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": { + "HEAD": "ref: refs/heads/main", + "config": "[core]\n\tbare = false\n", + "info": { + "exclude": "existing\n", + }, + "worktrees": { + "linked": { + "HEAD": "ref: refs/heads/linked", + "commondir": "../..", + "gitdir": "/worktree/.git", + }, + }, + }, + }), + ) + .await; + fs.insert_tree( + "/worktree", + json!({ + ".git": "gitdir: ../project/.git/worktrees/linked\n", + "logs": { + "app.log": "", + }, + "tmp.txt": "", + }), + ) + .await; + let repository_dir = Path::new("/project/.git/worktrees/linked"); + fs.set_branch_name(repository_dir, Some("linked")); + fs.set_head_for_repo(repository_dir, &[], "head-sha"); + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree(Path::new("/worktree"), true, cx) + }) + .await + .expect("should open remote linked worktree"); + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("remote linked worktree should have an active repository") + }); + + for (path, is_dir) in [("tmp.txt", false), ("logs", true), ("tmp.txt", false)] { + let repo_path = RepoPath::new(path).expect("path should be a valid repo path"); + cx.update(|cx| { + repository.update(cx, |repository, _| { + repository.add_path_to_git_info_exclude(&repo_path, is_dir) + }) + }) + .await + .expect("add to info/exclude request should complete") + .expect("add to info/exclude should succeed for remote linked worktree"); + } + + server_cx.run_until_parked(); + cx.run_until_parked(); + + assert_eq!( + fs.load(Path::new("/project/.git/info/exclude")) + .await + .expect("shared info/exclude should be readable"), + "existing\ntmp.txt\nlogs/\n" + ); + assert!( + fs.metadata(&repository_dir.join("info/exclude")) + .await + .expect("worktree-specific info/exclude metadata should be readable") + .is_none(), + "the action should not create a worktree-specific info/exclude" + ); +} + #[gpui::test] async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let text_2 = " @@ -4390,6 +4749,242 @@ async fn test_remote_apply_code_action_skips_unadvertised_command( assert_eq!(transaction.0.len(), 0); } +#[gpui::test] +async fn test_remote_lsp_show_document(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "fn one() -> usize { 1 }", + "other.rs": "fn two() -> usize { 2 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..FakeLspAdapter::default() + }, + ) + }); + + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities::default(), + None, + ) + }); + + cx.run_until_parked(); + + let worktree_id = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap() + .0 + .read_with(cx, |worktree, _| worktree.id()); + + cx.run_until_parked(); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let fake_lsp = fake_lsp.next().await.unwrap(); + + let shown_uri = lsp::Uri::from_file_path(path!("/code/project1/src/other.rs")).unwrap(); + let shown_selection = lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)); + let handled_requests = Arc::new(AtomicUsize::new(0)); + cx.update({ + let project = project.clone(); + let shown_uri = shown_uri.clone(); + let handled_requests = handled_requests.clone(); + move |cx| { + cx.subscribe(&project, move |_, event, _| { + if let project::Event::LanguageServerShowDocument(request) = event { + assert_eq!(request.uri, shown_uri); + assert_eq!(request.selection, Some(shown_selection)); + assert_eq!((request.external, request.take_focus), (false, true)); + handled_requests.fetch_add(1, Ordering::Release); + request.clone().respond(true); + } + }) + .detach(); + } + }); + + let response = fake_lsp + .request::( + lsp::ShowDocumentParams { + uri: shown_uri, + external: None, + take_focus: Some(true), + selection: Some(shown_selection), + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + assert_eq!(handled_requests.load(Ordering::Acquire), 1); +} + +#[gpui::test] +async fn test_remote_execute_lsp_command(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..FakeLspAdapter::default() + }, + ) + }); + + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["the-command".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + Some(Box::new(|fake| { + fake.set_request_handler::( + |params, _| async move { + assert_eq!(params.command, "the-command"); + assert_eq!(params.arguments, vec![json!("foo"), json!(42)]); + Ok(Some(json!({"answer": 3}))) + }, + ); + })), + ) + }); + + cx.run_until_parked(); + + let worktree_id = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap() + .0 + .read_with(cx, |worktree, _| worktree.id()); + + cx.run_until_parked(); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let _fake_lsp = fake_lsp.next().await.unwrap(); + + let server_id = cx.read(|cx| { + project + .read(cx) + .language_server_statuses(cx) + .next() + .expect("a language server should be running") + .0 + }); + + let result = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + server_id, + "the-command".to_string(), + vec![json!("foo"), json!(42)], + cx, + ) + }) + }) + .await + .expect("executing an advertised command should succeed"); + assert_eq!(result, Some(json!({"answer": 3}))); + + let unadvertised = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + server_id, + "unadvertised-command".to_string(), + Vec::new(), + cx, + ) + }) + }) + .await; + assert_eq!( + unadvertised + .err() + .map(|error| format!("{error:#}").contains("not advertised")), + Some(true), + "unadvertised commands should be rejected" + ); +} + #[gpui::test] async fn test_remote_restore_unstaged_hunk_clears_diff( cx: &mut TestAppContext, @@ -4692,6 +5287,77 @@ async fn test_remote_project_creation_notifies_new_entity_observers( assert!(project.read_with(cx, |project, _| project.is_remote())); } +#[gpui::test] +async fn test_log_store_keys_remote_events_by_primary_kind_on_supplementary_id_collision( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let server_fs = Arc::new(FakeFs::new(server_cx.executor())); + server_fs + .insert_tree(path!("/code"), json!({ "project1": { "README.md": "" } })) + .await; + let (project, _headless) = init_test(&server_fs, cx, server_cx).await; + + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |log_store, cx| log_store.add_project(&project, cx)); + + // A supplementary server (e.g. Copilot) allocates its ID from the local + // registry, which may collide numerically with a host-side server ID. + let server_id = LanguageServerId(42); + let supplementary_server_key = LanguageServerLogKey::new( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + server_id, + ); + log_store.update(cx, |log_store, cx| { + log_store.add_language_server( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + server_id, + Some(LanguageServerName::new_static("copilot")), + None, + None, + cx, + ); + }); + + project.update(cx, |_, cx| { + cx.emit(project::Event::LanguageServerLog( + server_id, + LanguageServerLogType::Log(lsp::MessageType::LOG), + "host server log".to_string(), + )); + }); + cx.run_until_parked(); + + let remote_server_key = LanguageServerLogKey::new( + LanguageServerKind::Remote { + project: project.downgrade(), + }, + server_id, + ); + log_store.read_with(cx, |log_store, _| { + assert_eq!( + log_store.server_logs(&remote_server_key).map(|logs| { + logs.iter() + .map(|log| log.as_ref().to_string()) + .collect::>() + }), + Some(vec!["host server log".to_string()]), + "host server logs should be keyed by the remote server kind" + ); + assert_eq!( + log_store + .server_logs(&supplementary_server_key) + .map(|logs| logs.len()), + Some(0), + "host server logs should not leak into the supplementary server with the same ID" + ); + }); +} + pub async fn init_test( server_fs: &Arc, cx: &mut TestAppContext, diff --git a/crates/remote_server/src/server.rs b/crates/remote_server/src/server.rs index bd5bda5c26..6a4bf3d4d1 100644 --- a/crates/remote_server/src/server.rs +++ b/crates/remote_server/src/server.rs @@ -677,7 +677,7 @@ pub fn execute_run( json_schema_store::init(cx); let project = cx.new(|cx| { - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); let node_settings_rx = initialize_settings(session.clone(), fs.clone(), cx); let proxy_url = read_proxy_settings(cx); diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index f5fa70f3e2..f874126b9c 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -47,13 +47,13 @@ pub struct TerminalOutput { /// Returns the default text style for the terminal output. pub fn text_style(window: &mut Window, cx: &App) -> TextStyle { - let settings = ThemeSettings::get_global(cx).clone(); + let settings = ThemeSettings::get_global(cx); let font_size = settings.buffer_font_size(cx).into(); - let font_family = settings.buffer_font.family; - let font_features = settings.buffer_font.features; + let font_family = settings.buffer_font.family.clone(); + let font_features = settings.buffer_font.features.clone(); let font_weight = settings.buffer_font.weight; - let font_fallbacks = settings.buffer_font.fallbacks; + let font_fallbacks = settings.buffer_font.fallbacks.clone(); let theme = cx.theme(); diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index 61bed513a1..c9512b220c 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -839,7 +839,7 @@ mod tests { editor::init(cx); // Initialize the ReplStore with a fake filesystem - let fs = Arc::new(project::RealFs::new(None, cx.background_executor().clone())); + let fs = project::RealFs::new(None, cx.background_executor().clone()); ReplStore::init(fs, cx); // Add mock kernel specifications for TypeScript and Python diff --git a/crates/reqwest_client/Cargo.toml b/crates/reqwest_client/Cargo.toml index ae1150051a..066c1b0ef5 100644 --- a/crates/reqwest_client/Cargo.toml +++ b/crates/reqwest_client/Cargo.toml @@ -13,7 +13,7 @@ test-support = [] [lib] path = "src/reqwest_client.rs" -doctest = true +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/rope/Cargo.toml b/crates/rope/Cargo.toml index 76515d6b9d..41fb3b87fd 100644 --- a/crates/rope/Cargo.toml +++ b/crates/rope/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/rope.rs" +doctest = false [dependencies] heapless.workspace = true diff --git a/crates/rpc/src/message_stream.rs b/crates/rpc/src/message_stream.rs index 34888d9814..b1072216b9 100644 --- a/crates/rpc/src/message_stream.rs +++ b/crates/rpc/src/message_stream.rs @@ -4,7 +4,6 @@ pub use ::proto::*; use async_tungstenite::tungstenite::Message as WebSocketMessage; use futures::{SinkExt as _, StreamExt as _}; -use proto::Message as _; use std::time::Instant; use std::{fmt::Debug, io}; @@ -47,9 +46,9 @@ where match message { Message::Envelope(message) => { - self.encoding_buffer.reserve(message.encoded_len()); + self.encoding_buffer.reserve(message.encoded_size()); message - .encode(&mut self.encoding_buffer) + .encode_to_buffer(&mut self.encoding_buffer) .map_err(io::Error::from)?; let buffer = zstd::stream::encode_all(self.encoding_buffer.as_slice(), COMPRESSION_LEVEL) @@ -90,7 +89,7 @@ where zstd::zstd_safe::WriteBuf::as_slice(&*bytes), &mut self.encoding_buffer, )?; - let envelope = Envelope::decode(self.encoding_buffer.as_slice()) + let envelope = Envelope::decode_from_slice(self.encoding_buffer.as_slice()) .map_err(io::Error::from)?; self.encoding_buffer.clear(); diff --git a/crates/rpc/src/peer.rs b/crates/rpc/src/peer.rs index 4fac9d84ca..2ab50ae0e3 100644 --- a/crates/rpc/src/peer.rs +++ b/crates/rpc/src/peer.rs @@ -253,125 +253,8 @@ impl Peer { } }; - let response_channels = connection_state.response_channels.clone(); - let stream_response_channels = connection_state.stream_response_channels.clone(); - self.connections - .write() - .insert(connection_id, connection_state); - - let incoming_rx = incoming_rx.filter_map(move |(incoming, received_at)| { - let response_channels = response_channels.clone(); - let stream_response_channels = stream_response_channels.clone(); - async move { - let message_id = incoming.id; - tracing::trace!(?incoming, "incoming message future: start"); - let _end = util::defer(move || { - tracing::trace!(%connection_id, message_id, "incoming message future: end"); - }); - - if let Some(responding_to) = incoming.responding_to { - tracing::trace!( - %connection_id, - message_id, - responding_to, - "incoming response: received" - ); - let response_channel = - response_channels.lock().as_mut()?.remove(&responding_to); - let terminal_stream_response = matches!( - &incoming.payload, - Some(proto::envelope::Payload::Error(_)) - | Some(proto::envelope::Payload::EndStream(_)) - ); - let stream_response_channel = if terminal_stream_response { - stream_response_channels - .lock() - .as_mut()? - .remove(&responding_to) - } else { - stream_response_channels - .lock() - .as_ref()? - .get(&responding_to) - .cloned() - }; - - if let Some(tx) = response_channel { - let requester_resumed = oneshot::channel(); - if let Err(error) = tx.send((incoming, received_at, requester_resumed.0)) { - tracing::trace!( - %connection_id, - message_id, - responding_to = responding_to, - ?error, - "incoming response: request future dropped", - ); - } - - tracing::trace!( - %connection_id, - message_id, - responding_to, - "incoming response: waiting to resume requester" - ); - let _ = requester_resumed.1.await; - tracing::trace!( - %connection_id, - message_id, - responding_to, - "incoming response: requester resumed" - ); - } else if let Some(tx) = stream_response_channel { - let requester_resumed = oneshot::channel(); - if let Err(error) = tx.unbounded_send((Ok(incoming), requester_resumed.0)) { - tracing::debug!( - %connection_id, - message_id, - responding_to = responding_to, - ?error, - "incoming stream response: request future dropped", - ); - // The consumer has gone away, so drop the bookkeeping - // for this stream rather than letting it accumulate - // every subsequent message until a terminal frame. - if let Some(channels) = stream_response_channels.lock().as_mut() { - channels.remove(&responding_to); - } - } else { - let _ = requester_resumed.1.await; - } - } else { - let message_type = proto::build_typed_envelope( - connection_id.into(), - received_at, - incoming, - ) - .map(|p| p.payload_type_name()); - tracing::warn!( - %connection_id, - message_id, - responding_to, - message_type, - "incoming response: unknown request" - ); - } - - None - } else { - tracing::trace!(%connection_id, message_id, "incoming message: received"); - proto::build_typed_envelope(connection_id.into(), received_at, incoming) - .or_else(|| { - tracing::error!( - %connection_id, - message_id, - "unable to construct a typed envelope" - ); - None - }) - } - } - }); - (connection_id, handle_io, incoming_rx.boxed()) + let incoming_rx = self.register_connection(connection_id, connection_state, incoming_rx); + (connection_id, handle_io, incoming_rx) } #[cfg(any(test, feature = "test-support"))] @@ -704,6 +587,134 @@ impl Peer { Ok(()) } + #[inline(never)] + fn register_connection( + &self, + connection_id: ConnectionId, + connection_state: ConnectionState, + incoming_rx: mpsc::Receiver<(proto::Envelope, Instant)>, + ) -> BoxStream<'static, Box> { + let response_channels = connection_state.response_channels.clone(); + let stream_response_channels = connection_state.stream_response_channels.clone(); + self.connections + .write() + .insert(connection_id, connection_state); + + let incoming_rx = incoming_rx.filter_map(move |(incoming, received_at)| { + let response_channels = response_channels.clone(); + let stream_response_channels = stream_response_channels.clone(); + async move { + let message_id = incoming.id; + tracing::trace!(?incoming, "incoming message future: start"); + let _end = util::defer(move || { + tracing::trace!(%connection_id, message_id, "incoming message future: end"); + }); + + if let Some(responding_to) = incoming.responding_to { + tracing::trace!( + %connection_id, + message_id, + responding_to, + "incoming response: received" + ); + let response_channel = + response_channels.lock().as_mut()?.remove(&responding_to); + let terminal_stream_response = matches!( + &incoming.payload, + Some(proto::envelope::Payload::Error(_)) + | Some(proto::envelope::Payload::EndStream(_)) + ); + let stream_response_channel = if terminal_stream_response { + stream_response_channels + .lock() + .as_mut()? + .remove(&responding_to) + } else { + stream_response_channels + .lock() + .as_ref()? + .get(&responding_to) + .cloned() + }; + + if let Some(tx) = response_channel { + let requester_resumed = oneshot::channel(); + if let Err(error) = tx.send((incoming, received_at, requester_resumed.0)) { + tracing::trace!( + %connection_id, + message_id, + responding_to = responding_to, + ?error, + "incoming response: request future dropped", + ); + } + + tracing::trace!( + %connection_id, + message_id, + responding_to, + "incoming response: waiting to resume requester" + ); + let _ = requester_resumed.1.await; + tracing::trace!( + %connection_id, + message_id, + responding_to, + "incoming response: requester resumed" + ); + } else if let Some(tx) = stream_response_channel { + let requester_resumed = oneshot::channel(); + if let Err(error) = tx.unbounded_send((Ok(incoming), requester_resumed.0)) { + tracing::debug!( + %connection_id, + message_id, + responding_to = responding_to, + ?error, + "incoming stream response: request future dropped", + ); + // The consumer has gone away, so drop the bookkeeping + // for this stream rather than letting it accumulate + // every subsequent message until a terminal frame. + if let Some(channels) = stream_response_channels.lock().as_mut() { + channels.remove(&responding_to); + } + } else { + let _ = requester_resumed.1.await; + } + } else { + let message_type = proto::build_typed_envelope( + connection_id.into(), + received_at, + incoming, + ) + .map(|p| p.payload_type_name()); + tracing::warn!( + %connection_id, + message_id, + responding_to, + message_type, + "incoming response: unknown request" + ); + } + + None + } else { + tracing::trace!(%connection_id, message_id, "incoming message: received"); + proto::build_typed_envelope(connection_id.into(), received_at, incoming) + .or_else(|| { + tracing::error!( + %connection_id, + message_id, + "unable to construct a typed envelope" + ); + None + }) + } + } + }); + incoming_rx.boxed() + } + fn connection_state(&self, connection_id: ConnectionId) -> Result { let connections = self.connections.read(); let connection = connections diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index 8644d554f3..0e1ee50bb5 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -339,11 +339,13 @@ impl AnyProtoClient { pub fn send_lsp_response( &self, project_id: u64, + peer_id: proto::PeerId, lsp_request_id: LspRequestId, server_responses: HashMap, ) -> Result<()> { self.send(proto::LspQueryResponse { project_id, + peer_id: Some(peer_id), lsp_request_id: lsp_request_id.0, responses: server_responses .into_iter() @@ -359,7 +361,7 @@ impl AnyProtoClient { let request_id = LspRequestId(envelope.payload.lsp_request_id); let mut response_senders = self.0.request_ids.lock(); if let Some(tx) = response_senders.remove(&request_id) { - let responses = envelope.payload.responses.drain(..).collect::>(); + let responses = std::mem::take(&mut envelope.payload.responses); tx.send(Ok(Some(proto::TypedEnvelope { sender_id: envelope.sender_id, original_sender_id: envelope.original_sender_id, diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml index a04f7b2bc2..672d0bfb0b 100644 --- a/crates/sandbox/Cargo.toml +++ b/crates/sandbox/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/sandbox.rs" +doctest = false [features] # Builds `bwrap_test_helper`, the behavior helper driven by the NixOS sandbox diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs index abac281d9a..b0a137697c 100644 --- a/crates/sandbox/src/linux_bubblewrap.rs +++ b/crates/sandbox/src/linux_bubblewrap.rs @@ -26,7 +26,7 @@ use anyhow::{Context as _, Result, anyhow, bail}; use std::ffi::{OsStr, OsString}; use std::io::{Read, Write}; use std::net::{Ipv4Addr, Shutdown, TcpListener, TcpStream}; -use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd}; +use std::os::fd::{AsFd as _, AsRawFd as _, BorrowedFd, FromRawFd as _, OwnedFd, RawFd}; use std::os::unix::fs::MetadataExt as _; use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::process::{CommandExt as _, ExitStatusExt as _}; @@ -848,7 +848,7 @@ fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> { ); } for (fd, path) in fds.iter().zip(paths) { - let expected = fd_dev_ino(fd.as_raw_fd()) + let expected = fd_dev_ino(fd.as_fd()) .with_context(|| format!("fstat of captured descriptor for {}", path.display()))?; let mounted = lstat_dev_ino(path) .with_context(|| format!("lstat of mounted bind {}", path.display()))?; @@ -1121,7 +1121,7 @@ fn recv_fds(stream: &UnixStream) -> std::io::Result> { } /// `(device, inode)` of the object an already-open descriptor refers to. -fn fd_dev_ino(fd: RawFd) -> std::io::Result<(u64, u64)> { +fn fd_dev_ino(fd: BorrowedFd<'_>) -> std::io::Result<(u64, u64)> { let stat = nix::sys::stat::fstat(fd).map_err(std::io::Error::from)?; Ok((stat.st_dev as u64, stat.st_ino as u64)) } diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs index 866e16a104..9b19321dba 100644 --- a/crates/sandbox/src/sandbox.rs +++ b/crates/sandbox/src/sandbox.rs @@ -246,7 +246,7 @@ fn linux_location_is_equal_or_descendant( location: &HostFilesystemLocation, ancestor: &HostFilesystemLocation, ) -> bool { - use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; + use std::os::fd::{AsFd as _, AsRawFd as _, FromRawFd as _, OwnedFd}; if location == ancestor { return true; @@ -270,15 +270,15 @@ fn linux_location_is_equal_or_descendant( OwnedFd::from_raw_fd(fd) }; - let Some(parent_identity) = linux_fd_identity(parent.as_raw_fd()) else { + let Some(parent_identity) = linux_fd_identity(parent.as_fd()) else { log::warn!("failed to fstat parent fd while checking protected path overlap"); return false; }; - let Some(current_identity) = linux_fd_identity(current.as_raw_fd()) else { + let Some(current_identity) = linux_fd_identity(current.as_fd()) else { log::warn!("failed to fstat current fd while checking protected path overlap"); return false; }; - let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_raw_fd()) else { + let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_fd()) else { log::warn!("failed to fstat protected fd while checking protected path overlap"); return false; }; diff --git a/crates/sandbox/src/util/canonical_path.rs b/crates/sandbox/src/util/canonical_path.rs index 2b820bae16..3c8c88dd50 100644 --- a/crates/sandbox/src/util/canonical_path.rs +++ b/crates/sandbox/src/util/canonical_path.rs @@ -145,7 +145,7 @@ impl CanonicalPathBuf { // and `readlink` of an `O_PATH|O_NOFOLLOW` fd on a symlink returns // the symlink's *own* path (equal to `path`), so the comparison // below wouldn't catch it. - let stat = nix::sys::stat::fstat(fd.as_raw_fd()).map_err(io::Error::from)?; + let stat = nix::sys::stat::fstat(&fd).map_err(io::Error::from)?; if stat.st_mode & libc::S_IFMT == libc::S_IFLNK { return Err(io::Error::new( io::ErrorKind::PermissionDenied, @@ -266,8 +266,8 @@ impl PartialEq for CanonicalPathBuf { #[cfg(target_os = "linux")] { match ( - linux_fd_identity(self.fd.as_raw_fd()), - linux_fd_identity(other.fd.as_raw_fd()), + linux_fd_identity(self.fd.as_fd()), + linux_fd_identity(other.fd.as_fd()), ) { (Some(a), Some(b)) => a == b, // An `fstat` on an `O_PATH` fd we own should never fail; if it @@ -296,7 +296,7 @@ impl Eq for CanonicalPathBuf {} /// whether two [`CanonicalPathBuf`]s (or their parents) refer to the same /// filesystem object. #[cfg(target_os = "linux")] -pub(crate) fn linux_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> { +pub(crate) fn linux_fd_identity(fd: BorrowedFd<'_>) -> Option<(u64, u64)> { let stat = nix::sys::stat::fstat(fd).ok()?; Some((stat.st_dev as u64, stat.st_ino as u64)) } diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index bacf9fbda9..ad2ea929dc 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -65,11 +65,11 @@ impl LocalExecutor { F: Future + 'static, F::Output: 'static, { - let dispatch = self.dispatch.clone(); + let schedule = self.schedule(); let location = Location::caller(); let (runnable, task) = spawn_local_with_source_location( future, - move |runnable| dispatch(runnable), + schedule, RunnableMeta { location, spawned: crate::SpawnTime(Instant::now()), @@ -181,6 +181,11 @@ impl LocalExecutor { .spawn_dedicated(box_dedicated(f)) .downcast::() } + + fn schedule(&self) -> impl Fn(Runnable) + Send + Sync + 'static { + let dispatch = self.dispatch.clone(); + move |runnable| dispatch(runnable) + } } /// Boxes the user-supplied dedicated closure into the type-erased shape @@ -229,21 +234,14 @@ impl BackgroundExecutor { F: Future + Send + 'static, F::Output: Send + 'static, { - let scheduler = Arc::downgrade(&self.scheduler); + let schedule = self.schedule_with_priority(priority); let location = Location::caller(); let (runnable, task) = async_task::Builder::new() .metadata(RunnableMeta { location, spawned: crate::SpawnTime(Instant::now()), }) - .spawn( - move |_| future, - move |runnable| { - if let Some(scheduler) = scheduler.upgrade() { - scheduler.schedule_background_with_priority(runnable, priority); - } - }, - ); + .spawn(move |_| future, schedule); runnable.schedule(); Task(TaskState::Spawned(task)) } @@ -312,6 +310,18 @@ impl BackgroundExecutor { .spawn_dedicated(box_dedicated(f)) .downcast::() } + + fn schedule_with_priority( + &self, + priority: Priority, + ) -> impl Fn(Runnable) + Send + Sync + 'static { + let scheduler = Arc::downgrade(&self.scheduler); + move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_background_with_priority(runnable, priority); + } + } + } } /// A long-lived handle to one dedicated session: every future spawned on it diff --git a/crates/settings/src/content_into_gpui.rs b/crates/settings/src/content_into_gpui.rs index fc38b2129a..4c5cba6148 100644 --- a/crates/settings/src/content_into_gpui.rs +++ b/crates/settings/src/content_into_gpui.rs @@ -50,6 +50,8 @@ impl IntoGpui for WindowBackgroundContent { WindowBackgroundContent::Opaque => WindowBackgroundAppearance::Opaque, WindowBackgroundContent::Transparent => WindowBackgroundAppearance::Transparent, WindowBackgroundContent::Blurred => WindowBackgroundAppearance::Blurred, + WindowBackgroundContent::MicaBackdrop => WindowBackgroundAppearance::MicaBackdrop, + WindowBackgroundContent::MicaAltBackdrop => WindowBackgroundAppearance::MicaAltBackdrop, } } } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 6590d6be71..0c0b00652f 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2673,6 +2673,51 @@ mod tests { .unindent(), cx, ); + + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ + "window.title": "${activeEditorShort}${separator}${rootName}${separator}${appName}", + "window.titleSeparator": " - " + }"# + .unindent(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "window_title_separator": " - ", + "window_title_format": "${fileName}${separator}${projectName}${separator}${appName}" + } + "# + .unindent(), + cx, + ); + + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ + "window.title": "${unsupportedVariable}" + }"# + .unindent(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } + } + "# + .unindent(), + cx, + ); } #[track_caller] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index f287e9d817..dee446905b 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -99,6 +99,15 @@ impl VsCodeSettings { .map(|s| s.to_owned()) } + fn read_window_title_format(&self) -> Option { + self.read_string("window.title") + .map(|template| translate_vscode_window_title_format(&template)) + // If every placeholder is dropped during translation, keep Zed's + // default title behavior instead of importing a template that + // renders as nothing but whitespace. + .filter(|template| !template.trim().is_empty()) + } + fn read_bool(&self, setting: &str) -> Option { self.read_value(setting).and_then(|v| v.as_bool()) } @@ -180,6 +189,11 @@ impl VsCodeSettings { base_keymap: Some(BaseKeymapContent::VSCode), calls: None, collaboration_panel: None, + command_palette: self + .read_u64("workbench.commandPalette.history") + .map(|history| CommandPaletteSettingsContent { + use_command_history: Some(history > 0), + }), credentials_url: None, debugger: None, diagnostics: None, @@ -260,6 +274,7 @@ impl VsCodeSettings { "underline" | "underline-thin" => Some(CursorShape::Underline), _ => None, }), + cursor_animation: None, current_line_highlight: self.read_enum("editor.renderLineHighlight", |s| match s { "gutter" => Some(CurrentLineHighlight::Gutter), "line" => Some(CurrentLineHighlight::Line), @@ -663,19 +678,22 @@ impl VsCodeSettings { } fn edit_predictions_settings_content(&self) -> Option { - let disabled_globs = self + let mut disabled_globs = self .read_value("cursor.general.globalCursorIgnoreList")? - .as_array()?; + .as_array()? + .iter() + .filter_map(Value::as_str) + .filter(|glob| !glob.is_empty() && *glob != SplicingVec::REST) + .map(str::to_owned) + .collect::>(); + if disabled_globs.is_empty() { + return None; + } + disabled_globs.push(SplicingVec::REST.to_owned()); - skip_default(EditPredictionSettingsContent { - disabled_globs: skip_default( - disabled_globs - .iter() - .filter_map(|glob| glob.as_str()) - .map(|s| s.to_string()) - .collect(), - ), - ..Default::default() + Some(EditPredictionSettingsContent { + disabled_globs: Some(SplicingVec::from(disabled_globs)), + ..EditPredictionSettingsContent::default() }) } @@ -817,6 +835,7 @@ impl VsCodeSettings { cursor_position_button: None, line_endings_button: None, active_encoding_button: None, + pending_keystrokes_indicator: None, }) } @@ -826,6 +845,7 @@ impl VsCodeSettings { auto_reveal_entries: self.read_bool("explorer.autoReveal"), bold_folder_labels: None, button: None, + title_tooltip_delay: None, default_width: None, dock: None, drag_and_drop: None, @@ -1010,10 +1030,6 @@ impl VsCodeSettings { agent_buffer_font_family: None, agent_buffer_font_size: None, git_commit_buffer_font_size: None, - markdown_preview_font_family: None, - markdown_preview_code_font_family: None, - markdown_preview_font_size: None, - markdown_preview_theme: None, theme: None, icon_theme: None, ui_density: None, @@ -1063,6 +1079,7 @@ impl VsCodeSettings { } else { None }, + on_new_window: None, on_last_window_closed: None, pane_split_direction_horizontal: None, pane_split_direction_vertical: None, @@ -1082,6 +1099,8 @@ impl VsCodeSettings { FullscreenMode::Simple } }), + window_title_format: self.read_window_title_format(), + window_title_separator: self.read_string("window.titleSeparator"), when_closing_with_no_tabs: self.read_bool("window.closeWhenEmpty").map(|b| { if b { CloseWindowWhenNoItems::CloseWindow @@ -1111,45 +1130,93 @@ impl VsCodeSettings { WorktreeSettingsContent { prevent_sharing_in_public_channels: false, file_scan_depth: None, - file_scan_exclusions: self - .read_value("files.watcherExclude") - .and_then(|v| v.as_array()) - .map(|v| { - v.iter() - .filter_map(|n| n.as_str().map(str::to_owned)) - .collect::>() - }) - .filter(|r| !r.is_empty()) - .map(SplicingVec::from), - file_scan_inclusions: self - .read_value("files.watcherInclude") - .and_then(|v| v.as_array()) - .map(|v| { - v.iter() - .filter_map(|n| n.as_str().map(str::to_owned)) - .collect::>() - }) - .filter(|r| !r.is_empty()), + file_scan_exclusions: Self::enabled_patterns(self.read_value("files.exclude")), + // `files.watcherInclude` adds watch roots, not Git-ignore overrides + file_scan_inclusions: None, scan_symlinks: None, private_files: None, hidden_files: None, - read_only_files: self - .read_value("files.readonlyExclude") - .and_then(|v| v.as_object()) - .map(|v| { - v.iter() - .filter_map(|(k, v)| { - if v.as_bool().unwrap_or(false) { - Some(k.to_owned()) - } else { - None - } + // Zed cannot represent the writable exceptions in `files.readonlyExclude` + read_only_files: Self::enabled_patterns( + self.read_value("files.readonlyInclude").filter(|_| { + !self + .read_value("files.readonlyExclude") + .and_then(Value::as_object) + .is_some_and(|patterns| { + patterns + .values() + .any(|enabled| enabled.as_bool() == Some(true)) }) - .collect::>() - }) - .filter(|r| !r.is_empty()), + }), + ), } } + + fn enabled_patterns(value: Option<&Value>) -> Option { + value + .and_then(Value::as_object) + .map(|patterns| { + patterns + .iter() + .filter(|(pattern, enabled)| { + // Zed reserves `...` for inheritance, not a literal path + !pattern.is_empty() + && pattern.as_str() != SplicingVec::REST + && enabled.as_bool() == Some(true) + }) + .map(|(pattern, _)| pattern.to_owned()) + .collect::>() + }) + .filter(|patterns| !patterns.is_empty()) + .map(|mut patterns| { + patterns.push(SplicingVec::REST.to_owned()); + SplicingVec::from(patterns) + }) + } +} + +fn translate_vscode_window_title_format(template: &str) -> String { + let mut translated = String::new(); + let mut start = 0; + + // Workspace owns the runtime template parser, so the VS Code importer keeps + // its own small placeholder scan here instead of depending on that crate. + while let Some(offset) = template[start..].find("${") { + let variable_start = start + offset; + translated.push_str(&template[start..variable_start]); + + let content_start = variable_start + 2; + let Some(content_end_offset) = template[content_start..].find('}') else { + translated.push_str(&template[variable_start..]); + return translated; + }; + + let content_end = content_start + content_end_offset; + let variable = &template[content_start..content_end]; + match variable { + "projectName" | "fileName" | "filePath" | "relativePath" | "fileStem" + | "remoteHost" | "appName" | "branch" | "separator" => { + translated.push_str(&template[variable_start..=content_end]); + } + // Keep VS Code alias support in the importer so native Zed settings + // only expose the documented placeholder names. + "rootName" => translated.push_str("${projectName}"), + "activeEditorShort" => translated.push_str("${fileName}"), + "activeEditorMedium" => translated.push_str("${relativePath}"), + "activeEditorLong" => translated.push_str("${filePath}"), + "activeRepositoryBranchName" => translated.push_str("${branch}"), + // VS Code's `${remoteName}` is a provider label such as `SSH`, while + // Zed's `${remoteName}` resolves to the connection's name or host, + // so the token is dropped rather than imported with mismatched + // semantics. + _ => {} + } + + start = content_end + 1; + } + + translated.push_str(&template[start..]); + translated } fn skip_default(value: T) -> Option { @@ -1163,6 +1230,7 @@ fn skip_default(value: T) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::settings_content::merge_from::MergeFrom; fn imported_reduce_motion(content: &str) -> Option { VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode) @@ -1171,6 +1239,282 @@ mod tests { .reduce_motion } + #[test] + fn test_import_disabled_globs_extends_inherited_patterns() -> Result<()> { + for (ignore_list, expected_imported, expected_merged) in [ + ( + serde_json::json!(["**/build/**", "**/cache/**"]), + serde_json::json!(["**/build/**", "**/cache/**", "..."]), + serde_json::json!(["**/build/**", "**/cache/**", "**/inherited/**"]), + ), + ( + serde_json::json!(["**/build/**", "", false, null, 1, {}, []]), + serde_json::json!(["**/build/**", "..."]), + serde_json::json!(["**/build/**", "**/inherited/**"]), + ), + ( + serde_json::json!(["...", "**/build/**", false]), + serde_json::json!(["**/build/**", "..."]), + serde_json::json!(["**/build/**", "**/inherited/**"]), + ), + ( + serde_json::json!(["**/inherited/**", "**/build/**"]), + serde_json::json!(["**/inherited/**", "**/build/**", "..."]), + serde_json::json!(["**/inherited/**", "**/build/**"]), + ), + ] { + let content = serde_json::json!({ + "cursor.general.globalCursorIgnoreList": ignore_list, + }); + let imported = + VsCodeSettings::from_str(&content.to_string(), VsCodeSettingsSource::Cursor)? + .settings_content(); + let imported = imported + .project + .all_languages + .edit_predictions + .context("imported edit prediction settings")?; + assert_eq!( + serde_json::to_value(&imported.disabled_globs)?, + expected_imported + ); + + let mut inherited = EditPredictionSettingsContent { + disabled_globs: Some(SplicingVec::from(vec!["**/inherited/**".to_string()])), + ..Default::default() + }; + inherited.merge_from(&imported); + assert_eq!( + serde_json::to_value(&inherited.disabled_globs)?, + expected_merged + ); + } + Ok(()) + } + + #[test] + fn test_import_disabled_globs_omits_empty_results() -> Result<()> { + let inherited = AllLanguageSettingsContent { + edit_predictions: Some(EditPredictionSettingsContent { + disabled_globs: Some(SplicingVec::from(vec!["**/inherited/**".to_string()])), + ..Default::default() + }), + ..Default::default() + }; + for content in [ + r#"{"cursor.general.globalCursorIgnoreList": "**/build/**"}"#, + r#"{"cursor.general.globalCursorIgnoreList": ["..."]}"#, + r#"{"cursor.general.globalCursorIgnoreList": [""]}"#, + r#"{"cursor.general.globalCursorIgnoreList": ["...", "", false, null]}"#, + r#"{"cursor.general.globalCursorIgnoreList": []}"#, + r#"{"cursor.general.globalCursorIgnoreList": [false, null, 1, {}, []]}"#, + r#"{"cursor.general.globalCursorIgnoreList": null}"#, + r#"{}"#, + ] { + let imported = + VsCodeSettings::from_str(content, VsCodeSettingsSource::Cursor)?.settings_content(); + assert_eq!(imported.project.all_languages.edit_predictions, None); + let mut unchanged = inherited.clone(); + unchanged.merge_from(&imported.project.all_languages); + assert_eq!(unchanged.edit_predictions, inherited.edit_predictions); + } + Ok(()) + } + + #[test] + fn test_import_file_exclusions() -> Result<()> { + let imported = VsCodeSettings::from_str( + r#"{ + "files.exclude": { + "": true, + "**/array/**": [], + "**/build/**": true, + "**/cache/**": false, + "**/null/**": null, + "**/number/**": 1, + "**/object/**": {"enabled": true}, + "**/string/**": "true", + "**/target/**": true, + "**/*.js": {"when": "$(basename).ts"}, + "...": true + } + }"#, + VsCodeSettingsSource::VsCode, + )? + .settings_content(); + assert_eq!( + serde_json::to_value(&imported.project.worktree.file_scan_exclusions)?, + serde_json::json!(["**/build/**", "**/target/**", "..."]) + ); + + let mut inherited = WorktreeSettingsContent { + file_scan_exclusions: Some(SplicingVec::from(vec!["**/inherited/**".to_string()])), + ..Default::default() + }; + inherited.merge_from(&imported.project.worktree); + assert_eq!( + serde_json::to_value(&inherited.file_scan_exclusions)?, + serde_json::json!(["**/build/**", "**/target/**", "**/inherited/**"]) + ); + Ok(()) + } + + #[test] + fn test_import_file_exclusions_without_usable_patterns() -> Result<()> { + let inherited = WorktreeSettingsContent { + file_scan_exclusions: Some(SplicingVec::from(vec!["**/inherited/**".to_string()])), + ..Default::default() + }; + for content in [ + r#"{"files.exclude": "**/cache/**"}"#, + r#"{"files.exclude": 1}"#, + r#"{"files.exclude": ["**/cache/**"]}"#, + r#"{"files.exclude": []}"#, + r#"{"files.exclude": null}"#, + r#"{"files.exclude": true}"#, + r#"{"files.exclude": {"": true}}"#, + r#"{"files.exclude": {"**/cache/**": "true"}}"#, + r#"{"files.exclude": {"**/cache/**": false}}"#, + r#"{"files.exclude": {"**/*.js": {"when": "$(basename).ts"}}}"#, + r#"{"files.exclude": {"...": true}}"#, + r#"{"files.exclude": {}}"#, + r#"{"files.watcherExclude": {"**/cache/**": true}}"#, + r#"{}"#, + ] { + let imported = + VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode)?.settings_content(); + assert_eq!( + imported.project.worktree.file_scan_exclusions, None, + "{content}" + ); + let mut unchanged = inherited.clone(); + unchanged.merge_from(&imported.project.worktree); + assert_eq!( + unchanged.file_scan_exclusions, inherited.file_scan_exclusions, + "{content}" + ); + } + Ok(()) + } + + #[test] + fn test_import_watcher_include_preserves_file_scan_inclusions() -> Result<()> { + let inherited = WorktreeSettingsContent { + file_scan_inclusions: Some(SplicingVec::from(vec![ + ".env*".to_string(), + "**/*.local".to_string(), + ])), + ..Default::default() + }; + for content in [ + r#"{}"#, + r#"{"files.watcherInclude": []}"#, + r#"{"files.watcherInclude": ["linked-folder"]}"#, + r#"{"files.watcherInclude": ["..."]}"#, + r#"{"files.watcherInclude": ["linked-folder", false]}"#, + r#"{"files.watcherInclude": {"linked-folder": true}}"#, + ] { + let mut content: Value = serde_json::from_str(content)?; + content["editor.tabSize"] = serde_json::json!(8); + let imported = VsCodeSettings::from_str( + &serde_json::to_string(&content)?, + VsCodeSettingsSource::VsCode, + )? + .settings_content(); + assert_eq!(imported.project.worktree.file_scan_inclusions, None); + assert_eq!( + imported.project.all_languages.defaults.tab_size, + NonZeroU32::new(8) + ); + + let mut unchanged = inherited.clone(); + unchanged.merge_from(&imported.project.worktree); + assert_eq!( + unchanged.file_scan_inclusions, + inherited.file_scan_inclusions + ); + } + Ok(()) + } + + #[test] + fn test_import_read_only_files() -> Result<()> { + let inherited = WorktreeSettingsContent { + read_only_files: Some(SplicingVec::from(vec!["**/*.lock".to_string()])), + ..Default::default() + }; + let imported = VsCodeSettings::from_str( + r#"{ + "files.readonlyInclude": { + "": true, + "**/*.gen.rs": true, + "**/*.lock": false, + "**/generated/**": true, + "...": true + }, + "files.readonlyExclude": {"**/editable.gen.rs": false} + }"#, + VsCodeSettingsSource::VsCode, + )? + .worktree_settings_content(); + assert_eq!( + serde_json::to_value(&imported.read_only_files)?, + serde_json::json!(["**/*.gen.rs", "**/generated/**", "..."]) + ); + let mut spliced = inherited.clone(); + spliced.merge_from(&imported); + assert_eq!( + serde_json::to_value(&spliced.read_only_files)?, + serde_json::json!(["**/*.gen.rs", "**/generated/**", "**/*.lock"]) + ); + + for content in [ + r#"{"files.readonlyExclude": {"**/*.gen.rs": true}}"#, + r#"{"files.readonlyInclude": {"**/*.gen.rs": false}}"#, + r#"{"files.readonlyInclude": {"": true}}"#, + r#"{"files.readonlyInclude": {"...": true}}"#, + r#"{"files.readonlyInclude": ["**/*.gen.rs"]}"#, + r#"{"files.readonlyInclude": {}}"#, + "{}", + ] { + let imported = VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode)? + .worktree_settings_content(); + assert_eq!(imported.read_only_files, None); + let mut unchanged = inherited.clone(); + unchanged.merge_from(&imported); + assert_eq!(unchanged.read_only_files, inherited.read_only_files); + } + Ok(()) + } + + #[test] + fn test_import_read_only_files_with_exclusions() -> Result<()> { + let imported = VsCodeSettings::from_str( + r#"{ + "files.readonlyExclude": {"**/*.lock": true, "**/editable.gen.rs": true}, + "files.readonlyInclude": {"**/*.gen.rs": true}, + "editor.tabSize": 8 + }"#, + VsCodeSettingsSource::VsCode, + )? + .settings_content(); + assert_eq!(imported.project.worktree.read_only_files, None); + assert_eq!( + imported.project.all_languages.defaults.tab_size, + NonZeroU32::new(8) + ); + let mut inherited = WorktreeSettingsContent { + read_only_files: Some(SplicingVec::from(vec![String::from("**/*.lock")])), + ..WorktreeSettingsContent::default() + }; + inherited.merge_from(&imported.project.worktree); + assert_eq!( + serde_json::to_value(&inherited.read_only_files)?, + serde_json::json!(["**/*.lock"]) + ); + Ok(()) + } + #[test] fn test_import_reduce_motion() { assert_eq!( @@ -1188,6 +1532,26 @@ mod tests { assert_eq!(imported_reduce_motion("{}"), None); } + #[test] + fn test_import_command_palette_history() { + for (content, expected) in [ + (r#"{ "workbench.commandPalette.history": 0 }"#, Some(false)), + (r#"{ "workbench.commandPalette.history": 1 }"#, Some(true)), + (r#"{ "workbench.commandPalette.history": 50 }"#, Some(true)), + ("{}", None), + ] { + let settings = VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode) + .unwrap() + .settings_content(); + assert_eq!( + settings + .command_palette + .and_then(|settings| settings.use_command_history), + expected, + ); + } + } + #[test] fn test_import_reveal_if_open() { let settings = VsCodeSettings::from_str( @@ -1199,4 +1563,51 @@ mod tests { assert_eq!(settings.workspace.reveal_if_open, Some(true)); } + + #[test] + fn test_import_window_title_format() { + let imported_title = |content: &str| { + VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode) + .unwrap() + .settings_content() + .workspace + .window_title_format + }; + + assert_eq!(imported_title("{}"), None); + + // VS Code variables are translated to their Zed equivalents. + assert_eq!( + imported_title( + r#"{ "window.title": "${rootName} ${activeRepositoryBranchName} ${activeEditorMedium}" }"#, + ), + Some("${projectName} ${branch} ${relativePath}".to_string()) + ); + + // Zed-native variables pass through unchanged. + assert_eq!( + imported_title(r#"{ "window.title": "${projectName}${separator}${filePath}" }"#), + Some("${projectName}${separator}${filePath}".to_string()) + ); + + // VS Code's `${remoteName}` is a provider label such as `SSH`, which + // doesn't match Zed's connection-name semantics, so it is dropped. + assert_eq!( + imported_title(r#"{ "window.title": "${remoteName}: ${rootName}" }"#), + Some(": ${projectName}".to_string()) + ); + + // Templates that translate to nothing but whitespace fall back to + // Zed's default title instead of overriding it. + assert_eq!( + imported_title(r#"{ "window.title": " ${activeFolderShort} " }"#), + None + ); + + // Literal text around unsupported variables is still imported. + assert_eq!( + imported_title(r#"{ "window.title": "${activeFolderShort} — literal" }"#), + Some(" — literal".to_string()) + ); + } } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 107c19b55b..d8c755e48c 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -36,6 +36,29 @@ pub enum SidebarDockPosition { Right, } +#[with_fallible_options] +#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] +pub struct ThreadsSidebarSettingsContent { + /// Whether opening a folder in an existing window automatically opens the + /// Threads Sidebar. Applies when `default_open_behavior` or + /// `cli_default_open_behavior` is set to `existing_window`. + /// + /// Default: true + pub auto_open: Option, + /// Where to position the threads sidebar. + /// + /// Default: left + pub position: Option, + /// Default width of the threads sidebar in pixels. + /// + /// Values range from 200 to 800, matching the widths the sidebar can be + /// dragged to. Values outside that range are clamped into it. + /// + /// Default: 300 + #[schemars(range(min = 200, max = 800))] + pub default_width: Option, +} + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub enum SidebarSide { #[default] @@ -206,15 +229,33 @@ pub struct AgentSettingsContent { /// /// Default: left (Agentic layout), right (Classic layout) pub dock: Option, - /// Whether the agent panel should use flexible (proportional) sizing. + /// Whether the agent panel should use flexible (proportional) sizing when docked to the + /// left or right. + /// + /// When enabled, `default_width` does not control the panel width, and resetting the panel + /// restores the default proportion. /// /// Default: true pub flexible: Option, - /// Where to position the threads sidebar. + /// The deprecated version of `threads_sidebar.position`. /// - /// Default: left + /// Don't use this field. + #[schemars(skip)] pub sidebar_side: Option, - /// Default width in pixels when the agent panel is docked to the left or right. + /// The deprecated version of `threads_sidebar.default_width`. + /// + /// Don't use this field. + #[schemars(skip)] + pub threads_sidebar_default_width: Option, + /// The deprecated version of `threads_sidebar.auto_open`. + /// + /// Don't use this field. + #[schemars(skip)] + pub threads_sidebar_auto_open: Option, + /// Settings for the threads sidebar. + pub threads_sidebar: Option, + /// Default fixed width in pixels when the agent panel is docked to the left or right and + /// `flexible` is false. /// /// Default: 640 pub default_width: Option, @@ -279,6 +320,10 @@ pub struct AgentSettingsContent { /// /// Default: never pub play_sound_when_agent_done: Option, + /// Whether to keep the system awake while agent threads are running. + /// + /// Default: true + pub prevent_idle_sleep: Option, /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane. /// /// Default: false @@ -364,8 +409,22 @@ impl AgentSettingsContent { self.dock = Some(dock); } - pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) { - self.sidebar_side = Some(position); + pub fn set_threads_sidebar_position(&mut self, position: Option) { + self.sidebar_side = None; + self.threads_sidebar.get_or_insert_default().position = position; + } + + pub fn set_threads_sidebar_default_width( + &mut self, + default_width: Option, + ) { + self.threads_sidebar_default_width = None; + self.threads_sidebar.get_or_insert_default().default_width = default_width; + } + + pub fn set_threads_sidebar_auto_open(&mut self, auto_open: Option) { + self.threads_sidebar_auto_open = None; + self.threads_sidebar.get_or_insert_default().auto_open = auto_open; } pub fn set_flexible_size(&mut self, flexible: bool) { @@ -640,6 +699,7 @@ impl JsonSchema for LanguageModelProviderSetting { "openrouter", "vercel_ai_gateway", "x_ai", + "x_ai_subscribed", "zed.dev" ] }, diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 3abd295278..23ff824a88 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -22,6 +22,8 @@ pub struct EditorSettingsContent { /// /// Default: bar pub cursor_shape: Option, + /// Cursor movement animation settings. + pub cursor_animation: Option, /// Determines how snippets are sorted relative to other completion items. /// /// Default: inline @@ -364,6 +366,15 @@ impl RelativeLineNumbers { } } +#[with_fallible_options] +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] +pub struct CursorAnimationSettingsContent { + /// Whether cursor movement animation is enabled. + /// + /// Default: false + pub enabled: Option, +} + // Toolbar related settings #[with_fallible_options] #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] diff --git a/crates/settings_content/src/fallible_options.rs b/crates/settings_content/src/fallible_options.rs index 91a8e183e0..731b83b464 100644 --- a/crates/settings_content/src/fallible_options.rs +++ b/crates/settings_content/src/fallible_options.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::{cell::RefCell, fmt::Display}; use serde::Deserialize; @@ -50,14 +50,22 @@ where { match T::deserialize(deserializer) { Ok(value) => Ok(value), - Err(e) => ERRORS.with_borrow_mut(|errors| { - if let Some(errors) = errors { - errors.push(anyhow::anyhow!("{}", e)); - Ok(Default::default()) - } else { - Err(e) + Err(error) => { + let mut error = Some(error); + let mut fallback = None; + recover_deserialization_error(&mut |record_error| { + if let Some(error) = error.take() { + record_error(&error); + let value = T::default(); + drop(error); + fallback = Some(value); + } + }); + match error { + Some(error) => Err(error), + None => Ok(fallback.expect("recovery creates a fallback")), } - }), + } } } @@ -146,6 +154,15 @@ where T::deserialize(rest) } +#[inline(never)] +fn recover_deserialization_error(recover: &mut dyn FnMut(&mut dyn FnMut(&dyn Display))) { + ERRORS.with_borrow_mut(|errors| { + if let Some(errors) = errors { + recover(&mut |error| errors.push(anyhow::anyhow!("{error}"))); + } + }); +} + #[cfg(test)] mod tests { use serde::Deserialize; diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index e7e9e3a47f..75ea59f83d 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -7,7 +7,8 @@ use settings_macros::{MergeFrom, with_fallible_options}; use std::sync::Arc; use crate::{ - DelayMs, DocumentFoldingRanges, DocumentSymbols, ExtendingSet, SemanticTokens, merge_from, + DelayMs, DocumentFoldingRanges, DocumentSymbols, ExtendingSet, SemanticTokens, SplicingVec, + merge_from, }; /// The state of the modifier keys at some point in time @@ -132,10 +133,29 @@ impl EditPredictionProvider { pub struct EditPredictionSettingsContent { /// Determines which edit prediction provider to use. pub provider: Option, - /// A list of globs representing files that edit predictions should be disabled for. - /// This list adds to a pre-existing, sensible default set of globs. - /// Any additional ones you add are combined with them. - pub disabled_globs: Option>, + /// Disable edit predictions for files matching these glob patterns. + /// + /// Use `"..."` to add patterns without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. + /// + /// ```json + /// { + /// "edit_predictions": { + /// "disabled_globs": ["**/build/**", "..."] + /// } + /// } + /// ``` + /// + /// Inherited patterns are inserted at `"..."`, and duplicates keep their first + /// occurrence. + /// + /// Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. + /// + /// Relative patterns are matched against paths relative to the worktree root. + /// Absolute patterns are matched against absolute paths. A leading `~` is + /// expanded to your home folder. + pub disabled_globs: Option, /// The mode used to display edit predictions in the buffer. /// Provider support required. pub mode: Option, @@ -636,13 +656,38 @@ pub struct LanguageSettingsContent { /// /// Default: true pub show_edit_predictions: Option, - /// Controls whether edit predictions are shown in the given language - /// scopes. + /// Disable edit predictions in these language scopes, such as "comment" and + /// "string". /// - /// Example: ["string", "comment"] + /// Default: /// - /// Default: [] - pub edit_predictions_disabled_in: Option>, + /// ```json + /// { + /// "edit_predictions_disabled_in": [] + /// } + /// ``` + /// + /// Use `"..."` to add scopes without repeating the inherited list. In project + /// settings, it extends the user or parent configuration value. In + /// language-specific settings, it extends the scopes inherited by that + /// language. Omit `"..."` to replace the inherited list. + /// + /// ```json + /// { + /// "edit_predictions_disabled_in": ["comment"], + /// "languages": { + /// "Go": { + /// "edit_predictions_disabled_in": ["string", "..."] + /// } + /// } + /// } + /// ``` + /// + /// Inherited scopes are inserted at `"..."`, and duplicates keep their first + /// occurrence. + /// + /// Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. + pub edit_predictions_disabled_in: Option, /// Whether to show tabs and spaces in the editor. pub show_whitespaces: Option, /// Visible characters used to render whitespace when show_whitespaces is enabled. diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 0335a26499..49bd242d5f 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -128,33 +128,68 @@ pub struct WorktreeSettingsContent { #[serde(default)] pub prevent_sharing_in_public_channels: bool, - /// Completely ignore files matching globs from `file_scan_exclusions`. Overrides - /// `file_scan_inclusions`. + /// Exclude files matching these glob patterns from file scans, file searches, + /// and the project file tree. Takes precedence over `file_scan_inclusions`. /// - /// A `"..."` entry expands to the value being overridden, so - /// `["**/node_modules", "..."]` adds to the inherited globs instead of - /// replacing them. Leave `"..."` out to replace them. + /// Default: /// - /// Default: [ - /// "**/.git", - /// "**/.svn", - /// "**/.hg", - /// "**/.jj", - /// "**/CVS", - /// "**/.DS_Store", - /// "**/Thumbs.db", - /// "**/.classpath", - /// "**/.settings" - /// ] + /// ```json + /// { + /// "file_scan_exclusions": [ + /// "**/.git", + /// "**/.svn", + /// "**/.hg", + /// "**/.jj", + /// "**/.sl", + /// "**/.repo", + /// "**/CVS", + /// "**/.DS_Store", + /// "**/Thumbs.db", + /// "**/.classpath", + /// "**/.settings" + /// ] + /// } + /// ``` + /// + /// Use `"..."` to add patterns without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. + /// + /// ```json + /// { + /// "file_scan_exclusions": ["**/node_modules", "..."] + /// } + /// ``` + /// + /// Inherited patterns are inserted at `"..."`, and duplicates keep their first + /// occurrence. pub file_scan_exclusions: Option, - /// Always include files that match these globs when scanning for files, even if they're - /// ignored by git. This setting is overridden by `file_scan_exclusions`. - /// Default: [ - /// ".env*", - /// "docker-compose.*.yml", - /// ] - pub file_scan_inclusions: Option>, + /// Include files matching these glob patterns when scanning, even if ignored + /// by Git. Note that broad patterns can slow file scanning. + /// `file_scan_exclusions` takes precedence. + /// + /// Default: + /// + /// ```json + /// { + /// "file_scan_inclusions": [".env*"] + /// } + /// ``` + /// + /// Use `"..."` to add patterns without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. + /// + /// ```json + /// { + /// "file_scan_inclusions": ["**/build/**", "..."] + /// } + /// ``` + /// + /// Inherited patterns are inserted at `"..."`, and duplicates keep their first + /// occurrence. + pub file_scan_inclusions: Option, /// When to scan content of linked directories. /// @@ -176,15 +211,61 @@ pub struct WorktreeSettingsContent { /// Default: ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"] pub private_files: Option>, - /// Treat the files matching these globs as hidden files. You can hide hidden files in the project panel. - /// Default: ["**/.*"] - pub hidden_files: Option>, + /// Treat files and folders matching these glob patterns as hidden, including + /// files inside matching folders. To hide these entries, run + /// `project panel: toggle hide hidden` from the command palette or set + /// `project_panel.hide_hidden` to `true`. + /// + /// Default: + /// + /// ```json + /// { + /// "hidden_files": ["**/.*"] + /// } + /// ``` + /// + /// Use `"..."` to add patterns without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. + /// + /// ```json + /// { + /// "hidden_files": ["**/*.log", "..."], + /// "project_panel": { + /// "hide_hidden": true + /// } + /// } + /// ``` + /// + /// Inherited patterns are inserted at `"..."`, and duplicates keep their first + /// occurrence. + pub hidden_files: Option, - /// Treat the files matching these globs as read-only. These files can be opened and viewed, - /// but cannot be edited. This is useful for generated files, build outputs, or files from - /// external dependencies that should not be modified directly. - /// Default: [] - pub read_only_files: Option>, + /// Treat files matching these glob patterns as read-only when opened. You can + /// view but not edit them, which is useful for build outputs, external + /// dependencies, or generated files. + /// + /// Default: + /// + /// ```json + /// { + /// "read_only_files": [] + /// } + /// ``` + /// + /// Use `"..."` to add patterns without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. + /// + /// ```json + /// { + /// "read_only_files": ["**/build/**", "..."] + /// } + /// ``` + /// + /// Inherited patterns are inserted at `"..."`, and duplicates keep their first + /// occurrence. + pub read_only_files: Option, } #[with_fallible_options] @@ -955,7 +1036,7 @@ pub enum GitHostingProviderKind { #[cfg(test)] mod tests { use super::*; - use crate::{REST_OF_FILE_SCAN_EXCLUSIONS, merge_from::MergeFrom}; + use crate::merge_from::MergeFrom; fn exclusions(globs: &[&str]) -> WorktreeSettingsContent { WorktreeSettingsContent { @@ -974,10 +1055,7 @@ mod tests { let defaults = exclusions(&["**/.git", "**/.DS_Store"]); let mut extended = defaults.clone(); - extended.merge_from(&exclusions(&[ - "**/node_modules", - REST_OF_FILE_SCAN_EXCLUSIONS, - ])); + extended.merge_from(&exclusions(&["**/node_modules", SplicingVec::REST])); assert_eq!( extended.file_scan_exclusions.unwrap().0, vec!["**/node_modules", "**/.git", "**/.DS_Store"] @@ -994,8 +1072,8 @@ mod tests { #[test] fn test_file_scan_exclusions_splice_each_layer() { let mut settings = exclusions(&["**/.git"]); - settings.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS, "**/target"])); - settings.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS, "**/dist"])); + settings.merge_from(&exclusions(&[SplicingVec::REST, "**/target"])); + settings.merge_from(&exclusions(&[SplicingVec::REST, "**/dist"])); assert_eq!( settings.file_scan_exclusions.unwrap().0, @@ -1006,14 +1084,11 @@ mod tests { #[test] fn test_file_scan_exclusions_splice_edge_cases() { let mut repeated = exclusions(&["**/.git"]); - repeated.merge_from(&exclusions(&[ - REST_OF_FILE_SCAN_EXCLUSIONS, - REST_OF_FILE_SCAN_EXCLUSIONS, - ])); + repeated.merge_from(&exclusions(&[SplicingVec::REST, SplicingVec::REST])); assert_eq!(repeated.file_scan_exclusions.unwrap().0, vec!["**/.git"]); let mut relisted = exclusions(&["**/.git", "**/.DS_Store"]); - relisted.merge_from(&exclusions(&["**/.git", REST_OF_FILE_SCAN_EXCLUSIONS])); + relisted.merge_from(&exclusions(&["**/.git", SplicingVec::REST])); assert_eq!( relisted.file_scan_exclusions.unwrap().0, vec!["**/.git", "**/.DS_Store"] @@ -1024,7 +1099,7 @@ mod tests { assert!(cleared.file_scan_exclusions.unwrap().0.is_empty()); let mut unchanged = exclusions(&["**/.git", "**/.DS_Store"]); - unchanged.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS])); + unchanged.merge_from(&exclusions(&[SplicingVec::REST])); assert_eq!( unchanged.file_scan_exclusions.unwrap().0, vec!["**/.git", "**/.DS_Store"] @@ -1034,7 +1109,7 @@ mod tests { #[test] fn test_file_scan_exclusions_splice_without_a_base_layer() { let mut settings = WorktreeSettingsContent::default(); - settings.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS, "**/target"])); + settings.merge_from(&exclusions(&[SplicingVec::REST, "**/target"])); // `Option::merge_from` replaces a `None` base outright rather than // splicing, so the sentinel survives here. `assets/settings/default.json` @@ -1042,7 +1117,234 @@ mod tests { // unwraps it, so no glob is ever compiled from this state. assert_eq!( settings.file_scan_exclusions.unwrap().0, - vec![REST_OF_FILE_SCAN_EXCLUSIONS, "**/target"] + vec![SplicingVec::REST, "**/target"] + ); + } + + fn inclusions(globs: &[&str]) -> WorktreeSettingsContent { + WorktreeSettingsContent { + file_scan_inclusions: Some(SplicingVec::from( + globs + .iter() + .map(|glob| glob.to_string()) + .collect::>(), + )), + ..Default::default() + } + } + + #[test] + fn test_file_scan_inclusions_splice_each_layer() { + let mut settings = inclusions(&[".env*"]); + settings.merge_from(&inclusions(&[SplicingVec::REST, "**/*.user"])); + assert_eq!(settings, inclusions(&[".env*", "**/*.user"])); + + settings.merge_from(&inclusions(&["**/*.project", SplicingVec::REST])); + assert_eq!( + settings, + inclusions(&["**/*.project", ".env*", "**/*.user"]) + ); + } + + #[test] + fn test_file_scan_inclusions_replace_and_clear() { + let mut settings = inclusions(&[".env*"]); + settings.merge_from(&inclusions(&["**/*.user"])); + assert_eq!(settings, inclusions(&["**/*.user"])); + + settings.merge_from(&inclusions(&["**/*.project"])); + assert_eq!(settings, inclusions(&["**/*.project"])); + + settings.merge_from(&inclusions(&[])); + assert_eq!(settings, inclusions(&[])); + + settings.merge_from(&inclusions(&[SplicingVec::REST, "**/*.next"])); + assert_eq!(settings, inclusions(&["**/*.next"])); + } + + #[test] + fn test_file_scan_inclusions_splice_preserves_first_occurrence() { + let inherited = inclusions(&[".env*", "**/*.user"]); + let mut settings = inherited.clone(); + settings.merge_from(&WorktreeSettingsContent::default()); + assert_eq!(settings, inherited); + + settings.merge_from(&inclusions(&[SplicingVec::REST, SplicingVec::REST])); + assert_eq!(settings, inherited); + + settings.merge_from(&inclusions(&[ + "**/*.user", + SplicingVec::REST, + ".env*", + "**/*.project", + SplicingVec::REST, + "**/*.project", + ])); + assert_eq!( + settings, + inclusions(&["**/*.user", ".env*", "**/*.project"]) + ); + + settings.merge_from(&inclusions(&["**/*.project", "**/*.user", "**/*.project"])); + assert_eq!(settings, inclusions(&["**/*.project", "**/*.user"])); + } + + fn hidden_files(globs: &[&str]) -> WorktreeSettingsContent { + WorktreeSettingsContent { + hidden_files: Some(SplicingVec::from( + globs + .iter() + .map(|glob| glob.to_string()) + .collect::>(), + )), + ..Default::default() + } + } + + #[test] + fn test_hidden_files_splice_each_layer() { + let mut settings = hidden_files(&["**/.*"]); + settings.merge_from(&hidden_files(&[SplicingVec::REST, "**/*.user"])); + assert_eq!(settings, hidden_files(&["**/.*", "**/*.user"])); + + settings.merge_from(&hidden_files(&["**/*.project", SplicingVec::REST])); + assert_eq!( + settings, + hidden_files(&["**/*.project", "**/.*", "**/*.user"]) + ); + + settings.merge_from(&hidden_files(&[ + "**/*.before", + SplicingVec::REST, + "**/*.after", + ])); + assert_eq!( + settings, + hidden_files(&[ + "**/*.before", + "**/*.project", + "**/.*", + "**/*.user", + "**/*.after", + ]) + ); + } + + #[test] + fn test_hidden_files_replace_and_clear() { + let mut settings = hidden_files(&["**/.*"]); + settings.merge_from(&hidden_files(&["**/*.user"])); + assert_eq!(settings, hidden_files(&["**/*.user"])); + + settings.merge_from(&hidden_files(&["**/*.project"])); + assert_eq!(settings, hidden_files(&["**/*.project"])); + + settings.merge_from(&hidden_files(&[])); + assert_eq!(settings, hidden_files(&[])); + + settings.merge_from(&hidden_files(&[SplicingVec::REST, "**/*.next"])); + assert_eq!(settings, hidden_files(&["**/*.next"])); + } + + #[test] + fn test_hidden_files_splice_preserves_first_occurrence() { + let inherited = hidden_files(&["**/.*", "**/*.user"]); + let mut settings = inherited.clone(); + settings.merge_from(&WorktreeSettingsContent::default()); + assert_eq!(settings, inherited); + + settings.merge_from(&hidden_files(&[SplicingVec::REST])); + assert_eq!(settings, inherited); + + settings.merge_from(&hidden_files(&[SplicingVec::REST, SplicingVec::REST])); + assert_eq!(settings, inherited); + + settings.merge_from(&hidden_files(&[ + "**/*.user", + SplicingVec::REST, + "**/.*", + "**/*.project", + SplicingVec::REST, + "**/*.project", + ])); + assert_eq!( + settings, + hidden_files(&["**/*.user", "**/.*", "**/*.project"]) + ); + + settings.merge_from(&hidden_files(&[ + "**/*.project", + "**/*.user", + "**/*.project", + ])); + assert_eq!(settings, hidden_files(&["**/*.project", "**/*.user"])); + } + + fn read_only_files(globs: &[&str]) -> WorktreeSettingsContent { + WorktreeSettingsContent { + read_only_files: Some(SplicingVec::from( + globs + .iter() + .map(|glob| glob.to_string()) + .collect::>(), + )), + ..Default::default() + } + } + + #[test] + fn test_read_only_files_splice_rest_of_list() { + let inherited = read_only_files(&["**/*.lock"]); + let mut extended = inherited.clone(); + extended.merge_from(&read_only_files(&["**/generated/**", SplicingVec::REST])); + assert_eq!(extended, read_only_files(&["**/generated/**", "**/*.lock"])); + + let mut replaced = inherited; + replaced.merge_from(&read_only_files(&["**/generated/**"])); + assert_eq!(replaced, read_only_files(&["**/generated/**"])); + } + + #[test] + fn test_read_only_files_splice_each_layer() { + let mut settings = read_only_files(&[]); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/*.lock"])); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/generated/**"])); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/vendor/**"])); + assert_eq!( + settings, + read_only_files(&["**/*.lock", "**/generated/**", "**/vendor/**"]) + ); + } + + #[test] + fn test_read_only_files_splice_edge_cases() { + let inherited = read_only_files(&["**/*.gen.rs", "**/*.lock"]); + let mut repeated = inherited.clone(); + repeated.merge_from(&read_only_files(&[SplicingVec::REST, SplicingVec::REST])); + assert_eq!(repeated, inherited); + + let mut relisted = inherited.clone(); + relisted.merge_from(&read_only_files(&["**/*.lock", SplicingVec::REST])); + assert_eq!(relisted, read_only_files(&["**/*.lock", "**/*.gen.rs"])); + + let mut cleared = inherited.clone(); + cleared.merge_from(&read_only_files(&[])); + assert_eq!(cleared, read_only_files(&[])); + + let mut unchanged = inherited.clone(); + unchanged.merge_from(&read_only_files(&[SplicingVec::REST])); + assert_eq!(unchanged, inherited); + unchanged.merge_from(&WorktreeSettingsContent::default()); + assert_eq!(unchanged, inherited); + } + + #[test] + fn test_read_only_files_splice_without_a_base_layer() { + let mut settings = WorktreeSettingsContent::default(); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/generated/**"])); + assert_eq!( + settings, + read_only_files(&[SplicingVec::REST, "**/generated/**"]) ); } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 0c46b5a4cf..300b5794d4 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -192,6 +192,9 @@ pub struct SettingsContent { #[serde(flatten)] pub remote: RemoteSettingsContent, + /// Settings related to the command palette. + pub command_palette: Option, + /// Settings related to the file finder. pub file_finder: Option, @@ -404,7 +407,7 @@ impl SettingsContent { fallible_options::flattened_deserialize!(SettingsContent { sections: { project, theme, extension, workspace, editor, remote }, options: { - call_hierarchy, file_finder, git_panel, tabs, tab_bar, status_bar, preview_tabs, agent, + call_hierarchy, command_palette, file_finder, git_panel, tabs, tab_bar, status_bar, preview_tabs, agent, agent_servers, audio, auto_update, background, base_keymap, collaboration_panel, debugger, diagnostics, git, global_lsp_settings, image_viewer, markdown_preview, repl, helix_mode, hide_mouse, @@ -929,6 +932,15 @@ pub struct PanelSettingsContent { pub default_width: Option, } +#[with_fallible_options] +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)] +pub struct CommandPaletteSettingsContent { + /// Whether to use command history ranking for sorting in the command palette. + /// + /// Default: true + pub use_command_history: Option, +} + #[with_fallible_options] #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)] pub struct FileFinderSettingsContent { @@ -1283,6 +1295,22 @@ pub enum LineIndicatorFormat { #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, Default, PartialEq)] pub struct MarkdownPreviewSettingsContent { + /// The name of a font to use for rendering in the markdown preview. + /// Falls back to the UI font if unset. + pub font_family: Option, + /// The name of a font to use for code (code blocks and inline code) in the + /// markdown preview. Falls back to the buffer font if unset. + pub code_font_family: Option, + /// The font size to use for rendering in the markdown preview. + /// Falls back to the UI font size if unset. + pub font_size: Option, + /// The theme to use for the markdown preview. + /// Falls back to the main editor theme if unset. + pub theme: Option, + /// Whether to automatically open Markdown files in the preview. + /// + /// Default: false + pub open_markdown_files_in_preview: Option, /// Whether to limit the width of the rendered markdown content. When /// enabled, content is constrained to `max_width` and centered /// horizontally within the preview pane, for optimal readability. @@ -1444,13 +1472,15 @@ pub struct ReplSettingsContent { /// Settings for configuring the which-key popup behaviour. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct WhichKeySettingsContent { - /// Whether to show the which-key popup when holding down key combinations + /// Whether to show the which-key popup when holding down key combinations. + /// When enabled, the pending keystrokes indicator remains visible, but its binding preview + /// popover is disabled. /// /// Default: false pub enabled: Option, /// Delay in milliseconds before showing the which-key popup. /// - /// Default: 700 + /// Default: 1000 pub delay_ms: Option, } @@ -1482,8 +1512,6 @@ impl merge_from::MergeFrom for ExtendingVec { } } -pub const REST_OF_FILE_SCAN_EXCLUSIONS: &str = "..."; - // A SplicingVec in the settings replaces the value it merges over, except that // a `...` entry expands to that previous value. // @@ -1497,6 +1525,10 @@ pub const REST_OF_FILE_SCAN_EXCLUSIONS: &str = "..."; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SplicingVec(pub Vec); +impl SplicingVec { + pub const REST: &str = "..."; +} + impl From> for SplicingVec { fn from(vec: Vec) -> Self { SplicingVec(vec) @@ -1510,7 +1542,7 @@ impl merge_from::MergeFrom for SplicingVec { .0 .iter() .flat_map(|entry| { - if entry == REST_OF_FILE_SCAN_EXCLUSIONS { + if entry == Self::REST { inherited.clone() } else { vec![entry.clone()] diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs index 86b9557639..e9bf73ac56 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use collections::HashMap; +use collections::{HashMap, HashSet}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; @@ -30,35 +30,78 @@ pub struct ProjectTerminalSettingsContent { pub detect_venv: Option, /// Regexes used to identify paths for hyperlink navigation. /// - /// Default: [ - /// // Python-style diagnostics - /// "File \"(?[^\"]+)\", line (?[0-9]+)", - /// // Common path syntax with optional line, column, description, trailing punctuation, or - /// // surrounding symbols or quotes - /// [ - /// "(?x)", - /// "# optionally starts with 0-2 opening prefix symbols", - /// "[({\\[<]{0,2}", - /// "# which may be followed by an opening quote", - /// "(?[\"'`])?", - /// "# `path` is the shortest sequence of any non-space character", - /// "(?(?[^ ]+?", - /// " # which may end with a line and optionally a column,", - /// " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", - /// "))", - /// "# which must be followed by a matching quote", - /// "(?()\\k)", - /// "# and optionally a single closing symbol", - /// "[)}\\]>]?", - /// "# if line/column matched, may be followed by a description", - /// "(?():[^ 0-9][^ ]*)?", - /// "# which may be followed by trailing punctuation", - /// "[.,:)}\\]>]*", - /// "# and always includes trailing whitespace or end of line", - /// "([ ]+|$)" - /// ] - /// ] - pub path_hyperlink_regexes: Option>, + /// Default: + /// + /// ```json + /// { + /// "terminal": { + /// "path_hyperlink_regexes": [ + /// // Python-style diagnostics + /// "File \"(?[^\"]+)\", line (?[0-9]+)", + /// // Common path syntax with optional line, column, description, trailing punctuation, or + /// // surrounding symbols or quotes + /// [ + /// "(?x)", + /// "(?", + /// " (", + /// " # multi-char path: first char (not opening delimiter, space, or box drawing char)", + /// " [^({\\[<\"'`\\ \\u2500-\\u257F]", + /// " # middle chars: non-space, and colon/paren only if not followed by digit/paren/space", + /// " ([^\\ :(]|[:(][^0-9()\\ ])*", + /// " # last char: not closing delimiter or colon", + /// " [^()}\\]>\"'`.,;:\\ ]", + /// " |", + /// " # single-char path: not delimiter, punctuation, space, or box drawing char", + /// " [^(){}\\[\\]<>\"'`.,;:\\ \\u2500-\\u257F]", + /// " )", + /// " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + /// " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + /// ")" + /// ] + /// ] + /// } + /// } + /// ``` + /// + /// Use `"..."` to add regexes without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. Set `[]` to clear it. Omitting this + /// setting keeps the inherited list. + /// + /// ```json + /// { + /// "terminal": { + /// "path_hyperlink_regexes": [ + /// "\\s+(-->|:::|at) (?(?.+?))(:$|$)", + /// "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)", + /// "..." + /// ] + /// } + /// } + /// ``` + /// + /// Inherited regexes are inserted at `"..."`, and duplicates keep their first + /// occurrence. Regexes are duplicates when their text is identical after + /// joining multiline entries with newlines. + /// + /// Each regex can be a single string or an array of strings joined with + /// newlines. The marker is recognized only as a top-level string. To use + /// `...` as a regex matching three characters, write `["..."]` as an entry. + /// A `"..."` line inside a multiline entry is always regex text. + /// + /// The optional named capture `path` selects the hyperlink target. Without + /// it, the entire match is the target. With `path`, `line` and `column` + /// specify the position. Without a captured `line`, built-in suffix + /// processing parses `line:column` and `(line,column)` variants. The optional + /// `link` capture selects the clickable text, otherwise the entire match is + /// clickable. + /// + /// Processing stops at the first regex that matches the terminal line, even + /// if the cursor is outside its clickable text. Put more specific regexes + /// before broader ones. Regexes use Rust's `regex` syntax. Invalid regexes + /// are logged and ignored. The `path_hyperlink_timeout_ms` setting controls + /// the discovery timeout. Setting it to `0` disables path hyperlinks. + pub path_hyperlink_regexes: Option, /// Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. /// /// Default: 1 @@ -504,6 +547,38 @@ impl VenvSettings { } } +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(transparent)] +pub struct PathHyperlinkRegexes(pub Vec); + +impl crate::merge_from::MergeFrom for PathHyperlinkRegexes { + fn merge_from(&mut self, other: &Self) { + let inherited = std::mem::take(&mut self.0); + let mut seen = HashSet::default(); + self.0 = other + .0 + .iter() + .flat_map(|entry| { + if entry.is_rest() { + inherited.as_slice() + } else { + std::slice::from_ref(entry) + } + }) + .filter(|entry| { + let regex = match entry { + PathHyperlinkRegex::SingleLine(regex) => regex.clone(), + PathHyperlinkRegex::MultiLine(lines) => lines.join("\n"), + }; + // Project layers can still contain an unresolved marker, which must + // not collapse with the literal regex written as ["..."] + seen.insert((entry.is_rest(), regex)) + }) + .cloned() + .collect(); + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] #[serde(untagged)] pub enum PathHyperlinkRegex { @@ -511,6 +586,12 @@ pub enum PathHyperlinkRegex { MultiLine(Vec), } +impl PathHyperlinkRegex { + fn is_rest(&self) -> bool { + matches!(self, Self::SingleLine(regex) if regex == crate::SplicingVec::REST) + } +} + #[derive( Copy, Clone, @@ -547,7 +628,47 @@ pub enum ActivateScript { mod test { use serde_json::json; - use crate::{ProjectSettingsContent, Shell}; + use crate::{PathHyperlinkRegexes, ProjectSettingsContent, Shell, merge_from::MergeFrom}; + + #[test] + fn test_path_hyperlink_regexes_preserve_entry_forms() -> anyhow::Result<()> { + let entries = json!([ + "...", + ["..."], + ["(?x)", "# comment", "...", "(?file)"], + "first\nsecond", + ["first", "second"] + ]); + let regexes: PathHyperlinkRegexes = serde_json::from_value(entries.clone())?; + assert_eq!(serde_json::to_value(regexes)?, entries); + Ok(()) + } + + #[test] + fn test_path_hyperlink_regexes_merge_preserves_first_representation() -> anyhow::Result<()> { + let mut regexes: PathHyperlinkRegexes = serde_json::from_value(json!([ + "...", + ["..."], + "first\nsecond", + ["(?x)", "# comment", "(?file)"] + ]))?; + regexes.merge_from(&serde_json::from_value(json!([ + ["first", "second"], + "...", + "(?x)\n# comment\n(?file)", + "..." + ]))?); + assert_eq!( + serde_json::to_value(regexes)?, + json!([ + ["first", "second"], + "...", + ["..."], + ["(?x)", "# comment", "(?file)"] + ]) + ); + Ok(()) + } #[test] #[ignore] diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index cf12cce971..157d138455 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -214,18 +214,6 @@ pub struct ThemeSettingsContent { /// The font size for user messages in the agent panel. pub agent_buffer_font_size: Option, pub git_commit_buffer_font_size: Option, - /// The name of a font to use for rendering in the markdown preview. - /// Falls back to the UI font if unset. - pub markdown_preview_font_family: Option, - /// The name of a font to use for code (code blocks and inline code) in the - /// markdown preview. Falls back to the buffer font if unset. - pub markdown_preview_code_font_family: Option, - /// The font size to use for rendering in the markdown preview. - /// Falls back to the UI font size if unset. - pub markdown_preview_font_size: Option, - /// The theme to use for the markdown preview. - /// Falls back to the main editor theme if unset. - pub markdown_preview_theme: Option, /// The name of the Zed theme to use. pub theme: Option, /// The name of the icon theme to use. @@ -854,6 +842,10 @@ pub struct ThemeColorsContent { #[serde(rename = "editor.foreground")] pub editor_foreground: Option, + /// Text color used for CodeLens items in the editor. + #[serde(rename = "editor.code_lens.foreground")] + pub editor_code_lens_foreground: Option, + #[serde(rename = "editor.background")] pub editor_background: Option, @@ -1367,6 +1359,8 @@ pub enum WindowBackgroundContent { Opaque, Transparent, Blurred, + MicaBackdrop, + MicaAltBackdrop, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 7aa8bc42a8..dbcb6fc414 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -109,6 +109,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: none pub max_tabs: Option, + /// What to show when opening a new window. + /// Values: empty_tab, launchpad + /// Default: launchpad + pub on_new_window: Option, /// What to do when the last window is closed /// /// Default: auto (nothing on macOS, "app quit" otherwise) @@ -121,7 +125,7 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub close_on_file_delete: Option, - /// Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). + /// Whether to allow windows to tab together based on the user's tabbing preference (macOS only). /// /// Default: false pub use_system_window_tabs: Option, @@ -142,6 +146,25 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub close_panel_on_toggle: Option, + /// Window title template. + /// + /// Available variables are `${projectName}`, `${fileName}`, + /// `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, + /// `${remoteHost}`, `${appName}`, `${branch}`, + /// and `${separator}`. + /// `${separator}` is omitted when adjacent variables are empty, + /// but literal text is preserved. + /// The collaboration indicator, when present, is appended after the + /// rendered template. + /// If the template renders to nothing, the default template is used instead. + /// + /// Default: `${projectName}${separator}${fileName}` + pub window_title_format: Option, + /// String substituted for `${separator}` in the window title format. + /// Include any surrounding whitespace in the value. + /// + /// Default: ` — ` + pub window_title_separator: Option, /// Controls whether Zed or the window manager or compositor draws window decorations on Linux. /// /// Default: client @@ -189,7 +212,8 @@ pub struct PreviewTabsSettingsContent { /// /// Default: true pub enabled: Option, - /// Whether to open tabs in preview mode when opened from the project panel with a single click. + /// Whether to open tabs in preview mode when opened from the project panel + /// with a single click or the `project_panel::Open` action. /// /// Default: true pub enable_preview_from_project_panel: Option, @@ -557,6 +581,12 @@ pub struct StatusBarSettingsContent { /// /// Default: non_utf8 pub active_encoding_button: Option, + /// Whether to show an indicator while multi-stroke input is pending. + /// If the input has a timeout, a countdown is shown and hovering the indicator pauses it. + /// Its binding preview popover is disabled when the which-key popup is enabled. + /// + /// Default: true + pub pending_keystrokes_indicator: Option, } #[derive( @@ -683,6 +713,29 @@ pub struct CenteredLayoutSettings { pub right_padding: Option, } +#[derive( + Copy, + Clone, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + Debug, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum OnNewWindow { + /// Show an empty untitled buffer when opening a new window + EmptyTab, + /// Show the launchpad with recent projects when opening a new window + #[default] + Launchpad, +} + #[derive( Copy, Clone, @@ -775,6 +828,8 @@ pub struct ProjectPanelSettingsContent { /// /// Default: right (Agentic layout), left (Classic layout) pub dock: Option, + // TODO + pub title_tooltip_delay: Option, /// Spacing between worktree entries in the project panel. /// /// Default: comfortable @@ -860,6 +915,31 @@ pub struct ProjectPanelSettingsContent { pub git_status_indicator: Option, } +/// Controls the width of the git diff hunk indicators in the gutter. +#[derive( + Clone, + Copy, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + strum::EnumDiscriminants, +)] +#[strum_discriminants(derive(strum::VariantArray, strum::VariantNames, strum::FromRepr))] +#[serde(rename_all = "snake_case")] +pub enum ProjectPanelTitleTooltipDelay { + /// Default is 1500ms. + #[default] + Default, + /// A custom offset in milliseconds for the tooltip show delay. + Custom(crate::DelayMs), + /// Disables the tooltip + Disabled, +} + #[derive( Copy, Clone, diff --git a/crates/settings_json/Cargo.toml b/crates/settings_json/Cargo.toml index a780b7fa0d..d84d9bb9cd 100644 --- a/crates/settings_json/Cargo.toml +++ b/crates/settings_json/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/settings_json.rs" +doctest = false [features] default = ["editing"] diff --git a/crates/settings_json/src/settings_json.rs b/crates/settings_json/src/settings_json.rs index f3458d5333..483cfd1f6b 100644 --- a/crates/settings_json/src/settings_json.rs +++ b/crates/settings_json/src/settings_json.rs @@ -71,6 +71,37 @@ pub fn update_value_in_json_text<'a>( } } +#[cfg(feature = "editing")] +pub fn find_value_range_in_json_text>( + text: &str, + key_path: &[T], +) -> Option> { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_json::LANGUAGE.into()) + .ok()?; + let syntax_tree = parser.parse(text, None)?; + let mut node = syntax_tree.root_node(); + if node.kind() == TS_DOCUMENT_KIND { + let mut cursor = node.walk(); + node = node + .named_children(&mut cursor) + .find(|child| child.kind() != TS_COMMENT_KIND)?; + } + for key in key_path { + let key_json = serde_json::to_string(key.as_ref()).ok()?; + let mut cursor = node.walk(); + let value = node.named_children(&mut cursor).find_map(|pair| { + let key_node = pair.child_by_field_name("key")?; + (text.get(key_node.byte_range()) == Some(key_json.as_str())) + .then(|| pair.child_by_field_name("value")) + .flatten() + })?; + node = value; + } + Some(node.byte_range()) +} + /// * `replace_key` - When an exact key match according to `key_path` is found, replace the key with `replace_key` if `Some`. #[cfg(feature = "editing")] pub fn replace_value_in_json_text>( @@ -99,6 +130,7 @@ pub fn replace_value_in_json_text>( let mut depth = 0; let mut last_value_range = 0..0; let mut first_key_start = None; + let mut matched_key_start = None; let mut existing_value_range = 0..text.len(); let mut matches = cursor.matches(&PAIR_QUERY, syntax_tree.root_node(), text.as_bytes()); @@ -135,6 +167,7 @@ pub fn replace_value_in_json_text>( .unwrap_or(false); if found_key { + matched_key_start = Some(key_range.start); existing_value_range = value_range; // Reset last value range when increasing in depth last_value_range = existing_value_range.start..existing_value_range.start; @@ -166,12 +199,8 @@ pub fn replace_value_in_json_text>( let new_val = to_pretty_json(new_value, tab_size, tab_size * depth); if let Some(replace_key) = replace_key.and_then(|str| serde_json::to_string(str).ok()) { let new_key = format!("{}: ", replace_key); - if let Some(key_start) = text[..existing_value_range.start].rfind('"') { - if let Some(prev_key_start) = text[..key_start].rfind('"') { - existing_value_range.start = prev_key_start; - } else { - existing_value_range.start = key_start; - } + if let Some(key_start) = matched_key_start { + existing_value_range.start = key_start; } (existing_value_range, new_key + &new_val) } else { @@ -181,14 +210,8 @@ pub fn replace_value_in_json_text>( let mut removal_start = first_key_start.unwrap_or(existing_value_range.start); let mut removal_end = existing_value_range.end; - // Find the actual key position by looking for the key in the pair - // We need to extend the range to include the key, not just the value - if let Some(key_start) = text[..existing_value_range.start].rfind('"') { - if let Some(prev_key_start) = text[..key_start].rfind('"') { - removal_start = prev_key_start; - } else { - removal_start = key_start; - } + if let Some(key_start) = matched_key_start { + removal_start = key_start; } let mut removed_comma = false; @@ -228,7 +251,7 @@ pub fn replace_value_in_json_text>( } else { if let Some(first_key_start) = first_key_start { // We have key paths, construct the sub objects - let new_key = key_path[depth].as_ref(); + let new_key = serde_json::to_string(key_path[depth].as_ref()).unwrap(); // We don't have the key, construct the nested objects let new_value = construct_json_value(&key_path[(depth + 1)..], new_value); @@ -250,11 +273,11 @@ pub fn replace_value_in_json_text>( // depth is 0 based, but division needs to be 1 based. let new_val = to_pretty_json(&new_value, column / (depth + 1), column); let space = ' '; - let content = format!("\"{new_key}\": {new_val},\n{space:width$}", width = column); + let content = format!("{new_key}: {new_val},\n{space:width$}", width = column); (first_key_start..first_key_start, content) } else { let new_val = serde_json::to_string(&new_value).unwrap(); - let mut content = format!(r#""{new_key}": {new_val},"#); + let mut content = format!("{new_key}: {new_val},"); content.push(' '); (first_key_start..first_key_start, content) } @@ -2588,6 +2611,47 @@ mod tests { ) } + #[test] + fn object_replace_escapes_new_key() { + // An object that already has a key: an empty one takes the nested-construction path. + let single_line = r#"{"theme": "One Dark"}"#; + let multi_line = "{\n \"theme\": \"One Dark\"\n}"; + let key = r#"/home/me/say "hi" C:\x"#; + + for input in [single_line, multi_line] { + let mut text = input.to_string(); + let (range, replacement) = + replace_value_in_json_text(&text, &[key], 4, Some(&json!("One Light")), None); + text.replace_range(range, &replacement); + + let parsed: Value = serde_json::from_str(&text) + .expect("a folder name carrying a quote must not break settings.json"); + pretty_assertions::assert_eq!(parsed, json!({ "theme": "One Dark", key: "One Light" })); + } + } + + #[test] + fn object_remove_and_rename_find_an_escaped_key_by_its_own_range() { + let key = "say \"hi\""; + let input = format!( + "{{{}: \"V\", \"theme\": \"One Dark\"}}", + serde_json::to_string(key).unwrap() + ); + + let mut removed = input.clone(); + let (range, replacement) = replace_value_in_json_text(&removed, &[key], 4, None, None); + removed.replace_range(range, &replacement); + let parsed: Value = serde_json::from_str(&removed).expect("removal must leave valid JSON"); + pretty_assertions::assert_eq!(parsed, json!({ "theme": "One Dark" })); + + let mut renamed = input; + let (range, replacement) = + replace_value_in_json_text(&renamed, &[key], 4, Some(&json!("V2")), Some("plain")); + renamed.replace_range(range, &replacement); + let parsed: Value = serde_json::from_str(&renamed).expect("rename must leave valid JSON"); + pretty_assertions::assert_eq!(parsed, json!({ "plain": "V2", "theme": "One Dark" })); + } + #[test] fn test_infer_json_indent_size() { let json_2_spaces = r#"{ @@ -2667,4 +2731,38 @@ mod tests { "#; parse_json_with_comments::>(trailing).unwrap_err(); } + + #[test] + fn test_find_value_range_in_json_text() { + let text = r#"// "edit_predictions": { "disabled_globs": ["commented/**"] }, + { + // "disabled_globs": ["commented/**"], + "languages": { "edit_predictions": { "disabled_globs": ["nested/**"] } }, + "edit_predictions": { + /* "disabled_globs": ["commented/**"], */ + "mode": "subtle", + "disabled_globs": ["live/**", /* ] */ "..."], // ] + } + }"#; + let range = find_value_range_in_json_text(text, &["edit_predictions", "disabled_globs"]) + .expect("value range"); + assert_eq!(&text[range], r#"["live/**", /* ] */ "..."]"#); + assert_eq!( + find_value_range_in_json_text(text, &["edit_predictions", "missing"]), + None + ); + assert_eq!(find_value_range_in_json_text(text, &["missing"]), None); + assert_eq!( + find_value_range_in_json_text("// only a comment", &["edit_predictions"]), + None + ); + assert_eq!( + find_value_range_in_json_text("", &["edit_predictions"]), + None + ); + assert_eq!( + find_value_range_in_json_text(r#"{"a": 1}"#, &["a", "b"]), + None + ); + } } diff --git a/crates/settings_macros/Cargo.toml b/crates/settings_macros/Cargo.toml index 175c2f26a3..ecb60970c7 100644 --- a/crates/settings_macros/Cargo.toml +++ b/crates/settings_macros/Cargo.toml @@ -8,6 +8,8 @@ license = "GPL-3.0-or-later" [lib] path = "src/settings_macros.rs" proc-macro = true +test = false +doctest = false [lints] workspace = true diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index b3e7acd324..861b71e221 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/settings_ui.rs" +doctest = false [features] default = [] @@ -78,6 +79,7 @@ zed_credentials_provider.workspace = true fs = { workspace = true, features = ["test-support"] } futures.workspace = true gpui = { workspace = true, features = ["test-support"] } +language_models = { workspace = true, features = ["test-support"] } paths.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index af91f73a29..a60922edc6 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -172,6 +172,20 @@ fn general_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "On New Window", + description: "What to show when opening a new window.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("on_new_window"), + pick: |settings_content| settings_content.workspace.on_new_window.as_ref(), + write: |settings_content, value, _| { + settings_content.workspace.on_new_window = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "On Last Window Closed", description: "What to do when the last window is closed.", @@ -238,7 +252,7 @@ fn general_page(cx: &App) -> SettingsPage { field: Box::new( SettingField { organization_override: None, - json_path: Some("worktree.private_files"), + json_path: Some("private_files"), pick: |settings_content| { settings_content.project.worktree.private_files.as_ref() }, @@ -1240,12 +1254,19 @@ fn appearance_page() -> SettingsPage { description: "Font family for the markdown preview. Falls back to the UI font family.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_font_family"), + json_path: Some("markdown_preview.font_family"), pick: |settings_content| { - settings_content.theme.markdown_preview_font_family.as_ref() + settings_content + .markdown_preview + .as_ref()? + .font_family + .as_ref() }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_font_family = value; + settings_content + .markdown_preview + .get_or_insert_default() + .font_family = value; }, }), metadata: None, @@ -1256,15 +1277,19 @@ fn appearance_page() -> SettingsPage { description: "Font family for code blocks in the markdown preview. Falls back to the editor font family.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_code_font_family"), + json_path: Some("markdown_preview.code_font_family"), pick: |settings_content| { settings_content - .theme - .markdown_preview_code_font_family + .markdown_preview + .as_ref()? + .code_font_family .as_ref() }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_code_font_family = value; + settings_content + .markdown_preview + .get_or_insert_default() + .code_font_family = value; }, }), metadata: None, @@ -1275,16 +1300,19 @@ fn appearance_page() -> SettingsPage { description: "Font size for the markdown preview. Falls back to the editor font size.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_font_size"), + json_path: Some("markdown_preview.font_size"), pick: |settings_content| { settings_content - .theme - .markdown_preview_font_size + .markdown_preview .as_ref() + .and_then(|preview| preview.font_size.as_ref()) .or(settings_content.theme.buffer_font_size.as_ref()) }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_font_size = value; + settings_content + .markdown_preview + .get_or_insert_default() + .font_size = value; }, }), metadata: None, @@ -1315,7 +1343,7 @@ fn appearance_page() -> SettingsPage { ] } - fn cursor_section() -> [SettingsPageItem; 6] { + fn cursor_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SectionHeader("Cursor"), SettingsPageItem::SettingItem(SettingItem { @@ -1346,6 +1374,31 @@ fn appearance_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Cursor Animation", + description: "Whether the cursor smoothly animates when moving around the editor.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("cursor_animation.enabled"), + pick: |settings_content| { + settings_content + .editor + .cursor_animation + .as_ref()? + .enabled + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .editor + .cursor_animation + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cursor Shape", description: "Cursor shape for the editor.", @@ -1829,7 +1882,7 @@ fn editor_page() -> SettingsPage { SettingsPageItem::SectionHeader("Which-key Menu"), SettingsPageItem::SettingItem(SettingItem { title: "Show Which-key Menu", - description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.", + description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending. The pending keystrokes indicator remains visible, but its binding preview popover is disabled.", field: Box::new(SettingField { organization_override: None, json_path: Some("which_key.enabled"), @@ -3862,6 +3915,35 @@ fn search_and_files_page() -> SettingsPage { ] } + fn command_palette_section() -> [SettingsPageItem; 2] { + [ + SettingsPageItem::SectionHeader("Command Palette"), + SettingsPageItem::SettingItem(SettingItem { + title: "Use Command History", + description: "Whether to use command history ranking for sorting in the command palette.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("command_palette.use_command_history"), + pick: |settings_content| { + settings_content + .command_palette + .as_ref()? + .use_command_history + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .command_palette + .get_or_insert_default() + .use_command_history = value; + }, + }), + metadata: None, + files: USER, + }), + ] + } + fn file_finder_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("File Finder"), @@ -4052,12 +4134,17 @@ fn search_and_files_page() -> SettingsPage { SettingsPage { title: "Search & Files", - items: concat_sections![search_section(), file_finder_section(), file_scan_section()], + items: concat_sections![ + search_section(), + command_palette_section(), + file_finder_section(), + file_scan_section(), + ], } } fn window_and_layout_page() -> SettingsPage { - fn status_bar_section() -> [SettingsPageItem; 11] { + fn status_bar_section() -> [SettingsPageItem; 12] { [ SettingsPageItem::SectionHeader("Status Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -4171,6 +4258,29 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Pending Keystrokes Indicator", + description: "Show an indicator while a multi-stroke key binding is pending. If the input has a timeout, a countdown is shown and hovering pauses it. Its binding preview popover is disabled when the which-key menu is enabled.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("status_bar.pending_keystrokes_indicator"), + pick: |settings_content| { + settings_content + .status_bar + .as_ref()? + .pending_keystrokes_indicator + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .status_bar + .get_or_insert_default() + .pending_keystrokes_indicator = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Terminal Button", description: "Show the terminal button in the status bar.", @@ -4817,7 +4927,7 @@ fn window_and_layout_page() -> SettingsPage { }), SettingsPageItem::SettingItem(SettingItem { title: "Enable Preview From Project Panel", - description: "Whether to open tabs in preview mode when opened from the project panel with a single click.", + description: "Whether to open tabs in preview mode when opened from the project panel with a single click or the Open action.", field: Box::new(SettingField { organization_override: None, json_path: Some("preview_tabs.enable_preview_from_project_panel"), @@ -5074,9 +5184,48 @@ fn window_and_layout_page() -> SettingsPage { ] } - fn window_section() -> [SettingsPageItem; 4] { + fn window_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Window"), + SettingsPageItem::SettingItem(SettingItem { + title: "Title Format", + description: "Window title template. Available variables are `${projectName}`, `${fileName}`, `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, `${remoteHost}`, `${appName}`, `${branch}`, and `${separator}`. `${separator}` is omitted when adjacent variables are empty, but literal text is preserved. The collaboration indicator, when present, is appended after the rendered template. If the template renders to nothing, the default template is used instead.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("window_title_format"), + pick: |settings_content| { + settings_content.workspace.window_title_format.as_ref() + }, + write: |settings_content, value, _| { + settings_content.workspace.window_title_format = + value.filter(|format| !format.is_empty()); + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("${projectName}${separator}${fileName}"), + ..Default::default() + })), + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Title Separator", + description: "String substituted for `${separator}` in the window title format. Include any surrounding whitespace in the value.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("window_title_separator"), + pick: |settings_content| { + settings_content.workspace.window_title_separator.as_ref() + }, + write: |settings_content, value, _| { + settings_content.workspace.window_title_separator = value; + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some(" — "), + ..Default::default() + })), + files: USER, + }), // todo(settings_ui): Should we filter by platform.as_ref()? SettingsPageItem::SettingItem(SettingItem { title: "Use System Window Tabs", @@ -5272,7 +5421,7 @@ fn window_and_layout_page() -> SettingsPage { } fn panels_page() -> SettingsPage { - fn project_panel_section() -> [SettingsPageItem; 29] { + fn project_panel_section() -> [SettingsPageItem; 30] { [ SettingsPageItem::SectionHeader("Project Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5312,6 +5461,105 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + title: "Project Panel Title Tooltips Delay", + description: "Delay in milliseconds before tooltips appear for project panel titles.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("project_panel.title_tooltip_delay$"), + pick: |settings_content| { + Some( + &dynamic_variants::() + [settings_content + .project_panel + .as_ref()? + .title_tooltip_delay + .as_ref()? + .discriminant() + as usize], + ) + }, + write: |settings_content, value, _| { + let project_panel = + settings_content.project_panel.get_or_insert_default(); + project_panel.title_tooltip_delay = value.map(|value| match value { + settings::ProjectPanelTitleTooltipDelayDiscriminants::Default => { + settings::ProjectPanelTitleTooltipDelay::Default + } + settings::ProjectPanelTitleTooltipDelayDiscriminants::Disabled => { + settings::ProjectPanelTitleTooltipDelay::Disabled + } + settings::ProjectPanelTitleTooltipDelayDiscriminants::Custom => { + let delay = match project_panel.title_tooltip_delay { + Some(settings::ProjectPanelTitleTooltipDelay::Custom( + delay, + )) => settings::DelayMs(delay.0), + _ => settings::DelayMs(1500), + }; + settings::ProjectPanelTitleTooltipDelay::Custom(delay) + } + }); + }, + }), + metadata: None, + files: USER, + }, + pick_discriminant: |settings_content| { + Some( + settings_content + .project_panel + .as_ref()? + .title_tooltip_delay + .as_ref()? + .discriminant() as usize, + ) + }, + fields: dynamic_variants::() + .into_iter() + .map(|variant| match variant { + settings::ProjectPanelTitleTooltipDelayDiscriminants::Default => vec![], + settings::ProjectPanelTitleTooltipDelayDiscriminants::Disabled => vec![], + settings::ProjectPanelTitleTooltipDelayDiscriminants::Custom => { + vec![SettingItem { + files: USER, + title: "Custom Delay", + description: "Delay in milliseconds of the project panel title tooltips.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("project_panel.title_tooltip_delay"), + pick: |settings_content| match settings_content + .project_panel + .as_ref() + .and_then(|project_panel| { + project_panel.title_tooltip_delay.as_ref() + }) { + Some(settings::ProjectPanelTitleTooltipDelay::Custom( + value, + )) => Some(value), + _ => None, + }, + write: |settings_content, value, _| { + let Some(value) = value else { + return; + }; + if let Some( + settings::ProjectPanelTitleTooltipDelay::Custom(width), + ) = settings_content.project_panel.as_mut().and_then( + |project_panel| { + project_panel.title_tooltip_delay.as_mut() + }, + ) { + *width = value; + } + }, + }), + metadata: None, + }] + } + }) + .collect(), + }), SettingsPageItem::SettingItem(SettingItem { title: "Hide .gitignore", description: "Whether to hide the gitignore entries in the project panel.", @@ -5899,7 +6147,7 @@ fn panels_page() -> SettingsPage { field: Box::new( SettingField { organization_override: None, - json_path: Some("worktree.hidden_files"), + json_path: Some("hidden_files"), pick: |settings_content| { settings_content.project.worktree.hidden_files.as_ref() }, @@ -6697,7 +6945,7 @@ fn panels_page() -> SettingsPage { }), SettingsPageItem::SettingItem(SettingItem { title: "Agent Panel Flexible Sizing", - description: "Whether the agent panel should use flexible (proportional) sizing when docked to the left or right.", + description: "Whether the agent panel should use flexible (proportional) sizing when docked to the left or right. When enabled, the default width does not control the panel width, and resetting the panel restores the default proportion.", field: Box::new(SettingField { organization_override: None, json_path: Some("agent.flexible"), @@ -6711,7 +6959,7 @@ fn panels_page() -> SettingsPage { }), SettingsPageItem::SettingItem(SettingItem { title: "Agent Panel Default Width", - description: "Default width when the agent panel is docked to the left or right.", + description: "Default fixed width when the agent panel is docked to the left or right and flexible sizing is disabled.", field: Box::new(SettingField { organization_override: None, json_path: Some("agent.default_width"), @@ -8430,7 +8678,7 @@ fn collaboration_page() -> SettingsPage { } fn ai_page(cx: &App) -> SettingsPage { - fn general_section() -> [SettingsPageItem; 6] { + fn general_section() -> [SettingsPageItem; 8] { [ SettingsPageItem::SectionHeader("General"), SettingsPageItem::SettingItem(SettingItem { @@ -8448,14 +8696,75 @@ fn ai_page(cx: &App) -> SettingsPage { files: USER | PROJECT, }), SettingsPageItem::SettingItem(SettingItem { - title: "Threads Sidebar Side", - description: "Which side of the window the threads sidebar appears on.", + title: "Threads Sidebar Position", + description: "Which side of the window the Threads Sidebar appears on.", field: Box::new(SettingField { organization_override: None, - json_path: Some("agent.sidebar_side"), - pick: |settings_content| settings_content.agent.as_ref()?.sidebar_side.as_ref(), + json_path: Some("agent.threads_sidebar.position"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .threads_sidebar + .as_ref()? + .position + .as_ref() + }, write: |settings_content, value, _| { - settings_content.agent.get_or_insert_default().sidebar_side = value; + settings_content + .agent + .get_or_insert_default() + .set_threads_sidebar_position(value); + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Threads Sidebar Default Width", + description: "Default width of the Threads Sidebar. Changing this setting also updates a manually resized sidebar. Double-click the divider to reset to this width.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.threads_sidebar.default_width"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .threads_sidebar + .as_ref()? + .default_width + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .set_threads_sidebar_default_width(value); + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Threads Sidebar Auto Open", + description: "Whether opening a folder in an existing window automatically opens the Threads Sidebar.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.threads_sidebar.auto_open"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .threads_sidebar + .as_ref()? + .auto_open + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .set_threads_sidebar_auto_open(value); }, }), metadata: None, @@ -8675,6 +8984,25 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Prevent Idle Sleep", + description: "Whether to keep the system awake while agent threads are running.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.prevent_idle_sleep"), + pick: |settings_content| { + settings_content.agent.as_ref()?.prevent_idle_sleep.as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .prevent_idle_sleep = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Expand Edit Card", description: "Whether to have edit cards in the agent panel expanded, showing a Preview of the diff.", @@ -10292,7 +10620,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { ] } - fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 3] { + fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SettingItem(SettingItem { title: "Image Viewer", @@ -10313,6 +10641,29 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Open Markdown Files in Preview", + description: "Whether to automatically open Markdown files in the preview.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("markdown_preview.open_markdown_files_in_preview"), + pick: |settings_content| { + settings_content + .markdown_preview + .as_ref()? + .open_markdown_files_in_preview + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .markdown_preview + .get_or_insert_default() + .open_markdown_files_in_preview = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::DynamicItem(DynamicItem { discriminant: SettingItem { files: USER, @@ -10935,7 +11286,7 @@ fn edit_prediction_language_settings_section() -> [SettingsPageItem; 5] { }), SettingsPageItem::SettingItem(SettingItem { title: "Disable in Language Scopes", - description: "Controls whether edit predictions are shown in the given language scopes.", + description: "Disable edit predictions in these language scopes, such as \"comment\" and \"string\". Use \"...\" to add scopes without repeating the inherited list.", field: Box::new( SettingField { organization_override: None, diff --git a/crates/settings_ui/src/pages/llm_providers_page.rs b/crates/settings_ui/src/pages/llm_providers_page.rs index bac9b7abb0..2717d1cd59 100644 --- a/crates/settings_ui/src/pages/llm_providers_page.rs +++ b/crates/settings_ui/src/pages/llm_providers_page.rs @@ -324,8 +324,10 @@ fn render_inline_body( provider_name: SharedString, title: Option, description: Option, - view: AnyView, + view: impl IntoElement, ) -> AnyElement { + let view = view.into_any_element(); + if title.is_none() && description.is_none() { return v_flex() .pt_1() @@ -346,12 +348,20 @@ fn render_inline_body( .w_full() .min_w_0() .max_w_1_2() + .debug_selector(|| "inline-provider-description".into()) .when_some(title, |this, title| this.child(Label::new(title))) .when_some(description, |this, description| { this.child(render_inline_description(provider_name, description)) }), ) - .child(h_flex().flex_none().child(view)) + .child( + h_flex() + .min_w_0() + .max_w_1_2() + .flex_1() + .justify_end() + .child(view), + ) .into_any_element() } @@ -1314,3 +1324,74 @@ fn parse_u64_field(value: &str, name: &str) -> Result { .parse::() .map_err(|_| format!("{name} must be a number").into()) } + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, VisualTestContext, size}; + use language_models::provider::cloud; + use settings::SettingsStore; + + use super::*; + + struct YoungAccountProviderRow; + + impl Render for YoungAccountProviderRow { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().p_4().child( + div() + .w_full() + .debug_selector(|| "provider-row".into()) + .child(render_inline_body( + "Zed".into(), + Some("Subscribed to Business".into()), + Some(InlineDescription::Text( + "You have access to Zed's hosted models through your organization." + .into(), + )), + cloud::test_support::young_account_configuration(), + )), + ) + } + } + + #[gpui::test] + fn young_account_configuration_stays_within_provider_row(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + + for width in [500., 600., 800.] { + let window = cx.open_window(size(px(width), px(400.)), |_window, _cx| { + YoungAccountProviderRow + }); + cx.run_until_parked(); + + let mut visual_context = VisualTestContext::from_window(window.into(), cx); + let provider_row_bounds = visual_context + .debug_bounds("provider-row") + .expect("provider row should be rendered"); + let description_bounds = visual_context + .debug_bounds("inline-provider-description") + .expect("provider description should be rendered"); + let configuration_bounds = visual_context + .debug_bounds("zed-ai-configuration") + .expect("Zed AI configuration should be rendered"); + + assert!( + configuration_bounds.right() <= provider_row_bounds.right(), + "young account configuration extends past the provider row at {width}px" + ); + assert!( + configuration_bounds.size.height >= px(80.), + "young account warning does not wrap at {width}px" + ); + assert!( + description_bounds.size.width >= px(width / 3.), + "provider description collapsed at {width}px" + ); + } + } +} diff --git a/crates/settings_ui/src/pages/skill_creator.rs b/crates/settings_ui/src/pages/skill_creator.rs index 35f9cc6d27..de998e0da0 100644 --- a/crates/settings_ui/src/pages/skill_creator.rs +++ b/crates/settings_ui/src/pages/skill_creator.rs @@ -402,7 +402,7 @@ impl SkillCreatorPage { fn recompute_description_error(&mut self, cx: &mut Context) { let description = self.current_description(cx); - self.description_length = description.len(); + self.description_length = description.chars().count(); let error = validate_description(&description).err(); self.description_error = error; self.description_editor @@ -1178,15 +1178,9 @@ fn derived_description_from_markdown(content: &str) -> Option { } fn truncate_description(description: &str) -> String { - if description.len() <= MAX_SKILL_DESCRIPTION_LEN { - return description.to_string(); - } - - let mut end = MAX_SKILL_DESCRIPTION_LEN; - while !description.is_char_boundary(end) { - end -= 1; - } - description[..end].trim().to_string() + util::truncate(description, MAX_SKILL_DESCRIPTION_LEN) + .trim() + .to_string() } /// Serialize the SKILL.md file to disk at `//SKILL.md`. @@ -1541,6 +1535,15 @@ mod tests { ); } + #[test] + fn truncate_description_counts_unicode_characters() { + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let truncated = truncate_description(&description); + + assert_eq!(truncated.chars().count(), MAX_SKILL_DESCRIPTION_LEN); + assert_eq!(truncated, "中".repeat(MAX_SKILL_DESCRIPTION_LEN)); + } + #[gpui::test] async fn fetch_imported_skill_retries_404_with_github_token(_cx: &mut gpui::TestAppContext) { let client = TestHttpClient::new_sequence(vec![ diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index e40854cffc..78a83488f9 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -554,6 +554,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_toggle_button) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -655,6 +656,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_icon_theme_picker) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -4694,6 +4696,20 @@ fn update_settings_file( window: &mut Window, cx: &mut App, update: impl 'static + Send + FnOnce(&mut SettingsContent, &App), +) -> Result<()> { + let mut update = Some(update); + update_settings_file_inner(file, file_name, window, cx, &mut || { + Box::new(update.take().expect("called once")) + }) +} + +#[inline(never)] +fn update_settings_file_inner( + file: SettingsUiFile, + file_name: Option<&'static str>, + window: &mut Window, + cx: &mut App, + update: &mut dyn FnMut() -> Box, ) -> Result<()> { telemetry::event!("Settings Change", setting = file_name, type = file.setting_type()); @@ -4708,7 +4724,7 @@ fn update_settings_file( } SettingsUiFile::User => { // todo(settings_ui) error? - SettingsStore::global(cx).update_settings_file(::global(cx), update); + SettingsStore::global(cx).update_settings_file(::global(cx), update()); Ok(()) } SettingsUiFile::Server(_) => unimplemented!(), @@ -4833,10 +4849,11 @@ impl ProjectSettingsUpdateQueue { } } +#[inline(never)] fn update_project_setting_file( worktree_id: WorktreeId, rel_path: Arc, - update: impl 'static + FnOnce(&mut SettingsContent, &App), + update: &mut dyn FnMut() -> Box, settings_window: Entity, cx: &mut App, ) -> Result<()> { @@ -4857,7 +4874,7 @@ fn update_project_setting_file( settings_window: settings_window.downgrade(), project: project.downgrade(), worktree: worktree.downgrade(), - update: Box::new(update), + update: update(), }; ProjectSettingsUpdateQueue::enqueue(cx, entry); diff --git a/crates/shell_command_parser/Cargo.toml b/crates/shell_command_parser/Cargo.toml index 0e00eac7ef..8dec81812a 100644 --- a/crates/shell_command_parser/Cargo.toml +++ b/crates/shell_command_parser/Cargo.toml @@ -10,8 +10,7 @@ workspace = true [lib] path = "src/shell_command_parser.rs" +doctest = false [dependencies] brush-parser.workspace = true - -[dev-dependencies] diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index e15b7b339e..6d4b5b8e32 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/sidebar.rs" +doctest = false [features] default = [] diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 5046781f55..88a0065585 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4,7 +4,7 @@ use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::{ThreadStore, ZED_AGENT_ID}; use agent_client_protocol::schema::v1 as acp; -use agent_settings::AgentSettings; +use agent_settings::{AgentSettings, THREADS_LIST_MAX_WIDTH, THREADS_LIST_MIN_WIDTH}; use agent_ui::terminal_thread_metadata_store::{ TerminalThreadMetadata, TerminalThreadMetadataStore, terminal_title_prefix, }; @@ -47,7 +47,7 @@ use remote::{RemoteConnectionOptions, same_remote_connection_identity}; use ui::utils::platform_title_bar_height; use serde::{Deserialize, Serialize}; -use settings::Settings as _; +use settings::{Settings as _, SettingsStore}; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::mem; @@ -103,10 +103,6 @@ gpui::actions!( ] ); -const DEFAULT_WIDTH: Pixels = px(300.0); -const MIN_WIDTH: Pixels = px(200.0); -const MAX_WIDTH: Pixels = px(800.0); - #[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum SerializedSidebarView { #[default] @@ -125,6 +121,13 @@ enum NewEntryTarget { struct SerializedSidebar { #[serde(default)] width: Option, + /// Whether `width` came from the user dragging the divider. + /// + /// Legacy state recorded every width without this flag. A width other than + /// the old default of 300 pixels still identifies a manual resize. Only + /// that ambiguous default falls back to `agent.threads_sidebar.default_width`. + #[serde(default)] + width_set_by_user: bool, #[serde(default)] active_view: SerializedSidebarView, } @@ -763,6 +766,11 @@ fn create_worktree_in_workspace( pub struct Sidebar { multi_workspace: WeakEntity, width: Pixels, + /// Whether `width` came from the user rather than from + /// `agent.threads_sidebar.default_width`. Only a user-chosen width is persisted, so + /// that changing the setting is not overridden by a width the user never + /// picked. Serialization runs on many triggers besides resizing. + width_set_by_user: bool, focus_handle: FocusHandle, filter_editor: Entity, rename_editor: Entity, @@ -832,6 +840,18 @@ impl Sidebar { AgentThreadWorktreeLabelFlag::watch(cx); + let mut previous_default_width = + AgentSettings::get_global(cx).threads_sidebar.default_width; + cx.observe_global::(move |this, cx| { + let width = AgentSettings::get_global(cx).threads_sidebar.default_width; + if previous_default_width != width { + previous_default_width = width; + this.set_width(None, cx); + this.serialize(cx); + } + }) + .detach(); + let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Search threads…", window, cx); @@ -917,7 +937,8 @@ impl Sidebar { Self { multi_workspace: multi_workspace.downgrade(), - width: DEFAULT_WIDTH, + width: AgentSettings::get_global(cx).threads_sidebar.default_width, + width_set_by_user: false, focus_handle, filter_editor, rename_editor, @@ -3847,7 +3868,11 @@ impl Sidebar { anyhow::bail!("Thread not found in database"); }; - let request = agent::build_thread_title_request(&db_thread.messages, temperature); + let request = agent::build_thread_title_request( + &session_id, + &db_thread.messages, + temperature, + ); let title = SharedString::from(agent::stream_thread_title(model, request, cx).await?); @@ -6619,6 +6644,7 @@ impl Sidebar { let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); move |this, _, window, cx| { + this.selection = None; this.activate_terminal_entry( metadata.clone(), workspace.clone(), @@ -7788,7 +7814,11 @@ impl WorkspaceSidebar for Sidebar { } fn set_width(&mut self, width: Option, cx: &mut Context) { - self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + // `None` is the reset gesture, which hands the width back to the setting. + self.width_set_by_user = width.is_some(); + self.width = width + .unwrap_or_else(|| AgentSettings::get_global(cx).threads_sidebar.default_width) + .clamp(THREADS_LIST_MIN_WIDTH, THREADS_LIST_MAX_WIDTH); cx.notify(); } @@ -7828,7 +7858,8 @@ impl WorkspaceSidebar for Sidebar { fn serialized_state(&self, _cx: &App) -> Option { let serialized = SerializedSidebar { - width: Some(f32::from(self.width)), + width: self.width_set_by_user.then(|| f32::from(self.width)), + width_set_by_user: self.width_set_by_user, active_view: match self.view { SidebarView::ThreadList => SerializedSidebarView::ThreadList, SidebarView::Archive(_) => SerializedSidebarView::History, @@ -7844,8 +7875,13 @@ impl WorkspaceSidebar for Sidebar { cx: &mut Context, ) { if let Some(serialized) = serde_json::from_str::(state).log_err() { - if let Some(width) = serialized.width { - self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH); + // Legacy widths other than the old 300-pixel default imply a manual resize + if let Some(width) = serialized + .width + .filter(|width| serialized.width_set_by_user || *width != 300.0) + { + self.width = px(width).clamp(THREADS_LIST_MIN_WIDTH, THREADS_LIST_MAX_WIDTH); + self.width_set_by_user = true; } if serialized.active_view == SerializedSidebarView::History { cx.defer_in(window, |this, window, cx| { diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 9d1a0abc37..941e1a3f2d 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -14,7 +14,7 @@ use agent_ui::{ }; use chrono::DateTime; use fs::{FakeFs, Fs}; -use gpui::TestAppContext; +use gpui::{TestAppContext, UpdateGlobal}; use pretty_assertions::assert_eq; use project::AgentId; use settings::SettingsStore; @@ -298,6 +298,17 @@ fn setup_sidebar_closed( sidebar } +fn set_threads_sidebar_default_width(width: f32, cx: &mut App) { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + &format!(r#"{{"agent": {{"threads_sidebar": {{"default_width": {width}}}}}}}"#), + cx, + ) + .unwrap(); + }); +} + async fn save_n_test_threads( count: u32, project: &Entity, @@ -871,6 +882,236 @@ async fn test_serialization_round_trip(cx: &mut TestAppContext) { assert_eq!(width1, px(420.0)); } +#[gpui::test] +async fn test_width_reset_returns_configured_default(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + cx.update(|cx| set_threads_sidebar_default_width(360.0, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.width), + px(360.0), + "a fresh sidebar should open at the configured width" + ); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.set_width(Some(px(420.0)), cx); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(420.0)); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.set_width(None, cx); + }); + cx.run_until_parked(); + + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.width), + px(360.0), + "resetting the width should return to the configured width, not a fixed default" + ); +} + +#[gpui::test] +async fn test_width_setting_overrides_manual_resize(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + cx.update(|_window, cx| set_threads_sidebar_default_width(360.0, cx)); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(360.0)); + assert!(!sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user)); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.set_width(Some(px(420.0)), cx); + }); + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{"agent":{"threads_sidebar":{"default_width":360,"position":"right"}}}"#, + cx, + ) + .expect("settings are valid"); + }); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(420.0)); + assert!(sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user)); + + cx.update(|_window, cx| set_threads_sidebar_default_width(500.0, cx)); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(500.0)); + assert!(!sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user)); + + for (configured, expected) in [ + (5.0, THREADS_LIST_MIN_WIDTH), + (360.0, px(360.0)), + (5000.0, THREADS_LIST_MAX_WIDTH), + ] { + cx.update(|_window, cx| set_threads_sidebar_default_width(configured, cx)); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), expected); + let serialized = sidebar + .read_with(cx, |sidebar, cx| sidebar.serialized_state(cx)) + .expect("sidebar state is serialized"); + let serialized: SerializedSidebar = + serde_json::from_str(&serialized).expect("sidebar state is valid"); + assert_eq!(serialized.width, None); + assert!(!serialized.width_set_by_user); + } +} + +#[gpui::test] +async fn test_restored_width_preserves_legacy_resizes(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + for (state, expected, width_set_by_user) in [ + (r#"{"width":null}"#, 360.0, false), + (r#"{"width":300.0}"#, 360.0, false), + (r#"{"width":420.0}"#, 420.0, true), + (r#"{"width":300.0,"width_set_by_user":true}"#, 300.0, true), + ] { + cx.update(|_window, cx| set_threads_sidebar_default_width(360.0, cx)); + let sidebar = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.restore_serialized_state(state, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.width), + px(expected) + ); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user), + width_set_by_user + ); + + let serialized = sidebar + .read_with(cx, |sidebar, cx| sidebar.serialized_state(cx)) + .expect("sidebar state is serialized"); + let serialized: SerializedSidebar = + serde_json::from_str(&serialized).expect("sidebar state is valid"); + assert_eq!(serialized.width, width_set_by_user.then_some(expected)); + assert_eq!(serialized.width_set_by_user, width_set_by_user); + + cx.update(|_window, cx| set_threads_sidebar_default_width(500.0, cx)); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.width), px(500.0)); + assert!(!sidebar.read_with(cx, |sidebar, _| sidebar.width_set_by_user)); + } +} + +#[gpui::test] +async fn test_configured_width_is_clamped_into_range(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + cx.update(|cx| set_threads_sidebar_default_width(5000.0, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let wide_sidebar = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); + cx.run_until_parked(); + assert_eq!( + wide_sidebar.read_with(cx, |sidebar, _| sidebar.width), + THREADS_LIST_MAX_WIDTH, + "a configured width above the maximum should be clamped at construction" + ); + + wide_sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.set_width(None, cx); + }); + cx.run_until_parked(); + assert_eq!( + wide_sidebar.read_with(cx, |sidebar, _| sidebar.width), + THREADS_LIST_MAX_WIDTH, + "resetting the width should not restore the unclamped configured width" + ); + + cx.update(|_window, cx| set_threads_sidebar_default_width(5.0, cx)); + let narrow_sidebar = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); + cx.run_until_parked(); + assert_eq!( + narrow_sidebar.read_with(cx, |sidebar, _| sidebar.width), + THREADS_LIST_MIN_WIDTH, + "a configured width below the minimum should be clamped at construction" + ); +} + +#[gpui::test] +async fn test_only_a_user_chosen_width_is_persisted(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + cx.update(|cx| set_threads_sidebar_default_width(360.0, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Serialization runs on many triggers besides resizing, so a sidebar the + // user has never resized must not record a width at all. + let untouched = sidebar + .read_with(cx, |sidebar, cx| sidebar.serialized_state(cx)) + .expect("serialized_state should return Some"); + assert!( + !untouched.contains("360"), + "an unresized sidebar should not persist its width, got {untouched}" + ); + + // A legacy width matching the old default is ambiguous, so the setting takes precedence + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.restore_serialized_state(r#"{"width":300.0}"#, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.width), + px(360.0), + "the legacy default width falls back to the setting" + ); + + // A width the user picked survives, and keeps surviving across restores. + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.set_width(Some(px(420.0)), cx); + }); + cx.run_until_parked(); + let resized = sidebar + .read_with(cx, |sidebar, cx| sidebar.serialized_state(cx)) + .expect("serialized_state should return Some"); + + let restored = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); + cx.run_until_parked(); + restored.update_in(cx, |sidebar, window, cx| { + sidebar.restore_serialized_state(&resized, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + restored.read_with(cx, |sidebar, _| sidebar.width), + px(420.0), + "a user-chosen width should survive a restore" + ); + + // Resetting gives the width back to the setting, and stops persisting it. + restored.update_in(cx, |sidebar, _window, cx| { + sidebar.set_width(None, cx); + }); + cx.run_until_parked(); + let after_reset = restored + .read_with(cx, |sidebar, cx| sidebar.serialized_state(cx)) + .expect("serialized_state should return Some"); + assert!( + !after_reset.contains("420"), + "resetting should stop persisting the width, got {after_reset}" + ); +} + #[gpui::test] async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppContext) { // A regression test to ensure that restoring a serialized archive view does not panic. @@ -884,6 +1125,7 @@ async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppCon let serialized = serde_json::to_string(&SerializedSidebar { width: Some(400.0), + width_set_by_user: true, active_view: SerializedSidebarView::History, }) .expect("serialization should succeed"); @@ -1517,35 +1759,58 @@ async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { +async fn test_refocus_sidebar_with_no_selection_focuses_search(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); - // Initially no selection - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + assert!(!sidebar.focus_handle.is_focused(window)); + assert!(!sidebar.filter_editor.read(cx).is_focused(window)); + assert_eq!(sidebar.selection, None); + }); - // Open the sidebar so it's rendered, then focus it to trigger focus_in. - // focus_in no longer sets a default selection. + // Refocusing with no selection sends focus to search without selecting a row. focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + sidebar.update_in(cx, |sidebar, window, cx| { + assert!(sidebar.filter_editor.read(cx).is_focused(window)); + assert_eq!(sidebar.selection, None); + }); +} - // Manually set a selection, blur, then refocus — selection should be preserved - sidebar.update_in(cx, |sidebar, _window, _cx| { +#[gpui::test] +async fn test_refocus_sidebar_with_selection_preserves_it(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + + sidebar.update(cx, |sidebar, cx| { sidebar.selection = Some(0); + cx.notify(); }); - - cx.update(|window, cx| { - window.blur(cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); }); cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); + // Refocusing with a selection returns focus to the list and keeps that row selected. + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, _cx| { + assert!(sidebar.focus_handle.is_focused(window)); + assert_eq!(sidebar.selection, Some(0)); }); - cx.run_until_parked(); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); } #[gpui::test] @@ -5001,74 +5266,301 @@ async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_threa ); } +struct SidebarClickFixture { + sidebar: Entity, + panel: Entity, +} + +impl SidebarClickFixture { + fn new(multi_workspace: &Entity, cx: &mut gpui::VisualTestContext) -> Self { + let (sidebar, panel) = setup_sidebar_with_agent_panel(multi_workspace, cx); + Self { sidebar, panel } + } + + fn insert_terminal(&self, title: &str, cx: &mut gpui::VisualTestContext) -> TerminalId { + let terminal_id = self + .panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal(title, true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + terminal_id + } + + fn terminal_index(&self, terminal_id: TerminalId, cx: &mut gpui::VisualTestContext) -> usize { + self.sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == terminal_id + ) + }) + .expect("terminal should be visible in sidebar") + }) + } + + fn thread_index(&self, session_id: &acp::SessionId, cx: &mut gpui::VisualTestContext) -> usize { + self.sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) + if thread.metadata.session_id.as_ref() == Some(session_id) + ) + }) + .expect("thread should be visible in sidebar") + }) + } + + fn select_and_focus(&self, entry_index: usize, cx: &mut gpui::VisualTestContext) { + self.sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(entry_index); + sidebar.focus_handle.focus(window, cx); + cx.notify(); + }); + cx.run_until_parked(); + } + + fn click(&self, entry_index: usize, cx: &mut gpui::VisualTestContext) { + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::size(px(400.), px(400.)), + |_, _| self.sidebar.clone().into_any_element(), + ); + let entry_bounds = self.sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .list_state + .bounds_for_item(entry_index) + .expect("sidebar entry should be measured") + }); + cx.simulate_click(entry_bounds.center(), gpui::Modifiers::none()); + cx.run_until_parked(); + } +} + #[gpui::test] -async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; +async fn test_keyboard_confirm_on_terminal_preserves_selection(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let fixture = SidebarClickFixture::new(&multi_workspace, cx); + let terminal_id = fixture.insert_terminal("Terminal A", cx); + let other_terminal_id = fixture.insert_terminal("Terminal B", cx); + let terminal_index = fixture.terminal_index(terminal_id, cx); + fixture.select_and_focus(terminal_index, cx); + + fixture.sidebar.update_in(cx, |sidebar, window, _cx| { + assert!(sidebar.focus_handle.is_focused(window)); + assert_eq!(sidebar.selection, Some(terminal_index)); + assert!( + matches!( + &sidebar.active_entry, + Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) + if *active_terminal_id == other_terminal_id + ), + "Terminal B should be active before confirming Terminal A, got {:?}", + sidebar.active_entry, + ); + }); + + // Confirm switches from Terminal B to Terminal A without clearing selection. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + fixture.sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!(sidebar.selection, Some(terminal_index)); + assert!( + matches!( + &sidebar.active_entry, + Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) + if *active_terminal_id == terminal_id + ), + "confirmed terminal should be active, got {:?}", + sidebar.active_entry, + ); + }); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_thread_preserves_selection(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - + let fixture = SidebarClickFixture::new(&multi_workspace, cx); + let session_id = acp::SessionId::new(Arc::from("thread-a")); save_thread_metadata( - acp::SessionId::new(Arc::from("t-1")), + session_id.clone(), Some("Thread A".into()), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + Utc::now(), None, None, &project, cx, ); + let thread_index = fixture.thread_index(&session_id, cx); + fixture.select_and_focus(thread_index, cx); + // Confirm must preserve selection without the test setting it again. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + fixture.sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!(sidebar.selection, Some(thread_index)); + assert_active_thread(sidebar, &session_id, "confirmed thread should be active"); + }); +} + +#[gpui::test] +async fn test_clicking_different_terminal_clears_sidebar_selection(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let fixture = SidebarClickFixture::new(&multi_workspace, cx); + let terminal_a_id = fixture.insert_terminal("Terminal A", cx); + let terminal_b_id = fixture.insert_terminal("Terminal B", cx); + let terminal_a_index = fixture.terminal_index(terminal_a_id, cx); + let terminal_b_index = fixture.terminal_index(terminal_b_id, cx); + + fixture.panel.update_in(cx, |panel, window, cx| { + panel.activate_terminal(terminal_a_id, true, window, cx); + }); + cx.run_until_parked(); + fixture.select_and_focus(terminal_a_index, cx); + + fixture.sidebar.update_in(cx, |sidebar, window, _cx| { + assert!(sidebar.focus_handle.is_focused(window)); + assert_eq!(sidebar.selection, Some(terminal_a_index)); + assert!( + matches!( + &sidebar.active_entry, + Some(ActiveEntry::Terminal { terminal_id, .. }) + if *terminal_id == terminal_a_id + ), + "Terminal A should be active before clicking Terminal B, got {:?}", + sidebar.active_entry, + ); + }); + + // Clicking a different terminal clears keyboard selection and activates that terminal. + fixture.click(terminal_b_index, cx); + + fixture.sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!(sidebar.selection, None); + assert!( + matches!( + &sidebar.active_entry, + Some(ActiveEntry::Terminal { terminal_id, .. }) + if *terminal_id == terminal_b_id + ), + "Terminal B should be active after the click, got {:?}", + sidebar.active_entry, + ); + }); +} + +#[gpui::test] +async fn test_clicking_active_terminal_clears_sidebar_selection(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let fixture = SidebarClickFixture::new(&multi_workspace, cx); + let terminal_id = fixture.insert_terminal("Terminal A", cx); + let terminal_index = fixture.terminal_index(terminal_id, cx); + fixture.select_and_focus(terminal_index, cx); + + fixture.sidebar.update_in(cx, |sidebar, window, _cx| { + assert!(sidebar.focus_handle.is_focused(window)); + assert_eq!(sidebar.selection, Some(terminal_index)); + assert!( + matches!( + &sidebar.active_entry, + Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) + if *active_terminal_id == terminal_id + ), + "Terminal A should be active before clicking it, got {:?}", + sidebar.active_entry, + ); + }); + + // Clicking the active terminal clears keyboard selection without changing the active terminal. + fixture.click(terminal_index, cx); + + fixture.sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!(sidebar.selection, None); + assert!( + matches!( + &sidebar.active_entry, + Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) + if *active_terminal_id == terminal_id + ), + "Terminal A should remain active after the click, got {:?}", + sidebar.active_entry, + ); + }); +} + +#[gpui::test] +async fn test_clicking_different_thread_clears_sidebar_selection(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let fixture = SidebarClickFixture::new(&multi_workspace, cx); + let thread_a_session_id = acp::SessionId::new(Arc::from("thread-a")); + let thread_b_session_id = acp::SessionId::new(Arc::from("thread-b")); save_thread_metadata( - acp::SessionId::new(Arc::from("t-2")), - Some("Thread B".into()), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + thread_a_session_id.clone(), + Some("Thread A".into()), + Utc::now() + chrono::Duration::days(2), None, None, &project, cx, ); - - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [my-project]", - " Thread A", - " Thread B", - ] + save_thread_metadata( + thread_b_session_id.clone(), + Some("Thread B".into()), + Utc::now() + chrono::Duration::days(1), + None, + None, + &project, + cx, ); + let thread_a_index = fixture.thread_index(&thread_a_session_id, cx); + let thread_b_index = fixture.thread_index(&thread_b_session_id, cx); + fixture.select_and_focus(thread_a_index, cx); + cx.dispatch_action(Confirm); + cx.run_until_parked(); + fixture.select_and_focus(thread_a_index, cx); - // Keyboard confirm preserves selection. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); + fixture.sidebar.update_in(cx, |sidebar, window, _cx| { + assert!(sidebar.focus_handle.is_focused(window)); + assert_eq!(sidebar.selection, Some(thread_a_index)); + assert_active_thread( + sidebar, + &thread_a_session_id, + "Thread A should be active before clicking Thread B", + ); }); - assert_eq!( - sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(1) - ); - // Click handlers clear selection to None so no highlight lingers - // after a click regardless of focus state. The hover style provides - // visual feedback during mouse interaction instead. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = None; - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let project_group_key = ProjectGroupKey::new(None, path_list); - sidebar.toggle_collapse(&project_group_key, window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + // Clicking a different thread clears keyboard selection and activates that thread. + fixture.click(thread_b_index, cx); - // When the user tabs back into the sidebar, focus_in no longer - // restores selection — it stays None. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.focus_in(window, cx); + fixture.sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!(sidebar.selection, None); + assert_active_thread( + sidebar, + &thread_b_session_id, + "Thread B should be active after the click", + ); }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); } #[gpui::test] diff --git a/crates/snippet_provider/Cargo.toml b/crates/snippet_provider/Cargo.toml index c1f04117d4..da1ed237d7 100644 --- a/crates/snippet_provider/Cargo.toml +++ b/crates/snippet_provider/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +doctest = false + [features] test-support = [] diff --git a/crates/snippet_provider/src/registry.rs b/crates/snippet_provider/src/registry.rs index ae721ee632..17c6096295 100644 --- a/crates/snippet_provider/src/registry.rs +++ b/crates/snippet_provider/src/registry.rs @@ -43,10 +43,14 @@ impl SnippetRegistry { let kind = file_path .file_stem() .and_then(|stem| stem.to_str().and_then(file_stem_to_key)); - let snippets = crate::file_to_snippets(snippets_in_file, file_path); + let new_snippets = + crate::file_to_snippets(snippets_in_file, file_path).filter_map(Result::log_err); + self.snippets .write() - .insert(kind, snippets.filter_map(Result::log_err).collect()); + .entry(kind) + .or_default() + .extend(new_snippets); Ok(()) } @@ -55,3 +59,186 @@ impl SnippetRegistry { self.snippets.read().get(kind).cloned().unwrap_or_default() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_register_snippets_single_language() { + let registry = SnippetRegistry::new(); + registry + .register_snippets( + Path::new("rust.json"), + r#"{"Hello World": {"prefix": "hello", "body": "Hello, ${1:World}!"}}"#, + ) + .unwrap(); + + let snippets = registry.get_snippets(&Some("rust".to_owned())); + assert_eq!(snippets.len(), 1); + assert_eq!(snippets[0].name, "Hello World"); + assert_eq!(snippets[0].prefix, vec!["hello".to_owned()]); + } + + #[test] + fn test_register_snippets_two_extensions_same_language() { + let registry = SnippetRegistry::new(); + registry + .register_snippets( + Path::new("ruby.json"), + r#"{"Snippet One": {"prefix": "one", "body": "snippet_one"}}"#, + ) + .unwrap(); + registry + .register_snippets( + Path::new("ruby.json"), + r#"{"Snippet Two": {"prefix": "two", "body": "snippet_two"}}"#, + ) + .unwrap(); + + let snippets = registry.get_snippets(&Some("ruby".to_owned())); + assert_eq!( + snippets.len(), + 2, + "Snippets from both extensions should be present" + ); + let names: Vec<&str> = snippets.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Snippet One")); + assert!(names.contains(&"Snippet Two")); + } + + #[test] + fn test_register_snippets_same_first_prefix_preserved() -> Result<()> { + let registry = SnippetRegistry::new(); + registry.register_snippets( + Path::new("ruby.json"), + r#"{"For Loop": {"prefix": "for", "body": "for ${1:i} in ${2:iter} do\n$0\nend"}}"#, + )?; + registry.register_snippets( + Path::new("ruby.json"), + r#"{"Different Name": {"prefix": "for", "body": "${2:iter}.each do |${1:item}|\n$0\nend"}}"#, + )?; + + let snippets = registry.get_snippets(&Some("ruby".to_owned())); + assert_eq!( + snippets.len(), + 2, + "Snippets sharing the same first prefix should both be preserved" + ); + assert_eq!(snippets[0].name, "For Loop"); + assert_eq!(snippets[0].prefix, vec!["for".to_owned()]); + assert_eq!(snippets[1].name, "Different Name"); + assert_eq!(snippets[1].prefix, vec!["for".to_owned()]); + Ok(()) + } + + #[test] + fn test_register_snippets_same_first_prefix_with_extra_prefixes_preserved() -> Result<()> { + let registry = SnippetRegistry::new(); + registry.register_snippets( + Path::new("ruby.json"), + r#"{"For Loop": {"prefix": "for", "body": "for ${1:i} in ${2:iter} do\n$0\nend"}}"#, + )?; + registry.register_snippets( + Path::new("ruby.json"), + r#"{"For Loop Alt": {"prefix": ["for", "floop"], "body": "${2:iter}.each do |${1:item}|\n$0\nend"}}"#, + )?; + + let snippets = registry.get_snippets(&Some("ruby".to_owned())); + assert_eq!( + snippets.len(), + 2, + "Snippets sharing the same first prefix should be preserved along with their extra prefixes" + ); + assert_eq!(snippets[0].name, "For Loop"); + assert_eq!(snippets[0].prefix, vec!["for".to_owned()]); + assert_eq!(snippets[1].name, "For Loop Alt"); + assert_eq!( + snippets[1].prefix, + vec!["for".to_owned(), "floop".to_owned()] + ); + Ok(()) + } + + #[test] + fn test_register_snippets_different_first_prefixes_not_deduplicated() { + let registry = SnippetRegistry::new(); + registry + .register_snippets( + Path::new("ruby.json"), + r#"{"For Loop": {"prefix": "for", "body": "for ${1:i} in ${2:iter} do\n$0\nend"}}"#, + ) + .unwrap(); + registry + .register_snippets( + Path::new("ruby.json"), + r#"{"While Loop": {"prefix": "while", "body": "while ${1:cond}\n$0\nend"}}"#, + ) + .unwrap(); + + let snippets = registry.get_snippets(&Some("ruby".to_owned())); + assert_eq!( + snippets.len(), + 2, + "Snippets with different first prefixes should not be deduplicated" + ); + } + + #[test] + fn test_register_global_snippets() { + let registry = SnippetRegistry::new(); + registry + .register_snippets( + Path::new("snippets.json"), + r#"{"Global Snippet": {"prefix": "global", "body": "a global snippet"}}"#, + ) + .unwrap(); + + let global_snippets = registry.get_snippets(&None); + assert_eq!(global_snippets.len(), 1); + assert_eq!(global_snippets[0].name, "Global Snippet"); + + let miskeyed = registry.get_snippets(&Some("snippets".to_owned())); + assert!( + miskeyed.is_empty(), + "Should not be stored under the language key 'snippets'" + ); + } + + #[test] + fn test_register_snippets_different_languages_are_isolated() { + let registry = SnippetRegistry::new(); + registry + .register_snippets( + Path::new("rust.json"), + r#"{"Rust Snippet": {"prefix": "rsnip", "body": "fn ${1:name}() {}"}}"#, + ) + .unwrap(); + registry + .register_snippets( + Path::new("python.json"), + r#"{"Python Snippet": {"prefix": "pysnip", "body": "def ${1:name}():"}}"#, + ) + .unwrap(); + + let rust_snippets = registry.get_snippets(&Some("rust".to_owned())); + assert_eq!(rust_snippets.len(), 1); + assert_eq!(rust_snippets[0].name, "Rust Snippet"); + + let python_snippets = registry.get_snippets(&Some("python".to_owned())); + assert_eq!(python_snippets.len(), 1); + assert_eq!(python_snippets[0].name, "Python Snippet"); + } + + #[test] + fn test_get_snippets_unknown_language_returns_empty() { + let registry = SnippetRegistry::new(); + assert!( + registry + .get_snippets(&Some("nonexistent".to_owned())) + .is_empty() + ); + assert!(registry.get_snippets(&None).is_empty()); + } +} diff --git a/crates/snippets_ui/Cargo.toml b/crates/snippets_ui/Cargo.toml index 7335accac0..a52a2a33cb 100644 --- a/crates/snippets_ui/Cargo.toml +++ b/crates/snippets_ui/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/snippets_ui.rs" +test = false +doctest = false [dependencies] file_icons.workspace = true diff --git a/crates/sqlez/Cargo.toml b/crates/sqlez/Cargo.toml index f5db6f96a0..f3b666911e 100644 --- a/crates/sqlez/Cargo.toml +++ b/crates/sqlez/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +doctest = false + [dependencies] anyhow.workspace = true collections.workspace = true diff --git a/crates/sqlez/src/connection.rs b/crates/sqlez/src/connection.rs index fb3194aaf4..a756436b55 100644 --- a/crates/sqlez/src/connection.rs +++ b/crates/sqlez/src/connection.rs @@ -37,6 +37,15 @@ impl Connection { // Turn on extended error codes sqlite3_extended_result_codes(connection.sqlite3, 1); + // Wait for the database lock to be released instead of failing + // immediately with SQLITE_BUSY. Some databases (e.g. the agent + // threads database) are shared between Zed instances, so transient + // lock contention is expected; failing fast turns it into a + // storm of dropped saves and retry churn. + if !connection.sqlite3.is_null() { + sqlite3_busy_timeout(connection.sqlite3, 5000); + } + connection.last_error()?; } diff --git a/crates/sqlez_macros/Cargo.toml b/crates/sqlez_macros/Cargo.toml index cff96d0b89..0c9beac390 100644 --- a/crates/sqlez_macros/Cargo.toml +++ b/crates/sqlez_macros/Cargo.toml @@ -12,6 +12,7 @@ workspace = true path = "src/sqlez_macros.rs" proc-macro = true doctest = false +test = false [dependencies] sqlez.workspace = true diff --git a/crates/streaming_diff/Cargo.toml b/crates/streaming_diff/Cargo.toml index d3365d5793..8d13705b9c 100644 --- a/crates/streaming_diff/Cargo.toml +++ b/crates/streaming_diff/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/streaming_diff.rs" +doctest = false [dependencies] ordered-float.workspace = true diff --git a/crates/svg_preview/Cargo.toml b/crates/svg_preview/Cargo.toml index 9ee085ee07..b206b616d9 100644 --- a/crates/svg_preview/Cargo.toml +++ b/crates/svg_preview/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/svg_preview.rs" +test = false +doctest = false [dependencies] multi_buffer.workspace = true diff --git a/crates/system_specs/Cargo.toml b/crates/system_specs/Cargo.toml index 7af62d934a..dcd6dd1e8c 100644 --- a/crates/system_specs/Cargo.toml +++ b/crates/system_specs/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/system_specs.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 62be1daf1b..68ab07bb63 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -16,6 +16,7 @@ use project::Project; use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; +use std::time::Duration; use std::{cmp::Reverse, sync::Arc}; use ui::{ DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip, @@ -29,6 +30,7 @@ use workspace::{ }; const PANEL_WIDTH_REMS: f32 = 28.; +const POPOVER_DELAY: Duration = Duration::from_millis(300); /// Toggles the tab switcher interface. #[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)] @@ -54,6 +56,8 @@ actions!( pub struct TabSwitcher { picker: Entity>, init_modifiers: Option, + visible: bool, + _show_task: Option>, } impl ModalView for TabSwitcher {} @@ -169,6 +173,17 @@ impl TabSwitcher { } else { window.modifiers().modified().then_some(window.modifiers()) }; + let has_modifiers = init_modifiers.is_some(); + let _show_task = has_modifiers.then(|| { + cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(POPOVER_DELAY).await; + this.update_in(cx, |this, _window, cx| { + this.visible = true; + cx.notify(); + }) + .ok(); + }) + }); Self { picker: cx.new(|cx| { if is_global { @@ -179,6 +194,8 @@ impl TabSwitcher { .initial_width(rems(PANEL_WIDTH_REMS)) }), init_modifiers, + visible: !has_modifiers, + _show_task, } } @@ -195,8 +212,12 @@ impl TabSwitcher { self.init_modifiers = None; if self.picker.read(cx).delegate.matches.is_empty() { cx.emit(DismissEvent) - } else { + } else if self.visible { window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } else { + self.picker.update(cx, |picker, cx| { + picker.delegate.confirm(false, window, cx); + }); } } } @@ -225,12 +246,16 @@ impl Focusable for TabSwitcher { impl Render for TabSwitcher { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let picker = self.picker.clone(); v_flex() .key_context("TabSwitcher") .w(rems(PANEL_WIDTH_REMS)) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_close_selected_item)) - .child(self.picker.clone()) + .when(self.visible, |el| el.child(picker.clone())) + .when(!self.visible, |el| { + el.child(div().size_0().overflow_hidden().child(picker.clone())) + }) } } diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 0700781ebb..dd1c183152 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -255,6 +255,61 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { assert_tab_switcher_is_closed(workspace, cx); } +#[gpui::test] +async fn test_quick_switch_before_popover_visible(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + "3.txt": "Third file", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + open_buffer("1.txt", &workspace, cx).await; + open_buffer("2.txt", &workspace, cx).await; + let _tab_3 = open_buffer("3.txt", &workspace, cx).await; + + // Simulate quick Ctrl+Tab: press modifier, open switcher, release modifier + // all before the POPOVER_DELAY (300ms) elapses. + cx.simulate_modifiers_change(Modifiers::control()); + let tab_switcher = open_tab_switcher(false, &workspace, cx); + + // Verify the switcher is not visible yet (before delay) + tab_switcher.read_with(cx, |picker, cx| { + let tab_switcher = picker + .delegate + .tab_switcher + .upgrade() + .expect("tab switcher should exist"); + assert!(!tab_switcher.read(cx).visible); + }); + + // Release modifiers before delay — should confirm the pre-selected item (2.txt) + cx.simulate_modifiers_change(Modifiers::none()); + + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + assert_eq!( + active_editor.read(cx).title(cx), + "2.txt", + "quick switch should select previous tab, not a random one" + ); + }); + assert_tab_switcher_is_closed(workspace, cx); +} + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); diff --git a/crates/tabular_data_preview/Cargo.toml b/crates/tabular_data_preview/Cargo.toml index a9d263b130..dfa1239701 100644 --- a/crates/tabular_data_preview/Cargo.toml +++ b/crates/tabular_data_preview/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [lib] path = "src/tabular_data_preview.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index e6cc357bf6..635715c0be 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -626,6 +626,46 @@ mod tests { ); } + #[test] + fn test_worktree_root_with_spaces_stays_atomic_in_args_and_cwd() { + let worktree_root = r"C:\worktrees\Godot Projects\sample-game"; + let task = TaskTemplate { + label: "Run Godot Game".to_string(), + command: "godot".to_string(), + args: vec![ + "--path".to_string(), + VariableName::WorktreeRoot.template_value(), + "scenes/main_menu.tscn".to_string(), + ], + cwd: Some(VariableName::WorktreeRoot.template_value()), + ..TaskTemplate::default() + }; + + let resolved = task + .resolve_task( + TEST_ID_BASE, + &TaskContext { + cwd: None, + task_variables: TaskVariables::from_iter([( + VariableName::WorktreeRoot, + worktree_root.to_string(), + )]), + project_env: HashMap::default(), + }, + ) + .expect("task should resolve with worktree root variable"); + + assert_eq!( + resolved.resolved.args, + vec![ + "--path".to_string(), + worktree_root.to_string(), + "scenes/main_menu.tscn".to_string() + ] + ); + assert_eq!(resolved.resolved.cwd, Some(PathBuf::from(worktree_root))); + } + #[test] fn test_template_variables_resolution() { let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1")); diff --git a/crates/tasks_ui/Cargo.toml b/crates/tasks_ui/Cargo.toml index 89a1426a5d..7512b22521 100644 --- a/crates/tasks_ui/Cargo.toml +++ b/crates/tasks_ui/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/tasks_ui.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 41b24dee32..80c5dcf557 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -361,7 +361,7 @@ pub fn task_contexts( cx: &mut App, ) -> Task { let active_item = workspace.active_item(cx); - let active_worktree = active_item + let active_item_worktree = active_item .as_ref() .and_then(|item| item.project_path(cx)) .map(|project_path| project_path.worktree_id) @@ -371,21 +371,25 @@ pub fn task_contexts( .read(cx) .worktree_for_id(*worktree_id, cx) .is_some_and(|worktree| is_visible_directory(&worktree, cx)) - }) - .or_else(|| { - workspace - .visible_worktrees(cx) - .next() - .map(|tree| tree.read(cx).id()) }); + let active_worktree = active_item_worktree.or_else(|| { + workspace + .visible_worktrees(cx) + .next() + .map(|tree| tree.read(cx).id()) + }); let active_editor = active_item.and_then(|item| item.act_as::(cx)); + let active_project_editor = active_item_worktree + .is_some() + .then(|| active_editor.clone()) + .flatten(); - let editor_context_task = active_editor.as_ref().map(|active_editor| { + let editor_context_task = active_project_editor.as_ref().map(|active_editor| { active_editor.update(cx, |editor, cx| editor.task_context(window, cx)) }); - let location = active_editor.as_ref().and_then(|editor| { + let location = active_project_editor.as_ref().and_then(|editor| { editor.update(cx, |editor, cx| { let selection = editor.selections.newest_anchor(); let multi_buffer = editor.buffer().clone(); @@ -401,14 +405,14 @@ pub fn task_contexts( }) }); - let lsp_task_sources = active_editor + let lsp_task_sources = active_project_editor .as_ref() .map(|active_editor| { active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx)) }) .unwrap_or_default(); - let latest_selection = active_editor.as_ref().and_then(|active_editor| { + let latest_selection = active_project_editor.as_ref().and_then(|active_editor| { let snapshot = active_editor.read(cx).buffer().read(cx).snapshot(cx); snapshot .anchor_to_buffer_anchor(active_editor.read(cx).selections.newest_anchor().head()) @@ -433,7 +437,8 @@ pub fn task_contexts( if let Some(editor_context_task) = editor_context_task && let Some(editor_context) = editor_context_task.await { - task_contexts.active_item_context = Some((active_worktree, location, editor_context)); + task_contexts.active_item_context = + Some((active_item_worktree, location, editor_context)); } if let Some(active_worktree) = active_worktree { @@ -491,6 +496,53 @@ mod tests { use crate::task_contexts; + #[gpui::test] + async fn test_non_project_active_editor_uses_visible_worktree_context(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let worktree_root = path!("/worktrees/Godot Projects/sample-game"); + fs.insert_tree( + worktree_root, + json!({ + ".zed": { + "tasks.json": "[]", + }, + "scenes": { + "main_menu.tscn": "", + } + }), + ) + .await; + + let project = Project::test(fs, [worktree_root.as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let global_tasks_editor = cx.new_window_entity(|window, cx| Editor::multi_line(window, cx)); + + let contexts = workspace + .update_in(cx, |workspace, window, cx| { + workspace.add_item_to_center(Box::new(global_tasks_editor), window, cx); + task_contexts(workspace, window, cx) + }) + .await; + + assert!(contexts.active_item_context.is_none()); + assert_eq!( + contexts + .active_context() + .expect("visible worktree should provide an active task context"), + &TaskContext { + cwd: Some(worktree_root.into()), + task_variables: TaskVariables::from_iter([( + VariableName::WorktreeRoot, + worktree_root.into(), + )]), + project_env: HashMap::default(), + } + ); + } + #[gpui::test] async fn test_default_language_context(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/telemetry/Cargo.toml b/crates/telemetry/Cargo.toml index f79c80bb17..263ebbab25 100644 --- a/crates/telemetry/Cargo.toml +++ b/crates/telemetry/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/telemetry.rs" +test = false [dependencies] serde_json.workspace = true diff --git a/crates/telemetry_events/Cargo.toml b/crates/telemetry_events/Cargo.toml index 6a5149c545..1094f13896 100644 --- a/crates/telemetry_events/Cargo.toml +++ b/crates/telemetry_events/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/telemetry_events.rs" +test = false +doctest = false [dependencies] semver.workspace = true diff --git a/crates/terminal/src/alacritty/hyperlinks.rs b/crates/terminal/src/alacritty/hyperlinks.rs index cabf1d3cf6..be314dc431 100644 --- a/crates/terminal/src/alacritty/hyperlinks.rs +++ b/crates/terminal/src/alacritty/hyperlinks.rs @@ -508,6 +508,36 @@ mod tests { assert_eq!(results, expected); } + #[test] + fn test_default_path_hyperlink_regexes() -> anyhow::Result<()> { + let content = settings::parse_json_with_comments(&settings::default_settings())?; + let settings = TerminalSettings::from_settings(&content); + let searches = RegexSearches::new( + &settings.path_hyperlink_regexes, + Duration::from_millis(settings.path_hyperlink_timeout_ms), + ); + let examples = [ + (r#"File "src/main.py", line 42"#, "src/main.py", Some("42")), + ("src/main.rs:42", "src/main.rs:42", None), + ]; + assert_eq!(searches.path_hyperlink_regexes.len(), examples.len()); + assert_eq!( + searches.path_hyperlink_regexes.len(), + settings.path_hyperlink_regexes.len() + ); + for (regex, (input, path, line)) in searches.path_hyperlink_regexes.iter().zip(examples) { + let captures = regex + .captures(input) + .ok_or_else(|| anyhow::anyhow!("Default regex does not match {input}"))?; + assert_eq!( + captures.name("path").map(|capture| capture.as_str()), + Some(path) + ); + assert_eq!(captures.name("line").map(|capture| capture.as_str()), line); + } + Ok(()) + } + #[test] fn test_url_regex() { re_test( diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 37c32e9f97..ed12f8eb44 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -934,6 +934,12 @@ fn init_command_startup_marker_command(shell_kind: ShellKind, marker_id: u64) -> /// sender and receiver remain paired. pub struct TerminalMode(TerminalModeKind); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MouseInputMode { + ReportToTerminal, + LocalSelection, +} + enum TerminalModeKind { Interactive, InteractiveWithCompletion(Sender>), @@ -1019,7 +1025,7 @@ impl TerminalBuilder { completion_tx: None, term, term_config: config, - output_processor: Processor::::new(), + output_processor: None, title_override: None, events: VecDeque::with_capacity(10), last_content: Content { @@ -1306,7 +1312,7 @@ impl TerminalBuilder { completion_tx, term, term_config: config, - output_processor: Processor::::new(), + output_processor: None, title_override: terminal_title_override, events: VecDeque::with_capacity(10), //Should never get this high. last_content: Default::default(), @@ -1506,7 +1512,7 @@ pub struct Terminal { completion_tx: Option>>, term: Arc, term_config: AlacrittyTermConfig, - output_processor: Processor, + output_processor: Option>, events: VecDeque, /// This is only used for mouse mode cell change detection last_mouse: Option<(Point, SelectionSide)>, @@ -1965,7 +1971,9 @@ impl Terminal { let converted = convert_lf_to_crlf(bytes, &mut previous_byte_was_cr); let mut term = self.term.lock(); - self.output_processor.advance(&mut *term, &converted); + self.output_processor + .get_or_insert_with(Processor::::new) + .advance(&mut *term, &converted); drop(term); self.detect_init_command_startup_marker(); cx.emit(Event::Wakeup); @@ -2474,13 +2482,15 @@ impl Terminal { } } - pub fn mouse_mode(&self, shift: bool) -> bool { - self.last_content.mode.intersects(Modes::MOUSE_MODE) && !shift + pub fn mouse_mode(&self, shift: bool, mode: MouseInputMode) -> bool { + mode == MouseInputMode::ReportToTerminal + && self.last_content.mode.intersects(Modes::MOUSE_MODE) + && !shift } - pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context) { + pub fn mouse_move(&mut self, e: &MouseMoveEvent, mode: MouseInputMode, cx: &mut Context) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; - if self.mouse_mode(e.modifiers.shift) { + if self.mouse_mode(e.modifiers.shift, mode) { // A ctrl/cmd press on a link suppressed its button-press report in // `mouse_down`. Since the app never saw the press, we must swallow // the whole gesture rather than forward later motion/release @@ -2578,10 +2588,11 @@ impl Terminal { &mut self, e: &MouseMoveEvent, region: Bounds, + mode: MouseInputMode, cx: &mut Context, ) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; - if !self.mouse_mode(e.modifiers.shift) { + if !self.mouse_mode(e.modifiers.shift, mode) { if let Some(hyperlink) = &self.mouse_down_hyperlink { let point = grid_point( position, @@ -2644,7 +2655,7 @@ impl Terminal { Some(scroll_lines.clamp(-3, 3)) } - pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context) { + pub fn mouse_down(&mut self, e: &MouseDownEvent, mode: MouseInputMode, cx: &mut Context) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; let point = grid_point( position, @@ -2655,7 +2666,7 @@ impl Terminal { if e.button == MouseButton::Left && e.modifiers.secondary() && (TerminalSettings::get_global(cx).open_links_in_mouse_mode - || !self.mouse_mode(e.modifiers.shift)) + || !self.mouse_mode(e.modifiers.shift, mode)) { self.mouse_down_hyperlink = self.find_hyperlink_at_point(point); @@ -2664,7 +2675,7 @@ impl Terminal { } } - if self.mouse_mode(e.modifiers.shift) { + if self.mouse_mode(e.modifiers.shift, mode) { let bytes = mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode); @@ -2715,7 +2726,9 @@ impl Terminal { } #[cfg(any(target_os = "linux", target_os = "freebsd"))] MouseButton::Middle => { - if let Some(item) = cx.read_from_primary() { + if mode == MouseInputMode::ReportToTerminal + && let Some(item) = cx.read_from_primary() + { let text = item.text().unwrap_or_default(); self.paste(&text); } @@ -2725,7 +2738,7 @@ impl Terminal { } } - pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context) { + pub fn mouse_up(&mut self, e: &MouseUpEvent, mode: MouseInputMode, cx: &Context) { let setting = TerminalSettings::get_global(cx); let position = e.position - self.last_content.terminal_bounds.bounds.origin; @@ -2748,7 +2761,7 @@ impl Terminal { return; } - if self.mouse_mode(e.modifiers.shift) { + if self.mouse_mode(e.modifiers.shift, mode) { self.selection_phase = SelectionPhase::Ended; self.last_mouse = None; self.mouse_down_position = None; @@ -2756,7 +2769,7 @@ impl Terminal { } } - if self.mouse_mode(e.modifiers.shift) { + if self.mouse_mode(e.modifiers.shift, mode) { let point = grid_point( position, self.last_content.terminal_bounds, @@ -2798,8 +2811,13 @@ impl Terminal { } ///Scroll the terminal - pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, scroll_multiplier: f32) { - let mouse_mode = self.mouse_mode(e.shift); + pub fn scroll_wheel( + &mut self, + e: &ScrollWheelEvent, + scroll_multiplier: f32, + mode: MouseInputMode, + ) { + let mouse_mode = self.mouse_mode(e.shift, mode); let scroll_multiplier = if mouse_mode { 1. } else { scroll_multiplier }; if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier) @@ -2818,10 +2836,11 @@ impl Terminal { self.write_to_pty(scroll); } }; - } else if self - .last_content - .mode - .contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL) + } else if mode == MouseInputMode::ReportToTerminal + && self + .last_content + .mode + .contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL) && !e.shift { self.write_to_pty(alt_scroll(scroll_lines)); @@ -3051,7 +3070,9 @@ impl Terminal { ) } - /// Releases live PTY resources while retaining process metadata and buffered output. + /// Releases live PTY resources, including the parse buffer used by + /// [`Terminal::write_output`], while retaining process metadata and buffered + /// output. /// /// Calling this method after the resources have already been released is a no-op. pub fn release_pty_resources(&mut self) { @@ -3064,6 +3085,10 @@ impl Terminal { }; let info = info.clone(); + // The terminal is retained past the command, so drop the parse buffer's + // reservation; `write_output` builds a new one if it is called again. + self.output_processor = None; + pty_tx.shutdown(); info.terminate_child_process(); @@ -3572,7 +3597,8 @@ mod tests { }; use collections::HashMap; use gpui::{ - ClipboardItem, Entity, Pixels, TestAppContext, VisualTestContext, bounds, point, size, + ClipboardItem, Entity, Pixels, ScrollDelta, TestAppContext, VisualContext, + VisualTestContext, bounds, point, size, }; use parking_lot::Mutex; use rand::{Rng, distr, rngs::StdRng}; @@ -3919,7 +3945,7 @@ mod tests { click_count: 1, first_mouse: true, }; - terminal.mouse_down(&mouse_down, cx); + terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx); } fn left_mouse_up_at( @@ -3933,7 +3959,7 @@ mod tests { modifiers: Modifiers::none(), click_count: 1, }; - terminal.mouse_up(&mouse_up, cx); + terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx); } fn left_mouse_drag_to( @@ -3947,7 +3973,7 @@ mod tests { pressed_button: Some(MouseButton::Left), modifiers: Modifiers::none(), }; - terminal.mouse_drag(&drag_event, region, cx); + terminal.mouse_drag(&drag_event, region, MouseInputMode::ReportToTerminal, cx); } /// A left click that jitters by a pixel or two (e.g. the window-focusing @@ -3998,6 +4024,92 @@ mod tests { }); } + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + #[gpui::test] + async fn test_local_selection_does_not_paste_primary_selection(cx: &mut TestAppContext) { + let terminal = init_terminal_test(cx, b"hello world\r\n"); + terminal.update(cx, |terminal, cx| { + cx.write_to_primary(ClipboardItem::new_string("primary selection".into())); + let event = MouseDownEvent { + button: MouseButton::Middle, + position: point(px(50.), px(10.)), + modifiers: Modifiers::none(), + click_count: 1, + first_mouse: true, + }; + terminal.mouse_down(&event, MouseInputMode::LocalSelection, cx); + assert!(terminal.take_input_log().is_empty()); + assert!(terminal.take_pty_write_log().is_empty()); + + terminal.mouse_down(&event, MouseInputMode::ReportToTerminal, cx); + assert_eq!( + terminal.take_input_log(), + vec![b"primary selection".to_vec()] + ); + assert_eq!( + terminal.take_pty_write_log(), + vec![b"primary selection".to_vec()] + ); + }); + } + + #[gpui::test] + async fn test_local_selection_scrolls_without_terminal_reports(cx: &mut TestAppContext) { + let mut output = Vec::new(); + output.extend_from_slice(b"\x1b[?1002h\x1b[?1006h"); + for line in 0..80 { + output.extend_from_slice(format!("scrollback line {line}\r\n").as_bytes()); + } + let (terminal, cx) = init_terminal_test_with_window(cx, &output); + cx.run_until_parked(); + + cx.update_window_entity(&terminal, |terminal, window, cx| { + terminal.sync(window, cx); + assert!(terminal.last_content.mode.intersects(Modes::MOUSE_MODE)); + assert_eq!(display_offset(&terminal.term.lock()), 0); + + let scroll_event = ScrollWheelEvent { + position: point(px(50.0), px(10.0)), + delta: ScrollDelta::Lines(point(0.0, 1.0)), + ..Default::default() + }; + + terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::LocalSelection); + terminal.sync(window, cx); + assert!(display_offset(&terminal.term.lock()) > 0); + assert!(terminal.take_input_log().is_empty()); + assert!(terminal.take_pty_write_log().is_empty()); + + terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::ReportToTerminal); + assert!(!terminal.take_pty_write_log().is_empty()); + + terminal.write_output(b"\x1b[?1049h\x1b[?1007h", cx); + }); + cx.run_until_parked(); + + cx.update_window_entity(&terminal, |terminal, window, cx| { + terminal.sync(window, cx); + assert!( + terminal + .last_content + .mode + .contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL) + ); + assert_eq!(display_offset(&terminal.term.lock()), 0); + + let scroll_event = ScrollWheelEvent { + position: point(px(50.0), px(10.0)), + delta: ScrollDelta::Lines(point(0.0, 1.0)), + ..Default::default() + }; + terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::LocalSelection); + terminal.sync(window, cx); + assert_eq!(display_offset(&terminal.term.lock()), 0); + assert!(terminal.take_input_log().is_empty()); + assert!(terminal.take_pty_write_log().is_empty()); + }); + } + /// With mouse tracking active (e.g. htop), Shift is the escape hatch to /// select terminal text. Shift+drag must start a selection rather than being /// swallowed as a "extend existing selection" no-op. Regression test for #60254. @@ -4024,6 +4136,7 @@ mod tests { click_count: 1, first_mouse: true, }, + MouseInputMode::ReportToTerminal, cx, ); @@ -4046,6 +4159,7 @@ mod tests { modifiers: shift, }, region, + MouseInputMode::ReportToTerminal, cx, ); @@ -4086,6 +4200,7 @@ mod tests { click_count: 1, first_mouse: true, }, + MouseInputMode::ReportToTerminal, cx, ); @@ -4814,7 +4929,7 @@ mod tests { click_count: 1, first_mouse: true, }; - terminal.mouse_down(&mouse_down, cx); + terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx); } fn ctrl_mouse_drag_to( @@ -4828,7 +4943,12 @@ mod tests { pressed_button: Some(MouseButton::Left), modifiers: Modifiers::secondary_key(), }; - terminal.mouse_drag(&drag_event, terminal_bounds, cx); + terminal.mouse_drag( + &drag_event, + terminal_bounds, + MouseInputMode::ReportToTerminal, + cx, + ); } fn ctrl_mouse_up_at( @@ -4842,7 +4962,7 @@ mod tests { modifiers: Modifiers::secondary_key(), click_count: 1, }; - terminal.mouse_up(&mouse_up, cx); + terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx); } macro_rules! any_event_matches { @@ -4889,6 +5009,38 @@ mod tests { }); } + #[gpui::test] + async fn test_local_selection_activates_hyperlink_in_mouse_mode(cx: &mut TestAppContext) { + let terminal = init_terminal_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + let position = point(px(80.0), px(10.0)); + let mouse_down = MouseDownEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::secondary_key(), + click_count: 1, + first_mouse: true, + }; + let mouse_up = MouseUpEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::secondary_key(), + click_count: 1, + }; + + terminal.mouse_down(&mouse_down, MouseInputMode::LocalSelection, cx); + terminal.mouse_up(&mouse_up, MouseInputMode::LocalSelection, cx); + + assert!(any_event_matches!( + terminal, + InternalEvent::ProcessHyperlink(_, true) + )); + assert!(terminal.take_pty_write_log().is_empty()); + }); + } + #[gpui::test] async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture( cx: &mut TestAppContext, @@ -4912,6 +5064,7 @@ mod tests { pressed_button: Some(MouseButton::Left), modifiers: Modifiers::secondary_key(), }, + MouseInputMode::ReportToTerminal, cx, ); ctrl_mouse_up_at(terminal, up_position, cx); @@ -5197,7 +5350,8 @@ mod tests { }; self.window.simulate_mouse_move(position, self.cx); self.unthrottle(); - self.terminal.mouse_move(&move_event, self.cx); + self.terminal + .mouse_move(&move_event, MouseInputMode::ReportToTerminal, self.cx); } fn try_modifiers_change(&mut self, modifiers: Modifiers) { @@ -5678,6 +5832,49 @@ mod tests { ); } + /// The parse buffer `write_output` needs is only built for injected bytes and + /// must not outlive the command, even though the terminal does. + #[gpui::test] + async fn test_release_pty_resources_drops_an_allocated_parse_buffer(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let terminal = build_test_terminal(cx, "echo", &["captured_output"]).await; + + assert!( + terminal.read_with(cx, |terminal, _| terminal.output_processor.is_none()), + "a terminal that received no injected output should not hold a parse buffer" + ); + + let exit_status = + terminal.read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx)); + assert_eq!(exit_status.await, Some(ExitStatus::default())); + assert_content_eventually(&terminal, "captured_output", cx).await; + + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"injected_before_release\n", cx); + }); + assert!( + terminal.read_with(cx, |terminal, _| terminal.output_processor.is_some()), + "injecting output should build the parse buffer" + ); + + terminal.update(cx, |terminal, _| terminal.release_pty_resources()); + assert!( + terminal.read_with(cx, |terminal, _| terminal.output_processor.is_none()), + "releasing the PTY should drop the parse buffer" + ); + + assert_content_eventually(&terminal, "captured_output", cx).await; + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"injected_after_release\n", cx); + }); + assert!( + terminal.read_with(cx, |terminal, _| terminal.output_processor.is_some()), + "injecting output after a release should build a new parse buffer" + ); + assert_content_eventually(&terminal, "injected_after_release", cx).await; + } + mod perf { use super::{super::*, init_terminal_test_with_window}; use gpui::{ScrollDelta, ScrollWheelEvent, TestAppContext, VisualContext, point}; @@ -5701,6 +5898,7 @@ mod tests { position, ..default() }, + MouseInputMode::ReportToTerminal, cx, ); @@ -5711,6 +5909,7 @@ mod tests { ..default() }, 1.0, + MouseInputMode::ReportToTerminal, ); assert!( diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index e9ac01b71f..6464d49e69 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -127,6 +127,7 @@ impl settings::Settings for TerminalSettings { path_hyperlink_regexes: project_content .path_hyperlink_regexes .unwrap() + .0 .into_iter() .map(|regex| match regex { PathHyperlinkRegex::SingleLine(regex) => regex, @@ -164,3 +165,164 @@ impl From for CursorShape { } } } + +#[cfg(test)] +mod tests { + use super::*; + use settings::{ + LocalSettingsKind, LocalSettingsPath, SettingsLocation, SettingsStore, WorktreeId, + }; + use util::rel_path::rel_path; + + fn path_regexes<'a>(store: &'a SettingsStore, path: Option<&str>) -> &'a [String] { + &store + .get::(path.map(|path| SettingsLocation { + worktree_id: WorktreeId::from_usize(1), + path: rel_path(path), + })) + .path_hyperlink_regexes + } + + #[gpui::test] + fn test_path_hyperlink_regexes_inherit_across_settings_layers(cx: &mut gpui::App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + let defaults = path_regexes(&store, None).to_vec(); + store + .set_user_settings( + r##"{"terminal": {"path_hyperlink_regexes": [ + ["(?x)", "# Keep this comment", "(?user)"], + "...", + "(?tail)", + "..." + ]}}"##, + cx, + ) + .expect("user terminal settings must parse"); + let user_regex = "(?x)\n# Keep this comment\n(?user)".to_owned(); + let mut user = vec![user_regex.clone()]; + user.extend(defaults.clone()); + user.push("(?tail)".into()); + assert_eq!(path_regexes(&store, None), user); + + for (path, content) in [ + ( + "project", + r#"{"terminal": {"path_hyperlink_regexes": [ + "(?project)", + "...", + ["..."], + "(?x)\n# Keep this comment\n(?user)" + ]}}"#, + ), + ( + "project/child", + r##"{"terminal": {"path_hyperlink_regexes": [ + ["(?x)", "# Keep this comment", "(?user)"], + "...", + ["..."], + ["(?x)", "# Keep this too", "(?child)", "..."] + ]}}"##, + ), + ] { + store + .set_local_settings( + WorktreeId::from_usize(1), + LocalSettingsPath::InWorktree(rel_path(path).into()), + LocalSettingsKind::Settings, + Some(content), + cx, + ) + .expect("project terminal settings must parse"); + } + + let mut project = vec!["(?project)".to_owned()]; + project.extend(user.clone()); + project.push("...".into()); + assert_eq!(path_regexes(&store, Some("project/file")), project); + + let mut child = vec![user_regex, "(?project)".to_owned()]; + child.extend(defaults); + child.extend([ + "(?tail)".into(), + "...".into(), + "(?x)\n# Keep this too\n(?child)\n...".into(), + ]); + assert_eq!(path_regexes(&store, Some("project/child/file")), child); + assert_eq!(path_regexes(&store, Some("elsewhere/file")), user); + } + + #[gpui::test] + fn test_path_hyperlink_regexes_replace_clear_and_omit(cx: &mut gpui::App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + let defaults = path_regexes(&store, None).to_vec(); + store + .set_user_settings( + r#"{"terminal": {"path_hyperlink_regexes": ["(?user)"]}}"#, + cx, + ) + .expect("user terminal settings must parse"); + assert_eq!(path_regexes(&store, None), ["(?user)"]); + + for (path, content) in [ + ( + "project", + r#"{"terminal": {"path_hyperlink_regexes": ["(?project)"]}}"#, + ), + ( + "project/child", + r#"{"terminal": {"path_hyperlink_regexes": []}}"#, + ), + ( + "project/child/nested", + r#"{"terminal": {"path_hyperlink_regexes": [ + "...", ["(?x)", "...", "(?nested)"] + ]}}"#, + ), + ] { + store + .set_local_settings( + WorktreeId::from_usize(1), + LocalSettingsPath::InWorktree(rel_path(path).into()), + LocalSettingsKind::Settings, + Some(content), + cx, + ) + .expect("project terminal settings must parse"); + } + assert_eq!( + path_regexes(&store, Some("project/file")), + ["(?project)"] + ); + assert!(path_regexes(&store, Some("project/child/file")).is_empty()); + assert_eq!( + path_regexes(&store, Some("project/child/nested/file")), + ["(?x)\n...\n(?nested)"] + ); + assert_eq!(path_regexes(&store, None), ["(?user)"]); + + store + .set_local_settings( + WorktreeId::from_usize(1), + LocalSettingsPath::InWorktree(rel_path("project").into()), + LocalSettingsKind::Settings, + Some(r#"{"terminal": {"path_hyperlink_timeout_ms": 17}}"#), + cx, + ) + .expect("project terminal settings must parse"); + assert_eq!( + path_regexes(&store, Some("project/file")), + ["(?user)"] + ); + store + .set_user_settings(r#"{"terminal": {"path_hyperlink_regexes": []}}"#, cx) + .expect("user terminal settings must parse"); + assert!(path_regexes(&store, None).is_empty()); + assert!(path_regexes(&store, Some("project/file")).is_empty()); + store + .set_user_settings("{}", cx) + .expect("empty user settings must parse"); + assert_eq!(path_regexes(&store, None), defaults); + assert_eq!(path_regexes(&store, Some("project/file")), defaults); + assert!(path_regexes(&store, Some("project/child/file")).is_empty()); + } +} diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 893e908c57..7e5c2ab2cd 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1,9 +1,9 @@ use editor::{CursorLayout, EditorSettings, HighlightedRange, HighlightedRangeLine}; use gpui::{ - AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, - Element, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, - GlobalElementId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, - IntoElement, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, + AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, + ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, GlobalElementId, + HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, + LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point as GpuiPoint, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative, size, @@ -13,9 +13,10 @@ use language::CursorShape as EditorCursorShape; use settings::Settings; use std::time::Instant; use terminal::{ - Cell, Color, Content, CursorShape, IndexedCell, Modes, NamedColor, Point, Range, Terminal, - TerminalBounds, is_app_chosen_exact_color as terminal_is_app_chosen_exact_color, - is_default_background_color, terminal_settings::TerminalSettings, + Cell, Color, Content, CursorShape, IndexedCell, Modes, MouseInputMode, NamedColor, Point, + Range, Terminal, TerminalBounds, + is_app_chosen_exact_color as terminal_is_app_chosen_exact_color, is_default_background_color, + terminal_settings::TerminalSettings, }; use theme::{ActiveTheme, Theme}; use theme_settings::ThemeSettings; @@ -40,7 +41,6 @@ pub struct LayoutState { ime_cursor_bounds: Option>, background_color: Hsla, dimensions: TerminalBounds, - mode: Modes, display_offset: usize, hyperlink_tooltip: Option, block_below_cursor_element: Option, @@ -940,31 +940,11 @@ impl TerminalElement { result } - fn generic_button_handler( - connection: Entity, - focus_handle: FocusHandle, - steal_focus: bool, - f: impl Fn(&mut Terminal, &E, &mut Context), - ) -> impl Fn(&E, &mut Window, &mut App) { - move |event, window, cx| { - if steal_focus { - window.focus(&focus_handle, cx); - } else if !focus_handle.is_focused(window) { - return; - } - connection.update(cx, |terminal, cx| { - f(terminal, event, cx); - - cx.notify(); - }) - } - } - fn register_mouse_listeners( &mut self, - mode: Modes, hitbox: &Hitbox, content_mode: &ContentMode, + mouse_input_mode: MouseInputMode, window: &mut Window, ) { let focus = self.focus.clone(); @@ -979,13 +959,14 @@ impl TerminalElement { move |e, window, cx| { window.focus(&focus, cx); - let scroll_top = terminal_view.read(cx).scroll_top; + let view = terminal_view.read(cx); + let scroll_top = view.scroll_top; terminal.update(cx, |terminal, cx| { let mut adjusted_event = e.clone(); if scroll_top > Pixels::ZERO { adjusted_event.position.y += scroll_top; } - terminal.mouse_down(&adjusted_event, cx); + terminal.mouse_down(&adjusted_event, mouse_input_mode, cx); cx.notify(); }) } @@ -995,7 +976,6 @@ impl TerminalElement { let terminal = self.terminal.clone(); let hitbox = hitbox.clone(); let focus = focus.clone(); - let terminal_view = terminal_view; move |e: &MouseMoveEvent, phase, window, cx| { if phase != DispatchPhase::Bubble { return; @@ -1011,7 +991,12 @@ impl TerminalElement { if scroll_top > Pixels::ZERO { adjusted_event.position.y += scroll_top; } - terminal.mouse_drag(&adjusted_event, hitbox.bounds, cx); + terminal.mouse_drag( + &adjusted_event, + hitbox.bounds, + mouse_input_mode, + cx, + ); cx.notify(); } }) @@ -1019,34 +1004,60 @@ impl TerminalElement { if hitbox.is_hovered(window) { terminal.update(cx, |terminal, cx| { - terminal.mouse_move(e, cx); + terminal.mouse_move(e, mouse_input_mode, cx); }) } } }); - self.interactivity.on_mouse_up( - MouseButton::Left, - TerminalElement::generic_button_handler( - terminal.clone(), - focus.clone(), - false, - move |terminal, e, cx| { - terminal.mouse_up(e, cx); - }, - ), - ); - self.interactivity.on_mouse_down( - MouseButton::Middle, - TerminalElement::generic_button_handler( - terminal.clone(), - focus.clone(), - true, - move |terminal, e, cx| { - terminal.mouse_down(e, cx); - }, - ), - ); + for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] { + self.interactivity.on_mouse_up(button, { + let terminal = terminal.clone(); + let focus = focus.clone(); + move |event, window, cx| { + if !focus.is_focused(window) { + return; + } + if button != MouseButton::Left + && (mouse_input_mode == MouseInputMode::LocalSelection + || !terminal + .read(cx) + .last_content + .mode + .intersects(Modes::MOUSE_MODE)) + { + return; + } + terminal.update(cx, |terminal, cx| { + terminal.mouse_up(event, mouse_input_mode, cx); + cx.notify(); + }); + } + }); + } + for button in [MouseButton::Middle, MouseButton::Right] { + self.interactivity.on_mouse_down(button, { + let terminal = terminal.clone(); + let focus = focus.clone(); + move |event, window, cx| { + if mouse_input_mode == MouseInputMode::LocalSelection + || (button == MouseButton::Right + && !terminal + .read(cx) + .last_content + .mode + .intersects(Modes::MOUSE_MODE)) + { + return; + } + window.focus(&focus, cx); + terminal.update(cx, |terminal, cx| { + terminal.mouse_down(event, mouse_input_mode, cx); + cx.notify(); + }); + } + }); + } if content_mode.is_scrollable() { self.interactivity.on_scroll_wheel({ @@ -1065,48 +1076,10 @@ impl TerminalElement { } }); } - - // Mouse mode handlers: - // All mouse modes need the extra click handlers - if mode.intersects(Modes::MOUSE_MODE) { - self.interactivity.on_mouse_down( - MouseButton::Right, - TerminalElement::generic_button_handler( - terminal.clone(), - focus.clone(), - true, - move |terminal, e, cx| { - terminal.mouse_down(e, cx); - }, - ), - ); - self.interactivity.on_mouse_up( - MouseButton::Right, - TerminalElement::generic_button_handler( - terminal.clone(), - focus.clone(), - false, - move |terminal, e, cx| { - terminal.mouse_up(e, cx); - }, - ), - ); - self.interactivity.on_mouse_up( - MouseButton::Middle, - TerminalElement::generic_button_handler( - terminal, - focus, - false, - move |terminal, e, cx| { - terminal.mouse_up(e, cx); - }, - ), - ); - } } fn rem_size(&self, cx: &mut App) -> Option { - let settings = ThemeSettings::get_global(cx).clone(); + let settings = ThemeSettings::get_global(cx); let buffer_font_size = settings.buffer_font_size(cx); let rem_size_scale = { // Our default UI font size is 14px on a 16px base scale. @@ -1205,7 +1178,7 @@ impl Element for TerminalElement { cx, |_, _, hitbox, window, cx| { let hitbox = hitbox.unwrap(); - let settings = ThemeSettings::get_global(cx).clone(); + let settings = ThemeSettings::get_global(cx); let buffer_font_size = settings.buffer_font_size(cx); @@ -1395,14 +1368,12 @@ impl Element for TerminalElement { let Content { cells, - mode, display_offset, cursor_char, selection, cursor, .. } = &self.terminal.read(cx).last_content; - let mode = *mode; let display_offset = *display_offset; // searches, highlights to a single range representations @@ -1589,7 +1560,6 @@ impl Element for TerminalElement { dimensions, rects, relative_highlighted_ranges, - mode, display_offset, hyperlink_tooltip, block_below_cursor_element, @@ -1612,7 +1582,9 @@ impl Element for TerminalElement { ) { let paint_start = Instant::now(); window.with_content_mask(Some(ContentMask { bounds }), |window| { - let scroll_top = self.terminal_view.read(cx).scroll_top; + let terminal_view = self.terminal_view.read(cx); + let scroll_top = terminal_view.scroll_top; + let mouse_input_mode = terminal_view.mouse_input_mode(); window.paint_quad(fill(bounds, layout.background_color)); let origin = layout.dimensions.bounds.origin - GpuiPoint::new(px(0.), scroll_top); @@ -1634,9 +1606,9 @@ impl Element for TerminalElement { }; self.register_mouse_listeners( - layout.mode, &layout.hitbox, &layout.content_mode, + mouse_input_mode, window, ); if window.modifiers().secondary() @@ -1799,10 +1771,13 @@ struct TerminalInputHandler { impl InputHandler for TerminalInputHandler { fn selected_text_range( &mut self, - _ignore_disabled_input: bool, + ignore_disabled_input: bool, _: &mut Window, - _cx: &mut App, + cx: &mut App, ) -> Option { + if self.terminal_view.read(cx).is_read_only() && !ignore_disabled_input { + return None; + } // Always return a valid selection for IME positioning, // even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.) // The terminal still has a cursor position that should be used for IME candidate window placement. @@ -1837,6 +1812,9 @@ impl InputHandler for TerminalInputHandler { window: &mut Window, cx: &mut App, ) { + if self.terminal_view.read(cx).is_read_only() { + return; + } self.terminal_view.update(cx, |view, view_cx| { view.clear_marked_text(view_cx); view.commit_text(text, view_cx); @@ -2030,9 +2008,62 @@ pub fn convert_color(fg: &Color, theme: &Theme) -> Hsla { #[cfg(test)] mod tests { use super::*; - use gpui::{AbsoluteLength, Hsla, font}; + use gpui::{AbsoluteLength, Hsla, TestAppContext, font}; use ui::utils::apca_contrast; + #[gpui::test] + async fn terminal_input_handler_respects_read_only_mode(cx: &mut TestAppContext) { + let (project, workspace, window_handle) = crate::tests::init_test_with_window(cx).await; + for read_only in [false, true] { + let (_pane, terminal, terminal_view) = crate::tests::add_display_only_terminal( + &project, + window_handle, + true, + read_only, + cx, + ); + + window_handle + .update(cx, |_multi_workspace, window, cx| { + let mut input_handler = TerminalInputHandler { + terminal_view, + workspace: workspace.downgrade(), + cursor_bounds: None, + }; + + assert_eq!( + input_handler + .selected_text_range(false, window, cx) + .is_none(), + read_only + ); + assert!( + input_handler + .selected_text_range(true, window, cx) + .is_some() + ); + + input_handler.replace_and_mark_text_in_range(None, "あ", None, window, cx); + assert_eq!( + input_handler.marked_text_range(window, cx), + (!read_only).then_some(0..1) + ); + + input_handler.replace_text_in_range(None, "亜", window, cx); + assert_eq!(input_handler.marked_text_range(window, cx), None); + assert_eq!( + terminal.update(cx, |terminal, _| terminal.take_input_log()), + if read_only { + Vec::new() + } else { + vec!["亜".as_bytes().to_vec()] + } + ); + }) + .expect("test window should remain open"); + } + } + #[test] fn test_is_decorative_character() { // Box Drawing characters (U+2500 to U+257F) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 5f8cf0c3a3..7651034547 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -33,9 +33,10 @@ use std::{ }; use task::TaskId; use terminal::{ - Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, Paste, PasteText, Point, Range, - ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, - Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal, TerminalBounds, ToggleViMode, + Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, MouseInputMode, Paste, + PasteText, Point, Range, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, + ScrollToBottom, ScrollToTop, Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal, + TerminalBounds, ToggleViMode, terminal_settings::{CursorShape, TerminalSettings}, }; use terminal_element::TerminalElement; @@ -138,6 +139,7 @@ pub struct TerminalView { cursor_shape: CursorShape, blink_manager: Entity, mode: TerminalMode, + read_only: bool, // Explicit override for whether workspace-specific context menu actions are shown. // When `None`, visibility is derived from `mode` (hidden for embedded terminals). show_workspace_actions: Option, @@ -290,6 +292,7 @@ impl TerminalView { hover: None, hover_tooltip_update: Task::ready(()), mode: TerminalMode::Standalone, + read_only: false, show_workspace_actions: None, workspace_id, show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs, @@ -319,6 +322,25 @@ impl TerminalView { cx.notify(); } + pub fn is_read_only(&self) -> bool { + self.read_only + } + + /// Fixes the view's input policy at construction, without restricting producer output + /// or local navigation. + pub fn with_read_only(mut self, read_only: bool) -> Self { + self.read_only = read_only; + self + } + + fn mouse_input_mode(&self) -> MouseInputMode { + if self.read_only { + MouseInputMode::LocalSelection + } else { + MouseInputMode::ReportToTerminal + } + } + /// Explicitly override whether workspace-specific context menu actions (e.g. creating or /// closing terminal tabs, inline assist) are shown. /// @@ -371,6 +393,9 @@ impl TerminalView { /// Sets the marked (pre-edit) text from the IME. pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context) { + if self.read_only { + return; + } if text.is_empty() { return self.clear_marked_text(cx); } @@ -395,7 +420,7 @@ impl TerminalView { /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range. pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context) { - if !text.is_empty() { + if !self.read_only && !text.is_empty() { self.terminal.update(cx, |term, _| { term.input(text.to_string().into_bytes()); }); @@ -543,7 +568,7 @@ impl TerminalView { }) .action("Copy", Box::new(Copy)) .when( - !matches!(self.mode, TerminalMode::Embedded { .. }), + !self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }), |menu| { menu.action("Paste", Box::new(Paste)) .action("Paste Text", Box::new(PasteText)) @@ -551,14 +576,16 @@ impl TerminalView { ) .action("Select All", Box::new(SelectAll)) .when( - !matches!(self.mode, TerminalMode::Embedded { .. }), + !self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }), |menu| menu.action("Clear", Box::new(Clear)), ) .when( assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }), |menu| { menu.separator() - .action("Inline Assist", Box::new(InlineAssist::default())) + .when(!self.read_only, |menu| { + menu.action("Inline Assist", Box::new(InlineAssist::default())) + }) .when(has_selection && self.shows_workspace_actions(), |menu| { menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) }) @@ -633,6 +660,9 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + if self.read_only { + return; + } if self .terminal .read(cx) @@ -657,6 +687,9 @@ impl TerminalView { } fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } let task = self .terminal .read(cx) @@ -667,6 +700,9 @@ impl TerminalView { } fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } self.scroll_top = px(0.); self.terminal.update(cx, |term, _| term.clear()); cx.notify(); @@ -711,6 +747,7 @@ impl TerminalView { term.scroll_wheel( event, TerminalSettings::get_global(cx).scroll_multiplier.max(0.01), + self.mouse_input_mode(), ) }); } @@ -910,6 +947,9 @@ impl TerminalView { ///Attempt to paste the clipboard into the terminal fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } let Some(clipboard) = cx.read_from_clipboard() else { return; }; @@ -944,6 +984,9 @@ impl TerminalView { ///Attempt to paste the clipboard text into the terminal fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } let Some(clipboard) = cx.read_from_clipboard() else { return; }; @@ -963,6 +1006,9 @@ impl TerminalView { } pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { + if self.read_only { + return; + } let mut text = paths .iter() .filter_map(|path| Some(format!(" {}", shlex::try_quote(path.to_str()?).ok()?))) @@ -975,6 +1021,9 @@ impl TerminalView { } fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } self.clear_bell(cx); self.blink_manager.update(cx, BlinkManager::pause_blinking); self.terminal.update(cx, |term, _| { @@ -1085,8 +1134,8 @@ impl TerminalView { self.terminal = terminal; } - fn rerun_button(task: &TaskState) -> Option { - if !task.spawned_task.show_rerun { + fn rerun_button(&self, task: &TaskState) -> Option { + if self.read_only || !task.spawned_task.show_rerun { return None; } @@ -1271,6 +1320,9 @@ impl TerminalView { /// updates the cursor locally without sending data to the shell, so there's no /// shell output to automatically trigger a re-render. fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) -> bool { + if self.read_only && !self.terminal.read(cx).vi_mode_enabled() { + return false; + } let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| { ( term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta), @@ -1289,6 +1341,13 @@ impl TerminalView { self.clear_bell(cx); self.pause_cursor_blinking(window, cx); + if event.prefer_character_input + && event.keystroke.key_char.is_some() + && !self.terminal.read(cx).vi_mode_enabled() + { + return; + } + if self.process_keystroke(&event.keystroke, cx) { cx.stop_propagation(); } @@ -1297,7 +1356,9 @@ impl TerminalView { fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { self.terminal.update(cx, |terminal, _| { terminal.set_cursor_shape(self.cursor_shape); - terminal.focus_in(); + if !self.read_only { + terminal.focus_in(); + } }); let should_blink = match TerminalSettings::get_global(cx).blinking { @@ -1317,7 +1378,9 @@ impl TerminalView { fn focus_out(&mut self, _window: &mut Window, cx: &mut Context) { self.blink_manager.update(cx, BlinkManager::disable); self.terminal.update(cx, |terminal, _| { - terminal.focus_out(); + if !self.read_only { + terminal.focus_out(); + } terminal.set_cursor_shape(CursorShape::Hollow); }); cx.notify(); @@ -1374,7 +1437,11 @@ impl Render for TerminalView { .on_mouse_down( MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, window, cx| { - if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) { + if !this + .terminal + .read(cx) + .mouse_mode(event.modifiers.shift, this.mouse_input_mode()) + { let had_selection = this.terminal.read(cx).last_content.selection.is_some(); if !had_selection { this.terminal.update(cx, |terminal, _| { @@ -1475,15 +1542,15 @@ impl Item for TerminalView { TaskStatus::Running => ( IconName::PlayFilled, Color::Disabled, - TerminalView::rerun_button(terminal_task), + self.rerun_button(terminal_task), ), TaskStatus::Unknown => ( IconName::Warning, Color::Warning, - TerminalView::rerun_button(terminal_task), + self.rerun_button(terminal_task), ), TaskStatus::Completed { success } => { - let rerun_button = TerminalView::rerun_button(terminal_task); + let rerun_button = self.rerun_button(terminal_task); if *success { (IconName::Check, Color::Success, rerun_button) @@ -1584,6 +1651,9 @@ impl Item for TerminalView { window: &mut Window, cx: &mut App, ) -> bool { + if self.read_only { + return false; + } let Some(project) = self.project.upgrade() else { return false; }; @@ -2170,7 +2240,7 @@ fn first_project_directory(workspace: &Workspace, cx: &App) -> Option { #[cfg(test)] mod tests { use super::*; - use gpui::{TestAppContext, VisualTestContext}; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{Entry, Project, ProjectPath, Worktree}; use remote::RemoteClient; use std::path::{Path, PathBuf}; @@ -2226,7 +2296,7 @@ mod tests { ) { let (project, _workspace, window_handle) = init_test_with_window(cx).await; let (_pane, terminal, _terminal_view) = - add_display_only_terminal(&project, window_handle, true, cx); + add_display_only_terminal(&project, window_handle, true, false, cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx); cx.update(|window, cx| { @@ -2246,12 +2316,169 @@ mod tests { }); } + #[gpui::test] + async fn read_only_blocks_input_actions_and_preserves_output(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let rerun_count = Rc::new(std::cell::Cell::new(0)); + cx.update(|cx| { + cx.on_action({ + let rerun_count = rerun_count.clone(); + move |_: &zed_actions::Rerun, _| rerun_count.set(rerun_count.get() + 1) + }); + }); + let (pane, terminal, terminal_view) = + add_display_only_terminal(&project, window_handle, true, true, cx); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + for output in [ + b"provider output".as_slice(), + b"\x1b[?1049hprovider alternate-screen output".as_slice(), + ] { + let content = cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(output, cx); + terminal.sync(window, cx); + terminal.get_content() + }) + }); + cx.simulate_keystrokes("a enter ctrl-v shift-up"); + cx.update(|_, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string("clipboard input".into())); + }); + + let actions: Vec> = vec![ + Box::new(SendText("direct input".into())), + Box::new(SendKeystroke("enter".into())), + Box::new(Paste), + Box::new(editor::actions::Paste), + Box::new(PasteText), + Box::new(Clear), + Box::new(ShowCharacterPalette), + Box::new(RerunTask), + ]; + for action in actions { + cx.update(|window, cx| window.dispatch_action(action, cx)); + cx.run_until_parked(); + } + cx.update(|window, cx| { + let paths = ExternalPaths(vec![PathBuf::from(util::path!("/root.txt"))].into()); + assert!(!pane.update(cx, |pane, cx| { + terminal_view.update(cx, |view, cx| view.handle_drop(pane, &paths, window, cx)) + })); + terminal_view.update(cx, |view, cx| { + view.add_paths_to_terminal(paths.paths(), window, cx); + }); + terminal.update(cx, |terminal, cx| { + terminal.sync(window, cx); + assert!(terminal.take_input_log().is_empty()); + assert!(terminal.take_pty_write_log().is_empty()); + assert_eq!(terminal.get_content(), content); + }); + }); + } + assert_eq!(rerun_count.get(), 0); + } + + #[gpui::test] + async fn read_only_preserves_vi_navigation_without_enabling_input(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, true, cx); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"first line\nsecond line\n", cx); + terminal.sync(window, cx); + }); + window.dispatch_action(Box::new(ToggleViMode), cx); + }); + cx.run_until_parked(); + let cursor = terminal.read_with(&cx, |terminal, _| { + assert!(terminal.vi_mode_enabled()); + terminal.last_content.cursor.point + }); + cx.update(|window, cx| { + window.dispatch_action(Box::new(SendKeystroke("k".into())), cx); + }); + cx.run_until_parked(); + assert_eq!( + terminal.read_with(&cx, |terminal, _| terminal.last_content.cursor.point.line), + cursor.line - 1, + ); + cx.simulate_keystrokes("v l"); + assert!( + terminal + .read_with(&cx, |terminal, _| terminal + .last_content + .selection_text + .clone()) + .is_some_and(|text| !text.is_empty()), + ); + cx.simulate_keystrokes("y"); + assert!( + cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())) + .is_some_and(|text| !text.is_empty()), + ); + cx.simulate_keystrokes("i"); + assert!(!terminal.read_with(&cx, |terminal, _| terminal.vi_mode_enabled())); + cx.simulate_keystrokes("a enter"); + cx.update(|window, cx| { + window.dispatch_action(Box::new(SendKeystroke("enter".into())), cx); + }); + cx.run_until_parked(); + assert!( + terminal + .update(&mut cx, |terminal, _| terminal.take_input_log()) + .is_empty() + ); + } + + #[gpui::test] + async fn read_only_mouse_selection_and_focus_do_not_report_input(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, true, cx); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"\x1b[?1003h\x1b[?1006h\x1b[?1004hhello world\n", cx); + terminal.sync(window, cx); + terminal.take_pty_write_log(); + }); + window.blur(cx); + }); + cx.run_until_parked(); + let bounds = terminal.read_with(&cx, |terminal, _| terminal.last_content.terminal_bounds); + let start = + bounds.bounds.origin + gpui::point(bounds.cell_width * 0.1, bounds.line_height * 0.5); + let end = + bounds.bounds.origin + gpui::point(bounds.cell_width * 5.1, bounds.line_height * 0.5); + cx.simulate_mouse_move(start, None, gpui::Modifiers::default()); + cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move(end, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_up(end, MouseButton::Left, gpui::Modifiers::default()); + cx.update(|window, cx| window.dispatch_action(Box::new(Copy), cx)); + cx.run_until_parked(); + assert_eq!( + cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())), + Some("hello".into()), + ); + cx.update(|window, cx| window.blur(cx)); + cx.run_until_parked(); + assert!( + terminal + .update(&mut cx, |terminal, _| terminal.take_pty_write_log()) + .is_empty() + ); + } + #[gpui::test] async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) { let (project, _workspace, window_handle) = init_test_with_window(cx).await; cx.update(load_default_keymap); let (_pane, terminal, _terminal_view) = - add_display_only_terminal(&project, window_handle, true, cx); + add_display_only_terminal(&project, window_handle, true, false, cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx); cx.update(|window, cx| { @@ -2296,7 +2523,7 @@ mod tests { let (project, _workspace, window_handle) = init_test_with_window(cx).await; cx.update(load_default_keymap); let (_pane, terminal, _terminal_view) = - add_display_only_terminal(&project, window_handle, true, cx); + add_display_only_terminal(&project, window_handle, true, false, cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx); cx.update(|window, cx| { @@ -2328,7 +2555,7 @@ mod tests { let (project, _workspace, window_handle) = init_test_with_window(cx).await; cx.update(load_default_keymap); let (_pane, terminal, _terminal_view) = - add_display_only_terminal(&project, window_handle, true, cx); + add_display_only_terminal(&project, window_handle, true, false, cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx); cx.update(|window, cx| { @@ -2344,6 +2571,70 @@ mod tests { ); } + #[gpui::test] + async fn altgr_character_input_is_not_swallowed_as_meta_sequence(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.terminal.get_or_insert_default().option_as_meta = Some(true); + }); + }); + }); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, false, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + let mut altgr_a = Keystroke::parse("ctrl-alt-a").unwrap(); + altgr_a.key_char = Some("ą".to_string()); + + let propagate = cx.update(|window, cx| { + window + .dispatch_event( + gpui::PlatformInput::KeyDown(KeyDownEvent { + keystroke: altgr_a.clone(), + is_held: false, + prefer_character_input: true, + }), + cx, + ) + .propagate + }); + assert!( + propagate, + "a keystroke that produced a character must propagate so the platform can commit the text", + ); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + Vec::>::new(), + "AltGr-produced characters must not be sent as meta escape sequences", + ); + + let propagate = cx.update(|window, cx| { + window + .dispatch_event( + gpui::PlatformInput::KeyDown(KeyDownEvent { + keystroke: altgr_a, + is_held: false, + prefer_character_input: false, + }), + cx, + ) + .propagate + }); + assert!(!propagate); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + vec![b"\x1b\x01".to_vec()], + "ctrl-alt-a without character input preference is still sent as meta ctrl-a", + ); + } + // Working directory calculation tests // No Worktrees in project -> home_dir() @@ -2526,10 +2817,11 @@ mod tests { ); } - fn add_display_only_terminal( + pub(super) fn add_display_only_terminal( project: &Entity, window_handle: gpui::WindowHandle, focus: bool, + read_only: bool, cx: &mut TestAppContext, ) -> (Entity, Entity, Entity) { let project = project.clone(); @@ -2558,6 +2850,7 @@ mod tests { window, cx, ) + .with_read_only(read_only) }); active_pane.update(cx, |pane, cx| { @@ -2582,7 +2875,7 @@ mod tests { } /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle. - async fn init_test_with_window( + pub(super) async fn init_test_with_window( cx: &mut TestAppContext, ) -> ( Entity, @@ -2782,7 +3075,7 @@ mod tests { .unwrap(); let (active_pane, terminal, terminal_view) = - add_display_only_terminal(&project, window_handle, false, cx); + add_display_only_terminal(&project, window_handle, false, false, cx); let tab_item = window_handle .update(cx, |_, window, cx| { diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index b52e855f2b..7a6cba6697 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -112,6 +112,7 @@ impl ThemeColors { minimap_thumb_active_background: neutral().light_alpha().step_5().alpha(0.7), minimap_thumb_border: gpui::transparent_black(), editor_foreground: neutral().light().step_12(), + editor_code_lens_foreground: None, editor_background: neutral().light().step_1(), editor_gutter_background: neutral().light().step_1(), editor_subheader_background: neutral().light().step_2(), @@ -265,6 +266,7 @@ impl ThemeColors { minimap_thumb_active_background: neutral().dark_alpha().step_5().alpha(0.7), minimap_thumb_border: gpui::transparent_black(), editor_foreground: neutral().dark().step_12(), + editor_code_lens_foreground: None, editor_background: neutral().dark().step_1(), editor_gutter_background: neutral().dark().step_1(), editor_subheader_background: neutral().dark().step_3(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 2716eae0b2..c22a3e13ef 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -247,6 +247,7 @@ pub(crate) fn zed_default_dark() -> Theme { minimap_thumb_active_background: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 0.7), minimap_thumb_border: hsla(228. / 360., 8. / 100., 25. / 100., 1.), editor_foreground: hsla(218. / 360., 14. / 100., 71. / 100., 1.), + editor_code_lens_foreground: None, link_text_hover: blue, version_control_added: ADDED_COLOR, version_control_deleted: REMOVED_COLOR, diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index f9ebd441aa..8364994d39 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -204,6 +204,10 @@ pub struct ThemeColors { // Editor // === pub editor_foreground: Hsla, + /// Text color used for CodeLens items in the editor. + /// + /// Falls back to `text_muted` when not explicitly set. + pub editor_code_lens_foreground: Option, pub editor_background: Hsla, pub editor_gutter_background: Hsla, pub editor_subheader_background: Hsla, @@ -404,6 +408,7 @@ pub enum ThemeColorField { MinimapThumbActiveBackground, MinimapThumbBorder, EditorForeground, + EditorCodeLensForeground, EditorBackground, EditorGutterBackground, EditorSubheaderBackground, @@ -522,6 +527,9 @@ impl ThemeColors { ThemeColorField::MinimapThumbActiveBackground => self.minimap_thumb_active_background, ThemeColorField::MinimapThumbBorder => self.minimap_thumb_border, ThemeColorField::EditorForeground => self.editor_foreground, + ThemeColorField::EditorCodeLensForeground => { + self.editor_code_lens_foreground.unwrap_or(self.text_muted) + } ThemeColorField::EditorBackground => self.editor_background, ThemeColorField::EditorGutterBackground => self.editor_gutter_background, ThemeColorField::EditorSubheaderBackground => self.editor_subheader_background, diff --git a/crates/theme_extension/Cargo.toml b/crates/theme_extension/Cargo.toml index ca5b71de20..3094ddb0b5 100644 --- a/crates/theme_extension/Cargo.toml +++ b/crates/theme_extension/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/theme_extension.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/theme_settings/src/schema.rs b/crates/theme_settings/src/schema.rs index 904c216715..d8c753f81c 100644 --- a/crates/theme_settings/src/schema.rs +++ b/crates/theme_settings/src/schema.rs @@ -541,6 +541,10 @@ pub fn theme_colors_refinement( .editor_foreground .as_ref() .and_then(|color| try_parse_color(color).ok()), + editor_code_lens_foreground: this + .editor_code_lens_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), editor_background: this .editor_background .as_ref() diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index f6e4025f2d..c05b7eb8c6 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -701,8 +701,9 @@ fn font_fallbacks_from_settings( } impl settings::Settings for ThemeSettings { - fn from_settings(content: &settings::SettingsContent) -> Self { - let content = &content.theme; + fn from_settings(settings_content: &settings::SettingsContent) -> Self { + let content = &settings_content.theme; + let markdown_preview = settings_content.markdown_preview.as_ref(); let theme_selection: ThemeSelection = content.theme.clone().unwrap().into(); let icon_theme_selection: IconThemeSelection = content.icon_theme.clone().unwrap().into(); Self { @@ -742,18 +743,17 @@ impl settings::Settings for ThemeSettings { .map(|font| font.0.clone().into()), agent_buffer_font_size: content.agent_buffer_font_size.map(|s| s.into_gpui()), git_commit_buffer_font_size: content.git_commit_buffer_font_size.map(|s| s.into_gpui()), - markdown_preview_font_family: content - .markdown_preview_font_family - .as_ref() + markdown_preview_font_family: markdown_preview + .and_then(|preview| preview.font_family.as_ref()) .map(|f| f.0.clone().into()), - markdown_preview_code_font_family: content - .markdown_preview_code_font_family - .as_ref() + markdown_preview_code_font_family: markdown_preview + .and_then(|preview| preview.code_font_family.as_ref()) .map(|f| f.0.clone().into()), - markdown_preview_font_size: content.markdown_preview_font_size.map(|s| s.into_gpui()), - markdown_preview_theme: content - .markdown_preview_theme - .clone() + markdown_preview_font_size: markdown_preview + .and_then(|preview| preview.font_size) + .map(|size| size.into_gpui()), + markdown_preview_theme: markdown_preview + .and_then(|preview| preview.theme.clone()) .map(ThemeSelection::from), theme: theme_selection, experimental_theme_overrides: content.experimental_theme_overrides.clone(), @@ -764,3 +764,101 @@ impl settings::Settings for ThemeSettings { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn theme_with_colors(colors: ::settings::ThemeColorsContent) -> Theme { + crate::refine_theme(&crate::ThemeContent { + name: "Test".into(), + appearance: ::theme::AppearanceContent::Dark, + style: ::settings::ThemeStyleContent { + colors, + ..Default::default() + }, + }) + } + + fn style_with_colors(colors: ::settings::ThemeColorsContent) -> ::settings::ThemeStyleContent { + ::settings::ThemeStyleContent { + colors, + ..Default::default() + } + } + + #[test] + fn code_lens_foreground_from_theme_survives_text_muted_override() { + let magenta = ::theme::try_parse_color("#ff00ff").unwrap(); + let green = ::theme::try_parse_color("#00ff00").unwrap(); + let mut test_theme = theme_with_colors(::settings::ThemeColorsContent { + editor_code_lens_foreground: Some("#ff00ff".into()), + ..Default::default() + }); + + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + text_muted: Some("#00ff00".into()), + ..Default::default() + }), + ); + + assert_eq!( + test_theme.styles.colors.editor_code_lens_foreground, + Some(magenta) + ); + assert_eq!(test_theme.styles.colors.text_muted, green); + } + + #[test] + fn code_lens_foreground_from_earlier_override_survives_later_muted_override() { + let magenta = ::theme::try_parse_color("#ff00ff").unwrap(); + let green = ::theme::try_parse_color("#00ff00").unwrap(); + let mut test_theme = theme_with_colors(Default::default()); + + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + editor_code_lens_foreground: Some("#ff00ff".into()), + ..Default::default() + }), + ); + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + text_muted: Some("#00ff00".into()), + ..Default::default() + }), + ); + + assert_eq!( + test_theme.styles.colors.editor_code_lens_foreground, + Some(magenta) + ); + assert_eq!(test_theme.styles.colors.text_muted, green); + } + + #[test] + fn code_lens_foreground_follows_effective_text_muted_without_explicit_color() { + let green = ::theme::try_parse_color("#00ff00").unwrap(); + let mut test_theme = theme_with_colors(Default::default()); + + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + text_muted: Some("#00ff00".into()), + ..Default::default() + }), + ); + + assert_eq!(test_theme.styles.colors.editor_code_lens_foreground, None); + assert_eq!( + test_theme + .styles + .colors + .color(::theme::ThemeColorField::EditorCodeLensForeground), + green + ); + } +} diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 2d82ab65b9..57aceb8fc2 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -74,5 +74,6 @@ remote = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } semver.workspace = true settings = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 369f232441..8ab7590b5b 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,4 +1,5 @@ use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, Subscription, actions}; +use project::DisableAiSettings; use settings::{Settings, SettingsStore}; use workspace::AccessibleMode; @@ -52,22 +53,42 @@ pub struct ApplicationMenu { impl ApplicationMenu { pub fn new(_: &mut Window, cx: &mut Context) -> Self { let menus = cx.get_menus().unwrap_or_default(); - // Re-render when settings change so toggling "accessible mode" expands or - // collapses the menu bar (see `all_menus_shown`). - let settings_subscription = cx.observe_global::(|_, cx| cx.notify()); + + let entries = Self::build_entries(menus); + let mut disable_ai = DisableAiSettings::get_global(cx).disable_ai; + let settings_subscription = + cx.observe_global::(move |application_menu, cx| { + let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; + if new_disable_ai != disable_ai { + disable_ai = new_disable_ai; + for entry in &application_menu.entries { + if entry.handle.is_deployed() { + entry.handle.hide(cx); + } + } + let menus = cx.get_menus().unwrap_or_default(); + application_menu.entries = Self::build_entries(menus); + } + cx.notify(); + }); + Self { - entries: menus - .into_iter() - .map(|menu| MenuEntry { - menu, - handle: PopoverMenuHandle::default(), - }) - .collect(), + entries, pending_menu_open: None, _settings_subscription: settings_subscription, } } + fn build_entries(menus: Vec) -> SmallVec<[MenuEntry; 8]> { + menus + .into_iter() + .map(|menu| MenuEntry { + menu, + handle: PopoverMenuHandle::default(), + }) + .collect() + } + fn sanitize_menu_items(items: Vec) -> Vec { let mut cleaned = Vec::new(); let mut last_was_separator = false; @@ -343,3 +364,200 @@ impl Render for ApplicationMenu { }) } } + +#[cfg(test)] +mod tests { + use gpui::{Action, Menu, MenuItem, OwnedMenu, OwnedMenuItem, TestAppContext, UpdateGlobal}; + use project::DisableAiSettings; + use settings::{Settings, SettingsStore}; + + use crate::application_menu::{ApplicationMenu, MenuEntry, OpenApplicationMenu}; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + DisableAiSettings::register(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn owned_menu(name: &str, item_name: &str) -> OwnedMenu { + OwnedMenu { + name: name.into(), + disabled: false, + items: vec![OwnedMenuItem::Action { + name: item_name.to_string(), + action: OpenApplicationMenu(String::new()).boxed_clone(), + os_action: None, + checked: false, + disabled: false, + }], + } + } + + fn has_item(entries: &[MenuEntry], menu_name: &str, item_name: &str) -> bool { + entries.iter().any(|entry| { + entry.menu.name == menu_name + && entry.menu.items.iter().any( + |item| matches!(item, OwnedMenuItem::Action { name, .. } if name == item_name), + ) + }) + } + + fn menu_entry<'a>(entries: &'a [MenuEntry], menu_name: &str) -> &'a MenuEntry { + entries + .iter() + .find(|entry| entry.menu.name == menu_name) + .expect("expected menu") + } + + fn view_menu(include_agent_panel: bool) -> Menu { + let mut items = Vec::new(); + if include_agent_panel { + items.push(MenuItem::action( + "Agent Panel", + OpenApplicationMenu(String::new()), + )); + } + items.push(MenuItem::action( + "Diagnostics", + OpenApplicationMenu(String::new()), + )); + + Menu { + name: "View".into(), + items, + disabled: false, + } + } + + #[test] + fn test_build_entries_reflects_current_menu_data() { + let with_agent_panel = + ApplicationMenu::build_entries(vec![owned_menu("View", "Agent Panel")]); + assert!(has_item(&with_agent_panel, "View", "Agent Panel")); + + let without_agent_panel = + ApplicationMenu::build_entries(vec![owned_menu("View", "Diagnostics")]); + assert!(!has_item(&without_agent_panel, "View", "Agent Panel")); + } + + #[gpui::test] + fn test_entries_refresh_on_settings_change(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.set_menus(vec![view_menu(true)]); + }); + + let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); + let view_handle = cx.update(|window, cx| { + let view_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.clone() + }); + view_handle.show(window, cx); + view_handle + }); + assert!(view_handle.is_deployed()); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + ))); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + + cx.update(|_, cx| { + cx.set_menus(vec![view_menu(false)]); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + }); + + assert!(!view_handle.is_deployed()); + assert!( + !app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + )), + "expected Agent Panel is not in the View menu" + ); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + + cx.update(|_, cx| { + cx.set_menus(vec![view_menu(true)]); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); + }); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + ))); + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + } + + #[gpui::test] + fn test_entries_preserve_deployed_menu_on_unrelated_settings_change(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.set_menus(vec![ + Menu::new("Zed").items([MenuItem::action( + "Settings", + OpenApplicationMenu(String::new()), + )]), + view_menu(true), + ]); + }); + + let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); + + let zed_handle = cx.update(|window, cx| { + let zed_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "Zed").handle.clone() + }); + zed_handle.show(window, cx); + zed_handle + }); + cx.run_until_parked(); + assert!(zed_handle.is_deployed()); + + cx.update(|window, cx| { + let view_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.clone() + }); + zed_handle.hide(cx); + view_handle.show(window, cx); + }); + cx.run_until_parked(); + assert!(!zed_handle.is_deployed()); + + assert!(app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.is_deployed() + })); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |_, _| {}); + }); + cx.run_until_parked(); + + assert!(app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.is_deployed() + })); + } +} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index a317eb4f1f..09c36b3855 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -1331,7 +1331,7 @@ impl TitleBar { ) .separator() }) - .map(|this| { + .when(is_signed_in, |this| { let mut this = this.header("Organization"); for (organization, plan) in &organizations { diff --git a/crates/title_bar/src/update_version.rs b/crates/title_bar/src/update_version.rs index e90764ab81..b5164b23ea 100644 --- a/crates/title_bar/src/update_version.rs +++ b/crates/title_bar/src/update_version.rs @@ -1,15 +1,20 @@ use std::sync::Arc; +use std::time::Duration; use anyhow::anyhow; -use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType}; -use gpui::{Empty, Render}; +use auto_update::{AutoUpdateEvent, AutoUpdateStatus, AutoUpdater, UpdateCheckType}; +use gpui::{Empty, Render, Task}; use semver::Version; use ui::{Tooltip, UpdateButton, prelude::*}; +const UP_TO_DATE_DISPLAY_DURATION: Duration = Duration::from_secs(4); + pub struct UpdateVersion { status: AutoUpdateStatus, update_check_type: UpdateCheckType, dismissed_status: Option, + showing_up_to_date: bool, + _up_to_date_timer: Option>, } impl UpdateVersion { @@ -20,23 +25,53 @@ impl UpdateVersion { this.status = auto_update.status(); this.update_check_type = auto_update.update_check_type(); this.dismissed_status = auto_update.dismissed_status(); + if !matches!(this.status, AutoUpdateStatus::Idle) { + this.hide_up_to_date(); + } cx.notify(); }) .detach(); + cx.subscribe(&auto_updater, |this, _, event, cx| match event { + AutoUpdateEvent::UpToDate => this.show_up_to_date(cx), + }) + .detach(); Self { status: auto_updater.read(cx).status(), update_check_type: UpdateCheckType::Automatic, dismissed_status: auto_updater.read(cx).dismissed_status(), + showing_up_to_date: false, + _up_to_date_timer: None, } } else { Self { status: AutoUpdateStatus::Idle, update_check_type: UpdateCheckType::Automatic, dismissed_status: None, + showing_up_to_date: false, + _up_to_date_timer: None, } } } + fn show_up_to_date(&mut self, cx: &mut Context) { + self.showing_up_to_date = true; + self._up_to_date_timer = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(UP_TO_DATE_DISPLAY_DURATION) + .await; + this.update(cx, |this, cx| { + this.hide_up_to_date(); + cx.notify(); + }) + .ok(); + })); + } + + fn hide_up_to_date(&mut self) { + self.showing_up_to_date = false; + self._up_to_date_timer = None; + } + pub fn update_simulation(&mut self, cx: &mut Context) { let next_state = match self.status { AutoUpdateStatus::Idle => AutoUpdateStatus::Checking, @@ -59,6 +94,7 @@ impl UpdateVersion { self.status = next_state; self.update_check_type = UpdateCheckType::Manual; self.dismissed_status = None; + self.hide_up_to_date(); cx.notify() } @@ -91,6 +127,9 @@ impl Render for UpdateVersion { if self.is_dismissed() { return Empty.into_any_element(); } + if self.showing_up_to_date { + return UpdateButton::up_to_date().into_any_element(); + } match &self.status { AutoUpdateStatus::Checking if self.update_check_type.is_manual() => { UpdateButton::checking().into_any_element() diff --git a/crates/toolchain_selector/Cargo.toml b/crates/toolchain_selector/Cargo.toml index 09f9acceab..0c3cd7ce0b 100644 --- a/crates/toolchain_selector/Cargo.toml +++ b/crates/toolchain_selector/Cargo.toml @@ -27,3 +27,4 @@ workspace = true [lib] path = "src/toolchain_selector.rs" doctest = false +test = false diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 6b2ed189aa..7ff2cfa371 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -42,4 +42,3 @@ ignored = ["log"] [features] default = [] -derive_inspector_reflection = [] diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index e8e55836c4..d461b8ed26 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -314,6 +314,7 @@ impl Disableable for Button { } impl Clickable for Button { + #[inline(always)] fn on_click( mut self, handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, @@ -393,6 +394,7 @@ impl ButtonCommon for Button { /// Button::new("tooltip_button", "Hover Me") /// .tooltip(Tooltip::text("This is a tooltip")); /// ``` + #[inline(always)] fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { self.base = self.base.tooltip(tooltip); self diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index bc7fb9d2fd..ed77c80108 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -670,6 +670,7 @@ impl SelectableButton for ButtonLike { } impl Clickable for ButtonLike { + #[inline(always)] fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { self.on_click = Some(Box::new(handler)); self @@ -708,6 +709,7 @@ impl ButtonCommon for ButtonLike { self } + #[inline(always)] fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { self.tooltip = Some(Box::new(tooltip)); self @@ -776,7 +778,11 @@ impl RenderOnce for ButtonLike { Toggled::False }) }) - .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)) + .when_some(self.tab_index, |this, tab_index| { + // Keep an already-focused button registered so disabling it does not + // move focus outside the view. + this.tab_index(tab_index).tab_stop(!self.disabled) + }) .when_some(self.focus_handle, |this, focus_handle| { this.track_focus(&focus_handle) }) diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 2da97d4bc5..c5b039532b 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -160,6 +160,7 @@ impl SelectableButton for IconButton { } impl Clickable for IconButton { + #[inline(always)] fn on_click( mut self, handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, @@ -201,6 +202,7 @@ impl ButtonCommon for IconButton { self } + #[inline(always)] fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { self.base = self.base.tooltip(tooltip); self diff --git a/crates/ui/src/components/collab/update_button.rs b/crates/ui/src/components/collab/update_button.rs index 0b94072e90..9c17f498f8 100644 --- a/crates/ui/src/components/collab/update_button.rs +++ b/crates/ui/src/components/collab/update_button.rs @@ -118,6 +118,10 @@ impl UpdateButton { .disabled(true) } + pub fn up_to_date() -> Self { + Self::new(IconName::Check, "Up to Date").disabled(true) + } + pub fn updated(version: impl Into) -> Self { Self::new(IconName::Download, "Restart to Update") .tooltip(version) @@ -254,6 +258,7 @@ impl Component for UpdateButton { "Installing", UpdateButton::installing(version).into_any_element(), ), + single_example("Up to Date", UpdateButton::up_to_date().into_any_element()), ], ), example_group_with_title( diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index cea25ab5e7..21d5ce3dd3 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -12,9 +12,10 @@ use std::{ cell::{Cell, RefCell}, collections::HashMap, rc::Rc, - time::{Duration, Instant}, + time::Duration, }; use theme::BufferLineHeight; +use web_time::Instant; #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SubmenuOpenTrigger { @@ -270,81 +271,14 @@ impl EventEmitter for ContextMenu {} impl FluentBuilder for ContextMenu {} impl ContextMenu { + #[inline(always)] pub fn new( window: &mut Window, cx: &mut Context, - f: impl FnOnce(Self, &mut Window, &mut Context) -> Self, + build_menu: impl FnOnce(Self, &mut Window, &mut Context) -> Self, ) -> Self { - let focus_handle = cx.focus_handle(); - let _on_blur_subscription = cx.on_blur( - &focus_handle, - window, - |this: &mut ContextMenu, window, cx| { - if let Some(ignore_until) = this.ignore_blur_until { - if Instant::now() < ignore_until { - return; - } else { - this.ignore_blur_until = None; - } - } - - if this.main_menu.is_none() { - if let SubmenuState::Open(open_submenu) = &this.submenu_state { - let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone(); - if submenu_focus.contains_focused(window, cx) { - return; - } - } - } - - this.cancel(&menu::Cancel, window, cx) - }, - ); - window.refresh(); - - // When the menu first receives focus (i.e. when it opens), move the - // selection onto a menu item so assistive technology announces a real - // item rather than the bare menu container. Per the ARIA menu button - // pattern, opening a menu places focus on a menu item; for select-style - // menus we prefer the currently-checked item. We only do this when - // nothing is selected yet so we don't override an existing selection. - cx.on_focus_in(&focus_handle, window, |this, window, cx| { - if this.selected_index.is_none() && !this.suppress_focus_selection { - this.select_toggled_or_first(window, cx); - } - this.suppress_focus_selection = false; - }) - .detach(); - - f( - Self { - builder: None, - items: Default::default(), - focus_handle, - action_context: None, - selected_index: None, - delayed: false, - clicked: false, - end_slot_action: None, - key_context: "menu".into(), - _on_blur_subscription, - keep_open_on_confirm: false, - fixed_width: None, - main_menu: None, - main_menu_observed_bounds: Rc::new(Cell::new(None)), - documentation_aside: None, - aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())), - submenu_state: SubmenuState::Closed, - hover_target: HoverTarget::MainMenu, - submenu_safety_threshold_x: None, - submenu_trigger_bounds: Rc::new(Cell::new(None)), - submenu_trigger_mouse_down: false, - ignore_blur_until: None, - suppress_focus_selection: false, - }, - window, - cx, - ) + let menu = Self::new_inner(window, cx); + build_menu(menu, window, cx) } pub fn build( @@ -2174,6 +2108,76 @@ impl ContextMenu { ) .into_any_element() } + + #[inline(never)] + fn new_inner(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + let _on_blur_subscription = cx.on_blur( + &focus_handle, + window, + |context_menu: &mut ContextMenu, window, cx| { + if let Some(ignore_until) = context_menu.ignore_blur_until { + if Instant::now() < ignore_until { + return; + } else { + context_menu.ignore_blur_until = None; + } + } + + if context_menu.main_menu.is_none() { + if let SubmenuState::Open(open_submenu) = &context_menu.submenu_state { + let submenu_focus = open_submenu.entity.read(cx).focus_handle.clone(); + if submenu_focus.contains_focused(window, cx) { + return; + } + } + } + + context_menu.cancel(&menu::Cancel, window, cx) + }, + ); + window.refresh(); + + // When the menu first receives focus (i.e. when it opens), move the + // selection onto a menu item so assistive technology announces a real + // item rather than the bare menu container. Per the ARIA menu button + // pattern, opening a menu places focus on a menu item; for select-style + // menus we prefer the currently-checked item. We only do this when + // nothing is selected yet so we don't override an existing selection. + cx.on_focus_in(&focus_handle, window, |context_menu, window, cx| { + if context_menu.selected_index.is_none() && !context_menu.suppress_focus_selection { + context_menu.select_toggled_or_first(window, cx); + } + context_menu.suppress_focus_selection = false; + }) + .detach(); + + Self { + builder: None, + items: Vec::new(), + focus_handle, + action_context: None, + selected_index: None, + delayed: false, + clicked: false, + end_slot_action: None, + key_context: SharedString::from("menu"), + _on_blur_subscription, + keep_open_on_confirm: false, + fixed_width: None, + main_menu: None, + main_menu_observed_bounds: Rc::new(Cell::new(None)), + documentation_aside: None, + aside_trigger_bounds: Rc::new(RefCell::new(HashMap::default())), + submenu_state: SubmenuState::Closed, + hover_target: HoverTarget::MainMenu, + submenu_safety_threshold_x: None, + submenu_trigger_bounds: Rc::new(Cell::new(None)), + submenu_trigger_mouse_down: false, + ignore_blur_until: None, + suppress_focus_selection: false, + } + } } impl ContextMenuItem { diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index d6d644db43..bacadaa078 100644 --- a/crates/ui/src/components/diff_stat.rs +++ b/crates/ui/src/components/diff_stat.rs @@ -44,12 +44,12 @@ impl RenderOnce for DiffStat { .gap_1() .child( Label::new(format!("+\u{2009}{added}")) - .color(Color::Success) + .color(Color::VersionControlAdded) .size(self.label_size), ) .child( Label::new(format!("\u{2012}\u{2009}{removed}")) - .color(Color::Error) + .color(Color::VersionControlDeleted) .size(self.label_size), ) .when_some(tooltip, |this, tooltip| { diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index bc8c68067e..0d03ddff3c 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -46,6 +46,8 @@ impl Clone for Source { pub struct KeyBinding { source: Source, size: Option, + style: KeyBindingStyle, + color: Option, /// The [`PlatformStyle`] to use when displaying this keybinding. platform_style: PlatformStyle, /// Determines whether the keybinding is meant for vim mode. @@ -54,9 +56,28 @@ pub struct KeyBinding { disabled: bool, } +/// Controls how a [`KeyBinding`] is presented. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KeyBindingStyle { + /// Render each key using compact key dimensions and muted colors. + #[default] + Default, + /// Render keys and modifier symbols using the same metrics and colors as a [`Label`]. + Label, +} + struct VimStyle(bool); impl Global for VimStyle {} +struct KeyBindingVisibility(bool); +impl Global for KeyBindingVisibility {} + +impl Default for KeyBindingVisibility { + fn default() -> Self { + Self(true) + } +} + impl KeyBinding { /// Returns the highest precedence keybinding for an action. This is the last binding added to /// the keymap. User bindings are added after built-in bindings so that they take precedence. @@ -89,6 +110,17 @@ impl KeyBinding { cx.try_global::().is_some_and(|g| g.0) } + /// Sets the application-wide visibility of keybindings. + pub fn set_default_visibility(cx: &mut App, visible: bool) { + cx.set_global(KeyBindingVisibility(visible)); + cx.refresh_windows(); + } + + /// Returns whether keybindings are visible application-wide. + pub fn default_visibility(cx: &mut App) -> bool { + cx.default_global::().0 + } + pub fn new(action: &dyn Action, focus_handle: Option, cx: &App) -> Self { Self { source: Source::Action { @@ -96,6 +128,8 @@ impl KeyBinding { focus_handle, }, size: None, + style: KeyBindingStyle::Default, + color: None, vim_mode: KeyBinding::is_vim_mode(cx), platform_style: PlatformStyle::platform(), disabled: false, @@ -106,6 +140,8 @@ impl KeyBinding { Self { source: Source::Keystrokes { keystrokes }, size: None, + style: KeyBindingStyle::Default, + color: None, vim_mode, platform_style: PlatformStyle::platform(), disabled: false, @@ -124,6 +160,18 @@ impl KeyBinding { self } + /// Sets how this keybinding is presented. + pub fn style(mut self, style: KeyBindingStyle) -> Self { + self.style = style; + self + } + + /// Sets the color used to render the keybinding. + pub fn color(mut self, color: Color) -> Self { + self.color = Some(color); + self + } + /// Sets whether this keybinding is currently disabled. /// Disabled keybinds will be rendered in a dimmed state. pub fn disabled(mut self, disabled: bool) -> Self { @@ -186,21 +234,36 @@ fn render_key( color: Option, platform_style: PlatformStyle, size: impl Into>, + style: KeyBindingStyle, ) -> AnyElement { let key_icon = icon_for_key(key, platform_style); match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { let key = capitalize(key); - Key::new(&key, color).size(size).into_any_element() + match style { + KeyBindingStyle::Default => Key::new(&key, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(&key, color).size(size).into_any_element(), + } } } } impl RenderOnce for KeyBinding { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + if !Self::default_visibility(cx) { + return gpui::Empty.into_any_element(); + } + let render_keybinding = |keystrokes: &[KeybindingKeystroke]| { - let color = self.disabled.then_some(Color::Disabled); + let color = if self.disabled { + Some(Color::Disabled) + } else { + self.color.or(match self.style { + KeyBindingStyle::Default => None, + KeyBindingStyle::Label => Some(Color::Default), + }) + }; h_flex() .debug_selector(|| { @@ -218,15 +281,16 @@ impl RenderOnce for KeyBinding { .children(keystrokes.iter().map(|keystroke| { h_flex() .flex_none() - .py_0p5() + .when(self.style == KeyBindingStyle::Default, |this| this.py_0p5()) .rounded_xs() - .text_color(cx.theme().colors().text_muted) - .children(render_keybinding_keystroke( + .text_color(color.unwrap_or(Color::Muted).color(cx)) + .children(render_keybinding_keystroke_with_style( keystroke, color, self.size, PlatformStyle::platform(), self.vim_mode, + self.style, )) })) .into_any_element() @@ -255,6 +319,24 @@ pub fn render_keybinding_keystroke( size: impl Into>, platform_style: PlatformStyle, vim_mode: bool, +) -> Vec { + render_keybinding_keystroke_with_style( + keystroke, + color, + size, + platform_style, + vim_mode, + KeyBindingStyle::Default, + ) +} + +fn render_keybinding_keystroke_with_style( + keystroke: &KeybindingKeystroke, + color: Option, + size: impl Into>, + platform_style: PlatformStyle, + vim_mode: bool, + style: KeyBindingStyle, ) -> Vec { let use_text = vim_mode || matches!( @@ -264,28 +346,40 @@ pub fn render_keybinding_keystroke( let size = size.into(); if use_text { - let element = Key::new( - keystroke_text( - keystroke.modifiers(), - keystroke.key(), - platform_style, - vim_mode, - ), - color, - ) - .size(size) - .into_any_element(); + let text = keystroke_text( + keystroke.modifiers(), + keystroke.key(), + platform_style, + vim_mode, + ); + let element = match style { + KeyBindingStyle::Default => Key::new(text, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(text, color).size(size).into_any_element(), + }; vec![element] } else { let mut elements = Vec::new(); - elements.extend(render_modifiers( + let modifiers = render_modifiers_with_style( keystroke.modifiers(), platform_style, color, size, true, + style, + ) + .collect::>(); + if style == KeyBindingStyle::Label && !modifiers.is_empty() { + elements.push(h_flex().children(modifiers).mr_0p5().into_any_element()); + } else { + elements.extend(modifiers); + } + elements.push(render_key( + keystroke.key(), + color, + platform_style, + size, + style, )); - elements.push(render_key(keystroke.key(), color, platform_style, size)); elements } } @@ -320,6 +414,24 @@ pub fn render_modifiers( color: Option, size: Option, trailing_separator: bool, +) -> impl Iterator { + render_modifiers_with_style( + modifiers, + platform_style, + color, + size, + trailing_separator, + KeyBindingStyle::Default, + ) +} + +fn render_modifiers_with_style( + modifiers: &Modifiers, + platform_style: PlatformStyle, + color: Option, + size: Option, + trailing_separator: bool, + style: KeyBindingStyle, ) -> impl Iterator { #[derive(Clone)] enum KeyOrIcon { @@ -335,37 +447,42 @@ pub fn render_modifiers( windows: KeyOrIcon, } + let mac_modifier = |icon, label| match style { + KeyBindingStyle::Default => KeyOrIcon::Icon(icon), + KeyBindingStyle::Label => KeyOrIcon::Key(label), + }; + let table = { use KeyOrIcon::*; [ Modifier { enabled: modifiers.function, - mac: Icon(IconName::Control), + mac: mac_modifier(IconName::Control, "Fn"), linux: Key("Fn"), windows: Key("Fn"), }, Modifier { enabled: modifiers.control, - mac: Icon(IconName::Control), + mac: mac_modifier(IconName::Control, "⌃"), linux: Key("Ctrl"), windows: Key("Ctrl"), }, Modifier { enabled: modifiers.alt, - mac: Icon(IconName::Option), + mac: mac_modifier(IconName::Option, "⌥"), linux: Key("Alt"), windows: Key("Alt"), }, Modifier { enabled: modifiers.platform, - mac: Icon(IconName::Command), + mac: mac_modifier(IconName::Command, "⌘"), linux: Key("Super"), windows: Key("Win"), }, Modifier { enabled: modifiers.shift, - mac: Icon(IconName::Shift), + mac: mac_modifier(IconName::Shift, "⇧"), linux: Key("Shift"), windows: Key("Shift"), }, @@ -401,9 +518,15 @@ pub fn render_modifiers( }) .flatten() .map(move |key_or_icon| match key_or_icon { - KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(), + KeyOrIcon::Key(key) => match style { + KeyBindingStyle::Default => Key::new(key, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(key, color).size(size).into_any_element(), + }, KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), - KeyOrIcon::Plus => "+".into_any_element(), + KeyOrIcon::Plus => match style { + KeyBindingStyle::Default => "+".into_any_element(), + KeyBindingStyle::Label => LabelKey::new("+", color).size(size).into_any_element(), + }, }) } @@ -453,6 +576,41 @@ impl Key { } } +#[derive(IntoElement)] +struct LabelKey { + key: SharedString, + color: Option, + size: Option, +} + +impl RenderOnce for LabelKey { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let size = self + .size + .map(|size| LabelSize::Custom(size.to_rems(window.rem_size()))) + .unwrap_or_default(); + + Label::new(self.key) + .size(size) + .color(self.color.unwrap_or(Color::Default)) + } +} + +impl LabelKey { + fn new(key: impl Into, color: Option) -> Self { + Self { + key: key.into(), + color, + size: None, + } + } + + fn size(mut self, size: impl Into>) -> Self { + self.size = size.into(); + self + } +} + #[derive(IntoElement)] pub struct KeyIcon { icon: IconName, diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index e7177f4eca..8beb4a26ec 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -2,12 +2,12 @@ use std::{any::Any, fmt::Debug, ops::Not, time::Duration}; use web_time::Instant; use gpui::{ - Along, Anchor, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, - Context, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, Entity, EntityId, - GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, - LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, - Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful, - StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration, + Along, Anchor, AnyElement, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, + ContentMask, Context, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, + Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, + IntoElement, IsZero, LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement, Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, + Size, Stateful, StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration, UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative, size, }; @@ -237,7 +237,7 @@ impl UniformListDecoration for ScrollbarStateWrapper { origin: -scroll_offset, state: self.0.clone(), } - .into_any() + .into_any_element() } } @@ -934,7 +934,7 @@ impl Render for ScrollbarState { } } -struct ScrollbarElement { +pub struct ScrollbarElement { origin: Point, state: Entity>, } @@ -954,6 +954,11 @@ impl ThumbState { } impl ScrollableHandle for UniformListScrollHandle { + #[inline(never)] + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point { self.0.borrow().base_handle.max_offset() } @@ -972,6 +977,11 @@ impl ScrollableHandle for UniformListScrollHandle { } impl ScrollableHandle for ListState { + #[inline(never)] + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point { self.max_offset_for_scrollbar() } @@ -998,6 +1008,11 @@ impl ScrollableHandle for ListState { } impl ScrollableHandle for ScrollHandle { + #[inline(never)] + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point { self.max_offset() } @@ -1016,6 +1031,10 @@ impl ScrollableHandle for ScrollHandle { } pub trait ScrollableHandle: 'static + Any + Sized + Clone { + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point; fn set_offset(&self, point: Point); fn offset(&self) -> Point; @@ -1642,6 +1661,10 @@ impl IntoElement for ScrollbarElement { fn into_element(self) -> Self::Element { self } + + fn into_any_element(self) -> AnyElement { + T::into_scrollbar_element(self) + } } #[cfg(test)] diff --git a/crates/ui/src/styles/appearance.rs b/crates/ui/src/styles/appearance.rs index 007511584e..6daaaa28b6 100644 --- a/crates/ui/src/styles/appearance.rs +++ b/crates/ui/src/styles/appearance.rs @@ -14,6 +14,9 @@ fn window_appearance(cx: &mut App) -> WindowBackgroundAppearance { pub fn theme_is_transparent(cx: &mut App) -> bool { matches!( window_appearance(cx), - WindowBackgroundAppearance::Transparent | WindowBackgroundAppearance::Blurred + WindowBackgroundAppearance::Transparent + | WindowBackgroundAppearance::Blurred + | WindowBackgroundAppearance::MicaBackdrop + | WindowBackgroundAppearance::MicaAltBackdrop ) } diff --git a/crates/ui/src/styles/platform.rs b/crates/ui/src/styles/platform.rs index 0d873a2ff2..cee7dfe013 100644 --- a/crates/ui/src/styles/platform.rs +++ b/crates/ui/src/styles/platform.rs @@ -1,3 +1,21 @@ +/// Expands to a literal so platform-specific hints can use `concat!` without allocating. +#[cfg(target_os = "macos")] +#[macro_export] +macro_rules! alt_key_name { + () => { + "option" + }; +} + +/// Expands to a literal so platform-specific hints can use `concat!` without allocating. +#[cfg(not(target_os = "macos"))] +#[macro_export] +macro_rules! alt_key_name { + () => { + "alt" + }; +} + /// The platform style to use when rendering UI. /// /// This can be used to abstract over platform differences. diff --git a/crates/ui/src/traits/styled_ext.rs b/crates/ui/src/traits/styled_ext.rs index c509595362..849e56a024 100644 --- a/crates/ui/src/traits/styled_ext.rs +++ b/crates/ui/src/traits/styled_ext.rs @@ -20,10 +20,7 @@ fn elevated_borderless(this: E, cx: &mut App, index: ElevationIndex) /// Extends [`gpui::Styled`] with Zed-specific styling methods. // gate on rust-analyzer so rust-analyzer never needs to expand this macro, it takes up to 10 seconds to expand due to inefficiencies in rust-analyzers proc-macro srv #[cfg_attr( - all( - any(debug_assertions, feature = "derive_inspector_reflection"), - not(rust_analyzer) - ), + all(debug_assertions, not(rust_analyzer)), gpui_macros::derive_inspector_reflection )] pub trait StyledExt: Styled + Sized { diff --git a/crates/ui_input/Cargo.toml b/crates/ui_input/Cargo.toml index fef5216266..6ee0df793f 100644 --- a/crates/ui_input/Cargo.toml +++ b/crates/ui_input/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/ui_input.rs" +test = false +doctest = false [dependencies] component.workspace = true diff --git a/crates/ui_macros/Cargo.toml b/crates/ui_macros/Cargo.toml index 74bd2186a7..e2e3f96697 100644 --- a/crates/ui_macros/Cargo.toml +++ b/crates/ui_macros/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/ui_macros.rs" proc-macro = true +test = false [dependencies] quote.workspace = true diff --git a/crates/ui_prompt/Cargo.toml b/crates/ui_prompt/Cargo.toml index b8425a1a66..a8644cc8ae 100644 --- a/crates/ui_prompt/Cargo.toml +++ b/crates/ui_prompt/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/ui_prompt.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index d1f3c0c70e..7ed25dd73f 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -56,7 +56,7 @@ dirs.workspace = true [target.'cfg(unix)'.dependencies] command-fds = "0.3.1" libc.workspace = true -nix = { workspace = true, features = ["user"] } +nix = { workspace = true, features = ["resource", "user"] } [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index c2c3268714..42133b3df3 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1,4 +1,4 @@ -use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder}; use itertools::Itertools; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -258,6 +258,10 @@ impl SanitizedPath { #[cfg(target_os = "windows")] { + let path = match path.to_str().and_then(|s| s.strip_prefix(r"\\?\UNC\")) { + Some(rest) => PathBuf::from(format!(r"\\{rest}")).into(), + None => path, + }; let simplified = dunce::simplified(path.as_ref()); if simplified == path.as_ref() { // safe because `Path` and `SanitizedPath` have the same repr and Drop impl @@ -768,6 +772,44 @@ impl PathMatcher { .build() }) .collect::, _>>()?; + Self::from_globs(globs, path_style) + } + + /// Skips invalid globs, reporting each error to `on_error`. + /// If the combined set cannot be built, reports the error and matches nothing. + pub fn new_lenient( + globs: impl IntoIterator>, + path_style: PathStyle, + mut on_error: impl FnMut(globset::Error), + ) -> Self { + let globs = globs + .into_iter() + .filter_map(|pattern| { + match GlobBuilder::new(pattern.as_ref()) + .backslash_escape(path_style.is_posix()) + .build() + { + Ok(glob) => Some(glob), + Err(error) => { + on_error(error); + None + } + } + }) + .collect(); + match Self::from_globs(globs, path_style) { + Ok(matcher) => matcher, + Err(error) => { + on_error(error); + Self { + path_style, + ..Self::default() + } + } + } + } + + fn from_globs(globs: Vec, path_style: PathStyle) -> Result { let sources = globs .iter() .filter_map(|glob| { @@ -1471,6 +1513,67 @@ mod tests { use super::*; use util_macros::perf; + #[test] + fn test_lenient_path_matcher() { + for path_style in [PathStyle::Unix, PathStyle::Windows] { + let patterns = ["**/.git", "[", "target/**", "{"]; + let mut errors = Vec::new(); + let matcher = + PathMatcher::new_lenient(patterns, path_style, |error| errors.push(error)); + let expected = PathMatcher::new(["**/.git", "target/**"], path_style).unwrap(); + assert_eq!(matcher, expected); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].glob(), Some("[")); + assert_eq!(errors[1].glob(), Some("{")); + for path in ["nested/.git", "src/file.rs", "target/file.rs"] { + let path = RelPath::from_unix_str(path).unwrap(); + assert_eq!(matcher.is_match(path), expected.is_match(path)); + } + if path_style == PathStyle::local() { + assert!(matcher.is_match(RelPath::from_unix_str("nested/.git").unwrap())); + assert!(matcher.is_match(RelPath::from_unix_str("target/file.rs").unwrap())); + } + assert!(PathMatcher::new(patterns, path_style).is_err()); + + let mut errors = Vec::new(); + let matcher = + PathMatcher::new_lenient(["[", "{"], path_style, |error| errors.push(error)); + assert_eq!(errors.len(), 2); + assert_eq!(matcher.path_style, path_style); + assert_eq!(matcher.sources().count(), 0); + assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap())); + assert!(!matcher.is_match_std_path("file.rs")); + + let matcher = PathMatcher::new_lenient([] as [&str; 0], path_style, |_| { + panic!("empty patterns are valid") + }); + assert_eq!(matcher.path_style, path_style); + assert_eq!(matcher.sources().count(), 0); + assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap())); + } + } + + #[test] + fn test_lenient_path_matcher_preserves_escaping() { + for path_style in [PathStyle::Unix, PathStyle::Windows] { + let patterns = [r"directory\file.rs", r"literal\*", r"literal\[name]"]; + let strict = PathMatcher::new(patterns, path_style).unwrap(); + let lenient = PathMatcher::new_lenient(patterns, path_style, |_| { + panic!("valid patterns are preserved") + }); + assert_eq!(lenient, strict); + for path in [ + "directory/file.rs", + "literal*", + "literal/file.rs", + "literal[name]", + ] { + let path = RelPath::from_unix_str(path).unwrap(); + assert_eq!(lenient.is_match(path), strict.is_match(path)); + } + } + } + #[test] fn test_parse_str_treats_paren_suffix_as_position() { // This documents the behavior that causes the folder-drop bug: a name ending in @@ -2642,6 +2745,14 @@ mod tests { ); } + #[perf] + #[cfg(target_os = "windows")] + fn test_sanitized_path_verbatim_unc() { + let path: Arc = PathBuf::from("\\\\?\\UNC\\server\\share\\file.txt").into(); + let sanitized_path = SanitizedPath::from_arc(path); + assert_eq!(sanitized_path.to_string(), "\\\\server\\share\\file.txt"); + } + #[perf] fn test_compare_numeric_segments() { // Helper function to create peekable iterators and test diff --git a/crates/util/src/redact.rs b/crates/util/src/redact.rs index ad11f7618b..a9a82bfb14 100644 --- a/crates/util/src/redact.rs +++ b/crates/util/src/redact.rs @@ -20,6 +20,13 @@ pub fn should_redact(env_var_name: &str) -> bool { .any(|suffix| env_var_name.ends_with(suffix)) } +pub fn is_valid_environment_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character != '=' && !character.is_control()) +} + /// Redact a string which could include a command with environment variables pub fn redact_command(command: &str) -> String { REDACT_REGEX diff --git a/crates/util/src/shell_builder.rs b/crates/util/src/shell_builder.rs index 76ff3923e6..870bf85caf 100644 --- a/crates/util/src/shell_builder.rs +++ b/crates/util/src/shell_builder.rs @@ -350,4 +350,38 @@ mod test { assert_eq!(program, "fish"); assert_eq!(args, vec!["-i", "-c", "echo oo"]); } + + #[test] + fn windows_powershell_preserves_spaced_arg_as_single_shell_argument() { + let worktree_root = r"C:\worktrees\Godot Projects\sample-game"; + let shell = Shell::Program("powershell".to_owned()); + + let (program, args) = ShellBuilder::new(&shell, true) + .build(Some("echo".into()), &[worktree_root.to_string()]); + + assert_eq!(program, "powershell"); + assert_eq!( + args, + vec!["-C".to_string(), format!("echo '{worktree_root}'")] + ); + } + + #[test] + fn windows_cmd_preserves_spaced_arg_as_single_shell_argument() { + let worktree_root = r"C:\worktrees\Godot Projects\sample-game"; + let shell = Shell::Program("cmd".to_owned()); + + let (program, args) = ShellBuilder::new(&shell, true) + .build(Some("echo".into()), &[worktree_root.to_string()]); + + assert_eq!(program, "cmd"); + assert_eq!( + args, + vec![ + "/S".to_string(), + "/C".to_string(), + format!("\"echo ^\"{worktree_root}^\"\""), + ] + ); + } } diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 88d874696c..f79a8b6c65 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -244,6 +244,39 @@ Error: Running Zed as root or via sudo is unsupported. } } +/// Raises the soft limit on open file descriptors without changing the hard limit. +/// +/// Call during startup, before spawning children that will inherit the limit. +#[cfg(unix)] +pub fn increase_open_file_limit() -> Result<()> { + use anyhow::Context as _; + use nix::sys::resource::{Resource::RLIMIT_NOFILE, getrlimit, setrlimit}; + + let (soft_limit, hard_limit) = getrlimit(RLIMIT_NOFILE).context("getrlimit(RLIMIT_NOFILE)")?; + // These are startup targets, not OS ceilings. Preserve higher inherited limits. + let target = if cfg!(target_os = "macos") { + 10_240 + } else { + 65_536 + }; + let mut requested_limit = hard_limit.min(target); + + while requested_limit > soft_limit { + let Err(error) = setrlimit(RLIMIT_NOFILE, requested_limit, hard_limit) else { + log::info!("raised open file soft limit from {soft_limit} to {requested_limit}"); + return Ok(()); + }; + + // Some systems enforce a ceiling below the reported hard limit. + if error != nix::errno::Errno::EINVAL || requested_limit == soft_limit + 1 { + return Err(error).context("setrlimit(RLIMIT_NOFILE)"); + } + requested_limit = soft_limit + (requested_limit - soft_limit) / 2; + } + + Ok(()) +} + #[cfg(unix)] fn load_shell_from_passwd() -> Result<()> { let buflen = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { @@ -680,7 +713,7 @@ pub fn dev_repo_root() -> Option<&'static std::path::Path> { /// detail). Hidden from the public API. #[doc(hidden)] pub mod __rust_embed { - pub use rust_embed::{EmbeddedFile, Filenames, Metadata, RustEmbed, utils}; + pub use rust_embed::{EmbeddedFile, Filenames, Metadata, RustEmbed, flate, utils}; } /// Backs the dev arm of [`fs_embed!`]'s `iter`: every file under the root-relative diff --git a/crates/util_macros/Cargo.toml b/crates/util_macros/Cargo.toml index f72955b3ae..5eb4173eb7 100644 --- a/crates/util_macros/Cargo.toml +++ b/crates/util_macros/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [lib] path = "src/util_macros.rs" proc-macro = true -doctest = false +test = false [dependencies] quote.workspace = true diff --git a/crates/vim/src/helix/paste.rs b/crates/vim/src/helix/paste.rs index b67c6243d3..17b325a420 100644 --- a/crates/vim/src/helix/paste.rs +++ b/crates/vim/src/helix/paste.rs @@ -1,8 +1,7 @@ -use editor::{ToOffset, movement}; +use editor::movement; use gpui::{Action, Context, Window}; use schemars::JsonSchema; use serde::Deserialize; -use text::LineEnding; use crate::{Vim, state::Mode}; @@ -121,33 +120,16 @@ impl Vim { sel.end }; let point = display_point.to_point(&display_map); - let anchor = if action.before { - display_map.buffer_snapshot().anchor_after(point) - } else { - display_map.buffer_snapshot().anchor_before(point) - }; - let mut to_insert = to_insert.repeat(count); - // Buffer edits normalize line endings, so measure the normalized text. - // Otherwise CRLF paste text can produce a selection range past the inserted text. - // which can cause panics (selection offset greater than snapshot len) or invalid - // selections - LineEnding::normalize(&mut to_insert); - new_selections.push((anchor, to_insert.len())); - edits.push((point..point, to_insert)); + let snapshot = display_map.buffer_snapshot(); + new_selections + .push(snapshot.anchor_before(point)..snapshot.anchor_after(point)); + edits.push((point..point, to_insert.repeat(count))); } editor.edit(edits, cx); - let snapshot = editor.buffer().read(cx).snapshot(cx); editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(new_selections.into_iter().map(|(anchor, len)| { - let offset = anchor.to_offset(&snapshot); - if action.before { - offset.saturating_sub_usize(len)..offset - } else { - offset..(offset + len) - } - })); + s.select_ranges(new_selections); }) }); }); @@ -224,6 +206,30 @@ mod test { cx.assert_state("«a\nbˇ»", Mode::HelixNormal); } + #[gpui::test] + async fn test_paste_in_expanded_deleted_hunk(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + cx.set_state("ˇkept\n", Mode::HelixNormal); + cx.set_head_text("kept\ndeleted\n"); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&editor::actions::ExpandAllDiffHunks, window, cx); + }); + + cx.write_to_clipboard(ClipboardItem::new_string("replacement text".to_string())); + cx.simulate_keystrokes("j p"); + + let buffer_text = cx.update_editor(|editor, _window, cx| { + let buffer = editor + .buffer() + .read(cx) + .as_singleton() + .expect("test editor should contain one buffer"); + buffer.read(cx).snapshot().text() + }); + assert_eq!(buffer_text, "kept\n"); + } + #[gpui::test] async fn test_read_only_paste(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 39195a7467..454e0019fb 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -146,11 +146,12 @@ impl Render for ModeIndicator { (pending.into(), Some(mode)) }; h_flex() + .h(ButtonSize::Default.rems()) .gap_1() .when(!label.is_empty(), |el| { el.child( Label::new(label) - .line_height_style(LineHeightStyle::UiLabel) + .size(LabelSize::Small) .weight(FontWeight::MEDIUM), ) }) @@ -166,7 +167,6 @@ impl Render for ModeIndicator { .child( Label::new(mode) .size(LabelSize::Small) - .line_height_style(LineHeightStyle::UiLabel) .weight(FontWeight::MEDIUM) .when( bg_color != system_transparent diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 7cb89a0053..113d4e20a5 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2174,6 +2174,10 @@ mod test { cx.shared_state().await.assert_eq("// hello\n// ˇ\n"); cx.simulate_shared_keystrokes("x escape shift-o").await; cx.shared_state().await.assert_eq("// hello\n// ˇ\n// x\n"); + + cx.set_state("/*\n * helloˇ\n */\n", Mode::Normal); + cx.simulate_keystrokes("o"); + cx.assert_state("/*\n * hello\n * ˇ\n */\n", Mode::Insert); } #[gpui::test] diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 92baae1d37..57c872a266 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -265,7 +265,7 @@ impl Vim { let subscription = cx.subscribe_in(&search_bar, window, |vim, _, event, window, cx| { if let buffer_search::Event::Dismissed = event { if !vim.search.prior_selections.is_empty() { - let prior_selections: Vec<_> = vim.search.prior_selections.drain(..).collect(); + let prior_selections = std::mem::take(&mut vim.search.prior_selections); vim.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges(prior_selections); @@ -335,7 +335,7 @@ impl Vim { search_bar.select_match(direction, count, window, cx); search_bar.focus_editor(&Default::default(), window, cx); - let prior_selections: Vec<_> = self.search.prior_selections.drain(..).collect(); + let prior_selections = std::mem::take(&mut self.search.prior_selections); let prior_mode = self.search.prior_mode; let prior_operator = self.search.prior_operator.take(); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 6d5bbdbc11..f1b2a75d1c 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -26,7 +26,7 @@ use util::{path, test::marked_text_ranges}; pub use vim_test_context::*; use gpui::VisualTestContext; -use indoc::indoc; +use indoc::{formatdoc, indoc}; use project::FakeFs; use search::BufferSearchBar; use search::{ProjectSearchView, project_search}; @@ -621,6 +621,84 @@ async fn test_join_lines(cx: &mut gpui::TestAppContext) { "}); } +#[perf] +#[gpui::test] +async fn test_join_lines_rust_dereference(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + for dereference in ["*value.get()", "* value.get()"] { + let initial = formatdoc! {" + ˇlet another_value = unsafe {{ + {dereference} + }};"}; + let joined = formatdoc! {" + let another_value = unsafe {{ˇ {dereference} + }};"}; + let fully_joined = format!("let another_value = unsafe {{ {dereference}ˇ }};"); + + for keystrokes in [ + "shift-j", + "1 shift-j", + "2 shift-j", + "v j shift-j", + "shift-v j shift-j", + "j v k shift-j", + "j shift-v k shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &joined); + } + + for keystrokes in [ + "3 shift-j", + "v 2 j shift-j", + "shift-v 2 j shift-j", + "2 j v 2 k shift-j", + "2 j shift-v 2 k shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &fully_joined); + } + } +} + +#[perf] +#[gpui::test] +async fn test_join_lines_rust_dereference_without_whitespace(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + for dereference in ["*value.get()", "* value.get()"] { + let initial = formatdoc! {" + ˇlet another_value = unsafe {{ + {dereference} + }};"}; + let joined = formatdoc! {" + let another_value = unsafe {{ˇ{dereference} + }};"}; + let fully_joined = format!("let another_value = unsafe {{{dereference}ˇ}};"); + + for keystrokes in [ + "g shift-j", + "1 g shift-j", + "2 g shift-j", + "v j g shift-j", + "shift-v j g shift-j", + "j v k g shift-j", + "j shift-v k g shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &joined); + } + + for keystrokes in [ + "3 g shift-j", + "v 2 j g shift-j", + "shift-v 2 j g shift-j", + "2 j v 2 k g shift-j", + "2 j shift-v 2 k g shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &fully_joined); + } + } +} + #[cfg(target_os = "macos")] #[perf] #[gpui::test] @@ -1432,7 +1510,7 @@ async fn test_remap(cx: &mut gpui::TestAppContext) { cx.update(|_, cx| { cx.bind_keys([KeyBinding::new( "g w", - workspace::SendKeystrokes(": j enter".to_string()), + workspace::SendKeystrokes(": j o i n space l i n e s enter".to_string()), None, )]) }); diff --git a/crates/vim_mode_setting/Cargo.toml b/crates/vim_mode_setting/Cargo.toml index 6306d125b2..99814994bb 100644 --- a/crates/vim_mode_setting/Cargo.toml +++ b/crates/vim_mode_setting/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/vim_mode_setting.rs" +test = false +doctest = false [dependencies] gpui.workspace = true diff --git a/crates/watch/Cargo.toml b/crates/watch/Cargo.toml index aea8b0bbbd..273a8b53c5 100644 --- a/crates/watch/Cargo.toml +++ b/crates/watch/Cargo.toml @@ -13,10 +13,11 @@ path = "src/watch.rs" doctest = true [dependencies] +arc-swap.workspace = true +futures.workspace = true parking_lot.workspace = true [dev-dependencies] ctor.workspace = true -futures.workspace = true gpui = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/watch/src/snapshot.rs b/crates/watch/src/snapshot.rs new file mode 100644 index 0000000000..0b1c406677 --- /dev/null +++ b/crates/watch/src/snapshot.rs @@ -0,0 +1,240 @@ +//! Provides a latest-value channel with independently owned snapshots. +//! +//! Channel synchronization uses atomic publication rather than reader/writer +//! locks. Retaining a snapshot never prevents publication. +//! +//! ``` +//! let (mut sender, mut receiver) = watch::snapshot::channel(1); +//! let previous = receiver.snapshot(); +//! assert_eq!(sender.send(2), Ok(())); +//! assert_eq!(*previous, 1); +//! assert_eq!(*receiver.snapshot(), 2); +//! ``` + +use crate::{NoReceiverError, NoSenderError}; +use arc_swap::ArcSwap; +use futures::task::AtomicWaker; +use std::{ + ops, + pin::Pin, + sync::{ + Arc, Weak, + atomic::{AtomicBool, Ordering::SeqCst}, + }, + task::{Context, Poll}, +}; + +struct Versioned { + value: T, + version: usize, +} + +struct State { + latest: ArcSwap>, + receivers: ArcSwap>>, + closed: AtomicBool, +} + +impl State { + fn subscribe(self: &Arc, version: usize) -> Receiver { + let waker = Arc::new(AtomicWaker::new()); + self.receivers.rcu(|receivers| { + let mut receivers = receivers.as_ref().clone(); + receivers.push(Arc::downgrade(&waker)); + receivers + }); + Receiver { + state: self.clone(), + waker, + version, + } + } + + fn wake_receivers(&self) -> Result<(), NoReceiverError> { + let mut result = Err(NoReceiverError); + for receiver in self.receivers.load().iter() { + if let Some(waker) = receiver.upgrade() { + result = Ok(()); + waker.wake(); + } + } + result + } +} + +/// Creates a channel whose initial value is already considered seen. +/// +/// Use [`Receiver::snapshot`] to read the initial value. Each send counts as a +/// change, even when the value is equal; slow receivers skip intermediate values. +pub fn channel(value: T) -> (Sender, Receiver) { + let receiver = Receiver::constant(value); + let sender = Sender { + state: receiver.state.clone(), + }; + (sender, receiver) +} + +/// Publishes values to all receivers without waiting for snapshots to be released. +pub struct Sender { + state: Arc>, +} + +impl Sender { + /// Creates a receiver with the current version already considered seen. + pub fn receiver(&self) -> Receiver { + let version = self.state.latest.load().version; + self.state.subscribe(version) + } + + /// Replaces the latest value and wakes receivers. + /// + /// # Errors + /// + /// Returns [`NoReceiverError`] if no receivers remain. The value is still + /// stored and can be read by a subsequently created receiver. + pub fn send(&mut self, value: T) -> Result<(), NoReceiverError> { + let version = self.state.latest.load().version.wrapping_add(1); + self.state + .latest + .store(Arc::new(Versioned { value, version })); + self.state.wake_receivers() + } +} + +impl Drop for Sender { + fn drop(&mut self) { + self.state.closed.store(true, SeqCst); + match self.state.wake_receivers() { + Ok(()) | Err(NoReceiverError) => {} + } + } +} + +/// Observes publications independently of other receivers. +/// +/// Cloning copies the last-seen version but creates an independent wake +/// registration. A snapshot does not require the value to implement `Clone`. +pub struct Receiver { + state: Arc>, + waker: Arc, + version: usize, +} + +impl Clone for Receiver { + fn clone(&self) -> Self { + self.state.subscribe(self.version) + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + let waker = Arc::downgrade(&self.waker); + self.state.receivers.rcu(|receivers| { + receivers + .iter() + .filter(|receiver| !receiver.ptr_eq(&waker)) + .cloned() + .collect::>() + }); + } +} + +impl Receiver { + /// Returns an owned snapshot and marks its version as seen. + /// + /// The snapshot remains valid after further sends or after the channel is + /// dropped, and does not prevent either operation. + pub fn snapshot(&mut self) -> Snapshot { + let snapshot = self.state.latest.load_full(); + self.version = snapshot.version; + Snapshot(snapshot) + } + + /// Waits for an unseen publication and marks that version as seen. + /// + /// Dropping a pending future unregisters its waker without marking any + /// publication as seen. + /// + /// # Errors + /// + /// Returns [`NoSenderError`] after the sender is dropped and the final + /// publication has been seen. + pub fn changed(&mut self) -> impl Future> { + Changed { receiver: self } + } + + /// Creates a fixed-value receiver whose change futures remain pending forever. + pub fn constant(value: T) -> Self { + Arc::new(State { + latest: ArcSwap::from_pointee(Versioned { value, version: 0 }), + receivers: ArcSwap::from_pointee(Vec::new()), + closed: AtomicBool::new(false), + }) + .subscribe(0) + } +} + +impl Receiver { + /// Waits for an unseen publication and clones the latest value. + /// + /// # Errors + /// + /// Returns [`NoSenderError`] after the sender is dropped and the final + /// publication has been seen. + pub async fn recv(&mut self) -> Result { + self.changed().await?; + Ok((*self.snapshot()).clone()) + } +} + +/// Owns an immutable channel value independently of its receiver. +pub struct Snapshot(Arc>); + +impl Clone for Snapshot { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl ops::Deref for Snapshot { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0.value + } +} + +struct Changed<'a, T> { + receiver: &'a mut Receiver, +} + +impl Future for Changed<'_, T> { + type Output = Result<(), NoSenderError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let receiver = &mut self.get_mut().receiver; + // Register before checking so a racing publication cannot go unnoticed. + receiver.waker.register(cx.waker()); + // Observing closure must precede loading the value, so closure cannot + // hide a final publication that raced with this poll. + let closed = receiver.state.closed.load(SeqCst); + let snapshot = receiver.state.latest.load(); + if snapshot.version != receiver.version { + receiver.version = snapshot.version; + Poll::Ready(Ok(())) + } else if closed { + Poll::Ready(Err(NoSenderError)) + } else { + Poll::Pending + } + } +} + +impl Drop for Changed<'_, T> { + fn drop(&mut self) { + self.receiver.waker.take(); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/watch/src/snapshot/tests.rs b/crates/watch/src/snapshot/tests.rs new file mode 100644 index 0000000000..2570e7d623 --- /dev/null +++ b/crates/watch/src/snapshot/tests.rs @@ -0,0 +1,346 @@ +use super::*; +use futures::task::{ArcWake, waker}; +use std::{ + future::Future, + sync::{ + Arc, Barrier, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + task::{Context, Poll, Waker}, + thread, + time::Duration, +}; + +#[derive(Default)] +struct WakeCounter(AtomicUsize); + +impl ArcWake for WakeCounter { + fn wake_by_ref(counter: &Arc) { + counter.0.fetch_add(1, Ordering::SeqCst); + } +} + +fn counting_waker() -> (Waker, Arc) { + let counter = Arc::new(WakeCounter::default()); + (waker(counter.clone()), counter) +} + +fn poll_changed(receiver: &mut Receiver, waker: &Waker) -> Poll> { + let mut changed = Box::pin(receiver.changed()); + changed.as_mut().poll(&mut Context::from_waker(waker)) +} + +#[test] +fn coalesces_publications_to_the_latest_snapshot() { + let (mut sender, mut receiver) = channel(0); + let (waker, _) = counting_waker(); + + assert_eq!(sender.send(1), Ok(())); + assert_eq!(sender.send(2), Ok(())); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(*receiver.snapshot(), 2); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); +} + +#[test] +fn equal_values_are_distinct_publications() { + let (mut sender, mut receiver) = channel(7); + let (waker, _) = counting_waker(); + + assert_eq!(sender.send(7), Ok(())); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(sender.send(7), Ok(())); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); +} + +#[test] +fn initial_value_is_seen() { + let (_sender, mut receiver) = channel(3); + let (waker, _) = counting_waker(); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); + assert_eq!(*receiver.snapshot(), 3); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); +} + +#[test] +fn snapshot_marks_the_latest_publication_as_seen() { + let (mut sender, mut receiver) = channel(0); + let (waker, _) = counting_waker(); + assert_eq!(sender.send(1), Ok(())); + assert_eq!(*receiver.snapshot(), 1); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); + drop(sender); + assert_eq!( + poll_changed(&mut receiver, &waker), + Poll::Ready(Err(NoSenderError)) + ); +} + +#[test] +fn clone_copies_seen_version_but_fresh_receiver_sees_current_version() { + let (mut sender, mut receiver) = channel(0); + assert_eq!(sender.send(1), Ok(())); + let mut clone = receiver.clone(); + let mut fresh = sender.receiver(); + let (waker, _) = counting_waker(); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(poll_changed(&mut clone, &waker), Poll::Ready(Ok(()))); + assert_eq!(poll_changed(&mut fresh, &waker), Poll::Pending); +} + +#[test] +fn retained_snapshots_do_not_require_clone_or_block_publication() { + #[derive(Debug, Eq, PartialEq)] + struct NotClone(usize); + + let (mut sender, mut receiver) = channel(NotClone(1)); + let retained = receiver.snapshot(); + assert_eq!(sender.send(NotClone(2)), Ok(())); + let latest = receiver.snapshot(); + drop(sender); + drop(receiver); + + assert_eq!(*retained, NotClone(1)); + assert_eq!(*latest, NotClone(2)); +} + +#[test] +fn send_without_receivers_still_stores_the_value() { + let (mut sender, receiver) = channel(1); + drop(receiver); + + assert_eq!(sender.send(2), Err(NoReceiverError)); + let mut receiver = sender.receiver(); + let (waker, _) = counting_waker(); + assert_eq!(*receiver.snapshot(), 2); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); +} + +#[test] +fn final_publication_precedes_channel_closure() { + let (mut sender, mut receiver) = channel(0); + let (waker, _) = counting_waker(); + assert_eq!(sender.send(1), Ok(())); + drop(sender); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(*receiver.snapshot(), 1); + assert_eq!( + poll_changed(&mut receiver, &waker), + Poll::Ready(Err(NoSenderError)) + ); +} + +#[test] +fn sender_drop_wakes_a_pending_change() { + let (sender, mut receiver) = channel(0); + let (waker, counter) = counting_waker(); + let mut changed = Box::pin(receiver.changed()); + assert_eq!( + changed.as_mut().poll(&mut Context::from_waker(&waker)), + Poll::Pending + ); + + drop(sender); + + assert_eq!(counter.0.load(Ordering::SeqCst), 1); + assert_eq!( + changed.as_mut().poll(&mut Context::from_waker(&waker)), + Poll::Ready(Err(NoSenderError)) + ); +} + +#[test] +fn constant_receiver_never_changes() { + #[derive(Debug, PartialEq)] + struct NotClone; + + let mut receiver = Receiver::constant(NotClone); + let (waker, counter) = counting_waker(); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); + assert_eq!(*receiver.snapshot(), NotClone); + assert_eq!(counter.0.load(Ordering::SeqCst), 0); +} + +#[test] +fn dropping_changed_unregisters_its_waker_and_repoll_replaces_it() { + let (mut sender, mut receiver) = channel(0); + let (first_waker, first_counter) = counting_waker(); + let (second_waker, second_counter) = counting_waker(); + + { + let mut changed = Box::pin(receiver.changed()); + assert_eq!( + changed + .as_mut() + .poll(&mut Context::from_waker(&first_waker)), + Poll::Pending + ); + assert_eq!( + changed + .as_mut() + .poll(&mut Context::from_waker(&second_waker)), + Poll::Pending + ); + assert_eq!(sender.send(1), Ok(())); + assert_eq!(first_counter.0.load(Ordering::SeqCst), 0); + assert_eq!(second_counter.0.load(Ordering::SeqCst), 1); + } + + assert_eq!( + poll_changed(&mut receiver, &second_waker), + Poll::Ready(Ok(())) + ); + let (cancelled_waker, cancelled_counter) = counting_waker(); + { + let mut changed = Box::pin(receiver.changed()); + assert_eq!( + changed + .as_mut() + .poll(&mut Context::from_waker(&cancelled_waker)), + Poll::Pending + ); + } + assert_eq!(sender.send(2), Ok(())); + assert_eq!(cancelled_counter.0.load(Ordering::SeqCst), 0); +} + +#[test] +fn publication_wakes_all_registered_receivers() { + let (mut sender, mut first_receiver) = channel(0); + let mut second_receiver = first_receiver.clone(); + let mut third_receiver = first_receiver.clone(); + let (first_waker, first_counter) = counting_waker(); + let (second_waker, second_counter) = counting_waker(); + let (third_waker, third_counter) = counting_waker(); + let mut first_changed = Box::pin(first_receiver.changed()); + let mut second_changed = Box::pin(second_receiver.changed()); + let mut third_changed = Box::pin(third_receiver.changed()); + + assert_eq!( + first_changed + .as_mut() + .poll(&mut Context::from_waker(&first_waker)), + Poll::Pending + ); + assert_eq!( + second_changed + .as_mut() + .poll(&mut Context::from_waker(&second_waker)), + Poll::Pending + ); + assert_eq!( + third_changed + .as_mut() + .poll(&mut Context::from_waker(&third_waker)), + Poll::Pending + ); + + assert_eq!(sender.send(1), Ok(())); + assert_eq!(first_counter.0.load(Ordering::SeqCst), 1); + assert_eq!(second_counter.0.load(Ordering::SeqCst), 1); + assert_eq!(third_counter.0.load(Ordering::SeqCst), 1); +} + +#[test] +fn receiver_churn_removes_waker_entries() { + let (mut sender, receiver) = channel(0); + + for _ in 0..1_000 { + drop(receiver.clone()); + } + + assert_eq!(sender.state.receivers.load().len(), 1); + drop(receiver); + assert!(sender.state.receivers.load().is_empty()); + assert_eq!(sender.send(1), Err(NoReceiverError)); +} + +#[test] +fn threaded_recv_delivers_each_acknowledged_publication_and_closure() { + let (mut sender, mut receiver) = channel(0); + let (acknowledgment_sender, acknowledgment_receiver) = mpsc::channel(); + let (completed_sender, completed_receiver) = mpsc::channel(); + thread::spawn(move || { + futures::executor::block_on(async move { + for expected in 1..=1_000 { + assert_eq!(receiver.recv().await, Ok(expected)); + acknowledgment_sender + .send(()) + .expect("publisher is waiting for acknowledgment"); + } + assert_eq!(receiver.recv().await, Err(NoSenderError)); + }); + completed_sender.send(()).expect("test is waiting"); + }); + for value in 1..=1_000 { + assert_eq!(sender.send(value), Ok(())); + acknowledgment_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("receiver missed a publication"); + } + drop(sender); + completed_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("receiver missed channel closure"); +} + +#[test] +fn concurrent_send_poll_register_and_drop_completes_within_deadline() { + const RECEIVER_COUNT: usize = 4; + const ITERATIONS: usize = 2_000; + + let (mut sender, receiver) = channel(0); + let receivers = (0..RECEIVER_COUNT) + .map(|_| receiver.clone()) + .collect::>(); + drop(receiver); + let sender_guard = sender.receiver(); + + let barrier = Arc::new(Barrier::new(RECEIVER_COUNT + 1)); + let (completed_sender, completed_receiver) = mpsc::channel(); + + for mut receiver in receivers { + let barrier = barrier.clone(); + let completed_sender = completed_sender.clone(); + thread::spawn(move || { + let (waker, _) = counting_waker(); + barrier.wait(); + for iteration in 0..ITERATIONS { + if poll_changed(&mut receiver, &waker) == Poll::Ready(Err(NoSenderError)) { + assert_eq!(*receiver.snapshot(), ITERATIONS); + } + if iteration % 3 == 0 { + let mut clone = receiver.clone(); + if poll_changed(&mut clone, &waker) == Poll::Ready(Err(NoSenderError)) { + assert_eq!(*clone.snapshot(), ITERATIONS); + } + } + } + completed_sender + .send(()) + .expect("completion receiver exists"); + }); + } + + thread::spawn(move || { + let _sender_guard = sender_guard; + barrier.wait(); + for value in 1..=ITERATIONS { + assert_eq!(sender.send(value), Ok(())); + } + completed_sender + .send(()) + .expect("completion receiver exists"); + }); + + for _ in 0..=RECEIVER_COUNT { + completed_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("concurrent channel operations exceeded deadline"); + } +} diff --git a/crates/watch/src/watch.rs b/crates/watch/src/watch.rs index cffe3216c2..828cafdd86 100644 --- a/crates/watch/src/watch.rs +++ b/crates/watch/src/watch.rs @@ -1,4 +1,38 @@ +//! Provides single-producer, multiple-consumer channels that retain the latest value. +//! +//! Both [`channel`] and [`snapshot::channel`] coalesce publications: receivers +//! observe the latest value, not a queue of every value sent. Each receiver tracks +//! changes independently. The initial value is considered seen, every send counts +//! as a change even if the value is equal, and an unseen final publication is +//! delivered before reporting that the sender was dropped. +//! +//! # Choosing an implementation +//! +//! Use [`channel`] for short-lived reads, low contention, bursty updates, or +//! frequent receiver creation and destruction. It stores the value behind a +//! reader/writer lock. [`Receiver::borrow`] returns a read guard and marks the +//! current version as seen. Holding that guard prevents publication, so release +//! it promptly and do not hold it across an await. +//! +//! Use [`snapshot::channel`] when readers need to retain values without delaying +//! publication, or channel synchronization must not block a thread. It atomically publishes +//! immutable snapshots. [`snapshot::Receiver::snapshot`] marks the captured +//! version as seen and returns an owned handle that can outlive the receiver. +//! Stable subscriptions suit this implementation better than frequent receiver +//! creation and destruction. +//! +//! # Performance tradeoffs +//! +//! | Operation | Lock-based channel | Snapshot channel | +//! | --- | --- | --- | +//! | Read | Acquires a read lock | Acquires a reference-counted snapshot | +//! | Send | Replaces the value in place; visits pending waiters | Allocates a snapshot; visits all registered receivers | +//! | Pending wait or cancellation | Updates a shared waiter tree under a lock | Updates a per-receiver atomic waker | +//! | Create or drop a receiver | Constant-time bookkeeping, excluding final destruction | Copies the receiver registry; retries under contention | +//! | Retain a value | Delays writers | Keeps that version alive without delaying writers | + mod error; +pub mod snapshot; pub use error::*; use parking_lot::{RwLock, RwLockReadGuard, RwLockUpgradableReadGuard}; diff --git a/crates/web_search/Cargo.toml b/crates/web_search/Cargo.toml index 1da6609514..9b28a6914b 100644 --- a/crates/web_search/Cargo.toml +++ b/crates/web_search/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/web_search.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/web_search_providers/Cargo.toml b/crates/web_search_providers/Cargo.toml index 92a33b7382..e745f19299 100644 --- a/crates/web_search_providers/Cargo.toml +++ b/crates/web_search_providers/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/web_search_providers.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml index c2f926efea..18e2bf1c75 100644 --- a/crates/which_key/Cargo.toml +++ b/crates/which_key/Cargo.toml @@ -19,4 +19,11 @@ settings.workspace = true theme_settings.workspace = true ui.workspace = true util.workspace = true +vim_mode_setting.workspace = true workspace.workspace = true +zed_actions.workspace = true + +[dev-dependencies] +collections.workspace = true +gpui = { workspace = true, features = ["test-support"] } +theme.workspace = true diff --git a/crates/which_key/src/pending_keystrokes_indicator.rs b/crates/which_key/src/pending_keystrokes_indicator.rs new file mode 100644 index 0000000000..8259ad5d05 --- /dev/null +++ b/crates/which_key/src/pending_keystrokes_indicator.rs @@ -0,0 +1,1129 @@ +use gpui::{ + Action as _, Anchor, Animation, AnimationExt, App, Context, HoverListenerMode, + KeybindingKeystroke, Render, Subscription, Task, Window, anchored, deferred, +}; +use settings::{Settings, SettingsStore}; +use std::{rc::Rc, time::Duration}; +use ui::{ + ButtonLike, CircularProgress, KeyBinding, KeyBindingStyle, prelude::*, + text_for_keybinding_keystrokes, tooltip_container, +}; +use util::ResultExt; +use vim_mode_setting::{HelixModeSetting, VimModeSetting}; +use workspace::{HideStatusItem, StatusBarSettings, StatusItemView, item::ItemHandle}; + +use crate::{ + bindings_for_pending_input, map_pending_keystrokes, which_key_settings::WhichKeySettings, +}; + +const MAX_TOOLTIP_BINDINGS: usize = 10; +const POPOVER_HIDE_DELAY: Duration = Duration::from_millis(300); + +/// A status bar item shown while pending input can complete a multi-stroke key binding. +pub struct PendingKeystrokesIndicator { + render_state: Option>, + pending_input_generation: u64, + popover: PopoverState, + _pending_input_subscription: Subscription, + _settings_subscription: Subscription, +} + +#[derive(Default)] +struct PopoverState { + indicator_pointer_over: bool, + pointer_over: bool, + visible: bool, + hide_task: Option>, +} + +impl PopoverState { + fn is_pointer_over(&self) -> bool { + self.indicator_pointer_over || self.pointer_over + } +} + +struct IndicatorRenderState { + keystrokes: Rc<[KeybindingKeystroke]>, + pending_input_generation: u64, + bindings: Vec<(Rc<[KeybindingKeystroke]>, SharedString)>, + timeout: Option, +} + +struct IndicatorTimeout { + timeout_duration: Duration, + remaining_duration: Duration, + timeout_paused: bool, +} + +impl PendingKeystrokesIndicator { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let pending_input_subscription = + cx.observe_pending_input(window, |this: &mut Self, window, cx| { + if this.refresh_render_state(window, cx) { + cx.notify(); + } + this.update_pointer_over_state(window, cx); + }); + + let mut enabled = Self::enabled(cx); + let mut popover_enabled = Self::popover_enabled(cx); + let settings_subscription = + cx.observe_global_in::(window, move |this, window, cx| { + let new_enabled = Self::enabled(cx); + let new_popover_enabled = Self::popover_enabled(cx); + if new_enabled == enabled && new_popover_enabled == popover_enabled { + return; + } + + enabled = new_enabled; + popover_enabled = new_popover_enabled; + if !new_popover_enabled { + this.popover.pointer_over = false; + this.popover.visible = false; + this.popover.hide_task.take(); + } + if this.refresh_render_state(window, cx) { + cx.notify(); + } + this.update_pointer_over_state(window, cx); + }); + + Self { + render_state: None, + pending_input_generation: 0, + popover: PopoverState::default(), + _pending_input_subscription: pending_input_subscription, + _settings_subscription: settings_subscription, + } + } + + fn enabled(cx: &App) -> bool { + let status_bar_settings = StatusBarSettings::get_global(cx); + status_bar_settings.show + && status_bar_settings.pending_keystrokes_indicator + && !VimModeSetting::is_enabled(cx) + && !HelixModeSetting::is_enabled(cx) + } + + fn popover_enabled(cx: &App) -> bool { + !WhichKeySettings::get_global(cx).enabled + } + + fn refresh_render_state(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if !Self::enabled(cx) { + return self.clear_render_state(window, cx); + } + + let Some(pending_input) = window.pending_input() else { + return self.clear_render_state(window, cx); + }; + let keystrokes = pending_input.keystrokes(); + + let mut bindings = bindings_for_pending_input(window, keystrokes) + .into_iter() + .map(|binding| { + let remaining_text = + text_for_keybinding_keystrokes(&binding.remaining_keystrokes, cx); + ( + remaining_text, + binding.remaining_keystrokes, + binding.action_name, + ) + }) + .collect::>(); + bindings.sort_by(|(text_a, keys_a, action_a), (text_b, keys_b, action_b)| { + keys_a + .len() + .cmp(&keys_b.len()) + .then_with(|| text_a.cmp(text_b)) + .then_with(|| action_a.cmp(action_b)) + }); + bindings.dedup_by(|(text_a, _, action_a), (text_b, _, action_b)| { + text_a == text_b && action_a == action_b + }); + + let keystrokes = map_pending_keystrokes(keystrokes, cx.keyboard_mapper().as_ref()); + // Pausing or resuming the timer also notifies observers. + // Only a change in pending keys should close the popover early. + if self.popover.visible + && !self.popover.is_pointer_over() + && self + .render_state + .as_ref() + .is_some_and(|previous| previous.keystrokes.as_ref() != keystrokes.as_slice()) + { + self.popover = PopoverState::default(); + } + + self.pending_input_generation = self.pending_input_generation.wrapping_add(1); + self.render_state = Some(Rc::new(IndicatorRenderState { + keystrokes: keystrokes.into(), + pending_input_generation: self.pending_input_generation, + bindings: bindings + .into_iter() + .map(|(_, keystrokes, action)| (Rc::from(keystrokes), action)) + .collect(), + timeout: pending_input.timeout().map(|timeout| IndicatorTimeout { + timeout_duration: timeout.duration(), + remaining_duration: timeout.remaining(cx), + timeout_paused: timeout.is_paused(), + }), + })); + true + } + + fn clear_render_state(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.popover = PopoverState::default(); + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + self.render_state.take().is_some() + } + + fn render_state(&self) -> Option<&Rc> { + self.render_state.as_ref() + } + + fn set_indicator_pointer_over( + &mut self, + pointer_over: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.popover.indicator_pointer_over = pointer_over; + self.update_pointer_over_state(window, cx); + } + + fn set_popover_pointer_over( + &mut self, + pointer_over: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.popover.pointer_over = pointer_over; + self.update_pointer_over_state(window, cx); + } + + fn update_pointer_over_state(&mut self, window: &mut Window, cx: &mut Context) { + if self.render_state.is_none() { + return; + } + + if self.popover.is_pointer_over() { + self.popover.hide_task.take(); + let was_visible = self.popover.visible; + self.popover.visible = Self::popover_enabled(cx); + window.set_pending_input_timeout_paused(&cx.entity(), true, cx); + if was_visible != self.popover.visible { + cx.notify(); + } + } else if self.popover.visible && self.popover.hide_task.is_none() { + window.set_pending_input_timeout_paused(&cx.entity(), true, cx); + self.popover.hide_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(POPOVER_HIDE_DELAY).await; + this.update_in(cx, |this, window, cx| { + this.popover.hide_task.take(); + if this.popover.is_pointer_over() { + return; + } + + this.popover.visible = false; + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + cx.notify(); + }) + .log_err(); + })); + } else if !self.popover.visible { + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + } + } +} + +impl Render for PendingKeystrokesIndicator { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(render_state) = self.render_state().cloned() else { + return div().hidden().into_any_element(); + }; + + let button = ButtonLike::new("pending-keystrokes-indicator") + .on_click(|_, window, cx| { + window.dispatch_action(zed_actions::dev::OpenKeyContextView.boxed_clone(), cx); + }) + .when_some(render_state.timeout.as_ref(), |button, timeout| { + let remaining_fraction = if timeout.timeout_duration.is_zero() { + 0.0 + } else { + (timeout.remaining_duration.as_secs_f32() + / timeout.timeout_duration.as_secs_f32()) + .clamp(0.0, 1.0) + }; + button.child(if cx.reduce_motion() { + Icon::new(IconName::CountdownTimer) + .size(IconSize::XSmall) + .color(Color::Muted) + .into_any_element() + } else { + let progress = CircularProgress::new( + remaining_fraction, + 1.0, + rems_from_px(13_f32).to_pixels(window.rem_size()), + cx, + ) + .stroke_width(rems_from_px(2_f32).to_pixels(window.rem_size())) + .progress_color(cx.theme().colors().text_muted); + if timeout.timeout_paused || timeout.remaining_duration.is_zero() { + progress.into_any_element() + } else { + progress + .with_animation( + ( + "pending-keystrokes-countdown", + render_state.pending_input_generation, + ), + Animation::new(timeout.remaining_duration).with_max_fps(30.0), + move |progress, delta| { + progress.value(remaining_fraction * (1.0 - delta)) + }, + ) + .into_any_element() + } + }) + }) + .child( + KeyBinding::from_keystrokes(render_state.keystrokes.clone(), false) + .size(rems_from_px(12_f32)) + .style(KeyBindingStyle::Label), + ); + + let popover = self.popover.visible.then(|| { + let popover_render_state = render_state.clone(); + let anchored_popover = deferred( + anchored() + .anchor(Anchor::BottomRight) + .snap_to_window_with_margin(px(8.)) + .child( + div() + .id("pending-keystrokes-popover") + .debug_selector(|| "PENDING_KEYSTROKES_POPOVER".into()) + .pb_2() + .occlude() + .on_hover(cx.listener(|this, pointer_over: &bool, window, cx| { + this.set_popover_pointer_over(*pointer_over, window, cx); + })) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .child(tooltip_container(cx, |el, _| { + el.child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_1() + .child( + KeyBinding::from_keystrokes( + popover_render_state.keystrokes.clone(), + false, + ) + .color(Color::Accent), + ) + .child( + Label::new("is waiting for more keys") + .color(Color::Muted), + ), + ) + .children( + popover_render_state + .bindings + .iter() + .take(MAX_TOOLTIP_BINDINGS) + .map(|(keystrokes, action)| { + h_flex() + .gap_2() + .child( + KeyBinding::from_keystrokes( + keystrokes.clone(), + false, + ) + .color(Color::Accent), + ) + .child( + Label::new(action.clone()) + .size(LabelSize::Small), + ) + }), + ) + .when( + popover_render_state.bindings.len() + > MAX_TOOLTIP_BINDINGS, + |el| { + el.child( + Label::new(format!( + "…and {} more", + popover_render_state.bindings.len() + - MAX_TOOLTIP_BINDINGS + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), + ) + })), + ), + ) + .with_priority(1); + + div() + .absolute() + .top_0() + .right_0() + .w_0() + .h_0() + .child(anchored_popover) + }); + + div() + .id("pending-keystrokes-indicator-wrapper") + .debug_selector(|| "PENDING_KEYSTROKES_INDICATOR".into()) + .relative() + .child(button) + .when_some(popover, |this, popover| this.child(popover)) + .on_hover(cx.listener(|this, pointer_over: &bool, window, cx| { + this.set_indicator_pointer_over(*pointer_over, window, cx); + })) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .into_any_element() + } +} + +impl StatusItemView for PendingKeystrokesIndicator { + fn set_active_pane_item( + &mut self, + _active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .status_bar + .get_or_insert_default() + .pending_keystrokes_indicator = Some(false); + })) + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + use command_palette::humanize_action_name; + use gpui::{ + Entity, FocusHandle, KeyBinding, Modifiers, TestAppContext, VisualTestContext, actions, + point, + }; + + actions!( + pending_keystrokes_indicator_test, + [ShorterBinding, LongerBinding, LongestBinding] + ); + + fn timed_bindings() -> [KeyBinding; 2] { + [ + KeyBinding::new( + "ctrl-b", + ShorterBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + ] + } + + fn nested_timed_bindings() -> [KeyBinding; 3] { + [ + KeyBinding::new( + "ctrl-b", + ShorterBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h j", + LongestBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + ] + } + + struct TestView { + focus_handle: FocusHandle, + indicator: Entity, + open_key_context_view_count: Rc>, + } + + #[derive(Debug, PartialEq)] + struct IndicatorSnapshot { + keystrokes: Vec, + generation: u64, + bindings: Vec<(Vec, String)>, + timeout_paused: bool, + popover_visible: bool, + popover_pointer_over: bool, + } + + fn indicator_snapshot(indicator: &PendingKeystrokesIndicator) -> Option { + indicator + .render_state() + .map(|render_state| IndicatorSnapshot { + keystrokes: render_state + .keystrokes + .iter() + .map(|keystroke| keystroke.inner().unparse()) + .collect(), + generation: render_state.pending_input_generation, + bindings: render_state + .bindings + .iter() + .map(|(keystrokes, action)| { + ( + keystrokes + .iter() + .map(|keystroke| keystroke.inner().unparse()) + .collect(), + action.to_string(), + ) + }) + .collect(), + timeout_paused: render_state + .timeout + .as_ref() + .is_some_and(|timeout| timeout.timeout_paused), + popover_visible: indicator.popover.visible, + popover_pointer_over: indicator.popover.pointer_over, + }) + } + + fn setup_indicator_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> ( + Entity, + Rc>, + &mut VisualTestContext, + ) { + cx.update(|cx| { + settings::init(cx); + WhichKeySettings::register(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + cx.bind_keys(bindings); + }); + + let open_key_context_view_count = Rc::new(Cell::new(0)); + let (test_view, cx) = cx.add_window_view({ + let open_key_context_view_count = open_key_context_view_count.clone(); + |window, cx| TestView { + focus_handle: cx.focus_handle(), + indicator: cx.new(|cx| PendingKeystrokesIndicator::new(window, cx)), + open_key_context_view_count, + } + }); + let (focus_handle, indicator) = test_view.read_with(cx, |test_view, _| { + (test_view.focus_handle.clone(), test_view.indicator.clone()) + }); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (indicator, open_key_context_view_count, cx) + } + + fn start_pending_input_and_hover_indicator(cx: &mut VisualTestContext) { + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + } + + fn move_pointer_over_popover(cx: &mut VisualTestContext) { + let popover_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_POPOVER") + .expect("rendered pending keystrokes popover"); + cx.simulate_mouse_move(popover_bounds.center(), None, Modifiers::none()); + } + + fn move_pointer_outside(cx: &mut VisualTestContext) { + let outside = cx.update(|window, _| point(window.viewport_size().width - px(1.), px(1.))); + cx.simulate_mouse_move(outside, None, Modifiers::none()); + } + + fn start_popover_dismissal(cx: &mut VisualTestContext) { + start_pending_input_and_hover_indicator(cx); + move_pointer_over_popover(cx); + move_pointer_outside(cx); + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .key_context("PendingKeystrokesIndicatorTest") + .track_focus(&self.focus_handle) + .on_action(|_: &ShorterBinding, _, _| {}) + .on_action(|_: &LongerBinding, _, _| {}) + .on_action(|_: &LongestBinding, _, _| {}) + .on_action( + cx.listener(|this, _: &zed_actions::dev::OpenKeyContextView, _, _| { + this.open_key_context_view_count + .set(this.open_key_context_view_count.get() + 1); + }), + ) + .child( + v_flex() + .size_full() + .justify_end() + .items_end() + .child(self.indicator.clone()), + ) + } + } + + #[gpui::test] + fn test_indicator_stays_hidden_without_pending_input(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings()); + cx.run_until_parked(); + + // Before any input, neither the indicator's state nor its rendered element should exist. + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_INDICATOR").is_none()); + + // "x" starts no chord in this keymap, so ordinary input must keep the indicator hidden. + cx.simulate_keystrokes("x"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_INDICATOR").is_none()); + } + + #[gpui::test] + fn test_indicator_tracks_pending_input(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let first_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input snapshot"); + assert_eq!(first_render_state.keystrokes, vec!["ctrl-b"]); + assert_eq!( + first_render_state.bindings, + vec![ + ( + vec!["h".to_string()], + humanize_action_name(LongerBinding.name()), + ), + ( + vec!["h".to_string(), "j".to_string()], + humanize_action_name(LongestBinding.name()), + ), + ] + ); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + + let second_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("updated pending input snapshot"); + assert_eq!(second_render_state.keystrokes, vec!["ctrl-b", "h"]); + assert!(second_render_state.generation > first_render_state.generation); + assert_eq!( + second_render_state.bindings, + vec![( + vec!["j".to_string()], + humanize_action_name(LongestBinding.name()), + )] + ); + + cx.simulate_keystrokes("j"); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + } + + #[gpui::test] + fn test_indicator_does_not_apply_which_key_binding_filter(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test( + cx, + [ + KeyBinding::new("g", ShorterBinding, Some("PendingKeystrokesIndicatorTest")), + KeyBinding::new("g j", LongerBinding, Some("PendingKeystrokesIndicatorTest")), + ], + ); + + cx.simulate_keystrokes("g"); + cx.run_until_parked(); + + cx.update(|window, _| { + let pending_keystrokes = window + .pending_input_keystrokes() + .expect("pending input keystrokes"); + assert!(crate::bindings_for_which_key(window, pending_keystrokes).is_empty()); + }); + + let render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input snapshot"); + assert_eq!( + render_state.bindings, + vec![( + vec!["j".to_string()], + humanize_action_name(LongerBinding.name()), + )] + ); + } + + #[gpui::test] + fn test_which_key_disables_popover_but_keeps_indicator_and_hover_pause( + cx: &mut TestAppContext, + ) { + let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store + .set_user_settings( + r#"{ + "status_bar": {"pending_keystrokes_indicator": true}, + "which_key": {"enabled": true} + }"#, + cx, + ) + .expect("valid test settings"); + }); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + assert!(StatusBarSettings::get_global(cx).pending_keystrokes_indicator); + assert!(WhichKeySettings::get_global(cx).enabled); + }); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_some())); + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + cx.run_until_parked(); + + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_none()); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + }); + + move_pointer_outside(cx); + cx.run_until_parked(); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + }); + } + + #[gpui::test] + fn test_clicking_indicator_opens_key_context_view(cx: &mut TestAppContext) { + let (_, open_key_context_view_count, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_click(indicator_bounds.center(), Modifiers::none()); + + assert_eq!(open_key_context_view_count.get(), 1); + } + + #[gpui::test] + fn test_hovering_indicator_opens_popover_and_pauses_timeout(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + + let paused_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("paused pending input"); + assert!(paused_render_state.timeout_paused); + assert!(paused_render_state.popover_visible); + } + + #[gpui::test] + fn test_popover_is_positioned_above_indicator(cx: &mut TestAppContext) { + let (_, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + let popover_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_POPOVER") + .expect("rendered pending keystrokes popover"); + assert!( + popover_bounds.bottom() <= indicator_bounds.top(), + "popover {popover_bounds:?} should render above indicator {indicator_bounds:?}" + ); + assert_eq!(popover_bounds.right(), indicator_bounds.right()); + } + + #[gpui::test] + fn test_pointer_handoff_keeps_popover_open_and_timeout_paused(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + move_pointer_over_popover(cx); + + let handoff_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input during popover handoff"); + assert!(handoff_render_state.timeout_paused); + assert!(handoff_render_state.popover_visible); + assert!(handoff_render_state.popover_pointer_over); + + cx.executor().advance_clock(POPOVER_HIDE_DELAY); + cx.run_until_parked(); + let stationary_popover_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input while pointer remains over popover"); + assert!(stationary_popover_render_state.timeout_paused); + assert!(stationary_popover_render_state.popover_visible); + assert!(stationary_popover_render_state.popover_pointer_over); + } + + #[gpui::test] + fn test_open_popover_tracks_pending_input_lifecycle(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + let initial_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("initial pending input"); + move_pointer_over_popover(cx); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + let updated_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("updated pending input while popover is open"); + assert_eq!(updated_render_state.keystrokes, vec!["ctrl-b", "h"]); + assert!(updated_render_state.generation > initial_render_state.generation); + assert!(updated_render_state.timeout_paused); + assert!(updated_render_state.popover_visible); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_some()); + + cx.simulate_keystrokes("j"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_none()); + } + + #[gpui::test] + fn test_hover_pauses_timeout_when_untimed_input_becomes_timed(cx: &mut TestAppContext) { + let (indicator, _, cx) = + setup_indicator_test(cx, nested_timed_bindings().into_iter().skip(1)); + start_pending_input_and_hover_indicator(cx); + move_pointer_over_popover(cx); + cx.update(|window, _| { + assert!( + window + .pending_input() + .expect("untimed prefix") + .timeout() + .is_none() + ); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, _| { + assert!( + window + .pending_input() + .expect("timed prefix") + .timeout() + .expect("new timeout") + .is_paused() + ); + }); + + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_some())); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + + move_pointer_outside(cx); + cx.executor().advance_clock(POPOVER_HIDE_DELAY); + cx.run_until_parked(); + cx.update(|window, _| { + assert!( + !window + .pending_input() + .expect("pending input after leaving popover") + .timeout() + .expect("resumed timeout") + .is_paused() + ); + }); + + cx.simulate_keystrokes("j"); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_none()); + } + + #[gpui::test] + fn test_timed_chord_progress_dismisses_popover_during_dismissal_delay(cx: &mut TestAppContext) { + // The standalone ctrl-b binding gives the initial prefix a timeout, which hovering pauses. + assert_chord_progress_dismisses_popover(cx, nested_timed_bindings()); + } + + #[gpui::test] + fn test_newly_timed_chord_progress_dismisses_popover_during_dismissal_delay( + cx: &mut TestAppContext, + ) { + // Without standalone ctrl-b, the initial prefix has no timeout. Pressing h creates one + // because ctrl-b h is both a complete binding and a prefix of ctrl-b h j. + assert_chord_progress_dismisses_popover(cx, nested_timed_bindings().into_iter().skip(1)); + } + + fn assert_chord_progress_dismisses_popover( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) { + let (indicator, _, cx) = setup_indicator_test(cx, bindings); + start_popover_dismissal(cx); + + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + + // The pointer has left, but only 100 ms of the 300 ms dismissal delay has elapsed. + indicator.read_with(cx, |indicator, _| { + assert!(indicator.popover.visible); + assert!(indicator.popover.hide_task.is_some()); + }); + + // Continuing the chord must close the popover now, without waiting for the delay. + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + + indicator.read_with(cx, |indicator, _| { + assert!(!indicator.popover.visible); + assert!(indicator.popover.hide_task.is_none()); + }); + + // Both a previously paused timeout and a newly created one must run for a full + // second from this keypress, without waiting for the popover's dismissal delay. + cx.update(|window, cx| { + let timeout = window + .pending_input() + .expect("pending chord") + .timeout() + .expect("timed chord"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), Duration::from_secs(1)); + }); + + cx.executor().advance_clock(Duration::from_millis(999)); + cx.run_until_parked(); + + // The chord must still be pending just before its one-second timeout. + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + + // At exactly one second, the timeout must clear pending input and the indicator. + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + } + + #[gpui::test] + fn test_timeout_notifications_preserve_popover_dismissal_delay(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_popover_dismissal(cx); + + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + + // Trigger notifications without changing the pending keys. They must neither + // close the popover early nor restart its 300 ms dismissal delay. + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&indicator, false, cx)); + assert!(window.set_pending_input_timeout_paused(&indicator, true, cx)); + }); + cx.run_until_parked(); + + assert!(indicator.read_with(cx, |indicator, _| indicator.popover.visible)); + + cx.executor().advance_clock(Duration::from_millis(199)); + cx.run_until_parked(); + + // At 299 ms since the pointer left, the popover must not have closed early. + assert!(indicator.read_with(cx, |indicator, _| indicator.popover.visible)); + + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + + // It must close at the original 300 ms deadline. Restarting the delay when + // the notifications arrived at 100 ms would leave it open until 400 ms. + assert!(!indicator.read_with(cx, |indicator, _| indicator.popover.visible)); + } + + #[gpui::test] + fn test_popover_hides_and_timeout_resumes_after_delay(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_popover_dismissal(cx); + + cx.executor() + .advance_clock(POPOVER_HIDE_DELAY - Duration::from_millis(1)); + cx.run_until_parked(); + + let dismissing_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input during popover dismissal delay"); + assert!(dismissing_render_state.timeout_paused); + assert!(dismissing_render_state.popover_visible); + + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + + let resumed_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("resumed pending input"); + assert!(!resumed_render_state.timeout_paused); + assert!(!resumed_render_state.popover_visible); + } + + #[gpui::test] + fn test_disabling_indicator_releases_timeout_pause(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store + .set_user_settings( + r#"{"status_bar":{"pending_keystrokes_indicator":false}}"#, + cx, + ) + .expect("valid test settings"); + }); + }); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + } + + #[gpui::test] + fn test_indicator_shows_pending_input_without_timeout(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test( + cx, + [KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + )], + ); + + let notification_count = Rc::new(Cell::new(0)); + let _notification_subscription = cx.update({ + let indicator = indicator.clone(); + let notification_count = notification_count.clone(); + move |_, cx| { + cx.observe(&indicator, move |_, _| { + notification_count.set(notification_count.get() + 1); + }) + } + }); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert!(pending_input.timeout().is_none()); + }); + // Untimed input must still show the pending keys and matching binding, + // without countdown state. + let snapshot = indicator + .read_with(cx, |indicator, _| { + assert!( + indicator + .render_state() + .expect("pending input") + .timeout + .is_none() + ); + indicator_snapshot(indicator) + }) + .expect("pending input snapshot"); + assert_eq!(snapshot.keystrokes, vec!["ctrl-b"]); + assert_eq!( + snapshot.bindings, + vec![( + vec!["h".to_string()], + humanize_action_name(LongerBinding.name()), + )] + ); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_INDICATOR").is_some()); + assert!(notification_count.get() > 0); + + // Waiting longer than the usual one-second timeout must not hide an untimed chord. + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_some())); + + // Completing the chord must clear pending input and hide the indicator. + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + } +} diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs index d71bd646e7..493aefcfe2 100644 --- a/crates/which_key/src/which_key.rs +++ b/crates/which_key/src/which_key.rs @@ -1,9 +1,11 @@ //! Which-key support for Zed. +mod pending_keystrokes_indicator; mod which_key_modal; mod which_key_settings; -use gpui::{App, Keystroke}; +use gpui::{App, KeybindingKeystroke, Keystroke, PlatformKeyboardMapper, SharedString, Window}; +pub use pending_keystrokes_indicator::PendingKeystrokesIndicator; use settings::Settings; use std::{sync::LazyLock, time::Duration}; use util::ResultExt; @@ -11,6 +13,69 @@ use which_key_modal::WhichKeyModal; use which_key_settings::WhichKeySettings; use workspace::Workspace; +pub(crate) struct PendingBinding { + pub(crate) remaining_keystrokes: Vec, + pub(crate) action_name: SharedString, +} + +pub(crate) fn map_pending_keystrokes( + keystrokes: &[Keystroke], + keyboard_mapper: &dyn PlatformKeyboardMapper, +) -> Vec { + keystrokes + .iter() + .cloned() + .map(|keystroke| KeybindingKeystroke::new_with_mapper(keystroke, false, keyboard_mapper)) + .collect() +} + +pub(crate) fn bindings_for_pending_input( + window: &Window, + pending_keystrokes: &[Keystroke], +) -> Vec { + collect_bindings_for_pending_input(window, pending_keystrokes, |_| true) +} + +pub(crate) fn bindings_for_which_key( + window: &Window, + pending_keystrokes: &[Keystroke], +) -> Vec { + collect_bindings_for_pending_input(window, pending_keystrokes, |binding| { + let binding_keystrokes = binding.keystrokes(); + !FILTERED_KEYSTROKES.iter().any(|filtered| { + binding_keystrokes.len() >= filtered.len() + && binding_keystrokes[..filtered.len()] + .iter() + .map(|keystroke| keystroke.inner()) + .eq(filtered.iter()) + }) + }) +} + +fn collect_bindings_for_pending_input( + window: &Window, + pending_keystrokes: &[Keystroke], + mut include_binding: impl FnMut(&gpui::KeyBinding) -> bool, +) -> Vec { + window + .possible_bindings_for_input(pending_keystrokes) + .into_iter() + .filter(|binding| include_binding(binding)) + .filter_map(|binding| { + let remaining_keystrokes = binding.keystrokes().get(pending_keystrokes.len()..)?; + if remaining_keystrokes.is_empty() { + return None; + } + let remaining_keystrokes = remaining_keystrokes.to_vec(); + let action_name = command_palette::humanize_action_name(binding.action().name()).into(); + Some(PendingBinding { + remaining_keystrokes, + action_name, + }) + }) + .collect() +} + pub fn init(cx: &mut App) { WhichKeySettings::register(cx); @@ -92,3 +157,81 @@ pub static FILTERED_KEYSTROKES: LazyLock>> = LazyLock::new(|| }) .collect() }); + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use collections::HashMap; + use gpui::PlatformKeyboardMapper; + + use super::*; + + struct TestKeyboardMapper { + call_count: Cell, + } + + impl PlatformKeyboardMapper for TestKeyboardMapper { + fn map_key_equivalent( + &self, + keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + assert!(!use_key_equivalents); + self.call_count.set(self.call_count.get() + 1); + + #[cfg(target_os = "windows")] + { + KeybindingKeystroke::new( + keystroke, + gpui::Modifiers::control_shift(), + "2".to_owned(), + ) + } + #[cfg(not(target_os = "windows"))] + { + KeybindingKeystroke::from_keystroke(keystroke) + } + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } + } + + #[gpui::test] + fn test_map_pending_keystrokes_uses_platform_mapper(cx: &mut App) { + let keyboard_mapper = TestKeyboardMapper { + call_count: Cell::new(0), + }; + let keystroke = Keystroke::parse("ctrl-@").expect("valid test keystroke"); + + let mapped = map_pending_keystrokes(std::slice::from_ref(&keystroke), &keyboard_mapper); + + assert_eq!(keyboard_mapper.call_count.get(), 1); + let Some(mapped_keystroke) = mapped.first() else { + panic!("expected mapped pending keystroke"); + }; + assert_eq!(mapped_keystroke.inner(), &keystroke); + #[cfg(target_os = "windows")] + { + assert_eq!( + mapped_keystroke.modifiers(), + &gpui::Modifiers::control_shift() + ); + assert_eq!(mapped_keystroke.key(), "2"); + } + + let expected_display_text = if cfg!(target_os = "windows") { + "Ctrl-Shift-2" + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { + "Ctrl-@" + } else { + "Control-@" + }; + assert_eq!( + ui::text_for_keybinding_keystrokes(&mapped, cx), + expected_display_text + ); + } +} diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs index 0a5bde4c9c..689ce10cc7 100644 --- a/crates/which_key/src/which_key_modal.rs +++ b/crates/which_key/src/which_key_modal.rs @@ -10,11 +10,11 @@ use std::collections::HashMap; use theme_settings::ThemeSettings; use ui::{ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, - text_for_keybinding_keystrokes, text_for_keystrokes, + text_for_keybinding_keystrokes, }; use workspace::{ModalView, Workspace}; -use crate::FILTERED_KEYSTROKES; +use crate::{bindings_for_which_key, map_pending_keystrokes}; pub struct WhichKeyModal { _workspace: WeakEntity, @@ -65,27 +65,9 @@ impl WhichKeyModal { cx.emit(DismissEvent); return; }; - let bindings = window.possible_bindings_for_input(pending_keys); - - let mut binding_data = bindings - .iter() - .map(|binding| (binding.keystrokes().to_vec(), binding.action())) - .filter(|(keystrokes, _action)| { - // Check if this binding matches any filtered keystroke pattern - !FILTERED_KEYSTROKES.iter().any(|filtered| { - keystrokes.len() >= filtered.len() - && keystrokes[..filtered.len()] - .iter() - .map(|keystroke| keystroke.inner()) - .eq(filtered.iter()) - }) - }) - .filter_map(|(keystrokes, action)| { - let remaining_keystrokes = keystrokes.get(pending_keys.len()..)?.to_vec(); - let action_name: SharedString = - command_palette::humanize_action_name(action.name()).into(); - Some((remaining_keystrokes, action_name)) - }) + let mut binding_data = bindings_for_which_key(window, pending_keys) + .into_iter() + .map(|binding| (binding.remaining_keystrokes, binding.action_name)) .collect(); binding_data = group_bindings(binding_data); @@ -119,7 +101,8 @@ impl WhichKeyModal { text_a.cmp(&text_b) }); binding_data.dedup(); - self.pending_keys = text_for_keystrokes(&pending_keys, cx).into(); + let pending_keys = map_pending_keystrokes(pending_keys, cx.keyboard_mapper().as_ref()); + self.pending_keys = text_for_keybinding_keystrokes(&pending_keys, cx).into(); self.bindings = binding_data .into_iter() .map(|(keystrokes, action)| { @@ -310,10 +293,57 @@ fn group_bindings( mod tests { #[cfg(target_os = "windows")] use gpui::Modifiers; - use gpui::{InvalidKeystrokeError, Keystroke}; + use gpui::{ + Action as _, Entity, FocusHandle, InvalidKeystrokeError, KeyBinding, Keystroke, + TestAppContext, VisualTestContext, actions, + }; use super::*; + actions!( + which_key_modal_test, + [FirstBinding, SecondBinding, ThirdBinding] + ); + + struct TestView { + focus_handle: FocusHandle, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .key_context("WhichKeyModalTest") + .track_focus(&self.focus_handle) + .on_action(|_: &FirstBinding, _, _| {}) + .on_action(|_: &SecondBinding, _, _| {}) + .on_action(|_: &ThirdBinding, _, _| {}) + } + } + + fn setup_modal_test<'a>( + cx: &'a mut TestAppContext, + bindings: impl IntoIterator, + pending_keystrokes: &str, + ) -> (Entity, &'a mut VisualTestContext) { + cx.update(|cx| cx.bind_keys(bindings)); + let (test_view, cx) = cx.add_window_view(|_, cx| TestView { + focus_handle: cx.focus_handle(), + }); + let focus_handle = test_view.read_with(cx, |test_view, _| test_view.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + cx.simulate_keystrokes(pending_keystrokes); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + + let modal = cx.update(|window, cx| { + cx.new(|cx| WhichKeyModal::new(WeakEntity::new_invalid(), window, cx)) + }); + (modal, cx) + } + #[test] fn test_group_bindings_preserves_keybinding_keystrokes() -> Result<(), InvalidKeystrokeError> { #[cfg(target_os = "windows")] @@ -334,4 +364,38 @@ mod tests { ); Ok(()) } + + #[gpui::test] + fn test_which_key_modal_groups_and_orders_pending_bindings(cx: &mut TestAppContext) { + let (modal, cx) = setup_modal_test( + cx, + [ + KeyBinding::new("ctrl-b h", FirstBinding, Some("WhichKeyModalTest")), + KeyBinding::new("ctrl-b h j", SecondBinding, Some("WhichKeyModalTest")), + KeyBinding::new("ctrl-b k", ThirdBinding, Some("WhichKeyModalTest")), + ], + "ctrl-b", + ); + + let h = KeybindingKeystroke::from_keystroke( + Keystroke::parse("h").expect("valid test keystroke"), + ); + let k = KeybindingKeystroke::from_keystroke( + Keystroke::parse("k").expect("valid test keystroke"), + ); + let h_text = cx.update(|_, cx| text_for_keybinding_keystrokes(&[h], cx)); + let k_text = cx.update(|_, cx| text_for_keybinding_keystrokes(&[k], cx)); + let expected_bindings = vec![ + ( + k_text.into(), + command_palette::humanize_action_name(ThirdBinding.name()).into(), + ), + (h_text.into(), SharedString::from("+2 keybinds")), + ]; + + assert_eq!( + modal.read_with(cx, |modal, _| modal.bindings.clone()), + expected_bindings + ); + } } diff --git a/crates/windows_resources/Cargo.toml b/crates/windows_resources/Cargo.toml index 4344040660..41a277cccd 100644 --- a/crates/windows_resources/Cargo.toml +++ b/crates/windows_resources/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/windows_resources.rs" doctest = false +test = false [target.'cfg(target_os = "windows")'.dependencies] embed-resource = "3.0" diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 4f88774b69..335f58919b 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -54,6 +54,7 @@ parking_lot.workspace = true paths.workspace = true postage.workspace = true project.workspace = true +release_channel.workspace = true remote.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index ec0254868b..884db6567a 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -157,15 +157,17 @@ impl ModalLayer { // Opening a modal explicitly supersedes any reveal that was waiting for the // layer to become free. self.reveal_stash_when_free = false; + let mut previous_focus_handle = window.focused(cx); if let Some(active_modal) = &self.active_modal { let should_close = active_modal.modal.view().downcast::().is_ok(); + previous_focus_handle = active_modal.previous_focus_handle.clone(); let did_close = self.hide_modal(window, cx); if should_close || !did_close { return; } } let new_modal = cx.new(|cx| build_view(window, cx)); - self.show_modal(Box::new(new_modal), window, cx); + self.show_modal(Box::new(new_modal), previous_focus_handle, window, cx); cx.emit(ModalOpenedEvent); } @@ -174,6 +176,7 @@ impl ModalLayer { fn show_modal( &mut self, modal: Box, + previous_focus_handle: Option, window: &mut Window, cx: &mut Context, ) { @@ -190,7 +193,7 @@ impl ModalLayer { } }), ], - previous_focus_handle: window.focused(cx), + previous_focus_handle, focus_handle, }); cx.defer_in(window, move |_, window, cx| { @@ -214,7 +217,8 @@ impl ModalLayer { let Some(modal) = self.stashed_modal.take() else { return false; }; - self.show_modal(modal, window, cx); + let previous_focus_handle = window.focused(cx); + self.show_modal(modal, previous_focus_handle, window, cx); cx.emit(ModalOpenedEvent); true } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 529e5b16c7..0946495883 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -70,7 +70,7 @@ pub fn sidebar_side_context_menu( id: impl Into, cx: &App, ) -> ui::RightClickMenu { - let current_position = AgentSettings::get_global(cx).sidebar_side; + let current_position = AgentSettings::get_global(cx).threads_sidebar.position; right_click_menu(id).menu(move |window, cx| { let fs = ::global(cx); ContextMenu::build(window, cx, move |mut menu, _, _cx| { @@ -95,7 +95,7 @@ pub fn sidebar_side_context_menu( settings .agent .get_or_insert_default() - .set_sidebar_side(position); + .set_threads_sidebar_position(Some(position)); }); }, ); @@ -1449,7 +1449,7 @@ impl MultiWorkspace { })); } - fn serialize_now(&mut self, cx: &mut Context) -> Task<()> { + fn serialize_now(&mut self, cx: &mut Context) -> impl Future + use<> { let state = MultiWorkspaceState { active_workspace_id: self.workspace().read(cx).database_id(), project_groups: self @@ -1467,16 +1467,17 @@ impl MultiWorkspace { }; let window_id = self.window_id; let kvp = db::kvp::KeyValueStore::global(cx); - cx.background_spawn(async move { + async move { crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await; - }) + } } /// Used by the quit handler to ensure pending DB writes /// complete before the process exits. pub fn flush_serialization(&mut self, cx: &mut Context) -> Task<()> { self._serialize_task.take(); - self.serialize_now(cx) + let serialization = self.serialize_now(cx); + cx.spawn(async move |_, _| serialization.await) } pub fn flush_pending_serialization( diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 289d640423..91abd7d548 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -316,6 +316,93 @@ async fn test_open_new_window_does_not_open_sidebar_on_existing_window(cx: &mut .unwrap(); } +#[gpui::test] +async fn test_open_directory_in_existing_window_opens_sidebar(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + let fs = app_state.fs.as_fake(); + fs.insert_tree(path!("/project_a"), json!({ "file.txt": "" })) + .await; + fs.insert_tree(path!("/project_b"), json!({ "file.txt": "" })) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/project_a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.run_until_parked(); + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/project_b"))], + app_state, + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + window + .read_with(cx, |mw, _cx| { + assert!( + mw.sidebar_open(), + "adding a directory to an existing window opens the sidebar by default", + ); + assert_eq!(mw.workspaces().count(), 2); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_open_directory_in_existing_window_respects_auto_open_setting( + cx: &mut TestAppContext, +) { + init_test(cx); + + let app_state = cx.update(AppState::test); + let fs = app_state.fs.as_fake(); + fs.insert_tree(path!("/project_a"), json!({ "file.txt": "" })) + .await; + fs.insert_tree(path!("/project_b"), json!({ "file.txt": "" })) + .await; + + cx.update(|cx| { + let mut settings = AgentSettings::get_global(cx).clone(); + settings.threads_sidebar.auto_open = false; + AgentSettings::override_global(settings, cx); + }); + + let project = Project::test(app_state.fs.clone(), [path!("/project_a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.run_until_parked(); + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/project_b"))], + app_state, + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + window + .read_with(cx, |mw, _cx| { + assert!( + !mw.sidebar_open(), + "the sidebar must stay closed when `threads_sidebar.auto_open` is disabled", + ); + assert_eq!( + mw.workspaces().count(), + 2, + "the directory is still added to the existing window, and the workspace it \ + replaces is retained", + ); + }) + .unwrap(); +} + #[gpui::test] async fn test_open_directory_in_empty_workspace_does_not_open_sidebar(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 860ac2ad9f..697f4f3ed5 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -71,6 +71,12 @@ pub trait Notification: pub struct SuppressEvent; impl Workspace { + pub fn has_notification(&self, id: &NotificationId) -> bool { + self.notifications + .iter() + .any(|(existing_id, _)| existing_id == id) + } + #[cfg(any(test, feature = "test-support"))] pub fn notification_ids(&self) -> Vec { self.notifications @@ -80,6 +86,14 @@ impl Workspace { .collect() } + #[cfg(any(test, feature = "test-support"))] + pub fn notification_views(&self) -> Vec { + self.notifications + .iter() + .map(|(_, view)| view.clone()) + .collect() + } + pub fn show_notification( &mut self, id: NotificationId, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 56338cbaa0..25771a8ee1 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -4554,16 +4554,18 @@ impl Render for Pane { )) .on_action(cx.listener( |pane: &mut Self, action: &RevealInProjectPanel, _window, cx| { - let active_item = pane.active_item(); - let entry_id = active_item.as_ref().and_then(|item| { - action - .entry_id - .map(ProjectEntryId::from_proto) - .or_else(|| item.project_entry_ids(cx).first().copied()) - }); + let entry_id = action.entry_id.map(ProjectEntryId::from_proto); + let active_project_path = + pane.active_item().and_then(|item| item.project_path(cx)); pane.project .update(cx, |project, cx| { + let entry_id = entry_id.or_else(|| { + active_project_path + .as_ref() + .and_then(|path| project.entry_for_path(path, cx)) + .map(|entry| entry.id) + }); if let Some(entry_id) = entry_id && project .worktree_for_entry(entry_id, cx) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 9c3c33458a..9d15c57ebb 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1062,6 +1062,9 @@ impl Domain for WorkspaceDb { ON UPDATE CASCADE ) STRICT; ), + sql!( + ALTER TABLE workspaces ADD COLUMN native_window_state BLOB; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -2472,18 +2475,27 @@ impl WorkspaceDb { } query! { - pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> { + pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid, native_window_state: Option>) -> Result<()> { UPDATE workspaces SET window_state = ?2, window_x = ?3, window_y = ?4, window_width = ?5, window_height = ?6, - display = ?7 + display = ?7, + native_window_state = ?8 WHERE workspace_id = ?1 } } + query! { + pub(crate) fn native_window_state(workspace_id: WorkspaceId) -> Result, Option>)>> { + SELECT display, native_window_state + FROM workspaces + WHERE workspace_id = ? + } + } + query! { pub(crate) async fn set_centered_layout(workspace_id: WorkspaceId, centered_layout: bool) -> Result<()> { UPDATE workspaces @@ -2492,14 +2504,6 @@ impl WorkspaceDb { } } - query! { - pub(crate) async fn set_session_id(workspace_id: WorkspaceId, session_id: Option) -> Result<()> { - UPDATE workspaces - SET session_id = ?2 - WHERE workspace_id = ?1 - } - } - query! { pub(crate) async fn set_session_binding(workspace_id: WorkspaceId, session_id: Option, window_id: Option) -> Result<()> { UPDATE workspaces @@ -3235,6 +3239,78 @@ mod tests { assert_eq!(test_text_1, "test-text-1"); } + #[gpui::test] + async fn test_native_window_state_round_trip() { + zlog::init_test(); + + let db = WorkspaceDb::open_test_db("test_native_window_state_round_trip").await; + let id = db.next_id().await.unwrap(); + let paths = &["/tmp/native-window-state"]; + let display_uuid = Uuid::new_v4(); + let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds { + origin: point(px(100.0), px(200.0)), + size: size(px(800.0), px(600.0)), + })); + + db.save_workspace(SerializedWorkspace { + id, + paths: PathList::new(paths), + identity_paths: None, + location: SerializedWorkspaceLocation::Local, + center_group: Default::default(), + window_bounds: None, + display: None, + docks: Default::default(), + bookmarks: Default::default(), + breakpoints: Default::default(), + centered_layout: false, + session_id: None, + window_id: None, + user_toolchains: Default::default(), + recent_navigation_history: Default::default(), + }) + .await; + assert_eq!(db.native_window_state(id).unwrap(), Some((None, None))); + assert_eq!( + db.native_window_state(WorkspaceId::from_i64(i64::MAX)) + .unwrap(), + None + ); + + let state = vec![0u8, 1, 2, 3, 250, 251, 252]; + db.set_window_open_status(id, window_bounds, display_uuid, Some(state.clone())) + .await + .unwrap(); + assert_eq!( + db.native_window_state(id).unwrap(), + Some((Some(display_uuid), Some(state))) + ); + let restored = db.workspace_for_roots(paths).unwrap(); + assert_eq!(restored.window_bounds, Some(window_bounds)); + assert_eq!(restored.display, Some(display_uuid)); + + let new_state = vec![9u8, 8, 7]; + db.set_window_open_status(id, window_bounds, display_uuid, Some(new_state.clone())) + .await + .unwrap(); + assert_eq!( + db.native_window_state(id).unwrap(), + Some((Some(display_uuid), Some(new_state))) + ); + + db.set_window_open_status(id, window_bounds, display_uuid, None) + .await + .unwrap(); + assert_eq!( + db.native_window_state(id).unwrap(), + Some((Some(display_uuid), None)) + ); + assert_eq!( + db.workspace_for_roots(paths).unwrap().window_bounds, + Some(window_bounds) + ); + } + #[gpui::test] async fn test_workspace_id_stability() { zlog::init_test(); @@ -4507,7 +4583,7 @@ mod tests { db.save_workspace(workspace.clone()).await; // Save window bounds separately (as the actual code does via set_window_open_status) - db.set_window_open_status(id, window_bounds, display_uuid) + db.set_window_open_status(id, window_bounds, display_uuid, None) .await .unwrap(); @@ -5393,6 +5469,108 @@ mod tests { ); } + #[gpui::test] + async fn test_empty_window_without_items_stays_in_session(cx: &mut gpui::TestAppContext) { + crate::tests::init_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let db = cx.update(|_, cx| WorkspaceDb::global(cx)); + let workspace_id = db.next_id().await.unwrap(); + let session_id = multi_workspace + .update_in(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, _| { + workspace.set_database_id(workspace_id); + workspace.session_id() + }) + }) + .unwrap(); + + multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.flush_serialization(window, cx) + }) + }) + .await; + + let restored = db + .last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .unwrap(); + assert_eq!( + restored + .iter() + .map(|workspace| workspace.workspace_id) + .collect::>(), + vec![workspace_id] + ); + } + + #[gpui::test] + async fn test_window_with_last_folder_removed_reopens_empty(cx: &mut gpui::TestAppContext) { + crate::tests::init_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({ "main.rs": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let db = cx.update(|_, cx| WorkspaceDb::global(cx)); + let workspace_id = db.next_id().await.unwrap(); + let session_id = multi_workspace + .update_in(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, _| { + workspace.set_database_id(workspace_id); + workspace.session_id() + }) + }) + .unwrap(); + multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.flush_serialization(window, cx) + }) + }) + .await; + assert_eq!( + db.workspace_for_id(workspace_id).unwrap().paths, + PathList::new(&[Path::new("/project")]) + ); + + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx)); + multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.flush_serialization(window, cx) + }) + }) + .await; + + assert_eq!( + db.workspace_for_id(workspace_id).unwrap().paths, + PathList::default() + ); + let restored = db + .last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .unwrap(); + assert_eq!( + restored + .iter() + .map(|workspace| (workspace.workspace_id, workspace.paths.clone())) + .collect::>(), + vec![(workspace_id, PathList::default())] + ); + } + #[gpui::test] async fn test_recent_workspace_identity_deduplication(cx: &mut gpui::TestAppContext) { let fs = fs::FakeFs::new(cx.executor()); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1a97606067..c71e356121 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -44,6 +44,7 @@ pub use remote::{ }; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; +use agent_settings::AgentSettings; use anyhow::{Context as _, Result, anyhow}; use client::{ ChannelId, Client, ErrorExt, ParticipantIndex, Status, TypedEnvelope, User, UserStore, @@ -101,10 +102,12 @@ use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, WorktreeSettings, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, + git_store::{GitStoreEvent, RepositoryEvent}, project_settings::ProjectSettings, toolchain_store::ToolchainStoreEvent, trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, TrustedWorktreesEvent}, }; +use release_channel::ReleaseChannel; use remote::{ RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, remote_client::ConnectionIdentifier, @@ -176,6 +179,140 @@ use crate::{ pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); pub const MAX_RECENT_SELECTIONS: usize = 20; +/// Which optional window-title variables are actually referenced by the active +/// template. Used to skip expensive lookups when the template doesn't need them. +struct WindowTitleNeeds { + file_path: bool, + relative_path: bool, + file_stem: bool, + remote: bool, + app_name: bool, + branch: bool, +} + +impl WindowTitleNeeds { + fn from_template(template: &str) -> Self { + Self { + file_path: template.contains("${filePath}"), + relative_path: template.contains("${relativePath}"), + file_stem: template.contains("${fileStem}"), + remote: template.contains("${remoteName}") || template.contains("${remoteHost}"), + app_name: template.contains("${appName}"), + branch: template.contains("${branch}"), + } + } +} + +#[derive(Default)] +struct WindowTitleContext { + project_name: String, + file_name: Option, + file_path: Option, + relative_path: Option, + file_stem: Option, + remote_name: Option, + remote_host: Option, + app_name: &'static str, + branch: Option, +} + +enum WindowTitleTemplatePart<'a> { + Literal(&'a str), + Variable(&'a str), + Separator, +} + +impl WindowTitleContext { + fn value_for(&self, variable: &str) -> Option<&str> { + match variable { + "projectName" => Some(self.project_name.as_str()), + "fileName" => self.file_name.as_deref(), + "filePath" => self.file_path.as_deref(), + "relativePath" => self.relative_path.as_deref(), + "fileStem" => self.file_stem.as_deref(), + "remoteName" => self.remote_name.as_deref(), + "remoteHost" => self.remote_host.as_deref(), + "appName" => Some(self.app_name), + "branch" => self.branch.as_deref(), + // Unknown placeholders collapse like missing values so imported and + // native templates follow the same rendering rules. + _ => None, + } + } +} + +fn parse_window_title_format(template: &str) -> Vec> { + let mut parts = Vec::new(); + let mut start = 0; + + // Keep this placeholder scan in sync with the importer in + // settings/src/vscode_import.rs. + while let Some(offset) = template[start..].find("${") { + let variable_start = start + offset; + if variable_start > start { + parts.push(WindowTitleTemplatePart::Literal( + &template[start..variable_start], + )); + } + + let content_start = variable_start + 2; + let Some(content_end_offset) = template[content_start..].find('}') else { + parts.push(WindowTitleTemplatePart::Literal( + &template[variable_start..], + )); + return parts; + }; + + let content_end = content_start + content_end_offset; + let variable = &template[content_start..content_end]; + if variable == "separator" { + parts.push(WindowTitleTemplatePart::Separator); + } else { + parts.push(WindowTitleTemplatePart::Variable(variable)); + } + + start = content_end + 1; + } + + if start < template.len() { + parts.push(WindowTitleTemplatePart::Literal(&template[start..])); + } + + parts +} + +fn render_window_title_format( + template: &str, + separator: &str, + context: &WindowTitleContext, +) -> String { + let parts = parse_window_title_format(template); + let mut segments = Vec::new(); + let mut current_segment = String::new(); + + for part in parts { + match part { + WindowTitleTemplatePart::Literal(text) => current_segment.push_str(text), + WindowTitleTemplatePart::Variable(variable) => { + if let Some(value) = context.value_for(variable) { + current_segment.push_str(value); + } + } + WindowTitleTemplatePart::Separator => { + if !current_segment.is_empty() { + segments.push(std::mem::take(&mut current_segment)); + } + } + } + } + + if !current_segment.is_empty() { + segments.push(current_segment); + } + + segments.join(separator) +} + static ZED_WINDOW_SIZE: LazyLock>> = LazyLock::new(|| { env::var("ZED_WINDOW_SIZE") .ok() @@ -1406,8 +1543,6 @@ pub enum OpenVisible { enum WorkspaceLocation { // Valid local paths or SSH project to serialize Location(SerializedWorkspaceLocation, PathList), - // No valid location found hence clear session id - DetachFromSession, // No valid location found to serialize None, } @@ -1475,6 +1610,10 @@ pub struct Workspace { auto_watch: AutoWatch, window_edited: bool, last_window_title: Option, + /// The `(window_title_format, window_title_separator)` pair last applied to + /// the window, used to skip title recomputation when unrelated settings + /// change. + last_window_title_settings: Option<(String, String)>, dirty_items: HashMap, active_call: Option<(GlobalAnyActiveCall, Vec)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, @@ -1878,6 +2017,39 @@ impl Workspace { let subscriptions = vec![ cx.observe_window_activation(window, Self::on_window_activation_changed), + cx.observe_global_in::(window, |this, window, cx| { + // Settings can only affect the title through these two values, + // so skip the recomputation when they are unchanged. + let settings = WorkspaceSettings::get_global(cx); + let title_settings = ( + settings.window_title_format.as_str(), + settings.window_title_separator.as_str(), + ); + let last_title_settings = this + .last_window_title_settings + .as_ref() + .map(|(format, separator)| (format.as_str(), separator.as_str())); + if last_title_settings != Some(title_settings) { + this.update_window_title(window, cx); + } + }), + cx.subscribe_in( + &project.read(cx).git_store().clone(), + window, + |this, _, event, window, cx| match event { + GitStoreEvent::ActiveRepositoryChanged(_) + | GitStoreEvent::RepositoryUpdated( + _, + RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged, + true, + ) => { + if this.window_title_needs_branch(cx) { + this.update_window_title(window, cx); + } + } + _ => {} + }, + ), cx.observe_window_bounds(window, move |this, window, cx| { if !window.is_window_active() { return; @@ -1956,6 +2128,7 @@ impl Workspace { dispatching_keystrokes: Default::default(), window_edited: false, last_window_title: None, + last_window_title_settings: None, dirty_items: Default::default(), active_call, database_id: workspace_id, @@ -2744,6 +2917,15 @@ impl Workspace { &self.app_state } + pub fn is_restoring(&self) -> bool { + self.restoring_workspace + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_restoring_workspace(&mut self, restoring: bool) { + self.restoring_workspace = restoring; + } + pub fn set_panels_task(&mut self, task: Task>) { self._panels_task = Some(task); } @@ -4137,7 +4319,14 @@ impl Workspace { pub fn active_item_as(&self, cx: &App) -> Option> { let item = self.active_item(cx)?; - item.to_any_view().downcast::().ok() + // Prefer an exact downcast so that we return the active item itself when + // its concrete type matches, preserving entity identity for callers that + // compare `entity_id`s. Fall back to `act_as` so that wrapper items (e.g. + // diff views) resolve to the inner view they expose. + item.to_any_view() + .downcast::() + .ok() + .or_else(|| item.act_as::(cx)) } fn active_project_path(&self, cx: &App) -> Option { @@ -6445,7 +6634,16 @@ impl Workspace { project.set_active_path(active_entry.clone(), cx) }); - if focus_changed && let Some(project_path) = &active_entry { + // Infer the active repository only from singleton items. + // A multibuffer's active path represents the cursor's location within + // an aggregate view, so we assume it's not the user's intent to switch + // repositories. + if focus_changed + && let Some(project_path) = &active_entry + && self + .active_item(cx) + .is_some_and(|item| item.buffer_kind(cx) == ItemBufferKind::Singleton) + { let git_store_entity = self.project.read(cx).git_store().clone(); git_store_entity.update(cx, |git_store, cx| { git_store.set_active_repo_for_path(project_path, cx); @@ -6489,39 +6687,45 @@ impl Workspace { self.apply_window_title(window, cx); } + /// Whether the active window-title template references `${branch}`, and so + /// can be affected by Git repository events. + fn window_title_needs_branch(&self, cx: &App) -> bool { + WindowTitleNeeds::from_template(&WorkspaceSettings::get_global(cx).window_title_format) + .branch + } + fn apply_window_title(&mut self, window: &mut Window, cx: &mut App) { let project = self.project().read(cx); - let mut title = String::new(); - - for (i, worktree) in project.visible_worktrees(cx).enumerate() { - let name = worktree.read(cx).root_name_str(); - - if i > 0 { - title.push_str(", "); - } - title.push_str(name); - } - - if title.is_empty() { - title = "empty project".to_string(); - } - let active_project_path = self.active_item(cx).and_then(|item| item.project_path(cx)); - - if let Some(path) = active_project_path.as_ref() { - let filename = path.path.file_name().or_else(|| { - Some( - project - .worktree_for_id(path.worktree_id, cx)? - .read(cx) - .root_name_str(), - ) - }); - - if let Some(filename) = filename { - title.push_str(" — "); - title.push_str(filename.as_ref()); - } + let settings = WorkspaceSettings::get_global(cx); + let template = settings.window_title_format.as_str(); + let separator = settings.window_title_separator.as_str(); + let settings_changed = self.last_window_title_settings.as_ref().is_none_or( + |(last_template, last_separator)| { + (last_template.as_str(), last_separator.as_str()) != (template, separator) + }, + ); + if settings_changed { + self.last_window_title_settings = Some((template.to_string(), separator.to_string())); + } + let needs = WindowTitleNeeds::from_template(template); + let context = + Self::window_title_context(&project, active_project_path.as_ref(), &needs, cx); + let mut title = render_window_title_format(template, separator, &context); + // Keep the normal title when a custom template resolves entirely to + // empty or unknown placeholders. + if title.trim().is_empty() + && let Some(default_template) = cx + .global::() + .raw_default_settings() + .workspace + .window_title_format + .as_deref() + { + let needs = WindowTitleNeeds::from_template(default_template); + let context = + Self::window_title_context(&project, active_project_path.as_ref(), &needs, cx); + title = render_window_title_format(default_template, separator, &context); } if project.is_via_collab() { @@ -6553,6 +6757,89 @@ impl Workspace { !self.project.read(cx).is_disconnected(cx) && !self.dirty_items.is_empty() } + fn window_title_context( + project: &Project, + project_path: Option<&ProjectPath>, + needs: &WindowTitleNeeds, + cx: &App, + ) -> WindowTitleContext { + let project_name = project_window_title(project, cx); + let path_style = project.path_style(cx); + + let (file_name, file_path, relative_path, file_stem) = project_path + .map(|project_path| { + let file_name = project_path + .path + .file_name() + .map(|file_name| file_name.to_string()) + .or_else(|| { + Some( + project + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .root_name_str() + .to_string(), + ) + }); + let file_path = if needs.file_path { + project + .absolute_path(project_path, cx) + .map(|path| path.to_string_lossy().into_owned()) + } else { + None + }; + let relative_path = if needs.relative_path { + (!project_path.path.as_unix_str().is_empty()) + .then(|| project_path.path.display(path_style).to_string()) + } else { + None + }; + let file_stem = if needs.file_stem { + project_path.path.file_stem().map(|s| s.to_string()) + } else { + None + }; + (file_name, file_path, relative_path, file_stem) + }) + .unwrap_or((None, None, None, None)); + + let remote_options = if needs.remote { + project.remote_connection_options(cx) + } else { + None + }; + let remote_name = remote_options + .as_ref() + .map(RemoteConnectionOptions::display_name); + let remote_host = remote_options.as_ref().map(RemoteConnectionOptions::host); + + let branch = if needs.branch { + project + .active_repository(cx) + .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name().to_owned())) + } else { + None + }; + + WindowTitleContext { + project_name, + file_name, + file_path, + relative_path, + file_stem, + remote_name, + remote_host, + app_name: if needs.app_name { + ReleaseChannel::try_global(cx) + .unwrap_or(ReleaseChannel::Stable) + .display_name() + } else { + "" + }, + branch, + } + } + fn update_window_edited(&mut self, window: &mut Window, cx: &mut App) { if !self.owns_window_chrome() { return; @@ -6560,7 +6847,7 @@ impl Workspace { let is_edited = self.is_window_edited(cx); if is_edited != self.window_edited { self.window_edited = is_edited; - window.set_window_edited(self.window_edited) + window.set_window_edited(self.window_edited); } } @@ -7261,6 +7548,11 @@ impl Workspace { let has_paths = !self.root_paths(cx).is_empty(); let db = WorkspaceDb::global(cx); let kvp = db::kvp::KeyValueStore::global(cx); + let native_window_state = if database_id.is_some() { + window.native_window_state() + } else { + None + }; cx.background_executor().spawn(async move { if !has_paths { @@ -7273,6 +7565,7 @@ impl Workspace { database_id, SerializedWindowBounds(window_bounds), display_uuid, + native_window_state, ) .await .log_err(); @@ -7454,6 +7747,9 @@ impl Workspace { let center_group = build_serialized_pane_group(&self.center.root, window, cx); let docks = build_serialized_docks(self, window, cx); + let default_docks = (paths.is_empty() + && location == SerializedWorkspaceLocation::Local) + .then(|| docks.clone()); let window_bounds = Some(SerializedWindowBounds(window.window_bounds())); let identity_paths_hint = self.project_group_key(cx).path_list().clone(); let recent_navigation_history = self.persisted_recent_navigation_history.clone(); @@ -7476,32 +7772,15 @@ impl Workspace { recent_navigation_history, }; - let db = WorkspaceDb::global(cx); - cx.background_spawn(async move { - db.save_workspace(serialized_workspace).await; - }) - } - WorkspaceLocation::DetachFromSession => { - let window_bounds = SerializedWindowBounds(window.window_bounds()); - let display = window.display(cx).and_then(|d| d.uuid().ok()); - // Save dock state for empty local workspaces - let docks = build_serialized_docks(self, window, cx); let db = WorkspaceDb::global(cx); let kvp = db::kvp::KeyValueStore::global(cx); cx.background_spawn(async move { - let open_status_write = db.set_window_open_status( - database_id, - window_bounds, - display.unwrap_or_default(), - ); - let session_id_write = db.set_session_id(database_id, None); - let (open_status, session_id) = - futures::join!(open_status_write, session_id_write); - open_status.log_err(); - session_id.log_err(); - persistence::write_default_dock_state(&kvp, docks) - .await - .log_err(); + if let Some(docks) = default_docks { + persistence::write_default_dock_state(&kvp, docks) + .await + .log_err(); + } + db.save_workspace(serialized_workspace).await; }) } WorkspaceLocation::None => { @@ -7517,20 +7796,12 @@ impl Workspace { } } - fn has_any_items_open(&self, cx: &App) -> bool { - self.panes.iter().any(|pane| pane.read(cx).items_len() > 0) - } - fn workspace_location(&self, cx: &App) -> WorkspaceLocation { let paths = PathList::new(&self.root_paths(cx)); if let Some(connection) = self.project.read(cx).remote_connection_options(cx) { WorkspaceLocation::Location(SerializedWorkspaceLocation::Remote(connection), paths) } else if self.project.read(cx).is_local() { - if !paths.is_empty() || self.has_any_items_open(cx) { - WorkspaceLocation::Location(SerializedWorkspaceLocation::Local, paths) - } else { - WorkspaceLocation::DetachFromSession - } + WorkspaceLocation::Location(SerializedWorkspaceLocation::Local, paths) } else { WorkspaceLocation::None } @@ -8830,6 +9101,25 @@ impl Workspace { } } +fn project_window_title(project: &Project, cx: &App) -> String { + let mut title = String::new(); + + for (index, worktree) in project.visible_worktrees(cx).enumerate() { + let name = worktree.read(cx).root_name_str(); + if index > 0 { + title.push_str(", "); + } + title.push_str(name); + } + + if title.is_empty() { + // Keep the default untitled-window text instead of showing a blank title. + "empty project".to_string() + } else { + title + } +} + pub trait AnyActiveCall { fn entity(&self) -> AnyEntity; fn is_in_room(&self, _: &App) -> bool; @@ -9953,7 +10243,7 @@ pub async fn restore_multiworkspace( None, None, None, - OpenMode::Activate, + OpenMode::Add, cx, ) }) @@ -9962,7 +10252,15 @@ pub async fn restore_multiworkspace( }; let window_handle = match workspace_result { - Ok(handle) => handle, + Ok(handle) => { + restore_native_window_state(handle, active_workspace.workspace_id, cx); + handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); + handle + } Err(err) => { log::error!("Failed to restore active workspace: {err:#}"); @@ -10080,6 +10378,37 @@ pub async fn apply_restored_multiworkspace_state( } } +fn restore_native_window_state( + window_handle: WindowHandle, + workspace_id: WorkspaceId, + cx: &mut AsyncApp, +) { + if window_bounds_env_override().is_some() { + return; + } + let Some((Some(display), Some(native_window_state))) = cx + .update(|cx| WorkspaceDb::global(cx)) + .native_window_state(workspace_id) + .log_err() + .flatten() + else { + return; + }; + let display_connected = cx.update(|cx| { + cx.displays() + .into_iter() + .any(|connected_display| connected_display.uuid().ok() == Some(display)) + }); + if !display_connected { + return; + } + window_handle + .update(cx, |_, window, _cx| { + window.restore_native_window_state(&native_window_state); + }) + .log_err(); +} + actions!( collab, [ @@ -10865,7 +11194,14 @@ pub fn open_paths( open_options.requesting_window = Some(window); window .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); + if AgentSettings::get_global(cx).threads_sidebar.auto_open { + multi_workspace.open_sidebar(cx); + } else { + // Opening the sidebar is also what pins the + // workspace we are about to navigate away from, + // so pin it here to keep it in this window. + multi_workspace.retain_active_workspace(cx); + } }) .log_err(); } @@ -12114,6 +12450,143 @@ mod tests { use util::path; use util::rel_path::rel_path; + #[test] + fn test_render_window_title_format_omits_empty_segments() { + let context = WindowTitleContext { + project_name: "project".to_string(), + file_name: None, + file_path: Some("/tmp/project/src/main.rs".to_string()), + relative_path: Some("src/main.rs".to_string()), + file_stem: Some("main".to_string()), + remote_name: Some("nickname".to_string()), + remote_host: Some("example.com".to_string()), + app_name: "Zed", + branch: Some("main".to_string()), + }; + + assert_eq!( + render_window_title_format( + "${projectName}${separator}${fileName}${separator}${remoteHost}", + " — ", + &context, + ), + "project — example.com" + ); + assert_eq!( + render_window_title_format( + "${fileName}${separator}${projectName}${separator}${remoteName}", + " — ", + &context, + ), + "project — nickname" + ); + assert_eq!( + render_window_title_format( + "${projectName}${separator}${relativePath}${separator}${remoteName}${separator}${remoteHost}", + " — ", + &context, + ), + "project — src/main.rs — nickname — example.com" + ); + assert_eq!( + render_window_title_format( + "${projectName}${separator}${remoteHost}${separator}${fileName}", + " | ", + &context, + ), + "project | example.com" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}", " — ", &context), + "project" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}${fileStem}", " — ", &context), + "project — main" + ); + } + + #[test] + fn test_render_window_title_format_omits_unknown_variables() { + let context = WindowTitleContext { + project_name: "project".to_string(), + file_name: Some("main.rs".to_string()), + file_path: Some("/tmp/project/src/main.rs".to_string()), + relative_path: Some("src/main.rs".to_string()), + file_stem: Some("main".to_string()), + remote_name: None, + remote_host: None, + app_name: "Zed", + branch: None, + }; + + assert_eq!( + render_window_title_format( + "${projectName}${separator}${typoName}${separator}${fileName}", + " — ", + &context, + ), + "project — main.rs" + ); + assert_eq!( + render_window_title_format("${typoName}", " — ", &context), + "" + ); + } + + #[test] + fn test_render_window_title_format_renders_new_variables() { + let context = WindowTitleContext { + project_name: "project".to_string(), + app_name: "Zed", + branch: Some("feature/foo".to_string()), + ..Default::default() + }; + + assert_eq!( + render_window_title_format("${projectName}${separator}${appName}", " — ", &context), + "project — Zed" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}${branch}", " — ", &context), + "project — feature/foo" + ); + } + + #[test] + fn test_window_title_needs_from_template() { + let default = WindowTitleNeeds::from_template("${projectName}${separator}${fileName}"); + assert!(!default.file_path); + assert!(!default.relative_path); + assert!(!default.file_stem); + assert!(!default.remote); + assert!(!default.app_name); + assert!(!default.branch); + + let all = WindowTitleNeeds::from_template( + "${filePath} ${relativePath} ${fileStem} ${remoteName} ${remoteHost} ${appName} ${branch}", + ); + assert!(all.file_path); + assert!(all.relative_path); + assert!(all.file_stem); + assert!(all.remote); + assert!(all.app_name); + assert!(all.branch); + + // `remote` is shared between the two remote-* placeholders; either one + // flips the flag on its own. + assert!(WindowTitleNeeds::from_template("${remoteName}").remote); + assert!(WindowTitleNeeds::from_template("${remoteHost}").remote); + assert!(!WindowTitleNeeds::from_template("${projectName}").remote); + + // Substrings and unrelated text must not trigger the expensive path. + let noise = WindowTitleNeeds::from_template("filePath relativePath remote ${projectName}"); + assert!(!noise.file_path); + assert!(!noise.relative_path); + assert!(!noise.remote); + assert!(!noise.branch); + } + #[gpui::test] async fn test_tab_disambiguation(cx: &mut TestAppContext) { init_test(cx); @@ -12337,6 +12810,270 @@ mod tests { assert_eq!(cx.document_path(), None); } + #[gpui::test] + async fn test_window_title_format_setting(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["root1".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "src/one.txt", cx)]) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + assert_eq!(cx.window_title().as_deref(), Some("root1 — one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${relativePath}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + // `${relativePath}` renders with the project's path style, which uses + // `\` separators on Windows. + let expected_relative_path = cx.update(|_, cx| { + let path_style = project.read(cx).path_style(cx); + rel_path("src/one.txt").display(path_style).to_string() + }); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 — {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.close_panel_on_toggle = Some(true); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 — {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_separator = Some(" | ".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 | {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${remoteHost}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some("${typoName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some(" ".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some(" ${remoteHost}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + } + + #[gpui::test] + async fn test_window_title_format_path_variables(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root1"), json!({ "src": { "one.txt": "" } })) + .await; + fs.insert_tree(path!("/root2"), json!({})).await; + let project = Project::test( + fs.clone(), + [path!("/root1").as_ref(), path!("/root2").as_ref()], + cx, + ) + .await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some( + "${appName}${separator}${projectName}${separator}${fileStem}${separator}${filePath}" + .to_string(), + ); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("Zed — root1, root2")); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new_in_worktree( + 1, + "src/one.txt", + worktree_id, + cx, + )]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + cx.executor().run_until_parked(); + let expected_file_path = path!("/root1/src/one.txt"); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("Zed — root1, root2 — one — {expected_file_path}").as_str()) + ); + } + + #[gpui::test] + async fn test_window_title_format_empty_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (_workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some("${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("empty project")); + } + + #[gpui::test] + async fn test_window_title_format_branch(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root1"), json!({ ".git": {}, "a.txt": "" })) + .await; + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("main")); + let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some( + "${projectName}${separator}${branch}${separator}${fileName}".to_string(), + ); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — main")); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "a.txt", cx)]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — main — a.txt")); + + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("feature")); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some("root1 — feature — a.txt") + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — a.txt")); + + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("other")); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — a.txt")); + } + + #[gpui::test] + async fn test_window_title_collab_indicator_remains_appended(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["root1".as_ref()], cx).await; + project.update(cx, |project, _| project.mark_as_collab_for_testing()); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "src/one.txt", cx)]) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — one.txt ↙")); + } + #[gpui::test] async fn test_close_window(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index b88658fea4..eff3a0a93c 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -33,11 +33,14 @@ pub struct WorkspaceSettings { pub command_aliases: HashMap, pub max_tabs: Option, pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems, + pub on_new_window: settings::OnNewWindow, pub on_last_window_closed: settings::OnLastWindowClosed, pub text_rendering_mode: settings::TextRenderingMode, pub resize_all_panels_in_dock: Vec, pub close_on_file_delete: bool, pub close_panel_on_toggle: bool, + pub window_title_format: String, + pub window_title_separator: String, pub use_system_window_tabs: bool, pub fullscreen_mode: settings::FullscreenMode, pub zoomed_padding: bool, @@ -129,6 +132,7 @@ impl Settings for WorkspaceSettings { command_aliases: workspace.command_aliases.clone(), max_tabs: workspace.max_tabs, when_closing_with_no_tabs: workspace.when_closing_with_no_tabs.unwrap(), + on_new_window: workspace.on_new_window.unwrap(), on_last_window_closed: workspace.on_last_window_closed.unwrap(), text_rendering_mode: workspace.text_rendering_mode.unwrap(), resize_all_panels_in_dock: workspace @@ -140,6 +144,8 @@ impl Settings for WorkspaceSettings { .collect(), close_on_file_delete: workspace.close_on_file_delete.unwrap(), close_panel_on_toggle: workspace.close_panel_on_toggle.unwrap(), + window_title_format: workspace.window_title_format.clone().unwrap(), + window_title_separator: workspace.window_title_separator.clone().unwrap(), use_system_window_tabs: workspace.use_system_window_tabs.unwrap(), fullscreen_mode: workspace.fullscreen_mode.unwrap(), zoomed_padding: workspace.zoomed_padding.unwrap(), @@ -215,6 +221,7 @@ pub struct StatusBarSettings { pub cursor_position_button: bool, pub line_endings_button: bool, pub active_encoding_button: EncodingDisplayOptions, + pub pending_keystrokes_indicator: bool, } impl Settings for StatusBarSettings { @@ -227,6 +234,7 @@ impl Settings for StatusBarSettings { cursor_position_button: status_bar.cursor_position_button.unwrap(), line_endings_button: status_bar.line_endings_button.unwrap(), active_encoding_button: status_bar.active_encoding_button.unwrap(), + pending_keystrokes_indicator: status_bar.pending_keystrokes_indicator.unwrap(), } } } diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 99cbf3268a..fb0ef41338 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -8,7 +8,6 @@ license = "GPL-3.0-or-later" [lib] path = "src/worktree.rs" doctest = false -test = false [[test]] name = "integration" diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index bffbe72449..8037416e1e 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -82,6 +82,12 @@ pub use worktree_settings::WorktreeSettings; pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100); +/// How often the background scanner verifies that the worktree root still +/// exists at its recorded path. Native watchers report the root itself being +/// renamed or deleted, but not a rename of one of its ancestors, which leaves +/// the watcher silently attached to a path that no longer exists. +pub const ROOT_PATH_CHECK_INTERVAL: Duration = Duration::from_secs(5); + /// A set of local or remote files that are being opened as part of a project. /// Responsible for tracking related FS (for local)/collab (for remote) events and corresponding updates. /// Stores git repositories data and the diagnostics for the file(s). @@ -1356,7 +1362,7 @@ impl LocalWorktree { let background = cx.background_executor().clone(); async move { let defer_watch = - force_defer_watch || (scanning_enabled && fs::requires_poll_watcher(&abs_path)); + force_defer_watch || (scanning_enabled && fs.requires_poll_watcher(&abs_path)); let (events, watcher) = if scanning_enabled && !defer_watch { fs.watch(&abs_path, FS_WATCH_LATENCY).await @@ -4558,6 +4564,10 @@ impl BackgroundScanner { // Continue processing events until the worktree is dropped. self.phase = BackgroundScannerPhase::Events; + let root_path_check_timer = self.executor.timer(ROOT_PATH_CHECK_INTERVAL).fuse(); + futures::pin_mut!(root_path_check_timer); + let mut root_path_missing = false; + loop { select_biased! { // Process any path refresh requests from the worktree. Prioritize @@ -4612,6 +4622,20 @@ impl BackgroundScanner { self.update_global_gitignore(&path).await; } } + + _ = root_path_check_timer => { + let root_path = self.state.lock().await.snapshot.abs_path.clone(); + match self.fs.canonicalize(root_path.as_path()).await { + Ok(_) => root_path_missing = false, + Err(error) => { + if !root_path_missing { + root_path_missing = true; + self.report_root_moved_or_deleted(&root_path, &error).await; + } + } + } + root_path_check_timer.set(self.executor.timer(ROOT_PATH_CHECK_INTERVAL).fuse()); + } } } } @@ -4746,56 +4770,67 @@ impl BackgroundScanner { events } + /// Called when the worktree root can no longer be canonicalized. Uses the + /// open handle on the root to find where it moved to, and reports the new + /// location (or, for single-file worktrees, the deletion) to the worktree. + async fn report_root_moved_or_deleted( + &self, + root_path: &Arc, + canonicalize_error: &anyhow::Error, + ) { + let new_path = self + .state + .lock() + .await + .snapshot + .root_file_handle + .clone() + .and_then(|handle| match handle.current_path(&self.fs) { + Ok(new_path) => Some(new_path), + Err(e) => { + log::error!("Failed to refresh worktree root path: {e:#}"); + None + } + }) + .map(|path| SanitizedPath::new_arc(&path)) + .filter(|new_path| new_path != root_path); + + if let Some(new_path) = new_path { + log::info!( + "root renamed from {:?} to {:?}", + root_path.as_path(), + new_path.as_path(), + ); + self.status_updates_tx + .unbounded_send(ScanState::RootUpdated { new_path }) + .ok(); + } else { + log::error!("root path could not be canonicalized: {canonicalize_error:#}"); + + // For single-file worktrees, if we can't canonicalize and the file handle + // fallback also failed, the file is gone - close the worktree + if self.is_single_file { + log::info!( + "single-file worktree root {:?} no longer exists, marking as deleted", + root_path.as_path() + ); + self.status_updates_tx + .unbounded_send(ScanState::RootDeleted) + .ok(); + } + } + } + async fn process_events(&self, mut events: Vec) { let root_path = self.state.lock().await.snapshot.abs_path.clone(); - let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await; - let root_canonical_path = match &root_canonical_path { - Ok(path) => SanitizedPath::new(path), - Err(err) => { - let new_path = self - .state - .lock() - .await - .snapshot - .root_file_handle - .clone() - .and_then(|handle| match handle.current_path(&self.fs) { - Ok(new_path) => Some(new_path), - Err(e) => { - log::error!("Failed to refresh worktree root path: {e:#}"); - None - } - }) - .map(|path| SanitizedPath::new_arc(&path)) - .filter(|new_path| *new_path != root_path); - - if let Some(new_path) = new_path { - log::info!( - "root renamed from {:?} to {:?}", - root_path.as_path(), - new_path.as_path(), - ); - self.status_updates_tx - .unbounded_send(ScanState::RootUpdated { new_path }) - .ok(); - } else { - log::error!("root path could not be canonicalized: {err:#}"); - - // For single-file worktrees, if we can't canonicalize and the file handle - // fallback also failed, the file is gone - close the worktree - if self.is_single_file { - log::info!( - "single-file worktree root {:?} no longer exists, marking as deleted", - root_path.as_path() - ); - self.status_updates_tx - .unbounded_send(ScanState::RootDeleted) - .ok(); - } - } + let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { + Ok(path) => path, + Err(error) => { + self.report_root_moved_or_deleted(&root_path, &error).await; return; } }; + let root_canonical_path = SanitizedPath::new(&root_canonical_path); { let state = self.state.lock().await; diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index 7a439125bb..39f840b612 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -1,9 +1,7 @@ use std::path::Path; -use anyhow::Context as _; use settings::{RegisterSetting, ScanSymlinksSetting, Settings}; use util::{ - ResultExt, paths::{PathMatcher, PathStyle}, rel_path::RelPath, }; @@ -61,51 +59,111 @@ impl Settings for WorktreeSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let worktree = content.project.worktree.clone(); let file_scan_exclusions = worktree.file_scan_exclusions.unwrap().0; - let file_scan_inclusions = worktree.file_scan_inclusions.unwrap(); + let file_scan_inclusions = worktree.file_scan_inclusions.unwrap().0; let private_files = worktree.private_files.unwrap().0; - let hidden_files = worktree.hidden_files.unwrap(); - let read_only_files = worktree.read_only_files.unwrap_or_default(); + let hidden_files = worktree.hidden_files.unwrap().0; + let read_only_files = worktree.read_only_files.unwrap_or_default().0; let scan_symlinks = worktree.scan_symlinks.unwrap(); - let parsed_file_scan_inclusions: Vec = file_scan_inclusions - .iter() - .flat_map(|glob| { - Path::new(glob) - .ancestors() - .skip(1) - .map(|a| a.to_string_lossy().into()) - }) - .filter(|p: &String| !p.is_empty()) - .collect(); + let (file_scan_inclusions, parent_dir_scan_inclusions) = + file_scan_inclusion_matchers(file_scan_inclusions); Self { prevent_sharing_in_public_channels: worktree.prevent_sharing_in_public_channels, - file_scan_exclusions: path_matchers(file_scan_exclusions, "file_scan_exclusions") - .log_err() - .unwrap_or_default(), - parent_dir_scan_inclusions: path_matchers( - parsed_file_scan_inclusions, - "file_scan_inclusions", - ) - .unwrap(), - file_scan_inclusions: path_matchers(file_scan_inclusions, "file_scan_inclusions") - .unwrap(), - private_files: path_matchers(private_files, "private_files") - .log_err() - .unwrap_or_default(), - hidden_files: path_matchers(hidden_files, "hidden_files") - .log_err() - .unwrap_or_default(), - read_only_files: path_matchers(read_only_files, "read_only_files") - .log_err() - .unwrap_or_default(), + file_scan_exclusions: valid_path_matchers(file_scan_exclusions, "file_scan_exclusions"), + parent_dir_scan_inclusions, + file_scan_inclusions, + private_files: valid_path_matchers(private_files, "private_files"), + hidden_files: valid_path_matchers(hidden_files, "hidden_files"), + read_only_files: valid_path_matchers(read_only_files, "read_only_files"), scan_symlinks, file_scan_depth: worktree.file_scan_depth.filter(|depth| *depth > 0), } } } -fn path_matchers(mut values: Vec, context: &'static str) -> anyhow::Result { +fn valid_path_matchers(mut values: Vec, context: &'static str) -> PathMatcher { values.sort(); - PathMatcher::new(values, PathStyle::local()) - .with_context(|| format!("Failed to parse globs from {}", context)) + PathMatcher::new_lenient(values, PathStyle::local(), |error| { + log::error!("Failed to compile patterns in `{context}`: {error}"); + }) +} + +fn file_scan_inclusion_matchers(mut values: Vec) -> (PathMatcher, PathMatcher) { + values.sort(); + let mut errors = Vec::new(); + let inclusions = PathMatcher::new_lenient(&values, PathStyle::local(), |error| { + errors.push(error); + }); + let parent_inclusions = values + .iter() + .filter(|pattern| { + !errors.iter().any(|error| { + error + .glob() + .is_none_or(|invalid| invalid == pattern.as_str()) + }) + }) + .flat_map(|pattern| inclusion_parent_patterns(pattern, PathStyle::local())) + .collect(); + for error in errors { + log::error!("Failed to compile patterns in `file_scan_inclusions`: {error}"); + } + ( + inclusions, + valid_path_matchers(parent_inclusions, "file_scan_inclusions"), + ) +} + +fn inclusion_parent_patterns(pattern: &str, path_style: PathStyle) -> Vec { + let mut parents = Vec::new(); + let mut open_braces = 0_usize; + let mut class_start = None; + let mut escaped = false; + for (index, character) in pattern.char_indices() { + let is_separator = path_style.separators_ch().contains(&character); + let mut split_index = index; + if class_start.is_none() { + if escaped { + escaped = false; + if !is_separator { + continue; + } + split_index -= 1; + } else if character == '\\' && path_style.is_posix() { + escaped = true; + continue; + } + } + if let Some(start) = class_start { + let first_content = start + + 1 + + usize::from(matches!( + pattern.as_bytes().get(start + 1), + Some(b'!' | b'^') + )); + if character == ']' && index > first_content { + class_start = None; + } + } else { + match character { + '[' => class_start = Some(index), + '{' => open_braces += 1, + '}' => open_braces = open_braces.saturating_sub(1), + _ => {} + } + } + if is_separator { + let closing_braces = "}".repeat(open_braces); + if let Some(start) = class_start { + parents.push(format!("{}*{closing_braces}", &pattern[..start])); + } else if open_braces > 0 { + parents.push(format!("{}{closing_braces}", &pattern[..split_index])); + } else if split_index > 0 { + parents.push(pattern[..split_index].to_string()); + } + } + } + parents.sort(); + parents.dedup(); + parents } diff --git a/crates/worktree/tests/integration/worktree_settings_tests.rs b/crates/worktree/tests/integration/worktree_settings_tests.rs index 993a095e41..7fc96fcf7e 100644 --- a/crates/worktree/tests/integration/worktree_settings_tests.rs +++ b/crates/worktree/tests/integration/worktree_settings_tests.rs @@ -1,7 +1,9 @@ +use serde_json::json; +use settings::{MergeFromTrait as _, RootUserSettings as _, Settings, SettingsContent}; use std::path::Path; use util::{ paths::{PathMatcher, PathStyle}, - rel_path::RelPath, + rel_path::{RelPath, rel_path}, }; use worktree::*; @@ -23,6 +25,215 @@ fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings { } } +fn settings_with_patterns(setting: &str, patterns: &[&str]) -> WorktreeSettings { + zlog::init_test(); + let mut content = SettingsContent::parse_json_with_comments(&settings::default_settings()) + .expect("default settings"); + let inherited: SettingsContent = + serde_json::from_value(json!({ setting: ["**/.git"] })).expect("inherited settings"); + content.merge_from(&inherited); + let overrides: SettingsContent = + serde_json::from_value(json!({ setting: patterns })).expect("override settings"); + content.merge_from(&overrides); + WorktreeSettings::from_settings(&content) +} + +fn assert_file_pattern_settings(setting: &str) { + for (patterns, expected) in [ + (vec!["**/alpha/**", "zeta/**"], [false, true, true]), + (vec!["**/alpha/**", "[", "zeta/**"], [false, true, true]), + (vec!["..."], [true, false, false]), + (vec!["...", "["], [true, false, false]), + ( + vec!["**/alpha/**", "...", "[", "zeta/**"], + [true, true, true], + ), + (vec!["[", "{"], [false, false, false]), + (vec![], [false, false, false]), + ] { + let settings = settings_with_patterns(setting, &patterns); + let matcher = match setting { + "file_scan_exclusions" => &settings.file_scan_exclusions, + "file_scan_inclusions" => &settings.file_scan_inclusions, + "hidden_files" => &settings.hidden_files, + "read_only_files" => &settings.read_only_files, + _ => unreachable!(), + }; + for (path, expected) in [".git", "alpha/file.txt", "zeta/file.txt"] + .into_iter() + .zip(expected) + { + assert_eq!( + matcher.is_match(rel_path(path)), + expected, + "{setting}: {patterns:?} matching {path}" + ); + } + assert!(!matcher.is_match(rel_path("unmatched/file.txt"))); + } +} + +#[test] +fn test_invalid_file_scan_exclusions() { + assert_file_pattern_settings("file_scan_exclusions"); +} + +#[test] +fn test_invalid_exclusion_addition_preserves_git() { + let settings = settings_with_patterns("file_scan_exclusions", &["...", "["]); + assert!(settings.is_path_excluded(rel_path(".git/config"))); + assert!(settings.is_path_excluded(rel_path("nested/.git/config"))); +} + +#[test] +fn test_invalid_file_scan_inclusions() { + assert_file_pattern_settings("file_scan_inclusions"); +} + +#[test] +fn test_invalid_hidden_files() { + assert_file_pattern_settings("hidden_files"); +} + +#[test] +fn test_invalid_read_only_files() { + assert_file_pattern_settings("read_only_files"); +} + +#[test] +fn test_invalid_direct_inclusions_do_not_include_parents() { + let settings = + settings_with_patterns("file_scan_inclusions", &["ignored/[", "valid/nested/**"]); + assert!(!settings.is_path_always_included(rel_path("ignored"), true)); + assert!(!settings.is_path_always_included(rel_path("ignored/file.txt"), false)); + assert!(settings.is_path_always_included(rel_path("valid"), true)); + assert!(settings.is_path_always_included(rel_path("valid/nested"), true)); + assert!(settings.is_path_always_included(rel_path("valid/nested/file.txt"), false)); +} + +#[test] +fn test_inclusions_preserve_brace_patterns() { + for (pattern, files) in [ + ( + "ignored/{one/two,three}/file.rs", + ["ignored/one/two/file.rs", "ignored/three/file.rs"], + ), + ( + "{one/two,three}/file.rs", + ["one/two/file.rs", "three/file.rs"], + ), + ] { + assert!(PathMatcher::new([pattern], PathStyle::local()).is_ok()); + let settings = + settings_with_patterns("file_scan_inclusions", &[pattern, "valid/nested/**"]); + for file in files { + assert!(settings.is_path_always_included(rel_path(file), false)); + for parent in rel_path(file) + .ancestors() + .skip(1) + .filter(|path| !path.is_empty()) + { + assert!( + settings.is_path_always_included(parent, true), + "{pattern}: missing ancestor {parent:?}" + ); + } + } + assert!(settings.is_path_always_included(rel_path("valid"), true)); + assert!(settings.is_path_always_included(rel_path("valid/nested/file.txt"), false)); + assert!(!settings.is_path_always_included(rel_path("unmatched/file.txt"), false)); + assert!( + !settings.is_path_always_included(rel_path("unmatched"), true), + "{pattern}: unexpected ancestor" + ); + } +} + +#[test] +fn test_inclusion_parents_close_nested_braces() { + let settings = settings_with_patterns("file_scan_inclusions", &["{a/{b,c},d}/file.rs"]); + for directory in ["a", "a/b", "a/c", "d"] { + assert!( + settings.is_path_always_included(rel_path(directory), true), + "missing ancestor {directory}" + ); + } + for directory in ["b", "c", "unmatched"] { + assert!( + !settings.is_path_always_included(rel_path(directory), true), + "unexpected ancestor {directory}" + ); + } + assert!(settings.is_path_always_included(rel_path("a/b/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("d/file.rs"), false)); +} + +#[test] +fn test_inclusion_parents_close_braces_around_character_classes() { + let settings = settings_with_patterns("file_scan_inclusions", &["{a,[x/y]z}/file.rs"]); + assert!(settings.is_path_always_included(rel_path("a"), true)); + assert!(settings.is_path_always_included(rel_path("xz"), true)); + assert!(settings.is_path_always_included(rel_path("a/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("xz/file.rs"), false)); +} + +#[test] +fn test_inclusion_parents_preserve_literal_braces() { + let mut patterns = vec!["ignored/[{]one/file.rs"]; + if PathStyle::local().is_posix() { + patterns.push(r"ignored/\{one/file.rs"); + } + for pattern in patterns { + let settings = settings_with_patterns("file_scan_inclusions", &[pattern]); + assert!(settings.is_path_always_included(rel_path("ignored/{one/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("ignored/{one"), true)); + assert!(settings.is_path_always_included(rel_path("ignored"), true)); + assert!(!settings.is_path_always_included(rel_path("ignored/other/file.rs"), false)); + } +} + +#[cfg(not(windows))] +#[test] +fn test_inclusion_parents_preserve_escaping() { + let settings = settings_with_patterns("file_scan_inclusions", &[r"{one\/two,three}/file.rs"]); + assert!(settings.is_path_always_included(rel_path("one/two/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("one/two"), true)); + assert!(settings.is_path_always_included(rel_path("one"), true)); + + let settings = settings_with_patterns("file_scan_inclusions", &[r"ignored\/nested/file.rs"]); + assert!(settings.is_path_always_included(rel_path("ignored/nested/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("ignored/nested"), true)); + assert!(settings.is_path_always_included(rel_path("ignored"), true)); +} + +#[cfg(not(windows))] +#[test] +fn test_inclusion_parents_preserve_character_classes() { + let settings = settings_with_patterns("file_scan_inclusions", &[r"[a\]cache/file.rs"]); + assert!(settings.is_path_always_included(rel_path("acache/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("acache"), true)); + assert!(!settings.is_path_always_included(rel_path("unmatched"), true)); +} + +#[test] +fn test_private_files_preserves_valid_patterns() { + let settings = settings_with_patterns("private_files", &["**/private/**"]); + assert!(settings.is_path_private(rel_path("private/file.txt"))); + assert!(settings.is_path_private(rel_path(".env.local"))); + + let settings = settings_with_patterns("private_files", &["**/private/**", "["]); + assert!(settings.is_path_private(rel_path("private/file.txt"))); + assert!(settings.is_path_private(rel_path(".env.local"))); + assert!(!settings.is_path_private(rel_path("src/main.rs"))); + assert!(PathMatcher::new(["**/private/**", "["], PathStyle::local()).is_err()); + + for patterns in [&["["][..], &[][..]] { + let settings = settings_with_patterns("private_files", patterns); + assert!(settings.is_path_private(rel_path(".env.local"))); + assert!(!settings.is_path_private(rel_path("private/file.txt"))); + } +} + #[test] fn test_is_path_read_only_with_glob_patterns() { let settings = make_settings_with_read_only(&["**/generated/**", "**/*.gen.rs"]); diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index be84ed1436..ad23eab8b6 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -1208,7 +1208,7 @@ async fn test_real_fs_scan_symlinks_always(cx: &mut TestAppContext) { let tree = Worktree::local( project_root.as_path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -1270,7 +1270,7 @@ async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) { let tree = Worktree::local( project_root.as_path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -1435,7 +1435,7 @@ async fn test_renaming_case_only(cx: &mut TestAppContext) { const OLD_NAME: &str = "aaa.rs"; const NEW_NAME: &str = "AAA.rs"; - let fs = Arc::new(RealFs::new(None, cx.executor())); + let fs = RealFs::new(None, cx.executor()); let temp_root = TempTree::new(json!({ OLD_NAME: "", })); @@ -1553,9 +1553,7 @@ async fn test_root_rescan_reconciles_stale_state(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_root_rescan_does_not_miss_event_before_readding_root_watcher( - cx: &mut TestAppContext, -) { +async fn test_root_rescan_keeps_root_watcher_registered(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree("/root", json!({})).await; @@ -1575,16 +1573,17 @@ async fn test_root_rescan_does_not_miss_event_before_readding_root_watcher( cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) .await; - fs.create_file_before_next_watch_add("/root", "/root/created-before-root-readd.txt"); + // Dropping and re-registering the root watch would open a window in + // which filesystem events are lost. fs.emit_fs_event("/root", Some(PathEventKind::Rescan)); + tree.flush_fs_events(cx).await; - wait_for_condition(cx, |cx| { - tree.read_with(cx, |tree, _| { - tree.entry_for_path(rel_path("created-before-root-readd.txt")) - .is_some() - }) - }) - .await; + let root_watch_calls = fs + .watch_calls() + .into_iter() + .filter(|path| path == Path::new("/root")) + .count(); + assert_eq!(root_watch_calls, 1); } #[gpui::test] @@ -1983,7 +1982,7 @@ async fn test_write_file(cx: &mut TestAppContext) { let worktree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -1992,9 +1991,6 @@ async fn test_write_file(cx: &mut TestAppContext) { .await .unwrap(); - #[cfg(not(target_os = "macos"))] - fs::fs_watcher::global(|_| {}).unwrap(); - cx.read(|cx| worktree.read(cx).as_local().unwrap().scan_complete()) .await; worktree.flush_fs_events(cx).await; @@ -2071,10 +2067,10 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![])); - settings.project.worktree.file_scan_inclusions = Some(vec![ - "node_modules/**/package.json".to_string(), + settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![ "**/.DS_Store".to_string(), - ]); + "node_modules/**/package.json".to_string(), + ])); }); }); }); @@ -2082,7 +2078,7 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2112,6 +2108,184 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext) { + init_test(cx); + let worktree_id = WorktreeId::from_proto(0); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![ + "...".to_string(), + "[".to_string(), + "ignored/**/excluded.project".to_string(), + ])); + settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![ + "...".to_string(), + "ignored/**/*.user".to_string(), + ])); + }); + store + .set_local_settings( + worktree_id, + LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())), + LocalSettingsKind::Settings, + Some(r#"{ "file_scan_inclusions": ["...", "ignored/**/*.project"] }"#), + cx, + ) + .expect("valid project settings"); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".env.local": "", + ".git": {}, + ".gitignore": ".env*\n/ignored/\n/one/\n/three/\nunmatched.txt\n", + "ignored": { + "nested": { + "excluded.project": "", + "included.project": "", + "included.user": "", + "unmatched.txt": "" + } + }, + "one": { "two": { "file.rs": "" } }, + "three": { "file.rs": "" }, + "unmatched.txt": "" + }), + ) + .await; + + let tree = build_worktree(fs, path!("/root"), cx).await; + tree.read_with(cx, |tree, _| { + for path in [ + ".env.local", + "ignored", + "ignored/nested", + "ignored/nested/included.project", + "ignored/nested/included.user", + ] { + assert!( + tree.entry_for_path(rel_path(path)) + .is_some_and(|entry| entry.is_ignored && entry.is_always_included), + "expected {path} to be ignored by Git and included by settings" + ); + } + for path in ["ignored/nested/unmatched.txt", "unmatched.txt"] { + assert!( + tree.entry_for_path(rel_path(path)) + .is_some_and(|entry| entry.is_ignored && !entry.is_always_included), + "expected {path} to remain ignored" + ); + } + assert_eq!(tree.entry_for_path(rel_path(".git")), None); + assert_eq!( + tree.entry_for_path(rel_path("ignored/nested/excluded.project")), + None + ); + assert_eq!( + tree.files(false, 0) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + rel_path(".env.local"), + rel_path(".gitignore"), + rel_path("ignored/nested/included.project"), + rel_path("ignored/nested/included.user"), + ] + ); + }); + + let ignored_directories = ["ignored", "ignored/nested", "one", "one/two", "three"]; + for (settings, included_paths, always_included_directories) in [ + ( + r#"{ "file_scan_inclusions": ["...", "ignored/**/*.project", "{one/two,three}/file.rs", "invalid/["] }"#, + vec![ + rel_path(".env.local"), + rel_path(".gitignore"), + rel_path("ignored/nested/included.project"), + rel_path("ignored/nested/included.user"), + rel_path("one/two/file.rs"), + rel_path("three/file.rs"), + ], + &ignored_directories[..], + ), + ( + r#"{ "file_scan_inclusions": ["{one/two,three}/file.rs"] }"#, + vec![ + rel_path(".gitignore"), + rel_path("one/two/file.rs"), + rel_path("three/file.rs"), + ], + &["one", "one/two", "three"][..], + ), + ( + r#"{ "file_scan_inclusions": ["ignored/{nested,missing}/included.project"] }"#, + vec![ + rel_path(".gitignore"), + rel_path("ignored/nested/included.project"), + ], + &["ignored", "ignored/nested"][..], + ), + ( + r#"{ "file_scan_inclusions": ["ignored/**/*.project"] }"#, + vec![ + rel_path(".gitignore"), + rel_path("ignored/nested/included.project"), + ], + &["ignored", "ignored/nested"][..], + ), + ( + r#"{ "file_scan_inclusions": ["invalid/["] }"#, + vec![rel_path(".gitignore")], + &[][..], + ), + ( + r#"{ "file_scan_inclusions": [] }"#, + vec![rel_path(".gitignore")], + &[][..], + ), + ] { + cx.update(|cx| { + cx.update_global::(|store, cx| { + store + .set_local_settings( + worktree_id, + LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())), + LocalSettingsKind::Settings, + Some(settings), + cx, + ) + .expect("valid project settings"); + }); + }); + cx.run_until_parked(); + tree.read_with(cx, |tree, _| { + tree.as_local().expect("local worktree").scan_complete() + }) + .await; + tree.read_with(cx, |tree, _| { + for path in ignored_directories { + assert_eq!( + tree.entry_for_path(rel_path(path)) + .is_some_and(|entry| entry.is_always_included), + always_included_directories.contains(&path), + "ancestor inclusion for {path} with {settings}" + ); + } + assert_eq!( + tree.files(false, 0) + .map(|entry| entry.path.as_ref()) + .collect::>(), + included_paths + ); + }); + } +} + #[gpui::test] async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) { init_test(cx); @@ -2143,7 +2317,7 @@ async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec!["**/.DS_Store".to_string()])); settings.project.worktree.file_scan_inclusions = - Some(vec!["**/.DS_Store".to_string()]); + Some(SplicingVec::from(vec!["**/.DS_Store".to_string()])); }); }); }); @@ -2151,7 +2325,7 @@ async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2206,14 +2380,14 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![])); settings.project.worktree.file_scan_inclusions = - Some(vec!["node_modules/**".to_string()]); + Some(SplicingVec::from(vec!["node_modules/**".to_string()])); }); }); }); let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2240,7 +2414,7 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![])); - settings.project.worktree.file_scan_inclusions = Some(vec![]); + settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![])); }); }); }); @@ -2302,7 +2476,7 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2389,7 +2563,7 @@ async fn test_hidden_files(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2425,7 +2599,8 @@ async fn test_hidden_files(cx: &mut TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.hidden_files = Some(vec!["**/*.log".to_string()]); + settings.project.worktree.hidden_files = + Some(SplicingVec::from(vec!["**/*.log".to_string()])); }); }); }); @@ -2454,6 +2629,124 @@ async fn test_hidden_files(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_hidden_files_from_project_settings(cx: &mut TestAppContext) { + init_test(cx); + let worktree_id = WorktreeId::from_proto(0); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".hidden_dir": { + "nested.rs": "" + }, + ".hidden_file": "", + "app.log": "", + "generated": { + "nested.rs": "" + }, + "visible.rs": "" + }), + ) + .await; + let tree = build_worktree(fs, path!("/root"), cx).await; + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .filter(|entry| entry.is_hidden) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + rel_path(".hidden_dir"), + rel_path(".hidden_dir/nested.rs"), + rel_path(".hidden_file"), + ] + ); + }); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.hidden_files = Some(SplicingVec::from(vec![ + "**/*.log".to_string(), + SplicingVec::REST.to_string(), + ])); + }); + }); + }); + + for (settings, hidden_paths) in [ + ( + None, + vec![ + rel_path(".hidden_dir"), + rel_path(".hidden_dir/nested.rs"), + rel_path(".hidden_file"), + rel_path("app.log"), + ], + ), + ( + Some(r#"{ "hidden_files": ["**/generated", "..."] }"#), + vec![ + rel_path(".hidden_dir"), + rel_path(".hidden_dir/nested.rs"), + rel_path(".hidden_file"), + rel_path("app.log"), + rel_path("generated"), + rel_path("generated/nested.rs"), + ], + ), + ( + Some(r#"{ "hidden_files": ["**/generated"] }"#), + vec![rel_path("generated"), rel_path("generated/nested.rs")], + ), + (Some(r#"{ "hidden_files": [] }"#), vec![]), + ] { + cx.update(|cx| { + cx.update_global::(|store, cx| { + store + .set_local_settings( + worktree_id, + LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())), + LocalSettingsKind::Settings, + settings, + cx, + ) + .expect("valid project settings"); + }); + }); + cx.run_until_parked(); + tree.read_with(cx, |tree, _| { + tree.as_local().expect("local worktree").scan_complete() + }) + .await; + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .filter(|entry| entry.is_hidden) + .map(|entry| entry.path.as_ref()) + .collect::>(), + hidden_paths + ); + assert_eq!( + tree.entries(true, 0) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + rel_path(""), + rel_path(".hidden_dir"), + rel_path(".hidden_dir/nested.rs"), + rel_path(".hidden_file"), + rel_path("app.log"), + rel_path("generated"), + rel_path("generated/nested.rs"), + rel_path("visible.rs"), + ] + ); + }); + } +} + #[gpui::test] async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { init_test(cx); @@ -2501,7 +2794,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2618,7 +2911,7 @@ async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) { let tree = Worktree::local( dot_git_worktree_dir.clone(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2775,7 +3068,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { assert!(tree.entry_for_path(rel_path("a/b")).unwrap().is_dir()); }); - let fs_real = Arc::new(RealFs::new(None, cx.executor())); + let fs_real = RealFs::new(None, cx.executor()); let temp_root = TempTree::new(json!({ "a": {} })); @@ -5425,7 +5718,7 @@ async fn test_ref_updates_in_dot_git_subdirectories_are_detected(cx: &mut TestAp let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -5949,6 +6242,70 @@ async fn test_single_file_worktree_deleted(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_root_ancestor_rename_is_detected_without_fs_events(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/code"), + json!({ + "project": { + "src": { + "main.rs": "fn main() {}", + }, + }, + }), + ) + .await; + + let tree = Worktree::local( + Path::new(path!("/code/project")), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + // Renaming an ancestor of the root produces no event under the watched + // path, so only the periodic root check can notice it. + fs.rename( + Path::new(path!("/code")), + Path::new(path!("/src")), + Default::default(), + ) + .await + .unwrap(); + cx.background_executor.run_until_parked(); + tree.read_with(cx, |tree, _| { + assert_eq!(tree.abs_path().as_ref(), Path::new(path!("/code/project"))); + }); + + cx.background_executor + .advance_clock(worktree::ROOT_PATH_CHECK_INTERVAL); + cx.background_executor.run_until_parked(); + + tree.read_with(cx, |tree, _| { + assert_eq!(tree.abs_path().as_ref(), Path::new(path!("/src/project"))); + assert_eq!(tree.root_name(), "project"); + assert_eq!( + tree.entries(false, 0) + .map(|entry| entry.path.clone()) + .collect::>(), + vec![ + rel_path("").into(), + rel_path("src").into(), + rel_path("src/main.rs").into(), + ] + ); + }); +} + #[gpui::test] async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_update( cx: &mut TestAppContext, @@ -6758,7 +7115,7 @@ async fn test_file_scan_depth_pierced_by_inclusions(cx: &mut TestAppContext) { store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_depth = Some(1); settings.project.worktree.file_scan_inclusions = - Some(vec!["junk/a/b/c/deep.txt".to_string()]); + Some(SplicingVec::from(vec!["junk/a/b/c/deep.txt".to_string()])); }); }); }); diff --git a/crates/worktree_benchmarks/src/main.rs b/crates/worktree_benchmarks/src/main.rs index 079867b5e8..87fa16f1c4 100644 --- a/crates/worktree_benchmarks/src/main.rs +++ b/crates/worktree_benchmarks/src/main.rs @@ -18,7 +18,7 @@ fn main() { app.run(|cx| { settings::init(cx); - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); cx.spawn(async move |cx| { let worktree = Worktree::local( diff --git a/crates/x_ai/Cargo.toml b/crates/x_ai/Cargo.toml index 8ff020df8c..bf6a463149 100644 --- a/crates/x_ai/Cargo.toml +++ b/crates/x_ai/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/x_ai.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/x_ai_subscribed/Cargo.toml b/crates/x_ai_subscribed/Cargo.toml new file mode 100644 index 0000000000..e1d1420952 --- /dev/null +++ b/crates/x_ai_subscribed/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "x_ai_subscribed" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/x_ai_subscribed.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +base64.workspace = true +credentials_provider.workspace = true +futures.workspace = true +gpui.workspace = true +http_client.workspace = true +language_model.workspace = true +log.workspace = true +oauth_callback_server.workspace = true +open_ai.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +smol.workspace = true +url.workspace = true +util.workspace = true +x_ai.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } +parking_lot.workspace = true diff --git a/crates/x_ai_subscribed/LICENSE-GPL b/crates/x_ai_subscribed/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/x_ai_subscribed/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/x_ai_subscribed/src/x_ai_subscribed.rs b/crates/x_ai_subscribed/src/x_ai_subscribed.rs new file mode 100644 index 0000000000..e5a9599460 --- /dev/null +++ b/crates/x_ai_subscribed/src/x_ai_subscribed.rs @@ -0,0 +1,1507 @@ +use anyhow::{Context as _, Result, anyhow}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use credentials_provider::CredentialsProvider; +use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared}; +use gpui::{App, AsyncApp, Context, Entity, SharedString, Task, WeakEntity}; +use http_client::{AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest}; +use language_model::chat_completion::ChatCompletionEventMapper; +use language_model::{ + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, + ProviderErrorCategory, RateLimiter, +}; +use open_ai::{ReasoningEffort, ResponseStreamEvent}; +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use url::form_urlencoded; +use util::ResultExt as _; + +pub const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai_subscribed"); +pub const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("SuperGrok"); + +pub use x_ai::XAI_API_URL; +const XAI_AUTHORIZE_URL: &str = "https://auth.x.ai/oauth2/authorize"; +const XAI_TOKEN_URL: &str = "https://auth.x.ai/oauth2/token"; +const CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; +const OAUTH_SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; + +/// Keychain slot. Must not collide with the xAI API-key provider (`https://api.x.ai/v1`). +const CREDENTIALS_KEY: &str = "https://auth.x.ai/zed-supergrok"; +const TOKEN_REFRESH_BUFFER_MS: u64 = Duration::from_secs(120).as_millis() as u64; + +const CALLBACK_HOST: &str = "127.0.0.1"; +const CALLBACK_PORT: u16 = 56121; +const CALLBACK_PATH: &str = "/callback"; + +const INFERENCE_FORBIDDEN_MESSAGE: &str = "Login succeeded, but this Grok account cannot use the API \ + (HTTP 403). Some plans do not include this access. You can also use the separate xAI provider \ + with an API key from console.x.ai."; + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct SuperGrokCredentials { + access_token: String, + refresh_token: String, + expires_at_ms: u64, + email: Option, +} + +impl SuperGrokCredentials { + fn is_expired(&self) -> bool { + now_ms() + TOKEN_REFRESH_BUFFER_MS >= self.expires_at_ms + } +} + +pub struct State { + credentials: Option, + sign_in_task: Option>>, + refresh_task: Option>>>>, + load_task: Option>>>>, + credentials_provider: Arc, + http_client: Arc, + auth_generation: u64, + last_auth_error: Option, +} + +#[derive(Debug)] +enum RefreshError { + Fatal(anyhow::Error), + Transient(anyhow::Error), +} + +impl std::fmt::Display for RefreshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RefreshError::Fatal(error) => write!(f, "{error}"), + RefreshError::Transient(error) => write!(f, "{error}"), + } + } +} + +impl State { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut Context, + ) -> Self { + let load_task = cx + .spawn({ + let credentials_provider = credentials_provider.clone(); + async move |this, cx| { + let result = credentials_provider + .read_credentials(CREDENTIALS_KEY, cx) + .await; + this.update(cx, |state, cx| { + match result { + Ok(Some((_, bytes))) => { + match serde_json::from_slice::(&bytes) { + Ok(credentials) => { + state.auth_generation = + state.auth_generation.wrapping_add(1); + state.credentials = Some(credentials); + } + Err(error) => { + log::warn!( + "Failed to deserialize SuperGrok credentials: {error}" + ); + } + } + } + Ok(None) => {} + Err(error) => { + log::error!("Failed to load SuperGrok credentials: {error:#}"); + } + } + state.load_task = None; + cx.notify(); + })?; + Ok::<(), Arc>(()) + } + }) + .shared(); + + Self { + credentials: None, + sign_in_task: None, + refresh_task: None, + load_task: Some(load_task), + credentials_provider, + http_client, + auth_generation: 0, + last_auth_error: None, + } + } + + pub fn is_authenticated(&self) -> bool { + self.credentials.is_some() + } + + pub fn email(&self) -> Option<&str> { + self.credentials.as_ref().and_then(|c| c.email.as_deref()) + } + + pub fn is_signing_in(&self) -> bool { + self.sign_in_task.is_some() + } + + pub fn last_auth_error(&self) -> Option { + self.last_auth_error.clone() + } + + pub fn load_task(&self) -> Option>>>> { + self.load_task.clone() + } + + pub fn http_client(&self) -> Arc { + self.http_client.clone() + } + + pub fn sign_in(&mut self, cx: &mut Context) { + if self.is_signing_in() { + return; + } + + let http_client = self.http_client.clone(); + let task = cx.spawn(async move |this, cx| { + match do_oauth_flow(http_client, cx).await { + Ok(creds) => { + let persist_result = async { + let credentials_provider = + this.read_with(cx, |state, _| state.credentials_provider.clone())?; + let json = serde_json::to_vec(&creds)?; + credentials_provider + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, cx) + .await?; + anyhow::Ok(()) + } + .await; + + match persist_result { + Ok(()) => { + this.update(cx, |state, cx| { + state.auth_generation = state.auth_generation.wrapping_add(1); + state.credentials = Some(creds); + state.last_auth_error = None; + state.sign_in_task = None; + cx.notify(); + })?; + } + Err(err) => { + log::error!("SuperGrok sign-in failed to persist credentials: {err:?}"); + this.update(cx, |state, cx| { + state.sign_in_task = None; + state.last_auth_error = + Some("Failed to save credentials. Please try again.".into()); + cx.notify(); + }) + .log_err(); + } + } + } + Err(err) => { + log::error!("SuperGrok sign-in failed: {err:?}"); + this.update(cx, |state, cx| { + state.sign_in_task = None; + state.last_auth_error = Some("Sign-in failed. Please try again.".into()); + cx.notify(); + }) + .log_err(); + } + } + anyhow::Ok(()) + }); + + self.last_auth_error = None; + self.sign_in_task = Some(task); + cx.notify(); + } + + pub fn sign_out(&mut self, cx: &mut Context) -> Task> { + self.auth_generation += 1; + self.credentials = None; + self.sign_in_task = None; + self.refresh_task = None; + self.last_auth_error = None; + cx.notify(); + + let credentials_provider = self.credentials_provider.clone(); + cx.spawn(async move |_this, cx| { + credentials_provider + .delete_credentials(CREDENTIALS_KEY, cx) + .await + .context("Failed to delete SuperGrok credentials from keychain")?; + anyhow::Ok(()) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SuperGrokModel { + Grok46, + Grok45, + GrokBuild01, +} + +impl SuperGrokModel { + pub fn all() -> Vec { + vec![Self::Grok46, Self::Grok45, Self::GrokBuild01] + } + + fn x_ai_model(&self) -> Option { + match self { + Self::Grok46 => Some(x_ai::Model::Grok46), + Self::Grok45 => Some(x_ai::Model::Grok45), + // grok-build-0.1 is SuperGrok-only; it is not in the BYOK catalog. + Self::GrokBuild01 => None, + } + } + + pub fn id(&self) -> &str { + match self { + Self::Grok46 => "grok-4.6", + Self::Grok45 => "grok-4.5", + Self::GrokBuild01 => "grok-build-0.1", + } + } + + pub fn display_name(&self) -> &str { + match self { + Self::Grok46 => "Grok 4.6", + Self::Grok45 => "Grok 4.5", + Self::GrokBuild01 => "Grok Build 0.1", + } + } + + pub fn max_token_count(&self) -> u64 { + self.x_ai_model() + .map(|model| model.max_token_count()) + .unwrap_or(256_000) + } + + pub fn max_output_tokens(&self) -> Option { + self.x_ai_model() + .map(|model| model.max_output_tokens()) + .unwrap_or(Some(64_000)) + } + + pub fn supports_images(&self) -> bool { + self.x_ai_model() + .map(|model| model.supports_images()) + .unwrap_or(true) + } + + pub fn supports_tools(&self) -> bool { + self.x_ai_model() + .map(|model| model.supports_tool()) + .unwrap_or(true) + } + + pub fn supports_parallel_tool_calls(&self) -> bool { + self.x_ai_model() + .map(|model| model.supports_parallel_tool_calls()) + .unwrap_or(true) + } + + pub fn supports_reasoning_effort(&self) -> bool { + self.x_ai_model() + .map(|model| model.supports_reasoning_effort()) + .unwrap_or(false) + } +} + +pub fn create_language_model( + model: SuperGrokModel, + state: &Entity, + cx: &App, +) -> Arc { + Arc::new(SuperGrokLanguageModel { + id: LanguageModelId::from(model.id().to_string()), + http_client: state.read(cx).http_client(), + model, + state: state.clone(), + api_url: XAI_API_URL.into(), + extra_headers: CustomHeaders::default(), + request_limiter: RateLimiter::new(4), + }) +} + +struct SuperGrokLanguageModel { + id: LanguageModelId, + model: SuperGrokModel, + state: Entity, + http_client: Arc, + api_url: SharedString, + extra_headers: CustomHeaders, + request_limiter: RateLimiter, +} + +fn advertised_reasoning_efforts(model: &SuperGrokModel) -> &'static [ReasoningEffort] { + // xAI rejects `reasoning_effort: "none"` on grok-4.5/4.6. Compact and title + // requests disable thinking, so we omit the field instead of sending none. + match model.x_ai_model() { + Some(x_ai::Model::Grok45) => &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + Some(x_ai::Model::Grok46) => &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ], + _ => &[], + } +} + +fn default_thinking_reasoning_effort(model: &SuperGrokModel) -> Option { + match model.x_ai_model() { + Some(x_ai::Model::Grok45 | x_ai::Model::Grok46) => Some(ReasoningEffort::High), + _ => None, + } +} + +fn reasoning_effort_for_request( + request: &LanguageModelRequest, + model: &SuperGrokModel, +) -> Option { + let supported_efforts = advertised_reasoning_efforts(model); + if supported_efforts.is_empty() { + return None; + } + + if request.thinking_allowed { + request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .filter(|effort| supported_efforts.contains(effort)) + .filter(|effort| *effort != ReasoningEffort::None) + .or_else(|| default_thinking_reasoning_effort(model)) + } else { + None + } +} + +fn supported_thinking_effort_levels(model: &SuperGrokModel) -> Vec { + let default_effort = default_thinking_reasoning_effort(model); + advertised_reasoning_efforts(model) + .iter() + .copied() + .filter_map(|effort| { + let (name, value) = match effort { + ReasoningEffort::None => return None, + ReasoningEffort::Minimal => ("Minimal", "minimal"), + ReasoningEffort::Low => ("Low", "low"), + ReasoningEffort::Medium => ("Medium", "medium"), + ReasoningEffort::High => ("High", "high"), + ReasoningEffort::XHigh => ("Extra High", "xhigh"), + ReasoningEffort::Max => return None, + }; + + Some(LanguageModelEffortLevel { + name: name.into(), + value: value.into(), + is_default: Some(effort) == default_effort, + }) + }) + .collect() +} + +fn map_completion_error(error: LanguageModelCompletionError) -> LanguageModelCompletionError { + match error { + LanguageModelCompletionError::ProviderRejection { + provider, + status, + code, + retry_after, + category: ProviderErrorCategory::Permission, + .. + } if provider == PROVIDER_NAME => LanguageModelCompletionError::ProviderRejection { + provider, + status, + code, + message: INFERENCE_FORBIDDEN_MESSAGE.to_string(), + retry_after, + category: ProviderErrorCategory::Permission, + }, + other => other, + } +} + +impl SuperGrokLanguageModel { + fn stream_open_ai_completion( + &self, + request: open_ai::Request, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + futures::stream::BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let http_client = self.http_client.clone(); + let api_url = self.api_url.clone(); + let extra_headers = self.extra_headers.clone(); + let state = self.state.downgrade(); + let request_limiter = self.request_limiter.clone(); + + let future = cx.spawn(async move |cx| { + let credentials = get_fresh_credentials(&state, &http_client, cx).await?; + let access_token = credentials.access_token.clone(); + request_limiter + .stream(async move { + open_ai::stream_completion( + http_client.as_ref(), + PROVIDER_NAME.0.as_str(), + api_url.as_ref(), + &access_token, + request, + &extra_headers, + ) + .await + .map_err(|error| { + map_completion_error(LanguageModelCompletionError::from(error)) + }) + }) + .await + }); + + async move { Ok(future.await?.boxed()) }.boxed() + } +} + +impl LanguageModel for SuperGrokLanguageModel { + fn id(&self) -> LanguageModelId { + self.id.clone() + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name().to_string()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + PROVIDER_ID + } + + fn provider_name(&self) -> LanguageModelProviderName { + PROVIDER_NAME + } + + fn supports_tools(&self) -> bool { + self.model.supports_tools() + } + + fn supports_images(&self) -> bool { + self.model.supports_images() + } + + fn supports_streaming_tools(&self) -> bool { + true + } + + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { + match choice { + LanguageModelToolChoice::Auto + | LanguageModelToolChoice::Any + | LanguageModelToolChoice::None => true, + } + } + + fn supports_thinking(&self) -> bool { + self.model.supports_reasoning_effort() + } + + fn supported_effort_levels(&self) -> Vec { + supported_thinking_effort_levels(&self.model) + } + + fn telemetry_id(&self) -> String { + format!("x_ai_subscribed/{}", self.model.id()) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn max_output_tokens(&self) -> Option { + self.model.max_output_tokens() + } + + fn supports_split_token_display(&self) -> bool { + true + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + futures::stream::BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, + > { + let reasoning_effort = reasoning_effort_for_request(&request, &self.model); + let request = match open_ai::completion::into_open_ai( + request, + self.model.id(), + self.model.supports_parallel_tool_calls(), + false, + self.max_output_tokens(), + open_ai::completion::ChatCompletionMaxTokensParameter::MaxCompletionTokens, + reasoning_effort, + false, + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; + let completions = self.stream_open_ai_completion(request, cx); + let executor = cx.background_executor().clone(); + async move { + let mapper = ChatCompletionEventMapper::new(); + Ok(language_model::stream_in_background( + mapper.map_stream(completions.await?).boxed(), + executor, + )) + } + .boxed() + } +} + +async fn get_fresh_credentials( + state: &WeakEntity, + http_client: &Arc, + cx: &mut AsyncApp, +) -> Result { + let (creds, existing_task) = state + .read_with(&*cx, |s, _| (s.credentials.clone(), s.refresh_task.clone())) + .map_err(LanguageModelCompletionError::Other)?; + + let creds = creds.ok_or(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + })?; + + if !creds.is_expired() { + return Ok(creds); + } + + if let Some(shared_task) = existing_task { + return shared_task + .await + .map_err(|e| LanguageModelCompletionError::Other(anyhow!("{e}"))); + } + + let http_client_clone = http_client.clone(); + let state_clone = state.clone(); + let previous_refresh_token = creds.refresh_token.clone(); + let previous_email = creds.email.clone(); + + let generation = state + .read_with(&*cx, |s, _| s.auth_generation) + .map_err(LanguageModelCompletionError::Other)?; + + let shared_task = cx + .spawn(async move |cx| { + let result = refresh_token(&http_client_clone, &previous_refresh_token).await; + + match result { + Ok(tokens) => { + let persist_result: Result> = async { + let current_generation = state_clone + .read_with(&*cx, |s, _| s.auth_generation) + .map_err(|e| Arc::new(e))?; + if current_generation != generation { + return Err(Arc::new(anyhow!( + "Sign-out occurred during token refresh" + ))); + } + + let claims = tokens + .id_token + .as_deref() + .map(extract_email_claim) + .unwrap_or(None); + let refreshed = SuperGrokCredentials { + access_token: tokens.access_token, + refresh_token: tokens + .refresh_token + .unwrap_or(previous_refresh_token.clone()), + expires_at_ms: now_ms() + tokens.expires_in * 1000, + email: claims.or(tokens.email).or(previous_email.clone()), + }; + + let credentials_provider = state_clone + .read_with(&*cx, |s, _| s.credentials_provider.clone()) + .map_err(|e| Arc::new(e))?; + + let json = + serde_json::to_vec(&refreshed).map_err(|e| Arc::new(e.into()))?; + + credentials_provider + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) + .await + .map_err(|e| Arc::new(e))?; + + state_clone + .update(cx, |s, _| { + s.credentials = Some(refreshed.clone()); + s.refresh_task = None; + }) + .map_err(|e| Arc::new(e))?; + + Ok(refreshed) + } + .await; + + if persist_result.is_err() { + state_clone + .update(cx, |s, _| { + s.refresh_task = None; + }) + .ok(); + } + + persist_result + } + Err(RefreshError::Fatal(e)) => { + log::error!("SuperGrok token refresh failed fatally: {e:?}"); + let still_current_generation = state_clone + .read_with(&*cx, |s, _| s.auth_generation == generation) + .unwrap_or(false); + if still_current_generation { + state_clone + .update(cx, |s, cx| { + s.refresh_task = None; + s.credentials = None; + s.last_auth_error = Some( + "Your SuperGrok session has expired. Sign in again.".into(), + ); + cx.notify(); + }) + .ok(); + if let Ok(credentials_provider) = + state_clone.read_with(&*cx, |s, _| s.credentials_provider.clone()) + { + credentials_provider + .delete_credentials(CREDENTIALS_KEY, &*cx) + .await + .log_err(); + } + } else { + state_clone + .update(cx, |s, _| { + s.refresh_task = None; + }) + .ok(); + } + Err(Arc::new(e)) + } + Err(RefreshError::Transient(e)) => { + log::warn!("SuperGrok token refresh failed transiently: {e:?}"); + state_clone + .update(cx, |s, _| { + s.refresh_task = None; + }) + .ok(); + Err(Arc::new(e)) + } + } + }) + .shared(); + + state + .update(cx, |s, _| { + s.refresh_task = Some(shared_task.clone()); + }) + .map_err(LanguageModelCompletionError::Other)?; + + shared_task + .await + .map_err(|e| LanguageModelCompletionError::Other(anyhow!("{e}"))) +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + id_token: Option, + expires_in: u64, + #[serde(default)] + email: Option, +} + +struct PkceAuthorizeRequest { + authorize_url: String, + verifier: String, + state: String, + redirect_uri: String, +} + +fn pkce_challenge(verifier: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(hasher.finalize().as_slice()) +} + +fn build_authorize_request( + redirect_uri: &str, + verifier: &str, + oauth_state: &str, +) -> Result { + let challenge = pkce_challenge(verifier); + let mut auth_url = url::Url::parse(XAI_AUTHORIZE_URL).context("invalid authorize URL")?; + auth_url + .query_pairs_mut() + .append_pair("client_id", CLIENT_ID) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", OAUTH_SCOPE) + .append_pair("response_type", "code") + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", oauth_state) + .append_pair("nonce", oauth_state); + Ok(auth_url.to_string()) +} + +fn new_pkce_authorize_request(redirect_uri: String) -> Result { + let mut verifier_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut verifier_bytes); + let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + + let mut state_bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut state_bytes); + let oauth_state: String = state_bytes.iter().map(|b| format!("{b:02x}")).collect(); + + let authorize_url = build_authorize_request(&redirect_uri, &verifier, &oauth_state)?; + Ok(PkceAuthorizeRequest { + authorize_url, + verifier, + state: oauth_state, + redirect_uri, + }) +} + +async fn do_oauth_flow( + http_client: Arc, + cx: &AsyncApp, +) -> Result { + let (redirect_uri, callback_rx) = + oauth_callback_server::start_oauth_callback_server_with_config( + oauth_callback_server::OAuthCallbackServerConfig { + host: CALLBACK_HOST, + preferred_port: CALLBACK_PORT, + fallback_port: None, + path: CALLBACK_PATH, + }, + ) + .context("Failed to start OAuth callback server")?; + + let pkce = new_pkce_authorize_request(redirect_uri)?; + cx.update(|cx| cx.open_url(&pkce.authorize_url)); + + let callback = callback_rx + .await + .map_err(|_| anyhow!("OAuth callback was cancelled"))? + .context("OAuth callback failed")?; + + if callback.state != pkce.state { + return Err(anyhow!("OAuth state mismatch")); + } + + let tokens = exchange_code( + &http_client, + &callback.code, + &pkce.verifier, + &pkce.redirect_uri, + ) + .await + .context("Token exchange failed")?; + + let refresh_token = tokens + .refresh_token + .filter(|token| !token.is_empty()) + .context("Token response did not include a refresh_token")?; + let email = tokens + .id_token + .as_deref() + .and_then(extract_email_claim) + .or(tokens.email); + + Ok(SuperGrokCredentials { + access_token: tokens.access_token, + refresh_token, + expires_at_ms: now_ms() + tokens.expires_in * 1000, + email, + }) +} + +async fn exchange_code( + client: &Arc, + code: &str, + verifier: &str, + redirect_uri: &str, +) -> Result { + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "authorization_code") + .append_pair("client_id", CLIENT_ID) + .append_pair("code", code) + .append_pair("redirect_uri", redirect_uri) + .append_pair("code_verifier", verifier) + .finish(); + + let request = HttpRequest::builder() + .method(Method::POST) + .uri(XAI_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(AsyncBody::from(body))?; + + let mut response = client.send(request).await?; + let mut body = String::new(); + smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body).await?; + + if !response.status().is_success() { + return Err(anyhow!( + "Token exchange failed (HTTP {}): {}", + response.status(), + redact_token_body(&body) + )); + } + + serde_json::from_str::(&body).context("Failed to parse token response") +} + +async fn refresh_token( + client: &Arc, + refresh_token: &str, +) -> Result { + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "refresh_token") + .append_pair("client_id", CLIENT_ID) + .append_pair("refresh_token", refresh_token) + .finish(); + + let request = HttpRequest::builder() + .method(Method::POST) + .uri(XAI_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(AsyncBody::from(body)) + .map_err(|e| RefreshError::Transient(e.into()))?; + + let mut response = client + .send(request) + .await + .map_err(RefreshError::Transient)?; + let status = response.status(); + let mut body = String::new(); + smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body) + .await + .map_err(|e| RefreshError::Transient(e.into()))?; + + if !status.is_success() { + let err = anyhow!( + "Token refresh failed (HTTP {}): {}", + status, + redact_token_body(&body) + ); + if status == http_client::StatusCode::BAD_REQUEST + || status == http_client::StatusCode::UNAUTHORIZED + || status == http_client::StatusCode::FORBIDDEN + { + return Err(RefreshError::Fatal(err)); + } + return Err(RefreshError::Transient(err)); + } + + serde_json::from_str(&body).map_err(|e| RefreshError::Transient(e.into())) +} + +fn extract_email_claim(jwt: &str) -> Option { + let payload_b64 = jwt.split('.').nth(1)?; + let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; + let claims = serde_json::from_slice::(&payload).ok()?; + claims + .get("email") + .and_then(|value| value.as_str()) + .map(str::to_owned) +} + +fn redact_token_body(body: &str) -> String { + const MAX_LEN: usize = 240; + if body.len() <= MAX_LEN { + body.to_string() + } else { + format!("{}…", &body[..MAX_LEN]) + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_else(|err| { + log::error!("System clock is before UNIX epoch: {err}"); + 0 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, TestAppContext}; + use http_client::FakeHttpClient; + use parking_lot::Mutex; + use std::future::Future; + use std::pin::Pin; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn grok_46_supports_xhigh_and_defaults_to_high() { + let effort_levels = supported_thinking_effort_levels(&SuperGrokModel::Grok46); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + + assert_eq!(values, ["low", "medium", "high", "xhigh"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("high") + ); + } + + #[test] + fn grok_46_request_uses_selected_reasoning_effort() { + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("xhigh".to_string()), + ..Default::default() + }; + + assert_eq!( + reasoning_effort_for_request(&request, &SuperGrokModel::Grok46), + Some(ReasoningEffort::XHigh) + ); + } + + #[test] + fn grok_46_omits_reasoning_effort_when_thinking_is_disabled() { + let request = LanguageModelRequest { + thinking_allowed: false, + thinking_effort: Some("medium".to_string()), + ..Default::default() + }; + + assert_eq!( + reasoning_effort_for_request(&request, &SuperGrokModel::Grok46), + None + ); + } + + #[test] + fn grok_build_omits_reasoning_effort() { + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("medium".to_string()), + ..Default::default() + }; + + assert_eq!( + reasoning_effort_for_request(&request, &SuperGrokModel::GrokBuild01), + None + ); + } + + #[test] + fn grok_45_does_not_advertise_xhigh() { + let effort_levels = supported_thinking_effort_levels(&SuperGrokModel::Grok45); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + assert_eq!(values, ["low", "medium", "high"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("high") + ); + } + + #[test] + fn grok_46_and_45_omit_max_output_tokens() { + assert_eq!(SuperGrokModel::Grok46.max_output_tokens(), None); + assert_eq!(SuperGrokModel::Grok45.max_output_tokens(), None); + assert_eq!( + SuperGrokModel::GrokBuild01.max_output_tokens(), + Some(64_000) + ); + } + + #[test] + fn authorize_url_uses_public_client_and_s256() { + let verifier = "test-verifier"; + let url = build_authorize_request("http://127.0.0.1:56121/callback", verifier, "abc123") + .expect("url should build"); + let parsed = url::Url::parse(&url).unwrap(); + let pairs: Vec<(String, String)> = parsed + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + + assert_eq!(parsed.as_str().split('?').next(), Some(XAI_AUTHORIZE_URL)); + assert!(pairs.contains(&("client_id".into(), CLIENT_ID.into()))); + assert!(pairs.contains(&( + "redirect_uri".into(), + "http://127.0.0.1:56121/callback".into() + ))); + assert!(pairs.contains(&("scope".into(), OAUTH_SCOPE.into()))); + assert!(pairs.contains(&("response_type".into(), "code".into()))); + assert!(pairs.contains(&("code_challenge_method".into(), "S256".into()))); + assert!(pairs.contains(&("code_challenge".into(), pkce_challenge(verifier)))); + assert!(pairs.contains(&("state".into(), "abc123".into()))); + assert!(pairs.contains(&("nonce".into(), "abc123".into()))); + } + + #[gpui::test] + async fn test_concurrent_refresh_deduplicates(cx: &mut TestAppContext) { + let refresh_count = Arc::new(AtomicUsize::new(0)); + let refresh_count_clone = refresh_count.clone(); + + let http_client = FakeHttpClient::create(move |_request| { + let refresh_count = refresh_count_clone.clone(); + async move { + refresh_count.fetch_add(1, Ordering::SeqCst); + let body = fake_token_response(true); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(body))?) + } + }); + + let http: Arc = http_client; + let state = make_state(http.clone(), Some(make_expired_credentials()), cx); + let weak_state = cx.read(|_cx| state.downgrade()); + + let weak1 = weak_state.clone(); + let http1 = http.clone(); + let task1 = + cx.spawn(async move |mut cx| get_fresh_credentials(&weak1, &http1, &mut cx).await); + + let weak2 = weak_state.clone(); + let http2 = http.clone(); + let task2 = + cx.spawn(async move |mut cx| get_fresh_credentials(&weak2, &http2, &mut cx).await); + + cx.run_until_parked(); + let result1 = task1.await; + let result2 = task2.await; + + assert!(result1.is_ok(), "first refresh should succeed"); + assert!(result2.is_ok(), "second refresh should succeed"); + assert_eq!(result1.unwrap().access_token, "fresh_access"); + assert_eq!(result2.unwrap().access_token, "fresh_access"); + assert_eq!( + refresh_count.load(Ordering::SeqCst), + 1, + "refresh_token should only be called once despite two concurrent callers" + ); + } + + #[gpui::test] + async fn test_fresh_credentials_skip_refresh(cx: &mut TestAppContext) { + let refresh_count = Arc::new(AtomicUsize::new(0)); + let refresh_count_clone = refresh_count.clone(); + + let http_client = FakeHttpClient::create(move |_request| { + let refresh_count = refresh_count_clone.clone(); + async move { + refresh_count.fetch_add(1, Ordering::SeqCst); + let body = fake_token_response(true); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(body))?) + } + }); + + let http: Arc = http_client; + let state = make_state(http.clone(), Some(make_fresh_credentials()), cx); + let weak_state = cx.read(|_cx| state.downgrade()); + let http_clone = http.clone(); + let result = cx + .spawn(async move |mut cx| { + get_fresh_credentials(&weak_state, &http_clone, &mut cx).await + }) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap().access_token, "fresh_access"); + assert_eq!(refresh_count.load(Ordering::SeqCst), 0); + } + + #[gpui::test] + async fn test_no_credentials_returns_no_api_key(cx: &mut TestAppContext) { + let http: Arc = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + let state = make_state(http.clone(), None, cx); + let weak_state = cx.read(|_cx| state.downgrade()); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak_state, &http, &mut cx).await) + .await; + + assert!(matches!( + result, + Err(LanguageModelCompletionError::NoApiKey { .. }) + )); + } + + #[gpui::test] + async fn test_fatal_refresh_clears_auth_state(cx: &mut TestAppContext) { + let http: Arc = FakeHttpClient::create(move |_request| async move { + Ok(http_client::Response::builder() + .status(401) + .body(http_client::AsyncBody::from(r#"{"error":"invalid_grant"}"#))?) + }); + let state = make_state(http.clone(), Some(make_expired_credentials()), cx); + let weak_state = cx.read(|_cx| state.downgrade()); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak_state, &http, &mut cx).await) + .await; + + cx.run_until_parked(); + assert!(result.is_err()); + cx.read(|cx| { + let state = state.read(cx); + assert!(state.credentials.is_none()); + assert!(state.last_auth_error.is_some()); + }); + } + + #[gpui::test] + async fn test_transient_refresh_keeps_credentials(cx: &mut TestAppContext) { + let http: Arc = FakeHttpClient::create(move |_request| async move { + Ok(http_client::Response::builder() + .status(500) + .body(http_client::AsyncBody::from("Internal Server Error"))?) + }); + let state = make_state(http.clone(), Some(make_expired_credentials()), cx); + let weak_state = cx.read(|_cx| state.downgrade()); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak_state, &http, &mut cx).await) + .await; + + cx.run_until_parked(); + assert!(result.is_err()); + cx.read(|cx| { + let state = state.read(cx); + assert!(state.credentials.is_some()); + assert!(state.last_auth_error.is_none()); + }); + } + + #[gpui::test] + async fn test_refresh_keeps_previous_refresh_token_when_omitted(cx: &mut TestAppContext) { + let http: Arc = FakeHttpClient::create(move |_request| async move { + let body = fake_token_response(false); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(body))?) + }); + let state = make_state(http.clone(), Some(make_expired_credentials()), cx); + let weak_state = cx.read(|_cx| state.downgrade()); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak_state, &http, &mut cx).await) + .await + .expect("refresh should succeed"); + + assert_eq!(result.access_token, "fresh_access"); + assert_eq!(result.refresh_token, "old_refresh"); + } + + #[gpui::test] + async fn test_sign_out_during_refresh_discards_result(cx: &mut TestAppContext) { + let (gate_tx, gate_rx) = futures::channel::oneshot::channel::<()>(); + let gate_rx = Arc::new(Mutex::new(Some(gate_rx))); + let gate_rx_clone = gate_rx.clone(); + + let http_client = FakeHttpClient::create(move |_request| { + let gate_rx = gate_rx_clone.clone(); + async move { + let rx = gate_rx.lock().take(); + if let Some(rx) = rx { + let _ = rx.await; + } + let body = fake_token_response(true); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(body))?) + } + }); + + let http: Arc = http_client; + let state = make_state(http.clone(), Some(make_expired_credentials()), cx); + let weak_state = cx.read(|_cx| state.downgrade()); + let refresh_task = + cx.spawn(async move |mut cx| get_fresh_credentials(&weak_state, &http, &mut cx).await); + + cx.run_until_parked(); + state.update(cx, |state, cx| { + state.sign_out(cx).detach(); + }); + cx.run_until_parked(); + let _ = gate_tx.send(()); + cx.run_until_parked(); + + assert!(refresh_task.await.is_err()); + cx.read(|cx| { + assert!(state.read(cx).credentials.is_none()); + }); + } + + #[gpui::test] + async fn test_fatal_refresh_after_sign_out_keeps_new_session(cx: &mut TestAppContext) { + let (gate_tx, gate_rx) = futures::channel::oneshot::channel::<()>(); + let gate_rx = Arc::new(Mutex::new(Some(gate_rx))); + let gate_rx_clone = gate_rx.clone(); + + let http_client = FakeHttpClient::create(move |_request| { + let gate_rx = gate_rx_clone.clone(); + async move { + let rx = gate_rx.lock().take(); + if let Some(rx) = rx { + let _ = rx.await; + } + Ok(http_client::Response::builder() + .status(401) + .body(http_client::AsyncBody::from(r#"{"error":"invalid_grant"}"#))?) + } + }); + + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + let http: Arc = http_client; + let state = make_state_with_credentials_provider( + http.clone(), + Some(make_expired_credentials()), + creds_provider.clone(), + cx, + ); + let weak_state = cx.read(|_cx| state.downgrade()); + let refresh_task = + cx.spawn(async move |mut cx| get_fresh_credentials(&weak_state, &http, &mut cx).await); + + cx.run_until_parked(); + state.update(cx, |state, cx| { + state.sign_out(cx).detach(); + }); + cx.run_until_parked(); + + let new_creds = make_fresh_credentials(); + let new_creds_json = serde_json::to_vec(&new_creds).unwrap(); + creds_provider + .storage + .lock() + .replace(("Bearer".to_string(), new_creds_json)); + state.update(cx, |state, cx| { + state.auth_generation = state.auth_generation.wrapping_add(1); + state.credentials = Some(new_creds); + cx.notify(); + }); + + let _ = gate_tx.send(()); + cx.run_until_parked(); + + assert!(refresh_task.await.is_err()); + cx.read(|cx| { + let state = state.read(cx); + assert!(state.is_authenticated()); + assert_eq!( + state.credentials.as_ref().unwrap().access_token, + "fresh_access" + ); + assert!(state.last_auth_error.is_none()); + }); + assert!(creds_provider.storage.lock().is_some()); + } + + #[gpui::test] + async fn test_sign_out_completes_fully(cx: &mut TestAppContext) { + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + creds_provider + .storage + .lock() + .replace(("Bearer".to_string(), b"some-creds".to_vec())); + + let http: Arc = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + let state = make_state_with_credentials_provider( + http, + Some(make_fresh_credentials()), + creds_provider.clone(), + cx, + ); + + let sign_out_task = state.update(cx, |state, cx| state.sign_out(cx)); + cx.run_until_parked(); + sign_out_task.await.expect("sign-out should succeed"); + + assert!(creds_provider.storage.lock().is_none()); + cx.read(|cx| { + assert!(!state.read(cx).is_authenticated()); + }); + } + + #[gpui::test] + async fn test_initial_load_restores_persisted_credentials(cx: &mut TestAppContext) { + let creds = make_fresh_credentials(); + let creds_json = serde_json::to_vec(&creds).unwrap(); + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + creds_provider + .storage + .lock() + .replace(("Bearer".to_string(), creds_json)); + + let http: Arc = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + let state = cx.new(|cx| State::new(http, creds_provider, cx)); + let load_task = cx + .read(|cx| state.read(cx).load_task()) + .expect("constructor should start the credentials load"); + + cx.run_until_parked(); + load_task.await.expect("load should succeed"); + + cx.read(|cx| { + let state = state.read(cx); + assert!(state.is_authenticated()); + assert!(state.load_task().is_none()); + }); + } + + struct FakeCredentialsProvider { + storage: Mutex)>>, + } + + impl FakeCredentialsProvider { + fn new() -> Self { + Self { + storage: Mutex::new(None), + } + } + } + + impl CredentialsProvider for FakeCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + Box::pin(async { Ok(self.storage.lock().clone()) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + username: &'a str, + password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + self.storage + .lock() + .replace((username.to_string(), password.to_vec())); + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + *self.storage.lock() = None; + Box::pin(async { Ok(()) }) + } + } + + fn make_state( + http_client: Arc, + credentials: Option, + cx: &mut TestAppContext, + ) -> Entity { + make_state_with_credentials_provider( + http_client, + credentials, + Arc::new(FakeCredentialsProvider::new()), + cx, + ) + } + + fn make_state_with_credentials_provider( + http_client: Arc, + credentials: Option, + credentials_provider: Arc, + cx: &mut TestAppContext, + ) -> Entity { + cx.new(|_cx| State { + credentials, + sign_in_task: None, + refresh_task: None, + load_task: None, + credentials_provider, + http_client, + auth_generation: 0, + last_auth_error: None, + }) + } + + fn make_expired_credentials() -> SuperGrokCredentials { + SuperGrokCredentials { + access_token: "old_access".to_string(), + refresh_token: "old_refresh".to_string(), + expires_at_ms: 0, + email: None, + } + } + + fn make_fresh_credentials() -> SuperGrokCredentials { + SuperGrokCredentials { + access_token: "fresh_access".to_string(), + refresh_token: "fresh_refresh".to_string(), + expires_at_ms: now_ms() + 3_600_000, + email: None, + } + } + + fn fake_token_response(include_refresh_token: bool) -> String { + let mut value = serde_json::json!({ + "access_token": "fresh_access", + "expires_in": 3600 + }); + if include_refresh_token { + value["refresh_token"] = serde_json::json!("fresh_refresh"); + } + value.to_string() + } +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 058487f3f5..cfac268123 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.19.0-Y0" +version = "1.22.0-Y0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -12,11 +12,6 @@ default-run = "zed" workspace = true [features] -inspector = [ - "gpui/inspector", - "inspector_ui/inspector", - "ui/derive_inspector_reflection", -] tracy = ["ztracing/tracy"] # LEAK_BACKTRACE=1 cargo run --features zed/track-project-leak --profile release-fast track-project-leak = ["gpui/leak-detection"] @@ -133,6 +128,7 @@ gpui_platform = { workspace = true, features = [ "x11", ] } gpui_tokio.workspace = true +hdrhistogram.workspace = true image = { workspace = true, optional = true } spin = "0.10.0" itertools.workspace = true @@ -161,6 +157,7 @@ language_selector.workspace = true language_tools.workspace = true languages = { workspace = true, features = ["load-grammars"] } line_ending_selector.workspace = true +lsp_command_selector.workspace = true log.workspace = true lsp_locations.workspace = true markdown.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index c7e4eb6e91..ec3198047c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod reliability; +mod watcher_debug; mod zed; // Ensure the binary name stays in sync with APP_NAME so that the paths used @@ -298,6 +299,9 @@ fn main() { } ztracing::init(); + #[cfg(unix)] + util::increase_open_file_limit().log_err(); + let version = option_env!("ZED_BUILD_ID"); let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|commit_sha| AppCommitSha::new(commit_sha.to_string())); @@ -429,7 +433,7 @@ fn main() { log::info!("Using git binary path: {:?}", git_binary_path); } - let fs = Arc::new(RealFs::new(git_binary_path, app.background_executor())); + let fs = RealFs::new(git_binary_path, app.background_executor()); let (user_keymap_file_rx, user_keymap_watcher) = watch_config_file( &app.background_executor(), fs.clone(), @@ -650,6 +654,7 @@ fn main() { }); AppState::set_global(app_state.clone(), cx); + watcher_debug::init(app_state.clone(), cx); auto_update::init(client.clone(), cx); dap_adapters::init(cx); auto_update_ui::init(cx); @@ -762,6 +767,7 @@ fn main() { encoding_selector::init(cx); language_selector::init(cx); line_ending_selector::init(cx); + lsp_command_selector::init(cx); toolchain_selector::init(cx); theme_selector::init(cx); settings_profile_selector::init(cx); diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 87ca151fd3..17b697956a 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use client::{Client, telemetry::MINIDUMP_ENDPOINT}; use feature_flags::FeatureFlagAppExt; use futures::{AsyncReadExt, TryStreamExt}; -use gpui::{App, AppContext, Entity, TaskExt}; +use gpui::{App, AppContext, Entity, TaskExt, WeakEntity}; use http_client::{AsyncBody, HttpClient, Request}; use project::{Project, worktree_store::WorktreeStoreDiagnostics}; use proto::{CrashReport, GetCrashFilesResponse}; @@ -13,9 +13,11 @@ use reqwest::{ use serde::Deserialize; use smol::stream::StreamExt; use std::{ + cell::RefCell, collections::HashSet, ffi::OsStr, fs, + rc::Rc, sync::Arc, time::{Duration, Instant}, }; @@ -25,9 +27,12 @@ use workspace::WorkspaceStore; mod hang_detection; +type ProjectRegistry = Rc>>>; + pub fn init(client: Arc, workspace_store: Entity, cx: &mut App) { hang_detection::start(client.clone(), cx); - start_memory_usage_logging(workspace_store, cx); + let projects = ProjectRegistry::default(); + start_memory_usage_logging(workspace_store, projects.clone(), cx); cx.on_flags_ready({ let client = client.clone(); @@ -52,6 +57,7 @@ pub fn init(client: Arc, workspace_store: Entity, cx: &m } cx.observe_new(move |project: &mut Project, _, cx| { + projects.borrow_mut().push(cx.weak_entity()); let client = client.clone(); let Some(remote_client) = project.remote_client() else { @@ -99,11 +105,15 @@ const MEMORY_USAGE_MINIMUM_LOGGED_DELTA: u64 = 64 * 1024 * 1024; /// Logs on a fixed heartbeat, and additionally whenever resident memory changed /// significantly since the last logged value, so that bursts of growth are timestamped /// against the surrounding log entries. -fn start_memory_usage_logging(workspace_store: Entity, cx: &App) { +fn start_memory_usage_logging( + workspace_store: Entity, + projects: ProjectRegistry, + cx: &App, +) { let (diagnostics_sender, mut diagnostics_receiver) = futures::channel::mpsc::unbounded(); cx.spawn(async move |cx| { while diagnostics_receiver.next().await.is_some() { - cx.update(|cx| log_worktree_diagnostics(&workspace_store, cx)); + cx.update(|cx| log_worktree_diagnostics(&workspace_store, &projects, cx)); } }) .detach(); @@ -157,18 +167,35 @@ fn start_memory_usage_logging(workspace_store: Entity, cx: &App) .detach(); } -fn log_worktree_diagnostics(workspace_store: &Entity, cx: &App) { - let workspaces = workspace_store +fn log_worktree_diagnostics( + workspace_store: &Entity, + projects: &ProjectRegistry, + cx: &App, +) { + let workspace_project_ids = workspace_store .read(cx) .workspaces() .filter_map(|workspace| workspace.upgrade()) - .collect::>(); + .map(|workspace| workspace.read(cx).project().entity_id()) + .collect::>(); + let live_projects = { + let mut projects = projects.borrow_mut(); + projects.retain(|project| project.upgrade().is_some()); + projects + .iter() + .filter_map(|project| project.upgrade()) + .collect::>() + }; + let project_count = live_projects.len(); + let orphaned_project_count = live_projects + .iter() + .filter(|project| !workspace_project_ids.contains(&project.entity_id())) + .count(); let mut worktree_store_ids = HashSet::new(); let mut store_count = 0; let mut aggregate = WorktreeStoreDiagnostics::default(); - for workspace in workspaces { - let project = workspace.read(cx).project().clone(); + for project in live_projects { let worktree_store = project.read(cx).worktree_store(); if !worktree_store_ids.insert(worktree_store.entity_id()) { continue; @@ -218,13 +245,13 @@ fn log_worktree_diagnostics(workspace_store: &Entity, cx: &App) } = aggregate; match largest_worktree { Some(largest_worktree) => log::info!( - "worktree diagnostics: stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest {} ({} entries, {} visible)", + "worktree diagnostics: projects {project_count}, orphaned projects {orphaned_project_count}, stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest {} ({} entries, {} visible)", largest_worktree.path.display(), largest_worktree.entries, largest_worktree.visible_entries, ), None => log::info!( - "worktree diagnostics: stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest none", + "worktree diagnostics: projects {project_count}, orphaned projects {orphaned_project_count}, stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest none", ), } } diff --git a/crates/zed/src/reliability/hang_detection.rs b/crates/zed/src/reliability/hang_detection.rs index bb4db32198..55423269ae 100644 --- a/crates/zed/src/reliability/hang_detection.rs +++ b/crates/zed/src/reliability/hang_detection.rs @@ -47,9 +47,9 @@ pub(crate) fn start(client: Arc, cx: &mut App) { // constantly. Duration::from_millis(100) } else { - // One 120Hz refresh: an interval that spends this much on the - // foreground has plausibly dropped a frame. - Duration::from_millis(8) + // At least one dropped frame on any display. Generous while budget + // incidents are plentiful; lower it as they get fixed. + Duration::from_millis(24) }; if cfg!(debug_assertions) { diff --git a/crates/zed/src/reliability/hang_detection/telemetry.rs b/crates/zed/src/reliability/hang_detection/telemetry.rs index 97eea191c0..32f2e08af4 100644 --- a/crates/zed/src/reliability/hang_detection/telemetry.rs +++ b/crates/zed/src/reliability/hang_detection/telemetry.rs @@ -1,9 +1,10 @@ use std::time::{Duration, Instant}; -use gpui::profiler::hang::SerializedHangIncident; +use gpui::profiler::hang::{HangTrigger, SerializedHangIncident}; +use hdrhistogram::Histogram; /// Cap on incidents per telemetry event. When more accrue between sends, the -/// ones with the largest stalls are kept and `total_incidents` still counts +/// ones with the largest stalls are kept and the incident counts still cover /// them all. const MAX_REPORTED_INCIDENTS: usize = 10; @@ -14,7 +15,10 @@ const SEND_INTERVAL: Duration = Duration::from_mins(30); pub struct Reporter { last_send: Instant, pending: Vec, - total_incidents: u64, + threshold_incidents: u64, + budget_incidents: u64, + // Every incident's stall, in milliseconds; `pending` keeps only the largest. + stalls: Histogram, } impl Reporter { @@ -22,12 +26,18 @@ impl Reporter { Self { last_send: Instant::now(), pending: Vec::new(), - total_incidents: 0, + threshold_incidents: 0, + budget_incidents: 0, + stalls: Histogram::new(3).expect("3 significant figures is a valid histogram"), } } pub fn add(&mut self, incident: SerializedHangIncident) { - self.total_incidents += 1; + match incident.trigger { + HangTrigger::Threshold => self.threshold_incidents += 1, + HangTrigger::Budget => self.budget_incidents += 1, + } + self.stalls.record(incident.stall_ms as u64).ok(); self.pending.push(incident); if self.pending.len() > MAX_REPORTED_INCIDENTS { self.pending @@ -42,15 +52,31 @@ impl Reporter { } } + // Sends even without incidents so summing `report_window_seconds` gives the + // observed time when computing incident rates. pub fn send(&mut self) { - self.last_send = Instant::now(); - if self.pending.is_empty() { - return; - } + let now = Instant::now(); + let report_window_seconds = now.duration_since(self.last_send).as_secs(); + self.last_send = now; let mut incidents = std::mem::take(&mut self.pending); incidents.sort_by(|a, b| b.stall_ms.total_cmp(&a.stall_ms)); - let total_incidents = std::mem::take(&mut self.total_incidents); + let threshold_incidents = std::mem::take(&mut self.threshold_incidents); + let budget_incidents = std::mem::take(&mut self.budget_incidents); + // `total_incidents` predates the split; existing queries key on it. + let total_incidents = threshold_incidents + budget_incidents; - telemetry::event!("Hang Incidents", incidents, total_incidents); + telemetry::event!( + "Hang Incidents", + incidents, + total_incidents, + threshold_incidents, + budget_incidents, + stall_p50_ms = self.stalls.value_at_quantile(0.5), + stall_p95_ms = self.stalls.value_at_quantile(0.95), + stall_max_ms = self.stalls.max(), + report_window_seconds, + measurement_version = gpui::profiler::hang::MEASUREMENT_VERSION + ); + self.stalls.reset(); } } diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index cea858e72e..6541bf666f 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -975,7 +975,7 @@ fn init_app_state(cx: &mut App) -> Arc { } // Use the real filesystem instead of FakeFs so we can access actual files on disk - let fs: Arc = Arc::new(fs::RealFs::new(None, cx.background_executor().clone())); + let fs: Arc = fs::RealFs::new(None, cx.background_executor().clone()); ::set_global(fs.clone(), cx); let languages = Arc::new(language::LanguageRegistry::test( diff --git a/crates/zed/src/watcher_debug.rs b/crates/zed/src/watcher_debug.rs new file mode 100644 index 0000000000..4d432ea51d --- /dev/null +++ b/crates/zed/src/watcher_debug.rs @@ -0,0 +1,929 @@ +use chrono::{DateTime, Local, Utc}; +use editor::{Editor, MultiBufferOffset}; +use fs::fs_watcher::{WatchDiagnosticEvent, WatchRecording, WatchSnapshot}; +use gpui::{ + App, AppContext, Context, Entity, FocusHandle, Focusable, Render, Task, TitlebarOptions, + Window, WindowBounds, WindowOptions, actions, px, size, +}; +use language::{LineEnding, language_settings::SoftWrap}; +use release_channel::AppVersion; +use serde::Serialize; +use std::{ + collections::{HashSet, VecDeque}, + sync::Arc, + time::Duration, +}; +use ui::{Tab, TabBar, TabPosition, prelude::*}; +use util::ResultExt; +use workspace::AppState; + +actions!( + dev, + [ + /// Open an app-wide recording of raw local filesystem watcher events. + DebugFilesystemWatching + ] +); + +pub fn init(app_state: Arc, cx: &mut App) { + cx.on_action(move |_: &DebugFilesystemWatching, cx| { + if let Some(existing) = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()) + { + existing + .update(cx, |view, window, cx| { + window.activate_window(); + view.focus_handle(cx).focus(window, cx); + }) + .log_err(); + return; + } + + let app_state = app_state.clone(); + cx.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions::default()), + window_bounds: Some(WindowBounds::centered(size(px(1000.), px(700.)), cx)), + ..Default::default() + }, + |window, cx| { + window.set_window_title("Debug Filesystem Watching"); + let view = cx.new(|cx| WatcherDebug::new(app_state, window, cx)); + window.activate_window(); + view.focus_handle(cx).focus(window, cx); + view + }, + ) + .log_err(); + }); +} + +#[derive(Serialize)] +struct WorktreeExclusions { + root: String, + file_scan_exclusions: Vec, +} + +#[derive(Serialize)] +struct Export { + zed_version: String, + os_name: String, + os_version: String, + watcher: WatchSnapshot, + worktree_scan_exclusions: Vec, + exclusion_scope: &'static str, +} + +const EXCLUSION_SCOPE: &str = "Patterns Zed skips when scanning your open local projects. Excluded files may still produce watcher events."; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum WatcherTab { + RawEvents, + WatchRoots, + ScanExclusions, +} + +impl WatcherTab { + const ALL: [Self; 3] = [Self::RawEvents, Self::WatchRoots, Self::ScanExclusions]; + + fn label(self) -> &'static str { + match self { + Self::RawEvents => "Raw Events", + Self::WatchRoots => "Watch Roots", + Self::ScanExclusions => "Scan Exclusions", + } + } + + fn description(self) -> &'static str { + match self { + Self::RawEvents => { + "Raw watcher notifications, oldest first. Times are shown in your local time zone." + } + Self::WatchRoots => "Live native and polling watch roots across the app.", + Self::ScanExclusions => EXCLUSION_SCOPE, + } + } + + fn empty_message(self) -> &'static str { + match self { + Self::RawEvents => "Waiting for filesystem watcher events…", + Self::WatchRoots => "No watch roots.", + Self::ScanExclusions => "No local projects are open.", + } + } +} + +fn format_event(event: &WatchDiagnosticEvent) -> SharedString { + let time = i64::try_from(event.timestamp_unix_millis) + .ok() + .and_then(DateTime::::from_timestamp_millis) + .map(|time| { + time.with_timezone(&Local) + .format("%H:%M:%S%.3f") + .to_string() + }) + .unwrap_or_else(|| "--:--:--.---".into()); + let name = if event.rescan { + "Rescan" + } else { + event.event_kind.as_deref().unwrap_or(&event.operation) + }; + let name = name + .split_once('(') + .map_or(name, |(name, _)| name) + .to_lowercase(); + let mut row = format!("{time} {name}"); + for path in &event.paths { + row.push_str(" "); + row.push_str(path); + } + row.into() +} + +fn create_editor( + tab: WatcherTab, + text: String, + window: &mut Window, + cx: &mut App, +) -> Entity { + let buffer = cx.new(|cx| language::Buffer::local(text, cx)); + cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.set_read_only(true); + editor.set_input_enabled(false); + editor.set_use_modal_editing(false); + editor.set_show_gutter(false, cx); + editor.hide_minimap_by_default(window, cx); + editor.set_show_edit_predictions(Some(false), window, cx); + editor.set_soft_wrap_mode(SoftWrap::None, cx); + editor.set_placeholder_text(tab.empty_message(), window, cx); + editor + }) +} + +fn update_editor_text(editor: &Entity, mut text: String, cx: &mut App) -> usize { + LineEnding::normalize(&mut text); + editor.update(cx, |editor, cx| { + let previous = editor.text(cx); + if previous == text { + return 0; + } + let edits = language::text_diff(&previous, &text); + let edited_bytes = edits + .iter() + .map(|(range, text)| range.len() + text.len()) + .sum(); + let edits = edits.into_iter().map(|(range, text)| { + ( + MultiBufferOffset(range.start)..MultiBufferOffset(range.end), + text, + ) + }); + editor + .buffer() + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + edited_bytes + }) +} + +fn worktree_exclusions(app_state: &AppState, cx: &App) -> Vec { + let mut seen = HashSet::new(); + let mut exclusions = Vec::new(); + for workspace in app_state.workspace_store.read(cx).workspaces() { + let Some(workspace) = workspace.upgrade() else { + continue; + }; + for worktree in workspace.read(cx).worktrees(cx) { + if !seen.insert(worktree.entity_id()) { + continue; + } + let Some(local) = worktree.read(cx).as_local() else { + continue; + }; + exclusions.push(WorktreeExclusions { + root: local.abs_path().to_string_lossy().into_owned(), + file_scan_exclusions: local + .settings() + .file_scan_exclusions + .sources() + .map(str::to_owned) + .collect(), + }); + } + } + exclusions.sort_by(|left, right| left.root.cmp(&right.root)); + exclusions +} + +struct WatcherDebug { + app_state: Arc, + recording: Option, + snapshot: Option, + active_tab: WatcherTab, + editors: [Entity; 3], + editor_edit_bytes: [usize; 3], + event_lengths: VecDeque, + save_error: Option, + saving: bool, + _poll_task: Task<()>, +} + +impl WatcherDebug { + fn new(app_state: Arc, window: &mut Window, cx: &mut Context) -> Self { + let editors = WatcherTab::ALL.map(|tab| create_editor(tab, String::new(), window, cx)); + let recording = app_state.fs.record_watcher_diagnostics(); + let poll_task = cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + if this + .update_in(cx, |this, window, cx| { + this.refresh(cx); + this.compact_editors(window, cx); + cx.notify(); + }) + .is_err() + { + break; + } + } + }); + let mut view = Self { + app_state, + recording, + snapshot: None, + active_tab: WatcherTab::RawEvents, + editors, + editor_edit_bytes: [0; 3], + event_lengths: VecDeque::new(), + save_error: None, + saving: false, + _poll_task: poll_task, + }; + view.refresh(cx); + view + } + + fn refresh(&mut self, cx: &mut Context) { + let snapshot = self.recording.as_ref().map(WatchRecording::snapshot); + let mut watch_roots = String::new(); + if let Some(snapshot) = &snapshot { + self.refresh_events(snapshot, cx); + for watcher in &snapshot.watchers { + watch_roots.push_str(&format!( + "{:?}: recursive={}, cooldown remaining={:?} ms\n", + watcher.backend, watcher.recursive, watcher.cooldown_remaining_millis + )); + for root in &watcher.roots { + watch_roots.push_str(&format!( + " {} ({} registrations)\n", + root.path, root.registrations + )); + } + } + } + self.snapshot = snapshot; + self.editor_edit_bytes[WatcherTab::WatchRoots as usize] += + update_editor_text(self.editor(WatcherTab::WatchRoots), watch_roots, cx); + let mut scan_exclusions = String::new(); + for worktree in worktree_exclusions(&self.app_state, cx) { + scan_exclusions.push_str(&worktree.root); + scan_exclusions.push('\n'); + if worktree.file_scan_exclusions.is_empty() { + scan_exclusions.push_str(" (none)\n"); + } + for pattern in worktree.file_scan_exclusions { + scan_exclusions.push_str(&format!(" {pattern}\n")); + } + } + self.editor_edit_bytes[WatcherTab::ScanExclusions as usize] += + update_editor_text(self.editor(WatcherTab::ScanExclusions), scan_exclusions, cx); + } + + fn refresh_events(&mut self, snapshot: &WatchSnapshot, cx: &mut Context) { + let previous_dropped = self + .snapshot + .as_ref() + .map_or(0, |snapshot| snapshot.dropped_events); + let removed = usize::try_from(snapshot.dropped_events.saturating_sub(previous_dropped)) + .unwrap_or(usize::MAX) + .min(self.event_lengths.len()); + let removed_bytes: usize = self.event_lengths.drain(..removed).sum(); + let retained = self.event_lengths.len(); + let mut appended = String::new(); + for event in snapshot.events.iter().skip(retained) { + let mut line = format!("{}\n", format_event(event)); + LineEnding::normalize(&mut line); + self.event_lengths.push_back(line.len()); + appended.push_str(&line); + } + if removed_bytes == 0 && appended.is_empty() { + return; + } + self.editor_edit_bytes[WatcherTab::RawEvents as usize] += removed_bytes + appended.len(); + self.editor(WatcherTab::RawEvents).update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + let length = buffer.len(cx); + // Keep anchors in retained events intact when the recording rolls over. + buffer.edit( + [ + ( + MultiBufferOffset(0)..MultiBufferOffset(removed_bytes), + String::new(), + ), + (length..length, appended), + ], + None, + cx, + ); + }); + }); + } + + fn compact_editors(&mut self, window: &mut Window, cx: &mut Context) { + for tab in WatcherTab::ALL { + let index = tab as usize; + let length = self.editors[index].read(cx).buffer().read(cx).len(cx).0; + if self.editor_edit_bytes[index] < length.saturating_mul(2).max(64 * 1024) { + continue; + } + + // Deletions leave CRDT tombstones and undo history behind. Replacing + // the editor releases that storage without invalidating live selections. + let (text, selections, scroll_position, focused) = + self.editors[index].update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + ( + editor.text(cx), + editor.selections.all::(&snapshot), + editor.scroll_position(cx), + editor.focus_handle(cx).is_focused(window), + ) + }); + let editor = create_editor(tab, text, window, cx); + editor.update(cx, |editor, cx| { + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selection| { + selection.select(selections); + }, + ); + editor.set_scroll_position(scroll_position, window, cx); + if focused { + editor.focus_handle(cx).focus(window, cx); + } + }); + self.editors[index] = editor; + self.editor_edit_bytes[index] = 0; + cx.notify(); + } + } + + fn editor(&self, tab: WatcherTab) -> &Entity { + &self.editors[tab as usize] + } + + fn select_tab(&mut self, tab: WatcherTab, window: &mut Window, cx: &mut Context) { + self.active_tab = tab; + self.focus_handle(cx).focus(window, cx); + cx.notify(); + } + + fn save(&mut self, cx: &mut Context) { + let Some(recording) = &self.recording else { + return; + }; + let watcher = recording.snapshot(); + let worktree_scan_exclusions = worktree_exclusions(&self.app_state, cx); + let zed_version = AppVersion::global(cx).to_string(); + let os_name = client::telemetry::os_name(); + let directory = std::env::home_dir().unwrap_or_default(); + let path = cx.prompt_for_new_path(&directory, Some("filesystem-watcher.json")); + let fs = self.app_state.fs.clone(); + self.saving = true; + self.save_error = None; + cx.notify(); + cx.spawn(async move |this, cx| { + let result = async { + let Some(path) = path.await?? else { + return anyhow::Ok(()); + }; + cx.background_spawn(async move { + let export = Export { + zed_version, + os_name, + os_version: client::telemetry::os_version(), + watcher, + worktree_scan_exclusions, + exclusion_scope: EXCLUSION_SCOPE, + }; + let json = serde_json::to_vec_pretty(&export)?; + fs.write(&path, &json).await + }) + .await + } + .await; + this.update(cx, |this, cx| { + this.saving = false; + this.save_error = match result { + Ok(()) => None, + Err(error) => Some(format!("Save failed: {error:#}").into()), + }; + cx.notify(); + }) + .log_err(); + }) + .detach(); + } +} + +impl Focusable for WatcherDebug { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor(self.active_tab).focus_handle(cx) + } +} + +impl Render for WatcherDebug { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .text_color(cx.theme().colors().text) + .child( + TabBar::new("watcher-tabs").children(WatcherTab::ALL.map(|tab| { + Tab::new(tab.label()) + .position(match tab { + WatcherTab::RawEvents => TabPosition::First, + WatcherTab::WatchRoots => { + TabPosition::Middle(tab.cmp(&self.active_tab)) + } + WatcherTab::ScanExclusions => TabPosition::Last, + }) + .toggle_state(self.active_tab == tab) + .child(Label::new(tab.label())) + .on_click( + cx.listener(move |this, _, window, cx| { + this.select_tab(tab, window, cx) + }), + ) + })), + ) + .child( + v_flex() + .flex_1() + .min_h_0() + .overflow_hidden() + .p_3() + .gap_2() + .child( + Label::new(self.active_tab.description()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when_some(self.save_error.clone(), |content, error| { + content.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .child(self.editor(self.active_tab).clone()), + ), + ) + .child( + h_flex() + .debug_selector(|| "watcher-footer".into()) + .flex_none() + .justify_end() + .bg(cx.theme().colors().elevated_surface_background) + .p(DynamicSpacing::Base04.rems(cx)) + .child( + Button::new("save-watcher-json", "Export as JSON") + .disabled(self.saving || self.recording.is_none()) + .on_click(cx.listener(|this, _, _, cx| this.save(cx))), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Timelike}; + use fs::fs_watcher::OsWatcherKind; + + #[test] + fn formats_local_times_event_names_and_plain_paths() { + let time = Local + .with_ymd_and_hms(2026, 9, 10, 16, 25, 49) + .single() + .unwrap() + .with_nanosecond(123_000_000) + .unwrap(); + let event = WatchDiagnosticEvent { + timestamp_unix_millis: time.timestamp_millis().try_into().unwrap(), + backend: OsWatcherKind::Native, + operation: "event".into(), + event_kind: Some("Modify(Name(Both))".into()), + paths: vec!["/project/old name.rs".into(), "/project/new name.rs".into()], + detail: "Extra raw backend attributes".into(), + rescan: false, + }; + assert_eq!( + format_event(&event).as_ref(), + "16:25:49.123 modify /project/old name.rs /project/new name.rs" + ); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["detail"], "Extra raw backend attributes"); + assert_eq!(json["timestamp_unix_millis"], time.timestamp_millis()); + assert_eq!(json["paths"].as_array().unwrap().len(), 2); + } + + #[test] + fn formats_pathless_rescans_and_errors() { + let mut event = WatchDiagnosticEvent { + timestamp_unix_millis: u128::MAX, + backend: OsWatcherKind::Poll, + operation: "event".into(), + event_kind: Some("Other".into()), + paths: vec![], + detail: "Raw rescan attributes".into(), + rescan: true, + }; + assert_eq!(format_event(&event).as_ref(), "--:--:--.--- rescan"); + event.rescan = false; + event.event_kind = None; + event.operation = "watch_error".into(); + event.detail = "Permission denied".into(); + assert_eq!(format_event(&event).as_ref(), "--:--:--.--- watch_error"); + } + + #[gpui::test] + fn editors_allow_copying_and_preserve_selections_on_updates(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let window = cx.add_window(|window, cx| WatcherDebug::new(app_state, window, cx)); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + for tab in WatcherTab::ALL { + window + .update(cx, |view, window, cx| { + let editor = view.editor(tab).clone(); + update_editor_text(&editor, "prefix\nselected path\nsuffix\n".into(), cx); + editor.update(cx, |editor, cx| { + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections + .select_ranges([MultiBufferOffset(7)..MultiBufferOffset(20)]); + }, + ); + }); + view.select_tab(tab, window, cx); + }) + .unwrap(); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("selected path") + ); + visual.simulate_input("should not edit"); + window + .update(cx, |view, _, cx| { + let editor = view.editor(tab); + assert_eq!(editor.read(cx).text(cx), "prefix\nselected path\nsuffix\n"); + update_editor_text( + editor, + "added\nprefix\nselected path\nsuffix\ntail\n".into(), + cx, + ); + }) + .unwrap(); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("selected path") + ); + } + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + #[gpui::test] + fn event_rollover_preserves_retained_selection(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let window = cx.add_window(|window, cx| WatcherDebug::new(app_state, window, cx)); + let snapshot = |dropped_events, paths: &[&str]| WatchSnapshot { + started_at_unix_millis: 0, + captured_at_unix_millis: 0, + capacity: 2, + dropped_events, + watchers: vec![], + events: paths + .iter() + .map(|path| { + Arc::new(WatchDiagnosticEvent { + timestamp_unix_millis: 0, + backend: OsWatcherKind::Native, + operation: "event".into(), + event_kind: Some("Create(File)".into()), + paths: vec![(*path).into()], + detail: String::new(), + rescan: false, + }) + }) + .collect(), + }; + window + .update(cx, |view, window, cx| { + let first = snapshot(0, &["/old\r\nfile", "/kept"]); + view.refresh_events(&first, cx); + view.snapshot = Some(first); + view.editor(WatcherTab::RawEvents).update(cx, |editor, cx| { + let start = editor.text(cx).find("/kept").unwrap(); + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([ + MultiBufferOffset(start)..MultiBufferOffset(start + 5) + ]); + }, + ); + }); + let next = snapshot(1, &["/kept", "/new"]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let text = view.editor(WatcherTab::RawEvents).read(cx).text(cx); + assert!(!text.contains("/old")); + assert!(text.contains("/new")); + view.select_tab(WatcherTab::RawEvents, window, cx); + }) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("/kept") + ); + let old_editor = window + .update(cx, |view, window, cx| { + let old_editor = view.editor(WatcherTab::RawEvents).downgrade(); + view.editor_edit_bytes[WatcherTab::RawEvents as usize] = 64 * 1024; + view.compact_editors(window, cx); + old_editor + }) + .unwrap(); + visual.run_until_parked(); + assert!(old_editor.upgrade().is_none()); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("/kept") + ); + window + .update(cx, |view, window, cx| { + let next = snapshot(10, &["/entirely", "/different"]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let text = view.editor(WatcherTab::RawEvents).read(cx).text(cx); + assert!(!text.contains("/kept")); + assert!(!text.contains("/new")); + assert!(text.contains("/entirely")); + assert_eq!(view.event_lengths.len(), 2); + let mut compactions = 0; + for cycle in 0..40 { + let path = format!("/{cycle}/{}", "x".repeat(4096)); + let next = snapshot(12 + cycle * 2, &[&path, &path]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let previous_editor = view.editor(WatcherTab::RawEvents).entity_id(); + view.compact_editors(window, cx); + let editor = view.editor(WatcherTab::RawEvents); + if previous_editor != editor.entity_id() { + compactions += 1; + } + let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let buffer = buffer.as_singleton().unwrap(); + assert!(buffer.deleted_text().len() < 64 * 1024); + assert_eq!(buffer.text().lines().count(), 2); + } + assert!(compactions > 1); + window.remove_window(); + }) + .unwrap(); + } + + #[gpui::test] + async fn tabs_separate_rows_and_keep_footer_visible(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let fs = app_state.fs.clone(); + let root = std::path::Path::new(util::path!("/watched")); + fs.create_dir(root).await.unwrap(); + let (_events, _watcher) = fs.watch(root, Duration::ZERO).await; + cx.update(|cx| { + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + fs.write(&root.join("event.txt"), b"first").await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(250)); + cx.run_until_parked(); + window + .update(cx, |view, _, cx| { + assert_eq!(view.active_tab, WatcherTab::RawEvents); + assert!( + view.editor(WatcherTab::RawEvents) + .read(cx) + .text(cx) + .contains("event.txt") + ); + }) + .unwrap(); + + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + assert_eq!( + visual.window_title().as_deref(), + Some("Debug Filesystem Watching") + ); + visual.run_until_parked(); + let footer = visual.debug_bounds("watcher-footer").unwrap(); + let status_bar_height = visual.update(|window, cx| { + ButtonSize::Default.rems().to_pixels(window.rem_size()) + + DynamicSpacing::Base04.rems(cx).to_pixels(window.rem_size()) * 2. + }); + assert_eq!(footer.size.height, status_bar_height); + for (selector, tab) in [ + ("TAB-Watch Roots", WatcherTab::WatchRoots), + ("TAB-Scan Exclusions", WatcherTab::ScanExclusions), + ("TAB-Raw Events", WatcherTab::RawEvents), + ] { + let bounds = visual.debug_bounds(selector).unwrap(); + visual.simulate_click(bounds.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + assert_eq!(visual.debug_bounds("watcher-footer"), Some(footer)); + window + .update(cx, |view, _, cx| { + assert_eq!(view.active_tab, tab); + let editor = view.editor(tab).read(cx); + assert!(editor.read_only(cx)); + let text = editor.text(cx); + match tab { + WatcherTab::RawEvents => { + assert!(text.contains("event.txt")) + } + WatcherTab::WatchRoots => { + assert!(text.contains("Native")); + assert!(text.contains(root.to_str().unwrap())); + } + WatcherTab::ScanExclusions => { + assert!(text.is_empty()); + } + } + }) + .unwrap(); + } + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + #[gpui::test] + fn singleton_releases_recording_view_on_close(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let app_state = AppState::test(cx); + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .expect("diagnostics window should open") + }); + let view = window + .update(cx, |view, _, cx| { + assert!(view.recording.is_some()); + cx.weak_entity() + }) + .expect("diagnostics window should exist"); + cx.update(|cx| cx.dispatch_action(&DebugFilesystemWatching)); + cx.update(|cx| { + assert_eq!( + cx.windows() + .into_iter() + .filter(|window| window.downcast::().is_some()) + .count(), + 1 + ); + }); + window + .update(cx, |_, window, _| window.remove_window()) + .expect("diagnostics window should close"); + cx.run_until_parked(); + assert!(view.upgrade().is_none()); + } + + #[gpui::test] + async fn saves_json_handles_cancellation_and_reports_errors(cx: &mut gpui::TestAppContext) { + cx.update(|cx| release_channel::init("1.2.3+dev.test".parse().unwrap(), cx)); + let app_state = cx.update(AppState::test); + let fs = app_state.fs.clone(); + let root = std::path::Path::new(util::path!("/exports")); + fs.create_dir(root).await.unwrap(); + let (_events, _watcher) = fs.watch(root, Duration::ZERO).await; + cx.update(|cx| { + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + assert!(cx.did_prompt_for_new_path()); + cx.simulate_new_path_selection(|_| None); + cx.run_until_parked(); + window + .update(cx, |view, _, _| { + assert!(!view.saving); + assert!(view.save_error.is_none()); + }) + .unwrap(); + + let path = root.join("watcher.json"); + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + cx.simulate_new_path_selection(|_| Some(path.clone())); + cx.run_until_parked(); + let json: serde_json::Value = serde_json::from_str(&fs.load(&path).await.unwrap()).unwrap(); + assert_eq!(json["zed_version"], "1.2.3+dev.test"); + assert_eq!(json["os_name"], client::telemetry::os_name()); + let os_version = cx + .background_executor + .spawn(async { client::telemetry::os_version() }) + .await; + assert_eq!(json["os_version"], os_version); + assert!(!os_version.is_empty()); + assert!(json.get("schema_version").is_none()); + assert_eq!(json["watcher"]["capacity"], 10_000); + assert_eq!( + json["watcher"]["watchers"][0]["roots"][0]["path"], + root.to_string_lossy().as_ref() + ); + assert_eq!(json["exclusion_scope"], EXCLUSION_SCOPE); + window + .update(cx, |view, _, _| { + assert!(!view.saving); + assert!(view.save_error.is_none()); + }) + .unwrap(); + + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + cx.simulate_new_path_selection(|_| Some(root.to_owned())); + cx.run_until_parked(); + window + .update(cx, |view, window, _| { + assert!(!view.saving); + assert!( + view.save_error + .as_ref() + .unwrap() + .starts_with("Save failed:") + ); + window.remove_window(); + }) + .unwrap(); + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 0f7c563b39..4034efd899 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -38,6 +38,7 @@ use git_ui::project_diff::ProjectDiffToolbar; use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar}; use git_ui::staged_diff::StagedDiffToolbar; use git_ui::unstaged_diff::UnstagedDiffToolbar; +use git_ui_core::file_diff_view::FileDiffStyleToolbar; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement, @@ -572,7 +573,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { .detach(); #[cfg(not(any(test, target_os = "macos")))] - initialize_file_watcher(window, cx); + initialize_file_watcher(workspace.app_state().fs.as_ref(), window, cx); if let Some(specs) = window.gpu_specs() { log::info!("Using GPU: {:?}", specs); @@ -602,12 +603,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let diagnostic_summary = cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new()); - let activity_indicator = activity_indicator::ActivityIndicator::new( - workspace, - workspace.project().read(cx).languages().clone(), - window, - cx, - ); + let activity_indicator = activity_indicator::ActivityIndicator::new(workspace, window, cx); let active_buffer_encoding = cx.new(|_| encoding_selector::ActiveBufferEncoding::new(workspace)); let active_buffer_language = @@ -615,6 +611,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let active_toolchain_language = cx.new(|cx| toolchain_selector::ActiveToolchain::new(workspace, window, cx)); let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx)); + let pending_keystrokes_indicator = + cx.new(|cx| which_key::PendingKeystrokesIndicator::new(window, cx)); let image_info = cx.new(|_cx| ImageInfo::new(workspace)); let lsp_button_menu_handle = PopoverMenuHandle::default(); @@ -646,9 +644,11 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { status_bar.add_right_item(active_buffer_language, window, cx); status_bar.add_right_item(active_toolchain_language, window, cx); status_bar.add_right_item(line_ending_indicator, window, cx); - status_bar.add_right_item(vim_mode_indicator, window, cx); status_bar.add_right_item(cursor_position, window, cx); status_bar.add_right_item(image_info, window, cx); + // Keep these last so they stay leftmost and can change without moving the other items. + status_bar.add_right_item(vim_mode_indicator, window, cx); + status_bar.add_right_item(pending_keystrokes_indicator, window, cx); }); let panels_task = initialize_panels(window, cx); @@ -664,8 +664,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { #[cfg(any(target_os = "linux", target_os = "freebsd"))] #[allow(unused)] -fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { - if let Err(e) = fs::fs_watcher::global(|_| {}) { +fn initialize_file_watcher(fs: &dyn Fs, window: &mut Window, cx: &mut Context) { + if let Err(e) = fs.start_native_watcher() { let message = format!( db::indoc! {r#" inotify_init returned {} @@ -695,8 +695,8 @@ fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { #[cfg(target_os = "windows")] #[allow(unused)] -fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { - if let Err(e) = fs::fs_watcher::global(|_| {}) { +fn initialize_file_watcher(fs: &dyn Fs, window: &mut Window, cx: &mut Context) { + if let Err(e) = fs.start_native_watcher() { let message = format!( db::indoc! {r#" ReadDirectoryChangesW initialization failed: {} @@ -1340,24 +1340,7 @@ fn register_actions( cx, |workspace, window, cx| { cx.activate(true); - // Create buffer synchronously to avoid flicker - let project = workspace.project().clone(); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer("", None, true, cx) - }); - buffer.update(cx, |buffer, _| { - buffer.set_content_language_detection_enabled(true); - }); - let editor = cx.new(|cx| { - Editor::for_buffer(buffer, Some(project), window, cx) - }); - workspace.add_item_to_active_pane( - Box::new(editor), - None, - true, - window, - cx, - ); + initialize_new_window(workspace, window, cx); }, ) .detach(); @@ -1484,6 +1467,8 @@ fn initialize_pane( toolbar.add_item(multibuffer_hint, window, cx); let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new); toolbar.add_item(solo_diff_style_toolbar, window, cx); + let file_diff_style_toolbar = cx.new(FileDiffStyleToolbar::new); + toolbar.add_item(file_diff_style_toolbar, window, cx); let breadcrumbs = cx.new(|_| Breadcrumbs::new()); toolbar.add_item(breadcrumbs, window, cx); let buffer_search_bar = cx.new(|cx| { @@ -2161,6 +2146,10 @@ pub fn handle_keymap_file_changes( let new_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0; let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; + if new_disable_ai != old_disable_ai { + reload_menus(cx); + } + if new_base_keymap != old_base_keymap || new_vim_enabled != old_vim_enabled || new_helix_enabled != old_helix_enabled @@ -2331,6 +2320,11 @@ fn show_markdown_app_notification( }) } +fn reload_menus(cx: &mut App) { + let menus = app_menus(cx); + cx.set_menus(menus); +} + fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { cx.clear_key_bindings(); load_default_keymap(cx); @@ -2339,9 +2333,7 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { key_binding.set_meta(KeybindSource::User.meta()); } cx.bind_keys(filter_disabled_ai_bindings(user_key_bindings, cx)); - - let menus = app_menus(cx); - cx.set_menus(menus); + reload_menus(cx); // On Windows, this is set in the `update_jump_list` method of the `HistoryManager`. #[cfg(not(target_os = "windows"))] cx.set_dock_menu(vec![gpui::MenuItem::action( @@ -2418,6 +2410,30 @@ fn filter_disabled_ai_bindings(bindings: Vec, cx: &App) -> Vec, +) { + if WorkspaceSettings::get_global(cx).on_new_window == settings::OnNewWindow::Launchpad { + return; + } + // Create buffer synchronously to avoid flicker + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("", None, true, cx) + }); + buffer.update(cx, |buffer, _| { + buffer.set_content_language_detection_enabled(true); + }); + let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); +} + pub fn open_new_ssh_project_from_project( workspace: &mut Workspace, paths: Vec, @@ -2859,8 +2875,8 @@ mod tests { use extension::ExtensionHostProxy; use fs::FakeFs; use gpui::{ - Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, TestAppContext, - UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px, + Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, OwnedMenuItem, + TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px, }; use http_client::BlockedHttpClient; use language::LanguageRegistry; @@ -2941,9 +2957,8 @@ mod tests { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); - let languages = project.read_with(cx, |project, _| project.languages().clone()); let indicator = workspace.update_in(cx, |workspace, window, cx| { - activity_indicator::ActivityIndicator::new(workspace, languages, window, cx) + activity_indicator::ActivityIndicator::new(workspace, window, cx) }); cx.run_until_parked(); @@ -3779,6 +3794,50 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_new_window_launchpad_opens_without_items(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.on_new_window = Some(settings::OnNewWindow::Launchpad); + }); + }); + }); + + cx.update(|cx| { + open_new( + Default::default(), + app_state.clone(), + cx, + |workspace, window, cx| initialize_new_window(workspace, window, cx), + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let multi_workspace = cx + .update(|cx| cx.windows().first().unwrap().downcast::()) + .unwrap(); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!( + workspace.active_item(cx).is_none(), + "launchpad window should not open any items" + ); + assert_eq!( + workspace.active_pane().read(cx).items_len(), + 0, + "launchpad window should have an empty pane so the launchpad is shown" + ); + }); + }) + .unwrap(); + } + #[gpui::test] async fn test_open_entry(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -5879,6 +5938,7 @@ mod tests { "language_selector", "welcome", "line_ending_selector", + "lsp_command_selector", "lsp_tool", "markdown", "menu", @@ -5889,7 +5949,6 @@ mod tests { "outline", "outline_panel", "pane", - "panel", "picker", "project_panel", "project_search", @@ -8098,4 +8157,156 @@ mod tests { window } + + #[gpui::test] + fn test_reload_keymaps_rebuilds_menus(cx: &mut TestAppContext) { + init_keymap_test(cx); + + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel in the View menu when AI is enabled" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to be in View menu" + ); + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.project.disable_ai = Some(SaturatingBool(true)) + }); + }) + }); + cx.update(|cx| { + reload_keymaps(cx, Vec::new()); + }); + cx.update(|cx| { + assert!( + !has_view_item(cx, "Agent Panel"), + "expected Agent Panel to be removed from the View menu after disabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.project.disable_ai = Some(SaturatingBool(false)); + }); + }); + }); + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel back in the View menu after re-enabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + } + + #[gpui::test] + async fn test_disable_ai_menu_update_with_malformed_keymap(cx: &mut TestAppContext) { + let executor = cx.executor(); + let app_state = init_keymap_test(cx); + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel before disabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics in the View menu" + ); + }); + + app_state + .fs + .save( + "/keymap.json".as_ref(), + &r#"!@!@this is not valid json"#.into(), + Default::default(), + ) + .await + .expect("failed to save malformed keymap.json to the fake fs"); + executor.run_until_parked(); + + cx.update(|cx| { + let (keymap_rx, keymap_watcher) = watch_config_file( + &executor, + app_state.fs.clone(), + PathBuf::from("/keymap.json"), + ); + watch_settings_files(app_state.fs.clone(), cx); + handle_keymap_file_changes(keymap_rx, keymap_watcher, cx); + }); + executor.run_until_parked(); + + app_state + .fs + .save( + paths::settings_file(), + &r#"{"disable_ai": true}"#.into(), + Default::default(), + ) + .await + .expect("failed to save disable_ai=true to the fake settings file"); + executor.advance_clock(Duration::from_secs(2)); + executor.run_until_parked(); + + cx.update(|cx| { + assert!( + !has_view_item(cx, "Agent Panel"), + "expected Agent Panel removed even though the keymap file is malformed" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + + app_state + .fs + .save( + paths::settings_file(), + &r#"{"disable_ai": false}"#.into(), + Default::default(), + ) + .await + .expect("failed to save disable_ai=false to the fake settings file"); + executor.run_until_parked(); + + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel added back even though the keymap file is malformed" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + } + + fn has_view_item(cx: &mut App, item_name: &str) -> bool { + cx.get_menus() + .expect("reload_keymaps should populate the menu bar") + .iter() + .find(|menu| menu.name == "View") + .expect("expected a View menu") + .items + .iter() + .any(|item| matches!(item, OwnedMenuItem::Action { name, .. } if name == item_name)) + } } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index bba2532543..aff790d5cc 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -1,6 +1,8 @@ use collab_ui::collab_panel; use gpui::{App, Menu, MenuItem, OsAction}; +use project::DisableAiSettings; use release_channel::ReleaseChannel; +use settings::Settings; use terminal_view::terminal_panel; use zed_actions::{Quit, assistant, debug_panel, dev, git_panel, project_panel}; @@ -43,12 +45,18 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("Collab Panel", collab_panel::ToggleFocus), MenuItem::action("Terminal Panel", terminal_panel::Toggle), MenuItem::action("Debugger Panel", debug_panel::ToggleFocus), - MenuItem::action("Agent Panel", assistant::ToggleFocus), + ]; + + if !DisableAiSettings::get_global(cx).disable_ai { + view_items.push(MenuItem::action("Agent Panel", assistant::ToggleFocus)); + } + + view_items.extend([ MenuItem::action("Git Panel", git_panel::ToggleFocus), MenuItem::separator(), MenuItem::action("Diagnostics", diagnostics::Deploy), MenuItem::separator(), - ]; + ]); if ReleaseChannel::try_global(cx) == Some(ReleaseChannel::Dev) { view_items.push(MenuItem::action( diff --git a/crates/zed_actions/Cargo.toml b/crates/zed_actions/Cargo.toml index 08dd736002..5f2dd6fa58 100644 --- a/crates/zed_actions/Cargo.toml +++ b/crates/zed_actions/Cargo.toml @@ -8,6 +8,10 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +test = false +doctest = false + [dependencies] gpui.workspace = true schemars.workspace = true diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 7f0a14d810..149991a64d 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -254,7 +254,9 @@ pub mod dev { ToggleFpsOverlay, /// Resets the debug frame-time overlay's statistics, except for the /// total frame count. - ResetFrameOverlayStats + ResetFrameOverlayStats, + /// Opens the key context view for debugging keybindings. + OpenKeyContextView ] ); } diff --git a/crates/zed_credentials_provider/Cargo.toml b/crates/zed_credentials_provider/Cargo.toml index 653aa05b80..50bbc74cbb 100644 --- a/crates/zed_credentials_provider/Cargo.toml +++ b/crates/zed_credentials_provider/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/zed_credentials_provider.rs" +test = false +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/zed_env_vars/Cargo.toml b/crates/zed_env_vars/Cargo.toml index bf863b7425..28a35c8f33 100644 --- a/crates/zed_env_vars/Cargo.toml +++ b/crates/zed_env_vars/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/zed_env_vars.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/zeta_prompt/Cargo.toml b/crates/zeta_prompt/Cargo.toml index 8acd91a7a4..e4601383f9 100644 --- a/crates/zeta_prompt/Cargo.toml +++ b/crates/zeta_prompt/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/zeta_prompt.rs" +doctest = false [dependencies] anyhow.workspace = true diff --git a/crates/zlog/Cargo.toml b/crates/zlog/Cargo.toml index 46f2cff324..b1a8ce3a43 100644 --- a/crates/zlog/Cargo.toml +++ b/crates/zlog/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [lib] path = "src/zlog.rs" +doctest = false [features] default = [] diff --git a/crates/zlog_settings/Cargo.toml b/crates/zlog_settings/Cargo.toml index 39c3b6a193..f7ca29c82a 100644 --- a/crates/zlog_settings/Cargo.toml +++ b/crates/zlog_settings/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [lib] path = "src/zlog_settings.rs" +test = false +doctest = false [features] default = [] diff --git a/crates/ztracing/Cargo.toml b/crates/ztracing/Cargo.toml index e823785acd..c08ca435cb 100644 --- a/crates/ztracing/Cargo.toml +++ b/crates/ztracing/Cargo.toml @@ -8,6 +8,9 @@ license = "Apache-2.0" [lints] workspace = true +[lib] +doctest = false + [features] tracy = ["tracing-tracy", "tracy-client"] web = ["dep:async-channel", "dep:js-sys", "dep:wasm-bindgen", "dep:web-sys"] diff --git a/crates/ztracing_macro/Cargo.toml b/crates/ztracing_macro/Cargo.toml index 5420bc6063..78feebb873 100644 --- a/crates/ztracing_macro/Cargo.toml +++ b/crates/ztracing_macro/Cargo.toml @@ -7,5 +7,5 @@ license = "Apache-2.0" [lib] proc-macro = true - -[dependencies] +test = false +doctest = false diff --git a/debug.plist b/debug.plist deleted file mode 100644 index e09573c9d1..0000000000 --- a/debug.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.get-task-allow - - - diff --git a/docs/src/account/billing.md b/docs/src/account/billing.md index 88a32467fc..e5d70fa2a3 100644 --- a/docs/src/account/billing.md +++ b/docs/src/account/billing.md @@ -18,7 +18,7 @@ This page embeds data from Orb, our invoicing and metering partner. ### Billing cycles {#billing-cycles} -Zed is billed on a monthly basis based on the date you initially subscribe. You'll receive _at least_ one invoice from Zed each month you're subscribed to Zed Pro, and may receive more than one invoice if you use hosted models beyond your included monthly token credit. +Zed is billed on a monthly basis based on the date you initially subscribe. You'll receive _at least_ one invoice from Zed each month you're subscribed to Zed Pro, and may receive more than one invoice if you use [hosted models](./zed-hosted-models.md) beyond your included monthly token credit. ### Zed Pro threshold billing {#threshold-billing} diff --git a/docs/src/account/plans-and-pricing.md b/docs/src/account/plans-and-pricing.md index e76819cddb..5dc34e5c9d 100644 --- a/docs/src/account/plans-and-pricing.md +++ b/docs/src/account/plans-and-pricing.md @@ -11,15 +11,15 @@ Zed works without AI features or a subscription. No [authentication](../authenti ## Plans {#plans} -| | Free | Pro | Student | Business | -| ---------------------------------------------- | ------- | --------- | --------- | --------- | -| Zed-hosted AI models | — | ✓ | ✓ | ✓ | -| [AI via own API keys](../ai/use-api-access.md) | ✓ | ✓ | ✓ | ✓ | -| [External Agents](../ai/external-agents.md) | ✓ | ✓ | ✓ | ✓ | -| Edit Predictions | Limited | Unlimited | Unlimited | Unlimited | -| Org-wide admin controls | — | — | — | ✓ | -| Roles & permissions | — | — | — | ✓ | -| Consolidated billing | — | — | — | ✓ | +| | Free | Pro | Student | Business | +| -------------------------------------------------------- | ------- | --------- | --------- | --------- | +| Zed-hosted AI models | — | ✓ | ✓ | ✓ | +| [AI via own API keys](../ai/use-api-access.md) | ✓ | ✓ | ✓ | ✓ | +| [External Agents](../ai/external-agents.md) | ✓ | ✓ | ✓ | ✓ | +| Edit Predictions | Limited | Unlimited | Unlimited | Unlimited | +| [Org-wide admin controls](../business/admin-controls.md) | — | — | — | ✓ | +| Roles & permissions | — | — | — | ✓ | +| Consolidated billing | — | — | — | ✓ | ### Zed Free {#free} @@ -27,7 +27,7 @@ Zed is free to use. You can configure AI agents with your own API keys via [Use ### Zed Pro {#pro} -Zed Pro includes access to all hosted AI models and Edit Predictions. The plan includes $5 of monthly token credit; usage beyond that is billed at the rates listed on [Zed-Hosted Models](./zed-hosted-models.md). A trial of Zed Pro includes $20 of credit, usable for 14 days. +Zed Pro includes access to all hosted AI models and Edit Predictions. The plan includes $5 of monthly token credit; usage beyond that is billed at the rates listed on [Zed-Hosted Models](./zed-hosted-models.md). A [trial of Zed Pro](#trials) includes $5 of GPT Luna and unlimited Edit Predictions for 14 days from when you start the trial. For details on billing and payment, see [Individual Billing](./billing.md). @@ -65,4 +65,6 @@ On Zed Business, administrators set a pre-tax org-wide spend limit from the Data ### Trials {#trials} -Trials automatically convert to Zed Free when they end. Trials do not include access to Anthropic's Opus models. No cancellation is needed to prevent conversion to Zed Pro. +Trials include $5 of GPT Luna and unlimited Edit Predictions for 14 days from when you start the trial. Trial hosted-model access is limited to GPT Luna. Zed and Delta share the same $5 trial balance. No credit card is required. + +Trials automatically convert to Zed Free when they end. No cancellation is needed to prevent conversion to Zed Pro. diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index a26189de21..127b163ca9 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -79,7 +79,8 @@ The checkpoint button appears even if you interrupt the thread midway through an Right-click on any agent response in the thread view to access a context menu with the following actions: -- **Copy Selection**: Copies the currently selected text as Markdown (available when text is selected). +- **Copy**: Copies the currently selected text as plain text (available when text is selected). +- **Copy as Markdown**: Copies the currently selected text as Markdown (available when text is selected). - **Copy This Agent Response**: Copies the full text of the agent response you right-clicked on. - **Scroll to Top / Scroll to Bottom**: Scrolls to the beginning or end of the thread, depending on your current position. - **Open Thread as Markdown**: Opens the entire thread as a Markdown file in a new tab. @@ -102,6 +103,14 @@ Your editor will jump to each file the agent touches. You can also hold `cmd`/`ctrl` when submitting a message to automatically follow. +### Keeping the System Awake {#prevent-idle-sleep} + +While an agent thread is running, Zed asks the operating system not to idle-sleep, so long-running turns are not interrupted when you step away. +The display can still turn off and the screen can still lock; only system sleep is held back, and only while a thread is generating, not while it waits for your confirmation. + +Turn this off with the `agent.prevent_idle_sleep` setting. +If the operating system rejects the request, Zed logs the reason and continues without it. + ### Get Notified {#get-notified} If you send a prompt to the Agent and then put Zed in the background, you can choose to be notified when its generation wraps up via: @@ -131,7 +140,7 @@ The Agent Panel can host Terminal Threads alongside your agent threads. For open The agent can search your codebase to find relevant context, but providing it explicitly improves response quality and reduces latency. Add context by typing `@` in the message editor. -You can mention files, directories, symbols, previous threads, skills, diagnostics, branch diffs, and URLs to fetch. +You can mention files, directories, symbols, previous threads, [skills](./skills.md), diagnostics, branch diffs, and URLs to fetch. When you paste multi-line code selections copied from a buffer, Zed automatically formats them as @-mentions with the file context. To paste content without this automatic formatting, use {#kb agent::PasteRaw} to paste raw text directly. diff --git a/docs/src/ai/agent-profiles.md b/docs/src/ai/agent-profiles.md index 2ae8924776..fbd1c6dcd3 100644 --- a/docs/src/ai/agent-profiles.md +++ b/docs/src/ai/agent-profiles.md @@ -5,7 +5,7 @@ description: Configure Zed Agent profiles for model selection, built-in tool ava # Agent Profiles -Agent profiles control how the [Zed Agent](./zed-agent.md) behaves in a thread. A profile can set a default model and choose which built-in tools and MCP tools are available. +Agent profiles control how the [Zed Agent](./zed-agent.md) behaves in a thread. A profile can set a default model and choose which [built-in tools](./tools.md) and MCP tools are available. Profiles do not decide whether a tool call is allowed automatically. Use [Tool Permissions](./tool-permissions.md) to control allow, deny, and confirm behavior. @@ -19,7 +19,7 @@ Zed includes three built-in profiles: ## Configure Profiles {#configure-profiles} -Open the profile selector in the Agent Panel, then click `Configure`. +Open the profile selector in the [Agent Panel](./agent-panel.md), then click `Configure`. You can also run {#action agent::ManageProfiles} from the command palette. @@ -73,4 +73,4 @@ If a tool is not available in the active profile, the Zed Agent cannot use it. I ## Agent Path Boundaries {#agent-path-boundaries} -Agent profiles apply to the Zed Agent. External Agents and Terminal Threads do not use Zed Agent profiles unless their integration explicitly supports similar behavior. +Agent profiles apply to the Zed Agent. External Agents and [Terminal Threads](./terminal-threads.md) do not use Zed Agent profiles unless their integration explicitly supports similar behavior. diff --git a/docs/src/ai/agents.md b/docs/src/ai/agents.md index bed5e7184e..c2dead09a1 100644 --- a/docs/src/ai/agents.md +++ b/docs/src/ai/agents.md @@ -7,11 +7,11 @@ description: Compare Zed Agent, External Agents, and Terminal Threads. Zed supports three agent paths. Choose the path based on how you want agentic work to run. -| Agent path | Runs in | Uses | Best when | -| ----------------------------------------- | ------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| [Zed Agent](./zed-agent.md) | Agent Panel and Threads Sidebar | Zed-configured LLM providers, native tools, skills, instructions, MCP | You want Zed's native agent integration | -| [External Agents](./external-agents.md) | Agent Panel and Threads Sidebar | ACP agent process and its own auth/config | You want Claude, Codex, OpenCode, Copilot, Cursor, Pi, or another ACP-integrated agent | -| [Terminal Threads](./terminal-threads.md) | Threads Sidebar and terminal | Native CLI/TUI auth/config | You want the tool's command-line experience organized in Zed | +| Agent path | Runs in | Uses | Best when | +| ----------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| [Zed Agent](./zed-agent.md) | [Agent Panel](./agent-panel.md) and Threads Sidebar | Zed-configured LLM providers, native tools, skills, [instructions](./instructions.md), MCP | You want Zed's native agent integration | +| [External Agents](./external-agents.md) | Agent Panel and Threads Sidebar | ACP agent process and its own auth/config | You want Claude, Codex, OpenCode, Copilot, Cursor, Pi, or another ACP-integrated agent | +| [Terminal Threads](./terminal-threads.md) | Threads Sidebar and terminal | Native CLI/TUI auth/config | You want the tool's command-line experience organized in Zed | An agent path is sometimes called a harness. It is the way agentic work is started, displayed, configured, and controlled in Zed. diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 09bcde5415..b49506ac4b 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -41,7 +41,7 @@ The free plan includes 2,000 Zeta predictions per month. The [Pro plan](../accou Edit Prediction has two display modes: -1. `eager` (default): predictions are displayed inline as long as they don't conflict with language server completions +1. `eager` (default): predictions are displayed inline as long as they don't conflict with [language server completions](../completions.md) 2. `subtle`: predictions only appear inline when holding a modifier key (`alt` by default) Toggle between them via the `mode` key: @@ -73,7 +73,7 @@ In `eager` mode, you can also use the `tab` key to accept edit predictions, unle To always use `tab` for accepting edit predictions, regardless of whether the LSP completions menu is open, you can add the following to your keymap: -Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and hit `edit`. Then change the context the binding is active in to just `Editor && edit_prediction` and save it. +Open the [keymap editor](../key-bindings.md) with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and hit `edit`. Then change the context the binding is active in to just `Editor && edit_prediction` and save it. Alternatively, you can put the following in your `keymap.json`: diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index e60dd32240..a9f5f5e8dc 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -124,16 +124,16 @@ You can also create keybindings for specific agents with {#action agent::NewExte External Agents run as separate processes that communicate with Zed over ACP. This creates a boundary between Zed configuration and agent-native configuration. -| Capability | Behavior in External Agent threads | -| -------------------------------- | ------------------------------------------------------------------------------------------ | -| Model/provider config | Usually owned by the External Agent | -| Auth/API keys/subscriptions | Usually owned by the External Agent | -| Zed Agent profiles | Do not apply unless the integration says otherwise | -| Zed Skills | Do not apply as Zed Skills | -| Native agent skills/instructions | Depends on the agent | -| Zed MCP servers | May be forwarded over ACP | -| Native MCP config | May also be read by the agent | -| Tool permissions | Zed ACP/tool forwarding permissions may apply; native tool permissions depend on the agent | +| Capability | Behavior in External Agent threads | +| ----------------------------------------- | ------------------------------------------------------------------------------------------ | +| Model/provider config | Usually owned by the External Agent | +| Auth/API keys/subscriptions | Usually owned by the External Agent | +| [Zed Agent profiles](./agent-profiles.md) | Do not apply unless the integration says otherwise | +| Zed Skills | Do not apply as Zed Skills | +| Native agent skills/instructions | Depends on the agent | +| Zed MCP servers | May be forwarded over ACP | +| Native MCP config | May also be read by the agent | +| Tool permissions | Zed ACP/tool forwarding permissions may apply; native tool permissions depend on the agent | For Zed's native agent configuration, see [Zed Agent](./zed-agent.md). diff --git a/docs/src/ai/inline-assistant.md b/docs/src/ai/inline-assistant.md index 2112665ac8..bb9cbdf367 100644 --- a/docs/src/ai/inline-assistant.md +++ b/docs/src/ai/inline-assistant.md @@ -21,7 +21,7 @@ You can do that by: If you have already set up an LLM provider to interact with [the Agent Panel](./agent-panel.md#getting-started), then that will also work for the Inline Assistant. -[External Agents](./external-agents.md) run in agent threads, but they are not available for Inline Assistant generations. The Inline Assistant uses Zed-configured [LLM providers](./llm-providers.md), including Zed-hosted models, provider API keys, gateways, local models, and supported subscriptions. +[External Agents](./external-agents.md) run in agent threads, but they are not available for Inline Assistant generations. The Inline Assistant uses Zed-configured [LLM providers](./llm-providers.md), including Zed-hosted models, provider API keys, gateways, [local models](./use-a-local-model.md), and supported subscriptions. ## Adding Context diff --git a/docs/src/ai/instructions.md b/docs/src/ai/instructions.md index 0f25bcae7b..39bbda7c9f 100644 --- a/docs/src/ai/instructions.md +++ b/docs/src/ai/instructions.md @@ -41,14 +41,14 @@ Project instructions override personal `AGENTS.md` when they conflict. ## Instruction File Support {#support} -| File | Zed Agent | External Agents | Terminal Threads | -| --------------------------------- | ------------------------------------------------------ | --------------------- | -------------------------------- | -| `~/.config/zed/AGENTS.md` | Loaded as personal instructions | Not generally used | Not used unless the CLI reads it | -| Project `AGENTS.md` | Loaded as project instructions | Depends on the agent | Depends on the CLI | -| `CLAUDE.md` | Loaded as compatible project instructions by Zed Agent | Claude reads natively | Claude Code CLI reads natively | -| `.github/copilot-instructions.md` | Loaded as compatible project instructions by Zed Agent | Depends on the agent | Depends on the CLI | +| File | [Zed Agent](./zed-agent.md) | [External Agents](./external-agents.md) | Terminal Threads | +| --------------------------------- | ------------------------------------------------------ | --------------------------------------- | -------------------------------- | +| `~/.config/zed/AGENTS.md` | Loaded as personal instructions | Not generally used | Not used unless the CLI reads it | +| Project `AGENTS.md` | Loaded as project instructions | Depends on the agent | Depends on the CLI | +| `CLAUDE.md` | Loaded as compatible project instructions by Zed Agent | Claude reads natively | Claude Code CLI reads natively | +| `.github/copilot-instructions.md` | Loaded as compatible project instructions by Zed Agent | Depends on the agent | Depends on the CLI | -External Agents and Terminal Threads may read their own native instruction files directly. Do not assume Zed's instruction loader controls those agents. +External Agents and [Terminal Threads](./terminal-threads.md) may read their own native instruction files directly. Do not assume Zed's instruction loader controls those agents. ## Instructions vs. Skills {#instructions-vs-skills} diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 89d7beac86..193d2b3103 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -51,7 +51,7 @@ Popular servers available as an extension include: ### As Custom Servers Creating an extension is not the only way to use MCP servers in Zed. -You can connect both local and remote MCP servers from **Settings → AI → MCP Servers** (also accessible via the {#action agent::OpenSettings} action, then selecting `MCP Servers`). Click `Add Server` in the page header, then choose `Add Local Server` or `Add Remote Server`. Your specified configuration will create entries in your settings file (which you can open with {#action zed::OpenSettingsFile}) similar to the ones below: +You can connect both local and remote MCP servers from **[Settings → AI → MCP Servers](./agent-settings.md)** (also accessible via the {#action agent::OpenSettings} action, then selecting `MCP Servers`). Click `Add Server` in the page header, then choose `Add Local Server` or `Add Remote Server`. Your specified configuration will create entries in your settings file (which you can open with {#action zed::OpenSettingsFile}) similar to the ones below: ```json [settings] { diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index 87ecba9277..a1ddb760fd 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -7,11 +7,11 @@ description: Understand Zed's AI features, agent paths, model providers, and set Zed's AI docs are organized around three areas: -| Area | Use it to choose | Examples | -| ------------ | ----------------------------------- | -------------------------------------------------------------------- | -| Agents | How agentic work runs in Zed | Zed Agent, External Agents, Terminal Threads | -| Model access | How Zed connects to language models | Zed-hosted models, API access, subscriptions, gateways, local models | -| Features | Which AI workflow you want to use | Agentic editing, inline edits, edit prediction, Git assistance | +| Area | Use it to choose | Examples | +| ------------ | ----------------------------------- | ------------------------------------------------------------------------------------------ | +| Agents | How agentic work runs in Zed | Zed Agent, External Agents, Terminal Threads | +| Model access | How Zed connects to language models | Zed-hosted models, API access, subscriptions, [gateways](./use-a-gateway.md), local models | +| Features | Which AI workflow you want to use | Agentic editing, inline edits, edit prediction, Git assistance | Start with [AI Quick Start](./quick-start.md) if you know what you want to do. Use [AI by Company](./by-company.md) if you know the company, subscription, model provider, agent, or CLI you want to use. @@ -19,7 +19,7 @@ Start with [AI Quick Start](./quick-start.md) if you know what you want to do. U Agent paths decide how agentic work runs in Zed. -- [Zed Agent](./zed-agent.md): Zed's native agent. It can use models configured through [LLM Providers](./llm-providers.md), including Zed-hosted models, provider API keys, supported subscriptions, gateways, and local models. It also uses built-in tools, profiles, skills, instructions, and MCP servers. +- [Zed Agent](./zed-agent.md): Zed's native agent. It can use models configured through [LLM Providers](./llm-providers.md), including [Zed-hosted models](../account/zed-hosted-models.md), provider API keys, supported subscriptions, gateways, and [local models](./use-a-local-model.md). It also uses built-in tools, profiles, skills, instructions, and MCP servers. - [External Agents](./external-agents.md): ACP-integrated agents that run through their own process and configuration. - [Terminal Threads](./terminal-threads.md): terminal-backed threads for running an agent CLI or TUI directly in Zed. @@ -29,7 +29,7 @@ See [Agents](./agents.md) for a comparison. ## Model Access {#model-access} -Model access controls which models power the Zed Agent and other model-backed Zed AI features. Zed can use hosted models, provider API access, subscription sign-in, gateways, and local models. +Model access controls which models power the Zed Agent and other model-backed Zed AI features. Zed can use hosted models, [provider API access](./use-api-access.md), subscription sign-in, gateways, and local models. See [LLM Providers](./llm-providers.md) to choose a model access path. diff --git a/docs/src/ai/parallel-agents.md b/docs/src/ai/parallel-agents.md index 70f0ff4f93..a23c1d4b86 100644 --- a/docs/src/ai/parallel-agents.md +++ b/docs/src/ai/parallel-agents.md @@ -19,6 +19,12 @@ Terminal Threads also appear as entries in the sidebar alongside agent threads, To focus the sidebar without toggling it, use {#kb multi_workspace::FocusWorkspaceSidebar}. To search your threads, press {#kb agents_sidebar::FocusSidebarFilter} while the sidebar is focused. +To change the sidebar’s position, open the Settings Editor and search for “Threads Sidebar Position”, which writes `agent.threads_sidebar.position`. To change its default width, search for “Threads Sidebar Default Width”. See [`agent.threads_sidebar.default_width`](../reference/all-settings.md#agent-threads-sidebar-default-width) for values and a configuration example. + +Drag the divider to resize the sidebar. Changing the default width updates even a manually resized sidebar. Double-click the divider to return to the configured width. + +The “Threads Sidebar Auto Open” setting controls whether opening a folder in an existing window opens the sidebar. Its JSON key is `agent.threads_sidebar.auto_open`. + ### Switching Threads {#switching-threads} Click any thread in the sidebar to switch to it. The Agent Panel updates to show that thread's conversation. @@ -53,11 +59,11 @@ Each thread can use a different agent, so you can run Zed's built-in agent in on The Threads Sidebar can hold different thread types: -| Thread type | Configuration | -| --------------------------------------------- | ------------------------------------------------------------------------------- | -| [Zed Agent thread](./zed-agent.md) | Uses Zed Agent settings, profiles, tools, Skills, Instructions, and MCP | -| [External Agent thread](./external-agents.md) | Uses the ACP integration and the agent's native configuration | -| [Terminal Thread](./terminal-threads.md) | Runs a CLI/TUI in a terminal-backed thread; the CLI owns auth and configuration | +| Thread type | Configuration | +| --------------------------------------------- | -------------------------------------------------------------------------------------- | +| [Zed Agent thread](./zed-agent.md) | Uses Zed Agent settings, profiles, tools, [Skills](./skills.md), Instructions, and MCP | +| [External Agent thread](./external-agents.md) | Uses the ACP integration and the agent's native configuration | +| [Terminal Thread](./terminal-threads.md) | Runs a CLI/TUI in a terminal-backed thread; the CLI owns auth and configuration | ## Multiple Projects {#multiple-projects} diff --git a/docs/src/ai/skills.md b/docs/src/ai/skills.md index 0d2f4dda2f..a3d6a26e60 100644 --- a/docs/src/ai/skills.md +++ b/docs/src/ai/skills.md @@ -119,11 +119,11 @@ Step-by-step instructions for the agent... #### Frontmatter Fields {#frontmatter-fields} -| Field | Required | Description | -| -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `name` | Yes | Lowercase letters, numbers, and hyphens only. Max 64 characters. Should match the folder name. | -| `description` | Yes | What the skill does and when to use it. Keep it under 1024 bytes; skills with longer descriptions still load, but with a warning. | -| `disable-model-invocation` | No | Set to `true` to hide from the agent's catalog (invocable via slash command or @-mention only). | +| Field | Required | Description | +| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Lowercase letters, numbers, and hyphens only. Max 64 characters. Should match the folder name. | +| `description` | Yes | What the skill does and when to use it. Keep it at most 1024 characters; skills with longer descriptions still load, but with a warning. | +| `disable-model-invocation` | No | Set to `true` to hide from the agent's catalog (invocable via slash command or @-mention only). | > **Tip:** Write descriptions that help the agent recognize when a skill is relevant. Include specific task types and trigger phrases: "Use when handling PDFs, extracting text, or filling forms" is better than "Helps with PDFs." @@ -190,7 +190,7 @@ The agent cannot edit `SKILL.md` files or their bundled resources without your e ## Agent Path Boundaries {#agent-path-boundaries} -Zed Skills apply to the Zed Agent. External Agents and Terminal Threads may have their own native skills, prompts, or instruction systems. Configure those in the External Agent or CLI. +Zed Skills apply to the Zed Agent. [External Agents](./external-agents.md) and Terminal Threads may have their own native skills, prompts, or instruction systems. Configure those in the External Agent or CLI. ## Limitations {#limitations} diff --git a/docs/src/ai/terminal-threads.md b/docs/src/ai/terminal-threads.md index b470b48ff2..7d20323e52 100644 --- a/docs/src/ai/terminal-threads.md +++ b/docs/src/ai/terminal-threads.md @@ -28,7 +28,7 @@ The CLI or TUI running inside the terminal owns its own: - skills and instruction files - MCP configuration -Zed Agent profiles, Zed Agent tool permissions, Zed Skills, and Zed Agent MCP settings do not automatically apply to Terminal Threads. +[Zed Agent profiles](./agent-profiles.md), Zed Agent tool permissions, Zed Skills, and Zed Agent MCP settings do not automatically apply to Terminal Threads. ## Opening a Terminal Thread {#opening-a-terminal-thread} @@ -56,6 +56,8 @@ You can also configure this from the Settings UI under **AI**, via the "Terminal The terminal title in the toolbar updates automatically to reflect the running shell or process. You can set a custom name by clicking the title or the pencil icon that appears on hover. In the Threads Sidebar, right-click a Terminal Thread and select **Rename Title**, or select it and press {#kb agent::RenameSelectedThread}. +To edit the title of the active Terminal Thread from the Agent Panel, custom-map {#action agent::RenameSelectedThread} in your `keymap.json`. Its default binding is scoped to the Threads Sidebar. + ## Notifications {#terminal-thread-notifications} When a terminal produces a bell character while not in focus, Zed notifies you the same way it does when an agent finishes: with a visual pop-up and an optional sound. Clicking the notification brings the terminal into focus and clears the indicator. @@ -159,7 +161,7 @@ terminal_title = ["spinner", "project-name", "run-state", "thread-title"] Credentials come from the terminal session and the CLI/TUI running inside it. -In remote projects, the CLI may read the remote shell environment and remote config files. In local Terminal Threads, it reads the local shell environment and local config files. Zed does not copy API keys from LLM provider settings into Terminal Threads. +In remote projects, the CLI may read the remote shell environment and remote config files. In local Terminal Threads, it reads the local shell environment and local config files. Zed does not copy [API keys from LLM provider settings](./use-api-access.md) into Terminal Threads. ## When to Use Terminal Threads {#when-to-use-terminal-threads} diff --git a/docs/src/ai/tool-permissions.md b/docs/src/ai/tool-permissions.md index 95f8240278..09bed49a99 100644 --- a/docs/src/ai/tool-permissions.md +++ b/docs/src/ai/tool-permissions.md @@ -56,7 +56,7 @@ The `tool_permissions` setting lets you customize tool permissions by specifying | `search_web` | The search query | | `skill` | The absolute path to the skill's `SKILL.md` file | -For MCP tools, use the format `mcp::`. +For [MCP tools](./mcp.md), use the format `mcp::`. For example, a tool called `create_issue` on a server called `github` would be `mcp:github:create_issue`. For model-invoked [Skills](./skills.md), use the `skill` tool. A user-invoked `/skill-name` slash command does not prompt again because you explicitly invoked the skill. diff --git a/docs/src/ai/use-a-gateway.md b/docs/src/ai/use-a-gateway.md index b1e05dcb8d..111de74b10 100644 --- a/docs/src/ai/use-a-gateway.md +++ b/docs/src/ai/use-a-gateway.md @@ -7,12 +7,12 @@ description: Configure OpenRouter, Vercel AI Gateway, Amazon Bedrock, and other Use a gateway when you route model requests through a platform such as OpenRouter, Vercel AI Gateway, Amazon Bedrock, or another OpenAI-compatible service. -| Gateway | Zed AI features | External Agents | Terminal Threads | Notes | -| ------------------------- | --------------- | --------------- | ---------------- | -------------------------------------------- | -| OpenRouter | Yes | Separate config | Separate config | Uses OpenRouter API access | -| Vercel AI Gateway | Yes | Separate config | Separate config | Uses Vercel AI Gateway API access | -| Amazon Bedrock | Yes | Separate config | Separate config | Uses AWS credentials or Bedrock bearer token | -| OpenAI-compatible gateway | Yes | Separate config | Separate config | Configure base URL, model, and key | +| Gateway | Zed AI features | [External Agents](./external-agents.md) | Terminal Threads | Notes | +| ------------------------- | --------------- | --------------------------------------- | ---------------- | -------------------------------------------- | +| OpenRouter | Yes | Separate config | Separate config | Uses OpenRouter API access | +| Vercel AI Gateway | Yes | Separate config | Separate config | Uses Vercel AI Gateway API access | +| Amazon Bedrock | Yes | Separate config | Separate config | Uses AWS credentials or Bedrock bearer token | +| OpenAI-compatible gateway | Yes | Separate config | Separate config | Configure base URL, model, and key | ## OpenRouter {#openrouter} @@ -160,7 +160,7 @@ For a named profile, configure Bedrock in settings: } ``` -For static credentials, open Agent Settings with {#action agent::OpenSettings}, go to the Amazon Bedrock section, and enter the access key ID, secret access key, and region. +For static credentials, open [Agent Settings](./agent-settings.md) with {#action agent::OpenSettings}, go to the Amazon Bedrock section, and enter the access key ID, secret access key, and region. For a Bedrock API key, choose API key authentication: diff --git a/docs/src/ai/use-a-local-model.md b/docs/src/ai/use-a-local-model.md index 5ffc60172c..d423393b1d 100644 --- a/docs/src/ai/use-a-local-model.md +++ b/docs/src/ai/use-a-local-model.md @@ -165,4 +165,4 @@ Edit Prediction has its own provider setup. See [Edit Prediction](./edit-predict ## Agent Path Boundaries {#agent-path-boundaries} -This page covers local models configured in Zed. External Agents and terminal CLIs may have their own local-model setup; configure those in the agent or CLI. +This page covers local models configured in Zed. [External Agents](./external-agents.md) and terminal CLIs may have their own local-model setup; configure those in the agent or CLI. diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md index 2064fce9c1..e6770d5106 100644 --- a/docs/src/ai/use-api-access.md +++ b/docs/src/ai/use-api-access.md @@ -43,7 +43,7 @@ agent paths and model access paths. Most API-access providers can be configured on the **Settings → AI → LLM Providers** page with {#action agent::OpenSettings}. Keys saved through Zed are stored in the system keychain, not in `settings.json`. -Zed also reads provider-specific environment variables. Non-empty environment variables take precedence over keychain values. If a key comes from an environment variable, unset the variable and restart Zed to stop using it. +Zed also reads provider-specific environment variables. Non-empty [environment variables](../environment.md) take precedence over keychain values. If a key comes from an environment variable, unset the variable and restart Zed to stop using it. | Provider | Environment variable | | ----------------- | ----------------------------------------------------- | @@ -80,7 +80,7 @@ Configure them with `language_models..custom_headers`: } ``` -`custom_headers` is supported by Amazon Bedrock, Anthropic, DeepSeek, Google AI, LM Studio, Mistral, Ollama, OpenAI, OpenAI-compatible providers, OpenCode, OpenRouter, Vercel AI Gateway, and xAI. +`custom_headers` is supported by Amazon Bedrock, Anthropic, DeepSeek, Google AI, LM Studio, Mistral, Ollama, OpenAI, OpenAI-compatible providers, OpenCode, OpenRouter, [Vercel AI Gateway](./use-a-gateway.md), and xAI. Headers managed by Zed for each provider, such as `Authorization`, `Content-Type`, `Accept`, and provider-specific authentication headers, are ignored with a warning if you try to override them. @@ -188,7 +188,7 @@ You must provide the model's context window in `max_tokens`. For reasoning-focus Use Google AI API access when you have a Gemini API key. 1. Go to Google AI Studio and [create an API key](https://aistudio.google.com/app/apikey). -2. Open Agent Settings with {#action agent::OpenSettings} and go to the Google AI section. +2. Open [Agent Settings](./agent-settings.md) with {#action agent::OpenSettings} and go to the Google AI section. 3. Enter your Google AI API key. Zed reads `GEMINI_API_KEY`, falling back to `GOOGLE_AI_API_KEY`, from the local Zed process environment. @@ -285,8 +285,8 @@ limits, or a custom endpoint. "api_url": "https://api.deepseek.com/v1", "available_models": [ { - "name": "deepseek-v4-flash", - "display_name": "DeepSeek V4 Flash", + "name": "deepseek-flash", + "display_name": "DeepSeek V4.1 Flash", "max_tokens": 1000000, "max_output_tokens": 384000 }, @@ -418,7 +418,7 @@ Custom OpenCode models are listed in the model dropdown in the Agent Panel. Use an Anthropic-compatible endpoint when a service implements Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) (`/v1/messages`) and gives you a custom base URL, model ID, and API key. -You can add a custom Anthropic-compatible provider from Agent Settings with {#action agent::OpenSettings}. Look for `Add Provider` in the LLM Providers section, choose `Anthropic`, and fill in the provider name, API URL, model ID, and context window. +You can add a custom Anthropic-compatible provider from Agent Settings with {#action agent::OpenSettings}. Look for `Add Provider` in the [LLM Providers](./llm-providers.md) section, choose `Anthropic`, and fill in the provider name, API URL, model ID, and context window. You can also configure the provider in your settings file: diff --git a/docs/src/business/organizations.md b/docs/src/business/organizations.md index 54b176415b..97f265af75 100644 --- a/docs/src/business/organizations.md +++ b/docs/src/business/organizations.md @@ -58,4 +58,4 @@ Removing a member ends their access to the organization's subscription, billing, ## Organization Dashboard -The dashboard shows your members, roles, and billing. Owners and admins have full access. Billing Managers have billing access. Members have no dashboard access. +The dashboard shows your members, roles, and billing. Owners and admins have full access. Billing Managers have [billing](../account/billing.md) access. Members have no dashboard access. diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md index d37cc967ef..b7958214bf 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -319,7 +319,7 @@ Zed supports both built-in and external formatters. See [`formatter`](./referenc } ``` -This example uses Prettier for JavaScript and the language server's formatter for Rust, both set to format on save. +This example uses Prettier for JavaScript and the language server's formatter for [Rust](./languages/rust.md), both set to format on save. To disable formatting for a specific language: @@ -347,7 +347,7 @@ Linting in Zed is typically handled by language servers. Many language servers a } ``` -This configuration sets up ESLint to organize imports on save for JavaScript files. +This configuration sets up ESLint to organize imports on save for [JavaScript](./languages/javascript.md) files. To run linter fixes automatically on save: diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index 3a627f6e74..2edeecd438 100644 --- a/docs/src/development/linux.md +++ b/docs/src/development/linux.md @@ -129,7 +129,7 @@ Use this when Zed is using a lot of CPU. It is not useful for hangs. - Perf record: Run `sudo perf record -g --call-graph dwarf -p `, wait a few seconds to gather data, then press Ctrl+C. You should now have a `perf.data` file. - The `--call-graph dwarf` part records the callers of every sampled function: without it, the profile shows isolated hot symbols with no way to tell who invoked them, which is rarely enough to act on. + The `--call-graph dwarf` option records callers of every sampled function and unwinds through the `.eh_frame` data kept in stripped release binaries; plain `-g` does not work without frame pointers. - Make the output file user owned: run `sudo chown $USER:$USER perf.data` @@ -143,8 +143,13 @@ The `perf.data` file can be sent to Zed together with the exact commit. This can be done by Zed staff. -- Build Zed with symbols: - Check out the commit found previously and modify `Cargo.toml`. +Released Linux binaries are stripped, but the unstripped binary for every release is archived in Sentry (uploaded by `script/bundle-linux`). +Download it from the Sentry project's Debug Files page (search by the release version or the binary's build id), then continue with the `perf buildid-cache` step below. +Stripping preserves the build id, so the downloaded binary matches the user's `perf.data`. + +Alternatively, rebuild the binary with symbols: + +- Check out the commit found previously and modify `Cargo.toml`. Apply the following diff, then make a release build. ```diff diff --git a/docs/src/development/macos.md b/docs/src/development/macos.md index 6315ddcab8..42f07aa4f2 100644 --- a/docs/src/development/macos.md +++ b/docs/src/development/macos.md @@ -106,6 +106,40 @@ UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visua > **Note:** In the future, baselines may be stored externally. For now, they > remain local-only to keep the git repository lightweight. +## Sampling released builds + +How to get a symbolicated CPU profile from a released (non-dev) Zed instance. +Use this when Zed is using a lot of CPU. + +Released macOS binaries are stripped of local symbols, so `sample` and Instruments show raw addresses for most frames. +The debug symbols for every release are archived: `script/bundle-mac` uploads `zed.dwarf` to Sentry before stripping. + +### During the incident + +- Run `sample Zed 10 -f zed-sample.txt` (adjust the process name for Preview or Nightly). +- Get the exact build: type {#action zed::About} in the command palette and copy the version and commit. + +The `zed-sample.txt` file can be sent to Zed together with the exact version. + +### Later + +This can be done by Zed staff. + +- Find the binary UUID in the `Binary Images` section at the bottom of the sample output. +- Download the matching `zed.dwarf` from the Sentry project's Debug Files page by searching for that UUID. +- Resolve the addresses: + `atos -o zed.dwarf -l ` + Each unresolved frame in the sample output prints its `load address` and absolute address. + +To profile a released build on your own machine with full symbols, download the matching `zed.dwarf`, convert it into a `.dSYM` bundle Spotlight can index, and re-run `sample`: + +```sh +mkdir -p Zed.dSYM/Contents/Resources/DWARF +cp zed.dwarf Zed.dSYM/Contents/Resources/DWARF/zed +``` + +Frames then resolve automatically, including file and line information. + ## Troubleshooting ### Error compiling metal shaders diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md index b9882c5135..2b85d9a6ca 100644 --- a/docs/src/development/windows.md +++ b/docs/src/development/windows.md @@ -133,10 +133,10 @@ Because these settings change over time, the resulting build errors may vary fro If you need extra Rust flags, use one of the following approaches in `.cargo/config.toml`: -Add your flags in the build section +Add your flags in the common target section ```toml -[build] +[target.'cfg(all())'] rustflags = ["-C", "symbol-mangling-version=v0", "--cfg", "tokio_unstable"] ``` diff --git a/docs/src/diagnostics.md b/docs/src/diagnostics.md index 7af28e062c..ad7e5a9660 100644 --- a/docs/src/diagnostics.md +++ b/docs/src/diagnostics.md @@ -50,7 +50,7 @@ This is disabled by default, but can either be temporarily turned on (or off) us ## Project Panel -Project panel can have its entries coloured based on the severity of the diagnostics in the file. +[Project panel](./project-panel.md) can have its entries coloured based on the severity of the diagnostics in the file. To configure, use diff --git a/docs/src/editing-code.md b/docs/src/editing-code.md index d096418ebe..9f177c7a59 100644 --- a/docs/src/editing-code.md +++ b/docs/src/editing-code.md @@ -20,7 +20,7 @@ Zed provides tools to help you write and modify code efficiently. This section c When you're editing code, Zed combines input from multiple sources: 1. **Language servers** provide completions, diagnostics, and quick fixes based on your project's types and structure -2. **Edit predictions** suggest multi-character or multi-line changes as you type +2. **[Edit predictions](./ai/edit-prediction.md)** suggest multi-character or multi-line changes as you type 3. **Multibuffers** let you apply changes across files in one operation For example, you might: diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index cc3be06049..c0157c1760 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -372,7 +372,7 @@ For example, in JavaScript, we also disable auto-closing of single quotes within ### Text objects -The `textobjects.scm` file defines rules for navigating by text objects. This was added in Zed v0.165 and is currently used only in Vim mode. +The `textobjects.scm` file defines rules for navigating by text objects. This was added in Zed v0.165 and is currently used only in [Vim mode](../vim.md). Vim provides two levels of granularity for navigating around files. Section-by-section with `[]` etc., and method-by-method with `]m` etc. Even languages that don't support functions and classes can work well by defining similar concepts. For example CSS defines a rule-set as a method, and a media-query as a class. @@ -503,7 +503,7 @@ You can customize the handling of the language server using several optional met ### Syntax Highlighting with Semantic Tokens -Zed supports syntax highlighting using semantic tokens from the attached language servers. This is currently disabled by default, but can be enabled in your settings file: +Zed supports syntax highlighting using [semantic tokens](../semantic-tokens.md) from the attached language servers. This is currently disabled by default, but can be enabled in your settings file: ```json [settings] { diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index ecc4bebfc9..ad7bf5a350 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -40,11 +40,11 @@ By default, new projects open in your current window's threads sidebar. To open | Toggle terminal | `` Ctrl+` `` | `` Ctrl+` `` | | Open settings | `Cmd+,` | `Ctrl+,` | -The command palette (`Cmd+Shift+P`) is your gateway to every action in Zed. If you forget a shortcut, search for it there. +The [command palette](./command-palette.md) (`Cmd+Shift+P`) is your gateway to every action in Zed. If you forget a shortcut, search for it there. ### Panel Layout -Use **Panel Layout > Agentic** from the user menu in the title bar (or the {#action workspace::UseAgenticLayout} action) when you want the Agent Panel and Threads Sidebar next to each other on the left. Use **Panel Layout > Classic** (or {#action workspace::UseClassicLayout}) to restore the editor-oriented layout. +Use **Panel Layout > Agentic** from the user menu in the title bar (or the {#action workspace::UseAgenticLayout} action) when you want the [Agent Panel](./ai/agent-panel.md) and Threads Sidebar next to each other on the left. Use **Panel Layout > Classic** (or {#action workspace::UseClassicLayout}) to restore the editor-oriented layout. ### 3. Configure Your Editor @@ -53,7 +53,7 @@ Open the Settings Editor with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows). Searc Common first changes: - **Theme**: Press `Cmd+K Cmd+T` (macOS) or `Ctrl+K Ctrl+T` (Linux/Windows) to open the theme selector -- **Font**: Search for `buffer_font_family` in Settings +- **Font**: Search for [`buffer_font_family`](./reference/all-settings.md#buffer-font-family) in Settings - **Format on save**: Search for `format_on_save` and set to `on` ### 4. Set Up Your Language diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 1f02b7048f..1b2b00bdfe 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -29,7 +29,7 @@ For more information, see the documentation for [Vim mode](./vim.md) and [Helix ## Keymap Editor -You can access the keymap editor through the {#kb zed::OpenKeymap} action or by running {#action zed::OpenKeymap} action from the command palette. You can easily add or change a keybind for an action with the `Change Keybinding` or `Add Keybinding` button on the command palette's left bottom corner. +You can access the keymap editor through the {#kb zed::OpenKeymap} action or by running {#action zed::OpenKeymap} action from the [command palette](./command-palette.md). You can easily add or change a keybind for an action with the `Change Keybinding` or `Add Keybinding` button on the command palette's left bottom corner. In there, you can see all of the existing actions in Zed as well as the associated keybindings set to them by default. @@ -175,6 +175,25 @@ The other kind of conflict that arises is when you have two bindings, one of whi When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you type `ctrl-w` to see if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. +Zed can also wait before inserting printable text when it might begin a multi-stroke binding. For example, with a `j k` binding, typing `j` waits briefly for `k`; otherwise, `j` is inserted after the timeout. + +Whenever multi-stroke input is pending, an indicator with the pending keystrokes is shown in the status bar. Hovering it lists the bindings that could still match. If the input has a timeout, the indicator also shows a countdown, and hovering pauses the timeout so you can read the bindings. The timeout resumes with the same remaining duration when the pointer leaves. Without a timeout, the pending keystrokes are shown without a countdown. The indicator can be hidden with `{"status_bar": {"pending_keystrokes_indicator": false}}`. Vim and Helix modes continue to use their existing pending-key indicator instead. + +To also use the larger which-key menu, open the Settings Editor and search for +`Show Which-key Menu`. The pending keystrokes indicator remains visible when the +menu is enabled, and hovering it still pauses the timeout, but its binding list +popover is disabled. Or add this to your settings.json: + +```json [settings] +{ + "which_key": { + "enabled": true + } +} +``` + +Set `which_key.delay_ms` to change how long Zed waits before opening the menu. + ### Non-QWERTY keyboards Zed's support for non-QWERTY keyboards is still a work in progress. diff --git a/docs/src/languages.md b/docs/src/languages.md index 7c62220609..4c471ef5dd 100644 --- a/docs/src/languages.md +++ b/docs/src/languages.md @@ -6,7 +6,7 @@ description: "Overview of programming language support in Zed, including built-i # Language Support in Zed Zed supports hundreds of programming languages and text formats. -Some work out-of-the-box and others rely on 3rd party extensions. +Some work out-of-the-box and others rely on [3rd party extensions](./extensions/installing-extensions.md). > The ones included out-of-the-box, natively built into Zed, are marked with \*. diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md index e8e8acf9f6..eb66ee48c8 100644 --- a/docs/src/languages/cpp.md +++ b/docs/src/languages/cpp.md @@ -142,7 +142,7 @@ After building your project, CMake will generate the `compile_commands.json` fil ## Debugging -You can use CodeLLDB or GDB to debug native binaries. (Make sure that your build process passes `-g` to the C++ compiler, so that debug information is included in the resulting binary.) See below for examples of debug configurations that you can add to `.zed/debug.json`. +You can use CodeLLDB or GDB to [debug](../debugger.md) native binaries. (Make sure that your build process passes `-g` to the C++ compiler, so that debug information is included in the resulting binary.) See below for examples of debug configurations that you can add to `.zed/debug.json`. - [CodeLLDB configuration documentation](https://github.com/vadimcn/codelldb/blob/master/MANUAL.md#starting-a-new-debug-session) - [GDB configuration documentation](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Debugger-Adapter-Protocol.html) diff --git a/docs/src/languages/emmet.md b/docs/src/languages/emmet.md index 1ef6a1dea9..b78ca96aa9 100644 --- a/docs/src/languages/emmet.md +++ b/docs/src/languages/emmet.md @@ -11,6 +11,39 @@ Emmet support is available through the [Emmet extension](https://github.com/zed- - Language Server: [olrtg/emmet-language-server](https://github.com/olrtg/emmet-language-server) +## Wrap with abbreviation + +With the Emmet extension installed, you can wrap the current selections in an expanded [Emmet abbreviation](https://docs.emmet.io/abbreviations/): + +1. Select the text to wrap. With an empty selection, the enclosing HTML element is wrapped; if no enclosing element can be determined, the current line is wrapped instead. +2. Run `editor: wrap with abbreviation` from the command palette. The last used abbreviation is prefilled and selected, so typing replaces it; `up` and `down` cycle through previously used abbreviations. +3. Type an abbreviation, for example `div.wrapper>ul>li*3`, into the input that appears below the selection. A preview of the expansion is shown under the input as you type. +4. Press `enter` to apply, or `escape` to cancel. If the abbreviation produces no expansion, the error is shown next to the input and the input stays open so you can correct it. + +For a single selection, the expansion is inserted as a snippet and the cursor is placed at the end of the expansion. With multiple selections, each selection is wrapped separately; overlapping selections are merged and wrapped once. + +The [documented wrap behaviors](https://docs.emmet.io/actions/wrap-with-abbreviation/) are supported: + +- Wrapping individual lines: mark the repeating element with `*` (without a number), for example `ul>li*`, and each selected line is wrapped in its own element. `$` numbering works too: `ul>li.item$*>a`. +- Removing list markers: append the trim filter `|t`, for example `ul>li*|t`, to strip list markers such as `*`, `-`, or `1.` from the wrapped lines. +- Controlling output position: use the `$#` placeholder inside attribute values or text nodes, for example `ul>li[title=$#]*>{$#}`. +- The [comment filter](https://docs.emmet.io/filters/comment/) `|c` and the [BEM filter](https://docs.emmet.io/filters/bem/) `|bem` are also supported and can be combined, for example `div#page>p.intro|c|bem`. + +Selections that partially cover an opening or closing tag are expanded to the whole element before wrapping. Wrapping is a markup action: it is not offered in stylesheets, where Emmet provides completions instead. + +The action has no default keybinding. To add one, bind it in your keymap: + +```json [keymap] +[ + { + "context": "Editor && mode == full", + "bindings": { + "ctrl-alt-w": "editor::WrapWithAbbreviation" + } + } +] +``` + diff --git a/docs/src/languages/html.md b/docs/src/languages/html.md index 5fc2dbacb1..adb19899bd 100644 --- a/docs/src/languages/html.md +++ b/docs/src/languages/html.md @@ -87,7 +87,7 @@ To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS lan } ``` -With these settings, you will get completions for Tailwind CSS classes in HTML `class` attributes. Examples: +With these settings, you will get completions for [Tailwind CSS](./tailwindcss.md) classes in HTML `class` attributes. Examples: ```html
diff --git a/docs/src/languages/javascript.md b/docs/src/languages/javascript.md index 16e5c2ad60..54d320b1a7 100644 --- a/docs/src/languages/javascript.md +++ b/docs/src/languages/javascript.md @@ -237,7 +237,7 @@ The following can be debugged without writing additional configuration: - Tasks from `package.json` - Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine, Bun, Node) -Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks. +Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined [debug tasks](../debugger.md). > **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`. diff --git a/docs/src/languages/r.md b/docs/src/languages/r.md index a40cda0242..794f7a77bc 100644 --- a/docs/src/languages/r.md +++ b/docs/src/languages/r.md @@ -141,7 +141,7 @@ TBD: R REPL Docs ### Ark Installation -To use the Zed REPL with R you need to install [Ark](https://github.com/posit-dev/ark), an R Kernel for Jupyter applications. +To use the [Zed REPL](../repl.md) with R you need to install [Ark](https://github.com/posit-dev/ark), an R Kernel for Jupyter applications. You can download the latest version from the [Ark GitHub Releases](https://github.com/posit-dev/ark/releases) and then extract the `ark` binary to a directory in your `PATH`. For example to install the latest non-debug build: diff --git a/docs/src/languages/typescript.md b/docs/src/languages/typescript.md index 108e6e34d4..0262129a60 100644 --- a/docs/src/languages/typescript.md +++ b/docs/src/languages/typescript.md @@ -242,7 +242,7 @@ The following can be debugged without writing additional configuration: - Tasks from `package.json` - Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine, Bun, Node) -Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks. +Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these [predefined debug tasks](../debugger.md). > **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`. diff --git a/docs/src/migrate/intellij.md b/docs/src/migrate/intellij.md index ca200c6725..a6f48809a2 100644 --- a/docs/src/migrate/intellij.md +++ b/docs/src/migrate/intellij.md @@ -249,7 +249,7 @@ Zed has three dock positions: left, bottom, and right. Panels can be moved betwe Both IntelliJ and Zed offer integrated debugging, but the experience differs: -- Zed's debugger uses the Debug Adapter Protocol (DAP), supporting multiple languages +- Zed's [debugger](../debugger.md) uses the Debug Adapter Protocol (DAP), supporting multiple languages - Set breakpoints with `Ctrl+F8` - Start debugging with `Alt+Shift+F9` - Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) diff --git a/docs/src/migrate/pycharm.md b/docs/src/migrate/pycharm.md index 7a6bcf9d17..e015032f4f 100644 --- a/docs/src/migrate/pycharm.md +++ b/docs/src/migrate/pycharm.md @@ -183,7 +183,7 @@ Where you might notice differences: In PyCharm, you select a Python interpreter through a GUI, and PyCharm manages the connection between your project and that interpreter. It shows available packages, lets you install new ones, and keeps track of which environment each project uses. -Zed handles virtual environments through its toolchain system: +Zed handles virtual environments through its [toolchain system](../toolchains.md): - Zed automatically discovers virtual environments in common locations (`.venv`, `venv`, `.env`, `env`) - When a virtual environment is detected, the terminal auto-activates it diff --git a/docs/src/migrate/vs-code.md b/docs/src/migrate/vs-code.md index 588f2ce46c..820889d060 100644 --- a/docs/src/migrate/vs-code.md +++ b/docs/src/migrate/vs-code.md @@ -84,12 +84,12 @@ The following VS Code settings are automatically imported when you use **Import | VS Code Setting | Zed Setting | | --------------------------- | ------------------------------ | +| `files.associations` | `file_types` | | `files.autoSave` | `autosave` | | `files.autoSaveDelay` | `autosave.milliseconds` | +| `files.exclude` | `file_scan_exclusions` | | `files.insertFinalNewline` | `ensure_final_newline_on_save` | -| `files.associations` | `file_types` | -| `files.watcherExclude` | `file_scan_exclusions` | -| `files.watcherInclude` | `file_scan_inclusions` | +| `files.readonlyInclude` | `read_only_files` | | `files.simpleDialog.enable` | `use_system_path_prompts` | | `search.smartCase` | `use_smartcase_search` | | `search.useIgnoreFiles` | `search.include_ignored` | @@ -174,14 +174,15 @@ Zed doesn’t import extensions or keybindings, but this import gets core editor You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Here’s how common VS Code settings translate: -| VS Code | Zed | Notes | -| --- | --- | --- | -| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | -| editor.fontSize | buffer_font_size | Set in pixels | -| editor.tabSize | tab_size | Can override per language | -| editor.insertSpaces | insert_spaces | Boolean | -| editor.formatOnSave | format_on_save | Works with formatter enabled | -| editor.wordWrap | soft_wrap | Supports optional wrap column | + +| VS Code | Zed | Notes | +| ------------------- | ------------------ | ----------------------------- | +| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | +| editor.fontSize | buffer_font_size | Set in pixels | +| editor.tabSize | tab_size | Can override per language | +| editor.insertSpaces | insert_spaces | Boolean | +| editor.formatOnSave | format_on_save | Works with formatter enabled | +| editor.wordWrap | soft_wrap | Supports optional wrap column | Zed also supports per-project settings. You can find these in the Settings Editor as well. @@ -278,7 +279,7 @@ In VS Code, the standard entry point is opening a folder. From there, the left-h Zed takes a different approach: - You can still open folders, but you don’t need to. Opening a single file or even starting with an empty workspace is valid. -- The Command Palette (`Cmd+Shift+P`) and File Finder (`Cmd+P`) are primary navigation tools. The File Finder searches files, symbols, and commands across the workspace. +- The [Command Palette](../command-palette.md) (`Cmd+Shift+P`) and File Finder (`Cmd+P`) are primary navigation tools. The File Finder searches files, symbols, and commands across the workspace. - Instead of a persistent panel, Zed encourages you to: - Fuzzy-find files by name (`Cmd+P`) - Jump directly to symbols (`Cmd+Shift+O`) diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md index aa4c193a5e..f6c3dc59b2 100644 --- a/docs/src/outline-panel.md +++ b/docs/src/outline-panel.md @@ -13,6 +13,12 @@ When viewing a "singleton" buffer (i.e., a single file on a tab), the outline pa ## Usage with multibuffers +File rows follow the order of the files in the multibuffer. +If files from a folder appear in separate parts of the multibuffer, the outline panel repeats that folder's path group to preserve their order. + +When a multibuffer includes deleted project files, they remain grouped under their original paths with struck-through file names. +Folder rows group files by path and are not struck through, even when the folders no longer exist on disk. + The outline panel truly excels when used with multi-buffers. Here are some examples of its versatility: ### Project Search Results diff --git a/docs/src/project-panel.md b/docs/src/project-panel.md index 7c382a2b0c..8619cbc387 100644 --- a/docs/src/project-panel.md +++ b/docs/src/project-panel.md @@ -24,11 +24,16 @@ permanent tab. Editing the file or double-clicking it promotes it to a permanent Middle-clicking a file skips the preview and opens it in a permanent, focused tab right away. +Set [`preview_tabs.enable_preview_from_project_panel`](./reference/all-settings.md#enable-preview-from-project-panel) +to `false` to open permanent tabs with a single click or {#action project_panel::Open} +({#kb project_panel::Open}). + ### Auto-reveal By default, switching to a file in the editor will automatically highlight it in the -project panel and scroll it into view. This can be disabled with the -`project_panel.auto_reveal_entries` setting. +project panel and scroll it into view. This +[auto-reveal behavior](./reference/all-settings.md#auto-reveal-entries) can be +disabled with the `project_panel.auto_reveal_entries` setting. ### Sticky Scroll @@ -158,8 +163,7 @@ See also [Diagnostics & Quick Fixes](./diagnostics.md) for editor and tab diagno - `project_panel.hide_gitignore` hides files matched by `.gitignore`. Toggle this with {#action project_panel::ToggleHideGitIgnore}. -- `project_panel.hide_hidden` hides dotfiles and other hidden entries. Toggle - with {#action project_panel::ToggleHideHidden}. +- `project_panel.hide_hidden` hides entries matching [`hidden_files`](./reference/all-settings.md#hidden-files) (dotfiles and dotfolders by default). Toggle with {#action project_panel::ToggleHideHidden}. ### Sorting diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 35ae9bb8dd..69d8eaa91c 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -88,10 +88,115 @@ Non-negative `float` values } ``` -## Agent Font Size +## Agent Panel {#agent-panel} + +To configure panel sizing, open the Settings Editor and search for “Agent Panel Default Width” or “Agent Panel Flexible Sizing”. + +### Default Width {#agent-panel-default-width} + +- Description: Default fixed width in pixels when the agent panel is docked to the left or right and `agent.flexible` is `false`. +- Setting: `agent.default_width` +- Default: `640` + +### Flexible Sizing {#agent-panel-flexible-sizing} + +- Description: Whether the agent panel uses flexible (proportional) sizing when docked to the left or right. When enabled, `agent.default_width` does not control the panel width, and double-clicking the panel's outer resize handle restores the default proportion. +- Setting: `agent.flexible` +- Default: `true` + +**Options** + +`boolean` values + +To use a fixed reset width, disable flexible sizing in the Settings Editor. Or add this to your settings.json: + +```json [settings] +{ + "agent": { + "default_width": 640, + "flexible": false + } +} +``` + +See [Agent Panel visual customization](../visual-customization.md#agent-panel) for other panel appearance settings. + +### Threads Sidebar Position {#agent-threads-sidebar-position} + +- Description: Which side of the window displays the [Threads Sidebar](../ai/parallel-agents.md#threads-sidebar). +- Setting: `agent.threads_sidebar.position` +- Default: `"left"` + +**Options** + +`"left"` or `"right"`. + +Open the Settings Editor and search for “Threads Sidebar Position”. Or add this to your `settings.json`: + +```json [settings] +{ + "agent": { + "threads_sidebar": { + "position": "right" + } + } +} +``` + +### Threads Sidebar Default Width {#agent-threads-sidebar-default-width} + +- Description: Default width in pixels of the [Threads Sidebar](../ai/parallel-agents.md#threads-sidebar). +- Setting: `agent.threads_sidebar.default_width` +- Default: `300` + +**Options** + +Numbers from `200` to `800` pixels (inclusive). Values outside this range are clamped to the nearest limit. + +Open the Settings Editor and search for “Threads Sidebar Default Width”. Or add this to your `settings.json`: + +```json [settings] +{ + "agent": { + "threads_sidebar": { + "default_width": 360 + } + } +} +``` + +Changing this setting immediately updates the sidebar width, even if you previously resized it manually. You can also double-click the divider to reset the sidebar to the configured width. + +Widths saved by older versions of Zed are preserved if they differ from the previous default of 300 pixels. A saved width of 300 pixels uses this setting instead. + +### Threads Sidebar Auto Open {#agent-threads-sidebar-auto-open} + +- Description: Whether opening a folder in an existing window automatically opens the [Threads Sidebar](../ai/parallel-agents.md#threads-sidebar). +- Setting: `agent.threads_sidebar.auto_open` +- Default: `true` + +**Options** + +`true` or `false`. + +This applies when a folder opens in an existing window instead of a new one, which happens when `default_open_behavior` or `cli_default_open_behavior` is set to `existing_window`. With `false`, the folder still opens in that window, but the sidebar stays closed until you open it with {#action multi_workspace::ToggleWorkspaceSidebar}. + +Open the Settings Editor and search for “Threads Sidebar Auto Open”. Or add this to your `settings.json`: + +```json [settings] +{ + "agent": { + "threads_sidebar": { + "auto_open": false + } + } +} +``` + +## Agent UI Font Size - Description: The font size for text in the agent panel. Inherits the UI font size if unset. -- Setting: `agent_font_size` +- Setting: `agent_ui_font_size` - Default: `null` **Options** @@ -130,17 +235,39 @@ Non-negative `float` values } ``` -> Note: This setting has no effect in Vim mode, as rewrap is already allowed everywhere. +> Note: This setting has no effect in [Vim mode](../vim.md), as rewrap is already allowed everywhere. ## Auto Indent -- Description: Whether indentation should be adjusted based on context while typing. This can be specified on a per-language basis. +- Description: Controls automatic indentation behavior when typing. This can be specified on a per-language basis. - Setting: `auto_indent` -- Default: `true` +- Default: `syntax_aware` **Options** -`boolean` values +1. `syntax_aware`, adjusts indentation based on syntax context, using Tree-sitter to analyze the code structure: + +```json [settings] +{ + "auto_indent": "syntax_aware" +} +``` + +2. `preserve_indent`, keeps the indentation of the current line when starting a new one, without adjusting for syntax: + +```json [settings] +{ + "auto_indent": "preserve_indent" +} +``` + +3. `none`, disables automatic indentation, so new lines start at column 0: + +```json [settings] +{ + "auto_indent": "none" +} +``` ## Auto Indent On Paste @@ -530,6 +657,24 @@ When enabled, this setting will automatically close tabs for files that have bee } ``` +## Command Palette + +### Use Command History + +- Description: Whether to use command history ranking for sorting in the command palette. +- Setting: `command_palette.use_command_history` +- Default: `true` + +Disabling this setting does not erase history. + +```json [settings] +{ + "command_palette": { + "use_command_history": false + } +} +``` + ## Confirm Quit - Description: Whether or not to prompt the user to confirm before closing the application. @@ -544,7 +689,7 @@ When enabled, this setting will automatically close tabs for files that have bee - Description: Which level to use to filter out diagnostics displayed in the editor - Setting: `diagnostics_max_severity` -- Default: `null` +- Default: `all` **Options** @@ -670,6 +815,7 @@ For the case of "open", regular selection behavior can be achieved by holding `a - Default: ```json [settings] +{ "edit_predictions": { "disabled_globs": [ "**/.env*", @@ -678,23 +824,81 @@ For the case of "open", regular selection behavior can be achieved by holding `a "**/*.cert", "**/*.crt", "**/.dev.vars", - "**/secrets.yml" + "**/secrets.yml", + "**/.zed/settings.json", + "/**/zed/settings.json", + "/**/zed/keymap.json" ] } +} ``` **Options** +### Edit Prediction Provider + +- Description: Which edit prediction provider to use +- Setting: `provider` +- Default: `"zed"` + +**Options** + +1. Use Zeta as the edit prediction provider: + +```json [settings] +{ + "edit_predictions": { + "provider": "zed" + } +} +``` + +2. Use Copilot as the edit prediction provider: + +```json [settings] +{ + "edit_predictions": { + "provider": "copilot" + } +} +``` + +3. Turn off edit predictions across all providers + +```json [settings] +{ + "edit_predictions": { + "provider": "none" + } +} +``` + ### Disabled Globs -- Description: A list of globs for which edit predictions should be disabled for. This list adds to a pre-existing, sensible default set of globs. Any additional ones you add are combined with them. +- Description: Disable edit predictions for files matching these glob patterns. - Setting: `disabled_globs` -- Default: `["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/.dev.vars", "**/secrets.yml"]` +- Default: `["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/.dev.vars", "**/secrets.yml", "**/.zed/settings.json", "/**/zed/settings.json", "/**/zed/keymap.json"]` **Options** List of `string` values. +Use `"..."` to add patterns without repeating Zed's defaults. In project settings, it extends the user or parent configuration value. Omit `"..."` to replace the inherited list. + +```json [settings] +{ + "edit_predictions": { + "disabled_globs": ["**/build/**", "..."] + } +} +``` + +Inherited patterns are inserted at `"..."`, and duplicates keep their first occurrence. + +Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. + +Relative patterns are matched against paths relative to the worktree root. Absolute patterns are matched against absolute paths. A leading `~` is expanded to your home folder. + ### Prediction Debounce - Description: How long Zed waits after you stop typing before automatically requesting an edit prediction. @@ -720,42 +924,33 @@ See [Configuring the Prediction Debounce](../ai/edit-prediction.md#configuring-t ## Edit Predictions Disabled in -- Description: A list of language scopes in which edit predictions should be disabled. - Setting: `edit_predictions_disabled_in` -- Default: `[]` - -**Options** - -List of `string` values - -1. Don't show edit predictions in comments: +- Description: Disable edit predictions in these language scopes, such as "comment" and "string". +- Default: ```json [settings] { - "edit_predictions_disabled_in": ["comment"] + "edit_predictions_disabled_in": [] } ``` -2. Don't show edit predictions in strings and comments: - -```json [settings] -{ - "edit_predictions_disabled_in": ["comment", "string"] -} -``` - -3. Only in Go, don't show edit predictions in strings and comments: +Use `"..."` to add scopes without repeating the inherited list. In project settings, it extends the user or parent configuration value. In language-specific settings, it extends the scopes inherited by that language. Omit `"..."` to replace the inherited list. ```json [settings] { + "edit_predictions_disabled_in": ["comment"], "languages": { "Go": { - "edit_predictions_disabled_in": ["comment", "string"] + "edit_predictions_disabled_in": ["string", "..."] } } } ``` +Inherited scopes are inserted at `"..."`, and duplicates keep their first occurrence. + +Set `[]` to clear the inherited list. Omit this setting to inherit it unchanged. + ## Current Line Highlight - Description: How to highlight the current line in the editor. @@ -1718,13 +1913,13 @@ Each option controls displaying of a particular toolbar element. If all elements ## Use System Tabs -- Description: Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). +- Description: Whether to allow windows to tab together based on the user's tabbing preference (macOS only). - Setting: `use_system_window_tabs` - Default: `false` **Options** -This setting enables integration with macOS’s native window tabbing feature. When set to `true`, Zed windows can be grouped together as tabs in a single macOS window, following the system-wide tabbing preferences set by the user (such as "Always", "In Full Screen", or "Never"). This setting is only available on macOS. +This setting enables integration with macOS's native window tabbing feature. When set to `true`, Zed windows can be grouped together as tabs in a single macOS window, following the system-wide tabbing preferences set by the user (such as "Always", "In Full Screen", or "Never"). This setting is only available on macOS. ## Fullscreen Mode @@ -1859,13 +2054,25 @@ Positive `integer` value between 1 and 32. Values outside of this range will be ```json [settings] { "status_bar": { + "show_active_file": false, "active_language_button": true, "cursor_position_button": true, - "line_endings_button": false + "line_endings_button": false, + "active_encoding_button": "non_utf8", + "pending_keystrokes_indicator": true } } ``` +**Options** + +- `show_active_file`: Whether to show the name of the active file in the status bar +- `active_language_button`: Whether to show the active language button (clicking it opens the language selector) +- `cursor_position_button`: Whether to show the cursor position button (clicking it opens the go-to-line/column input) +- `line_endings_button`: Whether to show the active line endings button (clicking it opens the line-ending selector) +- `active_encoding_button`: When to show the active encoding button: `"enabled"`, `"disabled"`, or `"non_utf8"` (only for encodings other than UTF-8 without BOM) +- `pending_keystrokes_indicator`: Whether to show an indicator while multi-stroke input is pending. If the input has a timeout, a countdown is shown and hovering the indicator pauses it. Its binding preview popover is disabled when the which-key popup is enabled (see [key bindings](../key-bindings.md#precedence)) + There is an experimental setting that completely hides the status bar. This causes major usability problems (you will be unable to use many of Zed's features), but is provided for those who value screen real-estate above all else. ```json @@ -1962,63 +2169,48 @@ While other options may be changed at a runtime and should be placed under `sett `integer` values representing milliseconds -## Features +## Focus Follows Mouse -- Description: Features that can be globally enabled or disabled -- Setting: `features` +- Description: Whether the focused panel follows the mouse location. +- Setting: `focus_follows_mouse` - Default: ```json [settings] { - "edit_predictions": { - "provider": "zed" + "focus_follows_mouse": { + "enabled": false, + "debounce_ms": 250 } } ``` -### Edit Prediction Provider +### Enabled -- Description: Which edit prediction provider to use -- Setting: `edit_prediction_provider` -- Default: `"zed"` +- Description: Whether hovering over a dock or pane moves focus to it. +- Setting: `enabled` +- Default: `false` **Options** -1. Use Zeta as the edit prediction provider: +`boolean` values -```json [settings] -{ - "edit_predictions": { - "provider": "zed" - } -} -``` +### Debounce -2. Use Copilot as the edit prediction provider: +- Description: How long the mouse must hover over a panel before it is focused, in milliseconds. +- Setting: `debounce_ms` +- Default: `250` -```json [settings] -{ - "edit_predictions": { - "provider": "copilot" - } -} -``` +**Options** -3. Turn off edit predictions across all providers - -```json [settings] -{ - "edit_predictions": { - "provider": "none" - } -} -``` +Non-negative `integer` values ## Format On Save {#format-on-save} - Description: Whether or not to perform a buffer format before saving. - Setting: `format_on_save` -- Default: `on` +- Default: `off` + +Zed ships `"format_on_save": "on"` as a per-language default for Astro, Dart, EEx, Elixir, Elm, Go, GraphQL, HEEx, Kotlin, Rust, Starlark, and Zig. Every other language uses the top-level default above. Use [`languages`](#languages) to configure individual languages differently. **Options** @@ -2175,7 +2367,7 @@ The result is still `)))` and not `))))))`, which is what it would be by default ## File Scan Exclusions - Setting: `file_scan_exclusions` -- Description: Files or globs of files that will be excluded by Zed entirely. They will be skipped during file scans, file searches, and not be displayed in the project file tree. Overrides `file_scan_inclusions`. +- Description: Exclude files matching these glob patterns from file scans, file searches, and the project file tree. Takes precedence over `file_scan_inclusions`. - Default: ```json [settings] @@ -2196,7 +2388,7 @@ The result is still `)))` and not `))))))`, which is what it would be by default } ``` -Note that specifying `file_scan_exclusions` in `settings.json` will override the defaults (listed above). Use `"..."` to keep them: +Use `"..."` to add patterns without repeating Zed's defaults. In project settings, it extends the user or parent configuration value. Omit `"..."` to replace the inherited list. ```json [settings] { @@ -2204,20 +2396,12 @@ Note that specifying `file_scan_exclusions` in `settings.json` will override the } ``` -The `"..."` entry expands to the list you are overriding, so the example above excludes `node_modules` in addition to every default. Entries you list by name keep their position, and `"..."` fills in the inherited ones at that point in the list. If you want full control over what is excluded, omit `"..."` — only the entries you list by name will be used. - -| Configuration | Result | -| ---------------------------- | ------------------------------------ | -| `["..."]` | The defaults, unchanged | -| `["**/node_modules", "..."]` | `**/node_modules`, then the defaults | -| `["**/node_modules"]` | `**/node_modules` only | - -> Note: `"..."` resolves one settings layer at a time. In a project’s `.zed/settings.json` it expands to whatever your user settings resolved to, rather than to Zed’s defaults. +Inherited patterns are inserted at `"..."`, and duplicates keep their first occurrence. ## File Scan Inclusions - Setting: `file_scan_inclusions` -- Description: Files or globs of files that will be included by Zed, even when ignored by git. This is useful for files that are not tracked by git, but are still important to your project. Note that globs that are overly broad can slow down Zed's file scanning. `file_scan_exclusions` takes precedence over these inclusions. +- Description: Include files matching these glob patterns when scanning, even if ignored by Git. Note that broad patterns can slow file scanning. `file_scan_exclusions` takes precedence. - Default: ```json [settings] @@ -2226,6 +2410,16 @@ The `"..."` entry expands to the list you are overriding, so the example above e } ``` +Use `"..."` to add patterns without repeating Zed's defaults. In project settings, it extends the user or parent configuration value. Omit `"..."` to replace the inherited list. + +```json [settings] +{ + "file_scan_inclusions": ["**/build/**", "..."] +} +``` + +Inherited patterns are inserted at `"..."`, and duplicates keep their first occurrence. + ## File Scan Depth - Setting: `file_scan_depth` @@ -2479,7 +2673,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files - Description: Sets the debounce threshold (in milliseconds) after which changes are reflected in the git gutter. - Setting: `gutter_debounce` -- Default: `null` +- Default: `0` **Options** @@ -2773,6 +2967,31 @@ Example: `boolean` values +## Hidden Files {#hidden-files} + +- Setting: `hidden_files` +- Description: Treat files and folders matching these glob patterns as hidden, including files inside matching folders. To [hide these entries](../project-panel.md#hiding-files), run `project panel: toggle hide hidden` from the command palette or set `project_panel.hide_hidden` to `true`. +- Default: + +```json [settings] +{ + "hidden_files": ["**/.*"] +} +``` + +Use `"..."` to add patterns without repeating Zed's defaults. In project settings, it extends the user or parent configuration value. Omit `"..."` to replace the inherited list. + +```json [settings] +{ + "hidden_files": ["**/*.log", "..."], + "project_panel": { + "hide_hidden": true + } +} +``` + +Inherited patterns are inserted at `"..."`, and duplicates keep their first occurrence. + ## Indent Guides - Description: Configuration related to indent guides. Indent guides can be configured separately for each language. @@ -3438,6 +3657,30 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en } ``` +## On New Window + +- Description: What to show when opening a new window. +- Setting: `on_new_window` +- Default: `"launchpad"` + +**Options** + +1. Show an empty untitled buffer: + +```json [settings] +{ + "on_new_window": "empty_tab" +} +``` + +2. Show the launchpad with recent projects: + +```json [settings] +{ + "on_new_window": "launchpad" +} +``` + ## Instrumentation - Description: Configuration for developer-oriented instrumentation tools (profilers, tracers, etc.) that can be toggled at runtime. @@ -3504,7 +3747,7 @@ Examples: ## Preview tabs - Description: - Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ + Preview tabs allow you to open files in preview mode. A pane keeps at most one preview tab at a time, so opening another file in preview mode takes over that slot. Switching to a file that is already open does not close the preview tab. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ There are several ways to convert a preview tab into a regular tab: - Double-clicking on the file @@ -3512,6 +3755,8 @@ Examples: - Using the {#action project_panel::OpenPermanent} action - Editing the file - Dragging the file to a different pane + - Pinning the tab with the {#action pane::TogglePinTab} action + - Using the {#action pane::TogglePreviewTab} action - Setting: `preview_tabs` - Default: @@ -3532,7 +3777,7 @@ Examples: ### Enable preview from project panel -- Description: Determines whether to open files in preview mode when opened from the project panel with a single click. +- Description: Determines whether to open files in preview mode when opened from the project panel with a single click or the {#action project_panel::Open} action ({#kb project_panel::Open}). - Setting: `enable_preview_from_project_panel` - Default: `true` @@ -3686,15 +3931,27 @@ Examples: List of `string` glob patterns -## Projects Online By Default +## Read-Only Files {#read-only-files} -- Description: Whether or not to show the online projects view by default. -- Setting: `projects_online_by_default` -- Default: `true` +- Setting: `read_only_files` +- Description: Treat files matching these glob patterns as read-only when opened. You can view but not edit them, which is useful for build outputs, external dependencies, or generated files. +- Default: -**Options** +```json [settings] +{ + "read_only_files": [] +} +``` -`boolean` values +Use `"..."` to add patterns without repeating Zed's defaults. In project settings, it extends the user or parent configuration value. Omit `"..."` to replace the inherited list. + +```json [settings] +{ + "read_only_files": ["**/build/**", "..."] +} +``` + +Inherited patterns are inserted at `"..."`, and duplicates keep their first occurrence. ## Read SSH Config @@ -3992,7 +4249,7 @@ Non-negative `integer` values ## Semantic Tokens -- Description: Controls how semantic tokens from language servers are used for syntax highlighting. +- Description: Controls how [semantic tokens](../semantic-tokens.md) from language servers are used for syntax highlighting. - Setting: `semantic_tokens` - Default: `off` @@ -4463,7 +4720,7 @@ List of `integer` column numbers - Description: Set whether Alternate Scroll mode (DECSET code: `?1007`) is active by default. Alternate Scroll mode converts mouse scroll events into up / down key presses when in the alternate screen (e.g. when running applications like vim or less). The terminal can still set and unset this mode with ANSI escape codes. - Setting: `alternate_scroll` -- Default: `off` +- Default: `on` **Options** @@ -4547,9 +4804,9 @@ List of `integer` column numbers ### Terminal: Cursor Shape -- Description: Controls the visual shape of the cursor in the terminal. When not explicitly set, it defaults to a block shape. +- Description: Controls the visual shape of the cursor in the terminal. - Setting: `cursor_shape` -- Default: `null` (defaults to block) +- Default: `block` **Options** @@ -5016,8 +5273,8 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` ### Terminal: Path Hyperlink Regexes -- Description: Regexes used to identify path hyperlinks. The regexes can be specified in two forms - a single regex string, or an array of strings (which will be collected into a single multi-line regex string). - Setting: `path_hyperlink_regexes` +- Description: Regexes used to identify paths for hyperlink navigation. - Default: ```json [settings] @@ -5030,31 +5287,49 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` // surrounding symbols or quotes [ "(?x)", - "# optionally starts with 0-2 opening prefix symbols", - "[({\\[<]{0,2}", - "# which may be followed by an opening quote", - "(?[\"'`])?", - "# `path` is the shortest sequence of any non-space character", - "(?(?[^ ]+?", - " # which may end with a line and optionally a column,", - " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", - "))", - "# which must be followed by a matching quote", - "(?()\\k)", - "# and optionally a single closing symbol", - "[)}\\]>]?", - "# if line/column matched, may be followed by a description", - "(?():[^ 0-9][^ ]*)?", - "# which may be followed by trailing punctuation", - "[.,:)}\\]>]*", - "# and always includes trailing whitespace or end of line", - "([ ]+|$)" + "(?", + " (", + " # multi-char path: first char (not opening delimiter, space, or box drawing char)", + " [^({\\[<\"'`\\ \\u2500-\\u257F]", + " # middle chars: non-space, and colon/paren only if not followed by digit/paren/space", + " ([^\\ :(]|[:(][^0-9()\\ ])*", + " # last char: not closing delimiter or colon", + " [^()}\\]>\"'`.,;:\\ ]", + " |", + " # single-char path: not delimiter, punctuation, space, or box drawing char", + " [^(){}\\[\\]<>\"'`.,;:\\ \\u2500-\\u257F]", + " )", + " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + ")" ] ] } } ``` +Use `"..."` to add regexes without repeating Zed's defaults. In project settings, it extends the user or parent configuration value. Omit `"..."` to replace the inherited list. Set `[]` to clear it. Omitting this setting keeps the inherited list. + +```json [settings] +{ + "terminal": { + "path_hyperlink_regexes": [ + "\\s+(-->|:::|at) (?(?.+?))(:$|$)", + "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)", + "..." + ] + } +} +``` + +Inherited regexes are inserted at `"..."`, and duplicates keep their first occurrence. Regexes are duplicates when their text is identical after joining multiline entries with newlines. + +Each regex can be a single string or an array of strings joined with newlines. The marker is recognized only as a top-level string. To use `...` as a regex matching three characters, write `["..."]` as an entry. A `"..."` line inside a multiline entry is always regex text. + +The optional named capture `path` selects the hyperlink target. Without it, the entire match is the target. With `path`, `line` and `column` specify the position. Without a captured `line`, built-in suffix processing parses `line:column` and `(line,column)` variants. The optional `link` capture selects the clickable text, otherwise the entire match is clickable. + +Processing stops at the first regex that matches the terminal line, even if the cursor is outside its clickable text. Put more specific regexes before broader ones. Regexes use Rust's `regex` syntax. Invalid regexes are logged and ignored. The `path_hyperlink_timeout_ms` setting controls the discovery timeout. Setting it to `0` disables path hyperlinks. + ### Terminal: Path Hyperlink Timeout (ms) - Description: Maximum time to search for a path hyperlink. When set to 0, path hyperlinks are disabled. @@ -5202,6 +5477,50 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_menus`: Whether to show the menus in the titlebar - `button_layout`: The layout of window control buttons in the title bar (Linux only). Can be set to `"platform_default"` to follow the system setting, `"standard"` to use Zed's built-in layout, or a custom format like `"close:minimize,maximize"` +## Window Title Format + +- Description: Template for the window title. Use `${separator}` to insert a + separator that is omitted when adjacent variables are empty. The collaboration + indicator, when present, is appended after the rendered template. If the + template renders to nothing (for example `${branch}` outside a Git + repository), the default template is used instead. +- Setting: `window_title_format` +- Default: `"${projectName}${separator}${fileName}"` + +Available variables: + +| Variable | Description | +| -------------- | ----------------------------------------------------- | +| `projectName` | Name of the current project | +| `fileName` | Name of the active file (e.g. `main.rs`) | +| `filePath` | Absolute path of the active file | +| `relativePath` | Path of the active file relative to its worktree root | +| `fileStem` | File name without extension (e.g. `main`) | +| `remoteName` | Display name of the remote connection, if any | +| `remoteHost` | Host of the remote connection, if any | +| `appName` | Zed release channel name (e.g. `Zed`, `Zed Nightly`) | +| `branch` | Git branch checked out in the active repository | +| `separator` | Separator string, omitted when neighbors are empty | + +```json [settings] +{ + "window_title_format": "${projectName}${separator}${fileName}" +} +``` + +## Window Title Separator + +- Description: The string substituted for `${separator}` in the window title + format. Include any surrounding whitespace in the value. +- Setting: `window_title_separator` +- Default: `" — "` + +```json [settings] +{ + "window_title_separator": " — " +} +``` + ## Window Decorations - Description: Controls whether Zed or the window manager or compositor draws window decorations. @@ -5234,6 +5553,26 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - Setting: `vim_mode` - Default: `false` +## Which-key Menu + +- Description: Show matching bindings while a multi-stroke binding is pending. +- Setting: `which_key` +- Default: + +```json [settings] +{ + "which_key": { + "enabled": false, + "delay_ms": 1000 + } +} +``` + +**Options** + +- `enabled`: Whether to show the which-key menu. When enabled, the pending keystrokes indicator remains visible, but its binding preview popover is disabled. +- `delay_ms`: How long Zed waits before showing the menu, in milliseconds. + ## When Closing With No Tabs - Description: Whether the window should be closed when using 'close active item' on a window with no tabs @@ -5671,6 +6010,16 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a Visit [AI Quick Start](../ai/quick-start.md) under the AI section to learn more about AI setup. +### Prevent Idle Sleep + +- Description: Whether to keep the system from idle-sleeping while an agent thread is running. See [Keeping the System Awake](../ai/agent-panel.md#prevent-idle-sleep). +- Setting: `agent.prevent_idle_sleep` +- Default: `true` + +**Options** + +`boolean` values + ## Collaboration Panel - Description: Customizations for the collaboration panel. @@ -5681,7 +6030,7 @@ Visit [AI Quick Start](../ai/quick-start.md) under the AI section to learn more { "collaboration_panel": { "button": true, - "dock": "left", + "dock": "right", "default_width": 240 } } @@ -5722,17 +6071,24 @@ See the [debugger page](../debugger.md) for more information about debugging sup { "git_panel": { "button": true, - "dock": "left", + "dock": "right", "default_width": 360, "status_style": "icon", + "file_icons": false, + "folder_indicator": "icon", "fallback_branch_name": "main", "sort_by": "path", "group_by": "status", "collapse_untracked_diff": false, + "tree_view": false, "scrollbar": { "show": null }, - "starts_open": false + "starts_open": false, + "show_count_badge": false, + "diff_stats": true, + "commit_title_max_length": 0, + "entry_primary_click_action": "project_diff" } } ``` @@ -5743,12 +6099,19 @@ See the [debugger page](../debugger.md) for more information about debugging sup - `dock`: Where to dock the git panel. Can be `left` or `right` - `default_width`: Default width of the git panel - `status_style`: How to display git status. Can be `label_color` or `icon` +- `file_icons`: Whether to show file icons in the git panel +- `folder_indicator`: What to show for directories in the git panel. Can be `icon`, `chevron`, or `both` - `fallback_branch_name`: What branch name to use if `init.defaultBranch` is not set - `sort_by`: How to sort entries in the git panel. Can be `path` or `name` - `group_by`: How to group entries in the git panel. Can be `none` or `status` - `collapse_untracked_diff`: Whether to collapse untracked files in the diff panel +- `tree_view`: Whether to show entries in tree or flat view in the panel - `scrollbar`: When to show the scrollbar in the git panel - `starts_open`: Whether the git panel should open on startup +- `show_count_badge`: Whether to show a badge on the git panel icon with the count of uncommitted changes +- `diff_stats`: Whether to show the addition/deletion change count next to each file in the git panel +- `commit_title_max_length`: Maximum length of the commit message title before a warning is shown. Set to `0` to disable +- `entry_primary_click_action`: Default action when clicking a changed file in the git panel. Can be `project_diff`, `file_diff`, or `view_file` ## Git Worktree Directory @@ -5806,7 +6169,7 @@ You can define these in user or project settings; project settings are merged on ## Outline Panel -- Description: Customize outline Panel +- Description: Customize outline panel - Setting: `outline_panel` - Default: @@ -5815,7 +6178,7 @@ You can define these in user or project settings; project settings are merged on "outline_panel": { "button": true, "default_width": 300, - "dock": "left", + "dock": "right", "file_icons": true, "folder_indicator": "icon", "git_status": true, diff --git a/docs/src/roles.md b/docs/src/roles.md index 4cd9490645..515aae1281 100644 --- a/docs/src/roles.md +++ b/docs/src/roles.md @@ -12,18 +12,18 @@ can access and configure. Every member of an organization is assigned one of four roles: -| Capability | Owner | Admin | Billing Manager | Member | -| --------------------------------------------------------------- | ----- | ----- | --------------- | ------ | -| Use hosted AI and Edit Predictions through Business | Yes | Yes | No | Yes | -| View organization members | Yes | Yes | Yes | Yes | -| Invite members | Yes | Yes | No | No | -| Change non-owner member roles | Yes | Yes | No | No | -| Remove non-owner members | Yes | Yes | No | No | -| Configure organization settings and data controls | Yes | Yes | No | No | -| View subscription, usage, and billing information | Yes | Yes | Yes | No | -| Update billing details, tax ID information, and payment methods | Yes | Yes | Yes | No | -| Cancel the subscription | Yes | No | No | No | -| Transfer ownership | Yes | No | No | No | +| Capability | Owner | Admin | Billing Manager | Member | +| --------------------------------------------------------------------------------- | ----- | ----- | --------------- | ------ | +| Use hosted AI and Edit Predictions through Business | Yes | Yes | No | Yes | +| View organization members | Yes | Yes | Yes | Yes | +| Invite members | Yes | Yes | No | No | +| Change non-owner member roles | Yes | Yes | No | No | +| Remove non-owner members | Yes | Yes | No | No | +| Configure [organization settings and data controls](./business/admin-controls.md) | Yes | Yes | No | No | +| View subscription, usage, and billing information | Yes | Yes | Yes | No | +| Update billing details, tax ID information, and payment methods | Yes | Yes | Yes | No | +| Cancel the subscription | Yes | No | No | No | +| Transfer ownership | Yes | No | No | No | ### Owner {#role-owner} diff --git a/docs/src/troubleshooting.md b/docs/src/troubleshooting.md index 9ef5cc9f32..6181c7b3b1 100644 --- a/docs/src/troubleshooting.md +++ b/docs/src/troubleshooting.md @@ -9,7 +9,7 @@ This guide covers common troubleshooting techniques for Zed. Sometimes you'll be able to identify and resolve issues on your own using this information. Other times, troubleshooting means gathering the right information (logs, profiles, or reproduction steps) to help us diagnose and fix the problem. -> **Note**: To open the command palette, use `cmd-shift-p` on macOS or `ctrl-shift-p` on Windows / Linux. +> **Note**: To open the [command palette](./command-palette.md), use `cmd-shift-p` on macOS or `ctrl-shift-p` on Windows / Linux. ## Retrieve Zed and System Information diff --git a/docs/src/vim.md b/docs/src/vim.md index 6604529a57..435ea759eb 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -32,7 +32,8 @@ When you first open Zed, you'll see a checkbox on the welcome screen that allows If you missed this, you can toggle vim mode on or off anytime by opening the command palette and using the workspace command {#action workspace::ToggleVimMode}. -> **Note**: This command toggles the following property in your user settings: +> **Note**: This command toggles the following +> [`vim_mode` setting](./reference/all-settings.md#vim) in your user settings: > > ```json [settings] > { @@ -388,7 +389,7 @@ With this configuration, you can use commands like: ### Selecting the correct context -Zed's key bindings are evaluated only when the `"context"` property matches your location in the editor. For example, if you add key bindings to the `"Editor"` context, they will only work when you're editing a file. If you add key bindings to the `"Workspace"` context, they will work everywhere in Zed. Here's an example of a key binding that saves when you're editing a file: +Zed's [key bindings](./key-bindings.md) are evaluated only when the `"context"` property matches your location in the editor. For example, if you add key bindings to the `"Editor"` context, they will only work when you're editing a file. If you add key bindings to the `"Workspace"` context, they will work everywhere in Zed. Here's an example of a key binding that saves when you're editing a file: ```json [keymap] { diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index da848c6325..2ecb58d210 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -11,7 +11,7 @@ See [All Settings](./reference/all-settings.md) for additional information and o ## Themes -You can install many [themes](./themes.md) and [icon themes](./icon-themes.md) in form of extensions by running {#action zed::Extensions} from the command palette. +You can install many [themes](./themes.md) and [icon themes](./icon-themes.md) in form of [extensions](./extensions.md) by running {#action zed::Extensions} from the command palette. You can preview/choose amongst your installed themes and icon themes with {#action theme_selector::Toggle} ({#kb theme_selector::Toggle}) and {#action icon_theme_selector::Toggle} which will modify the following settings: @@ -78,15 +78,17 @@ If you would like to use distinct themes for light mode/dark mode that can be se // and any other snippet of code. "agent_buffer_font_size": 12, - // Controls the font size for the markdown preview. - // If not specified, it falls back to the editor font size. - "markdown_preview_font_size": null, - // Controls the font family for the markdown preview. - // If not specified, it falls back to the UI font family. - "markdown_preview_font_family": null, - // Controls the font family for code blocks in the markdown preview. - // If not specified, it falls back to the editor font family. - "markdown_preview_code_font_family": null + "markdown_preview": { + // Controls the font size for the markdown preview. + // If not specified, it falls back to the editor font size. + "font_size": null, + // Controls the font family for the markdown preview. + // If not specified, it falls back to the UI font family. + "font_family": null, + // Controls the font family for code blocks in the markdown preview. + // If not specified, it falls back to the editor font family. + "code_font_family": null + } ``` ### Font ligatures @@ -358,12 +360,18 @@ TBD: Centered layout related settings // Show/hide a button that displays the buffer's character encoding. // If set to "non_utf8", the button is hidden only for UTF-8 without BOM. // Defaults to "non_utf8". - "active_encoding_button": "non_utf8" + "active_encoding_button": "non_utf8", + // Show/hide an indicator while multi-stroke input is pending. + // If the input has a timeout, a countdown is shown and hovering pauses it. + // Unless the which-key menu is enabled, hovering also lists the bindings + // that could still match. + // Defaults to true. + "pending_keystrokes_indicator": true }, "global_lsp_settings": { // Show/hide the LSP button in the status bar. // Activity from the LSP is still shown. - // Button is not shown if "enable_language_server" if false. + // Button is not shown if "enable_language_server" is false. "button": true }, ``` @@ -465,7 +473,7 @@ TBD: Centered layout related settings ## Project Panel -Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#kb project_panel::ToggleFocus}) or with {#action pane::RevealInProjectPanel} ({#kb pane::RevealInProjectPanel}). +[Project panel](./project-panel.md) can be shown/hidden with {#action project_panel::ToggleFocus} ({#kb project_panel::ToggleFocus}) or with {#action pane::RevealInProjectPanel} ({#kb pane::RevealInProjectPanel}). ```json [settings] // Project Panel Settings @@ -516,7 +524,8 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k "enabled": true, // Enable/disable the agent "button": true, // Show/hide the icon in the status bar "dock": "right", // Where to dock: left, right, bottom - "default_width": 640, // Default width (left/right docked) + "flexible": true, // Use proportional sizing (left/right docked) + "default_width": 640, // Fixed width when flexible is false "default_height": 320 // Default height (bottom docked) }, // Controls the font family and size for agent responses in the agent panel. @@ -531,6 +540,8 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k } ``` +When `agent.flexible` is enabled, `agent.default_width` does not control the panel width. Double-clicking the panel’s outer resize handle restores the default proportion. Set `agent.flexible` to `false` to use `agent.default_width` as the reset width. + See [Zed AI Documentation](./ai/overview.md) for additional non-visual AI settings. ## Terminal Panel diff --git a/docs/src/windows-and-projects.md b/docs/src/windows-and-projects.md index da5684cf40..3a821a71f2 100644 --- a/docs/src/windows-and-projects.md +++ b/docs/src/windows-and-projects.md @@ -7,7 +7,7 @@ description: "How Zed handles multiple projects in windows, including the thread Zed lets you work on multiple projects in a single window. Projects appear in the threads sidebar on the left, and you can switch between them while keeping your context intact. -Use **Panel Layout > Agentic** from the user menu in the title bar (or the {#action workspace::UseAgenticLayout} action) to keep the Threads Sidebar and Agent Panel together on the left. Use **Panel Layout > Classic** (or {#action workspace::UseClassicLayout}) to restore the editor-oriented layout. +Use **Panel Layout > Agentic** from the user menu in the title bar (or the {#action workspace::UseAgenticLayout} action) to keep the Threads Sidebar and [Agent Panel](./ai/agent-panel.md) together on the left. Use **Panel Layout > Classic** (or {#action workspace::UseClassicLayout}) to restore the editor-oriented layout. ## How Projects Open @@ -82,7 +82,7 @@ This setting affects CLI and double-click behavior, not the File > Open menu. If you want to add a folder to your current project (not as a separate project in the threads sidebar), you have several options: - **File menu**: File > Add Folder to Project -- **Project panel**: Right-click in the project panel and choose "Add Folders to Project" +- **[Project panel](./project-panel.md)**: Right-click in the project panel and choose "Add Folders to Project" - **Open Recent**: Select a recent project and click the "Add Folder to this Project" button This adds the folder as an additional root in your current project's file tree, similar to VS Code's multi-root workspaces. diff --git a/extensions/glsl/Cargo.toml b/extensions/glsl/Cargo.toml index 2c08cf936d..71df4d0bd1 100644 --- a/extensions/glsl/Cargo.toml +++ b/extensions/glsl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_glsl" -version = "0.2.4" +version = "0.2.5" edition.workspace = true publish.workspace = true license = "Apache-2.0" @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/glsl.rs" crate-type = ["cdylib"] +test = false [dependencies] zed_extension_api = "0.1.0" diff --git a/extensions/glsl/extension.toml b/extensions/glsl/extension.toml index 14731e7ba2..9a0a525a7a 100644 --- a/extensions/glsl/extension.toml +++ b/extensions/glsl/extension.toml @@ -1,7 +1,7 @@ id = "glsl" name = "GLSL" description = "GLSL support." -version = "0.2.4" +version = "0.2.5" schema_version = 1 authors = ["Mikayla Maki "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/html/Cargo.toml b/extensions/html/Cargo.toml index aefe0eb120..f842541dda 100644 --- a/extensions/html/Cargo.toml +++ b/extensions/html/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_html" -version = "0.3.1" +version = "0.3.2" edition.workspace = true publish.workspace = true license = "Apache-2.0" @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/html.rs" crate-type = ["cdylib"] +test = false [dependencies] zed_extension_api = "0.7.0" diff --git a/extensions/html/extension.toml b/extensions/html/extension.toml index eb8fc18621..b9ba7cfa9e 100644 --- a/extensions/html/extension.toml +++ b/extensions/html/extension.toml @@ -1,7 +1,7 @@ id = "html" name = "HTML" description = "HTML support." -version = "0.3.1" +version = "0.3.2" schema_version = 1 authors = ["Isaac Clayton "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 685b3271f5..dad9a67d95 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.3.3" +version = "0.3.4" edition.workspace = true publish.workspace = true license = "Apache-2.0" @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/proto.rs" crate-type = ["cdylib"] +test = false [dependencies] zed_extension_api = "0.7.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index 8ee2004883..d09dcf58f6 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,7 +1,7 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.3.3" +version = "0.3.4" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/test-extension/Cargo.toml b/extensions/test-extension/Cargo.toml index 7d2412b98f..9de84dec27 100644 --- a/extensions/test-extension/Cargo.toml +++ b/extensions/test-extension/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [lib] path = "src/test_extension.rs" crate-type = ["cdylib"] +test = false [dependencies] zed_extension_api = { path = "../../crates/extension_api" } diff --git a/flake.lock b/flake.lock index a0a2137505..d7d86c5b29 100644 --- a/flake.lock +++ b/flake.lock @@ -79,11 +79,11 @@ ] }, "locked": { - "lastModified": 1786247265, - "narHash": "sha256-cVTcTqAhzSNMOVddtBhFpuv+Vx6/SgrhgZWJiI61H24=", + "lastModified": 1788851328, + "narHash": "sha256-YOwlUD0Lt/3SulKhZ7z0Jmgbq/DWh8SjnGwUlFSw+YM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "6df7076ea0f5697e3242719a4c71211f00411cf5", + "rev": "6ae57a71bcb0bebc7a66cc2bd76942c4cf649167", "type": "github" }, "original": { diff --git a/nix/build.nix b/nix/build.nix index c01d869fdd..a3608f5896 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -79,6 +79,10 @@ let in builtins.elem firstComp topLevelIncludes; + corgiPatches = builtins.path { + path = ../tooling/corgi/patches; + name = "corgi-patches"; + }; craneLib = crane.overrideToolchain rustToolchain; gpu-lib = if withGLES then libglvnd else vulkan-loader; commonArgs = @@ -305,7 +309,21 @@ let drv; }; }; - cargoArtifacts = craneLib.buildDepsOnly commonArgs; + cargoArtifacts = craneLib.buildDepsOnly ( + builtins.removeAttrs commonArgs [ "src" ] + // { + dummySrc = craneLib.mkDummySrc { + inherit (commonArgs) src cargoLock; + # `scratch` is a local dependency of `cxx-build`, so its API is needed + # while Crane builds third-party dependencies. + extraDummyScript = '' + rm -rf $out/tooling/corgi/patches + mkdir -p $out/tooling/corgi + cp --recursive ${corgiPatches} $out/tooling/corgi/patches + ''; + }; + } + ); in craneLib.buildPackage ( lib.recursiveUpdate commonArgs { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 78f34e4d69..0d6cb31502 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.97.1" +channel = "1.98.1" profile = "minimal" components = [ "rustfmt", "clippy", "rust-analyzer", "rust-src" ] targets = [ diff --git a/script/bundle-linux b/script/bundle-linux index 8b2967e1f7..2c7d4430fa 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -43,6 +43,11 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -n "${RUSTFLAGS+x}" || -n "${CARGO_ENCODED_RUSTFLAGS+x}" ]]; then + echo "Unset RUSTFLAGS and CARGO_ENCODED_RUSTFLAGS; put extra flags in target rustflags in .cargo/config.toml so bundle flags are preserved." >&2 + exit 1 +fi + export ZED_BUNDLE=true channel=$(&1 | head -n 1 || echo "") if [ "$cargo_bundle_version" != "cargo-bundle v0.6.1-zed" ]; then @@ -87,10 +91,10 @@ script/generate-licenses rustup target add $target_triple echo "Compiling zed binaries" -cargo --config .cargo/bundle-config.toml build ${build_flag} --package zed --package cli --target $target_triple +cargo --config .cargo/bundle-config.toml build ${build_flag} ${zed_features} --package zed --package cli --target $target_triple # Build remote_server in separate invocation to prevent feature unification from other crates # from influencing dynamic libraries required by it. -cargo --config .cargo/bundle-config.toml build ${build_flag} --package remote_server --target $target_triple +cargo --config .cargo/bundle-config.toml build ${build_flag} ${remote_server_features} --package remote_server --target $target_triple echo "Creating application bundle" pushd crates/zed @@ -292,24 +296,30 @@ function sign_binary() { fi } +debug_symbols_extracted=false +function extract_debug_symbols() { + if [ "$debug_symbols_extracted" = true ]; then + return + fi + debug_symbols_extracted=true + if ! dsymutil --flat "target/${target_triple}/${target_dir}/zed" 2> target/dsymutil.log; then + echo "dsymutil failed" + cat target/dsymutil.log + exit 1 + fi + if ! dsymutil --flat "target/${target_triple}/${target_dir}/remote_server" 2> target/dsymutil.log; then + echo "dsymutil failed" + cat target/dsymutil.log + exit 1 + fi +} + function upload_debug_symbols() { if [ "$local_install" = true ]; then echo "local install; skipping sentry upload." elif [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then echo "Uploading zed debug symbols to sentry..." - exe_path="target/${target_triple}/release/Zed" - if ! dsymutil --flat "target/${target_triple}/${target_dir}/zed" 2> target/dsymutil.log; then - echo "dsymutil failed" - cat target/dsymutil.log - exit 1 - fi - if ! dsymutil --flat "target/${target_triple}/${target_dir}/remote_server" 2> target/dsymutil.log; then - echo "dsymutil failed" - cat target/dsymutil.log - exit 1 - fi - # note: this uploads the unstripped binary which is needed because it contains - # .eh_frame data for stack unwinding. see https://github.com/getsentry/symbolic/issues/783 + extract_debug_symbols # Try uploading up to 3 times for attempt in 1 2 3; do echo "Sentry upload attempt $attempt..." @@ -332,6 +342,16 @@ function upload_debug_symbols() { upload_debug_symbols +# Sentry receives the extracted zed.dwarf, not the binary, so debug symbols +# must be extracted before the binaries are stripped below. The .dwarf files +# also keep symbols recoverable when the sentry upload is skipped. +if [[ "$target_dir" == "release" && "$local_install" != true ]]; then + extract_debug_symbols + strip -x "target/${target_triple}/${target_dir}/zed" + strip -x "target/${target_triple}/${target_dir}/cli" + strip -x "target/${target_triple}/${target_dir}/remote_server" +fi + cp target/${target_triple}/${target_dir}/zed "${app_path}/Contents/MacOS/zed" cp target/${target_triple}/${target_dir}/cli "${app_path}/Contents/MacOS/cli" sign_app_binaries diff --git a/script/check-links b/script/check-links index 259ce1d1b9..f662305b18 100755 --- a/script/check-links +++ b/script/check-links @@ -25,8 +25,8 @@ cargo install lychee cd "$(dirname "$0")/.." if [ "$check_mode" = "all" ]; then - lychee --no-progress './docs/src/**/*' + lychee --config .config/lychee.toml --no-progress './docs/src/**/*' else - lychee --exclude '^http' './docs/src/**/*' + lychee --config .config/lychee.toml --exclude '^http' './docs/src/**/*' fi # diff --git a/script/clippy b/script/clippy index 2919abfb9d..a43168dea6 100755 --- a/script/clippy +++ b/script/clippy @@ -15,7 +15,7 @@ if [[ -z "${GITHUB_ACTIONS+x}" ]]; then cargo shear --locked --deny-warnings which typos >/dev/null 2>&1 || exit 0 - typos --config typos.toml + typos --config .config/typos.toml which buf >/dev/null 2>&1 || exit 0 buf lint crates/proto/proto diff --git a/script/community-pr-track-mapping.json b/script/community-pr-track-mapping.json index 74d5f3b4bf..4297a78ec0 100644 --- a/script/community-pr-track-mapping.json +++ b/script/community-pr-track-mapping.json @@ -269,7 +269,8 @@ "area:integrations/environment", "area:accessibility", "area:docs", - "platform:general" + "platform:general", + "platform:web" ] } ] diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index 919624a861..25ef35a9b9 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -1,4 +1,6 @@ -import { danger, message, warn, fail, schedule } from "danger"; +import { danger, message, fail, schedule } from "danger"; +import { releaseNotesSection, releaseNotesEntries } from "../lib/release-notes"; + const { prHygiene } = require("danger-plugin-pr-hygiene"); prHygiene({ @@ -12,13 +14,13 @@ prHygiene({ }, }); -const RELEASE_NOTES_PATTERN = /Release Notes:(\r?\n)+- /gm; const body = danger.github.pr.body; - -const hasReleaseNotes = RELEASE_NOTES_PATTERN.test(body); +const releaseNotes = releaseNotesSection(body); +const entries = releaseNotesEntries(releaseNotes); +const hasReleaseNotes = releaseNotes !== ""; if (!hasReleaseNotes) { - warn( + fail( [ "This PR is missing release notes.", "", @@ -36,14 +38,66 @@ if (!hasReleaseNotes) { "", "- N/A", "```", + "", + "If your change touches a `gpui`-related crate, you must also add an entry for the GPUI release notes:", + "```", + "Release Notes:", + "", + "- Added/Fixed/Improved ...", + "- [GPUI] Added/Fixed/Improved ...", + "```", + "", + 'If the change is not user-facing for GPUI users, use "- [GPUI] N/A" for that entry.', ].join("\n"), ); +} else if ( + entries.some((entry) => { + const description = entry.replace(/^-\s*(?:\[GPUI\]\s*)?/i, "").trim(); + return description === "" || /^(?:N\/A\s+or\s+)?Added\/Fixed\/Improved\s*(?:\.{3}|…)$/i.test(description); + }) +) { + fail( + [ + "This PR has no proper release notes.", + "", + 'Please replace them with "N/A" or a description of the changes.', + ].join("\n"), + ); +} + +const GPUI_RELEASE_NOTES_PATTERN = /^- \[GPUI\]/im; + +const gpuiCrates = danger.git.fileMatch("crates/gpui*/**"); + +if (gpuiCrates.edited || gpuiCrates.deleted) { + if (!entries.some((entry) => GPUI_RELEASE_NOTES_PATTERN.test(entry))) { + const { edited, deleted } = gpuiCrates.getKeyedPaths(); + const touchedGpuiCratesStr = [...edited, ...deleted] + .map((file) => "`" + file.split("/")[1] + "`") + .filter((crate, index, self) => self.indexOf(crate) === index) + .join(", "); + fail( + [ + `This PR modifies GPUI crates (${touchedGpuiCratesStr}), which requires a GPUI release notes entry.`, + "", + 'Please add at least one entry prefixed with `[GPUI]` to the "Release Notes" section:', + "```", + "Release Notes:", + "", + "- Added/Fixed/Improved ...", + "- [GPUI] Added/Fixed/Improved ...", + "```", + "", + 'If the change is not user-facing for GPUI users, use "- [GPUI] N/A" for that entry.', + ].join("\n"), + ); + } } const ISSUE_LINK_PATTERN = /(?:- )?(?= 14'} @@ -400,6 +409,14 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + universal-user-agent@6.0.1: resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} @@ -500,6 +517,10 @@ snapshots: dependencies: '@octokit/openapi-types': 24.2.0 + '@types/node@24.13.4': + dependencies: + undici-types: 7.18.2 + agent-base@7.1.4: {} ansi-styles@4.3.0: @@ -824,6 +845,10 @@ snapshots: tr46@0.0.3: {} + typescript@5.9.3: {} + + undici-types@7.18.2: {} + universal-user-agent@6.0.1: {} webidl-conversions@3.0.1: {} diff --git a/script/danger/tsconfig.json b/script/danger/tsconfig.json new file mode 100644 index 0000000000..ceb7eddf4d --- /dev/null +++ b/script/danger/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "strict": true, + "allowJs": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["dangerfile.ts"] +} diff --git a/script/download-wasi-sdk b/script/download-wasi-sdk index 888f5d9175..dad551a6fb 100755 --- a/script/download-wasi-sdk +++ b/script/download-wasi-sdk @@ -1,61 +1,74 @@ #!/bin/bash set -euo pipefail -# Check if ./target/wasi-sdk exists -if [ ! -d "./target/wasi-sdk" ]; then - echo "WASI SDK not found, downloading v25..." +WASI_SDK_VERSION="34" +WASI_SDK_DIR="./target/wasi-sdk" - # Determine OS and architecture - OS=$(uname -s | tr '[:upper:]' '[:lower:]') - ARCH=$(uname -m) - - # Map architecture names to WASI SDK format - case $ARCH in - x86_64) - ARCH="x86_64" - ;; - arm64|aarch64) - ARCH="arm64" - ;; - *) - echo "Unsupported architecture: $ARCH" - exit 1 - ;; - esac - - # Map OS names to WASI SDK format - case $OS in - darwin) - OS="macos" - ;; - linux) - OS="linux" - ;; - mingw*|msys*|cygwin*) - OS="mingw" - ;; - *) - echo "Unsupported OS: $OS" - exit 1 - ;; - esac - - # Construct download URL - WASI_SDK_VERSION="25" - WASI_SDK_URL="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}.tar.gz" - - echo "Downloading from: $WASI_SDK_URL" - - # Create target directory if it doesn't exist - mkdir -p ./target - - # Download and extract - curl -fL "$WASI_SDK_URL" | tar -xz -C ./target - - # Rename the extracted directory to wasi-sdk - mv "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" "./target/wasi-sdk" - - echo "WASI SDK v25 installed successfully" -else - echo "WASI SDK already exists at ./target/wasi-sdk" +installed_version="" +if [ -f "${WASI_SDK_DIR}/VERSION" ]; then + installed_version=$(head -n 1 "${WASI_SDK_DIR}/VERSION" | tr -d '\r') fi + +if [ "${installed_version}" = "${WASI_SDK_VERSION}.0" ]; then + echo "WASI SDK v${WASI_SDK_VERSION} already exists at ${WASI_SDK_DIR}" + exit 0 +fi + +if [ -d "${WASI_SDK_DIR}" ]; then + echo "WASI SDK at ${WASI_SDK_DIR} is version '${installed_version}', replacing with v${WASI_SDK_VERSION}..." +else + echo "WASI SDK not found, downloading v${WASI_SDK_VERSION}..." +fi + +# Determine OS and architecture +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +# Map architecture names to WASI SDK format +case $ARCH in + x86_64) + ARCH="x86_64" + ;; + arm64|aarch64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +# Map OS names to WASI SDK format +case $OS in + darwin) + OS="macos" + ;; + linux) + OS="linux" + ;; + mingw*|msys*|cygwin*) + OS="windows" + ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +# Construct download URL +WASI_SDK_URL="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}.tar.gz" + +echo "Downloading from: $WASI_SDK_URL" + +# Create target directory if it doesn't exist +mkdir -p ./target + +# Download and extract +rm -rf "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" +curl -fL "$WASI_SDK_URL" | tar -xz -C ./target + +# Rename the extracted directory to wasi-sdk +rm -rf "${WASI_SDK_DIR}" +mv "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" "${WASI_SDK_DIR}" + +echo "WASI SDK v${WASI_SDK_VERSION} installed successfully" diff --git a/script/draft-release-notes b/script/draft-release-notes index da819da88e..ac32ce9ad8 100755 --- a/script/draft-release-notes +++ b/script/draft-release-notes @@ -1,21 +1,52 @@ #!/usr/bin/env node --redirect-warnings=/dev/null const { execFileSync } = require("child_process"); +const { parseArgs } = require("util"); +const { releaseNotesSection, releaseNotesEntries } = require("./lib/release-notes"); -main(); +const GPUI_PREFIX_PATTERN = /^- \[GPUI\]\s*/i; -async function main() { - let version = process.argv[2]; - let channel = process.argv[3]; - let parts = version.split("."); +const USAGE = [ + "Usage: draft-release-notes {stable|preview} [--gpui]", + "", + "By default, release notes entries prefixed with `- [GPUI]` are excluded.", + "With --gpui, only those entries are included, with the prefix removed.", +].join("\n"); - if ( - process.argv.length != 4 || - parts.length != 3 || - parts.find((part) => isNaN(part)) != null || - (channel != "stable" && channel != "preview") - ) { - console.log("Usage: draft-release-notes {stable|preview}"); +function parseArguments() { + let values, positionals; + try { + ({ values, positionals } = parseArgs({ + allowPositionals: true, + options: { gpui: { type: "boolean", default: false } }, + })); + } catch (e) { + return { error: e.message }; + } + + let [version, channel, ...rest] = positionals; + if (version == null || channel == null || rest.length > 0) { + return { error: "Expected exactly two positional arguments: {stable|preview}" }; + } + + let parts = version.split(".").map((part) => Number(part)); + if (parts.length != 3 || parts.some((part) => !Number.isInteger(part))) { + return { error: `Invalid version "${version}", expected ..` }; + } + + if (channel != "stable" && channel != "preview") { + return { error: `Invalid channel "${channel}", expected "stable" or "preview"` }; + } + + return { version, channel, parts, gpui: values.gpui }; +} + +function main() { + let { error, version, channel, parts, gpui } = parseArguments(); + if (error) { + console.error(error); + console.error(""); + console.error(USAGE); process.exit(1); } @@ -61,7 +92,8 @@ async function main() { for (const commit of newCommits) { let link = "https://github.com/zed-industries/zed/pull/" + commit.pr; - let notes = commit.releaseNotes; + let filteredNotes = filterReleaseNotes(commit.releaseNotes, gpui); + let notes = filteredNotes; if (commit.pr == "") { link = "https://github.com/zed-industries/zed/commits/" + commit.hash; } else if (!notes.includes("zed-industries/zed/issues")) { @@ -70,7 +102,7 @@ async function main() { if (commit.releaseNotes == "") { missing.push("- MISSING " + commit.firstLine + " " + link); - } else if (commit.releaseNotes.startsWith("- N/A")) { + } else if (filteredNotes == "" || filteredNotes.startsWith("- N/A")) { skipped.push("- N/A " + commit.firstLine + " " + link); } else { releaseNotes.push(notes); @@ -85,6 +117,16 @@ async function main() { } } +// Entries prefixed with `- [GPUI]` describe changes for GPUI users. They are kept +// (with the prefix removed) only when drafting GPUI release notes, and dropped otherwise. +function filterReleaseNotes(releaseNotes, gpui) { + return releaseNotes + .split("\n") + .filter((line) => GPUI_PREFIX_PATTERN.test(line) == gpui) + .map((line) => (gpui ? line.replace(GPUI_PREFIX_PATTERN, "- ") : line)) + .join("\n"); +} + function getCommits(oldTag, newTag) { const pullRequestNumbers = execFileSync( "git", @@ -98,10 +140,7 @@ function getCommits(oldTag, newTag) { let [hash, firstLine] = commit.split("\n")[0].split("|||"); let cherryPick = firstLine.match(/\(cherry-pick #([0-9]+)\)/)?.[1] || ""; let pr = firstLine.match(/\(#(\d+)\)$/)?.[1] || ""; - let releaseNotes = (commit.split(/Release notes:.*\n/i)[1] || "") - .split("\n\n")[0] - .trim() - .replace(/\n(?![\n-])/g, " "); + let releaseNotes = releaseNotesEntries(releaseNotesSection(commit)).join("\n"); if (releaseNotes.includes("")) { releaseNotes = ""; @@ -118,3 +157,7 @@ function getCommits(oldTag, newTag) { return pullRequestNumbers; } + +if (require.main === module) { + main(); +} diff --git a/script/github-check-new-issue-for-duplicates.py b/script/github-check-new-issue-for-duplicates.py index c1289c2156..902ee03085 100644 --- a/script/github-check-new-issue-for-duplicates.py +++ b/script/github-check-new-issue-for-duplicates.py @@ -32,12 +32,11 @@ import json import os import re import sys -import time from datetime import datetime, timedelta import requests +from github_helpers import github_graphql, github_rest_api, post_github_comment -GITHUB_API = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" TRACKING_ISSUE_NUMBER = 46355 @@ -54,70 +53,26 @@ STOPWORDS = { "the", "this", "when", "while", "with", "won't", "work", "working", "zed", } -# HTTP statuses we'll retry on for GET requests -TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504} - def log(message): """Print to stderr so it doesn't interfere with JSON output on stdout.""" print(message, file=sys.stderr) -def github_api_get(path, params=None): - """Fetch JSON from the GitHub API, retrying transient failures. Raises on non-2xx status.""" - url = f"{GITHUB_API}/{path.lstrip('/')}" - for attempt in range(3): - try: - response = requests.get(url, headers=GITHUB_HEADERS, params=params) - response.raise_for_status() - return response.json() - except requests.RequestException as e: - transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( - isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES - ) - if not transient or attempt == 2: - raise - wait = 2 ** attempt - log(f" Transient GitHub API error ({e}); retrying in {wait}s") - time.sleep(wait) - - def github_search_issues(query, per_page=50, sort=None): """Search issues, using GitHub's relevance ordering unless a sort is specified.""" params = {"q": query, "per_page": per_page} if sort: params.update({"sort": sort, "order": "desc"}) - return github_api_get("/search/issues", params).get("items", []) - - -def github_api_graphql(query, variables=None): - """Run a GraphQL query against the GitHub API, retrying transient failures. """ - url = f"{GITHUB_API}/graphql" - for attempt in range(3): - try: - response = requests.post( - url, headers=GITHUB_HEADERS, json={"query": query, "variables": variables or {}} - ) - response.raise_for_status() - data = response.json() - if "errors" in data: - raise ValueError(f"GraphQL errors: {json.dumps(data['errors'])[:300]}") - return data["data"] - except requests.RequestException as e: - transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( - isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES - ) - if not transient or attempt == 2: - raise - wait = 2 ** attempt - log(f" Transient GitHub GraphQL error ({e}); retrying in {wait}s") - time.sleep(wait) + return github_rest_api("GET", "search/issues", params=params).get("items", []) def check_team_membership(org, team_slug, username): """Check if user is an active member of a team.""" try: - data = github_api_get(f"/orgs/{org}/teams/{team_slug}/memberships/{username}") + data = github_rest_api( + "GET", f"orgs/{org}/teams/{team_slug}/memberships/{username}" + ) return data.get("state") == "active" except requests.HTTPError as e: if e.response.status_code == 404: @@ -125,13 +80,6 @@ def check_team_membership(org, team_slug, username): raise -def post_comment(issue_number: int, body): - url = f"{GITHUB_API.rstrip('/')}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" - response = requests.post(url, headers=GITHUB_HEADERS, json={"body": body}) - response.raise_for_status() - log(f" Posted comment on #{issue_number}") - - def format_candidate_reference(match): candidate = match["candidate"] if candidate["kind"] == "discussion": @@ -288,7 +236,9 @@ def fetch_issue(issue_number: int): """Fetch issue from GitHub and return as a dict.""" log(f"Fetching issue #{issue_number}") - issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}") + issue_data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}" + ) issue = { "number": issue_number, "title": issue_data["title"], @@ -320,8 +270,9 @@ def fetch_area_labels(): labels = [] page = 1 - while page_labels := github_api_get( - f"/repos/{REPO_OWNER}/{REPO_NAME}/labels", + while page_labels := github_rest_api( + "GET", + f"repos/{REPO_OWNER}/{REPO_NAME}/labels", params={"per_page": 100, "page": page}, ): labels.extend(page_labels) @@ -481,7 +432,9 @@ def parse_duplicate_magnets(): """ log(f"Parsing duplicate magnets from #{TRACKING_ISSUE_NUMBER}") - issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}") + issue_data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}" + ) body = issue_data.get("body") or "" # parse the issue body @@ -528,7 +481,9 @@ def enrich_magnets(magnets): """Fetch details for magnets and normalize them as candidates.""" log(f" Fetching details for {len(magnets)} magnets") for magnet in magnets: - data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}") + data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}" + ) magnet.update({ "key": f"issue:{magnet['number']}", "kind": "issue", @@ -743,8 +698,9 @@ def enrich_popular_candidate_comments(candidates): ): continue try: - comments = github_api_get( - f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{candidate['number']}/comments", + comments = github_rest_api( + "GET", + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{candidate['number']}/comments", params={"per_page": 100}, ) except requests.RequestException as error: @@ -795,7 +751,7 @@ def search_discussions(issue, detected_areas, search_queries, max_searches=6): for search_type, query in queries[:max_searches]: log(f" Discussion search ({search_type}): {query}") try: - data = github_api_graphql(gql, {"q": query}) + data = github_graphql(gql, {"q": query}) for result_rank, node in enumerate(data["search"]["nodes"]): if not node: continue @@ -1322,22 +1278,12 @@ if __name__ == "__main__": parser.add_argument("--dry-run", action="store_true", help="Skip posting comment, just log what would be posted") args = parser.parse_args() - github_token = os.environ.get("GITHUB_TOKEN") anthropic_key = os.environ.get("ANTHROPIC_API_KEY") - if not github_token: - log("Error: GITHUB_TOKEN not set") - sys.exit(1) if not anthropic_key: log("Error: ANTHROPIC_API_KEY not set") sys.exit(1) - GITHUB_HEADERS = { - "Authorization": f"Bearer {github_token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - issue = fetch_issue(args.issue_number) if should_skip(issue): print(json.dumps({"skipped": True})) @@ -1376,7 +1322,10 @@ if __name__ == "__main__": else: log("Posting comment") try: - post_comment(issue["number"], comment_body) + post_github_comment( + REPO_OWNER, REPO_NAME, issue["number"], comment_body + ) + log(f" Posted comment on #{issue['number']}") commented = True except requests.RequestException as e: log(f" Failed to post comment: {e}") diff --git a/script/github-community-pr-board.py b/script/github-community-pr-board.py index 92724e5722..96a12cb7d6 100644 --- a/script/github-community-pr-board.py +++ b/script/github-community-pr-board.py @@ -5,8 +5,8 @@ and surface signal fields (Size, Issue Linked, Contributor, Upvotes) that the board's views can filter and sort on. Reads the event payload dispatched by the GitHub Actions workflow and: -- On `labeled`: adds the PR to the board (idempotent), sets the Track - field to the most specific matching track, and recomputes signal fields. +- On `labeled` or `reopened`: adds the PR to the board (idempotent), sets the + Track field to the most specific matching track, and recomputes signal fields. - On `unlabeled`: re-resolves Track from remaining labels, or clears it if no area/platform labels remain (PR stays on the board for visibility). Signals are recomputed if the PR is on the board. @@ -46,16 +46,17 @@ Usage (called by the workflow, not directly): import json import os import sys -import time from pathlib import Path import requests +from github_helpers import ( + add_github_project_item, + fetch_github_project, + github_graphql, + github_rest_api, + set_github_project_field, +) -RETRYABLE_STATUS_CODES = {502, 503, 504} -MAX_RETRIES = 3 -RETRY_DELAY_SECONDS = 5 - -GITHUB_API_URL = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" STAFF_TEAM_SLUG = "staff" @@ -81,7 +82,7 @@ def sync_track_from_labels(pr, project_number): pr_labels = {label["name"] for label in pr.get("labels", [])} track_name = resolve_track(pr_labels, load_mapping()) - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) project_item = github_find_project_item(project["id"], pr["node_id"]) if not track_name: @@ -97,8 +98,8 @@ def sync_track_from_labels(pr, project_number): print(f"Resolved track: {track_name}") if not project_item: - project_item = github_add_to_project(project["id"], pr["node_id"]) - github_set_project_field(project, project_item, "Track", track_name) + project_item = add_github_project_item(project["id"], pr["node_id"]) + set_github_project_field(project, project_item, "Track", track_name) return project, project_item @@ -108,13 +109,13 @@ def set_progress_status_on_assignment(pr, assignee_login, project_number): print(f"Assignee '{assignee_login}' is not a staff member, skipping") return - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) item_id = github_find_project_item(project["id"], pr["node_id"]) if not item_id: print(f"PR #{pr['number']} not on board, skipping assignment status update") return - github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) + set_github_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) def return_to_reviewer(pr, project_number, reason): @@ -123,7 +124,7 @@ def return_to_reviewer(pr, project_number, reason): Called when the author signals they're ready for re-review, either by re-requesting review or by commenting on the PR. """ - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) item_id = github_find_project_item(project["id"], pr["node_id"]) if not item_id: print(f"PR #{pr['number']} not on board, skipping") @@ -134,7 +135,7 @@ def return_to_reviewer(pr, project_number, reason): print( f"{reason}, flipping status from '{current_status}' to '{STATUS_IN_PROGRESS_US}'" ) - github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) + set_github_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) else: print(f"Current status is '{current_status}', not flipping ({reason})") @@ -183,15 +184,17 @@ def recompute_signals(pr, project, project_item): total_changes = pr.get("additions", 0) + pr.get("deletions", 0) author_login = (pr.get("user") or {}).get("login") + issue_type, upvotes = github_pr_issue_signals(pr["node_id"], author_login) + set_field_optional(project, project_item, "Size", compute_size_bucket(total_changes)) set_field_optional(project, project_item, "Contributor", compute_contributor(pr_labels)) - set_field_optional(project, project_item, "Issue Linked", github_pr_issue_type(pr["node_id"])) - set_number_field_optional(project, project_item, "Upvotes", github_pr_upvotes(pr["node_id"], author_login)) + set_field_optional(project, project_item, "Issue Linked", issue_type) + set_number_field_optional(project, project_item, "Upvotes", upvotes) def refresh_signals_if_on_board(pr, project_number): """Recompute signals for a PR only if it's already on the board.""" - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) project_item = github_find_project_item(project["id"], pr["node_id"]) if not project_item: print(f"PR #{pr['number']} not on board, skipping signal refresh") @@ -205,7 +208,7 @@ def refresh_all_board_items(project_number): Backstop for the daily refresh workflow. Individual PR failures are logged and don't abort the rest of the run. """ - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) processed = skipped = errors = 0 for item in github_list_project_items(project["id"]): if item.get("isArchived"): @@ -260,115 +263,28 @@ def resolve_track(pr_labels, tracks): return None -def github_graphql(query, variables): - """Execute a GitHub GraphQL query. Retries on transient server errors.""" - for attempt in range(MAX_RETRIES + 1): - response = requests.post( - f"{GITHUB_API_URL}/graphql", - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables}, - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - result = response.json() - if "errors" in result: - raise RuntimeError(f"GraphQL error: {result['errors']}") - return result["data"] - raise RuntimeError("github_graphql: retry loop exited without return") - - -def github_rest_get(path): - """GET from the GitHub REST API. Retries on transient server errors.""" - for attempt in range(MAX_RETRIES + 1): - response = requests.get(f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - return response.json() - raise RuntimeError("github_rest_get: retry loop exited without return") - - def github_is_staff_member(username): """Check if a user is a member of the staff team.""" try: - response = requests.get( - f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}", - headers=GITHUB_HEADERS, + github_rest_api( + "GET", f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}" ) - if response.status_code == 204: - return True - if response.status_code == 404: + return True + except requests.HTTPError as error: + if error.response.status_code == 404: return False - print( - f"Warning: unexpected status {response.status_code} checking staff membership for '{username}'" - ) + print(f"Warning: failed to check staff membership for '{username}': {error}") return False - except requests.RequestException as exc: - print(f"Warning: failed to check staff membership for '{username}': {exc}") + except requests.RequestException as error: + print(f"Warning: failed to check staff membership for '{username}': {error}") return False def github_fetch_pr(pr_number): """Fetch a PR by number via the REST API.""" - return github_rest_get(f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}") + return github_rest_api("GET", f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}") -def github_fetch_project(project_number): - """Fetch a GitHub project board's metadata including fields and their options.""" - data = github_graphql( - """ - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - fields(first: 50) { - nodes { - ... on ProjectV2Field { - id - name - dataType - } - ... on ProjectV2SingleSelectField { - id - name - options { id name } - } - } - } - } - } - } - """, - {"owner": REPO_OWNER, "number": project_number}, - ) - return data["organization"]["projectV2"] - - -def github_add_to_project(project_id, content_node_id): - """Add a PR to the project board. Returns the new project item ID.""" - data = github_graphql( - """ - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { - item { id } - } - } - """, - {"projectId": project_id, "contentId": content_node_id}, - ) - item_id = data["addProjectV2ItemById"]["item"]["id"] - print(f"Added PR to board (item: {item_id})") - return item_id - def github_list_project_items(project_id): """Yield every item on a project board, paginating as needed. @@ -417,29 +333,87 @@ def github_list_project_items(project_id): cursor = page["pageInfo"]["endCursor"] -def github_pr_upvotes(pr_node_id, author_login): - """Return the count of unique positive reactors on the PR. +POSITIVE_REACTIONS = ( + ("thumbsUp", "THUMBS_UP"), + ("heart", "HEART"), + ("hooray", "HOORAY"), + ("rocket", "ROCKET"), +) - Counts users (not bots) who left at least one of THUMBS_UP, HEART, - HOORAY, or ROCKET on the PR itself. Each user is counted once even if - they reacted with multiple positive emojis. The PR author is excluded - so self-reactions don't inflate the count. - Caps at 100 reactors per emoji — above that the exact count stops - mattering for ranking purposes. +def github_reaction_page(subject_node_id, content, cursor): + data = github_graphql( + """ + query($subjectId: ID!, $content: ReactionContent!, $cursor: String!) { + node(id: $subjectId) { + ... on Reactable { + reactions(first: 100, content: $content, after: $cursor) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + } + } + } + """, + {"subjectId": subject_node_id, "content": content, "cursor": cursor}, + ) + return data["node"]["reactions"] + + +def add_positive_reactors(subject, author_login, reactors): + for connection_name, content in POSITIVE_REACTIONS: + reactions = subject[connection_name] + while True: + for reaction in reactions["nodes"]: + user = reaction.get("user") + login = user.get("login") if user else None + if login and login != author_login: + reactors.add(login) + + page_info = reactions["pageInfo"] + if not page_info["hasNextPage"]: + break + reactions = github_reaction_page( + subject["id"], content, page_info["endCursor"] + ) + + +def github_pr_issue_signals(pr_node_id, author_login): + """Return linked issue type and deduplicated upvotes for a PR. + + Includes reactions on closing issues and excludes the PR author. """ data = github_graphql( """ + fragment PositiveReactions on Reactable { + id + thumbsUp: reactions(first: 100, content: THUMBS_UP) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + heart: reactions(first: 100, content: HEART) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + hooray: reactions(first: 100, content: HOORAY) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + rocket: reactions(first: 100, content: ROCKET) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + } + query($prId: ID!) { node(id: $prId) { ... on PullRequest { - reactionGroups { - content - reactors(first: 100) { - nodes { - __typename - ... on User { login } - } + ...PositiveReactions + closingIssuesReferences(first: 20) { + totalCount + nodes { + issueType { name } + ...PositiveReactions } } } @@ -448,63 +422,31 @@ def github_pr_upvotes(pr_node_id, author_login): """, {"prId": pr_node_id}, ) - positive_contents = {"THUMBS_UP", "HEART", "HOORAY", "ROCKET"} + pr = data["node"] + refs = pr["closingIssuesReferences"] + reactors = set() - for group in data["node"]["reactionGroups"] or []: - if group["content"] not in positive_contents: - continue - for node in group["reactors"]["nodes"]: - if node.get("__typename") != "User": - continue - login = node.get("login") - if login and login != author_login: - reactors.add(login) - return len(reactors) + add_positive_reactors(pr, author_login, reactors) + for issue in refs["nodes"]: + add_positive_reactors(issue, author_login, reactors) - -def github_pr_issue_type(pr_node_id): - """Return the Issue Linked field value for a PR. - - Reads `closingIssuesReferences` (authoritative source for what GitHub - will close on merge, covers both `Closes #N` keywords and Development - sidebar links) and maps the linked issues' types to one of: 'Crash', - 'Bug', 'Feature', 'Docs', or 'No issue'. - - When a PR closes multiple issues with different types, returns the - most urgent one by the priority Crash > Bug > Feature > Docs. - """ - data = github_graphql( - """ - query($prId: ID!) { - node(id: $prId) { - ... on PullRequest { - closingIssuesReferences(first: 20) { - totalCount - nodes { issueType { name } } - } - } - } - } - """, - {"prId": pr_node_id}, - ) - refs = data["node"]["closingIssuesReferences"] if refs["totalCount"] == 0: - return "No issue" + return "No issue", len(reactors) + type_names = { - n["issueType"]["name"] for n in refs["nodes"] if n.get("issueType") + issue["issueType"]["name"] + for issue in refs["nodes"] + if issue.get("issueType") } for priority in ("Crash", "Bug", "Feature", "Docs"): if priority in type_names: - return priority - # Org-wide guardrails should ensure every issue has a recognized type, - # so reaching this branch means something slipped through. Log it and - # fall back so the PR doesn't carry stale data. + return priority, len(reactors) + print( f"Warning: PR has {refs['totalCount']} linked issue(s) but none with " f"a recognized type (saw: {type_names or 'no type set'})" ) - return "No issue" + return "No issue", len(reactors) def github_find_project_item(project_id, content_node_id): @@ -535,58 +477,6 @@ def github_find_project_item(project_id, content_node_id): return None -def github_set_project_field(project, item_id, field_name, option_name): - """Set a single-select field on a project item.""" - field_id = None - option_id = None - for field in project["fields"]["nodes"]: - if field.get("name") == field_name: - field_id = field["id"] - for option in field.get("options", []): - if option["name"] == option_name: - option_id = option["id"] - break - break - - if not field_id: - available = [f["name"] for f in project["fields"]["nodes"] if "name" in f] - raise RuntimeError( - f"Field '{field_name}' not found on project. Available: {available}" - ) - if not option_id: - available = [ - opt["name"] - for f in project["fields"]["nodes"] - if f.get("name") == field_name - for opt in f.get("options", []) - ] - raise RuntimeError( - f"Option '{option_name}' not found in field '{field_name}'. " - f"Available: {available}" - ) - - github_graphql( - """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - }) { - projectV2Item { id } - } - } - """, - { - "projectId": project["id"], - "itemId": item_id, - "fieldId": field_id, - "optionId": option_id, - }, - ) - print(f"Set '{field_name}' to '{option_name}'") - def set_field_optional(project, item_id, field_name, option_name): """Set a single-select field, logging and skipping if the field or @@ -736,11 +626,6 @@ def github_get_field_value(item_id, field_name): if __name__ == "__main__": - GITHUB_HEADERS = { - "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } project_number = int(os.environ["PROJECT_NUMBER"]) @@ -796,7 +681,7 @@ if __name__ == "__main__": print(f"Processing PR #{pr['number']}: action={action}") - if action in ("labeled", "unlabeled"): + if action in ("labeled", "unlabeled", "reopened"): project, project_item = sync_track_from_labels(pr, project_number) if project_item: recompute_signals(pr, project, project_item) diff --git a/script/github-guild-board.py b/script/github-guild-board.py index 2635ac9b09..3202a2e39f 100644 --- a/script/github-guild-board.py +++ b/script/github-guild-board.py @@ -29,18 +29,20 @@ import html import json import os import random -import time import urllib.parse from datetime import datetime, timedelta, timezone from functools import lru_cache import requests +from github_helpers import ( + fetch_github_project, + github_graphql, + github_rest_api, + github_rest_get_paginated, + post_github_comment, + set_github_project_field, +) -RETRYABLE_STATUS_CODES = {502, 503, 504} -MAX_RETRIES = 3 -RETRY_DELAY_SECONDS = 5 - -GITHUB_API_URL = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" # Cohort members are outside collaborators on the repo holding this custom @@ -95,71 +97,23 @@ ZEDGAR_QUIPS = [ ] -def github_graphql(query, variables): - for attempt in range(MAX_RETRIES + 1): - response = requests.post( - f"{GITHUB_API_URL}/graphql", - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables}, - timeout=30, - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - result = response.json() - if "errors" in result: - raise RuntimeError(f"GraphQL error: {result['errors']}") - return result["data"] - raise RuntimeError("github_graphql: retry loop exited without return") - - -def github_rest_request(method, path, body=None): - url = f"{GITHUB_API_URL}/{path}" - for attempt in range(MAX_RETRIES + 1): - response = requests.request( - method, url, headers=GITHUB_HEADERS, json=body, timeout=30 - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - if response.status_code == 204 or not response.content: - return None - return response.json() - raise RuntimeError("github_rest_request: retry loop exited without return") - - -def github_rest_get_paginated(path): - results = [] - page = 1 - while True: - separator = "&" if "?" in path else "?" - batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}") - if not batch: - break - results.extend(batch) - if len(batch) < 100: - break - page += 1 - return results - @lru_cache(maxsize=None) def is_guild_member(username): - response = requests.get( - f"{GITHUB_API_URL}/repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission", - headers=GITHUB_HEADERS, - timeout=30, - ) - # 404 means the user isn't a collaborator on the repo at all. - if response.status_code == 404: - return False - response.raise_for_status() + try: + response = github_rest_api( + "GET", + f"repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission", + ) + except requests.HTTPError as error: + # 404 means the user isn't a collaborator on the repo at all. + if error.response.status_code == 404: + return False + raise # role_name is the effective (highest) role for the user. For a cohort of # outside collaborators whose only grant is this custom role, that is the # custom role's name; built-in roles come back lowercased and won't match. - role_name = response.json().get("role_name") or "" + role_name = response.get("role_name") or "" return role_name.lower() == GUILD_ROLE_NAME.lower() @@ -231,30 +185,6 @@ def pr_closing_issues(pr_node_id): return (node.get("closingIssuesReferences") or {}).get("nodes", []) -def fetch_project(project_number): - data = github_graphql( - """ - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - fields(first: 50) { - nodes { - ... on ProjectV2Field { id name dataType } - ... on ProjectV2SingleSelectField { id name options { id name } } - } - } - } - } - } - """, - {"owner": REPO_OWNER, "number": project_number}, - ) - project = data["organization"]["projectV2"] - if not project: - raise RuntimeError(f"Project #{project_number} not found in {REPO_OWNER}") - return project - def find_project_item(project_id, content_node_id): # Includes each item's single-select values so callers can read Status @@ -291,34 +221,6 @@ def find_project_item(project_id, content_node_id): return None -def set_project_field(project, item_id, field_name, option_name): - field = next( - (f for f in project["fields"]["nodes"] if f.get("name") == field_name), None - ) - if not field: - raise RuntimeError(f"Field '{field_name}' not found on board") - option_id = next( - (o["id"] for o in field.get("options", []) if o["name"] == option_name), None - ) - if not option_id: - raise RuntimeError(f"Option '{option_name}' not found in field '{field_name}'") - github_graphql( - """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, itemId: $itemId, fieldId: $fieldId, - value: { singleSelectOptionId: $optionId } - }) { projectV2Item { id } } - } - """, - { - "projectId": project["id"], - "itemId": item_id, - "fieldId": field["id"], - "optionId": option_id, - }, - ) - def list_project_issues(project_id): cursor = None @@ -365,17 +267,11 @@ def list_project_issues(project_id): cursor = page["pageInfo"]["endCursor"] -def post_comment(issue_number, body): - github_rest_request( - "POST", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments", {"body": body} - ) - - def remove_assignees(issue_number, assignees): - github_rest_request( + github_rest_api( "DELETE", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees", - {"assignees": assignees}, + body={"assignees": assignees}, ) @@ -441,7 +337,7 @@ def handle_assignment(issue, assignee_login, project_number): if not is_guild_member(assignee_login): return - project = fetch_project(project_number) + project = fetch_github_project(project_number) item = find_project_item(project["id"], issue["node_id"]) if not item: send_slack( @@ -452,7 +348,7 @@ def handle_assignment(issue, assignee_login, project_number): ) return - set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) + set_github_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) def handle_unassignment(issue, removed_login, sender, project_number): @@ -462,7 +358,7 @@ def handle_unassignment(issue, removed_login, sender, project_number): if not is_guild_member(removed_login): return - project = fetch_project(project_number) + project = fetch_github_project(project_number) item = find_project_item(project["id"], issue["node_id"]) if not item or item_status(item) != STATUS_IN_PROGRESS: return @@ -476,7 +372,7 @@ def handle_unassignment(issue, removed_login, sender, project_number): status = STATUS_FOR_TYPE.get((issue.get("type") or {}).get("name") or "") link = slack_link(issue["html_url"], issue["title"]) if status: - set_project_field(project, item["id"], STATUS_FIELD, status) + set_github_project_field(project, item["id"], STATUS_FIELD, status) send_slack( f"@{removed_login} {who} In Progress issue #{issue['number']} " f"{link}. I moved it back to *{status}*, " @@ -509,11 +405,11 @@ def set_linked_issues_in_progress(pull_request, project_number): ] if not open_issues: return - project = fetch_project(project_number) + project = fetch_github_project(project_number) for issue in open_issues: item = find_project_item(project["id"], issue["id"]) if item: - set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) + set_github_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) def warn_about_extra_open_prs(pull_request, author): @@ -521,7 +417,7 @@ def warn_about_extra_open_prs(pull_request, author): f"repo:{REPO_OWNER}/{REPO_NAME} is:pr is:open " f"author:{author} created:>={COHORT_START_DATE}" ) - result = github_rest_request( + result = github_rest_api( "GET", f"search/issues?q={urllib.parse.quote(query)}&per_page=100" ) others = [ @@ -589,7 +485,7 @@ def run_event(project_number): def run_stale(project_number): - project = fetch_project(project_number) + project = fetch_github_project(project_number) checked_in = cleared = 0 for item in list_project_issues(project["id"]): @@ -638,7 +534,9 @@ def run_stale(project_number): # they have stayed quiet for the check-in window. if (NOW - last_activity).days < CHECK_IN_AFTER_DAYS: continue - post_comment( + post_github_comment( + REPO_OWNER, + REPO_NAME, issue_number, CHECK_IN_BODY.format( marker=CHECK_IN_MARKER, @@ -658,7 +556,7 @@ def run_stale(project_number): status = STATUS_FOR_TYPE.get((content.get("issueType") or {}).get("name") or "") link = slack_link(content["url"], content["title"]) if status: - set_project_field(project, item["id"], STATUS_FIELD, status) + set_github_project_field(project, item["id"], STATUS_FIELD, status) send_slack( f"Issue #{issue_number} {link} went quiet with @{assignee} " "(no open PR, no reply to my check-in), so I cleared the assignment and " @@ -678,7 +576,7 @@ def run_stale(project_number): def run_weekly(project_number): - project = fetch_project(project_number) + project = fetch_github_project(project_number) cutoff = NOW - timedelta(days=SHIPPED_WINDOW_DAYS) shipped = [] @@ -705,11 +603,6 @@ def run_weekly(project_number): if __name__ == "__main__": - GITHUB_HEADERS = { - "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } NOW = datetime.now(timezone.utc) diff --git a/script/github-track-duplicate-bot-effectiveness.py b/script/github-track-duplicate-bot-effectiveness.py index a01a8941eb..569ccb2cff 100644 --- a/script/github-track-duplicate-bot-effectiveness.py +++ b/script/github-track-duplicate-bot-effectiveness.py @@ -24,13 +24,11 @@ import functools import os import re import sys -import time from datetime import datetime, timezone import requests +from github_helpers import add_github_project_item, github_graphql, github_rest_api -GITHUB_API = "https://api.github.com" -GRAPHQL_URL = "https://api.github.com/graphql" REPO_OWNER = "zed-industries" REPO_NAME = "zed" STAFF_TEAM_SLUG = "staff" @@ -48,8 +46,7 @@ BOT_START_DATE = "2026-02-18" NEEDS_TRIAGE_LABEL = "state:needs triage" DEFAULT_PROJECT_NUMBER = 76 VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"} -# HTTP statuses we'll retry on for GET requests -TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504} + # Add a new tuple when you deploy a new version of the bot that you want to # keep track of (e.g. the prompt gets a rewrite or the model gets swapped). # Newest first, please. The datetime is for the deployment time (merge to main). @@ -74,37 +71,19 @@ def bot_version_for_time(date_string): return BOT_VERSION_TIMELINE[-1][0] -def github_api_get(path, params=None): - """Fetch JSON from the GitHub REST API, retrying transient failures. Raises on non-2xx status.""" - url = f"{GITHUB_API}/{path.lstrip('/')}" - for attempt in range(3): - try: - response = requests.get(url, headers=GITHUB_HEADERS, params=params) - response.raise_for_status() - return response.json() - except requests.RequestException as e: - transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( - isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES - ) - if not transient or attempt == 2: - raise - wait = 2 ** attempt - print(f" Transient GitHub API error ({e}); retrying in {wait}s") - time.sleep(wait) - - def github_search_issues(query): """Search issues, returning most recently created first.""" # not handling pagination on purpose: the oldest issues are on the board already params = {"q": query, "sort": "created", "order": "desc", "per_page": 100} - return github_api_get("/search/issues", params).get("items", []) + return github_rest_api("GET", "search/issues", params=params).get("items", []) def is_staff_member(username): """Check if user is an active member of the staff team.""" try: - data = github_api_get( - f"/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/memberships/{username}" + data = github_rest_api( + "GET", + f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/memberships/{username}", ) return data.get("state") == "active" except requests.HTTPError as error: @@ -114,7 +93,9 @@ def is_staff_member(username): def fetch_issue(issue_number): - data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}") + data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}" + ) return { "number": issue_number, "node_id": data["node_id"], @@ -139,9 +120,11 @@ def get_bot_comment_with_time(issue_number): Recognizes user-facing issue and Discussion alerts as well as triage-only comments. Returns {"body": str, "created_at": str} if found, else None. """ - comments_path = f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" + comments_path = f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" page = 1 - while comments := github_api_get(comments_path, {"per_page": 100, "page": page}): + while comments := github_rest_api( + "GET", comments_path, params={"per_page": 100, "page": page} + ): for comment in comments: author = (comment.get("user") or {}).get("login", "") body = comment.get("body", "") @@ -156,22 +139,6 @@ def parse_suggested_issues(comment_body): return [int(match) for match in re.findall(r"^- #(\d+)", comment_body, re.MULTILINE)] -def github_api_graphql(query, variables=None, partial_errors_ok=False): - """Execute a GitHub GraphQL query. Raises on errors unless partial_errors_ok is set.""" - response = requests.post( - GRAPHQL_URL, - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables or {}}, - ) - response.raise_for_status() - data = response.json() - if "errors" in data: - if not partial_errors_ok or "data" not in data: - raise RuntimeError(f"GraphQL errors: {data['errors']}") - print(f" GraphQL partial errors (ignored): {data['errors']}") - return data["data"] - - def find_canonical_among(duplicate_number, candidates): """Check if any candidate issue has duplicate_number marked as a duplicate. @@ -187,7 +154,7 @@ def find_canonical_among(duplicate_number, candidates): # candidate issue numbers are baked into the query body via field aliases # (GraphQL doesn't let you parametrize alias names), so $numbers isn't needed. - data = github_api_graphql( + data = github_graphql( """ query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { @@ -219,7 +186,7 @@ def find_canonical_among(duplicate_number, candidates): @functools.lru_cache def get_project_config(): """Fetch the project board's ID, field IDs, and option IDs.""" - data = github_api_graphql( + data = github_graphql( """ query($org: String!, $number: Int!) { organization(login: $org) { @@ -260,7 +227,7 @@ def find_project_item(issue_node_id): Returns the project item ID if found, or None. """ - data = github_api_graphql( + data = github_graphql( "query($id: ID!) { node(id: $id) { ... on Issue { projectItems(first: 20) { nodes { id project { number } } } } } }", {"id": issue_node_id}, ) @@ -273,17 +240,7 @@ def find_project_item(issue_node_id): def add_project_item(issue_node_id): """Add an issue to the project board. Returns the new item ID.""" config = get_project_config() - data = github_api_graphql( - """ - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { - item { id } - } - } - """, - {"projectId": config["project_id"], "contentId": issue_node_id}, - ) - return data["addProjectV2ItemById"]["item"]["id"] + return add_github_project_item(config["project_id"], issue_node_id) def set_field_value(item_id, field_name, value): @@ -305,7 +262,7 @@ def set_field_value(item_id, field_name, value): # text field field_value = {"text": str(value)} - github_api_graphql( + github_graphql( """ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { updateProjectV2ItemFieldValue(input: { @@ -543,11 +500,6 @@ if __name__ == "__main__": args = parser.parse_args() - GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") - if not GITHUB_TOKEN: - print("Error: GITHUB_TOKEN environment variable is required") - sys.exit(1) - raw_project_number = os.environ.get("PROJECT_NUMBER", "") if raw_project_number: try: @@ -558,11 +510,6 @@ if __name__ == "__main__": else: PROJECT_NUMBER = DEFAULT_PROJECT_NUMBER - GITHUB_HEADERS = { - "Authorization": f"token {GITHUB_TOKEN}", - "Accept": "application/vnd.github+json", - } - if args.command == "classify-closed": classify_closed(args.issue_number, args.closer_login, args.state_reason) elif args.command == "classify-open": diff --git a/script/github-triage-queue-board.py b/script/github-triage-queue-board.py index 73738cd302..caebba03bb 100644 --- a/script/github-triage-queue-board.py +++ b/script/github-triage-queue-board.py @@ -15,18 +15,12 @@ Usage (called by the workflow, not directly): """ import os -import time import urllib.parse from datetime import datetime, timedelta from zoneinfo import ZoneInfo -import requests +from github_helpers import add_github_project_item, github_graphql, github_rest_api -RETRYABLE_STATUS_CODES = {502, 503, 504} -MAX_RETRIES = 3 -RETRY_DELAY_SECONDS = 5 - -GITHUB_API_URL = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" # The eligibility window's boundaries are calendar days in this timezone. GitHub @@ -36,44 +30,6 @@ REPO_NAME = "zed" BUSINESS_DAY_TZ = ZoneInfo("America/Los_Angeles") -def github_graphql(query, variables): - for attempt in range(MAX_RETRIES + 1): - response = requests.post( - f"{GITHUB_API_URL}/graphql", - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables}, - timeout=30, - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - result = response.json() - if "errors" in result: - raise RuntimeError(f"GraphQL error: {result['errors']}") - return result["data"] - raise RuntimeError("github_graphql: retry loop exited without return") - - -def github_rest_get(path): - for attempt in range(MAX_RETRIES + 1): - response = requests.get( - f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS, timeout=30 - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - return response.json() - raise RuntimeError("github_rest_get: retry loop exited without return") - - def day_start(day): # First instant of a calendar day in the business-day timezone. return datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=BUSINESS_DAY_TZ) @@ -108,8 +64,8 @@ def eligible_issues(window_start, window_end): issues = {} page = 1 while True: - data = github_rest_get( - f"search/issues?q={urllib.parse.quote(query)}&per_page=100&page={page}" + data = github_rest_api( + "GET", f"search/issues?q={urllib.parse.quote(query)}&per_page=100&page={page}" ) for item in data["items"]: issues[item["node_id"]] = item["number"] @@ -165,13 +121,6 @@ def project_items(project_id): cursor = items["pageInfo"]["endCursor"] -def add_to_project(project_id, content_id): - github_graphql( - "mutation($p: ID!, $c: ID!) { addProjectV2ItemById(input: {projectId: $p, contentId: $c}) { item { id } } }", - {"p": project_id, "c": content_id}, - ) - - def remove_from_project(project_id, item_id): github_graphql( "mutation($p: ID!, $i: ID!) { deleteProjectV2Item(input: {projectId: $p, itemId: $i}) { deletedItemId } }", @@ -192,7 +141,7 @@ def sync_project(project_id, dry_run=False): if content_id not in on_board: print(f"+ #{number}") if not dry_run: - add_to_project(project_id, content_id) + add_github_project_item(project_id, content_id) for item_id, content_id, number in current: if content_id not in eligible: @@ -202,11 +151,6 @@ def sync_project(project_id, dry_run=False): if __name__ == "__main__": - GITHUB_HEADERS = { - "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } project_number = int(os.environ["PROJECT_NUMBER"]) project_id = fetch_project_id(project_number) sync_project(project_id, os.environ.get("DRY_RUN") == "true") diff --git a/script/github_helpers.py b/script/github_helpers.py new file mode 100644 index 0000000000..42826d36c2 --- /dev/null +++ b/script/github_helpers.py @@ -0,0 +1,214 @@ +import os +import sys +import time + +import requests + +GITHUB_API_URL = "https://api.github.com" +RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} +MAX_ATTEMPTS = 4 +REQUEST_TIMEOUT_SECONDS = 30 + + +def _github_headers(): + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise RuntimeError("GITHUB_TOKEN environment variable is required") + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + +def _retry_delay(response, attempt): + retry_after = response.headers.get("Retry-After") + if retry_after: + try: + return max(float(retry_after), 0) + except ValueError: + pass + return 2**attempt + + +def _github_request(method, path, *, params=None, body=None, retry=True): + url = f"{GITHUB_API_URL}/{path}" + max_attempts = MAX_ATTEMPTS if retry else 1 + for attempt in range(max_attempts): + try: + response = requests.request( + method, + url, + headers=_github_headers(), + params=params, + json=body, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except (requests.ConnectionError, requests.Timeout) as error: + if attempt == max_attempts - 1: + raise + delay = 2**attempt + print( + f"Transient GitHub API error ({error}); retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + + if ( + response.status_code in RETRYABLE_STATUS_CODES + and attempt < max_attempts - 1 + ): + delay = _retry_delay(response, attempt) + print( + f"GitHub API returned {response.status_code}; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + + response.raise_for_status() + return response + + raise RuntimeError("GitHub API retry loop exited without returning") + + +def github_rest_api(method, path, params=None, body=None): + # Retrying writes after an ambiguous timeout can duplicate comments or mutations. + retry = method.upper() in {"GET", "HEAD", "OPTIONS"} + response = _github_request( + method, path, params=params, body=body, retry=retry + ) + if response.status_code == 204 or not response.content: + return {} + return response.json() + + +def github_rest_get_paginated(path): + results = [] + page = 1 + while True: + batch = github_rest_api( + "GET", path, params={"per_page": 100, "page": page} + ) + if not batch: + return results + results.extend(batch) + if len(batch) < 100: + return results + page += 1 + + +def post_github_comment(owner, repository, issue_number, body): + github_rest_api( + "POST", + f"repos/{owner}/{repository}/issues/{issue_number}/comments", + body={"body": body}, + ) + + +def github_graphql(query, variables=None, *, partial_errors_ok=False): + # GraphQL uses POST for reads and writes, so classify the operation before retrying. + retry = not query.lstrip().startswith("mutation") + response = _github_request( + "POST", + "graphql", + body={"query": query, "variables": variables or {}}, + retry=retry, + ) + result = response.json() + if "errors" in result: + if not partial_errors_ok or "data" not in result: + raise RuntimeError(f"GraphQL errors: {result['errors']}") + print(f"GraphQL partial errors (ignored): {result['errors']}", file=sys.stderr) + return result["data"] + + +def fetch_github_project(project_number, owner="zed-industries"): + data = github_graphql( + """ + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + } + """, + {"owner": owner, "number": project_number}, + ) + project = data["organization"]["projectV2"] + if not project: + raise RuntimeError(f"Project #{project_number} not found in {owner}") + return project + + +def add_github_project_item(project_id, content_id): + data = github_graphql( + """ + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + } + """, + {"projectId": project_id, "contentId": content_id}, + ) + return data["addProjectV2ItemById"]["item"]["id"] + + +def set_github_project_field(project, item_id, field_name, option_name): + field = next( + (field for field in project["fields"]["nodes"] if field.get("name") == field_name), + None, + ) + if not field: + available = [ + field["name"] for field in project["fields"]["nodes"] if "name" in field + ] + raise RuntimeError( + f"Field '{field_name}' not found on project. Available: {available}" + ) + + option_id = next( + ( + option["id"] + for option in field.get("options", []) + if option["name"] == option_name + ), + None, + ) + if not option_id: + available = [option["name"] for option in field.get("options", [])] + raise RuntimeError( + f"Option '{option_name}' not found in field '{field_name}'. " + f"Available: {available}" + ) + + github_graphql( + """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + """, + { + "projectId": project["id"], + "itemId": item_id, + "fieldId": field["id"], + "optionId": option_id, + }, + ) + print(f"Set '{field_name}' to '{option_name}'") diff --git a/script/lib/release-notes.js b/script/lib/release-notes.js new file mode 100644 index 0000000000..9e88ada524 --- /dev/null +++ b/script/lib/release-notes.js @@ -0,0 +1,22 @@ +function releaseNotesSection(body) { + // Inline mentions and bullet text must not override the final release notes heading. + const headings = [...body.matchAll(/^Release Notes:(?:\r?\n)+(?=- )/gim)]; + const lastHeading = headings[headings.length - 1]; + return lastHeading ? body.slice(lastHeading.index + lastHeading[0].length) : ""; +} + +function releaseNotesEntries(section) { + const entries = []; + // Blank lines can separate bullets, but a non-bullet paragraph ends the notes + // so commit trailers and other trailing text are not published as release notes. + for (const paragraph of section.split(/\r?\n(?:[ \t]*\r?\n)+/)) { + if (!paragraph.startsWith("- ")) { + break; + } + + entries.push(...paragraph.split(/\r?\n(?=- )/).map((entry) => entry.trimEnd().replace(/\r?\n/g, " "))); + } + return entries; +} + +module.exports = { releaseNotesSection, releaseNotesEntries }; diff --git a/script/linux b/script/linux index 8779263aff..f7445e43e2 100755 --- a/script/linux +++ b/script/linux @@ -173,7 +173,7 @@ if [[ -n $zyp ]]; then gzip jq libvulkan1 - libx11-devel + libX11-devel libxcb-devel libxkbcommon-devel libxkbcommon-x11-devel diff --git a/tooling/compliance/Cargo.toml b/tooling/compliance/Cargo.toml index f98fef6305..8a781b3a68 100644 --- a/tooling/compliance/Cargo.toml +++ b/tooling/compliance/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +doctest = false + [features] octo-client = ["dep:octocrab", "dep:jsonwebtoken", "dep:tokio"] diff --git a/corgi-patches/scratch/Cargo.toml b/tooling/corgi/patches/scratch/Cargo.toml similarity index 100% rename from corgi-patches/scratch/Cargo.toml rename to tooling/corgi/patches/scratch/Cargo.toml diff --git a/tooling/corgi/patches/scratch/LICENSE-APACHE b/tooling/corgi/patches/scratch/LICENSE-APACHE new file mode 120000 index 0000000000..15824831a2 --- /dev/null +++ b/tooling/corgi/patches/scratch/LICENSE-APACHE @@ -0,0 +1 @@ +../../../../LICENSE-APACHE \ No newline at end of file diff --git a/corgi-patches/scratch/build.rs b/tooling/corgi/patches/scratch/build.rs similarity index 100% rename from corgi-patches/scratch/build.rs rename to tooling/corgi/patches/scratch/build.rs diff --git a/corgi-patches/scratch/src/lib.rs b/tooling/corgi/patches/scratch/src/lib.rs similarity index 100% rename from corgi-patches/scratch/src/lib.rs rename to tooling/corgi/patches/scratch/src/lib.rs diff --git a/tooling/perf/Cargo.toml b/tooling/perf/Cargo.toml index d4acad1fdb..1dc07d89b3 100644 --- a/tooling/perf/Cargo.toml +++ b/tooling/perf/Cargo.toml @@ -6,8 +6,6 @@ edition.workspace = true license = "Apache-2.0" description = "A tool for measuring Zed test performance, with too many Clippy lints" -[lib] - # Some personal lint preferences :3 [lints.rust] missing_docs = "warn" @@ -30,3 +28,7 @@ disallowed_methods = { level = "allow", priority = 1} collections.workspace = true serde.workspace = true serde_json.workspace = true + +[lib] +test = false +doctest = false diff --git a/tooling/perf/src/main.rs b/tooling/perf/src/main.rs index 243658e508..78a56286d8 100644 --- a/tooling/perf/src/main.rs +++ b/tooling/perf/src/main.rs @@ -349,13 +349,15 @@ fn get_tests(t_bin: &str) -> impl ExactSizeIterator { ); let out = test_list - .chunks_exact_mut(2) - .map(|pair| { + .as_chunks_mut::<2>() + .0 + .iter() + .map(|&[first, second]| { // Be resilient against changes to these constants. if consts::SUF_NORMAL < consts::SUF_MDATA { - (pair[0].to_owned(), pair[1].to_owned()) + (first.to_owned(), second.to_owned()) } else { - (pair[1].to_owned(), pair[0].to_owned()) + (second.to_owned(), first.to_owned()) } }) .collect::>(); diff --git a/tooling/xtask/Cargo.toml b/tooling/xtask/Cargo.toml index f9628dfa63..5b6b5ecabc 100644 --- a/tooling/xtask/Cargo.toml +++ b/tooling/xtask/Cargo.toml @@ -17,6 +17,7 @@ cargo_toml.workspace = true clap = { workspace = true, features = ["derive"] } compliance = { workspace = true, features = ["octo-client"] } gh-workflow.workspace = true +guppy = "0.18" indoc.workspace = true indexmap.workspace = true itertools.workspace = true @@ -28,3 +29,4 @@ strum.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } toml.workspace = true toml_edit.workspace = true +url.workspace = true diff --git a/tooling/xtask/src/main.rs b/tooling/xtask/src/main.rs index f9db590d23..9594fc6b65 100644 --- a/tooling/xtask/src/main.rs +++ b/tooling/xtask/src/main.rs @@ -19,8 +19,9 @@ enum CliCommand { Licenses(tasks::licenses::LicensesArgs), /// Checks that packages conform to a set of standards. PackageConformity(tasks::package_conformity::PackageConformityArgs), - /// Publishes GPUI and its dependencies to crates.io. - PublishGpui(tasks::publish_gpui::PublishGpuiArgs), + /// Inspect the GPUI publish plan and its dependencies. + #[command(subcommand)] + Gpui(tasks::gpui::GpuiCommand), /// Runs the Linux Bubblewrap sandboxing NixOS VM tests. SandboxTests(tasks::sandbox_tests::SandboxTestsArgs), /// Runs the Windows WSL Bubblewrap sandbox behavior tests. @@ -43,7 +44,7 @@ fn main() -> Result<()> { CliCommand::PackageConformity(args) => { tasks::package_conformity::run_package_conformity(args) } - CliCommand::PublishGpui(args) => tasks::publish_gpui::run_publish_gpui(args), + CliCommand::Gpui(args) => tasks::gpui::run_gpui(args), CliCommand::SandboxTests(args) => tasks::sandbox_tests::run_sandbox_tests(args), CliCommand::WslSandboxTests(args) => tasks::wsl_sandbox_tests::run_wsl_sandbox_tests(args), CliCommand::SetupWebrtc(args) => tasks::setup_webrtc::run_setup_webrtc(args), diff --git a/tooling/xtask/src/tasks.rs b/tooling/xtask/src/tasks.rs index fba583b761..1f0552b2cf 100644 --- a/tooling/xtask/src/tasks.rs +++ b/tooling/xtask/src/tasks.rs @@ -1,8 +1,8 @@ pub mod clippy; pub mod compliance; +pub mod gpui; pub mod licenses; pub mod package_conformity; -pub mod publish_gpui; pub mod sandbox_tests; pub mod setup_webrtc; pub mod web_examples; diff --git a/tooling/xtask/src/tasks/publish_gpui.rs b/tooling/xtask/src/tasks/gpui.rs similarity index 79% rename from tooling/xtask/src/tasks/publish_gpui.rs rename to tooling/xtask/src/tasks/gpui.rs index 2740f75a48..76bd52803b 100644 --- a/tooling/xtask/src/tasks/publish_gpui.rs +++ b/tooling/xtask/src/tasks/gpui.rs @@ -1,12 +1,38 @@ #![allow(clippy::disallowed_methods, reason = "tooling is exempt")] + +pub mod crate_graph; +pub mod publish_plan; + use std::io::{self, Write}; use std::process::{Command, Output, Stdio}; use anyhow::{Context as _, Result, bail}; -use clap::Parser; +use clap::{Parser, Subcommand}; +use guppy::graph::DependencyDirection; + +use crate::tasks::workflows::GitSha; + +#[derive(Subcommand)] +pub enum GpuiCommand { + /// Print the publish plan in dependency-first order + List(GpuiArgs), + /// Show the dependency paths that put a crate in the publish list + Why { + /// Original Cargo package name + crate_name: String, + }, +} #[derive(Parser)] -pub struct PublishGpuiArgs { +pub struct GpuiArgs { + /// Full commit SHA to use when checking out workspace crates + #[arg(long)] + sha: GitSha, +} + +#[derive(Parser)] +#[allow(unused)] +struct PublishGpuiArgs { /// Perform a dry-run and wait for user confirmation before each publish #[arg(long)] dry_run: bool, @@ -16,7 +42,48 @@ pub struct PublishGpuiArgs { skip_to: Option, } -pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> { +pub fn run_gpui(command: GpuiCommand) -> Result<()> { + let graph = crate_graph::load_workspace_graph()?; + match command { + GpuiCommand::List(args) => { + let plan = publish_plan::build_publish_plan(&graph)?; + println!( + "GPUI publish plan ({} crates, dependencies first):", + plan.len() + ); + for entry in &plan { + let package = entry.repository.publish_info(entry.package.name()); + println!( + "{} -> {} ({})", + package.original_name, + package.repository.target_name(package.original_name), + package.repository.display(&args) + ); + } + } + GpuiCommand::Why { crate_name } => { + let explanation = crate_graph::explain_crate(&graph, &crate_name)?; + println!( + "Why `{crate_name}` is in the GPUI publish list (A -> B means A depends on B):" + ); + for package in explanation.packages(DependencyDirection::Forward) { + if crate_graph::is_gpui_root(package) { + println!(" {} is a GPUI workspace root", package.name()); + } + } + for link in explanation + .links(DependencyDirection::Forward) + .filter(|link| !link.dev_only()) + { + println!(" {} -> {}", link.from().name(), link.to().name()); + } + } + } + Ok(()) +} + +#[allow(unused, reason = "retained while GPUI releases are reworked")] +fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> { println!( "Starting GPUI publish process{}...", if args.dry_run { " (with dry-run)" } else { "" } @@ -340,6 +407,29 @@ mod tests { use super::*; + #[test] + fn gpui_list_accepts_an_explicit_workspace_sha() -> Result<()> { + let sha = "0123456789abcdef0123456789abcdef01234567"; + let args = crate::Args::try_parse_from(["xtask", "gpui", "list", "--sha", sha])?; + let crate::CliCommand::Gpui(GpuiCommand::List(args)) = args.command else { + bail!("expected the GPUI list command"); + }; + assert_eq!(args.sha.as_ref(), sha); + Ok(()) + } + + #[test] + fn gpui_list_requires_a_full_workspace_sha() { + assert!(crate::Args::try_parse_from(["xtask", "gpui"]).is_err()); + assert!(crate::Args::try_parse_from(["xtask", "gpui", "list"]).is_err()); + for sha in ["HEAD", "abc", "g123456789abcdef0123456789abcdef01234567"] { + assert!( + crate::Args::try_parse_from(["xtask", "gpui", "list", "--sha", sha]).is_err(), + "{sha}" + ); + } + } + #[test] fn test_update_dependency_version_in_toml() { let input = indoc! {r#" diff --git a/tooling/xtask/src/tasks/gpui/crate_graph.rs b/tooling/xtask/src/tasks/gpui/crate_graph.rs new file mode 100644 index 0000000000..289ac91532 --- /dev/null +++ b/tooling/xtask/src/tasks/gpui/crate_graph.rs @@ -0,0 +1,499 @@ +use anyhow::{Context as _, Result, ensure}; +use guppy::{ + MetadataCommand, + graph::{ + DependencyDirection, ExternalSource, PackageGraph, PackageMetadata, PackageSet, + PackageSource, + }, +}; +use url::Url; + +pub fn load_workspace_graph() -> Result { + MetadataCommand::new() + .build_graph() + .context("failed to load the workspace crate graph") +} + +pub fn is_gpui_root(package: PackageMetadata<'_>) -> bool { + package.in_workspace() && package.name().starts_with("gpui") +} + +/// Returns all GPUI crates in the current workspace +pub fn gpui_crates(graph: &PackageGraph) -> Result> { + let roots = graph + .workspace() + .iter_by_name() + .filter(|(_, package)| is_gpui_root(*package)) + .map(|(_, package)| package.id()) + .collect::>(); + ensure!(!roots.is_empty(), "no GPUI crates found in the workspace"); + + let dependencies = graph + .query_forward(roots)? + // we do not need dev dependencies for releases + .resolve_with_fn(|_, link| !link.dev_only()); + + // Keep all workspace crates and Zed forks + Ok( + dependencies.filter(DependencyDirection::Reverse, |package| { + package.in_workspace() || is_zed_fork(package.source()) + }), + ) +} + +pub fn explain_crate<'graph>( + graph: &'graph PackageGraph, + name: &str, +) -> Result> { + let crates = gpui_crates(graph)?; + let selected = crates.filter(DependencyDirection::Forward, |package| { + package.name() == name + }); + ensure!( + !selected.is_empty(), + "crate `{name}` is not in the GPUI publish list" + ); + + let dependents = selected + .to_package_query(DependencyDirection::Reverse) + .resolve_with_fn(|_, link| !link.dev_only()); + + let dependencies = crates + .to_package_query(DependencyDirection::Forward) + .resolve_with_fn(|_, link| !link.dev_only()); + Ok(dependents.intersection(&dependencies)) +} + +fn is_zed_fork(source: PackageSource<'_>) -> bool { + let Some(ExternalSource::Git { repository, .. }) = source.parse_external() else { + return false; + }; + + Url::parse(repository).is_ok_and(|repository| { + repository + .host_str() + .is_some_and(|host| host.eq_ignore_ascii_case("github.com")) + && repository.path_segments().is_some_and(|mut segments| { + segments + .next() + .is_some_and(|owner| owner.eq_ignore_ascii_case("zed-industries")) + && segments.next().is_some_and(|name| !name.is_empty()) + }) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::*; + use DependencyKind::{Build, Development, Normal}; + use DependencyRequirement::{Optional, Required}; + use DependencyTarget::{All, Platform}; + use PackageOrigin::{CratesIo, Git, Path, Workspace}; + + struct FixturePackage { + name: &'static str, + origin: PackageOrigin, + dependencies: &'static [FixtureDependency], + } + + enum PackageOrigin { + Workspace, + Path, + CratesIo, + Git(&'static str), + } + + struct FixtureDependency { + to: &'static str, + kind: DependencyKind, + target: DependencyTarget, + } + + enum DependencyKind { + Normal(DependencyRequirement), + Build(DependencyRequirement), + Development, + } + + enum DependencyRequirement { + Required, + Optional, + } + + enum DependencyTarget { + All, + Platform(&'static str), + } + + #[test] + fn selects_release_dependencies_in_topological_order() -> Result<()> { + let graph = fixture(&[ + FixturePackage { + name: "gpui", + origin: Workspace, + dependencies: &[ + FixtureDependency { + to: "gpui_platform", + kind: Development, + target: All, + }, + FixtureDependency { + to: "gpui_macros", + kind: Normal(Required), + target: All, + }, + FixtureDependency { + to: "collections", + kind: Normal(Required), + target: All, + }, + FixtureDependency { + to: "collections", + kind: Development, + target: All, + }, + FixtureDependency { + to: "build_helper", + kind: Build(Required), + target: All, + }, + FixtureDependency { + to: "optional_helper", + kind: Normal(Optional), + target: Platform("cfg(windows)"), + }, + FixtureDependency { + to: "test_helper", + kind: Development, + target: All, + }, + FixtureDependency { + to: "local_dependency", + kind: Normal(Required), + target: All, + }, + FixtureDependency { + to: "registry", + kind: Normal(Required), + target: All, + }, + FixtureDependency { + to: "upstream", + kind: Normal(Required), + target: All, + }, + ], + }, + FixturePackage { + name: "gpui_platform", + origin: Workspace, + dependencies: &[FixtureDependency { + to: "gpui", + kind: Normal(Required), + target: All, + }], + }, + FixturePackage { + name: "gpui_macros", + origin: Workspace, + dependencies: &[FixtureDependency { + to: "gpui", + kind: Development, + target: All, + }], + }, + FixturePackage { + name: "collections", + origin: Workspace, + dependencies: &[], + }, + FixturePackage { + name: "build_helper", + origin: Workspace, + dependencies: &[], + }, + FixturePackage { + name: "optional_helper", + origin: Workspace, + dependencies: &[], + }, + FixturePackage { + name: "test_helper", + origin: Workspace, + dependencies: &[], + }, + FixturePackage { + name: "zed", + origin: Workspace, + dependencies: &[FixtureDependency { + to: "gpui", + kind: Normal(Required), + target: All, + }], + }, + FixturePackage { + name: "local_dependency", + origin: Path, + dependencies: &[], + }, + FixturePackage { + name: "registry", + origin: CratesIo, + dependencies: &[FixtureDependency { + to: "fork", + kind: Normal(Required), + target: All, + }], + }, + FixturePackage { + name: "fork", + origin: Git( + "git+https://github.com/zed-industries/fork?rev=abc#0123456789abcdef0123456789abcdef01234567", + ), + dependencies: &[FixtureDependency { + to: "collections", + kind: Normal(Required), + target: All, + }], + }, + FixturePackage { + name: "upstream", + origin: Git("git+https://github.com/other/upstream#abc"), + dependencies: &[], + }, + FixturePackage { + name: "gpui_unrelated", + origin: Git("git+https://github.com/zed-industries/unrelated#abc"), + dependencies: &[], + }, + ])?; + let crates = gpui_crates(&graph)?; + let names = crates + .packages(DependencyDirection::Reverse) + .map(|package| package.name()) + .collect::>(); + assert_eq!( + names + .iter() + .copied() + .collect::>(), + [ + "gpui", + "gpui_platform", + "gpui_macros", + "collections", + "build_helper", + "optional_helper", + "fork" + ] + .into_iter() + .collect(), + ); + for (dependency, dependent) in [ + ("gpui", "gpui_platform"), + ("gpui_macros", "gpui"), + ("collections", "fork"), + ("fork", "gpui"), + ("build_helper", "gpui"), + ("optional_helper", "gpui"), + ] { + let dependency_index = names + .iter() + .position(|name| *name == dependency) + .context("missing dependency")?; + let dependent_index = names + .iter() + .position(|name| *name == dependent) + .context("missing dependent")?; + assert!( + dependency_index < dependent_index, + "{dependency} must precede {dependent}" + ); + } + assert!(crates.links(DependencyDirection::Reverse).all(|link| { + names.contains(&link.from().name()) && names.contains(&link.to().name()) + })); + + let explanation = explain_crate(&graph, "fork")?; + assert_eq!( + explanation + .packages(DependencyDirection::Forward) + .map(|package| package.name()) + .collect::>(), + ["gpui_platform", "gpui", "registry", "fork"], + ); + assert_eq!( + explanation + .links(DependencyDirection::Forward) + .filter(|link| !link.dev_only()) + .map(|link| (link.from().name(), link.to().name())) + .collect::>(), + [ + ("gpui_platform", "gpui"), + ("gpui", "registry"), + ("registry", "fork") + ] + .into_iter() + .collect(), + ); + let root = explain_crate(&graph, "gpui_platform")?; + assert_eq!(root.len(), 1); + assert!( + root.packages(DependencyDirection::Forward) + .all(is_gpui_root) + ); + for excluded in ["zed", "test_helper", "registry", "missing"] { + assert!(explain_crate(&graph, excluded).is_err(), "{excluded}"); + } + + use super::super::publish_plan::{Repository, build_publish_plan}; + + let plan = build_publish_plan(&graph)?; + assert_eq!( + plan.iter() + .map(|entry| entry.package.name()) + .collect::>(), + names, + ); + assert_eq!( + plan.iter() + .map(|entry| entry.package.id()) + .collect::>(), + crates + .package_ids(DependencyDirection::Reverse) + .collect::>(), + ); + for entry in &plan { + let package = entry.repository.publish_info(entry.package.name()); + match package.repository { + Repository::Workspace => { + assert_ne!(package.original_name, "fork"); + if package.original_name == "gpui_macros" { + assert_eq!( + package.repository.target_name(package.original_name), + "gpui-macros" + ); + } + } + Repository::Git(repository) => { + assert_eq!(package.original_name, "fork"); + assert_eq!( + package.repository.target_name(package.original_name), + "zed-fork" + ); + assert_eq!(repository.owner, "zed-industries"); + assert_eq!(repository.repo, "fork"); + assert_eq!( + repository.revision.as_ref(), + "0123456789abcdef0123456789abcdef01234567" + ); + } + } + } + Ok(()) + } + + #[test] + fn requires_workspace_gpui_roots() -> Result<()> { + let graph = fixture(&[FixturePackage { + name: "zed", + origin: Workspace, + dependencies: &[], + }])?; + assert!(gpui_crates(&graph).is_err()); + Ok(()) + } + + #[test] + fn identifies_forks_by_git_source_not_repository_metadata() { + for source in [ + "git+https://github.com/zed-industries/fork?rev=abc#abc", + "git+https://github.com/zed-industries/fork.git?branch=main#abc", + "git+ssh://git@github.com/zed-industries/fork?tag=v1#abc", + "git+https://github.com/Zed-Industries/fork#abc", + ] { + assert!(is_zed_fork(PackageSource::External(source)), "{source}"); + } + for source in [ + PackageSource::CRATES_IO_REGISTRY, + "sparse+https://index.crates.io/", + "git+https://github.com/other/fork#abc", + "git+https://github.com/zed-industries-other/fork#abc", + "git+https://github.com.example.org/zed-industries/fork#abc", + "git+https://example.org/github.com/zed-industries/fork#abc", + "git+https://github.com/zed-industries/#abc", + ] { + assert!(!is_zed_fork(PackageSource::External(source)), "{source}"); + } + } + + fn fixture(packages: &[FixturePackage]) -> Result { + let mut nodes = Vec::new(); + let mut metadata = Vec::new(); + for package in packages { + let name = package.name; + let source = match package.origin { + Workspace | Path => None, + CratesIo => Some(PackageSource::CRATES_IO_REGISTRY), + Git(source) => Some(source), + }; + let mut dependencies = Vec::new(); + let mut resolved = std::collections::BTreeMap::<&str, Value>::new(); + for dependency in package.dependencies { + let to = dependency.to; + let (kind, optional) = match &dependency.kind { + Normal(requirement) => (None, matches!(requirement, Optional)), + Build(requirement) => (Some("build"), matches!(requirement, Optional)), + Development => (Some("dev"), false), + }; + let target = match dependency.target { + All => None, + Platform(target) => Some(target), + }; + dependencies.push(json!({ + "name": to, "source": null, "req": "*", "kind": kind, + "optional": optional, "uses_default_features": true, + "features": [], "target": target, "registry": null, + })); + let link = resolved.entry(to).or_insert_with(|| { + json!({ + "name": to, "pkg": to, "dep_kinds": [], + }) + }); + link["dep_kinds"] + .as_array_mut() + .context("expected dependency kinds")? + .push(json!({"kind": kind, "target": target})); + } + nodes.push(json!({ + "id": name, "dependencies": resolved.keys().collect::>(), + "deps": resolved.values().collect::>(), "features": [], + })); + metadata.push(json!({ + "name": name, "version": "0.1.0", "id": name, "source": source, + "dependencies": dependencies, "features": {}, + "targets": [{ + "name": name, "kind": ["lib"], "crate_types": ["lib"], + "src_path": format!("/workspace/{name}/src/lib.rs"), + "edition": "2024", "doctest": false, "test": false, "doc": false, + }], + "manifest_path": format!("/workspace/{name}/Cargo.toml"), + "repository": "https://github.com/zed-industries/fixture", + })); + } + Ok(PackageGraph::from_json( + json!({ + "packages": metadata, + "workspace_members": packages.iter() + .filter(|package| matches!(package.origin, Workspace)) + .map(|package| package.name).collect::>(), + "resolve": { "nodes": nodes, "root": null }, + "workspace_root": "/workspace", "target_directory": "/workspace/target", + "version": 1, + }) + .to_string(), + )?) + } +} diff --git a/tooling/xtask/src/tasks/gpui/publish_plan.rs b/tooling/xtask/src/tasks/gpui/publish_plan.rs new file mode 100644 index 0000000000..67b0595f74 --- /dev/null +++ b/tooling/xtask/src/tasks/gpui/publish_plan.rs @@ -0,0 +1,286 @@ +use std::fmt; + +use anyhow::{Context as _, Result, bail, ensure}; +use guppy::graph::{DependencyDirection, ExternalSource, PackageGraph, PackageMetadata}; +use url::Url; + +use crate::tasks::{gpui::GpuiArgs, workflows::GitSha}; + +pub enum Repository { + Workspace, + Git(GitRepository), +} + +pub struct GitRepository { + pub owner: String, + pub repo: String, + pub revision: GitSha, +} + +impl GitRepository { + fn new(repository: &str, revision: &str) -> Result { + let url = Url::parse(repository).context("invalid Git repository URL")?; + ensure!( + url.host_str() + .is_some_and(|host| host.eq_ignore_ascii_case("github.com")), + "expected a GitHub repository, got {repository}" + ); + let (owner, repo) = url + .path() + .trim_matches('/') + .split_once('/') + .context("repository URL must contain an owner and repository name")?; + let repo = repo.strip_suffix(".git").unwrap_or(repo); + ensure!( + !owner.is_empty() && !repo.is_empty() && !repo.contains('/'), + "expected an owner/repo path, got {repository}" + ); + let revision = revision + .parse::() + .map_err(anyhow::Error::msg) + .with_context(|| format!("invalid resolved revision for {repository}"))?; + + Ok(Self { + owner: owner.to_owned(), + repo: repo.to_owned(), + revision, + }) + } +} + +impl Repository { + pub fn fork(repository: &str, revision: &str) -> Result { + Ok(Self::Git(GitRepository::new(repository, revision)?)) + } + + pub fn publish_info<'a>(&'a self, name: &'a str) -> CratePublishInfo<'a> { + CratePublishInfo { + repository: self, + original_name: name, + } + } + + pub fn name(&self) -> String { + match self { + Self::Workspace => "zed-industries/zed".to_owned(), + Self::Git(repository) => format!( + "{owner}/{repo}", + owner = repository.owner, + repo = repository.repo + ), + } + } + + pub fn target_name(&self, name: &str) -> String { + match self { + Self::Workspace => { + if name == "gpui_macros" { + "gpui-macros".to_owned() + } else if name == "gpui" || name.starts_with("gpui_") { + name.to_owned() + } else { + format!("gpui_{name}") + } + } + Self::Git(_) => { + if name.starts_with("zed-") { + name.to_owned() + } else { + format!("zed-{name}") + } + } + } + } + + pub fn display<'a>(&'a self, args: &'a GpuiArgs) -> impl fmt::Display + 'a { + let revision = match self { + Self::Workspace => &args.sha, + Self::Git(repository) => &repository.revision, + }; + fmt::from_fn(move |formatter| { + write!( + formatter, + "{name}@{revision}", + name = self.name(), + revision = revision.as_ref() + ) + }) + } +} + +pub struct CratePublishInfo<'a> { + pub repository: &'a Repository, + pub original_name: &'a str, +} + +pub struct PublishPlanEntry<'graph> { + pub package: PackageMetadata<'graph>, + pub repository: Repository, +} + +pub fn build_publish_plan(graph: &PackageGraph) -> Result>> { + let packages = super::crate_graph::gpui_crates(graph)?; + packages + .packages(DependencyDirection::Reverse) + .map(|package| { + let repository = if package.in_workspace() { + Repository::Workspace + } else if let Some(ExternalSource::Git { + repository, + resolved, + .. + }) = package.source().parse_external() + { + Repository::fork(repository, resolved)? + } else { + bail!( + "unexpected source for GPUI crate {}: {}", + package.name(), + package.source() + ); + }; + Ok(PublishPlanEntry { + package, + repository, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const REVISION: &str = "0123456789abcdef0123456789abcdef01234567"; + const WORKSPACE_REVISION: &str = "fedcba9876543210fedcba9876543210fedcba98"; + + #[test] + fn publishing_info_borrows_its_repository_and_crate_name() { + let repository = Repository::Workspace; + let name = String::from("collections"); + let package = repository.publish_info(&name); + + assert!(std::ptr::eq(package.repository, &repository)); + assert!(std::ptr::eq(package.original_name, name.as_str())); + } + + #[test] + fn workspace_names_follow_existing_publish_names() { + let repository = Repository::Workspace; + for (original, target) in [ + ("collections", "gpui_collections"), + ("perf", "gpui_perf"), + ("util_macros", "gpui_util_macros"), + ("util", "gpui_util"), + ("gpui_macros", "gpui-macros"), + ("http_client", "gpui_http_client"), + ("derive_refineable", "gpui_derive_refineable"), + ("refineable", "gpui_refineable"), + ("semantic_version", "gpui_semantic_version"), + ("sum_tree", "gpui_sum_tree"), + ("media", "gpui_media"), + ("gpui", "gpui"), + ("gpui_platform", "gpui_platform"), + ("gpui-macros", "gpui_gpui-macros"), + ("scheduler", "gpui_scheduler"), + ] { + let package = repository.publish_info(original); + assert_eq!(package.original_name, original); + assert_eq!( + package.repository.target_name(package.original_name), + target + ); + assert!(matches!(package.repository, Repository::Workspace)); + } + } + + #[test] + fn fork_names_have_a_single_zed_prefix() -> Result<()> { + let repository = Repository::fork("https://github.com/zed-industries/fork", REVISION)?; + for (original, target) in [ + ("calloop", "zed-calloop"), + ("wasm_thread", "zed-wasm_thread"), + ("zed-font-kit", "zed-font-kit"), + ("zed-scap", "zed-scap"), + ] { + let package = repository.publish_info(original); + assert_eq!(package.original_name, original); + assert_eq!( + package.repository.target_name(package.original_name), + target + ); + } + Ok(()) + } + + #[test] + fn workspace_repository_uses_the_argument_sha() -> Result<()> { + let repository = Repository::Workspace; + let package = repository.publish_info("gpui"); + assert_eq!(package.repository.name(), "zed-industries/zed"); + for revision in [REVISION, WORKSPACE_REVISION] { + let args = GpuiArgs { + sha: revision.parse().map_err(anyhow::Error::msg)?, + }; + assert_eq!( + package.repository.display(&args).to_string(), + format!("zed-industries/zed@{revision}") + ); + } + Ok(()) + } + + #[test] + fn git_repositories_have_checkout_coordinates() -> Result<()> { + let args = GpuiArgs { + sha: WORKSPACE_REVISION.parse().map_err(anyhow::Error::msg)?, + }; + for url in [ + "https://github.com/zed-industries/fork", + "https://github.com/zed-industries/fork.git", + "https://github.com/zed-industries/fork.git/", + "ssh://git@github.com/zed-industries/fork.git", + ] { + let repository = Repository::fork(url, REVISION)?; + let package = repository.publish_info("fork"); + assert_eq!(package.repository.name(), "zed-industries/fork"); + assert_eq!( + package.repository.display(&args).to_string(), + format!("zed-industries/fork@{REVISION}") + ); + let Repository::Git(repository) = package.repository else { + bail!("expected a Git repository"); + }; + assert_eq!(repository.owner, "zed-industries"); + assert_eq!(repository.repo, "fork"); + assert_eq!(repository.revision.as_ref(), REVISION); + } + Ok(()) + } + + #[test] + fn git_repositories_require_an_owner_repo_and_full_sha() { + for url in [ + "not a URL", + "https://example.com/zed-industries/fork", + "https://github.com/zed-industries", + "https://github.com/zed-industries/", + "https://github.com/zed-industries/.git", + "https://github.com/zed-industries/fork/tree/main", + ] { + assert!(Repository::fork(url, REVISION).is_err(), "{url}"); + } + for revision in [ + "", + "main", + "v1.0", + "abc", + "g123456789abcdef0123456789abcdef01234567", + ] { + assert!( + Repository::fork("https://github.com/zed-industries/fork", revision).is_err(), + "{revision}" + ); + } + } +} diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 86a2a95220..8107cb4571 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -41,44 +41,47 @@ impl AsRef for GitSha { } } +impl std::str::FromStr for GitSha { + type Err = String; + + fn from_str(value: &str) -> Result { + const GIT_SHA_LENGTH: usize = 40; + if value.len() != GIT_SHA_LENGTH { + return Err(format!( + "Git SHA has wrong length! \ + Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", + len = value.len() + )); + } + if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("Not a valid Git SHA".to_owned()); + } + Ok(Self(value.to_owned())) + } +} + #[allow( clippy::disallowed_methods, reason = "This runs only in a CLI environment" )] fn parse_ref(value: &str) -> Result { - const GIT_SHA_LENGTH: usize = 40; - (value.len() == GIT_SHA_LENGTH) - .then_some(value) - .ok_or_else(|| { - format!( - "Git SHA has wrong length! \ - Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", - len = value.len() - ) - }) - .and_then(|value| { - let mut tmp = [0; 4]; - value - .chars() - .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value) - .ok_or_else(|| "Not a valid Git SHA".to_owned()) - }) - .and_then(|sha| { - std::process::Command::new("git") - .args([ - "rev-parse", - "--quiet", - "--verify", - &format!("{sha}^{{commit}}") - ]) - .output() - .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned()) - .and_then(|output| - output - .status.success() - .then_some(sha) - .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!"))) - }).map(|sha| GitSha(sha.to_owned())) + let sha = value.parse::()?; + let output = std::process::Command::new("git") + .args([ + "rev-parse", + "--quiet", + "--verify", + &format!("{}^{{commit}}", sha.as_ref()), + ]) + .output() + .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned())?; + if !output.status.success() { + return Err(format!( + "SHA {} is not a valid Git SHA within this repository!", + sha.as_ref() + )); + } + Ok(sha) } #[derive(Parser)] diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs index 7ccb8539ad..403f5c51b1 100644 --- a/tooling/xtask/src/tasks/workflows/autofix_pr.rs +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -74,7 +74,7 @@ fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJo } fn run_cargo_shear_fix() -> Step { - named::bash("cargo shear --fix") + named::bash("cargo shear --fix --check-test-targets") } fn run_clippy_fix() -> Step { diff --git a/tooling/xtask/src/tasks/workflows/deploy_docs.rs b/tooling/xtask/src/tasks/workflows/deploy_docs.rs index 7871022f77..507261ddec 100644 --- a/tooling/xtask/src/tasks/workflows/deploy_docs.rs +++ b/tooling/xtask/src/tasks/workflows/deploy_docs.rs @@ -52,7 +52,10 @@ pub(crate) fn lychee_link_check(dir: &str) -> Step { "lychee-action", "82202e5e9c2f4ef1a55a3d02563e1cb6041e5332", ) // v2.4.1 - .add_with(("args", format!("--no-progress --exclude '^http' '{dir}'"))) + .add_with(( + "args", + format!("--config .config/lychee.toml --no-progress --exclude '^http' '{dir}'"), + )) .add_with(("fail", true)) .add_with(("jobSummary", false)) } diff --git a/tooling/xtask/src/tasks/workflows/release_nightly.rs b/tooling/xtask/src/tasks/workflows/release_nightly.rs index 4c18ba2136..2ec04776ec 100644 --- a/tooling/xtask/src/tasks/workflows/release_nightly.rs +++ b/tooling/xtask/src/tasks/workflows/release_nightly.rs @@ -49,8 +49,8 @@ pub fn release_nightly() -> Workflow { named::workflow() .with_minimal_permissions() .on(Event::default() - // Fire 6 times a day - .schedule([Schedule::new("0 */4 * * *")]) + // Fire twice a day at 02:00 and 14:00 UTC + .schedule([Schedule::new("0 2,14 * * *")]) .workflow_dispatch(WorkflowDispatch::default())) .concurrency( Concurrency::default() diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs index cb916e849e..6fc34c9d77 100644 --- a/tooling/xtask/src/tasks/workflows/run_bundling.rs +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -62,10 +62,26 @@ pub(crate) fn bundle_mac( release_channel: Option, deps: &[&NamedJob], ) -> NamedJob { + pub fn print_macos_toolchain() -> Step { + named::bash(indoc! {r#" + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" + "#}) + } + pub fn bundle_mac(arch: Arch) -> Step { - named::bash(&format!("./script/bundle-mac {arch}-apple-darwin")) + let target = Platform::Mac.target_triple(arch); + named::bash(&format!("./script/bundle-mac {target}")) } let platform = Platform::Mac; + let target = platform.target_triple(arch); let artifact_name = match arch { Arch::X86_64 => assets::MAC_X86_64, Arch::AARCH64 => assets::MAC_AARCH64, @@ -87,9 +103,10 @@ pub(crate) fn bundle_mac( .add_step(steps::setup_node()) .add_step(steps::setup_sentry()) .add_step(steps::clear_target_dir_if_large(runners::Platform::Mac)) + .add_step(print_macos_toolchain()) .add_step(bundle_mac(arch)) .add_step(upload_artifact(&format!( - "target/{arch}-apple-darwin/release/{artifact_name}" + "target/{target}/release/{artifact_name}" ))) .add_step(upload_artifact(&format!( "target/{remote_server_artifact_name}" diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 68470fe799..8b7c7d5a04 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -26,18 +26,21 @@ pub(crate) fn run_tests() -> Workflow { // - script/update_top_ranking_issues/ // - .github/ISSUE_TEMPLATE/ // - .github/workflows/ (except .github/workflows/ci.yml) + // - .wezel/ // - extensions/ (these have their own test workflow) let should_run_tests = PathCondition::inverted( "run_tests", - r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)", + r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|[.]wezel/|extensions/)", ); let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)"); let should_check_scripts = PathCondition::new( "run_action_checks", r"^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/", ); - let should_check_licences = - PathCondition::new("run_licenses", r"^(Cargo.lock|script/.*licenses)"); + let should_check_licences = PathCondition::new( + "run_licenses", + r"^(Cargo\.lock$|script/.*licenses|(?:.*/)?LICENSE[^/]*$)", + ); let orchestrate = orchestrate(&[ &should_check_scripts, @@ -83,9 +86,7 @@ pub(crate) fn run_tests() -> Workflow { .and_not_in_merge_queue() .then(build_visual_tests_binary()), should_run_tests.and_not_in_merge_queue().then(check_wasm()), - should_run_tests - .and_not_in_merge_queue() - .then(check_dependencies()), // could be more specific here? + should_run_tests.and_always().then(check_dependencies()), // could be more specific here? should_check_docs .and_not_in_merge_queue() .then(deploy_docs::check_docs()), @@ -94,6 +95,18 @@ pub(crate) fn run_tests() -> Workflow { .then(check_licenses()), should_check_scripts.and_always().then(check_scripts(true)), ]; + for (platform, arch) in [ + (Platform::Linux, Arch::X86_64), + (Platform::Mac, Arch::AARCH64), + (Platform::Windows, Arch::X86_64), + ] { + let condition = if platform == Platform::Linux { + should_run_tests.and_always() + } else { + should_run_tests.and_not_in_merge_queue() + }; + jobs.push(condition.then(check_remote_server(platform, arch))); + } let ext_tests = extension_tests(); let tests_pass = tests_pass(&jobs, &[&ext_tests.name]); @@ -121,12 +134,15 @@ pub(crate) fn run_tests() -> Workflow { .add_env(("RUST_BACKTRACE", 1)) .add_env(("CARGO_INCREMENTAL", 0)) .map(|mut workflow| { - for job in jobs { + for mut job in jobs { + if !matches!(job.name.as_str(), "orchestrate" | "check_style") { + job.job = job.job.add_need("check_style"); + } workflow = workflow.add_job(job.name, job.job) } workflow }) - .add_job(ext_tests.name, ext_tests.job) + .add_job(ext_tests.name, ext_tests.job.add_need("check_style")) .add_job(tests_pass.name, tests_pass.job) } @@ -429,7 +445,7 @@ fn check_style() -> NamedJob { "typos", "2d0ce569feab1f8752f1dde43cc2f2aa53236e06", ) // v1.40.0 - .with(("config", "./typos.toml")) + .with(("config", "./.config/typos.toml")) } named::job( @@ -455,7 +471,7 @@ fn check_dependencies() -> NamedJob { } fn run_cargo_shear() -> Step { - named::bash("cargo shear --locked --deny-warnings") + named::bash("cargo shear --locked --deny-warnings --check-test-targets") } fn check_cargo_lock() -> Step { @@ -524,6 +540,67 @@ fn check_wasm() -> NamedJob { ) } +fn check_remote_server(platform: Platform, arch: Arch) -> NamedJob { + let target = platform.target_triple(arch); + let runner = match (platform, arch) { + (Platform::Linux, Arch::X86_64) => runners::LINUX_LARGE, + (Platform::Linux, Arch::AARCH64) => runners::LINUX_ARM_BUNDLER, + (Platform::Mac, _) => runners::MAC_DEFAULT, + (Platform::Windows, _) => runners::WINDOWS_DEFAULT, + }; + let command = format!( + "cargo --config .cargo/ci-config.toml check --locked --release --package remote_server --target {target}" + ); + let check = match platform { + Platform::Windows => { + let architecture = match arch { + Arch::X86_64 => "amd64", + Arch::AARCH64 => "arm64", + }; + named::pwsh(&formatdoc! {r#" + $hostArchitecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) {{ + "X64" {{ "amd64" }} + "Arm64" {{ "arm64" }} + default {{ throw "Unsupported architecture" }} + }} + Push-Location + & "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Launch-VsDevShell.ps1" -Arch {architecture} -HostArch $hostArchitecture + Pop-Location + {command} + "#}) + } + Platform::Linux | Platform::Mac => named::bash(command), + }; + NamedJob { + name: format!("check_remote_server_{platform}_{arch}"), + job: release_job(&[]) + .runs_on(runner) + .when(platform == Platform::Linux, |job| { + use_clang(job) + .add_env((format!("CC_{}", target.replace('-', "_")), "musl-gcc")) + .add_env(( + format!( + "CARGO_TARGET_{}_RUSTFLAGS", + target.replace('-', "_").to_uppercase(), + ), + "-C target-feature=+crt-static", + )) + .add_step(steps::harden_runner()) + }) + .add_step(steps::checkout_repo()) + .when(platform != Platform::Windows, |job| { + job.add_step(steps::cache_rust_dependencies_namespace()) + }) + .when(platform == Platform::Linux, |job| { + job.add_step(steps::setup_linux()) + }) + .add_step(named::run(platform, &format!("rustup target add {target}"))) + .add_step(steps::setup_sccache(platform)) + .add_step(check) + .add_step(steps::show_sccache_stats(platform)), + } +} + fn check_workspace_binaries() -> NamedJob { named::job(use_clang( release_job(&[]) @@ -542,9 +619,8 @@ fn check_workspace_binaries() -> NamedJob { } pub(crate) fn clippy(platform: Platform, arch: Option, harden: bool) -> NamedJob { - let target = arch.map(|arch| match (platform, arch) { - (Platform::Mac, Arch::X86_64) => "x86_64-apple-darwin", - (Platform::Mac, Arch::AARCH64) => "aarch64-apple-darwin", + let target = arch.map(|arch| match platform { + Platform::Mac => platform.target_triple(arch), _ => unimplemented!("cross-arch clippy not supported for {platform}/{arch}"), }); let runner = match platform { @@ -601,6 +677,9 @@ fn run_platform_tests_impl(platform: Platform, filter_packages: bool, harden: bo name: format!("run_tests_{platform}"), job: release_job(&[]) .runs_on(runner) + .when(platform == Platform::Mac, |job| { + job.add_env(("RUST_LIB_BACKTRACE", 0)) + }) .when(platform == Platform::Linux, |job| { job.add_service( "postgres", @@ -713,7 +792,7 @@ pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { named::job( release_job(&[]) - .runs_on(runners::LINUX_DEFAULT) + .runs_on(runners::LINUX_MEDIUM) .add_env(("GIT_AUTHOR_NAME", "Protobuf Action")) .add_env(("GIT_AUTHOR_EMAIL", "ci@zed.dev")) .add_env(("GIT_COMMITTER_NAME", "Protobuf Action")) @@ -730,23 +809,30 @@ pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { fn miri_scheduler() -> NamedJob { fn install_miri() -> Step { + // TODO: Unpin Miri after updating parking_lot_core to fix its futex argument types. + // Nightly 2026-09-10 added stricter checks in rust-lang/rust#161734. named::bash( - "rustup toolchain install nightly --profile minimal --component miri --component rust-src", + "rustup toolchain install nightly-2026-09-09 --profile minimal --component miri --component rust-src", ) } + fn clean_miri() -> Step { + named::bash("cargo +nightly-2026-09-09 miri clean") + } + fn run_scheduler_tests_under_miri() -> Step { - named::bash("cargo +nightly -q miri test -p scheduler") + named::bash("cargo +nightly-2026-09-09 -q miri test -p scheduler") } named::job( release_job(&[]) - .runs_on(runners::LINUX_DEFAULT) + .runs_on(runners::LINUX_MEDIUM) .add_step(steps::harden_runner()) .add_step(steps::checkout_repo()) .add_step(steps::setup_cargo_config(Platform::Linux)) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(install_miri()) + .add_step(clean_miri()) .add_step(run_scheduler_tests_under_miri()) .add_step(steps::cleanup_cargo_config(Platform::Linux)), ) diff --git a/tooling/xtask/src/tasks/workflows/runners.rs b/tooling/xtask/src/tasks/workflows/runners.rs index bc4b17aaf6..82dbbe7a7a 100644 --- a/tooling/xtask/src/tasks/workflows/runners.rs +++ b/tooling/xtask/src/tasks/workflows/runners.rs @@ -53,6 +53,19 @@ pub enum Platform { Mac, } +impl Platform { + pub fn target_triple(self, arch: Arch) -> &'static str { + match (self, arch) { + (Self::Linux, Arch::X86_64) => "x86_64-unknown-linux-musl", + (Self::Linux, Arch::AARCH64) => "aarch64-unknown-linux-musl", + (Self::Mac, Arch::X86_64) => "x86_64-apple-darwin", + (Self::Mac, Arch::AARCH64) => "aarch64-apple-darwin", + (Self::Windows, Arch::X86_64) => "x86_64-pc-windows-msvc", + (Self::Windows, Arch::AARCH64) => "aarch64-pc-windows-msvc", + } + } +} + impl std::fmt::Display for Platform { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self {
+

GPUI Web Examples

+

Each example runs in its own WebAssembly instance. Choose an example to open it.

+
+